Repository navigation
fix(terminal): preserve agent identity when process evidence is unverifiable - #23786
brennanb2025 wants to merge 33 commits into
Conversation
Preserve chat and display identity when process observations are unverifiable, while keeping input routing subject to fresh evidence. Co-authored-by: lurunzi <lurunzi@gmail.com>
A rapid 133;C->133;D pair on a pane with no agent identity forced a foreground read, and a shell result then ran the confirmed-agent-exit routine (xterm mode reset, chat exit callback) after every short shell command. With no identity there is nothing to exit; typed agent commands are already covered by command-inferred identity.
Every local foreground refresh (each title status transition) had moved to the fresh confirmation read, which bypasses the cached-agent revalidation and forces a full process scan on Windows. A cached read that still recognizes the agent is live evidence; only a non-agent result needs the fresh read before it can certify an exit.
…an agent The renderer title tracker re-arms after every candidate, so byte-parser panes (remote runtime, main-authority kill switch) and old-host facts paid a foreground read ladder on every neutral title, including plain shell prompts long after the agent was gone. Skip the read when no agent identity remains. Also updates the transport callback list to the renamed candidate option.
These suites still asserted the old behaviour: a neutral title exiting the agent directly, an unavailable command-finish ladder retiring the launch identity, and command start clearing display identity. They now assert the candidate is confirmed before exit, unknown reads keep display identity, and agent-specific Shift+Enter routing stays revoked.
…und evidence Host foreground evidence named only recognized agents, so a shell back at its prompt read as live with no process name and no reader could tell an exited agent from an unreadable one. The shared builder used by the relay and the daemon now adds an optional shellForeground fact: the PTY root is a shell, it owns the terminal's foreground group, and no agent is recognized there. Old hosts omit the field and readers treat that as unable to certify.
…tions The exit rule only accepted a process name, and host evidence never names a shell, so an agent that really exited on SSH or a paired host could never be confirmed gone: the tab kept its agent icon, status row and Chat view. judgeForegroundAgent now takes a structured observation (a local process name, an admitted host record, a host without evidence, or no answer) and returns live / unverifiable / exited plus whether the host can ever certify an exit. Main's foreground record, main's exit confirmation, the status query and the renderer tracker all use it, so they cannot disagree on the same answer. Main makes one decision per read: the read that confirms an exit also clears pty.foregroundAgent, so mobile, CLI and prompt delivery match desktop Chat. Main also confirms an agent exit at the shell's own 133;D, which is the only exit signal when the shell sets no title. On hosts that can never show a shell (the WSL bridge, SSH to Windows, relays or hosts without evidence) that 133;D retires an agent the PTY held; a transient unknown read still keeps it. The status query again refuses a running agent under Claude's management title.
…lisher A visible-pane sample taken while a launched agent was still booting saw the shell and published an agent exit, flipping Chat back to Terminal. The renderer now separates retiring a launch/hook expectation from an agent exit: an exit needs an agent observed in this pane (a read or its own status title) or the shell's 133;D ending the command. The renderer publishes exits only when it is the byte parser; with main as the parser, main's fact is the one publisher. Host agent-exited facts are the host's decision, so an old host's unmarked fact is accepted instead of re-judged by a read that could never succeed. Untracked panes (WSL) clear the typed-command agent on 133;D again, and the parked byte parser no longer carries a dead exit callback.
The launch-identity assertion read an optional identity without a guard, and the exit-candidate test called a transport mock whose type is not callable; read the bound PTY id from the mock's recorded result instead.
… evidence The terminal agent-status query is what assertTerminalAgentSendable asks before typing a prompt into a pane. The PR had made it report a running agent when a shell title or shell foreground could not be confirmed, which would let an orchestrated send type a prompt into a bare shell. Restore the base query: a shell or Claude management title, or an unconfirmed shell foreground, means no agent is running for sending. Display surfaces keep identity on unknown; this guard refuses to act without positive evidence. Also align the runtime specs with exit confirmation reading a fresh process only after the cached read fails to name an agent, and cover main confirming an exit at the shell's 133;D. These specs only run with the runtime aggregator, which fails to import on the base (missing codex provider-verdicts module).
… not only the PTY root macOS PTYs are rooted at login(1), which forks the shell into its own process group, so a paired macOS host never reported the shell at its prompt and a real agent exit there never cleared the icon, status or Chat. A nested shell back at its prompt had the same gap on SSH. The rule now checks the foreground group's leader and refuses when an agent sits above it.
…neither read nor mark commands An SSH relay on Windows cannot read the foreground and injects no OSC 133, so neither a read nor the shell's command-finished mark could ever retire an agent there, and a real exit left Chat and the cache timer up. The agent's own idle-to-neutral title is that host's exit signal again, as before this PR.
Transports now take only the exit candidate; a caller still passing the old option would be silently ignored.
…hat-startup-fallback
…xits A tab left in Chat mode with no bound pane waited for any agent to become eligible, and handleConfirmedAgentExit ignored exits while no pane was bound, so a later unrelated agent run would enter Chat on its own.
…s host marks no commands Remote-runtime panes always parse bytes in the renderer, and a Windows host can neither read the foreground nor, under cmd.exe, mark commands, so a real exit never cleared the icon or Chat. Mirror main's SSH rule: on a remote host that can never certify, the agent's idle-to-neutral title retires it, as on base.
…own command A user shell integration that prints OSC 133;D at every prompt emits one at the first prompt, before Orca's startup command runs. Main counted the launch expectation as a held agent there, so a read that beat the agent's fork saw the shell and published an exit, and hosts that cannot read the foreground retired the agent outright. Chat then flipped to Terminal during startup. Main now records the shell's 133;C per incarnation. A 133;D counts toward an agent exit when it closes a command started in this incarnation of a PTY with a launch expectation, or when the agent was seen live by a read or its own title.
…aw the agent live Two gaps in the renderer's exit decision: - In byte mode a first-prompt 133;D from a user shell integration counted as an agent exit whenever the pane expected a launch, so Chat could flip to Terminal during startup. A 133;D now counts only when it closes a command the shell's own 133;C started, or when the agent was seen in this pane. - With main as the parser, if both of main's reads failed at a real exit nothing exited Chat, and composer sends then reached the bare shell. The pane's own read ladder now exits Chat when a read in this PTY saw the agent live and a later read confirms the shell. An expectation retired by a shell sample is still never an exit, and a repeat exit signal is a no-op downstream.
A same-id respawn keeps the previous process's foregroundAgent, so the new shell's first-prompt 133;D read as an agent the PTY held and a boot-time shell read published agent-exited. Stamp each read that names the agent with the incarnation it saw and require a match.
The pane tracker outlives a PTY replacement, and its agent-evidence flag and running-command mark carried into the next PTY. In byte mode that made the replacement shell's first-prompt 133;D a marked exit and left Chat during a wake or restart. Each mark now keeps the PTY key it was seen in; the marks move to their own module to keep the tracker under max-lines.
…ead of an unanswered one Main gated the agent's own neutral-title exit on SSH and the renderer on "any remote pane", so claude inside tmux or ssh, and WSL with a shell that marks no commands, never exited. Both now ask one shared predicate: a pane is blind when a read that answered can never show its shell (a host that cannot read, a program hosting the agent) or, where its shell marks no OSC 133 commands, names the WSL bridge or another program. An unanswered read is never blindness. When main's read after a title candidate cannot answer, it re-reads on the shared 1.2 s / 6 s ladder, then keeps the candidate only until the host is next reached (liveness or reattach), re-reading once. Exit, a live answer, a new incarnation, or disposal drop it. Publishing and the recheck move to their own runtime class to keep the split file under max-lines.
… revoked Every 133;C now revokes agent-specific input routing while the agent is still held, and the Chat eligibility index dropped revoked panes. An unbound Chat request then could not bind until the next read republished the agent, so the composer showed "No live terminal" with Claude alive. Revocation withholds input only; eligibility now ends only at a confirmed exit or PTY exit, and Shift+Enter and Ctrl+Enter still refuse agent encodings on a revoked pane.
…hat-startup-fallback
The bounded re-read of an unanswered agent-exit candidate kept only the PTY id. Two ways it could reach a same-id replacement process and publish agent-exited for it while that replacement's agent was still booting: - a queued re-read fired after the PTY's incarnation changed (a respawn whose old exit arrives late never disposes the title tracker); - a confirming read still out when the PTY exited resolved against the kept, disconnected record and re-armed a waiting candidate after dispose, which the next contact then re-read into the respawned process. Each candidate now records the incarnation and lifecycle generation it was raised in, a re-read for any other is dropped before it reads, and every title-candidate read (not only hook recovery) is dropped when the lifecycle generation moved while it was out.
zellij runs the agent in its own panes, out of the outer pane's process sight, exactly like tmux and screen, which the blindness rule already lists. Without it a Claude inside zellij on a pane that marks commands kept Chat and its icon until zellij itself exited.
… tests The anti-slop lint rejects Reflect.get and broad object parameters; the repo's existing tests use typed bracket access for the same records.
A local PTY's cached foreground name can still say the agent after it exits (the Windows daemon tracker keeps it until an async scan; POSIX keeps it for its 1 s TTL). Exit confirmation reused that cached-first read, so a real exit read as live, restored the agent, and never re-read. cmd.exe panes and parked panes had nothing else to retire Chat, the icon or the status. Exit decisions (title candidate, shell 133;D, and the bounded re-read ladder) now take confirmForegroundProcess. Routine refreshes and the per-turn hook recovery, which never certifies an exit, keep the cached-first read. A pending cached read no longer answers an exit decision; a pending fresh read may still answer a refresh. SSH reads are host inspections and are unchanged.
… fresh Every title exit candidate is preceded by the same title's cached refresh. When that refresh's cached name was no agent it fell through to the fresh read, but the exit check still took a second fresh read behind it (a second forced process scan on Windows). Freshness is now recorded from the answer, and a waiting exit check re-judges the settled read before reading again.
The shell's end-of-command mark is an exit decision, but no test covered it with a stale cached agent name, so passing it the cached read stayed green.
…unavailable The composed daemon/pane test asserted the old retire-on-unknown ladder; an unreadable process table is missing evidence, so the pane keeps its agent.
Review status: NOT READY (draft). One open SSH regression.Verdict: the local fix works and is confirmed live, but live QA found a user-visible regression on SSH that is not fixed yet. The PR stays a draft. Nobody merged it or marked it ready. Open regression (blocks this PR): on an SSH tab running terminal-backed Claude Chat, a real Confirmed fixed, live: on main, a neutral title on a live, idle Claude knocks its Chat tab to Terminal within 1 s, 4 out of 4 times. On this branch the tab keeps Chat and the Anthropic icon through +79 s. Local real exits ( Review: 11 review loops, plus two architecture challenges and a start-of-review and end-of-review readiness check. The design was reshaped into one shared evaluator (live, unverifiable or exited), an optional host Commits: 29 review commits on top of the original, plus two merges of current main. The head is Checks: node, web and cli typecheck are clean at Not run: Linux SSH (the QA machine had no access route without an authorized_keys change that needs approval), local Windows, WSL, and a final live pass at the last head. Seen in QA but not caused by this PR (same on main): after a remote real exit, the tab icon and "done" status never clear; after a 45 s SSH disconnect and reconnect, remote panes stop rendering new output; an exited background tab can regain the Claude icon minutes later; and the tab label can show raw Next step: diagnose why a real SSH exit never publishes an exit fact to the Chat route. The relay log shows nothing at the exit. Fix it, then re-run live QA at the new head. |
…st evidence is undecided A loaded relay misses the fenced evidence's ps budget or age gate, so every SSH exit read came back undecided and Chat never left after a real /exit. Fall back to the same inspect reply's live foreground name, only when the reply names this PTY incarnation and the name is a shell; anything else stays undecided.
Review status update: SSH Chat-exit regression fixed and verified live (still a draft)Verdict: the SSH regression reported in the previous status comment is fixed at Cause (confirmed): on SSH, the exit check used only the host's fenced foreground evidence. That evidence needs the relay's process-table capture to finish within 1.2 s and to be under 2 s old when main receives it. On the QA machine the capture alone took 0.8 to 1.3 s even idle, so under load every exit check came back undecided, and Chat never left after a real Fix: when that evidence is undecided, main uses the live foreground name from the same inspect reply. It counts only if the reply names this exact terminal incarnation and the name is a shell. Anything else, such as native Claude's Live QA at Tests and checks: new tests use an SSH-shaped terminal id with the host reporting New known weakness (disclosed in the body): a user-written wrapper script that runs Pre-existing SSH gaps, not fixed here (inputs for the planned SSH change): the relay's zsh wrapper disables OSC 133 command marks, and direct SSH connect drops the terminal incarnation, so remote panes never get a foreground-tracker entry. That is the likely reason the remote tab icon and "done" status never clear after an exit, which is the same on main. Still not run: Linux SSH (no access route without an approved authorized_keys change), local Windows, and WSL. |
|
Closing in favor of a redesign: agent presence will come from hook identity plus a definitive check on the agent's own process id in the one agent status store, instead of foreground-process polling and title changes. This PR's rule that an unanswered process read is never an exit carries over. STA-8819 stays open for the new work. |
What this fixes
Status: draft. The SSH Chat-exit regression found in earlier live QA is fixed and re-verified live. Remaining known weaknesses are listed below.
The problem: while an agent (Claude, Codex, OpenCode, and others) runs in a terminal-backed tab, Orca sometimes decided the agent had exited when it only lacked evidence. The triggers were a neutral terminal title, a foreground-process read that failed or came back unavailable, a replaced terminal controller, an unrecognized process name, or SSH relay cleanup bookkeeping. The user saw terminal-backed Claude Chat drop back to the raw Terminal view while Claude was still running, and the agent status and tab identity could clear.
Reproduced live on main (macOS): writing a neutral title to the terminal of an idle, still-running Claude flips its Chat tab to Terminal within 1 s, and it stays there, 4 out of 4 times. On this branch the same tab keeps Chat and the Anthropic icon.
Who hits it: anyone using terminal-backed Chat, or any agent tab, on local macOS, Linux or Windows, on WSL, over SSH, or on a paired remote host.
What is NOT affected: structured Claude chat startup (no startup timers changed), rewind storage (STA-8835), and plain shell tabs with no agent.
Linked issue: STA-8819 / #23638, likely fixes, pending reporter confirmation. The reported chat freeze plausibly fits this class, but no reporter trace confirms the exact cause. Locally only the Chat view flipped on main; the "icon disappears" part of the report did not reproduce here. No PR that introduced a regression has been identified.
ELI5
Losing sight of Claude does not mean Claude stopped. Orca now keeps the agent's tab, Chat view and status until it sees real proof that the agent ended: the shell is back in front, the terminal process exited, or the host says the process is gone. If it cannot tell, it keeps the agent and checks again later, a bounded number of times.
Before and after
Before, several places each turned "no answer" into "agent exited": the runtime's prompt-submission check, the foreground refresh, the renderer's foreground tracker, parked panes, the status query, and relay cleanup. A title change back to a plain title counted as an exit by itself.
After, one shared evaluator (
judgeForegroundAgentinsrc/shared/foreground-agent-verdict.ts) classifies every observation as live, unverifiable or exited. Only "exited" retires an agent. A neutral title is only a candidate that triggers a check.A check that gets no answer re-reads after 1.2 s and 6 s, then once more the next time the host is reached (relay reconnect or reattach), and then stops. Nothing retries without bound.
Exit decisions always use a fresh foreground read. Routine per-turn refreshes keep the cheap cached read, so there are no new process scans per turn.
SSH relays and paired hosts now report an optional
shellForegroundfact: a shell leads the terminal's foreground group with no agent above it. This works under macOS's login(1) root and for nested shells, so the host can prove a remote exit. The field is optional; there is no new stream opcode, and old clients ignore it.A loaded SSH host often cannot produce that fenced evidence in time: the relay's process-table capture has a 1.2 s budget, and main rejects records older than 2 s. On the QA machine the capture took 0.8 to 1.3 s even idle. When the evidence is undecided, main falls back to the live foreground process name from the same inspect reply. It uses that name only when the reply names this exact terminal incarnation, and only a shell name counts as an exit; anything else (for example native Claude's
2.1.258) stays undecided. There is no new timer and no wire change.Some panes are blind: the foreground can never prove a shell there. These are panes where a program hosting the agent is in front (tmux, screen, zellij, ssh, mosh, et, docker, podman), SSH to Windows, hosts without the new field, and WSL or other shells that print no command marks. On a blind pane the agent's own switch to a neutral title retires it, as it did before this PR. A single shared predicate (
isAgentExitBlind) decides blindness from what the pane has actually shown, not from host type.The shell's command-finished mark (OSC 133;D) ends a launched agent only when it closes the command the shell itself started (133;C) in this terminal incarnation. A shell integration that prints a mark at its first prompt therefore no longer exits Chat during startup, and evidence from a previous process never counts for its replacement after a respawn, wake or restart in place.
An exit needs an agent actually seen live in this terminal incarnation. A launch expectation alone never exits Chat, which keeps Chat through slow agent startup.
Revoking agent-specific keyboard routing (Shift+Enter CSI-u and similar) no longer removes Chat eligibility. That had briefly shown "No live terminal" while Claude was alive. Uncertainty still never enables agent-specific input.
A Chat request made before the agent is detectable now ends when that pane's agent really exits, instead of lingering until some later agent run auto-enters Chat.
The agent send guard behind orchestrated sends still fails closed on unconfirmed evidence, as on main. The
claude agentsmanagement-title gate is kept.One bounded trace span is emitted at the exit decision boundary, carrying only the reason category, evidence source and verdict. It holds no prompts, paths, process names or terminal bytes.
Why this design
A guard only in Chat routing would still let false exits reach the tab icon, status, mobile and the CLI. So the fix lives in the shared evaluator that every producer now calls.
Rejected: a longer timeout. It turns uncertainty into a guess on a timer. Also rejected: keeping the old "retire after N failed reads" ladder, which is the bug itself, since a failed read is loss of contact, not evidence of death.
Deferred to a follow-up: a single host-owned foreground-agent record per terminal incarnation, with the renderer tracker reduced to a reader. It would remove the remaining duplication (below), but it touches routing trust, the Windows retry ladder, reattach, parked panes and mobile. Doing it inside this P0 fix would restart the review cycle.
Honest deviations:
Supersedes #22708
Supersedes community draft #22708 by @lurunzi, credited with
Co-authored-by: lurunzi <lurunzi@gmail.com>. How this differs:Brennan's #17003, #16955, #23115 and #23131 are untouched, and their changed-file lists do not intersect this patch.
Known weaknesses
claudewithoutexec: a shell then leads the terminal's foreground group while Claude runs. If the host evidence is also undecided (a slow process-table capture) and Claude shows a neutral title after an idle one, the fallback above reads a shell and Chat can leave while Claude is alive. Main would not, because its slower full-table read finds Claude under the wrapper. Orca's own launches useexec. Closing it means either falling back to that slower full read on an undecided exit check, or having the relay name the foreground from its full capture.screen(tmux was not installed on the QA machine): after/exitthe icon stays untilscreenitself exits.Findings not caused by this PR
These are the same on main. The first two are inputs for a planned SSH change and are deliberately not fixed here.
osc133CommandMarkers: falseinsrc/relay/pty-shell-overlay-wrappers.ts), so SSH zsh panes never get the command-finished signal. The bash wrapper does print them.projectIpcPtyConnectResultnever forwardsincarnationId, so the renderer rejects every remote foreground read and remote panes never get a foreground-tracker entry. This is the likely reason the tab icon and "done" status never clear after a remote exit.<local-command-caveat>text after/exitis typed into the terminal.Visual proof
Main: a neutral title knocks a live Claude Chat tab back to Terminal.
This branch: the same lever leaves the tab in Chat with the Anthropic icon.
This branch: a local
/exitstill leaves Chat and clears the icon promptly.SSH Chat exit. Before the SSH fallback (an earlier commit of this branch): 120 s after
/exitthe tab is still in Chat over a dead agent.After: the same step leaves Chat for the Terminal view in about 3 s. The tab icon still shows Claude; that pre-existing gap is listed above.
After: a neutral title on a live SSH Claude still keeps Chat at +60 s.
Testing
The live QA ran in an isolated background Orca build, driven only over CDP, with a macOS loopback SSH target. Scenarios covered: the neutral-title lever, startup (3 fresh Chat tabs per arm), local
/exit, Ctrl-C twice and kill, hidden-tab exits, claude underscreen, SSH hang, disconnect and reconnect, remote exits, and a vim/sleep control. The Linux SSH target was blocked by access.At the final production code (
9820d77a95c, relay confirmed deployed): SSH Chat left for Terminal 3.0 s and 3.8 s after/exitand 2.2 s and 2.0 s after Ctrl-C twice, where the earlier commit never left within 110 to 143 s. A neutral title on a live Claude kept Chat through +60 s on SSH and locally. A local/exitcleared Chat, icon and status about 3.5 s after Enter.Typecheck: node is clean at
9820d77a95c; web and cli are clean at3404e8150f0, and later commits touch only main-process code and tests. CI is green at9820d77a95c, including static analysis.Tests: 70 named Vitest files pass (2457 tests, 1 skipped), including the runtime aggregator and
tests/e2e/terminal-foreground-confirmation.unit.test.ts. Every behaviour fix has a test that fails without it. These cover a neutral title while the agent is alive, SSH and paired-host exits built with the real evidence builder, startup and respawn, the first-prompt command mark, WSL with and without marks, claude in tmux or ssh, the bounded re-read and its fencing, a stale cached "claude" at exit, Chat eligibility under revoked routing, and an SSH host whose process table is unreadable (zshpublishes the exit; native Claude's2.1.258and a reply for another incarnation do not).Agent skill upstream boundary
Notes
Author X/Twitter: @BrennanKB5.