Repository navigation
fix(agent-status): preserve hook presence when process checks cannot answer (step 1 of 3) - #23947
Conversation
…osing real ones - Pin the macOS process start time to UTC on both the hook and the host so a shell TZ or a time-zone change cannot turn a live Claude into an exit. - An unanswered process check falls back to the foreground confirmation, so Codex, SSH and Windows panes still leave the agent state on a real exit and the Codex late-completion recovery still runs. - A nested agent that inherits the pane key cannot take over the pane's presence; a retired session is replaced by the next session even when its SessionStart was lost. - Drop the unused Windows process read (no Windows hook captures an identity yet) so shared code no longer imports main-process modules. - Skip the capture outside Orca panes, gate relay re-checks to real title changes, and list the capture module in the CLI project.
… its session Presence now exists only when a hook carries the agent's process identity, and only that process's evidence changes it: its SessionEnd ends the pane, its /clear and /resume keep it, and hooks from any other process (a nested agent inheriting the pane key) update status without taking ownership. Hooks without an identity (Windows, sessions started before the capture) behave exactly as before, so a nested agent can no longer end a pane it does not own. An ended owner stops answering 'exited', and the host probes the owner only when another process reports in the pane.
- Relay retries and transcript polls schedule against the row the relay cached, and identity-less events pass through the transition unchanged, so SSH Grok replies and Codex transcript polls deliver again. - A live process check restores the runtime's agent status and releases queued orchestration mail, like a foreground read that finds the agent. - An answered foreground read naming a non-agent (wsl.exe, tmux) is still an exit; only silence is not. - A suspended (Ctrl-Z) agent is unverifiable, not live, so the foreground read decides as before. - An agent of another type started mid-turn cannot own or end the pane. - Replayed spool hooks check each pane once, and SessionEnd ends presence only for reasons that end the process.
… process id Ownership is now decided only by comparing the recorded owner with the sender, never from the row's agent type or turn state (which identity resolution rewrites). The first agent hook in an empty pane claims it; a live owner keeps it against any other agent (a nested claude -p, a Codex started inside Claude or the reverse); only the owner's own proven process can end it. An owner no hook identified never ends from a hook and is not probed, so those panes behave as before.
… again Only an admitted exit (Claude's process-ending SessionEnd, or a host-proved exit) is marked ended on the event, and ownership keys on that marker, not the hook name. Devin, Qoder, CodeBuddy and Copilot SessionEnd hooks are ordinary status updates again, locally and through the relay.
Review status: 5 review rounds + live QA complete (head 721de1c, still draft)The problem. Orca sometimes decided a terminal agent had exited when it really just couldn't check (a failed or slow "which program is in front of this terminal" read, common over SSH or under load): the terminal-backed Chat view dropped to a bare terminal and the sidebar row could be deleted while Claude was still running. The opposite also happened: a real Claude What changes for users (step 1 of 3). For panes whose Claude reported its own process (Claude on macOS/Linux, locally or through an updated SSH relay), an unanswered check is never treated as an exit, so Chat and the row stay while Claude runs. When Claude really exits (its own SessionEnd with an exit reason, or its process proven gone), the machine running Claude marks it ended in the status store immediately, even headless. The desktop sidebar still clears through its existing path (when the shell prompt returns, about 0.4 s after How the design changed during review. The original PR decided presence per Claude session id. Review showed that model kept producing traps (a nested Fixed in this review
Deferred (disclosed in the PR body)
Verified
Not verified
This PR is not merged and not marked ready; that is the coordinator's call. |
…nd-finished cleanup The renderer's command-finished cleanup kept every row when its shell check could not answer, which left Codex, hookless-agent and old-relay rows over SSH showing done after a real exit. It now asks the host whether the pane's agent process can be checked: only a pane with an identified, running owner keeps its row on an unanswered check; every other pane drops it exactly as before. The drop stays armed while the host answers, so a new command still cancels it, and a missing or failing answer (web client, older host) keeps today's behaviour.
…t confirmation The title-driven exit confirmation applied 'silence is never an exit' to every pane. It now applies only when the pane has an identified owner whose process cannot be checked right now; a pane with no process identity (Codex, hookless agents, Windows, old relays, sessions started before the update, or an owner that already ended) confirms exits exactly as before.
|
| : undefined | ||
| } | ||
| // Why: nested agents inherit ORCA_PANE_KEY; their hooks update status, never ownership. | ||
| return incoming.agentPresence === owner ? incoming : { ...incoming, agentPresence: owner } |
There was a problem hiding this comment.
Replacement agent status gets deleted
When Claude dies without a SessionEnd and another agent starts in the same pane, this transition accepts the new agent’s status but keeps the dead Claude process as owner. A later title-triggered check finds that Claude exited and deletes the new agent’s live status. This can make the replacement agent’s Chat and status disappear while it is still running, particularly for Codex, whose hooks have no process identity to trigger a check on arrival.
|
Navigate logical layers of code changes, visualize relationships, and explore their blast radius. No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Advanced Run ID: 📒 Files selected for processing (2)
Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 9 remain after this review. 📝 WalkthroughWalkthroughThe change captures Claude process identity and carries it through hook, spool, and relay events. Main and relay servers probe process presence and reconcile ended agents. Runtime exit decisions and deferred terminal status cleanup use hook-presence results alongside existing evidence. Priority: ➖ Normal Severity of issue fixed: Medium Merge Risk: 🟡 Moderate · up to A live Claude process can cause queued orchestration input to be submitted to a shell or tmux pane instead of Claude. This is a concrete terminal execution risk that should be guarded before merging. Security Architecture ReviewSecurity architecture risk: 🟡 Moderate · up to A running process can now restore a terminal's ready state without confirming which program owns its input. This can initiate automated message delivery to the wrong program. Process-incarnation and stale-result controls limit exposure, but the final input safeguards and some remote trust controls remain only partially verified. Retained concerns
Security review detailsSecurity Blast Radius
Security Findings and Attack Paths
Trust Boundaries and Controls
Resilience and Maintainability Implications
Hardening Proposals
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Advanced
Run ID: ac138579-8f4a-4abd-a2b9-c67bfec08f25
📒 Files selected for processing (57)
config/tsconfig.cli.jsonsrc/main/agent-hooks/hook-post-command.tssrc/main/agent-hooks/hook-process-capture.test.tssrc/main/agent-hooks/hook-process-capture.tssrc/main/agent-hooks/hook-stdin-contract.tssrc/main/agent-hooks/runtime-home-hook-command.tssrc/main/agent-hooks/server-agent-presence.test.tssrc/main/agent-hooks/server.test-fixtures.tssrc/main/agent-hooks/server.tssrc/main/agent-hooks/server/server-agent-presence.tssrc/main/agent-hooks/server/server-cleanup.tssrc/main/agent-hooks/server/server-ingest-remote.tssrc/main/agent-hooks/server/server-lifecycle.tssrc/main/agent-hooks/server/server-persistence-validation.tssrc/main/agent-hooks/server/server-state.tssrc/main/agent-hooks/server/server-status-update.tssrc/main/agent-hooks/spool.test.tssrc/main/claude/hook-script.tssrc/main/ipc/agent-hooks.tssrc/main/orcad/orcad-entry.tssrc/main/runtime/orca-runtime-get-unpersisted-tracked-title-for-pty.tssrc/main/runtime/orca-runtime-mark-pty-liveness-unverifiable.tssrc/main/runtime/orca-runtime-preserved-branch-cleanup.tssrc/main/runtime/orca-runtime-serialize-agent-prompt-submission.tssrc/main/runtime/orca-runtime-state-fields.tssrc/main/runtime/orca-runtime-test-scenario-builders.spec.tssrc/main/runtime/orca-runtime-tests/terminal-side-effect-facts.spec.tssrc/main/runtime/orchestration-codex-completion-title.test.tssrc/main/runtime/orchestration-live-agent-title-restore.test.tssrc/main/runtime/orchestration-mailbox-notification-test-harness.tssrc/main/startup/main-process-runtime-service.tssrc/preload/api/agent-status-api.tssrc/preload/api/agent-status-bridge.tssrc/relay/agent-hook-envelope-build.tssrc/relay/agent-hook-request.tssrc/relay/agent-hook-retired-pane-suppression.test.tssrc/relay/agent-hook-server.tssrc/relay/pty-handler.tssrc/relay/relay-agent-hook-runtime.tssrc/relay/relay-agent-presence.test.tssrc/relay/relay-agent-presence.tssrc/renderer/src/components/terminal-pane/pty-connection-command-finished-cleanup.test.tssrc/renderer/src/components/terminal-pane/pty-connection-unanswered-command-finish.test.tssrc/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.tssrc/renderer/src/web/preload-api/web-agent-status-api.tssrc/shared/agent-decorative-title-signature.tssrc/shared/agent-hook-listener-relay-dependency.test.tssrc/shared/agent-hook-listener.tssrc/shared/agent-hook-listener/hook-envelope.tssrc/shared/agent-hook-listener/listener-event.tssrc/shared/agent-hook-presence-transition.tssrc/shared/agent-hook-relay.tssrc/shared/agent-hook-spool.tssrc/shared/agent-process-presence-platform.test.tssrc/shared/agent-process-presence-probe.test.tssrc/shared/agent-process-presence-probe.tssrc/shared/agent-process-presence.ts
Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 6 remain after this review.
| private restoreDisprovedAgentExit(ptyId: string, confirmedStatus?: 'idle'): void { | ||
| const current = this.ptysById.get(ptyId) | ||
| const restoredStatus = this.ptyTitleTrackersByPtyId | ||
| .get(ptyId) | ||
| ?.tracker.restoreLastAgentExit(confirmedStatus) | ||
| if (!current || restoredStatus === null || restoredStatus === undefined) { | ||
| return | ||
| } | ||
| current.lastAgentStatus = restoredStatus | ||
| if (restoredStatus === 'idle') { | ||
| this.resolvePtyTuiIdleWaiters(current, ptyId) | ||
| } | ||
| for (const leaf of this.getLeavesForPty(ptyId)) { | ||
| if (leaf.lastAgentStatus !== null) { | ||
| continue | ||
| } | ||
| // Why: the live agent disproved the neutral title's exit signal; keep runtime delivery state aligned with the restored tracker. | ||
| leaf.lastAgentStatus = restoredStatus | ||
| if (restoredStatus === 'idle') { | ||
| this.resolveTuiIdleWaiters(leaf) | ||
| // Why gated like every other delivery edge: a neutral-title restoration can | ||
| // reinstate `idle` from a name-only title, which is not evidence a turn ended. | ||
| if (this.checkDeliverySettledAndArmRecheck(leaf)) { | ||
| this.deliverPendingMessagesForLeaf(leaf) | ||
| } | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
sed -n '90,150p' src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts
cat src/main/runtime/orchestration-live-agent-title-restore.test.ts
rg -n 'deliverPendingMessages|deliverySettled|restoreDisprovedAgentExit' src/main/runtimeRepository: stablyai/orca
Length of output: 13829
🏁 Script executed:
#!/bin/bash
set -u
printf '%s\n' '--- revision availability ---'
git cat-file -t a57863ba167903309af608697daaf59607121c48 2>&1 || true
git cat-file -t 721de1cdbcc55c314725b6deee897036a56e1f13 2>&1 || true
printf '%s\n' '--- changed files base..head ---'
git diff --name-status a57863ba167903309af608697daaf59607121c48 721de1cdbcc55c314725b6deee897036a56e1f13 2>&1
printf '%s\n' '--- relevant diff ---'
git diff --unified=35 a57863ba167903309af608697daaf59607121c48 721de1cdbcc55c314725b6deee897036a56e1f13 -- \
src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts \
src/main/runtime/orca-runtime-deliver-pending-messages.ts \
src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts \
src/main/runtime/orca-runtime-apply-tracked-pty-title.ts \
src/main/runtime/orca-runtime-title-tracker.ts \
src/main/runtime/orchestration-live-agent-title-restore.test.ts
printf '%s\n' '--- delivery helper and callers ---'
sed -n '1,180p' src/main/runtime/orca-runtime-deliver-pending-messages.ts
sed -n '160,235p' src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts
sed -n '320,375p' src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts
printf '%s\n' '--- title/exit and tracker symbols ---'
rg -n -C 8 'confirmPtyAgentExit|restoreLastAgentExit|recordTerminalSideEffectFact|checkDeliverySettledAndArmRecheck|lastAgentStatus|agent-exited|onPtyData|applyTrackedPtyTitle' src/main/runtime -g '*.ts'
printf '%s\n' '--- leaf and foreground process definitions/usages ---'
rg -n -C 12 'getForegroundProcess|interface RuntimeLeafRecord|type RuntimeLeafRecord|RuntimeLeafRecord|ptyId|paneKey' src/main/runtime -g '*.ts' | head -n 500Repository: stablyai/orca
Length of output: 45644
🏁 Script executed:
#!/bin/bash
set -u
printf '%s\n' '--- delivery helper remainder ---'
sed -n '70,230p' src/main/runtime/orca-runtime-deliver-pending-messages.ts
printf '%s\n' '--- delivery gate definitions and callers ---'
rg -n -C 18 'checkDeliverySettledAndArmRecheck|lastAgentStatusObservedLive|deliverPendingMessagesForLeaf' src/main/runtime -g '*.ts'
printf '%s\n' '--- title trigger and exit call sites ---'
rg -n -C 25 'confirmPtyAgentExit\(|onPtyData\(|applyTrackedPtyTitle|lastOscTitleAt|agent-exited' src/main/runtime/orca-runtime-*.ts src/main/runtime/orchestration-*.ts -g '*.ts'
printf '%s\n' '--- hook pane collection and presence contract ---'
rg -n -C 20 'collectAgentStatusPaneKeysForPty|checkHookAgentPresenceFn|checkHookAgentPresence|recheckHookAgentPresenceForPty' src/main/runtime src/main -g '*.ts'
printf '%s\n' '--- direct relevant tests ---'
rg -n -C 20 'live agent|neutral title|foreground|wrong|shell|deliver.*idle|checkDeliverySettledAndArmRecheck' src/main/runtime -g '*.test.ts' -g '*.spec.ts'Repository: stablyai/orca
Length of output: 45652
🏁 Script executed:
#!/bin/bash
set -u
printf '%s\n' '--- gate definition locations ---'
rg -l 'checkDeliverySettledAndArmRecheck' src/main/runtime -g '*.ts'
printf '%s\n' '--- gate definition and nearby code ---'
for f in $(rg -l 'checkDeliverySettledAndArmRecheck' src/main/runtime -g '*.ts'); do
echo "FILE:$f"
rg -n -C 35 'checkDeliverySettledAndArmRecheck' "$f"
done
printf '%s\n' '--- presence-to-pty mapping ---'
rg -n -C 25 'collectAgentStatusPaneKeysForPty|getLeavesForPty|handleByLeafKey|leafKey|paneKey' \
src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts \
src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts \
src/main/runtime/orca-runtime-state-fields.ts \
src/main/runtime/orca-runtime-sync-window-graph.ts \
src/main/runtime/runtime-terminal-state-records.ts
printf '%s\n' '--- title callback exact ranges ---'
rg -l 'confirmPtyAgentExit|applyTrackedPtyTitle' src/main/runtime -g '*.ts' | while read f; do
echo "FILE:$f"
rg -n -C 20 'confirmPtyAgentExit|applyTrackedPtyTitle' "$f"
doneRepository: stablyai/orca
Length of output: 43483
🏁 Script executed:
#!/bin/bash
set -u
printf '%s\n' '--- gate files ---'
rg -l 'checkDeliverySettledAndArmRecheck' src/main/runtime -g '*.ts'
for f in $(rg -l 'checkDeliverySettledAndArmRecheck' src/main/runtime -g '*.ts'); do
echo "FILE:$f"
rg -n -C 20 'checkDeliverySettledAndArmRecheck' "$f"
done
printf '%s\n' '--- presence implementation ---'
for f in src/main/agent-hooks/server/server-agent-presence.ts src/main/agent-hooks/server.ts src/shared/agent-process-presence.ts src/shared/agent-process-presence-probe.ts; do
if test -f "$f"; then
echo "FILE:$f"
sed -n '1,260p' "$f"
fi
done
printf '%s\n' '--- pane-key collection ---'
rg -n -C 20 'collectAgentStatusPaneKeysForPty|checkHookAgentPresenceFn|checkHookAgentPresence' src/main/runtime src/main/agent-hooks src/main -g '*.ts'Repository: stablyai/orca
Length of output: 42598
Require foreground-agent verification before delivery.
The hook presence check proves that the recorded agent PID is alive. It does not prove that the agent owns the PTY foreground. A neutral title can call confirmPtyAgentExit, and a live verdict now restores idle without calling readPtyForegroundProcessFromController. The delivery helper then writes the orchestration pointer and delayed \r after only PTY and liveness checks. If the foreground is a shell or another program, the helper can inject and submit that input to the wrong process. Verify the foreground agent before the pointer write and again before the delayed carriage return.
There was a problem hiding this comment.
Caution
The SSH relay half of this change never takes effect: the desktop mux bridge does not forward agentPresence, so the host drops the relay's ended event and treats every SSH pane as unverifiable. Details inline on server-ingest-remote.ts.
Reviewed changes
- Owner rules: new
transitionHookPresence(src/shared/agent-hook-presence-transition.ts) makes one agent's proven process own a pane, wired into both the mainapplyNormalizedStatusand the relayapplyEventpaths. - Process capture + probe: the Claude managed hook script captures its parent pid and start time (
hook-process-capture.ts), which travels through the hook header, spool, persisted rows and relay envelope and is validated/checked byagent-process-presence.tsandagent-process-presence-probe.ts. - SessionEnd admission:
normalizeHookPayloadmarks only ClaudeSessionEndreasons that end the process asagentPresence.ended, and the host reconciles an ended owner to a resume-identity remnant. - Runtime exit producer:
confirmPtyAgentExitnow consults the hook owner's process before falling back to the legacy foreground read, with rechecks on title change, command-finished and unverifiable liveness. - Renderer: command-finished cleanup now asks main
agentStatus:hasVerifiableAgentProcessbefore dropping a row on an unanswered shell check.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
| }) | ||
| const event: AgentHookEventPayload & { authorityRestartId?: string } = { | ||
| paneKey, | ||
| agentPresence: readAgentProcessPresence(envelope.agentPresence), |
There was a problem hiding this comment.
envelope.agentPresence is always undefined on the SSH path, so the guard just above (160-164) rejects the relay's providerSessionOnly ended event and returns before the ended-owner reconcile runs. The relay does build the field (buildRelayHookEnvelope, src/relay/agent-hook-envelope-build.ts:19), but the sole production caller of ingestRemote rebuilds the argument field-by-field and never copies it: src/main/ssh/ssh-relay-session.ts:1622-1661.
Technical details
# SSH relay presence is dropped before it reaches the host
## Affected sites
- `src/main/ssh/ssh-relay-session.ts:1622-1661` — the mux notification handler constructs the `ingestRemote` envelope explicitly and omits `agentPresence`; this is the only production caller of `ingestRemote`, so every SSH/WSL-relayed event arrives with the field stripped. (File is unchanged by this PR.)
- `src/main/agent-hooks/server/server-ingest-remote.ts:160-164` — `!readAgentProcessPresence(undefined)?.ended` is `true` and `isValidPiProviderSessionOnly` is `false` for `agentType === 'claude'` (`server-status-identity.ts:45-50`), so the relay's ended event returns early and is never applied.
- `src/main/agent-hooks/server/server-ingest-remote.ts:262` — the reader added by this PR expects the field.
- `src/main/agent-hooks/server/server-agent-presence.ts:25-41` — with no `process`, `checkAgentPresence` returns `null` (not `unverifiable`) and `hasVerifiableAgentProcess` returns `false`, so the renderer keeps today's exit-on-silence and the runtime never takes the `keepOnSilence` branch (`orca-runtime-serialize-agent-prompt-submission.ts:102-104`).
- `src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts:616-624` — asserts the ingest shape with `expect.objectContaining`, which never covers `agentPresence`.
## Required outcome
- A remote Claude exit detected by the relay (`RelayAgentPresence` publishing `AgentProcessExit`, or an admitted `SessionEnd`) must reach the host and end/clear the host row, and `hasVerifiableAgentProcess` must return `true` for an SSH pane whose relay reported a process identity.
- A relay that sends no process field must keep today's behavior (no false keep).
## Suggested approach
- Forward `agentPresence: envelope.agentPresence` in the object passed to `ingestRemote` at `ssh-relay-session.ts:1622` (the host already re-validates it via `readAgentProcessPresence`, so no extra validation is needed here).
- Extend `ssh-relay-session-agent-hooks.integration.test.ts` to assert `agentPresence` crosses the mux, and add a case for an ended presence driving the host reconcile.…esence-pid # Conflicts: # src/relay/pty-handler.ts
There was a problem hiding this comment.
Actionable comments posted: 1
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Advanced
Run ID: 2cc40a46-4a64-44bb-9fb3-e0bb64592f2a
📒 Files selected for processing (2)
src/main/agent-hooks/spool.test.tssrc/relay/pty-handler.ts
Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 8 remain after this review.
| const recheckAgentPresence = (): void => { | ||
| if (managed.paneKey) { | ||
| this.agentPresenceTrigger?.(managed.paneKey) | ||
| } | ||
| } | ||
| let lastTitleGateKey: string | null = null | ||
| const presenceTriggers = createTerminalTitleTracker({ | ||
| onTitle: (normalizedTitle, rawTitle, meta) => { | ||
| // Why: spinner frames arrive several times a second; only a real title change re-checks. | ||
| const gateKey = getDecorativeTitleGateKey(rawTitle, normalizedTitle) | ||
| if (gateKey !== lastTitleGateKey && !meta?.staleWorkingTitleClear) { | ||
| recheckAgentPresence() | ||
| } | ||
| lastTitleGateKey = gateKey | ||
| }, | ||
| onCommandFinished: recheckAgentPresence | ||
| }) |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
rg -n "recheckAgentPresence|attachIdentity|paneKey|reviv|revived" src/relay/pty-handler.ts
sed -n '980,1090p' src/relay/pty-handler.tsRepository: stablyai/orca
Length of output: 11177
🏁 Script executed:
sed -n '1900,2120p' src/relay/pty-handler.ts
sed -n '2140,2220p' src/relay/pty-handler.ts
sed -n '2980,3220p' src/relay/pty-handler.tsRepository: stablyai/orca
Length of output: 23374
🏁 Script executed:
sed -n '1940,2110p' src/relay/pty-handler.ts
sed -n '2160,2210p' src/relay/pty-handler.ts
sed -n '2990,3220p' src/relay/pty-handler.tsRepository: stablyai/orca
Length of output: 20501
🏁 Script executed:
rg -n -C 5 "params\.paneKey|paneKey:.*params|pty\.spawn|spawnAfterAdmission|dispatcher\.onRequest.*pty" src/relay/pty-handler.ts src/relayRepository: stablyai/orca
Length of output: 41616
Suggested fix
const recheckAgentPresence = (): void => {
- if (managed.paneKey) {
- this.agentPresenceTrigger?.(managed.paneKey)
+ const paneKey = managed.paneKey ?? managed.attachIdentity?.paneKey
+ if (paneKey) {
+ this.agentPresenceTrigger?.(paneKey)
}
}📝 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.
| const recheckAgentPresence = (): void => { | |
| if (managed.paneKey) { | |
| this.agentPresenceTrigger?.(managed.paneKey) | |
| } | |
| } | |
| let lastTitleGateKey: string | null = null | |
| const presenceTriggers = createTerminalTitleTracker({ | |
| onTitle: (normalizedTitle, rawTitle, meta) => { | |
| // Why: spinner frames arrive several times a second; only a real title change re-checks. | |
| const gateKey = getDecorativeTitleGateKey(rawTitle, normalizedTitle) | |
| if (gateKey !== lastTitleGateKey && !meta?.staleWorkingTitleClear) { | |
| recheckAgentPresence() | |
| } | |
| lastTitleGateKey = gateKey | |
| }, | |
| onCommandFinished: recheckAgentPresence | |
| }) | |
| const recheckAgentPresence = (): void => { | |
| const paneKey = managed.paneKey ?? managed.attachIdentity?.paneKey | |
| if (paneKey) { | |
| this.agentPresenceTrigger?.(paneKey) | |
| } | |
| } | |
| let lastTitleGateKey: string | null = null | |
| const presenceTriggers = createTerminalTitleTracker({ | |
| onTitle: (normalizedTitle, rawTitle, meta) => { | |
| // Why: spinner frames arrive several times a second; only a real title change re-checks. | |
| const gateKey = getDecorativeTitleGateKey(rawTitle, normalizedTitle) | |
| if (gateKey !== lastTitleGateKey && !meta?.staleWorkingTitleClear) { | |
| recheckAgentPresence() | |
| } | |
| lastTitleGateKey = gateKey | |
| }, | |
| onCommandFinished: recheckAgentPresence | |
| }) |
|
Merged current main (a1f6b47): the PR merges cleanly again and CI is green (37 passed, 12 skipped, 0 failed). |

What this fixes
Orca sometimes decided a terminal agent had exited when the real answer was "Orca couldn't check." When a Claude or Codex tab's title changed to something that did not look like an agent, Orca asked the terminal which program was in front. If that question failed or timed out (a slow or reconnecting SSH host, a busy machine, a process read that errored), Orca treated the silence as "the agent is gone": the terminal-backed Chat view dropped to a bare terminal while the agent was still running, and the sidebar row could be deleted by the same unanswered check.
The opposite also happened: when Claude really did exit with
/exit, Orca ignored Claude's own "session ended" hook, so the sidebar row stayed on the last state until a desktop window happened to confirm a shell prompt. With no window open (headless host, or the tab not mounted) the row could stay indefinitely.Who hits it: anyone running Claude or Codex in Orca terminals, most often over SSH, under heavy machine load, or while a remote host reconnects.
What users see change in this PR: for a pane whose Claude reported its own process (Claude on macOS and Linux, locally or through an updated SSH relay), a check that cannot answer no longer ends the agent, so Chat stays open and the row stays while Claude is running. When Claude exits for real (
/exitand the other quit paths Claude reports with an exit reason,/logout, or a crash or kill that the process check detects), the machine running Claude marks it ended in its status store immediately, even with no Orca window open, so headless hosts and every reader of that store see the exit. The desktop sidebar display still clears through its existing path, when the shell prompt returns (measured: about 0.4 s after/exitnormally, same as today); reading the store directly is step 2. This covers local macOS and Linux Claude, Claude over SSH once the remote relay is updated, and Claude inside WSL on Windows (code path only; not yet live-tested).What is NOT affected in this PR:
/clearand/resumeinside Claude keep the session present exactly as today. Any pane without a recorded process identity keeps today's exit behaviour unchanged, including exiting when a check cannot answer: Codex, native Windows Claude, agents without hooks, panes behind an older SSH relay, and Claude sessions started before this update. The tab icon, Chat entry/exit, dashboard and mobile still use their existing logic. Restart/tab re-binding is owned by a separate change.This PR is step 1 of 3. Step 1 (this PR): the machine running the agent records which agent owns each terminal pane and, for Claude on macOS/Linux, that agent's own process id and start time; it decides live / can't tell / exited from that one process, and an unanswered check is never an exit. Step 2: the desktop tab icon, terminal-backed Chat, sidebar and dashboard read that single record, and the old renderer-side exit guesses (title and foreground-process based) are deleted. Step 3: paired web, mobile and CLI views read the same record, with reconnect coverage and cleanup of the old-version compatibility paths. Hookless agents get a launch-time process record in a later step.
ELI5
"I can't tell whether Claude is there" must not mean "Claude left." Each terminal pane now has one owner record kept on the machine where the agent runs: which agent owns the pane and, when Claude's hook reported it, Claude's own process number plus the time that process started. To answer "is it still there?", Orca looks at that one process: same number and same start time means it is running; missing, reused number or zombie means it exited; anything Orca cannot read means "can't tell", which keeps things as they are. Claude saying "my session ended" because the user quit also ends the record right away.
Before / After
tmux,wsl.exe), never on silence. Panes without a known process keep exactly today's foreground check, including exiting on silence.prompt_input_exit,logout,other,bypass_permissions_disabled) from the pane's owning Claude process ends the record and clears the row on the host;clear,resume, subagent and unknown reasons do not.claude -pstarted inside it), those other hooks still update status, and only the owner's own proven process can end it. An owner whose process was never reported is never ended by a hook and never process-checked./proc/<pid>/statplus boot id; macOSps -o lstartin UTC). It travels as an optional field through the local hook request, the hook spool file, saved status rows and the SSH relay message.Why this design
The bug class removed is "treat not knowing as knowing the agent left", plus "any hook in the pane can end the pane". The fact that decides presence is now the agent's own process on the machine running it, recorded in the one existing status store, instead of whichever program happens to be in front of the terminal. A foreground program can change (a tool command,
tmux, a nested shell) while the agent is still running, so it was never the right identity.Rejected: keeping foreground and title checks as the exit signal and only softening their failure case (the closed #23786 approach). It kept two exit deciders, made a normal local
/exitslower, and added a second renderer publisher.Rejected: keying presence on the Claude session id.
/clearand/resumeswitch sessions inside the same running process, and nestedclaude -pruns inherit the pane key with their own session ids. Earlier rounds of this PR tried session-keyed transitions and each fix produced a new trap: a nested session taking over the pane and ending it with its own SessionEnd, a lost SessionStart after/clearfreezing all later updates, and late events from a retired session re-pointing the pane. Keying on the owning process removes that whole family.Rejected for step 1: a periodic sweep (no observed failure that real triggers miss yet), a native module for sub-second macOS start times (not observed to matter), guessing Codex's process from its hook's parent (not provable for current Codex), and checking remote process ids from the desktop (the SSH boundary rule is that the machine running the agent owns liveness).
Differences from the usual process-identity approach used elsewhere: keeping an explicit "can't tell" verdict across permission errors and disconnected hosts, instead of treating a failed read as absent, is intended because that failure is exactly this bug. Second-resolution macOS start time is an intended step-1 trade-off. The remaining foreground/title readers are temporary and are removed in step 2 (desktop) and step 3 (web, mobile, CLI).
Known weaknesses
CLAUDE_CODE_SHELL_PREFIXwrapper, the recorded parent may be that short-lived wrapper, which would look like an exit once it ends. Rare setting; to be checked in QA.tmux(title passthrough on) and the user switched to a shell window, a message could be typed into that shell.claude -pinside a running Claude triggers one owner check, so a nested run with ten tool calls costs about twenty extrapscalls on macOS.claude; sleep 30), the host has already marked Claude ended but the sidebar row stays until the prompt returns, exactly as today. Step 2 fixes this./exitinstead of about 0.3 s. It did not reproduce on fresh panes and is unexplained; treat as unconfirmed.openclaudehook command also saves its parent process id, but nothing reads it yet (harmless).Test evidence
orca-runtime.test.ts,orchestration-live-agent-title-restore.test.ts,orchestration-codex-completion-title.test.ts,terminal-foreground-confirmation.unit.test.ts,session-tabs-decorative-title-fanout.unit.test.ts,pty-connection-command-finished-cleanup.test.ts): 1385 passed, 1 skipped at 089e085 (one renderer test timed out at 30 s under machine load and passed on a by-name rerun, 17/17).env -iwith an isolated temporary HOME; process capture exercised on macOS with an inert parent. Linux, Windows and relay verdict cases use mocks and a loopback relay./exit, Ctrl-C twice, Ctrl-D,kill -TERM,kill -9, and closing the tab clear the row at the same times as today;/exitmedian 381 ms after vs 330 ms before (5 runs each)./clearand/resumekeep the row. A neutral terminal title while Claude is idle, andTZ=Asia/Tokyoin the agent's shell, keep the row. A nestedclaude -pleaves the outer Claude owning the pane and its nested SessionEnd is ignored. With no Orca window visible,/exitwas recorded as ended on the host and the row was gone in 379 ms. SSH was blocked by the rig (see Known weaknesses).Linked Issue
Related: STA-8819 (Claude Chat freezes and its tab icon becomes a terminal icon). This removes verified false-exit paths but does not claim to close that ticket; its reported incident has not been reproduced. STA-8862 (restart/tab re-binding) belongs to a separate change; STA-8902 (permission arguments) is unrelated. No automatic issue-closing keywords.
Visual Proof
After (this PR), row present before








/exit:After, row gone after
/exit:After,
kill -9then prompt return, no row:After, neutral title while Claude is idle, row kept:
After,
TZ=Asia/Tokyoin the agent's shell, row kept through idle title changes:After, nested
claude -p, outer row kept and working afterwards:After,
/exitwith no Orca window visible, row gone:Before (main), row gone after
/exit:Testing
Review
Reviewers: the owner-record rules live in
src/shared/agent-hook-presence-transition.ts; the single-process check insrc/shared/agent-process-presence-probe.ts; the host check insrc/main/agent-hooks/server/server-agent-presence.tsand the relay's insrc/relay/relay-agent-presence.ts; the runtime exit producer insrc/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts. Mixed versions: new fields are optional; an old relay or host sends none and gets today's behaviour, and an old desktop ignores the relay's new "exited" message.Agent skill upstream boundary
Checklist