Skip to content

fix(agent-status): one owner per pane status row, so a nested agent can't relabel it (STA-9582) - #26408

Open
Jinwoo-H wants to merge 6 commits into
mainfrom
jinwoo/sta-9582-pane-owner-core
Open

Jinwoo-H wants to merge 6 commits into
mainfrom
jinwoo/sta-9582-pane-owner-core

Conversation

@Jinwoo-H

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

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 7 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​944 $\color{#cf222e}{\Huge{\mathbf{−}}}$​166 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​778
Prod 16 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​580 $\color{#cf222e}{\Huge{\mathbf{−}}}$​219 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​361

ELI5

Every terminal pane has one status row: the agent's icon, model, prompt, state, and the session Orca resumes later. Any agent started inside that pane (for example Claude running codex exec) writes to the same row, and until now nothing said whose row it was, so the last writer won, field by field. This PR gives each row exactly one owner, the agent that claimed it first. Only the owner's events change the row. Another agent's events are "guests": they leave the row alone until the owner is proven gone.

What Changed

Before (as the user sees it):

  • A Claude session showed Codex's model (gpt-6-astra) and Codex's session id after Claude ran Codex as a tool (STA-9582).
  • A Codex started detached from an idle Claude relabeled the pane as Codex, and Claude's next turn and its "done" were attributed to Codex until Codex finished.
  • On SSH, a Claude that was resumed in a pane could not end the row: its exit and the relay's "process exited" report were thrown away, because the desktop compared them against its own stale copy of the owner.

After:

  • The row keeps the owner's agent type, model, session and state while another agent reports into the pane. The owner's Stop settles the row.
  • A different agent takes the pane only when the owner is proven gone: its own exit, a process check on the machine running it that says exited, or (for an owner whose process the host cannot check, e.g. Codex today or Windows) the same freshness rule used before.
  • SSH/WSL rows end correctly when their agent exits.

Mechanism:

  • Each hook event has a producer identity: agent, session, process (when the hook reports it), and nestedIn (the agent sessions it runs inside; the field and its rules exist now, a follow-up makes hook scripts send it).
  • The owner record (agentPresence) now also stores the owner's current session and up to 4 sessions it held before (for example across Claude /clear). It is persisted and validated with the row, and old peers ignore the new fields.
  • One shared function, transitionHookPresence (src/shared/agent-hook-presence-transition.ts), sorts every hook event, first match wins: same session or process as the owner → owner; nested inside a session the owner holds → guest; same agent type → owner (as before); different type → guest, unless the owner is restored from disk, or cannot be checked and is stale or done. A producer nested in another agent's session never claims an empty pane.
  • A guest event does not write the row. If the owner has a process the host can check, the event is held (one slot per pane, the latest event wins, kept up to 30 seconds), tied to that owner, and one owner check starts. Checks run at most once per owner every 10 seconds, and nothing waits on them. Every check on a host goes through one entry point (PaneOwnerProbes.check in src/shared/agent-pane-owner-probes.ts): hook-triggered checks, the runtime's liveness checks, the relay's command-finished and reconnect checks, and the startup spool check. When any of them finds that same owner exited and releases the pane, the held event is replayed through the same rule, so it lands only if it may now take the pane. A guest held behind an owner that has since left (for example on its own clean exit) is never replayed onto a later owner. A proven nested guest needs no check and is not held.
  • The machine running the agents decides: Orca's main process for local panes, the relay for SSH/WSL. The SSH session now forwards the relay's owner record (agentPresence) to the desktop, as WSL already did; before, SSH dropped it, so a relayed exit was never adopted and saved SSH rows carried no owner. The relay forwards only the rows it builds, never guest events. The desktop adopts relayed rows as given, including their exit, and never re-decides them. A disconnect releases nothing; a replayed row gets the same verdict as the live one.
  • Terminal signals (OSC status, title- and process-derived rows) never claim and never become guests. One naming the owner's agent type, or no agent, updates the row and keeps the owner's agent type and model. One naming a different agent leaves a held owner's row unchanged (as today's identity fence did) and starts the same owner check. Once the owner is released, such a signal writes with no owner, so the next hook producer can take the pane. On a relayed pane the desktop cannot check the remote process, so there it falls back to the freshness rule, as before. An empty pane gets a row with no owner. A hook event that names no agent also keeps the owner's agent type.
  • Deleted from the host: the agent-type identity guess (resolveAgentStatusIdentity / shouldSuppressInheritedTerminalStatus in server-status-update.ts) and the Codex-only block that kept the root model and session. The owner's own events now carry its model and session forward when they omit them, which covers the same case (child hooks, a relay that restarted) for every agent; a restarted process of the same type starts clean. The desktop renderer's own identity re-guess (agent-status-event-applicator.ts, agent-status-live-entry-builder.ts) is untouched; a later cleanup removes it.
  • AGENTS.md "Agent Status" states the rule.

Why

The row had several partial owner concepts competing: a type-plus-freshness guess, a Codex-only keeper, Claude's process capture, and per-agent patches. Each fixed one field for one agent. One owner, decided once by the machine that can check the process, removes the guessing instead of adding another case. Alternatives considered:

  • Per-field patches, like the open PRs this supersedes: each one covers a single field for a single agent, and they conflict.
  • Letting the desktop re-decide relayed rows: the desktop cannot check a remote process, so two deciders could disagree. That is the root of the SSH exit drop.
  • Waiting for the owner check before accepting a guest event: that would make hook ingest asynchronous and order-sensitive. Guests are held instead.

Known limits (stated plainly):

  • Owner without a known process that quit mid-turn, then a different agent: today only Claude hooks report a process, so this covers Codex and the others. Over SSH, the new agent's status is invisible for up to 30 minutes, until the freshness window runs out. Before this change it was visible but labelled with the old agent. Locally, the renderer's shell-foreground check usually releases the pane first. Closed by 1c (every hook producer reports its process), which lands next.
  • Restart inversion: after a relay or app restart, a nested agent that reports first takes the empty pane, and the real owner's hooks become guests until the nested run stops. Closed by 1b (nested-session markers: marked producers never claim an empty pane).
  • Claude inside an idle Codex: the Claude claims the pane, and its exit leaves a Claude resume record on it. Closed by 1c.
  • Clean owner exit with a guest still running: the guest's next event takes the now-empty pane. This is today's behavior, and nothing is replayed onto the owner's resume session. Closed by 1b's marker rule.
  • Promoted guest's replay: it does not schedule the assistant-message retry or the transcript poll that a direct HTTP hook does, because those need the original request body, which only the HTTP handler has. Its row is correct; a late transcript result for that one event is not re-polled.
  • No re-check after a recent live verdict: an owner that dies within 10 seconds of a check that found it alive is not checked again until the next signal after that window. A guest event that arrives in the window is held for 30 seconds, and if no later signal starts a check in time, that event is not replayed. The guest's next event after the window handles it. No timers were added for this.
  • Renderer re-guess: the renderer still runs its own identity re-guess. A later cleanup removes it.
  • Agent that takes over when a launched agent exits: when an agent Orca launched exits, Orca resets the pane, which deletes the row of the agent that just took over unless its held event starts a new prompt. This reset happens on the base build too. Fixed in the follow-up PR.
  • Prompt cache: the listener's pane-wide prompt cache still lets a guest's prompt appear on the owner's next event. That is a separate change.

Linked Issue

STA-9582 (also STA-3754).

Visual Proof

N/A. This changes what the host stores and publishes for status rows; the automated tests below drive real hook bodies through the local hook server and through a real relay into the desktop.

Testing

  • New: server-pane-owner.test.ts (local hook server, real hook bodies):

    • the STA-9582 sequence, checking the snapshot and every pushed event;
    • an idle Claude with a live process against a detached Codex;
    • one check per owner for many guest events;
    • a guest promoted when the check finds the owner exited;
    • no replay after the owner's clean exit (resume session kept);
    • same-type sessions;
    • /clear session history;
    • signals that name no agent (OSC and hook) keeping the owner's agent type and model, locally and on a relayed pane;
    • a relayed owner the desktop cannot check yielding to a terminal signal only once it is stale;
    • terminal signals: one naming another agent leaves the owner's row unchanged and starts one check, writes after the check releases the pane, and one naming the owner's type still updates state. A released owner plus a signal naming another agent leaves the pane ownerless, and that agent's hook then owns it;
    • a guest held while a terminal signal had already started the check still takes over when that check finds the owner exited;
    • a restored owner against a nested producer;
    • a nested producer on an empty pane.
  • New: the SSH session forwarding test adopts the relay's owner and its exit (through the real SSH notification adapter).

  • New: server-pane-owner-relayed.test.ts, a real relay forwarding into the desktop's ingestRemote: the STA-9582 sequence, a nested Claude's exit inside a working Codex not ending the pane (fix(agent-status): preserve hook presence when process checks cannot answer (step 1 of 3) #23947), guest promotion on the relay, replay after a disconnect giving the live verdict, and a relayed owner with its session surviving save and restore.

  • New: unit tests for the classifier and the owner-check limiter.

  • Updated:

    • three presence tests and two remote-ingest tests that asserted the desktop's identity guess on relayed rows, or a process-less idle Codex keeping its pane; each now asserts the owner rule;
    • the Codex root-model tests, which pass unchanged through the owner carry-forward.
  • Ran: agent-hook, relay, shared, runtime and parity suites; pnpm tc; oxlint; the changed-code quality gate.

  • I manually tested these changes locally

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

Review

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

  • Cross-platform: the process check runs on macOS and Linux. On Windows no hook reports a process, so the freshness rule applies there, as before.
  • SSH: the relay decides, and the desktop adopts. The relay ships with the client build, so the two never run mismatched versions.
  • Compatibility: no new wire fields beyond optional owner-session fields inside the existing agentPresence. Old readers ignore them.
  • Performance: at most one process check per owner every 10 seconds, however many guest events arrive.

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)

…tion host

Hook events carry a producer identity (agent, session, process, nestedIn);
the pane's agentPresence records the owner and the sessions it has held.
transitionHookPresence classifies each producer as owner, guest or claim
(rules 1-5), shared by main for local panes and the relay for SSH/WSL panes.
Guests leave the row unchanged and start one owner check per owner, and
only the guest whose event proved the owner exited takes the pane. Main
adopts relayed rows as given. Deletes the agent-type identity guess and the
Codex-only root keeper on the host; the owner's sparse events carry its
model and session forward instead.
…l a held owner's row

A terminal signal (OSC, title, process-derived) that names a different agent
than a held owner now leaves the row unchanged and only starts the owner
check, matching the old identity fence. Once the check releases the pane the
next signal writes normally; same-type signals and ownerless panes are
unchanged. Main's pane presence decision moves to server-pane-presence.ts.
- One held event per pane, replayed on any exited verdict; replay re-runs the
  rule, so classification and the check's row-identity guard gate it. Fixes a
  lost handover when a terminal- or same-type-started check put the guest's own
  check in cooldown.
- transitionHookPresence returns write or skip; probe on skip means hold and
  check, set only for a live unproven guest whose owner has a process.
- PaneOwnerProbes.admit applies a transition for both main and the relay;
  the relay's checkPresence gate is gone.
- The terminal fence moves to terminal ingest as a predicate; a released
  owner plus a signal naming another agent leaves the pane ownerless.
- One currentOwner(row); owner fields never carry to a restarted process.
- The relay's loopback bind moves to agent-hook-loopback-listener.ts.
@coderabbitai

coderabbitai Bot commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 2013c747-de0d-492c-b7de-3f2eccf409f0
📥 Commits

Reviewing files that changed from the base of the PR and between 423f4a8 and f634863.

📒 Files selected for processing (3)
  • src/main/agent-hooks/server-pane-owner-relayed.test.ts
  • src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts
  • src/main/ssh/ssh-relay-session.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 4 remain after this review.


📝 Walkthrough

Walkthrough

Hook events now use pane ownership state to classify owner, nested, and guest producers. Local and relay paths use owner-process probes to decide whether held guest events can be applied. Presence records include normalized session history. Relayed rows use a separate adoption path. Terminal signals can preserve, release, or transfer ownership based on the current owner and process verdict.

Priority: ⬇️ Low

Merge Risk: 🟡 Moderate · up to f6348

A restarted agent may temporarily disappear from its pane status, and a failed owner check may interrupt relay status reporting. Resolve or explicitly accept these risks before merging.

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 28.13% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 32 functions across 21 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ⚠️ Warning The description is detailed and covers the required change, rationale, testing, visual proof, compatibility, and checklist sections. The Linked Issue section names STA-9582 and STA-3754 but does not p… Add a direct link to the relevant issue in the Linked Issue section, such as the repository's issue URL for STA-9582.
✅ Passed checks (3 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly identifies the main change: enforcing one owner per pane status row so nested agents cannot relabel it.
Full details: Description check

Explanation

The description is detailed and covers the required change, rationale, testing, visual proof, compatibility, and checklist sections. The Linked Issue section names STA-9582 and STA-3754 but does not provide an actual issue link, which the template explicitly requires.

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 2
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR
🛠️ Fix failing CI checks 💡
  • Commit to this branch
  • Create a new PR
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2


ℹ️ Review info
⚙️ Run configuration
  • Configuration used: Repository UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: d7b6b407-85b2-4ce7-b260-710353aaf094
📥 Commits

Reviewing files that changed from the base of the PR and between 4f4f021 and eb4e45a.

📒 Files selected for processing (21)
  • AGENTS.md
  • src/main/agent-hooks/server-agent-presence.test.ts
  • src/main/agent-hooks/server-ingest-remote.test.ts
  • src/main/agent-hooks/server-pane-owner-relayed.test.ts
  • src/main/agent-hooks/server-pane-owner.test.ts
  • src/main/agent-hooks/server/server-agent-presence.ts
  • src/main/agent-hooks/server/server-ingest-terminal.ts
  • src/main/agent-hooks/server/server-lifecycle.ts
  • src/main/agent-hooks/server/server-state.ts
  • src/main/agent-hooks/server/server-status-update.ts
  • src/relay/agent-hook-event-admission.ts
  • src/relay/agent-hook-loopback-listener.ts
  • src/relay/agent-hook-server.ts
  • src/relay/relay-agent-presence.ts
  • src/shared/agent-hook-listener/listener-event.ts
  • src/shared/agent-hook-presence-transition.test.ts
  • src/shared/agent-hook-presence-transition.ts
  • src/shared/agent-pane-owner-probes.test.ts
  • src/shared/agent-pane-owner-probes.ts
  • src/shared/agent-process-presence.ts
  • src/shared/agent-session-resume.ts
💤 Files with no reviewable changes (2)
  • src/main/agent-hooks/server/server-state.ts
  • src/main/agent-hooks/server/server-lifecycle.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 2 remain after this review.

Comment on lines +43 to +47
const written = host.write(transition.event)
// Why after the write: the host's check is bound to the row it reads, so it must read this one.
if (transition.probe) {
this.probe(paneKey, transition.probe)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Hold the written event when a write also probes the owner.

A write with a probe happens when a different process of the owner's type reports into the pane. Example: Claude process A crashes without SessionEnd, and Claude process B starts in the same pane. transitionHookPresence classifies B as owner and writes B's event under A's presence. It also returns probe: A. If the check returns exited, the host check ends the pane. Main calls reconcileEndedProcessForPaneKeys. The relay publishes AgentProcessExit. This removes the working row that B just wrote. admit holds nothing on the write path, so no event is replayed. B gets the pane back only on its next hook. An idle B therefore stays invisible until the user prompts it again.

Register host.reapply in held on this path too. After the exit, the replay runs through the classifier again. The owner is now ended and sameProcess(ended.process, B) is false, so B claims the pane immediately.

Proposed fix
--- "a/src/shared/agent-pane-owner-probes.ts"
+++ "b/src/shared/agent-pane-owner-probes.ts"
@@ -40,11 +40,13 @@
       }
       return undefined
     }
     const written = host.write(transition.event)
     // Why after the write: the host's check is bound to the row it reads, so it must read this one.
     if (transition.probe) {
+      // Why: an exited verdict ends the row this write just produced; replay lets its producer claim it.
+      this.held.set(paneKey, { heldAt: this.now(), reapply: host.reapply })
       this.probe(paneKey, transition.probe)
     }
     return written
   }
 
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const written = host.write(transition.event)
// Why after the write: the host's check is bound to the row it reads, so it must read this one.
if (transition.probe) {
this.probe(paneKey, transition.probe)
}
const written = host.write(transition.event)
// Why after the write: the host's check is bound to the row it reads, so it must read this one.
if (transition.probe) {
// Why: an exited verdict ends the row this write just produced; replay lets its producer claim it.
this.held.set(paneKey, { heldAt: this.now(), reapply: host.reapply })
this.probe(paneKey, transition.probe)
}

