Skip to content

fix: a terminal pane no longer resumes a conversation a native chat owns - #28288

Closed
brennanb2025 wants to merge 10 commits into
brennanb2025/pane-identity-leakfrom
brennanb2025/pane-identity-leak-recovery
Closed

brennanb2025 wants to merge 10 commits into
brennanb2025/pane-identity-leakfrom
brennanb2025/pane-identity-leak-recovery

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 6 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​286 $\color{#cf222e}{\Huge{\mathbf{−}}}$​4 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​282
Prod 25 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​404 $\color{#cf222e}{\Huge{\mathbf{−}}}$​46 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​358

ELI5

Before the fix in #28267, a chat agent could be mistaken for a terminal pane, and that pane saved the chat's conversation as its own. After a restart the pane "resumed" the chat's conversation as a terminal agent, in the pane's workspace instead of the chat's. #28267 stops new mix-ups, but panes that already saved a chat's conversation keep doing this on every restart. Now, before a pane resumes anything, Orca checks its own chat records. If a chat owns that conversation, the pane opens a plain shell, says the old session isn't available, and Orca forgets that conversation as the pane's to resume.

What Changed

What the user saw (#26417). A background Claude chat worker started in worktree A came back after every Orca restart as a claude --resume <id> terminal tab in a floating terminal, with the home directory as its working folder, while Orca's own session record still placed the chat in worktree A.

Why it kept happening after the leak was fixed. Terminal restore resumes whatever conversation the pane last saved: its saved restore record, or its last status row (kept for up to 7 days). Neither is checked against anything else, so a pane that once saved a chat's conversation resumes it forever. Orca already has the durable answer to "who owns this conversation": the chat session records, which native chat already uses to refuse a terminal resume command for a conversation a chat owns.

After.

  • On restart, a terminal pane whose saved conversation belongs to a native chat (Claude, Codex, Grok, OpenCode, OMP, Pi; matched over the chat's whole conversation chain, and by session file for agents that resume by file) opens a plain shell instead of the agent, with the existing "previous session unavailable, started fresh" notice. The notice now also shows when the pane had no scrollback to restore, which fixes the same missing notice for Codex's existing fallback.
  • Orca forgets the conversation as that pane's: its status row (with no resumable copy left behind), its saved restore record and any duplicate of it under an older pane key, and the conversation title shown on the pane's tab. The next restart opens a plain shell with no notice, and the sidebar shows no stale agent there.
  • The chat itself is untouched; it still opens in its own workspace from its own record, with its own title.
  • Terminal agents whose conversation no chat owns resume exactly as before.
  • If Orca can't read its chat records, it can't tell who owns the conversation. A restore Orca starts on its own (a hidden pane at startup, a workspace waking in the background) then opens a plain shell with the notice but keeps its saved session, so the next restore tries again. A resume the user asked for (a pane they are looking at, Session History) goes ahead as before. A warning is logged either way.

Mechanism.

  1. One ownership rule. nativeChatOwnsTerminalResume reuses the rule native chat already applies to terminal resume commands (same record matching, same per-agent session-file comparison), so terminal restore and native chat can't disagree.
  2. Decided where the shell is started. The local terminal spawn checks the requested resume before anything agent-specific runs. When it refuses, the whole agent launch is dropped (command, agent, launch config and token, and the env the launch config added), so the pane is an ordinary shell: no agent registration, no unattended-agent Git prompt guard, no Claude auth preparation. The result says agentResumeRefused alongside the existing agentResumeUnavailable.
  3. The host forgets, then tells the window once. As soon as the pane's identity is known, the spawn asks the hook server to forget the refused conversation for that pane (under the pane's current key if it moved): it drops the pane's status row only if that row still names the refused conversation, keeps no resumable copy, and saves on its normal schedule. It then sends one agentStatus:clear carrying the refused conversation; if a row was deleted, every listener hears it like any pane clear, otherwise only the window does. One listener in the window forgets that conversation as that pane's, whether or not the pane is mounted: status events still queued for it (matched by the pane's current key, as they are applied), its live and retained rows, its saved restore record and copies under its older numeric pane keys in the same tab, the launch config, a launch-derived agent hint, and the pane tab's conversation title. A pane already on a different conversation is left alone, and another pane holding the same conversation is refused and forgotten at its own restore, because the host checks every time. Nothing is sent back to the host.
  4. Panes only present the result. Every spawn result, including a shell that exits before it attaches, says agentResumeRefused and why (owned or unverifiable). The pane drops its resume plan (nothing of it is registered over the shell) and shows the notice. For unverifiable it keeps a resume handle the same way it already does when the host postpones a restore, because a background workspace wake removes the saved record before it spawns. Panes own no cleanup, so a pane that remounts or exits mid-spawn can't skip it.
  5. The cleanup reaches disk while Orca runs. Clearing the last saved restore record sent the field as "nothing", which the per-host session write silently drops, so the record stayed on disk until quit and a crash would bring it back. The write now sends an empty set, which it already keeps. This was a pre-existing bug for any last saved session; it is fixed here because this repair depends on it.
  6. "Agent resumed" counts what actually happened. Reopening a held terminal reported "agent sessions resumed" from the plan, not the result; a refused or exited shell now counts as not resumed.

Why

  • Re-derive from the durable record, don't add a second guard. The pane's saved record is a cache; the chat session record is the truth. Checking the truth at restore time fixes every pane already holding a bad record, needs no migration, and stays correct for any future way a conversation moves into a chat.
  • The host that refuses also forgets. An earlier version had each pane clean up after it received the spawn result. Review found several ways a pane never gets or never handles that result (a remount mid-spawn, a shell that exits before attaching), each leaving the refused conversation saved. Moving the cleanup to the refusal itself removes that class instead of guarding each path.
  • Plain shell rather than a fresh agent. The pane never asked for a new agent; it only wanted its old conversation back, and that conversation now lives in a chat. A fresh agent would start unattended work nobody requested.
  • Unknown owner: shell unless the user asked, and keep the session. Resuming an unverified conversation automatically could reproduce the bug; blocking a resume the user asked for on a bookkeeping failure would be worse. Keeping the saved session means nothing is lost when the records become readable again. "Automatic" reuses the classification the host's restore admission already uses: a pane restoring while hidden.

Known limits.

  • Intended: WSL panes skip the check. Native chats run only on the host, so a WSL conversation can never be a chat's, and the same id in the WSL namespace is a different conversation.
  • Intended: With unreadable chat records, a pane reconnecting to a terminal whose process has ended resumes as before. Orca does not classify that reconnect as an automatic restore anywhere (the host never pauses it), and inventing a separate classification for this rare failure would make the two disagree.
  • Intended: A chat record Orca can't read at all is not matched. Its conversation is unknown, and treating every terminal resume as unverifiable because one unreadable record exists would block terminal restores indefinitely, since record rows are never removed.
  • Temporary: SSH and paired-remote panes are not checked yet. Their resume is decided by the remote host, and today the remote path cannot carry the "started fresh" result back to the pane; that needs its own remote-host change.
  • Follow-up: Before fix: an Orca started from an Orca terminal no longer leaks that pane's identity into chat agents #28267, automatic chat naming also started a separate Claude process that inherited the pane's identity and could overwrite the pane's saved conversation with the naming conversation. No chat record owns that conversation, so this PR doesn't refuse it: such a pane resumes a short naming exchange, not the chat. fix: an Orca started from an Orca terminal no longer leaks that pane's identity into chat agents #28267 stops new cases; panes already holding one are not cleaned up here.
  • Wire: refusedResume is a new optional field on the window's existing status-clear message and agentResumeRefused a new optional field on the local spawn result. Both are local (main process to window), with no stored format change.

Builds on #28267. Supersedes #26426 by @petersindex together with #28267. Thank you.

Linked Issue

Fixes #26417

Visual Proof

Hidden-window restart on a remote QA Mac, with the same saved state on both sides: a profile from main where a floating terminal had saved a native Claude chat's conversation (the chat lives in worktree qa-W).

Before (main): after restart the floating terminal, titled with the chat's prompt, runs claude --resume <chat's id> in the home folder.

before: floating pane resumes the chat's conversation

After, first restart (this PR at 8686ba6): the floating terminal is a plain home shell with the "previous session unavailable, started fresh" notice and its own title; the chat is untouched in qa-W. The saved record was gone from disk 20 s after restore while Orca was still running.

after: plain shell with the notice

After, second restart: plain shell, no notice, nothing re-created.

after: second restart, plain shell

The final head 9035ba4 differs from the tested 8686ba6 in narrowing the window cleanup to the refusing pane (and how a queued event's pane is matched), which this single-pane scenario doesn't exercise differently, and in the end-of-review sync with main, which merged without conflicts in this PR's files.

Testing

  • pty-spawn-native-chat-owned-resume.test.ts (real spawn handler and ownership rule): a chat-owned resume starts a plain shell, never types the resume, reports both result fields, carries no unattended-agent Git guard, and asks the host to forget it for the pane; an unowned conversation still resumes; with unreadable chat records, an automatic restore starts the plain shell and forgets nothing, while a resume the user asked for still runs.

  • server-ai-vault-liveness.test.ts: the hook server forgets only the refused conversation's row, leaves no resumable copy, and tells the window for each refusal.

  • forget-refused-pane-resume.test.ts (real store): the pane's saved record, its copy under an older numeric key of the same tab, its launch config and its tab title are forgotten; a split sibling's copy, another tab's copy and the chat's own tab title are kept.

  • native-chat-owned-resume.test.ts: SSH and WSL spawns never consult the host's chat records.

  • pty-transport-recycled-pty-incarnation.test.ts: a shell that replaced a resume and exited first still reports the resume unavailable.

  • Ablation: with the refusal disabled, or with the host's forget step removed, the plain-shell test fails.

  • pnpm tc:node, pnpm tc:web and the repo's static checks pass.

  • I manually tested these changes locally

  • Automated tests added/updated, or explained why not below

Review

Agent skill upstream boundary

  • Not applicable, or this change copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.

Notes

Cross-platform: no platform-specific code; Windows and macOS/Linux take the same path, WSL is skipped as above. SSH: unchanged in this PR (see limits). Folder workspaces: ownership is by conversation, not workspace kind. Performance: the chat records are read only when a pane asks to resume a conversation, through the store native chat already opens. Backwards compatibility: optional local fields, no stored format change.

X: @BrennanKB5

… owns

A pane that saved a native chat's conversation before the previous fix (or a
CLI conversation the user later moved into a chat) still resumed it on every
restart, as a second writer on the chat's transcript and in the wrong mode.

The rule already exists: a conversation any native-chat record names is never
resumed in a terminal (typed resume commands, Session History). The pane's own
restore resumes now meet it too, at the one place every automatic resume
passes: the PTY spawn, which already receives the resume's provider session and
already has a "resume dropped" outcome. When a chat owns the conversation the
pane starts its plain shell, shows the existing "previous session unavailable"
line, and its saved record is cleared, so a stale record dies after one restart.
Ownership is read from the durable session records each time, never copied; a
journal that cannot be read leaves resumes as they were.
…e pane's saved session

The refusal now drops the whole agent launch, so the pane is an ordinary shell
(no agent registration, unattended Git guard or Claude auth prep). The spawn
reports agentResumeOwnedByChat and the renderer clears both recovery sources,
the saved record and the status row. WSL spawns skip the check; the remote
runtime path is left for a stacked change that can carry the result back.
…ands

The renderer now retires a refused plan before anything registers it (no agent
hint over the plain shell), on both the fresh-spawn and reattach paths, including
a fresh result with no scrollback. The status row is dropped with no resumable
remnant on the host, so a Pi pane cannot rebuild the record and refuse again.
…before attaching

The early-exit result kept only the id, and the reattach handler returned on it
before settling, so a chat-owned record stayed retryable. The result now keeps the
ownership flag and the handler settles before that return.
@brennanb2025
brennanb2025 force-pushed the brennanb2025/pane-identity-leak-recovery branch from 9636645 to c55aa94 Compare October 13, 2026 10:54
Each pane used to retire a refused resume after receiving the spawn result, so a
pane that remounted or whose shell exited mid-spawn never forgot it, and an exited
shell was counted as a resumed agent. The spawn now asks the hook server to forget
the refused conversation for the pane as soon as the pane is known; the server
drops a matching row with no resumable copy and sends one identity-scoped clear,
which the window applies everywhere the conversation is saved. Panes only show the
notice. When the chat records can't be read, an automatic restore opens a plain
shell and keeps its saved session; a resume the user asked for still runs.
The host now publishes the moved pane's own key and, when it really deleted the
row, tells every subscriber; the window drops queued events that would restore
the conversation and forgets it only within the pane's own workspace, leaving a
pane that moved on to another conversation alone. Every spawn result, including
an exit during registration and an exit before attaching, says the resume was
refused and why. An owner that can't be checked refuses any resume the user
didn't ask for, and the pane keeps the saved session for the next try even when
it was consumed before the spawn.
… runs

Clearing the last saved agent session sent the field as undefined, which the
per-host session split drops, so the record stayed on disk until the quit
snapshot and a crash brought it back. The patch now sends an empty map, which
the split already keeps. Also loosens the refused-resume matcher to the status
types' optional fields.
Clearing every saved copy of the conversation in the workspace reached panes in
another namespace (a WSL tab in the same folder), missed the pane's own record
after its workspace was re-keyed, and let one pane's refusal undo another's kept
session. The window now forgets the conversation only for the refusing pane and
its older keys in the same tab; any other pane holding it is refused and
forgotten at its own restore, since the host checks every time. Queued events
are matched by the pane's current key, as they are applied. An unknown owner
again refuses only what restore admission already calls automatic, so reopening
a terminal is never blocked by bookkeeping.
…session

The same-tab match meant for a pane's older numeric keys also matched a split
sibling's current key, so one pane's refusal could clear another's saved session
and let that pane's late result save the chat's conversation again. Only legacy
numeric keys of the tab now count as the pane's own.
@brennanb2025

brennanb2025 commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor Author

Review loop status: converged at 9035ba4 (synced with main once), ready for a human look (still draft, not merged).

What this PR does: on restart, a terminal pane whose saved conversation belongs to a native chat opens a plain shell with the "previous session unavailable, started fresh" notice, and Orca forgets that conversation as the pane's, instead of resuming the chat's conversation as a CLI in the pane's folder (#26417).

Since the last update, per the agreed design, cleanup moved from the panes to the host that refuses the resume: it drops its own copy right away and sends one message the window applies, so a pane that remounts or exits mid-spawn can no longer skip it. Three more review rounds on that change found and fixed: queued status events restoring the forgotten row, cleanup reaching other panes, split siblings and other hosts (now limited to the refusing pane and its old numeric keys), every spawn result now carrying the refusal, the deleted row reaching every listener, and the "unknown owner" rule following the existing restore classification.

Live check on a remote Mac (hidden windows, same saved state on both sides): main resumes the chat's conversation in the floating terminal; this PR shows the notice and a plain shell, then a clean shell on the next restart, with the chat untouched. That run also exposed that clearing the last saved session was only written at quit (a pre-existing session-write bug); fixed here, and the record now leaves disk within 20 s while Orca runs. Screenshots are in the PR body. The final head differs from the live-tested 8686ba6 in narrowing that cleanup, which the tested single-pane scenario doesn't exercise differently, and in the end-of-review sync with main.

Sync with main: the updated #28267 (synced with main) merged into this branch with no conflicts, and this PR's own diff is unchanged. Main's AI-button launch change (#28210) edits the same terminal-spawn files; a fresh independent review of how the two fit together found no defects. Verified at 9035ba4: node and web typechecks, lint and code-quality checks, and the targeted tests (spawn, agent hooks, terminal pane, the refusal and forget tests: 6,733 passed). With the refusal disabled, its spawn test fails.

CI at 9035ba4: static analysis, typecheck, packaging and test shards 1 and 5 pass. Four test failures in shards 2-4, none in files this PR touches. Main broke three of them (headless-detector-compiler-cache.test.mjs and two in orca-runtime-files-artifact-facade.test.ts); open PR #28689 fixes those. The fourth, in shard 2, is a hidden-Chromium popup timing check in browser-popup-background-compatibility.electron.test.ts (changed on main by #28474). It failed on the first run and on a rerun, in a different case each time. I didn't run it locally because it launches Electron on the desktop, so I haven't shown whether main fails it too.

Not verified: Windows, WSL and SSH machines, forced crashes, the unreadable-chat-records case, and a pane remounting mid-spawn (all covered only by tests or source review). Known gaps are listed in the body (SSH not checked yet; the old naming-process leak isn't cleaned up here).

Decision for you: the session-write fix (an emptied saved-session map now reaches disk) is a separate pre-existing bug. I kept it here because this repair depends on it; I can split it into its own PR if you prefer.

@OrcaWin

OrcaWin commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator

Closing as superseded by merged #30930, whose description explicitly includes the saved-pane repair from this draft. Its merge commit 883b768bc789ea6d622abd6611b663e29402fd72 is an ancestor of current main (ebeb6779438).

I reviewed this draft’s complete production and six-file test diff at 9035ba4d8c7c71c3a5ec31f99ce357b7d31ea1fd, including the cleanup/persistence work beyond the resume guard. Main now has host-side saved-resume admission and plain-shell fallback, refusal propagation through early-exit/reattach paths, cancellation of the saved startup and registration, host/renderer cleanup notifications, the unavailable banner, truthful reopen accounting, and the empty-map session patch that clears the last saved record on disk.

The successor deliberately refines the policy: saved starts are refused for an open chat or a chat that may still be writing its current conversation; a closed, stopped chat may resume in a terminal. Cleanup is fenced to captured status/saved-record versions so a late refusal cannot erase newer work. These replace this draft’s broader historical ownership and cleanup rules. Unreadable ownership retains the saved recipe; typed starts are left alone.

Focused verification on the checked-out main-derived tree: saved-resume spawn admission, saved-startup cancellation, and workspace-session patch suites passed, 31 tests across 3 files. Relevant implementations have no difference from fetched main. No live UI or cross-platform QA claim, and no branch deletion.

@OrcaWin OrcaWin closed this Oct 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants