Skip to content

feat(agent-presence): identify terminal owners on the execution host (step 4B) - #24578

Closed
brennanb2025 wants to merge 37 commits into
brennanb2025/agent-presence-step3from
brennanb2025/agent-presence-uniform
Closed

brennanb2025 wants to merge 37 commits into
brennanb2025/agent-presence-step3from
brennanb2025/agent-presence-uniform

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 43 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​2649 $\color{#cf222e}{\Huge{\mathbf{−}}}$​424 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​2225
Prod 115 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​2728 $\color{#cf222e}{\Huge{\mathbf{−}}}$​936 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​1792

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:

  • An agent that sets no title, such as aider, or one started by hand, is often missing from those places.
  • An agent can vanish while it is still running, when its title changes to something that looks like a shell (for example zsh or a folder path).
  • An agent can stay on screen after it has exited, until a timer or a title change catches up.

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:

  • An agent Orca recognises by its process shows up after one process-table read following its start, with or without a title or hooks. That is about one to two seconds on a quiet machine; on a heavily loaded Mac where one read takes 6 to 17 seconds, live QA measured 6 to 18 seconds.
  • It stays while its process lives, whatever the title says.
  • It clears within about two to three seconds of exiting, whether by Ctrl+C, /exit, kill -9, or agent; 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.
  • Finding an agent no longer creates a made-up "Done" turn, an unread mark, a notification, or a plugin or automation event.

Not affected:

  • Structured Chat sessions.
  • Turn state (working, waiting for permission, done) still comes from hooks and titles, as before.
  • WSL terminals, including wsl typed into a Windows terminal, record no process owner; see Known weaknesses.
  • Native Windows records no process owner unless the opt-in setting ORCA_WINDOWS_AGENT_PRESENCE_PROOF=1 is 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:

  • Execution host: the machine that runs the terminal, meaning your computer for local terminals and the SSH host for remote ones.
  • Relay: Orca's helper program on an SSH host.
  • Terminal background process: the process that keeps local terminals alive across app restarts.
  • Process table: one ps listing of every process on the machine.
  • Command marks: invisible codes that Orca's shell setup prints when a command starts and ends.
  • Capability: a name a client and host exchange to agree that both understand a feature.

Before

On main.

  • Claude's hook script sent its own process id and start time with each event. The desktop stored that on the agent's turn record and used it to tell when Claude exited.
  • Every other agent's presence came from the terminal title and from repeated reads of what is running in the terminal, every 0.75 to 15 seconds depending on activity.
  • Exit came from a title change or a timer.

On steps 2 and 3, and this PR's first version.

  • The owner was still stored on the turn record.
  • When this PR's first version found an owner with no turn, it created a placeholder "done" turn, which plugins, notifications and unread marks could see.

After

One owner record per terminal.

  • The hook server's status store, the one store every reader already subscribes to, gets a separate owner record for each terminal. The record holds the process id and start time, the agent's name, and which terminal it belongs to.
  • Turn records never carry an owner, and owner changes never reach plugins, notifications, statistics or unread marks.
  • Readers combine the two when they publish.

Only the execution host writes owners.

  • An owner is written only by that machine's reading of what is in front of the terminal, the same way for every agent.
  • Hooks report turn state only.
  • A running owner is never replaced. A stopped one (Ctrl+Z) can be replaced by a verified agent that is in front.

When the host reads the terminal. Each read starts from evidence:

  • A command that is still running one second after its start mark.
  • A hook or a title that names an agent, at most once per agent per command.
  • An Orca launch, one second after it.
  • A terminal that survived an app restart: once at startup, and once more the first time the app meets that terminal again. At startup, all surviving terminals share one process table, and at most four are read at a time.

How later evidence is handled.

  • If a read finds an owner, later evidence for that agent only re-checks that one process.
  • If it finds nothing, the next read for the same evidence waits 5, 10, 20, 40 and then 60 seconds.
  • A new run of the agent skips that wait. A new run means a provider session the terminal has not reported among its last eight, or a hook the listener already classifies as a session start.

Process tables must be newer than the evidence.

  • A read only accepts a process table that started at or after the moment of its evidence.
  • It joins a newer ps that is already running, or waits for the running one to finish and starts the next.
  • Reads from many terminals share one ps, and two never run at once.
  • A read nobody needs any more (the command ended or the terminal closed) does not start a new ps.

One clock for start times on macOS.

  • The process table and the per-process identity check now print start times the same way: UTC, with English month and day names.
  • The table keeps the user's character set, so non-ASCII command text is never turned into escape codes. Only the character-set setting is passed on to ps; every other language setting is dropped, because one naming a language that is not installed makes ps fall back and escape that text.

How an owner ends.

  • The host re-checks the exact recorded process every two seconds. "Gone" ends the owner; "can't tell" never does.
  • Closing the terminal or tab also releases it.

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.

  • Such a hook is treated as a request to check, never as a turn.
  • With a live owner, the host checks that process.
  • With none, the exit ends the turn only when both the agent and the provider's session id match.

What readers see. Readers show a terminal that has an owner but no turn:

  • Desktop. The tab and the Chat button read the owner. The sidebar adds an owner-only row for any terminal with a live owner, including unfocused splits.
  • orca worktree ps. It gains an optional presentAgents list. No existing value changes.
  • Mobile and web. Session tabs gain a top-level agentPresence field, but only for clients that list the capability agent-process-presence.v2. Owner changes republish the tab list, so mobile, web and paired desktops see an agent appear and leave.

SSH.

  • The relay applies the same rules on the SSH host.
  • It keeps its own turn-shaped replay cache and stamps owner messages with a sequence number, so the desktop never applies an older one over a newer one.
  • When it forwards an exit hook, it settles its own cache the same way the desktop does. A reconnect therefore can no longer replay the exited agent's last "Done".

Restarts.

  • Owners live in memory only and are never saved to disk.
  • After a restart they are re-derived from the live terminals, as described above.
  • One failed admission does not stop the rest. The hook server's old startup re-check of saved owners is removed, since none are saved.

Why this design

The bug class removed.

  • With the owner stored on a turn record, as on main and steps 2 and 3, finding an agent had to create a turn, and that turn reached plugins, notifications and unread marks as a fake "Done".
  • With a separate owner record, finding an agent cannot create a turn.
  • The extra tie-breaking rules each reader needed for owner-shaped turns are gone.

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.

  • The options were reading on a fixed timer, reading in bursts, forcing a fresh process table on every read, listing every process, or reading per terminal device.
  • On a busy Mac one ps takes 7 to 17 seconds, so any of these would multiply that cost.
  • Reading once per piece of evidence, and sharing tables, keeps the cost bounded.

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).

  • WSL terminals record no owner, because the Linux guest has no Orca helper that can tie a guest process to the terminal.
  • Turn state still comes from hooks and titles. A matching exit hook still ends its turn.
  • Unlike main, the process id Claude's hook reported from inside the guest is no longer stored, because the desktop could never check it.

Temporary — native Windows (follow-up presence-windows-exit-proof).

  • The Windows check proves an owner through the terminal's process group plus console attachment.
  • It is off unless ORCA_WINDOWS_AGENT_PRESENCE_PROOF=1 is set, and it must pass a high-spec Windows QA run before it ships on.
  • Windows has no title-silent discovery.
  • Its read also needs a recognition from the existing polling that is less than one second old, so step 4C cannot retire that polling on Windows until this is fixed. This is noted in the 4C plan.

Temporary — older reader rules still run (follow-up step 4C, a separate held PR).

  • The desktop still runs the older title-based retirement and its 0.75 to 15 second polling of the terminal foreground.
  • This PR adds reads on top of them and claims no total saving.
  • Step 4C moves readers onto the owner record and removes those rules.

Intended, needs your sign-off — process tables must be newer than the evidence.

  • The approved plan said discovery uses the normal cached read and never forces a fresh process table.
  • Live QA showed that a cached table started before the command cannot see the agent.
  • Each read now needs a table that started at or after its evidence, so it may start one ps. It never runs two at once and it is shared across terminals.
  • Measured on a quiet machine with the older reader polling present, it added no ps runs. Once 4C removes that polling, each command lasting a second or more may start its own ps.

Intended — one extremely slow process-table read delays discovery. Reads share one ps and never run two at once. So when a single ps takes 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.

  • These are:
    • SSH terminals using zsh (the relay's zsh setup prints no command marks);
    • fish, sh or dash;
    • shells nested inside a terminal (bash, nix-shell, sudo -s).
  • They get no command-start read.
  • Hooks, titles and launches still trigger reads, with the 5 to 60 second wait after a miss, so a second run of the same agent is still found.
  • A title-silent hookless agent there is not found.
  • Adding command marks to those shells is a shell setup change, separate from ownership.

Intended — some session starts do not skip the wait.

  • Cursor, Amp, Copilot, Antigravity, and a Pi resume of the same session start in a way the listener does not classify as a session start. Their start does not skip the 5 to 60 second wait.
  • A provider session the terminal has not reported recently still does.
  • Adding a per-agent table of start-event names would bring back the per-agent rules this PR removes.

Intended — agents inside tmux or screen.

  • The terminal's front process is the multiplexer, so no owner is recorded.
  • Presence comes from hooks and titles, and a matching exit hook still ends its turn.
  • On main, Claude's hook-reported process id covered this case.

Intended — exit of a nested run of the same agent.

  • This applies when there is no live owner (tmux, WSL, or a missed read) and a second copy of the same agent runs inside the first and takes over the terminal's turn record.
  • That copy's exit now ends the record, where main kept the copy's stale record. The outer agent's next hook restores its turn.
  • The reason: the exit can be tied to its session, while the stale record could not be cleared at all.

Intended, needs QA — more agents' exit hooks now end turns.

  • Gemini's and droid's session-end hooks, with the same exit reasons as Claude's, now end the matching turn (same agent, same session) when there is no live owner. On main only Claude's did.
  • Whether Gemini's "other" reason ever fires while the process keeps running is not verified.

Intended — an older relay with a newer desktop.

  • The old relay sends no host-stamped owners, so remote presence falls back to title and timer rules.
  • The old relay's Claude exit hook still clears that Claude turn, through an adapter marked for removal once the minimum relay version passes this release.

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.

  • Step 3 published the owner inside the turn under the capability agent-process-presence.v1; this PR publishes it as a top-level field under agent-process-presence.v2.
  • A client or host that knows only v1 gets no owner from the other side and keeps the legacy rules, rather than silently looking for a field that is not there.
  • No released build carries either name today.

Intended — the terminal background process after an in-place update.

  • That process survives app updates by design. An older one ignores the capture request and sends no command-start signal for background terminals.
  • Terminals it serves therefore record no owner until it is replaced, and keep the title and timer rules plus the exit hook meanwhile.
  • Claude's old hook-reported process id is not available on the new build.

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 ps after 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

  • An idle shell makes zero reads.
  • Each command lasting at least one second makes one read, also on terminals that already have an owner.
  • Hook, title or launch evidence adds at most one read per agent per command. After that:
    • Evidence only re-checks the one owner process.
    • After a miss, the next read waits 5 to 60 seconds, unless a new run starts.
    • Two sessions of one agent taking turns keep that bound.
  • A terminal that survives a restart costs one read. At startup at most four are read at a time.
  • Owners cost one exact check of one process every two seconds.
  • Each read may start one ps. Reads share them, and two never run at once.
  • Measured with a model of the renderer's polling schedule on a quiet machine, over 25 commands and 60 idle seconds:
    • hidden terminal: 39 calls against main's 26 (+50%);
    • visible terminal: 113 calls against 100 (+13%);
    • ps runs: equal to main in both cases.
  • On a loaded host where ps takes 7 to 17 seconds, ps ran back to back, never overlapping, at the same order as main.

Testing

  • I manually tested these changes: live QA on a remote Mac (not this laptop) with stub agent programs, a hidden development build and an isolated home folder.
  • Automated tests added/updated

Live QA

Setup.

  • The test Mac was heavily loaded: load average 40 to 52 and about 2,000 processes, so one whole-table ps took 6 to 17 seconds, with one 99-second outlier.
  • Agents were stub programs, so no real agent account was used. They were Python scripts, plus copies of /bin/sleep renamed aider, codex and claude.
  • The final round ran on the head, f391880. Earlier rounds ran on 3589316 and on 4ca2595.

Final head (f391880).

  • Discovery: 15 of 15 runs found the right process in 6.4 to 17.7 seconds. The runs were a no-title aider, a renamed aider, a titled Claude, a no-title Codex and three renamed Codex runs, all in the same terminal. Each showed on the tab, the sidebar and orca worktree ps --json (presentAgents), with no "Done" row and no notification at discovery.
  • Clearing after Ctrl+C, /exit and kill -9: 127 to 1,547 milliseconds on the owner, tab, sidebar and presentAgents.
  • aider; sleep 30, then /exit: cleared in 1,590 milliseconds while the shell was still sleeping.
  • A title change to zsh or ~/repo kept the owner.
  • An untitled agent in an unfocused split showed in the sidebar.
  • Idle cost, counted with a ps wrapper 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.
  • Flag, accepted limit: two titled Claude starts were not discovered for more than 60 seconds while one background whole-table ps took 99.4 seconds. A single extremely slow read delays every discovery queued behind it.

Earlier round (3589316).

  • Discovery: 19 of 20 runs, in 2.4 to 15.2 seconds. The miss was an agent that exited before the first usable process table finished.
  • Exits: 16 of 16 cleared in 0.1 to 1.3 seconds. These were Claude, Codex, aider and Pi, each with Ctrl+C, /exit, kill -9 and ; sleep 30. On 4ca2595 this took 31 seconds or never happened.
  • Ctrl+Z on Claude, then starting Codex: Codex became the owner. fg made Claude the owner again.
  • Orca-launched Claude and aider were found, and cleared in about 2.5 to 3 seconds.
  • Per-process identity reads stayed at about 0 seconds under that load, with no timeouts.
  • A shell without command marks (exec bash --noprofile --norc): a no-title agent was never found, as stated under Known weaknesses.
  • SSH, to a Linux container with renamed binaries:
    • discovered at the first read, in 2 to 3.5 seconds, with the remote process id;
    • Ctrl+C and a remote kill -9 cleared it;
    • a running remote Codex stayed the owner across a disconnect and reconnect.

Same on main, not caused by this PR.

  • A hooked Claude shows "Done - Claude now" about 0.3 seconds after it starts, from its own session-start hook.
  • After aider or Pi exits, the tab icon falls back to a stale sleeping-session Codex icon. The owner itself is cleared.
  • ssh.getState() called with no argument throws.

Not covered by live QA.

  • WSL.
  • Native Windows with ORCA_WINDOWS_AGENT_PRESENCE_PROOF=1.
  • The mobile and web UI.
  • The SSH replay of an exited agent's "Done" after a reconnect; this needs a hooked agent on the remote host.
  • The Linux desktop app.

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:

  • Process table and foreground: 80 files, 964 passed.
  • Hook server and startup: 190 files, 1,821 passed.
  • Capabilities and tab projection: 200 files, 2,336 passed.
  • Relay, hooks and presence: 122 files, 1,336 passed.

macOS-only tests run the real ps:

  • Under a Chinese locale and a Shanghai time zone, the table and the identity check print the same instant.
  • With a non-installed LANG or LC_MESSAGES, non-ASCII command text survives.

Ablation-checked: each of these tests fails with its fix removed.

  • The process-table freshness bound.
  • A turn event never dropped over an owner timestamp from the same millisecond.
  • An SSH owner message never treated as a turn.
  • The relay replay never bringing back an exited agent's "Done".
  • A new run skipping the miss wait, while two interleaved sessions keep the storm bound.
  • Session start taken from the listener's classification.
  • At most four startup reads at a time, and a failed admission not stopping the rest.
  • The pinned macOS clock and character set.

Review probes. 14 of 14 probes from the first review round pass.

Typecheck and lint.

  • The node, command-line and web typechecks are clean at aa6a098.
  • Direct lint and the changed-code quality gate pass.

CI.

  • verify passed at f391880, including the mobile test-typecheck check.
  • For the other checks, see the checks on this PR.
  • Mobile tests were not run locally, because the mobile app's dependencies are not installed here. CI covers them.

Not verified.

  • WSL, native Windows with the opt-in setting on, the mobile and web UI, the Linux desktop app, and the SSH replay of "Done" with a hooked remote agent. See Live QA.
  • What Gemini's "other" exit reason means.

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):

Hand-typed aider with no title discovered: tab shows the aider icon, sidebar Aider - Idle, no Done row

Renamed native codex binary discovered: codex tab icon and Codex - Idle sidebar row

3 s after the titled Claude exits: tab icon and sidebar row cleared

3 s after /exit in the aider; sleep 30 case: owner cleared while the shell is still sleeping

Original pane focused while the sidebar still shows the unfocused split's Aider - Idle row

Earlier round (3589316):

After Ctrl+Z on claude and starting codex, codex is the owner

After /title zsh on aider, the tab keeps the aider icon

3 s after kill -9 of the aider stub, owner cleared

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):

  • Production: 112 files, +2,619 / −897.
    • Main process: +1,488 / −514.
    • Shared: +570 / −225.
    • Relay: +463 / −116.
    • Renderer: +87 / −34.
    • Mobile: +11 / −7.
    • Configuration: −1.
  • Tests: 43 files, +2,649 / −424.

