Skip to content

Two-way teleport with native CLI sessions - #8357

Open
baanish wants to merge 123 commits into
pingdotgg:mainfrom
baanish:cursor/teleport-7136-mirror-4ce4
Open

Two-way teleport with native CLI sessions#8357
baanish wants to merge 123 commits into
pingdotgg:mainfrom
baanish:cursor/teleport-7136-mirror-4ce4

Conversation

@baanish

@baanish baanish commented Aug 27, 2026

Copy link
Copy Markdown

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 — t3 or native — 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: TeleportProvider is codex | claudeAgent only. 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:

  • File lock. Import and export refuse while the CLI holds the session file. Lock detection uses lsof; if lsof can'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.
  • Composer. Send, prompt, and stash are locked while presence is native or importing. The message-loading and attachment-upload disables stack on top of that lock — they don't replace it.
  • Turn ingress. Every thread.turn.start, over WebSocket or HTTP (/api/orchestration/dispatch), requires a matching native revision. The one exception is bootstrap.createThread when the thread doesn't exist yet.
  • Conflict banner. Once the watched revision diverges, send stays blocked until the user forks.

Recovery

In-place import is a single orchestration transition (thread.teleport.import). A failed or interrupted import reverts presence: importing and 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_json column (migration 044) and backfills only valid TeleportProvider values.

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

Draft hero Import a native CLI session link
Command palette Import sessions action
Native Claude and Codex sessions in the import picker

Tooltips

Teleport Out tooltip
Teleport In tooltip

Composer disabled after teleport out

Composer disabled when the thread is in the native CLI

Native-revision conflict banner

Shown when the revision watch detects the CLI file diverged after import. Send is blocked until the user forks.

Native CLI session changed banner in the dark chat UI
Fork native changes native-revision conflict banner

Recordings

Codex — full roundtrip: T3 export → native CLI (dark mode) → import back

Codex teleport roundtrip

Claude — full roundtrip: T3 export → native CLI (dark mode) → import back

Claude teleport roundtrip

Checklist

  • This PR is small and focused
  • I explained what changed and why
  • I included before/after screenshots for any UI changes
  • I included a video for animation/interaction changes

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 handle thread.history-replaced by 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.requireNativeRevisionForTurn before thread.turn.start (skipped for bootstrap create on a missing thread); teleport RPCs get auth scopes; environment advertises capabilities.teleport. Provider changes treat turn.aborted like completion for session state and stop recovering stale sessions on interruptTurn.

Mobile mirrors web fail-closed behavior: the thread composer disables send and shows teleportSendDisabledReason when 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 TeleportService

  • Introduces a two-way teleport subsystem to export, import, and fork threads between the app and native CLI providers (Codex, Claude).
  • Adds new RPCs (teleport.listSessions, teleport.importSessions, teleport.exportSession) and internal commands (thread.history.replace, thread.teleport.set, thread.teleport.import, thread.teleport.clear) to @t3tools/contracts.
  • Updates orchestration decider and projector to process thread.teleported and thread.history-replaced events, handling state transitions and attachment pruning.
  • Integrates import flows into the command palette and locks composer input in web/mobile clients when a thread is teleported out.
  • Risk: Dispatching thread.turn.start now fails if the thread is archived or has native/importing teleport presence in decideOrchestrationCommand. The WS layer intercepts thread.turn.start to run TeleportService.requireNativeRevisionForTurn before dispatch, failing with "Failed to verify the imported native session." on mismatch.

Macroscope summarized 55e775d.

cursoragent and others added 30 commits August 15, 2026 08:54
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>
@coderabbitai

coderabbitai Bot commented Aug 27, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: cc2a668b-5aa7-4ef3-8226-a418de05ebd1

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:XXL 1,000+ changed lines (additions + deletions). labels Aug 27, 2026

@macroscopeapp macroscopeapp Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment on lines +471 to +476
Effect.catchTag("TeleportSchemaVersionError", (error) =>
Effect.logWarning("teleport.claude.unsupported-session-skipped", {
nativePath,
foundVersion: error.foundVersion,
}).pipe(Effect.as(Option.none<ParsedNativeSession>())),
),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here: prefer Effect.catchTags({ ... }) over Effect.catchTag for statically known tagged failures, matching the catchTags usage in this file's write verification path.

Suggested change
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

Comment on lines +372 to +377
Effect.catchTag("TeleportSchemaVersionError", (error) =>
Effect.logWarning("teleport.codex.unsupported-session-skipped", {
nativePath,
foundVersion: error.foundVersion,
}).pipe(Effect.as(Option.none<ParsedNativeSession>())),
),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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

Comment thread apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts
Comment thread packages/client-runtime/src/state/threadDetail.ts
Comment thread apps/server/src/teleport/formats/claude.ts Outdated
return normalizeTeleportCwd(yield* resolveTeleportCwdPath(value));
});

export function teleportCwdsMatch(left: string, right: string): boolean {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Comment thread apps/server/src/teleport/homes.ts Outdated
Comment thread apps/server/src/teleport/importTransaction.ts
Comment thread apps/server/src/teleport/TeleportService.ts
Comment thread apps/server/src/teleport/sessionFile.ts Outdated
base: committedTeleport,
restorePresence: "t3",
});
yield* Effect.acquireUseRelease(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Comment thread apps/server/src/teleport/TeleportService.ts

@macroscopeapp macroscopeapp Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread apps/web/src/components/chat/ChatComposer.tsx Outdated
Comment thread apps/server/src/orchestration/projector.ts
@macroscopeapp

macroscopeapp Bot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: 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:

  • 7 blocking correctness issues found at or above your repo's Minimum Blocking Severity

Adjust the Minimum Blocking Severity for this repo — including turning it Off — in Settings. You can add or adjust custom eligibility rules. Learn more.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +752 to +756
resumeCursor: buildTeleportResumeCursor({
provider: parsed.provider,
externalSessionId: parsed.externalSessionId,
adapter: formats.get(parsed.provider),
}),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment on lines +166 to +171
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]`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment on lines +362 to +368
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({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment thread apps/server/src/orchestration/http.ts Outdated
Comment on lines +119 to +125
yield* teleport
.requireNativeRevisionForTurn(normalizedCommand.threadId)
.pipe(
Effect.catch((cause) =>
failEnvironmentInternal("orchestration_dispatch_failed", cause),
),
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment on lines +296 to +298
const loadProjectWorktreeCwds = (projectId: ProjectId) =>
snapshotQuery.getShellSnapshot().pipe(
Effect.map((shell) => worktreeCwdsFromThreads(shell.threads, projectId)),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment on lines +547 to +548
for (const ref of input.sessions) {
const parsed = yield* loadTeleportSession({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment on lines +1078 to +1082
const existingPayload =
persistedPayload && isPendingTeleportNativePath(persistedPayload.nativePath)
? { ...persistedPayload, presence: "t3" as const }
: persistedPayload;
if (resolveTeleportPresence(existingPayload) === "native") {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment on lines +203 to +207
nativeTextMessage({
role: message.role,
text: message.text,
createdAt: message.createdAt,
id: message.id,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

@baanish

baanish commented Aug 27, 2026

Copy link
Copy Markdown
Author

@cursor subscribe to this pr

@cursor

cursor Bot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Subscribed Bugbot to this PR. I'll review it now and on future commits.

cursoragent and others added 4 commits August 27, 2026 06:30
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>

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ 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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit ca3e9b7. Configure here.

cursoragent and others added 9 commits August 27, 2026 06:38
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>

@macroscopeapp macroscopeapp Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 ||

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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>

@macroscopeapp macroscopeapp Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(() =>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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")));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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())));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 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.

@macroscopeapp macroscopeapp Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment on lines +1451 to +1477
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],
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL 1,000+ changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants