Two-way teleport with native CLI sessions - #8357
Conversation
Contracts, orchestration, and the teleport service land first. Native CLI formats register later, so list/import/export stay empty until a provider adapter is added. Co-authored-by: aanishbhirud <aanishbhirud@gmail.com>
The UI can list, import, and export native sessions. Until a format adapter is registered, the picker stays empty and export fails closed. Co-authored-by: aanishbhirud <aanishbhirud@gmail.com>
Register the Codex jsonl adapter so T3 can list, import, and export rollouts the CLI can resume. Co-authored-by: aanishbhirud <aanishbhirud@gmail.com>
Register the OpenCode adapter so T3 can list, import, and export text turns from opencode.db or JSON storage without treating the live db as a foreign lock. Co-authored-by: aanishbhirud <aanishbhirud@gmail.com>
Register the Claude jsonl adapter so T3 can list, import, and export sessions from the Claude projects folder. Co-authored-by: aanishbhirud <aanishbhirud@gmail.com>
Register the Grok session-directory adapter so T3 can list, import, and export native Grok Build chats. Co-authored-by: aanishbhirud <aanishbhirud@gmail.com>
Stop marked the turn interrupted but left the session running, so the thread stayed Working after abort hung or only emitted turn.aborted. Co-authored-by: aanishbhirud <aanishbhirud@gmail.com>
A projected running OpenCode thread stayed Working after Stop if the in-memory provider session was already gone. Interrupt no longer recovers a session just to abort it, and Stop settles the projection when there is nothing live to interrupt. Co-authored-by: aanishbhirud <aanishbhirud@gmail.com>
Keep personal project directories out of the public diff. Co-authored-by: aanishbhirud <aanishbhirud@gmail.com>
Keep personal project directories out of the public diff. Co-authored-by: aanishbhirud <aanishbhirud@gmail.com>
Keep personal project directories out of the public diff. Co-authored-by: aanishbhirud <aanishbhirud@gmail.com>
Keep personal project directories out of the public diff. Co-authored-by: aanishbhirud <aanishbhirud@gmail.com>
Keep personal project directories out of the public diff. Co-authored-by: aanishbhirud <aanishbhirud@gmail.com>
Keep personal project directories out of the public diff. Co-authored-by: aanishbhirud <aanishbhirud@gmail.com>
Keep live-session titles and personal directories out of the public diff. Co-authored-by: aanishbhirud <aanishbhirud@gmail.com>
Co-authored-by: aanishbhirud <aanishbhirud@gmail.com>
Co-authored-by: aanishbhirud <aanishbhirud@gmail.com>
Co-authored-by: aanishbhirud <aanishbhirud@gmail.com>
Co-authored-by: aanishbhirud <aanishbhirud@gmail.com>
Co-authored-by: aanishbhirud <aanishbhirud@gmail.com>
The import CTA lacked cursor-pointer, and the header export control used the text xs size instead of the square icon-xs used by neighboring actions. Co-authored-by: aanishbhirud <aanishbhirud@gmail.com>
Reject unsafe session ids on native reads/writes, fail closed when lock checks cannot run, keep Codex whitespace, preserve in-place provider instances, and clear stale history/approvals when replacing a thread. Co-authored-by: aanishbhirud <aanishbhirud@gmail.com>
Macroscope expects new Effect services as a single canonical module with the tag, make, and layer together. Inline dispatch-error construction at the failure boundary instead of a curried helper. Co-authored-by: aanishbhirud <aanishbhirud@gmail.com>
Older remotes do not implement teleport RPCs. Advertise a teleport capability from current servers and keep the web entry points hidden when it is absent, including the React Native client until it opts in. Co-authored-by: aanishbhirud <aanishbhirud@gmail.com>
Teleport listed only the default provider home, so instances with a custom homePath never appeared. Scan every configured instance, keep the matching instance id on import, and skip unsafe path reuse. Co-authored-by: aanishbhirud <aanishbhirud@gmail.com>
…tate Reject history replace and export while a T3 session is live, skip OpenCode message ids that would leave the storage root, and keep the header control hoverable so its idle-state tooltip can show. Co-authored-by: aanishbhirud <aanishbhirud@gmail.com>
Failed lsof or open checks were reported as a locked native file, so a missing probe looked like a live CLI lock. New Codex and Claude exports also landed under the default home even when the thread used a custom instance such as codex_work. Keep probe failures on their own error tag, allocate files under the bound instance root, and import TeleportService as a namespace at the WS boundary. Co-authored-by: aanishbhirud <aanishbhirud@gmail.com>
Threads on instances such as codex_work hid Teleport Out because the header treated the instance id as a driver kind. Import could also load the first matching session id across homes. Resolve export support from the instance driver, pass providerInstanceId through the session ref, and drop stale command-palette scans when the import view is left. Co-authored-by: aanishbhirud <aanishbhirud@gmail.com>
The custom-instance export coverage was appended without removing the original describe, so the capability checks ran twice. Co-authored-by: aanishbhirud <aanishbhirud@gmail.com>
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Comment |
There was a problem hiding this comment.
Effect service conventions: two new Effect.catchTag call sites should use Effect.catchTags. Everything else in the new TeleportService (canonical single-file layout, inline Context.Service interface, environment-acquired dependencies, make/layer exports, Schema.TaggedErrorClass failures with cause preserved) matches the conventions.
Posted via Macroscope — Effect Service Conventions
| Effect.catchTag("TeleportSchemaVersionError", (error) => | ||
| Effect.logWarning("teleport.claude.unsupported-session-skipped", { | ||
| nativePath, | ||
| foundVersion: error.foundVersion, | ||
| }).pipe(Effect.as(Option.none<ParsedNativeSession>())), | ||
| ), |
There was a problem hiding this comment.
Same here: prefer Effect.catchTags({ ... }) over Effect.catchTag for statically known tagged failures, matching the catchTags usage in this file's write verification path.
| Effect.catchTag("TeleportSchemaVersionError", (error) => | |
| Effect.logWarning("teleport.claude.unsupported-session-skipped", { | |
| nativePath, | |
| foundVersion: error.foundVersion, | |
| }).pipe(Effect.as(Option.none<ParsedNativeSession>())), | |
| ), | |
| Effect.catchTags({ | |
| TeleportSchemaVersionError: (error) => | |
| Effect.logWarning("teleport.claude.unsupported-session-skipped", { | |
| nativePath, | |
| foundVersion: error.foundVersion, | |
| }).pipe(Effect.as(Option.none<ParsedNativeSession>())), | |
| }), |
Posted via Macroscope — Effect Service Conventions
| Effect.catchTag("TeleportSchemaVersionError", (error) => | ||
| Effect.logWarning("teleport.codex.unsupported-session-skipped", { | ||
| nativePath, | ||
| foundVersion: error.foundVersion, | ||
| }).pipe(Effect.as(Option.none<ParsedNativeSession>())), | ||
| ), |
There was a problem hiding this comment.
Known tagged failures should be recovered with Effect.catchTags({ ... }) (even for a single tag) rather than Effect.catchTag. The write path in this same file already uses catchTags, so this is also inconsistent locally.
| Effect.catchTag("TeleportSchemaVersionError", (error) => | |
| Effect.logWarning("teleport.codex.unsupported-session-skipped", { | |
| nativePath, | |
| foundVersion: error.foundVersion, | |
| }).pipe(Effect.as(Option.none<ParsedNativeSession>())), | |
| ), | |
| Effect.catchTags({ | |
| TeleportSchemaVersionError: (error) => | |
| Effect.logWarning("teleport.codex.unsupported-session-skipped", { | |
| nativePath, | |
| foundVersion: error.foundVersion, | |
| }).pipe(Effect.as(Option.none<ParsedNativeSession>())), | |
| }), |
Posted via Macroscope — Effect Service Conventions
| return normalizeTeleportCwd(yield* resolveTeleportCwdPath(value)); | ||
| }); | ||
|
|
||
| export function teleportCwdsMatch(left: string, right: string): boolean { |
There was a problem hiding this comment.
🟡 Medium teleport/cwd.ts:34
On case-sensitive macOS volumes, teleportCwdsEquivalent reports distinct paths such as /projects/App and /projects/app as equivalent, so teleportSessionBelongsToProject can import a native session from a different same-named project. The unconditional toLowerCase() after realPath assumes every macOS volume is case-insensitive; restrict case folding to case-insensitive volumes.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/teleport/cwd.ts around line 34:
On case-sensitive macOS volumes, `teleportCwdsEquivalent` reports distinct paths such as `/projects/App` and `/projects/app` as equivalent, so `teleportSessionBelongsToProject` can import a native session from a different same-named project. The unconditional `toLowerCase()` after `realPath` assumes every macOS volume is case-insensitive; restrict case folding to case-insensitive volumes.
| base: committedTeleport, | ||
| restorePresence: "t3", | ||
| }); | ||
| yield* Effect.acquireUseRelease( |
There was a problem hiding this comment.
🟡 Medium teleport/TeleportService.ts:918
thread.create is committed before runNewThreadTeleportImport writes the presence: "importing" fence, so a process failure in that gap leaves an empty fork thread with neither history nor teleport state. Startup recovery only scans importing threads, and a later retry can hit the deterministic thread ID conflict; additionally, a concurrent retry can reuse a still-importing fork and return a success response for a thread that the first replica later deletes. Make creation and fencing atomic, or add recovery/ conflict handling that cannot expose or retain this unfenced husk.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/teleport/TeleportService.ts around line 918:
`thread.create` is committed before `runNewThreadTeleportImport` writes the `presence: "importing"` fence, so a process failure in that gap leaves an empty fork thread with neither history nor teleport state. Startup recovery only scans importing threads, and a later retry can hit the deterministic thread ID conflict; additionally, a concurrent retry can reuse a still-importing fork and return a success response for a thread that the first replica later deletes. Make creation and fencing atomic, or add recovery/ conflict handling that cannot expose or retain this unfenced husk.
There was a problem hiding this comment.
One finding: the new attachment guard in ChatComposer keys off the generic sendDisabledReason instead of the teleport-specific predicate used everywhere else in this PR, which blocks attaching images while another attachment upload is pending or failed.
Posted via Macroscope — UI Consistency
ApprovabilityVerdict: Not approved Macroscope's review found this PR not approvable — This PR introduces a large, cross-cutting native CLI integration that changes turn admission, persistence, provider sessions, filesystem writes, recovery, and user-facing workflows. Unresolved substantive concerns include potential transcript/attachment loss, writer-isolation gaps, recovery inconsistencies, and duplicate or incorrectly resumed sessions. Not approved because:
Adjust the Minimum Blocking Severity for this repo — including turning it Off — in Settings. You can add or adjust custom eligibility rules. Learn more. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ca3e9b78c0
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| resumeCursor: buildTeleportResumeCursor({ | ||
| provider: parsed.provider, | ||
| externalSessionId: parsed.externalSessionId, | ||
| adapter: formats.get(parsed.provider), | ||
| }), |
There was a problem hiding this comment.
Avoid resuming the imported native session
After a Claude or Codex import, persisting the native session ID as the provider resumeCursor causes the next T3 turn to start the adapter by resuming that same native CLI session. The provider then appends T3's turn to the watched native file, so T3 becomes a second writer and its own first turn makes the recorded revision diverge, blocking subsequent sends. Keep the external identity only in the teleport metadata and start or fork an independent provider session for T3.
Useful? React with 👍 / 👎.
| function capMessages(messages: ReadonlyArray<NativeTextMessage>): NativeTextMessage[] { | ||
| return messages.slice(-MAX_TELEPORT_MESSAGES).map((message) => | ||
| message.text.length > MAX_TELEPORT_MESSAGE_CHARS | ||
| ? { | ||
| ...message, | ||
| text: `${message.text.slice(0, MAX_TELEPORT_MESSAGE_CHARS)}\n\n[truncated]`, |
There was a problem hiding this comment.
Reject oversized transcripts instead of silently truncating them
When a native session contains more than 2,000 importable messages, this silently discards the beginning of the conversation; individual messages over 100,000 characters are likewise rewritten. The RPC still reports a successful import, leaving the resulting durable thread incomplete with no warning. Preserve the transcript or fail with an explicit size error so users do not unknowingly lose history.
Useful? React with 👍 / 👎.
| for (const home of codexSearchRoots(input.homes)) { | ||
| const files = yield* listCodexJsonlFiles(home.root); | ||
| for (const nativePath of files) { | ||
| if (seen.has(nativePath)) { | ||
| continue; | ||
| } | ||
| const parsed = yield* readNativeSessionFile({ |
There was a problem hiding this comment.
Bound native-session discovery work
For users with a large Codex home, opening the import picker recursively stats and then reads/parses every historical JSONL file, even though only sessions for one project can be returned. Because the scan is uncached and sequential, discovery latency and I/O grow with the user's entire session history and can make this interactive entry point stall; use an index/cache or otherwise bound and prune the scan before parsing file contents.
AGENTS.md reference: AGENTS.md:L15-L17
Useful? React with 👍 / 👎.
| createdAt: now, | ||
| reason: "Failed to write forked thread history.", | ||
| }), | ||
| finalizeDirectory: Effect.void, |
There was a problem hiding this comment.
Preserve provider context in divergence forks
The divergence fork imports the native messages into the T3 projection but deliberately finalizes without creating any provider-directory continuation. On the fork's first turn, the command reactor therefore starts a fresh provider session and sends only the new user message, so the agent cannot see the native transcript displayed above it and responds without the conversation context the fork was meant to preserve. Create an independent provider-side fork/copy seeded from the native session rather than leaving this binding empty.
Useful? React with 👍 / 👎.
| yield* teleport | ||
| .requireNativeRevisionForTurn(normalizedCommand.threadId) | ||
| .pipe( | ||
| Effect.catch((cause) => | ||
| failEnvironmentInternal("orchestration_dispatch_failed", cause), | ||
| ), | ||
| ); |
There was a problem hiding this comment.
Clean claimed attachments when the revision gate rejects
On /api/orchestration/dispatch, normalizeDispatchCommand has already moved pending uploads to their claimed thread paths before this native-revision check runs. If the native file diverged, this branch fails before reaching the later orchestrationEngine.dispatch(...).tapError(cleanupFailedUploadedAttachments), so every attachment on the rejected turn is orphaned on disk. Wrap the revision gate in the same failure cleanup region as dispatch.
Useful? React with 👍 / 👎.
| const loadProjectWorktreeCwds = (projectId: ProjectId) => | ||
| snapshotQuery.getShellSnapshot().pipe( | ||
| Effect.map((shell) => worktreeCwdsFromThreads(shell.threads, projectId)), |
There was a problem hiding this comment.
Include archived worktrees in teleport discovery
Worktree paths are collected only from the active shell. If a thread was exported from a worktree and then archived, its native session cwd is outside the project root and is no longer included in extraCwds, so listing/import rejects that session before the later archived-thread identity lookup can automatically unarchive it. Combine active and archived shell worktree paths so the reverse teleport remains available for archived worktree threads.
AGENTS.md reference: AGENTS.md:L73-L74
Useful? React with 👍 / 👎.
| for (const ref of input.sessions) { | ||
| const parsed = yield* loadTeleportSession({ |
There was a problem hiding this comment.
Honor the documented per-session batch semantics
All requested sessions are loaded and lock-checked before the import loop performs any mutation. Consequently, for a batch such as [validSession, missingOrLockedSession], the failure of the second entry prevents the first from being imported at all, contrary to TELEPORT_IMPORT_BATCH_SEMANTICS, which promises that earlier successful imports are retained when a later session fails. Load, validate, and commit each entry inside the same sequential loop.
Useful? React with 👍 / 👎.
| const existingPayload = | ||
| persistedPayload && isPendingTeleportNativePath(persistedPayload.nativePath) | ||
| ? { ...persistedPayload, presence: "t3" as const } | ||
| : persistedPayload; | ||
| if (resolveTeleportPresence(existingPayload) === "native") { |
There was a problem hiding this comment.
Check authoritative thread presence before exporting
The already-exported guard consults only the provider-directory payload and ignores thread.value.teleport. If a native file was written and the orchestration presence was persisted but the separate directory upsert failed, the RPC reports failure while the thread is authoritatively native; retrying exportSession then passes this check and writes another native session, potentially while the first is being used. Prefer the projected thread presence and use the directory payload only as a recovery fallback.
Useful? React with 👍 / 👎.
| nativeTextMessage({ | ||
| role: message.role, | ||
| text: message.text, | ||
| createdAt: message.createdAt, | ||
| id: message.id, |
There was a problem hiding this comment.
Preserve attachments across a teleport round trip
Export converts every orchestration message to text-only native data and drops its attachments. When that exported session is imported back in place, thread.history-replaced replaces the original messages and the projection cleanup deletes their attachment files, so image inputs disappear permanently even if the native CLI only added a later reply. Either retain attachments from matching existing messages or reject/warn before performing this lossy round trip.
Useful? React with 👍 / 👎.
|
@cursor subscribe to this pr |
|
Subscribed Bugbot to this PR. I'll review it now and on future commits. |
A refreshed shell with teleport: null is newer than a cached detail snapshot. Keeping the stale detail left the composer locked after presence was cleared. Co-authored-by: aanishbhirud <aanishbhirud@gmail.com>
Pending or failed image uploads still disable send, but they must not refuse a second paste. Draft mutation already used the teleport-specific predicate everywhere else in this flow. Co-authored-by: aanishbhirud <aanishbhirud@gmail.com>
isSyntheticNativeUserText is for injected CLI user prompts. Applying it to assistant responses dropped legitimate imported replies whose text started with the same markers. Co-authored-by: aanishbhirud <aanishbhirud@gmail.com>
…omitted Two native files can share an externalSessionId. Skipping the real-path check unless the client sent nativePath let import replace the bound thread with a different file. Co-authored-by: aanishbhirud <aanishbhirud@gmail.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit ca3e9b7. Configure here.
| reason: `Duplicate session '${ref.externalSessionId}' in the import batch.`, | ||
| }); | ||
| } | ||
| seenRefs.add(key); |
There was a problem hiding this comment.
Duplicate import refs create two threads
High Severity
Import de-duplicates batch entries with provider:providerInstanceId:externalSessionId, so an omitted instance id and the discovery-selected instance count as different refs. loadTeleportSession still resolves both to the same native file, while bindings and thread shells are snapshotted before the loop, so the second entry cannot see the thread the first just created and mints another T3 thread for that session.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit ca3e9b7. Configure here.
stat follows symlink targets, so a sessions/loop -> sessions link re-entered the same tree forever. Track visited canonical directories before descending. Co-authored-by: aanishbhirud <aanishbhirud@gmail.com>
Stat-then-readFile could still allocate a replacement or append that grew past the cap. Stream the file with a hard byte limit and treat overflow as oversize. Co-authored-by: aanishbhirud <aanishbhirud@gmail.com>
…fails normalizeDispatchCommand moves uploads onto the thread before the native revision check. A rejected turn then skipped the dispatch tapError cleanup and left those files orphaned. Co-authored-by: aanishbhirud <aanishbhirud@gmail.com>
getThreadShellById and getThreadDetailById already load archived threads, but latest-turn lookup still filtered archived_at IS NULL, so import/UI paths lost turn state. Co-authored-by: aanishbhirud <aanishbhirud@gmail.com>
An explicit providerInstances envelope replaces legacy providers.codex config. Merging omitted homePath from the legacy field made teleport scan or export to a different home than Codex itself. Co-authored-by: aanishbhirud <aanishbhirud@gmail.com>
Failed export fell back to a pending t3 sentinel when the directory row was missing, wiping nativePath and nativeRevision so later turns stopped watching the imported file. Co-authored-by: aanishbhirud <aanishbhirud@gmail.com>
…overy Clearing a leftover importing fence left the provider-directory binding in place, so the next T3 turn could resume the native file without the revision gate. Also treat native+importing directory lag as recoverable. Co-authored-by: aanishbhirud <aanishbhirud@gmail.com>
Canonicalize omitted providerInstanceId before the duplicate check, load and commit each batch session sequentially, honor thread presence on export, include archived worktree cwds, and delete leftover directory rows when recovery clears an importing fence. Co-authored-by: aanishbhirud <aanishbhirud@gmail.com>
…eSystem Assigning wrapped.rename failed typecheck because FileSystem.rename is readonly. Spread a new service object instead. Co-authored-by: aanishbhirud <aanishbhirud@gmail.com>
There was a problem hiding this comment.
One finding: a composer insertion guard was widened to every sendDisabledReason, which blocks ordinary draft input (file-tree mentions, terminal links, type-to-focus) while an image upload is in flight or the thread is still loading. Details inline.
Posted via Macroscope — UI Consistency
| ): boolean => { | ||
| if ( | ||
| text.length === 0 || | ||
| isSendDisabled || |
There was a problem hiding this comment.
isSendDisabled is true for every send-disabled reason, not just the teleport lock: attachmentBlockReason ("Image still uploading" / "Retry or remove the failed image") and the reasons composerSendDisabledReason adds ("Sending feedback", "Messages loading", native-divergence) all flow through it. Because insertComposerTextAtEnd backs ChatComposerHandle.insertTextAtEnd, that now rejects file-tree "Add to chat" mentions (FileBrowserPanel toasts "The chat isn't ready to accept input right now."), terminal-link insertion, and type-to-focus keystrokes while an attachment is uploading or the thread is loading — the same class of over-blocking the comment at line 2742 explicitly rules out ("Other send disables (pending/failed upload) must not drop a second paste.").
Suggest gating on the teleport-specific predicate used everywhere else in this change; it also reads through sendDisabledReasonRef, so the imperative handle (whose dep list has canMutateTeleportDraft but not isSendDisabled) can't capture a stale lock state.
| isSendDisabled || | |
| !canMutateTeleportDraft() || |
Posted via Macroscope — UI Consistency
A later session that fails to load or unlock could leave earlier sessions already imported while the batch RPC still failed. Validate every ref first so those environmental failures leave zero imports. Co-authored-by: aanishbhirud <aanishbhirud@gmail.com>
There was a problem hiding this comment.
Reviewed the new apps/server/src/teleport/** service code against the Effect service conventions. Two findings on startup reconciliation error handling. The Effect.catchTag usages in formats/codex.ts and formats/claude.ts flagged in a previous run are still present and unchanged; not re-posting those.
Posted via Macroscope — Effect Service Conventions
| })(); | ||
| yield* recover.pipe( | ||
| Effect.flatMap(() => input.afterRecover?.(thread.id, restored) ?? Effect.void), | ||
| Effect.catchCause(() => |
There was a problem hiding this comment.
Per-entity startup repair here (and the finalizeDirectory recovery at line 460) swallows interruption and never retries a transient failure before readiness, so one flaky dispatch permanently leaves a thread fenced at presence: "importing". The convention (and serverRuntimeStartup.ts) is: retry once, then isolate a persistent failure while re-failing interrupts.
Effect.flatMap(() => input.afterRecover?.(thread.id, restored) ?? Effect.void),
- Effect.catchCause(() =>
- Effect.logWarning("teleport.import.recovery-skipped").pipe(
- Effect.annotateLogs({ threadId: thread.id }),
- ),
- ),
+ Effect.retry({ times: 1 }),
+ Effect.catchCause((cause) =>
+ Cause.hasInterrupts(cause)
+ ? Effect.failCause(cause)
+ : Effect.logWarning("teleport.import.recovery-skipped").pipe(
+ Effect.annotateLogs({ threadId: thread.id }),
+ ),
+ ),(adds import * as Cause from "effect/Cause";) importTransaction.test.ts already covers persistent-failure continuation; a retry-success case would be worth adding alongside it.
Posted via Macroscope — Effect Service Conventions
| ), | ||
| ); | ||
| } | ||
| }).pipe(Effect.catchCause(() => Effect.logWarning("teleport.recovery-failed"))); |
There was a problem hiding this comment.
recoverInterruptedTeleports is startup reconciliation (it is yield*ed during layer construction), and this catchCause also absorbs interruption, so a shutdown mid-recovery is reduced to a warning and construction reports success. The existing startup pattern in serverRuntimeStartup.ts re-fails interrupts and logs only real failures. Consider:
- }).pipe(Effect.catchCause(() => Effect.logWarning("teleport.recovery-failed")));
+ }).pipe(
+ Effect.catchCause((cause) =>
+ Cause.hasInterrupts(cause)
+ ? Effect.failCause(cause)
+ : Effect.logWarning("teleport.recovery-failed", { cause }),
+ ),
+ );(adds import * as Cause from "effect/Cause";)
Posted via Macroscope — Effect Service Conventions
| updatedInPlace = true; | ||
| const previousBinding = yield* directory | ||
| .getBinding(threadId) | ||
| .pipe(Effect.catch(() => Effect.succeed(Option.none()))); |
There was a problem hiding this comment.
🟠 High teleport/TeleportService.ts:829
A transient directory.getBinding failure is treated as Option.none(), so a later rollback deletes an existing provider binding instead of restoring it. Preserve or propagate the read error and abort before entering the mutation region rather than treating an unknown binding as absent.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/teleport/TeleportService.ts around line 829:
A transient `directory.getBinding` failure is treated as `Option.none()`, so a later rollback deletes an existing provider binding instead of restoring it. Preserve or propagate the read error and abort before entering the mutation region rather than treating an unknown binding as absent.
There was a problem hiding this comment.
UI Consistency
One new finding on the teleport composer lock. The previously reported insertComposerTextAtEnd gate (apps/web/src/components/chat/ChatComposer.tsx L2803 — isSendDisabled also covers "Image still uploading" / "Sending feedback" / "Messages loading", so file-tree "Add to chat" and mention drops are rejected outside the teleport lock) is still present; not re-commented here.
Posted via Macroscope — UI Consistency
| const removeComposerElementContextFromDraft = useCallback( | ||
| (contextId: string) => { | ||
| if (canMutateTeleportDraft()) { | ||
| removeComposerDraftElementContext(composerDraftTarget, contextId); | ||
| } | ||
| }, | ||
| [canMutateTeleportDraft, composerDraftTarget, removeComposerDraftElementContext], | ||
| ); | ||
|
|
||
| const removeComposerPreviewAnnotationFromDraft = useCallback( | ||
| (annotationId: string) => { | ||
| if (canMutateTeleportDraft()) { | ||
| releaseAttachmentUpload(annotationId); | ||
| removeComposerDraftPreviewAnnotation(composerDraftTarget, annotationId); | ||
| } | ||
| }, | ||
| [canMutateTeleportDraft, composerDraftTarget, removeComposerDraftPreviewAnnotation], | ||
| ); | ||
|
|
||
| const removeComposerReviewCommentFromDraft = useCallback( | ||
| (commentId: string) => { | ||
| if (canMutateTeleportDraft()) { | ||
| removeComposerDraftReviewComment(composerDraftTarget, commentId); | ||
| } | ||
| }, | ||
| [canMutateTeleportDraft, composerDraftTarget, removeComposerDraftReviewComment], | ||
| ); |
There was a problem hiding this comment.
These guards make the rendered remove affordances inert rather than disabled. While the composer is teleport-locked, the attachment thumbnail X (Remove {name}, ~L3468), the preview-annotation / review-comment / element-context chip removes, and the terminal-context chip remove all still render as enabled, pointer-cursor controls, but clicking them now does nothing and gives no feedback — the only locked signal is the disabled editor and its placeholder.
Other teleport-locked paths in this file already surface a reason instead of returning silently (addComposerImages, stashCurrentPrompt, stash restore). Suggest gating the controls themselves — thread disabled={isTeleportComposerLocked} down to those remove buttons (or suppress the chip rows while locked) — so the shared Button disabled state, cursor, and keyboard semantics match the frozen draft.
Posted via Macroscope — UI Consistency


What this adds
T3 can now list, import, and export native Codex and Claude CLI sessions — "teleport." Start a session in either CLI and pick it up in T3, or hand a T3 thread back to the CLI, without losing the transcript and without two writers ever touching the same session file.
Three ideas hold the design together:
Native files stay native. Import copies a CLI session onto a T3 thread; export writes the thread back out in the CLI's own session format. T3 never becomes the owner of the native file.
One writer at a time. Every thread tracks a presence —
t3ornative— that says which side currently owns the session. The UI swaps Import/Export based on presence, so both sides can never be writers at once.Divergence is detected, never merged. After a successful import, a SHA-256 revision watch tracks the native file. If the CLI file changes or goes missing, further T3 turns are blocked and a conflict banner offers Fork native changes, which copies the diverged transcript into a new T3 thread. Both copies survive; nothing gets clobbered.
Scope note:
TeleportProvideriscodex | claudeAgentonly. Grok and OpenCode remain regular T3 providers — this PR adds no native CLI teleport for them.Fail-closed behavior
Every path that could produce a second writer refuses instead of guessing:
lsof; iflsofcan't be spawned, Unix falls back to an exclusive-open probe and fails closed whenever the file exists. A missing file counts as unlocked, so a first export can still create it.nativeorimporting. The message-loading and attachment-upload disables stack on top of that lock — they don't replace it.thread.turn.start, over WebSocket or HTTP (/api/orchestration/dispatch), requires a matching native revision. The one exception isbootstrap.createThreadwhen the thread doesn't exist yet.Recovery
In-place import is a single orchestration transition (
thread.teleport.import). A failed or interrupted import revertspresence: importingand stays retryable — including first-time directory-bound threads, so a failed first import never looks like it succeeded. Startup recovery deletes leftover new-thread husks and clears or restores existing threads. Custom instance homes, worktree threads, and archived canonical threads are all supported.Projection adds a
teleport_jsoncolumn (migration044) and backfills only validTeleportProvidervalues.Known limitations
There is no cross-store transaction spanning orchestration, the provider directory, and the native file — this is documented, not hidden. Mobile already blocks send while a thread is teleported out; the teleport-in UI is web/desktop only for now.
Why
Work started in the Codex or Claude CLI should be able to continue in T3, and the reverse, without losing the transcript or writing from both sides at once. Presence enforces a single writer, the file lock and revision watch back it up, and forking recovers cleanly when the CLI file changes after import.
Relates to #207, #5146, #5741, and #6590. Supersedes #7136.
UI changes
Import lives on the new-thread / sessions view. Export lives in the chat header. The composer is disabled while a thread is in the native CLI.
Entry points
Tooltips
Composer disabled after teleport out
Native-revision conflict banner
Shown when the revision watch detects the CLI file diverged after import. Send is blocked until the user forks.
Recordings
Codex — full roundtrip: T3 export → native CLI (dark mode) → import back
Claude — full roundtrip: T3 export → native CLI (dark mode) → import back
Checklist
Note
High Risk
Touches turn admission, history replacement, attachment deletion, and native revision gating—mistakes could block sends, lose messages/attachments, or allow divergent native/T3 writers.
Overview
Adds teleport as a first-class thread concern: threads carry
TeleportThreadState(native vs T3 vs importing), new orchestration commands/events (thread.teleport.set,thread.teleport.import,thread.teleport.clear,thread.history.replace), and decider rules that block turns on archived, native, or importing presence while allowing atomic import (unarchive + presence + history replace).Server persistence and projections gain
teleport_json(migration 044 with Codex/Claude backfill), hydrate teleport on shells/details (including archived threads for import/UI), and handlethread.history-replacedby wiping turns, plans, activities, and pending approvals while pruning attachment files via an owned-vs-kept plan so unrelated threads are not deleted.Ingress and runtime: HTTP/WS dispatch runs
TeleportService.requireNativeRevisionForTurnbeforethread.turn.start(skipped for bootstrap create on a missing thread); teleport RPCs get auth scopes; environment advertisescapabilities.teleport. Provider changes treatturn.abortedlike completion for session state and stop recovering stale sessions oninterruptTurn.Mobile mirrors web fail-closed behavior: the thread composer disables send and shows
teleportSendDisabledReasonwhen the thread is teleported out.Reviewed by Cursor Bugbot for commit ca3e9b7. Configure here.
Note
Add two-way teleport support for native CLI sessions via
TeleportServiceteleport.listSessions,teleport.importSessions,teleport.exportSession) and internal commands (thread.history.replace,thread.teleport.set,thread.teleport.import,thread.teleport.clear) to@t3tools/contracts.thread.teleportedandthread.history-replacedevents, handling state transitions and attachment pruning.thread.turn.startnow fails if the thread is archived or has native/importing teleport presence indecideOrchestrationCommand. The WS layer interceptsthread.turn.startto runTeleportService.requireNativeRevisionForTurnbefore dispatch, failing with "Failed to verify the imported native session." on mismatch.Macroscope summarized 55e775d.