Repository navigation
feat(agent-presence): identify terminal owners on the execution host (step 4B) - #24578
brennanb2025 wants to merge 37 commits into
Conversation
…urn rows An agent process with no turn was stored as a placeholder Done row, so every turn consumer had to filter it; plugins did not (fake Done), and the filters hid the owner from worktree ps, mobile and paired clients. The hook server now keeps owners in their own memory-only map. Turn rows never store an owner; publication joins it for the window and snapshots, and an owner with no turn goes out as the existing presence-only envelope. Turn subscribers (plugins, stats, notifications) never see owner writes. Runtime, session tabs (top-level agentPresence for capable clients), mobile, the web mirror, the sidebar and worktree ps (additive presentAgents) read the owner. Every reattach re-derives a surviving owner with one read. Hydration strips owners from older rows and drops owner-only placeholder rows. Relay keeps its row-shaped replay cache with one shared admission decision; it keeps resume identity on discovery. A stopped (Ctrl-Z) owner may yield to a verified foreground agent. An older relay's unstamped exit claim may clear the turn it names (version adapter) but never mints one.
…every agent Hook evidence ran one whole-machine foreground read per hook on a pane that never admits an owner (tmux), title evidence fired only for Pi-compatible titles, and an Orca launch's spawn-time read used up the launch command's own command-start slot, so a slow shell never had its launched agent read. The shared command observer now keys evidence by command generation and agent: each agent named by a hook, a title or a launch costs at most one read per shell command, and a command-start after the read ran begins the next command. Titles use the agent-neutral title recognizer. A launch is evidence with its own delayed read, so the launch command's command-start still reads. A command-start now doubts a recorded owner (it proves the owner is not in front), which lets a stopped owner yield. The discovery mixin is typed (no @ts-nocheck); the hook recheck it calls moves into it. The model cost test is replaced by production runtime bounds.
With hooks no longer carrying ownership, the branch only rewrote the pane's last row as a fresh turn: a /exit brought back a dismissed Done, and with no row it minted a new one. Claude's SessionEnd now normalizes like any other hook (to nothing), and the host's exact process check ends the owner.
The default-off Windows adapter admitted any member of the pane's job, so a detached Start-Process agent in another window became the pane's owner. The job proves lifetime only; capture now also requires the candidate to be attached to this terminal's console, read once per capture rather than per poll, and refuses when that read is unavailable.
… than its evidence On a loaded Mac the whole-table ps takes 7-17 s, so the read one second after a command-start was served by a cached or still-running capture that began before the agent existed, and a hand-typed agent with no title was never found. A discovery read now carries the time of the evidence it answers (command start, title, hook, launch, reattach). The resolver accepts only a table that began at or after that time: it joins a qualifying capture, otherwise waits out the running one and starts the next, so concurrent waiters share one ps and there is still at most one read per command. The bound crosses to the daemon as an optional field; an older daemon answers from its cache as before. A newer command's read no longer joins one still waiting for an older command.
Give the Pi session fixture its key, pass owner records to the keyboard presence check (an SSH owner now stands for another host, since WSL panes no longer get owners), type the observer mock with its real callback, and type the mirrored surface's owner so the snapshot fixture matches the client tab.
Only a command-start or command-end reset an agent's evidence claim, so in a pane without OSC 133 marks (fish, a nested bash, nix-shell, sudo -s) each agent bought one read for the pane's whole life: a second run of the same agent was never read, and an Orca launch whose early read missed used up the agent's claim. A claim now records what its read found. A key whose read found the pane owned re-checks on new evidence, which costs no process read while that owner lives and reads once it has ended. A key whose read found nothing waits out a doubling delay (5 s to 60 s) before evidence may buy another, so a hook storm on a pane that can never be captured stays a handful of reads. A launch reads under its own key and no longer uses up the agent's.
…process table A read waiting for a table newer than its evidence kept waiting after its command ended, its pane closed or its controller changed, then started a whole-table ps nobody wanted. In-process reads (local PTYs and the relay) now carry whether they are still wanted; a waiter that is no longer wanted starts no new table and gives up. Daemon-hosted reads cannot see main's command state, so they still finish their one table; the reply is fenced and dropped.
An owner with no turn (a hookless agent, a dismissed turn) changes no status row, so session.tabs was never republished: mobile, paired web and a desktop attached to a remote runtime never saw it appear or exit. The one owner mutation point now notifies owner-change subscribers, and the session-tabs republisher listens to them as it does to row mutations.
An exited owner stayed recorded and was joined onto every later turn in the pane, so a second agent's turns carried the first agent's ended presence: the tab, Chat, mobile and the send guard hid the live agent, worktree ps labelled its rows with the exited agent, and legacy shell-foreground cleanup refused the pane. A live (non-replayed) turn written after the exit now releases the exited owner's record, so nothing labels that turn and discovery can admit the next process; a replay keeps the record to suppress the old run's history. Legacy shell evidence no longer treats an exited owner as live.
The renderer applied presence from each status event and dropped the whole event when the owner record held a newer stamp. With owners stamped on their own clock, a turn written in the same millisecond as an owner change was lost. A stale owner stamp now skips only the owner part of the event.
Title-derived sidebar rows exist only for titled panes or a tab's active pane, so a hookless owner in an unfocused, untitled split never appeared. Every pane with a live recorded owner that no other row covers now gets an idle owner row.
The host publishes the pane owner as top-level tab.agentPresence; the fixture still nested it in agentStatus, which the mobile readers no longer read.
When the relay admitted an owner over a Pi pane, the forwarded envelope still carried the cached Pi resume identity, so the desktop took it as a valid Pi session row: it went through the turn path (plugins heard agent.status.changed) and skipped the owner fence. Owner envelopes now drop resume identity, which the desktop already holds from the Pi session itself.
…o owner can be checked With the Claude-only SessionEnd branch gone, a /exit in tmux or a WSL pane no longer cleared the agent's turn: main did that through the hook's process id, and there is no host-checkable owner there. A session-end hook that its provider maps to no turn state, for a reason that ends the process, is now an exit claim and never a row. When the pane has a live owner, the claim only triggers the exact check. Otherwise it ends the pane's turn if that turn belongs to the same agent and provider session, and keeps resume identity, as main does. It never mints, revives or re-dates a turn. The relay forwards claims without caching them; an older relay's hook-claimed exit takes the same path.
… session inventory Owners live only in memory and were re-derived only when a pane reattached, so a terminal that survived a restart in a worktree not yet opened had no owner for mobile, worktree ps or the sidebar. A surviving terminal the session inventory meets for the first time now re-derives its owner with one read; reattach and inventory share one entry, so each surviving terminal reads once. The reads share the reader's process table.
…aemon lists its sessions Owners live only in memory, so after a restart a terminal in a worktree not yet opened had no owner until a pane reattached or an inventory met it: the first worktree ps or mobile answer and the sidebar lacked it. Once the daemon lists its surviving sessions at startup, each one still bound to a saved pane (same incarnation, not WSL) re-derives its owner. Every read shares one process table, and nothing else changes: no terminal records, liveness verdicts, handle adoption or foreground refresh.
A remote agent that exits with no owner on the SSH host (tmux, every WSL pane) sends an exit claim. The desktop ended the matching turn, but the relay kept the last Done row in its replay cache, so each reconnect or desktop restart replayed that Done and a dismissal never stuck. The relay now applies the desktop's rule to its own cache: when the cached turn belongs to the claim's agent and provider session, it keeps only the resume identity, so nothing replayable as a turn remains.
…ked off In a terminal without command marks, an agent whose reads kept missing (tmux hid it, or an identity read timed out) waits up to 60 s before its evidence buys another read. A new run of the same agent inherited that wait, so its owner could stay missing for a minute, or until its next prompt if it sat idle after starting. Hook evidence now says which run it belongs to: its provider session, and whether it is a session start. A session start or a session the key has not seen clears the wait; repeats of one run keep the doubling bound, so a hook storm still costs a handful of reads. The relay passes the same run evidence.
…artup Startup re-derivation captured every surviving terminal at once. The process table is shared, but on macOS each recognised agent then runs two identity reads, so many surviving agents forked many `ps -p` at boot, each with a one-second timeout. It now uses the same bound as the hook server's boot owner probes, shared as one constant, and both use the shared concurrency helper.
…w calls The owner record refactor made the main-window status listener register an owner listener, the status snapshot IPC join owners, and a reattach register its terminal as reattached. Three suites mocked the old surface and failed: their hook-server mocks lacked setAgentOwnerListener and getAgentOwners, and one expectation predated the reattached flag.
The shared concurrency helper takes a void callback; the boot recheck returned the check's verdict, which the node typecheck rejected.
…s not seen Two sessions of one agent interleaving on a pane that can never be captured (a background nested run inside tmux) reset the miss backoff on every switch, because the claim compared against the last session only: up to five whole-table reads a second. A claim now remembers its last eight sessions and resets only for one outside them. A run start is now the listener's normalized session boundary rather than the literal 'SessionStart' event name, so it means the same thing for every agent the listener classifies.
…dmit Startup re-derivation runs a few sessions at a time; an admit that threw ended its worker, so the sessions queued behind it were never re-derived. Each admit failure is now logged and the worker moves on.
…ntity read's clock On macOS, capture compares the process table's lstart with the identity read's lstart. The identity read pins TZ=UTC0 and the C locale; the table used the user's zone and locale. Under zh_CN (and other locales) the table's date does not parse, so no owner was ever recorded; under a non-UTC zone the two instants differed. Both table captures (full and cheap, whose fingerprints are compared) now pin TZ=UTC0 and LC_TIME=C, keeping the caller's character set so non-ASCII command text is not rewritten as escapes, which LC_ALL=C did. Capture compares both sides as UTC instants.
Step 3 publishes a terminal's owner inside `agentStatus` to clients that list 'agent-process-presence.v1'; this change publishes it as a top-level `agentPresence` under the same name. If a step-3 build reached users first, each side would silently miss the other's field. The capability is now 'agent-process-presence.v2'. A client that knows only v1 gets no presence from this host, and a current client asks a v1 host for nothing, so both fall back to the legacy projection.
…g to check Owners are memory-only and never hydrated, so the hook server's boot recheck walked the hydrated turn rows and found no owner on any of them. It and its re-arm of the recheck beat are removed; an owner written later still arms the beat through the owner write. The startup re-derivation keeps its own concurrency bound, now beside it. The hook capture test is renamed for what it checks (managed hook scripts no longer inspect processes), and the CLI typecheck no longer lists the deleted hook capture module.
…cale is not installed The process table pinned its clock by dropping LC_ALL and setting LC_TIME, which left LANG and the other LC_* variables in charge. If one of them names a locale macOS does not have (a Linux-spelled LANG such as en_US.utf8, or a bogus LC_MESSAGES), ps falls back to C and prints non-ASCII command text as escapes. The table now runs with only LC_CTYPE (from LC_ALL, else LC_CTYPE, else LANG), LC_TIME=C and TZ=UTC0; every other locale variable is removed.
…wner can be cleared The fixture declared its owner as `AgentProcessPresence | undefined` but assigned an object, so the returned tab's owner narrowed to always present and the old-host case's `agentPresence = undefined` no longer typechecked under the mobile tests config. The fixture now returns the tab type the readers take, where the owner is optional.
Review status (head f391880, code identical to aa6a098) — still draft, not merged, not marked readyThe problem. Orca decides which coding agent is running in a terminal (tab icon, sidebar, Chat button, What users get with this PR (local macOS/Linux and SSH terminals). Orca's own host records the agent process in front of the terminal, the same way for every agent, launched or hand-typed. The agent stays shown while its process lives whatever the title says, and clears within about 1–2 seconds of exiting (Ctrl+C, Fixed during review (all pushed as new commits on this branch):
Deferred / known limits (also in the PR body):
Needs a decision: the approved design said discovery uses a normal cached read and never forces a fresh process list. Live QA showed that a cached list on a busy Mac predates the agent, so the read now requires a list started after the command. Cost stays bounded (at most one per command; idle shells still cost nothing extra). Please confirm or reject this change. Verified:
Not verified: WSL; native Windows with the setting on; the mobile and web UI on a device; an SSH reconnect replaying a hooked agent's Done; the Linux desktop app. Seen in QA but not caused by this PR (same on main): a hooked Claude shows "Done – Claude now" right after starting, from its own session-start hook; after aider or Pi exits, the tab icon can fall back to a stale Codex icon. |
…3' into brennanb2025/agent-presence-uniform # Conflicts: # src/main/daemon/daemon-pty-router.ts # src/main/orcad/orcad-entry.ts
…derivation after main's Phase 3 revert
…2025/agent-presence-uniform
…on.ts to stay under max-lines
|
Close this older combined presence stack while preserving the work to recognize agents by the process running on their own host. The current server and terminal background process need to own that record. |


What this fixes
Orca shows which coding agent is running in a terminal on the tab icon, in the sidebar, on the terminal's Chat button, in
orca worktree ps, and in the mobile and web apps. Today only Claude reports its own process to Orca, through a small script Claude runs on every event (a "hook"). Every other agent is found by guessing from the terminal title and from a repeating check of what is running in the terminal.What users see today:
zshor a folder path).Who hits it: anyone who runs a terminal agent other than Claude, or Claude without Orca's hooks, in a local macOS or Linux terminal or in a terminal on an SSH host.
What this PR changes for users, on those terminals:
/exit,kill -9, oragent; sleep 30. Live QA measured 0.1 to 1.6 seconds for hand-typed agents and about 2.5 to 3 seconds for Orca-launched ones.Not affected:
wsltyped into a Windows terminal, record no process owner; see Known weaknesses.ORCA_WINDOWS_AGENT_PRESENCE_PROOF=1is present, so it keeps today's title and polling rules.Live status: live QA passed on the head, f391880 (code identical to aa6a098), on a remote, heavily loaded Mac using stub agents. Hand-typed agents were found in 15 of 15 runs, exits cleared in at most 1.6 seconds, and SSH discovery, clearing and reconnect passed. The first live run, on 4ca2595, had failed because a hand-typed agent without a title was never found; that is fixed. Details are under Testing.
This is step 4B. It is stacked on step 3 (#24184) and step 2 (#24175), which are both held. It also includes the separation, from #24322, between taking away a terminal's authority and removing its status row; that branch itself was not edited.
ELI5
Orca used to work out which agent was in a terminal by reading the terminal's name tag, and only Claude ever said "it's me" directly. Now the computer that runs the terminal looks at which program is actually in front, writes that program down as the terminal's owner, and checks every two seconds whether that exact program is still alive. The agent's own messages still say whether it is working or done, but they can no longer decide who owns the terminal.
What Changed (before and after)
Words used below:
pslisting of every process on the machine.Before
On main.
On steps 2 and 3, and this PR's first version.
After
One owner record per terminal.
Only the execution host writes owners.
When the host reads the terminal. Each read starts from evidence:
How later evidence is handled.
Process tables must be newer than the evidence.
psthat is already running, or waits for the running one to finish and starts the next.ps, and two never run at once.ps.One clock for start times on macOS.
ps; every other language setting is dropped, because one naming a language that is not installed makespsfall back and escape that text.How an owner ends.
Exit hooks. Some session-end hooks carry an exit reason but name no turn state. Claude's do, and so do Gemini's and droid's.
What readers see. Readers show a terminal that has an owner but no turn:
orca worktree ps. It gains an optionalpresentAgentslist. No existing value changes.agentPresencefield, but only for clients that list the capabilityagent-process-presence.v2. Owner changes republish the tab list, so mobile, web and paired desktops see an agent appear and leave.SSH.
Restarts.
Why this design
The bug class removed.
Rejected: let each agent's hook report its process (main's approach). Only Claude's hook can do it. A nested agent's hook can claim the outer terminal, and every agent would need its own rule.
Rejected: keep the owner on the turn record (steps 2 and 3). That needs the placeholder turn described above.
Rejected: titles and timers. They cannot prove a process is alive. Titles change while the agent keeps running, and timers clear agents that are still alive.
Rejected: more scanning.
pstakes 7 to 17 seconds, so any of these would multiply that cost.Rejected: saving owners across restarts. Process ids are reused, so a saved owner could point at an unrelated process. Re-deriving from the live terminal is the safe source.
Rejected: a per-agent list of "session started" event names. A run start comes from the hook listener's existing session-start classification, so the rule is the same for every agent the listener understands.
Known weaknesses
Each item is labelled intended (safe to keep, with the reason) or temporary (with the follow-up that removes it).
Temporary — WSL (follow-up
presence-wsl-guest-binding).Temporary — native Windows (follow-up
presence-windows-exit-proof).ORCA_WINDOWS_AGENT_PRESENCE_PROOF=1is set, and it must pass a high-spec Windows QA run before it ships on.Temporary — older reader rules still run (follow-up step 4C, a separate held PR).
Intended, needs your sign-off — process tables must be newer than the evidence.
ps. It never runs two at once and it is shared across terminals.psruns. Once 4C removes that polling, each command lasting a second or more may start its ownps.Intended — one extremely slow process-table read delays discovery. Reads share one
psand never run two at once. So when a singlepstakes very long, every discovery queued behind it waits. Live QA saw one 99-second read delay two Claude starts by more than 60 seconds on a Mac with load average around 50. Running reads in parallel would multiply the load that made the read slow.Intended — late silent starts. An agent that starts more than about one second after its command starts, and sends no title or hook, waits for the next evidence (a title, a hook, or the next command). Reading again later would mean timed polling, which this design avoids.
Intended — shells without command marks.
bash,nix-shell,sudo -s).Intended — some session starts do not skip the wait.
Intended — agents inside tmux or screen.
Intended — exit of a nested run of the same agent.
Intended, needs QA — more agents' exit hooks now end turns.
Intended — an older relay with a newer desktop.
Intended — a newer relay with an older desktop. The older desktop ignores the new owner messages, as main does today.
Intended — clients built from step 3.
agent-process-presence.v1; this PR publishes it as a top-level field underagent-process-presence.v2.Intended — the terminal background process after an in-place update.
Intended — reads inside the terminal background process cannot be cancelled. That process cannot see the app's cancellation. The cost is at most one extra
psafter the last command, and the stale answer is discarded.Intended — macOS process start times have one-second precision. This is unchanged from steps 2 and 3. A process id reused within the same second could match.
Cost bound
ps. Reads share them, and two never run at once.psruns: equal to main in both cases.pstakes 7 to 17 seconds,psran back to back, never overlapping, at the same order as main.Testing
Live QA
Setup.
pstook 6 to 17 seconds, with one 99-second outlier./bin/sleeprenamedaider,codexandclaude.Final head (f391880).
orca worktree ps --json(presentAgents), with no "Done" row and no notification at discovery./exitandkill -9: 127 to 1,547 milliseconds on the owner, tab, sidebar andpresentAgents.aider; sleep 30, then/exit: cleared in 1,590 milliseconds while the shell was still sleeping.zshor~/repokept the owner.pswrapper placed first on the app's search path, over a fresh terminal with command marks for 120 seconds: about 5 whole-table reads per minute on this PR against about 14 on main. Typing commands added no whole-table reads.pstook 99.4 seconds. A single extremely slow read delays every discovery queued behind it.Earlier round (3589316).
/exit,kill -9and; sleep 30. On 4ca2595 this took 31 seconds or never happened.fgmade Claude the owner again.exec bash --noprofile --norc): a no-title agent was never found, as stated under Known weaknesses.kill -9cleared it;Same on main, not caused by this PR.
ssh.getState()called with no argument throws.Not covered by live QA.
ORCA_WINDOWS_AGENT_PRESENCE_PROOF=1.Safety. The test machine's real agent configuration was unchanged every round, by hash. No credentials were copied.
Automated
All runs are named Vitest runs on macOS with mocks and fake clocks. No app, real agent CLI, or login shell was launched. Real-CLI, real-binary and integration suites were excluded.
Final run at aa6a098 (the head's code; the head, f391880, adds only a mobile test typing fix). It covers every test file that imports a production file changed since step 3, plus the runtime harness: 436 files, 6,325 tests passed, 26 skipped, 0 failed.
Runs on the commits that changed each area:
macOS-only tests run the real
ps:LANGorLC_MESSAGES, non-ASCII command text survives.Ablation-checked: each of these tests fails with its fix removed.
Review probes. 14 of 14 probes from the first review round pass.
Typecheck and lint.
CI.
verifypassed at f391880, including the mobile test-typecheck check.Not verified.
Linked Issue
Partly helps STA-8819 / #23638. Does not claim a full ticket closure: STA-8862 / #23753 also concerns restart recovery, STA-7949 / #22727 concerns turn state, and STA-8902 is unrelated.
Visual Proof
Screenshots from the hidden development build on the remote Mac, final head (f391880):
Earlier round (3589316):
Review
Draft only. Keep the lower stack held. Do not merge or mark ready.
Size against step 3 (fc4d138 to the head, f391880, counted with git; the table at the top uses its own classifier):
Open questions for the reviewer.
Agent skill upstream boundary
docs/reference/agent-skill-sharing-upstream-boundary.mdand copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.Notes
orca worktree psaddition is optional.Author: @BrennanKB5
Checklist
N/Awith reasonpnpm lint,pnpm typecheck,pnpm test, andpnpm buildpass (or CI will cover; local preferred).verifypassed at f391880. For the full lint, test and build, see the checks on this PR.