Comment thread src/shared/agent-pane-owner-probes.ts Outdated
Comment on lines +59 to +69
void this.deps.checkOwner(paneKey).then((verdict) => {
const held = this.held.get(paneKey)
if (verdict !== 'exited' || !held) {
return
}
this.held.delete(paneKey)
// Why: replay re-classifies against the released row, so it only lands if it may claim it.
if (this.now() - held.heldAt <= HELD_GUEST_WINDOW_MS) {
held.reapply()
}
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Catch rejections from the discarded owner-check chain.

void this.deps.checkOwner(paneKey).then(...) has no rejection handler. This chain runs host code that can throw:

  • Main: reconcileEndedProcessForPaneKeys inside checkAgentPresence.
  • Relay: publish → applyEvent inside RelayAgentPresence.check.
  • Both hosts: held.reapply(), which re-enters full admission and writes the row.

If any of these throws, the rejection is unhandled. The manifest declares Node 24. Since Node 15, Node terminates the process on an unhandled rejection by default. The relay is a standalone Node process, so a single bad replay can stop status reporting for every remote pane. Add a .catch that logs the error.

Proposed fix
--- "a/src/shared/agent-pane-owner-probes.ts"
+++ "b/src/shared/agent-pane-owner-probes.ts"
@@ -56,17 +56,23 @@
       return
     }
     this.probes.set(paneKey, { owner: key, startedAt: now })
-    void this.deps.checkOwner(paneKey).then((verdict) => {
-      const held = this.held.get(paneKey)
-      if (verdict !== 'exited' || !held) {
-        return
-      }
-      this.held.delete(paneKey)
-      // Why: replay re-classifies against the released row, so it only lands if it may claim it.
-      if (this.now() - held.heldAt <= HELD_GUEST_WINDOW_MS) {
-        held.reapply()
-      }
-    })
+    void this.deps
+      .checkOwner(paneKey)
+      .then((verdict) => {
+        const held = this.held.get(paneKey)
+        if (verdict !== 'exited' || !held) {
+          return
+        }
+        this.held.delete(paneKey)
+        // Why: replay re-classifies against the released row, so it only lands if it may claim it.
+        if (this.now() - held.heldAt <= HELD_GUEST_WINDOW_MS) {
+          held.reapply()
+        }
+      })
+      .catch((error: unknown) => {
+        // Why: ingest never awaits this chain, so a throw here must not become an unhandled rejection.
+        console.error('[pane-owner-probes] owner check failed', error)
+      })
   }
 
   private sweep(now: number): void {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
void this.deps.checkOwner(paneKey).then((verdict) => {
const held = this.held.get(paneKey)
if (verdict !== 'exited' || !held) {
return
}
this.held.delete(paneKey)
// Why: replay re-classifies against the released row, so it only lands if it may claim it.
if (this.now() - held.heldAt <= HELD_GUEST_WINDOW_MS) {
held.reapply()
}
})
void this.deps
.checkOwner(paneKey)
.then((verdict) => {
const held = this.held.get(paneKey)
if (verdict !== 'exited' || !held) {
return
}
this.held.delete(paneKey)
// Why: replay re-classifies against the released row, so it only lands if it may claim it.
if (this.now() - held.heldAt <= HELD_GUEST_WINDOW_MS) {
held.reapply()
}
})
.catch((error: unknown) => {
// Why: ingest never awaits this chain, so a throw here must not become an unhandled rejection.
console.error('[pane-owner-probes] owner check failed', error)
})

Source: Learnings

…surface

- An event naming no agent (OSC without agentType, a hook with 'unknown')
  writes under the owner's agent type and keeps its model.
- classifyTerminalSignal replaces the two terminal helpers; a relayed owner
  main cannot check falls back to freshness instead of blocking forever.
- A held guest is tied to the owner it waited behind and replays only when
  that owner is found exited.
- Every host check (runtime liveness, relay command-finished and reconnect,
  startup spool) goes through PaneOwnerProbes.check; the raw probe is private.
- Relay publish reports whether the exit was applied, so exited always
  means released.
The SSH notification adapter copied a fixed field list into ingestRemote and
left out agentPresence, so a relayed exit was never adopted over SSH and saved
SSH rows had no owner or session. WSL already passed it through.

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