Skip to content

fix(terminal): preserve agent identity when process evidence is unverifiable - #23786

Closed
brennanb2025 wants to merge 33 commits into
mainfrom
brennanb2025/claude-chat-startup-fallback
Closed

brennanb2025 wants to merge 33 commits into
mainfrom
brennanb2025/claude-chat-startup-fallback

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 23 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​2046 $\color{#cf222e}{\Huge{\mathbf{−}}}$​59 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​1987
Prod 37 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​1061 $\color{#cf222e}{\Huge{\mathbf{−}}}$​219 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​842

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 (judgeForegroundAgent in src/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 shellForeground fact: 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 agents management-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:

  • Exit is still decided in two places, the main-process runtime and the renderer's foreground tracker. Both use the same evaluator and the same blindness predicate. In the default mode both can publish the Chat exit, and the exit handling is idempotent.
  • The positive exit fact is "a shell is in front" (or the terminal exited, or the host confirms the process is gone). It is not tied to the agent's own process id or a session-end hook.

Supersedes #22708

Supersedes community draft #22708 by @lurunzi, credited with Co-authored-by: lurunzi <lurunzi@gmail.com>. How this differs:

  1. It carries forward fix(chat): preserve Chat through startup and unverifiable foreground reads #22708's current-controller and available-read checks, the positive-shell requirement, and keeping an unbound Chat request alive.
  2. It moves the rule into one shared evaluator used by every producer and consumer, instead of guarding one runtime path.
  3. It adds host-side shell evidence for SSH and paired hosts, a shared blindness rule for hosts that cannot prove a shell, and command-mark ordering per terminal incarnation.
  4. It adds a bounded re-read of unanswered checks, fresh reads for exit decisions, and red-before regression tests for each case.

Brennan's #17003, #16955, #23115 and #23131 are untouched, and their changed-file lists do not intersect this patch.

Known weaknesses

  • SSH with a user-written wrapper script that runs claude without exec: 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 use exec. 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.
  • claude inside screen (tmux was not installed on the QA machine): after /exit the icon stays until screen itself exits.
  • On a blind pane (above), the agent's own neutral title retires it even if the agent is still alive, as on main. This cannot happen on a pane whose shell prints command marks.
  • An agent that sets no title and is launched into a hidden pane exits when a later read or title sees it, not at the command-finished mark.
  • Two older cache paths could still re-assert a stale name on Windows: the fallback local provider's cache, and a daemon background scan that started before the exit.
  • Local Windows and WSL were not run live.

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.

  • The relay's zsh wrapper disables OSC 133 command marks (osc133CommandMarkers: false in src/relay/pty-shell-overlay-wrappers.ts), so SSH zsh panes never get the command-finished signal. The bash wrapper does print them.
  • Direct SSH connect drops the terminal incarnation: projectIpcPtyConnectResult never forwards incarnationId, 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.
  • After a remote real exit, the tab icon and "done" status never clear.
  • After a 45 s SSH disconnect and auto-reconnect, remote panes stop rendering new output.
  • An exited background tab can regain the Claude icon minutes later. This is suspected to be the hook server keeping the last status row for the pane, and it is not verified.
  • The tab label can show raw <local-command-caveat> text after /exit is typed into the terminal.

Visual proof

Main: a neutral title knocks a live Claude Chat tab back to Terminal.

Main: fresh Claude Chat tab before the neutral title

Main: same tab in Terminal view while Claude still runs

This branch: the same lever leaves the tab in Chat with the Anthropic icon.

This branch: tab stays in Chat after the neutral title

This branch: still Chat view at +60 s

This branch: a local /exit still leaves Chat and clears the icon promptly.

This branch: local /exit leaves Chat and clears the icon

SSH Chat exit. Before the SSH fallback (an earlier commit of this branch): 120 s after /exit the tab is still in Chat over a dead agent.

Before the SSH fallback: SSH Chat tab still in Chat 120 s after /exit

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: SSH tab in Terminal view at a shell prompt after /exit

After: a neutral title on a live SSH Claude still keeps Chat at +60 s.

After: SSH Chat tab unchanged 60 s after a neutral title

Testing

  • Manually tested (live Electron QA on a remote macOS machine: main versus this branch, local and SSH)
  • Automated tests added/updated

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 under screen, 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 /exit and 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 /exit cleared Chat, icon and status about 3.5 s after Enter.

Typecheck: node is clean at 9820d77a95c; web and cli are clean at 3404e8150f0, and later commits touch only main-process code and tests. CI is green at 9820d77a95c, 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 (zsh publishes the exit; native Claude's 2.1.258 and a reply for another incarnation do not).

Agent skill upstream boundary

  • Not applicable; no upstream skill source copied.

Notes

Author X/Twitter: @BrennanKB5.

brennanb2025 and others added 30 commits September 28, 2026 21:18
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.
…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.
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.
@brennanb2025

Copy link
Copy Markdown
Contributor Author

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 /exit or Ctrl-C twice ends Claude within 1 s, but the tab stays in Chat view for at least 120 s, over a live shell prompt. The composer stays enabled and would type into that shell. On main the same step left Chat after 9.6 s. It was measured twice on a macOS loopback SSH target with this branch's relay deployed. Unit tests built with the real host evidence builder pass, so the live path differs from what the tests model. Not diagnosed yet.

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 (/exit, Ctrl-C twice, kill), including on hidden tabs, still clear. There was no Chat-to-Terminal flip during startup in 3 out of 3 fresh tabs.

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 shellForeground fact for SSH and paired hosts, one blindness rule for hosts that cannot prove a shell, command-mark ordering per terminal incarnation, a bounded re-read of unanswered checks, and fresh reads for exit decisions. The single host-owned foreground-agent record is deferred to a follow-up PR.

Commits: 29 review commits on top of the original, plus two merges of current main. The head is b8f321d0af8.

Checks: node, web and cli typecheck are clean at 3404e8150f0; the one later commit changes only a test. CI is green at b8f321d0af8. 69 named Vitest files pass (2449 tests), and every behaviour fix has a test that fails without it.

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 <local-command-caveat> text after /exit is typed into the terminal.

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.
@brennanb2025

Copy link
Copy Markdown
Contributor Author

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 9820d77a95c and re-verified live. Code review of the fix came back clean. The PR stays a draft; nobody merged it or marked it ready.

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 /exit.

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 2.1.258, stays undecided. There is no new timer and no wire change.

Live QA at 9820d77a95c (macOS loopback SSH target, relay confirmed deployed): SSH Chat left for Terminal 3.0 s and 3.8 s after /exit, and 2.2 s and 2.0 s after Ctrl-C twice. Before the fix it never left within 110 to 143 s. A neutral title on a live Claude kept Chat through +60 s, both over SSH and locally. A local /exit cleared Chat, icon and status about 3.5 s after Enter.

Tests and checks: new tests use an SSH-shaped terminal id with the host reporting process_table_unreadable. In them, zsh publishes the exit, while 2.1.258 and a reply for another incarnation do not; with the fix neutered they fail. 70 named Vitest files pass (2457 tests). The node typecheck is clean, CI is green at 9820d77a95c, and lint and the anti-slop lint are clean.

New known weakness (disclosed in the body): a user-written wrapper script that runs claude without exec over SSH, combined with a slow host capture and a neutral title, can still end Chat while Claude is alive. Main does not, because it waits for the full process table. Closing this needs either a fall back to that slower full read on undecided exit checks, or a relay change. Both are left for Brennan to decide.

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.

@brennanb2025

Copy link
Copy Markdown
Contributor Author

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.

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.

1 participant