Skip to content

fix(agent-status): the execution host ends the agent a finished command ran - #26495

Open
Jinwoo-H wants to merge 7 commits into
jinwoo/sta-9582-retire-ownerfrom
jinwoo/sta-9582-host-command-end
Open

Jinwoo-H wants to merge 7 commits into
jinwoo/sta-9582-retire-ownerfrom
jinwoo/sta-9582-host-command-end

Conversation

@Jinwoo-H

@Jinwoo-H Jinwoo-H commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 19 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​530 $\color{#cf222e}{\Huge{\mathbf{−}}}$​718 $\color{#cf222e}{\Huge{\mathbf{−}}}$​188
Prod 45 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​546 $\color{#cf222e}{\Huge{\mathbf{−}}}$​406 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​140

ELI5

When a command finishes in a terminal, Orca decides whether the agent it ran has stopped. Until now the Orca window made that decision. The window cannot see the processes behind a pane, so it sometimes hid the wrong agent. Now the machine that runs the terminal decides: the desktop for local and WSL terminals, the remote relay for SSH terminals. The window only shows the result.

What Changed

Before:

  • A launched Claude is killed (kill -9) while a Codex it started in the background waits behind it. Codex takes the pane, then the window hides Codex's row as soon as the shell prompt returns (over SSH, about 7 seconds later). Codex reappears on its next tool event, and locally that row has lost its prompt.
  • Every command-end decision lived in the window. Headless orca serve and the phone had no such cleanup at all.

After:

  • Codex stays visible with its prompt, locally and over SSH.
  • A typed Codex (or any agent that reports no process), stopped with Ctrl-C, killed, or suspended with Ctrl-Z: its row ends when its command finishes, as before.
  • A typed Claude killed mid-turn: it ends through the existing process check, which now also runs on every command finish.
  • A row with no owner record (painted from the terminal): it clears when its agent ran the command, or when the command that printed it ran under a program Orca does not recognize (printf <status>; sleep 6).
  • An agent running in the background (codex &): a later command such as ls no longer ends it.

Mechanism: one shared rule, commandEndEndsRow (src/shared/agent-command-end.ts).

  • The relay applies it to the SSH hook rows it holds.
  • Main applies it to every other row (endCommand): local, WSL, and SSH rows painted only from terminal output, which no relay holds. Main cannot read an SSH foreground, so for those rows the "no read named it" branch applies.
  • Owner with a process: never decided by this rule; the host's process check decides.
  • Process-less owner, or a row with no owner record: ends when the command's foreground agent was that agent. A hook owner is kept when another program held the command; a row painted from terminal output is not, because the command that printed it is its agent.
  • No read named the foreground: the row ends if it reported during the command, which is what the window's drop did.
  • Always kept: an ended owner, a resume remnant, or a row written after the command finished (a run's own Done).

What counts as "the command's foreground agent" comes from the host's process table (CommandForegroundTracker, src/shared/command-foreground-tracker.ts):

  • When it reads: once on each live agent report while the command runs (hook or terminal-painted). A report that arrives while a read is in flight adds nothing. Cached replays are not reports.
  • Start ladder: the shared 350/1200/6000 ms ladder runs only when a consumer needs it: main's opencode run producer, which has no reports to wait for. It runs only on PTYs main can read (local, not SSH or WSL), and it retries only while a shell or launcher still holds the foreground.
  • One sampler on main: the opencode run producer no longer reads the foreground itself; it consumes the tracker's samples. The window's own foreground tracker now serves only presentation (routing, the pane's agent icon, title and mode cleanup, its launch registry), so it stays.
  • How names are read: through the existing recognizer, from names the host already resolves. On the desktop, Node and Python agents are resolved through the process table on macOS and Linux, and through the Windows table on Windows. The relay resolves them through its process-tree walk.
  • Why event order cannot fool it: every read names the real foreground. A background guest's event reads Claude, never Codex.
  • A leaked command end: a nested shell inside a full-screen agent can print a command end while the agent still runs. Before a row ends, the host reads which process group holds the terminal right now, and only a shell there confirms the end. This read is node-pty's own name for the terminal's foreground process group (tcgetpgrp): instant, never cached, and a job stopped with Ctrl-Z is not in it. Local panes read it from the local provider, daemon panes through a new readTerminalForeground daemon request, SSH panes on the relay. Windows names only the spawned shell, and an older daemon does not know the request; both fall back to the existing fresh confirmForegroundProcess. The read runs only when a row would end.

One rule, not two: #26434's launch-specific owner end is deleted, along with the relay's per-terminal launch agent and the launch-to-hook-name map. A launched agent is simply its command's foreground agent. The launch keeps only what is launch-scoped: its orchestration authority and the token fence. The fence now also stops a surviving row from persisting the ended token as a restart commitment.

Removed from the window:

  • The command-finished drop, on mounted and on parked panes.
  • The deferred drop and its confirm path.
  • The agentStatus:reconcileEndedProcess and agentStatus:hasVerifiableAgentProcess IPC.

The window keeps only presentation: routing, title and mode cleanup, and clearing its launch registry.

Prompt loss: when an owner's exit replays the guest held behind it, the guest's prompt now seeds the pane's prompt cache. The owner's exit had cleared that cache.

Why

The execution host owns agent status (docs/reference/agent-status-store.md), and it is the only side that can tell which process held a terminal. The window's drop was a reader making a status decision, and it could not tell a detached guest from the agent that exited.

Alternatives considered:

Linked Issue

STA-9582 (stacked on #26434).

Visual Proof

N/A. No new UI; this changes which status rows stay visible after a command finishes. It is covered by the tests below; the e2e scenario (i) evidence is in the review thread.

Testing

  • server-command-end.test.ts (main):

    • (i): a killed launched Claude's guest stays visible, and keeps its prompt on its next event.
    • (a): a killed typed Claude ends through the process check.
    • (b): a Ctrl-C'd or Ctrl-Z'd Codex ends, and so does one where no read named the foreground; late hooks never revive it.
    • codex &: kept when another program or another agent held the command, or when it reported only before the command.
    • (c): an owner-less row clears.
    • SSH and WSL: an SSH row is left to its relay; a WSL row ends.
    • Session-less agent: it is fenced.
    • Ended token: never attests again.
  • relay-agent-command-end.test.ts: (b) and codex & over SSH, and (i) over SSH.

  • pty-handler-agent-command-end.test.ts: the relay reports the command's foreground agent, and confirms the end from node-pty's terminal group: a leaked end under Codex is not confirmed, and an exited Codex the cached sampler still names is.

  • command-foreground-tracker.test.ts: reads only on reports unless the ladder is asked for, guest event order, an agent winning over an earlier program, unknown; the end is confirmed from the terminal read, never the cached sampler, read once, and an unreadable terminal trusts the end.

  • local-pty-provider-foreground-process.test.ts, daemon-server.test.ts: the terminal read is node-pty's name, not the cached identity, and reads nothing on Windows.

  • terminal-creation-and-readiness.spec.ts: the runtime confirms the end through readTerminalForeground.

  • Main and relay: a leaked end keeps the row; a row painted from output ends under a program (e2e viii).

  • opencode-run-lifetime-status.test.ts: all 24 run-producer cases now go through the tracker, wired as the runtime wires them.

  • An SSH row painted only from terminal output clears on main. The relay reads nothing on a cached replay.

  • agent-command-end.test.ts: the rule's table.

  • Updated the window tests that asserted the drop: they now assert the row is left to the host.

  • Ran, all under node where needed:

    • src/main/agent-hooks, src/main/runtime, src/relay, src/shared, src/renderer, src/main/ipc, src/preload, src/main/startup, src/main/daemon, src/main/providers, src/main/orcad.
    • The one failure is omp-draft-launch-shell's fish case: the local ~/.local/bin/env.fish errors while being sourced. It fails on the base branch too.
    • pnpm tc, oxlint, and the changed-code quality gate.
  • I manually tested these changes locally (the owner matrix e2e below)

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

Review

Owner matrix e2e (control, S1-S6, (i)-(ix); Electron in the background, SSH against a Docker host), on this branch merged with #26434 and #26391 plus the one-line seed resolution above: all checks pass, locally (32) and over SSH (33). Root causes found with it:

  • (iii), (iv), (vi), and (ix) over SSH: the leaked-end guard read the cached foreground, which still named the exited or stopped agent. Fixed by the terminal-group read.
  • (viii) locally: the command's foreground read as sleep, and the rule kept every row under a program. Fixed by the output-painted row rule.
  • (i) and S5 locally: the merged tree's replay threw on the renamed prompt cache (see "Merging with fix(agent-hooks): keep each agent's prompt and tool memory separate on a shared pane (STA-9582) #26391").
  • A full fresh process-table read was tried first; it took one to three seconds under load, so Ctrl-C'd rows ended late.

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

Where the foreground cannot be read, the rule behaves as the window's drop did: it ends the agent that reported during the command.

  • WSL panes (the guest's processes are not visible to the desktop).
  • Windows SSH relays (their process read is POSIX-only).
  • Commands whose start the desktop did not see (the terminal daemon reports command ends only).

In those cases (i) behaves as before.

macOS: Claude's foreground name can be hidden there, but Claude has a process id, so only the process check decides for it.

SSH: the relay always runs the desktop's exact build, so there is no mixed-version gap. No wire change; the relay sends the ended row it already sends for a proven exit.

Mobile and orca serve: they now get the same command-end behaviour, since it lives on the host. The headless runtime (orcad) now wires the launch and command ends the desktop wires.

Merging with #26391: that PR keys the prompt cache by producer (lastPromptByProducerKey). Whichever of the two merges second must change this PR's guest-replay prompt seed in server-status-update.ts to lastPromptByProducerKey.set(producerCacheKey(paneKey, source), prompt). Without it the merged tree does not typecheck, and at runtime the replay throws, so a killed owner's held guest is never shown.

Checklist

  • This PR is small and focused
  • I explained what changed and why (ELI5, the user-facing before/after, the mechanism, and why over the alternatives)
  • Before/after screenshots or videos attached for UI changes, or N/A with reason
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A)
  • pnpm lint, pnpm typecheck, pnpm test, and pnpm build pass (or CI will cover; local preferred)

…nd ran

When a command finishes, main (local and WSL panes) and the relay (SSH panes)
apply one rule, commandEndEndsRow: an owner with a process is left to the host's
process check; a process-less owner, or a row with no owner record, ends when it
was that command's foreground agent, read from the host's process table
(CommandForegroundTracker), or when nothing named the foreground and it reported
during the command. The renderer's command-finished drop, its deferred confirm
path, and the reconcileEndedProcess and hasVerifiableAgentProcess IPC are gone.
The launch-specific owner end and its hook-agent map are folded into this rule;
the launch keeps only its authority and token fence. A guest replayed after its
owner's exit keeps its turn's prompt.
…-command-end

# Conflicts:
#	src/main/agent-hooks/server-launch-retirement.test.ts
#	src/main/agent-hooks/server/server-persistence.ts
…r on main

Main's command end skips only hook rows a relay holds, so an SSH row painted
only from terminal output ends there. The command foreground tracker reads on
live agent reports only, plus the shared start ladder when a consumer needs it:
main's opencode run producer now consumes the tracker's samples instead of
running its own ladder, and only on PTYs main can read. Relay replays are not
activity.
…and end output-painted rows under a program

The leaked-end guard read the pane's cached foreground at the command end. That
cache still named an agent that had just exited (Codex Ctrl-C, Codex normal
exit) and, on the relay, a job Ctrl-Z had stopped, so real ends were dropped.
The end now asks a fresh read which process group holds the terminal: main uses
the controller's fresh confirmForegroundProcess (local and daemon panes), the
relay a fresh process table. Only a non-shell there is a leaked end; the read
runs only when a row would end.

A row painted from terminal output has no process of its own, so a command
whose foreground read as an unrecognized program (`printf <OSC>; sleep`) is
that row's agent and now ends it.
…end, not a process-table scan

A fresh process-table capture costs one to several seconds on a loaded host, so
a Ctrl-C'd agent's row stayed working past the end. The command end now reads
node-pty's own name for the terminal's foreground process group (tcgetpgrp),
which costs nothing and never lists a stopped job: local panes from the local
provider, daemon panes through a new readTerminalForeground daemon request, SSH
panes on the relay. Windows names only the spawned shell, and an older daemon
does not know the request; both fall back to the fresh confirmForegroundProcess.
… runtime too

orcad built the runtime without the hook server's launch and command ends, so
its panes never ran the host command-end rule the desktop runs.

This branch has not been deployed

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