Open questions for the reviewer.

  • Ratify the process-table freshness bound in place of the earlier "cached read only" rule.
  • Decide whether step 4C must land in the same merge for the stack to be honest about cost.

Agent skill upstream boundary

  • Not applicable, or this change follows docs/reference/agent-skill-sharing-upstream-boundary.md and copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.

Notes

  • Security. The host only checks processes running on its own machine. A process id sent by a client or a remote machine is never checked locally. Owner messages from SSH are accepted only from the relay that serves that terminal, and only in sequence.
  • Cross-platform. macOS and Linux share the process-table path, and on macOS it now uses one clock whatever the user's language and time zone. Native Windows is opt-in and off by default. WSL is excluded, as listed above.
  • SSH. The SSH host owns its terminals' owners. Losing the connection is never treated as the agent exiting, so the owner is kept while the host is unreachable.
  • Mobile and web. They read the new tab field only under the v2 capability. Clients that know only v1 get neither shape.
  • Backwards compatibility.
    • Every new wire field is optional, and existing reply shapes are unchanged.
    • The orca worktree ps addition is optional.
    • Owners are never saved. Turn records saved by earlier versions, which carried Claude's hook-reported process, are loaded without it.
  • Performance. See the cost bound under Known weaknesses.
  • Workspaces. Folder workspaces and git worktrees follow the same terminal and process rules.

Author: @BrennanKB5

Checklist

  • This PR is small and focused. It is not: it replaces step 2 and 3's owner-on-turn storage across the main process, relay, renderer, mobile and web readers.
  • 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).
    • The node, command-line and web typechecks are clean locally at aa6a098.
    • Named tests and the changed-code lint gate pass.
    • CI verify passed at f391880. For the full lint, test and build, see the checks on this PR.

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

Copy link
Copy Markdown
Contributor Author

Review status (head f391880, code identical to aa6a098) — still draft, not merged, not marked ready

The problem. Orca decides which coding agent is running in a terminal (tab icon, sidebar, Chat button, orca worktree ps, mobile and web) mostly by guessing from the terminal title plus a repeating check, because only Claude's hook reported its own process. Agents without a title or hooks were missed, agents vanished when their title looked like a shell, and exited agents lingered.

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, /exit, kill -9, agent; sleep 30). Finding an agent never creates a fake "Done", unread mark, notification or plugin event. WSL and native Windows (setting off) keep today's behaviour.

Fixed during review (all pushed as new commits on this branch):

  • Discovery invented a completed turn: "agent present, no turn" was stored as a placeholder Done row, so plugins received a fake done event and worktree ps, mobile and web could not see a running hookless agent. Owners now live in their own record beside turns; turn listeners never see owner changes; every presence reader reads the owner.
  • Hand-typed agents without a title were not found on a busy Mac, because the read reused a process list taken before the agent started. Reads now only accept a process list that started after the command (still at most one per command).
  • Title-triggered discovery only worked for Pi/OMP titles; it is now the same for every recognised agent.
  • A second run of the same agent in one pane, and Orca-launched agents in slow shells, were never found; an exited owner also hid the next agent. Fixed, with a bounded retry for panes without shell command markers.
  • Every hook in a pane that can never be captured (for example inside tmux) ran a full process scan; now at most one read per agent per command, then backing off.
  • Owner changes were not pushed to mobile, web or remote desktops; Claude /exit could bring back a dismissed Done; hooks from some agents were dropped after an owner exited; the sidebar missed an agent in an untitled, unfocused split; a same-millisecond update could drop a turn.
  • Claude /exit inside tmux or WSL and remote replays after an exit now clear the turn without creating a Done; surviving terminals re-derive their owner after an app restart (one shared process read).
  • macOS start-time comparison failed in non-English locales; the capability name was bumped to agent-process-presence.v2 because its meaning changed from step 3; five test suites that had gone red were repaired.

Deferred / known limits (also in the PR body):

  • Step 4C (retiring the renderer's old title/timer presence path) is still separate; until it lands, unowned panes keep the old rules.
  • Agents that start more than about a second after the command and send no title or hook, and shells without Orca's command markers (fish, SSH zsh, bash --norc), wait for the next evidence.
  • On a heavily loaded Mac, discovery waits for one process-table read (6–18 s in QA; one 99 s outlier).
  • A terminal daemon left running from an older version records no owners until it restarts; a nested run of the same agent can briefly clear the outer agent's turn in tmux/WSL.
  • Native Windows stays behind ORCA_WINDOWS_AGENT_PRESENCE_PROOF (follow-up presence-windows-exit-proof); WSL keeps today's behaviour (follow-up presence-wsl-guest-binding).

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:

  • Live QA on a remote Mac with stub agents: hand-typed discovery 15/15 with the right process id; every exit cleared in ≤1.6 s; title changes keep the owner; Ctrl+Z hand-off between agents works; Orca-launched agents pass; idle full process scans about 5/min vs main about 14/min.
  • SSH (Linux container): remote process ids discovered in 2–3.5 s, Ctrl+C and remote kill -9 clear, a running agent survives disconnect and reconnect.
  • Node, CLI and web typechecks clean; CI verify (including the mobile test typecheck) passed on this head; named unit suites green (final sweep 436 files, 6,325 tests).
  • Hand-typed aider with no title discovered: tab shows the aider icon, sidebar Aider - Idle, no Done row
  • 3 s after the titled Claude exits: tab icon and sidebar row cleared

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

Copy link
Copy Markdown
Contributor Author

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.

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