From e3c9370da4180cbeb4bb5a0f926e4bece412ed0b Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:12:37 -0700 Subject: [PATCH] feat(agent-status): read OSC 7501 program status beside hooks (#30261) Programs can tell Orca what they are doing through the Program Status Protocol (OSC 7501); Claude Code, Codex, Grok, pi and oh-my-pi already do. Orca answers the protocol's feature-detection probe (visible xterm and the hidden-pane emulator), parses reports from PTY output on the execution host (src/shared/program-status/), and keeps two lanes: - Lifecycle state keeps one owner per pane. Hooks win; OSC 7501 drives the row when hooks are off, missing, or silent on a state the program has left (server/program-status/hook-precedence). - The program's own report (progress, its one-line message, blocks such as sign-in) is a live-only `programStatus` facet kept per pane (server/program-status/facet-store), stitched onto rows at publish time and carried to the renderer store, mobile, paired clients and `worktree ps`. Facet-only changes patch just the facet. Title-driven readers (idle edges, delivery, tui-idle, exit inference) classify by the program's OSC 7501 activity while it reports one and fall back to title glyphs only for agents that report nothing explicit, so they keep working when Claude stops painting its title glyph after the probe. --- src/cli/workspace-format.ts | 3 +- src/cli/worktree-ps-program-status.test.ts | 49 +++ src/cli/worktree-ps-program-status.ts | 24 ++ src/main/agent-hooks/README.md | 87 ++++++ .../agent-hooks/agent-hook-runtime-deps.ts | 1 + .../server-program-status-hook-yield.test.ts | 283 ++++++++++++++++++ .../server/program-status/facet-store.test.ts | 38 +++ .../server/program-status/facet-store.ts | 72 +++++ .../program-status/hook-precedence.test.ts | 88 ++++++ .../server/program-status/hook-precedence.ts | 30 ++ .../server/program-status/pane-timers.test.ts | 35 +++ .../server/program-status/pane-timers.ts | 60 ++++ .../server/server-authority-aliases.ts | 3 + .../server/server-ingest-terminal.ts | 92 +++++- .../agent-hooks/server/server-lifecycle.ts | 3 + .../agent-hooks/server/server-listeners.ts | 17 +- .../agent-hooks/server/server-persistence.ts | 3 + .../server/server-row-ownership.ts | 15 + .../server/server-status-identity.ts | 1 + .../agent-hooks/server/server-tab-cleanup.ts | 7 + src/main/agent-hooks/server/server-types.ts | 7 + .../terminal-view-attribute-responder.test.ts | 24 ++ .../terminal-view-attribute-responder.ts | 6 +- .../runtime-construction-dependencies.ts | 6 +- .../construction/runtime-construction.ts | 2 + .../construction/runtime-host-services.ts | 4 + .../program-status-osc.spec.ts | 189 ++++++++++++ ...terminal-side-effect-facts-part-02.spec.ts | 3 +- ...terminal-side-effect-facts-part-03.spec.ts | 2 +- .../terminal-side-effect-facts.spec.ts | 4 +- src/main/runtime/orca-runtime.test.ts | 1 + .../runtime-hook-agent-row-selection.ts | 3 + ...untime-mobile-agent-status-builder.test.ts | 44 +++ .../runtime-mobile-agent-status-builder.ts | 3 +- .../runtime-mobile-agent-status-projection.ts | 3 +- .../runtime/runtime-terminal-contracts.ts | 16 +- .../runtime/runtime-terminal-state-records.ts | 7 + .../runtime/runtime-worktree-agent-rows.ts | 3 +- .../runtime/runtime-worktree-agent-source.ts | 2 + .../runtime-worktree-agent-sources.test.ts | 12 + .../runtime-worktree-pty-agent-sources.ts | 3 +- .../lifecycle/floating-session-liveness.ts | 2 + .../terminal/lifecycle/pty-exit-lifecycle.ts | 5 + .../lifecycle/terminal-view-resources.ts | 3 + .../terminal/output/pty-output-ingestion.ts | 9 +- .../output/terminal-derived-agent-facts.ts | 98 +++++- .../output/terminal-output-generation.ts | 6 +- .../output/terminal-title-observation.ts | 80 ++++- .../terminal/output/terminal-title-tracker.ts | 20 +- .../wait/terminal-wait-blocked-checks.ts | 4 +- src/main/runtime/tui-idle-evidence.test.ts | 24 ++ src/main/runtime/tui-idle-evidence.ts | 9 +- src/main/startup/main-window-agent-status.ts | 11 +- .../parked-terminal-byte-watcher.ts | 7 +- .../agent-task-complete-notify.ts | 15 +- .../pty-connection/pty-input-recovery.ts | 8 + .../pty-output-effect-ordering.ts | 95 ++++++ ...-output-processor-program-activity.test.ts | 134 +++++++++ .../terminal-pane/pty-output-processor.ts | 137 ++++----- .../pty-output-side-effect-queue.ts | 10 + .../pty-output-title-observer.ts | 7 +- .../terminal-pane/pty-transport-types.ts | 3 +- .../components/terminal-pane/pty-transport.ts | 4 +- .../terminal-capability-replies.ts | 10 +- ...terminal-side-effect-facts-handler.test.ts | 2 +- .../terminal-side-effect-facts-handler.ts | 11 +- ...events-agent-status-store-test-fixtures.ts | 19 ++ .../agent-status-event-applicator.ts | 24 +- .../ipc-events/agent-status-listeners.ts | 1 + .../agent-status-partial-updates.ts | 39 ++- .../agent-status-program-status-only.test.ts | 163 ++++++++++ .../agent-status-unavailable.test.ts | 5 +- ...ents-agent-status-batch-projection.test.ts | 2 +- src/renderer/src/lib/agent-idle-edge.test.ts | 27 ++ src/renderer/src/lib/agent-idle-edge.ts | 13 + .../remote-agent-row-program-status.test.ts | 140 +++++++++ .../src/runtime/runtime-rpc-client.ts | 10 + ...time-graph-agent-status-projection.test.ts | 28 +- ...-runtime-graph-projection-hot-path.test.ts | 10 +- .../agent-status-projection.ts | 4 +- .../agent-status-patch.ts | 14 +- .../state-equality-core.test.ts | 20 ++ .../state-equality-core.ts | 2 + .../store/slices/agent-status-batch.test.ts | 2 + .../src/store/slices/agent-status-contract.ts | 16 +- .../slices/agent-status-live-entry-builder.ts | 3 + .../agent-status-program-status-actions.ts | 39 +++ .../agent-status-program-status.test.ts | 94 ++++++ .../src/store/slices/agent-status-runtime.ts | 7 +- .../slices/agent-status-slice-contract.ts | 4 + src/renderer/src/store/slices/agent-status.ts | 12 +- src/shared/agent-detection.ts | 3 +- src/shared/agent-status-ipc-payload.ts | 12 +- src/shared/agent-status-observation.ts | 2 +- ...agent-status-osc-pending-retention.test.ts | 9 +- .../agent-status-osc-scan-budget.test.ts | 3 +- src/shared/agent-status-osc.test.ts | 9 +- src/shared/agent-status-osc.ts | 25 +- src/shared/agent-status-subagent-snapshot.ts | 2 +- ...nt-status-tracker-program-activity.test.ts | 50 ++++ src/shared/agent-status-types.ts | 5 + src/shared/agent-title-status.ts | 73 +++-- src/shared/claude-terminal-interrupt.test.ts | 48 +++ src/shared/claude-terminal-interrupt.ts | 59 +++- src/shared/osc-title-extraction.ts | 16 +- src/shared/program-status/activity.ts | 21 ++ .../agent-status-projector.test.ts | 262 ++++++++++++++++ .../program-status/agent-status-projector.ts | 276 +++++++++++++++++ src/shared/program-status/facet.ts | 109 +++++++ src/shared/program-status/osc-scanner.test.ts | 74 +++++ src/shared/program-status/osc-scanner.ts | 147 +++++++++ .../program-status/presentation.test.ts | 58 ++++ src/shared/program-status/presentation.ts | 73 +++++ src/shared/program-status/protocol.test.ts | 111 +++++++ src/shared/program-status/protocol.ts | 190 ++++++++++++ .../program-status/query-responder.test.ts | 37 +++ src/shared/program-status/query-responder.ts | 29 ++ src/shared/program-status/records.test.ts | 61 ++++ src/shared/program-status/records.ts | 131 ++++++++ .../program-status/title-activity-order.ts | 22 ++ src/shared/protocol-version.ts | 2 + src/shared/runtime-worktree-contracts.ts | 3 + .../terminal-agent-status-processor.test.ts | 63 ++++ src/shared/terminal-agent-status-processor.ts | 97 ++++++ src/shared/terminal-output-side-effects.ts | 61 +++- src/shared/terminal-side-effect-facts.ts | 8 +- 126 files changed, 4594 insertions(+), 224 deletions(-) create mode 100644 src/cli/worktree-ps-program-status.test.ts create mode 100644 src/cli/worktree-ps-program-status.ts create mode 100644 src/main/agent-hooks/README.md create mode 100644 src/main/agent-hooks/server-program-status-hook-yield.test.ts create mode 100644 src/main/agent-hooks/server/program-status/facet-store.test.ts create mode 100644 src/main/agent-hooks/server/program-status/facet-store.ts create mode 100644 src/main/agent-hooks/server/program-status/hook-precedence.test.ts create mode 100644 src/main/agent-hooks/server/program-status/hook-precedence.ts create mode 100644 src/main/agent-hooks/server/program-status/pane-timers.test.ts create mode 100644 src/main/agent-hooks/server/program-status/pane-timers.ts create mode 100644 src/main/daemon/terminal-view-attribute-responder.test.ts create mode 100644 src/main/runtime/orca-runtime-tests/program-status-osc.spec.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-output-effect-ordering.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-output-processor-program-activity.test.ts create mode 100644 src/renderer/src/hooks/ipc-events/agent-status-program-status-only.test.ts create mode 100644 src/renderer/src/lib/agent-idle-edge.test.ts create mode 100644 src/renderer/src/lib/agent-idle-edge.ts create mode 100644 src/renderer/src/runtime/remote-agent-row-program-status.test.ts create mode 100644 src/renderer/src/store/slices/agent-status-program-status-actions.ts create mode 100644 src/renderer/src/store/slices/agent-status-program-status.test.ts create mode 100644 src/shared/agent-status-tracker-program-activity.test.ts create mode 100644 src/shared/program-status/activity.ts create mode 100644 src/shared/program-status/agent-status-projector.test.ts create mode 100644 src/shared/program-status/agent-status-projector.ts create mode 100644 src/shared/program-status/facet.ts create mode 100644 src/shared/program-status/osc-scanner.test.ts create mode 100644 src/shared/program-status/osc-scanner.ts create mode 100644 src/shared/program-status/presentation.test.ts create mode 100644 src/shared/program-status/presentation.ts create mode 100644 src/shared/program-status/protocol.test.ts create mode 100644 src/shared/program-status/protocol.ts create mode 100644 src/shared/program-status/query-responder.test.ts create mode 100644 src/shared/program-status/query-responder.ts create mode 100644 src/shared/program-status/records.test.ts create mode 100644 src/shared/program-status/records.ts create mode 100644 src/shared/program-status/title-activity-order.ts create mode 100644 src/shared/terminal-agent-status-processor.test.ts create mode 100644 src/shared/terminal-agent-status-processor.ts diff --git a/src/cli/workspace-format.ts b/src/cli/workspace-format.ts index fc7a9bc0acb..e9ac6ffed09 100644 --- a/src/cli/workspace-format.ts +++ b/src/cli/workspace-format.ts @@ -9,6 +9,7 @@ import type { import type { MemorySnapshot, WorktreeMemory } from '../shared/process-stats-types' import { formatListingHostScope, type WithAnnotatedHostScope } from './omitted-host-scope-selectors' import { formatWorktreePsTerminalFields } from './worktree-ps-terminal-verdict' +import { formatWorktreePsProgramStatus } from './worktree-ps-program-status' export function formatMemorySnapshot(snapshot: MemorySnapshot): string { const topWorktrees = [...snapshot.worktrees].sort((a, b) => b.memory - a.memory).slice(0, 10) @@ -140,7 +141,7 @@ export function formatWorktreePs(result: WithAnnotatedHostScope - `${worktree.repo} ${worktree.branch} host=${worktree.hostId ?? 'unverifiable'} ${formatWorktreePsTerminalFields(worktree)} unread:${worktree.unread ? 'yes' : 'no'}\n${worktree.path}${worktree.preview ? `\npreview: ${worktree.preview}` : ''}` + `${worktree.repo} ${worktree.branch} host=${worktree.hostId ?? 'unverifiable'} ${formatWorktreePsTerminalFields(worktree)} unread:${worktree.unread ? 'yes' : 'no'}${formatWorktreePsProgramStatus(worktree.agents)}\n${worktree.path}${worktree.preview ? `\npreview: ${worktree.preview}` : ''}` ) .join('\n\n') const bodyWithScope = `${body}\n\n${scope}` diff --git a/src/cli/worktree-ps-program-status.test.ts b/src/cli/worktree-ps-program-status.test.ts new file mode 100644 index 00000000000..9ba0f9ed44a --- /dev/null +++ b/src/cli/worktree-ps-program-status.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' +import type { RuntimeWorktreeAgentRow } from '../shared/runtime-types' +import { formatWorktreePsProgramStatus } from './worktree-ps-program-status' + +function agent(over: Partial): RuntimeWorktreeAgentRow { + return { + paneKey: 'tab:leaf', + parentPaneKey: null, + state: 'working', + agentType: 'codex', + prompt: 'ship it', + taskTitle: null, + displayName: null, + lastAssistantMessage: null, + toolName: null, + toolInput: null, + interrupted: false, + stateStartedAt: 1, + updatedAt: 1, + ...over + } +} + +describe('formatWorktreePsProgramStatus', () => { + it('is empty without agents or facets', () => { + expect(formatWorktreePsProgramStatus(undefined)).toBe('') + expect(formatWorktreePsProgramStatus([agent({})])).toBe('') + }) + + it('shows live progress', () => { + const programStatus = { state: 'working' as const, progress: 42, observedAt: 1 } + expect(formatWorktreePsProgramStatus([agent({ programStatus })])).toBe(' 42%') + }) + + it('prefers a reported block over another agent progress', () => { + const working = agent({ programStatus: { state: 'working', progress: 42, observedAt: 1 } }) + const signIn = agent({ + paneKey: 'tab:leaf-2', + state: 'done', + programStatus: { state: 'blocked', kind: 'auth', observedAt: 1 } + }) + expect(formatWorktreePsProgramStatus([working, signIn])).toBe(' (Sign-in required)') + }) + + it('hides progress on a finished row', () => { + const programStatus = { state: 'working' as const, progress: 42, observedAt: 1 } + expect(formatWorktreePsProgramStatus([agent({ state: 'done', programStatus })])).toBe('') + }) +}) diff --git a/src/cli/worktree-ps-program-status.ts b/src/cli/worktree-ps-program-status.ts new file mode 100644 index 00000000000..52d0df0a03f --- /dev/null +++ b/src/cli/worktree-ps-program-status.ts @@ -0,0 +1,24 @@ +import type { RuntimeWorktreeAgentRow } from '../shared/runtime-types' +import { + programStatusAttentionLabel, + resolveProgramStatusPresentation +} from '../shared/program-status/presentation' + +/** Compact OSC 7501 suffix for a `worktree ps` line: a block beats progress; '' when neither. */ +export function formatWorktreePsProgramStatus( + agents: readonly RuntimeWorktreeAgentRow[] | undefined +): string { + let progress: number | undefined + for (const agent of agents ?? []) { + const presentation = resolveProgramStatusPresentation({ + state: agent.state, + programStatus: agent.programStatus, + toolName: agent.toolName ?? undefined + }) + if (presentation.attention) { + return ` (${programStatusAttentionLabel(presentation.attention)})` + } + progress ??= presentation.progress + } + return progress === undefined ? '' : ` ${progress}%` +} diff --git a/src/main/agent-hooks/README.md b/src/main/agent-hooks/README.md new file mode 100644 index 00000000000..f60f7bc56f2 --- /dev/null +++ b/src/main/agent-hooks/README.md @@ -0,0 +1,87 @@ +# Agent status integrations + +Orca learns what each agent is doing through two channels. Both write into the hook server's +status store. + +| Channel | How | What it carries | +| ---------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| **Managed integrations** (this folder) | Per-agent hooks, plugins and extensions that POST to the loopback hook server. Installers are listed in `managed-agent-hook-registry.ts`; each agent's code lives in `src/main//`. | State, prompt, tool name and input, assistant reply, provider session id (resume and native chat), model, subagents | +| **OSC 7501** (Program Status Protocol, [spec](https://www.superlogical.com/rex/docs/build/program-status)) | The agent writes its own status into the terminal stream. Orca reads it from PTY output and answers the protocol's `?` probe. | State, blocked `kind`, one free-text line (`msg`), child records | + +**Precedence:** hooks win, and OSC 7501 fills in wherever they don't speak. That covers: + +- hooks turned off, +- hooks not installed (a fresh SSH host, an agent run by hand), +- programs with no integration at all, +- hooks that went silent for 2 minutes on a state the program has since left. + +The next hook event takes the pane back. The rule lives in +`server/program-status/hook-precedence.ts`; the parser and the mapping live in +`src/shared/program-status/`. + +**Hybrid:** beside whichever producer owns the row's state, the program's OSC 7501 report rides +along as a facet (`programStatus`: progress, its own one-line message, and blocks such as sign-in). +It comes only from the OSC 7501 channel, lives in `server/program-status/facet-store.ts`, is +attached to rows at publish time, and never decides state. What each surface shows is decided by +one resolver, `src/shared/program-status/presentation.ts`. + +## OSC 7501 support matrix + +_Last updated: 2026-10-17._ Each "✅ live" row was checked by running the real CLI in a PTY whose +terminal answered queries the way Orca does. + +**Columns:** + +- **Probe:** whether the agent waits for the terminal to answer `OSC 7501 ; ?` before it sends + anything (Orca answers). +- **Kinds:** which `blocked` reasons it names: `permission`, `question`, `auth`. +- **Children:** whether it reports subagents as child records. + +### Agents that send OSC 7501 + +| Agent | Verified | Probe | States | Kinds | `msg` content | Children | Orca integration | Use OSC 7501 for | +| ---------------------- | ----------------------------------------- | ------------------------------------------ | -------- | -------------------- | -------------------------------------------------------------- | ------------ | ---------------- | ----------------------------------------------------------------------------- | +| Claude Code (`claude`) | ✅ live, 2.1.302 (support since 2.1.295) | required | all five | all three | task summary, "approve Write: …", "answer: Q? (A · B)", errors | ✅ subagents | hooks | Fallback. Richest OSC 7501 of any harness, but no prompt and no session id. ¹ | +| Grok (`grok`) | ✅ live, 1.0.60 (`program_status = auto`) | required (`on` forces it) | all five | yes | live phase ("Writing command…"), "Turn complete in 19s." | ✅ | hooks | Fallback | +| pi (`pi`) | ✅ live, 1.3.0 | required (`PI_PROGRAM_STATUS=1` forces it) | all five | all three | session name, dialog title, first line of an error | — | extension | Fallback | +| oh-my-pi (`omp`) | ✅ live, 18.10.3 | sends without asking | all five | permission, question | on `blocked`/`error` only | — | extension | Fallback | +| Codex (`codex`) | ✅ live, 0.163.0 | sends without asking | all five | none sent (0.163) | none | — | hooks | Fallback; state only | + +### Agents that don't send OSC 7501 yet + +| Agent | Checked | Orca integration | +| -------------------------------------------------------------------------------------- | ----------------------------------- | -------------------------- | +| OpenCode (`opencode`, `opencode2`) | installed binary v2.0.16 and source | plugin | +| Kilocode (`kilo`) | installed package and source | — | +| Cursor (`cursor`) | installed binary 2026.06.02 | hooks | +| Amp, Droid, Hermes, Muse, Devin, Kiro | installed binaries | hooks | +| Qoder (`qoder`) | installed binary 1.1.65 | hooks | +| CodeBuddy (`codebuddy`) | installed package | hooks | +| Jcode (`jcode`) | installed binary 0.95.0 | hooks | +| Qwen Code, Freebuff, DeepSeek (`dsh`) | source | hooks (qwen-code, dsh) | +| Gemini, Antigravity, Copilot, Kimi, Command Code, ZCode, OpenClaude, Mimo, Prime Agent | not checked | hooks / plugin / extension | + +¹ Claude Code stops putting its `⠂`/`✳` status glyph in the window title once Orca answers the +probe. So everything that read that glyph reads the program's activity instead while it reports: +the title trackers (`createAgentStatusTracker`, and `classifyPtyTitleStatus` in main) and the +Escape-interrupt check (`ClaudeTerminalInterruptTracker`). Activity ends when the shell prompt +returns or the terminal resets, even though a `done`/`error` record stays on screen, so a later +agent in the same pane is classified by its own titles again. Because answering changes what Claude +paints, a client answers for a remote Orca host only when that host advertises +`terminal.program-status.v1`. + +## Could an agent drop its hook for OSC 7501? + +Not today. OSC 7501 has no field for the prompt, tool input, assistant reply, model, or provider +session id. Native chat, resume, sidebar previews and notification text all need those. An agent +can move to OSC 7501 only once its OSC 7501 output, or another channel, carries the same data. + +## Keeping this table current + +When an agent ships OSC 7501 support, or changes what it reports: + +1. Run it in a PTY whose terminal answers `OSC 7501 ; ?` and record the reports. +2. If its `app` id differs from Orca's agent id, add the mapping to `APP_TO_AGENT_TYPE` in + `src/shared/program-status/agent-status-projector.ts` (examples: `claude-code` → `claude`, + `grok-build` → `grok`). Otherwise hook precedence can't match its rows. +3. Update the row and the date above. diff --git a/src/main/agent-hooks/agent-hook-runtime-deps.ts b/src/main/agent-hooks/agent-hook-runtime-deps.ts index 1708496b2fb..08f0ba89474 100644 --- a/src/main/agent-hooks/agent-hook-runtime-deps.ts +++ b/src/main/agent-hooks/agent-hook-runtime-deps.ts @@ -15,6 +15,7 @@ export function agentHookRuntimeDeps( // Why: main's OSC parse is the only producer for a PTY agent, and this store is the only // place `worktree.ps` and the mobile projection read from. onTerminalAgentStatus: (event) => server.ingestTerminalStatus(event), + onTerminalProgramStatusFacet: (event) => server.ingestProgramStatusFacet(event), onClaudeTerminalEvidence: (paneKey, evidence) => server.observeClaudeTerminalEvidence(paneKey, evidence), getAgentStatusSnapshot: () => diff --git a/src/main/agent-hooks/server-program-status-hook-yield.test.ts b/src/main/agent-hooks/server-program-status-hook-yield.test.ts new file mode 100644 index 00000000000..cc7d3cea1aa --- /dev/null +++ b/src/main/agent-hooks/server-program-status-hook-yield.test.ts @@ -0,0 +1,283 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AgentHookServer } from './server' +import { buildBody, PANE, postHookEvent } from './server.test-fixtures' +import type { ParsedAgentStatusPayload } from '../../shared/agent-status-types' +import { HOOK_SILENCE_BEFORE_PROGRAM_STATUS_FALLBACK_MS } from './server/program-status/hook-precedence' + +let dir: string +let server: AgentHookServer + +beforeEach(async () => { + dir = mkdtempSync(join(tmpdir(), 'orca-program-status-')) + server = new AgentHookServer() + await server.start({ env: 'production', userDataPath: dir }) +}) + +afterEach(() => { + vi.useRealTimers() + server.stop() + rmSync(dir, { recursive: true, force: true }) +}) + +function programStatus( + payload: Omit, + extra: { removesProgramStatusRow?: true; renewsProgramStatusLease?: true } = {} +): void { + server.ingestTerminalStatus({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + connectionId: null, + fromProgramStatus: true, + payload: { prompt: '', ...payload }, + ...extra + }) +} + +async function openCodeHook(hookEventName: string): Promise { + const response = await postHookEvent( + server, + buildBody({ hook_event_name: hookEventName, sessionID: 'ses_1' }), + '/hook/opencode' + ) + expect(response.status).toBe(204) +} + +function paneRow() { + return server.getStatusSnapshotForPane(PANE)[0] +} + +describe('OSC 7501 status beside hooks', () => { + it('drives the pane on its own when no hook reports it', () => { + programStatus({ state: 'working', agentType: 'claude', toolInput: 'Fixing the parser' }) + expect(paneRow()).toMatchObject({ + state: 'working', + agentType: 'claude', + toolInput: 'Fixing the parser' + }) + programStatus({ state: 'waiting', agentType: 'claude' }) + expect(paneRow()?.state).toBe('waiting') + }) + + it('yields to the same agent once its hooks own the row', async () => { + await openCodeHook('SessionBusy') + programStatus({ state: 'done', agentType: 'opencode' }) + expect(paneRow()?.state).toBe('working') + programStatus({ state: 'done' }) + expect(paneRow()?.state).toBe('working') + }) + + it('yields to a mid-turn hook row even under another app id', async () => { + await openCodeHook('SessionBusy') + programStatus({ state: 'done', agentType: 'claude' }) + expect(paneRow()).toMatchObject({ state: 'working', agentType: 'opencode' }) + }) + + it('takes over when a different program reports after the hook agent finished', async () => { + await openCodeHook('SessionIdle') + programStatus({ state: 'working', agentType: 'terraform' }) + expect(paneRow()).toMatchObject({ state: 'working', agentType: 'terraform' }) + }) + + it('never blocks a later hook event', async () => { + programStatus({ state: 'working', agentType: 'opencode' }) + await openCodeHook('SessionIdle') + expect(paneRow()?.state).toBe('done') + }) + + it('falls back to OSC 7501 once hooks go silent on a state the program has left', async () => { + await openCodeHook('SessionBusy') + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(Date.now() + HOOK_SILENCE_BEFORE_PROGRAM_STATUS_FALLBACK_MS) + programStatus({ state: 'working', agentType: 'opencode' }) + expect(paneRow()?.state).toBe('working') + programStatus({ state: 'done', agentType: 'opencode' }) + expect(paneRow()?.state).toBe('done') + vi.useRealTimers() + // The next hook event hands the pane back. + await openCodeHook('SessionBusy') + expect(paneRow()?.state).toBe('working') + }) + + it('re-offers a settled report once the hooks fall silent, without the program resending it', async () => { + await openCodeHook('SessionBusy') + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) + programStatus({ state: 'done', agentType: 'opencode' }) + expect(paneRow()?.state).toBe('working') + vi.advanceTimersByTime(HOOK_SILENCE_BEFORE_PROGRAM_STATUS_FALLBACK_MS + 1) + expect(paneRow()?.state).toBe('done') + }) + + it('never settles a later hook turn with a report that yielded to an earlier one', async () => { + await openCodeHook('SessionBusy') + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) + programStatus({ state: 'done', agentType: 'opencode' }) + vi.advanceTimersByTime(1_000) + await openCodeHook('SessionBusy') + vi.advanceTimersByTime(HOOK_SILENCE_BEFORE_PROGRAM_STATUS_FALLBACK_MS + 1) + expect(paneRow()?.state).toBe('working') + }) + + it('removes a row only OSC wrote once the program leaves no records, and tells the window', async () => { + const cleared: string[] = [] + server.setPaneStatusClearListener((event) => + cleared.push('paneKey' in event && event.programStatusRemoved ? event.paneKey : '') + ) + programStatus({ state: 'done', agentType: 'brew' }) + programStatus( + { state: 'done', sessionBoundary: true, agentType: 'brew' }, + { removesProgramStatusRow: true } + ) + expect(paneRow()).toBeUndefined() + expect(cleared).toEqual([PANE]) + }) + + it('keeps the facet a later report in the same chunk installed when removing the row', () => { + const facet = { state: 'working' as const, progress: 50, observedAt: 1 } + programStatus({ state: 'working', agentType: 'brew' }) + server.ingestProgramStatusFacet({ paneKey: PANE, facet }) + programStatus( + { state: 'done', sessionBoundary: true, agentType: 'brew' }, + { removesProgramStatusRow: true } + ) + programStatus({ state: 'working', agentType: 'brew' }) + expect(paneRow()?.programStatus).toEqual(facet) + }) + + it("never settles a hook row when a program's records go, even once the hooks fall silent", async () => { + await openCodeHook('SessionBusy') + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) + vi.setSystemTime(Date.now() + HOOK_SILENCE_BEFORE_PROGRAM_STATUS_FALLBACK_MS) + programStatus({ state: 'done', sessionBoundary: true }, { removesProgramStatusRow: true }) + vi.advanceTimersByTime(HOOK_SILENCE_BEFORE_PROGRAM_STATUS_FALLBACK_MS + 1) + expect(paneRow()?.state).toBe('working') + }) + + it('drops a pending re-offer once the pane is cleared', async () => { + await openCodeHook('SessionBusy') + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) + programStatus({ state: 'done', agentType: 'opencode' }) + server.reconcileEndedProcessForPaneKeys([PANE], { preserveResumeIdentity: true }) + const published: unknown[] = [] + server.setListener((row) => published.push(row)) + published.length = 0 + vi.advanceTimersByTime(HOOK_SILENCE_BEFORE_PROGRAM_STATUS_FALLBACK_MS + 1) + expect(published).toEqual([]) + }) + + it('applies a child-only change while the root report is unchanged', () => { + const child = { id: 'worker', state: 'blocked' as const, startedAt: 1 } + programStatus({ state: 'working', agentType: 'deploy' }) + programStatus({ state: 'working', agentType: 'deploy', subagents: [child] }) + expect(server.getStatusSnapshotForPane(PANE)[0]?.subagents).toEqual([child]) + programStatus({ state: 'working', agentType: 'deploy' }) + expect(server.getStatusSnapshotForPane(PANE)[0]?.subagents).toBeUndefined() + }) + + it("publishes a program's lease renewal to the window", () => { + const published: string[] = [] + server.setListener((row) => published.push(row.payload.state)) + programStatus({ state: 'working', agentType: 'brew' }) + programStatus({ state: 'working', agentType: 'brew' }) + programStatus({ state: 'working', agentType: 'brew' }, { renewsProgramStatusLease: true }) + expect(published).toEqual(['working', 'working']) + }) + + it('drops the re-offer when a later report agrees with the hooks', async () => { + await openCodeHook('SessionBusy') + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) + programStatus({ state: 'done', agentType: 'opencode' }) + programStatus({ state: 'working', agentType: 'opencode' }) + vi.advanceTimersByTime(HOOK_SILENCE_BEFORE_PROGRAM_STATUS_FALLBACK_MS + 1) + expect(paneRow()?.state).toBe('working') + }) + + describe('hybrid: the program facet rides whichever producer owns the row', () => { + const facet = { + state: 'working' as const, + progress: 42, + message: 'Running tests', + observedAt: 1 + } + + it('stitches the facet onto a hook-owned row without touching its state', async () => { + await openCodeHook('SessionBusy') + const published: { state?: string; programStatus?: unknown; programStatusOnly?: true }[] = [] + server.setListener((row) => + published.push({ + state: row.payload.state, + programStatus: row.programStatus, + programStatusOnly: row.programStatusOnly + }) + ) + published.length = 0 + server.ingestProgramStatusFacet({ paneKey: PANE, facet }) + expect(published).toEqual([ + { state: 'working', programStatus: facet, programStatusOnly: true } + ]) + expect(paneRow()).toMatchObject({ + state: 'working', + agentType: 'opencode', + programStatus: facet + }) + + // Redelivering the same content is not news. + server.ingestProgramStatusFacet({ paneKey: PANE, facet: { ...facet, observedAt: 2 } }) + expect(published).toHaveLength(1) + }) + + it('tells row-mutation readers the row is unchanged, and skips identity-only rows', async () => { + const mutations: Parameters< + Parameters[0] + >[0][] = [] + server.subscribeStatusRowMutations((mutation) => mutations.push(mutation)) + const published: unknown[] = [] + server.setListener((row) => published.push(row)) + server.recordLaunchResumeIdentity({ + paneKey: PANE, + agentType: 'codex', + providerSession: { key: 'session_id', id: 'resumed-session' }, + launchedAt: Date.now() + }) + mutations.length = 0 + published.length = 0 + server.ingestProgramStatusFacet({ paneKey: PANE, facet }) + expect(published).toEqual([]) + expect(mutations).toEqual([]) + + await openCodeHook('SessionBusy') + mutations.length = 0 + server.ingestProgramStatusFacet({ paneKey: PANE, facet: { ...facet, state: 'blocked' } }) + expect(mutations).toHaveLength(1) + expect(mutations[0]?.before).toMatchObject({ paneKey: PANE }) + expect(mutations[0]?.after).toEqual(mutations[0]?.before) + }) + + it('publishes a state change at once and coalesces same-state ticks', async () => { + await openCodeHook('SessionBusy') + const progress: (number | undefined)[] = [] + server.setListener((row) => progress.push(row.programStatus?.progress)) + progress.length = 0 + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + server.ingestProgramStatusFacet({ paneKey: PANE, facet }) + for (const tick of [43, 44, 45]) { + server.ingestProgramStatusFacet({ paneKey: PANE, facet: { ...facet, progress: tick } }) + } + expect(progress).toEqual([42]) + vi.advanceTimersByTime(250) + expect(progress).toEqual([42, 45]) + }) + + it('keeps the facet across later hook events and drops it when the program clears', async () => { + await openCodeHook('SessionBusy') + server.ingestProgramStatusFacet({ paneKey: PANE, facet }) + await openCodeHook('SessionIdle') + expect(paneRow()).toMatchObject({ state: 'done', programStatus: facet }) + server.ingestProgramStatusFacet({ paneKey: PANE, facet: null }) + expect(paneRow()?.programStatus).toBeUndefined() + }) + }) +}) diff --git a/src/main/agent-hooks/server/program-status/facet-store.test.ts b/src/main/agent-hooks/server/program-status/facet-store.test.ts new file mode 100644 index 00000000000..592075fb8eb --- /dev/null +++ b/src/main/agent-hooks/server/program-status/facet-store.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import { ProgramStatusFacetStore } from './facet-store' + +const facet = (progress: number, observedAt = 1) => ({ + state: 'working' as const, + progress, + observedAt +}) + +describe('ProgramStatusFacetStore', () => { + it('reports only content changes, not redelivery', () => { + const store = new ProgramStatusFacetStore() + expect(store.set('p', facet(40))).toBe(true) + expect(store.set('p', facet(40, 99))).toBe(false) + expect(store.set('p', facet(41))).toBe(true) + expect(store.set('p', null)).toBe(true) + expect(store.set('p', null)).toBe(false) + }) + + it('stitches a copy and never mutates the stored row', () => { + const store = new ProgramStatusFacetStore() + const row = { paneKey: 'p', state: 'working' } + store.set('p', facet(10)) + const stitched = store.stitch(row) + expect(stitched).toEqual({ ...row, programStatus: facet(10) }) + expect(row).toEqual({ paneKey: 'p', state: 'working' }) + store.delete('p') + expect(store.stitch(stitched)).toEqual(row) + }) + + it('moves a facet with its pane', () => { + const store = new ProgramStatusFacetStore() + store.set('old', facet(5)) + store.move('old', 'new') + expect(store.get('old')).toBeUndefined() + expect(store.get('new')?.progress).toBe(5) + }) +}) diff --git a/src/main/agent-hooks/server/program-status/facet-store.ts b/src/main/agent-hooks/server/program-status/facet-store.ts new file mode 100644 index 00000000000..3ed2f216bad --- /dev/null +++ b/src/main/agent-hooks/server/program-status/facet-store.ts @@ -0,0 +1,72 @@ +import { + programStatusFacetsEqual, + type ProgramStatusFacet +} from '../../../../shared/program-status/facet' + +// Why a bound: one entry per pane that ever ran an OSC 7501 program; stale panes age out first. +const MAX_PROGRAM_STATUS_FACETS = 1024 + +/** + * Each pane's latest OSC 7501 facet, kept apart from the status rows. A row's producer (hook or OSC) + * owns its state; this map is owned by the OSC 7501 channel alone and is stitched onto rows only as + * they are published, so it is never persisted and never compared as a row change. + */ +export class ProgramStatusFacetStore { + private readonly byPaneKey = new Map() + + /** Returns whether the pane's facet changed. `null` removes it. */ + set(paneKey: string, facet: ProgramStatusFacet | null): boolean { + const previous = this.byPaneKey.get(paneKey) + if (facet === null) { + return this.byPaneKey.delete(paneKey) + } + if (programStatusFacetsEqual(previous, facet)) { + return false + } + this.byPaneKey.delete(paneKey) + this.byPaneKey.set(paneKey, facet) + if (this.byPaneKey.size > MAX_PROGRAM_STATUS_FACETS) { + const oldest = this.byPaneKey.keys().next().value + if (oldest !== undefined) { + this.byPaneKey.delete(oldest) + } + } + return true + } + + get(paneKey: string): ProgramStatusFacet | undefined { + return this.byPaneKey.get(paneKey) + } + + delete(paneKey: string): void { + this.byPaneKey.delete(paneKey) + } + + /** A pane remint keeps the program's facet with its row. */ + move(fromPaneKey: string, toPaneKey: string): void { + const facet = this.byPaneKey.get(fromPaneKey) + if (facet === undefined) { + return + } + this.byPaneKey.delete(fromPaneKey) + this.byPaneKey.set(toPaneKey, facet) + } + + clear(): void { + this.byPaneKey.clear() + } + + /** A copy of `row` carrying its pane's facet. The stored row is never mutated. */ + stitch(row: T): T { + const facet = this.byPaneKey.get(row.paneKey) + if (facet === row.programStatus) { + return row + } + if (facet === undefined) { + const { programStatus: _stale, ...rest } = row + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: dropping one optional key keeps T's shape. + return rest as T + } + return { ...row, programStatus: facet } + } +} diff --git a/src/main/agent-hooks/server/program-status/hook-precedence.test.ts b/src/main/agent-hooks/server/program-status/hook-precedence.test.ts new file mode 100644 index 00000000000..6e83e12e364 --- /dev/null +++ b/src/main/agent-hooks/server/program-status/hook-precedence.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest' +import type { EnrichedAgentHookEventPayload } from '../server-types' +import { + HOOK_SILENCE_BEFORE_PROGRAM_STATUS_FALLBACK_MS as SILENCE, + programStatusYieldsToHookRow +} from './hook-precedence' +import type { ParsedAgentStatusPayload } from '../../../../shared/agent-status-types' + +const NOW = 1_000_000 + +function row( + payload: Omit, + overrides: Partial = {}, + origin: 'hook' | 'osc' = 'hook' +): EnrichedAgentHookEventPayload { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the rule reads only origin, providerSessionOnly, receivedAt and payload. + return { + paneKey: 'tab:leaf', + receivedAt: NOW, + observation: { origin }, + payload: { prompt: 'fix it', ...payload }, + ...overrides + } as EnrichedAgentHookEventPayload +} + +const report = (payload: Omit): ParsedAgentStatusPayload => ({ + prompt: '', + ...payload +}) + +describe('programStatusYieldsToHookRow', () => { + it('applies when no hook row speaks for the pane', () => { + expect(programStatusYieldsToHookRow(undefined, report({ state: 'working' }), NOW)).toBe(false) + expect( + programStatusYieldsToHookRow( + row({ state: 'working' }, {}, 'osc'), + report({ state: 'done' }), + NOW + ) + ).toBe(false) + expect( + programStatusYieldsToHookRow( + row({ state: 'done' }, { providerSessionOnly: true }), + report({ state: 'working' }), + NOW + ) + ).toBe(false) + }) + + it('yields to a live hook row of the same agent', () => { + const hook = row({ state: 'working', agentType: 'claude' }) + expect( + programStatusYieldsToHookRow(hook, report({ state: 'done', agentType: 'claude' }), NOW) + ).toBe(true) + }) + + it('keeps yielding through a long silent tool call that the program agrees with', () => { + const hook = row({ state: 'working', agentType: 'claude' }) + const later = NOW + 10 * SILENCE + expect( + programStatusYieldsToHookRow(hook, report({ state: 'working', agentType: 'claude' }), later) + ).toBe(true) + }) + + it('takes the pane once silent hooks disagree with the program', () => { + const hook = row({ state: 'working', agentType: 'claude' }) + const disagree = report({ state: 'done', agentType: 'claude' }) + expect(programStatusYieldsToHookRow(hook, disagree, NOW + SILENCE - 1)).toBe(true) + expect(programStatusYieldsToHookRow(hook, disagree, NOW + SILENCE)).toBe(false) + }) + + it('yields to a mid-turn hook row under another app id, not to a finished one', () => { + expect( + programStatusYieldsToHookRow( + row({ state: 'working', agentType: 'openclaude' }), + report({ state: 'done', agentType: 'claude' }), + NOW + ) + ).toBe(true) + expect( + programStatusYieldsToHookRow( + row({ state: 'done', agentType: 'claude' }), + report({ state: 'working', agentType: 'terraform' }), + NOW + ) + ).toBe(false) + }) +}) diff --git a/src/main/agent-hooks/server/program-status/hook-precedence.ts b/src/main/agent-hooks/server/program-status/hook-precedence.ts new file mode 100644 index 00000000000..c1e2567799a --- /dev/null +++ b/src/main/agent-hooks/server/program-status/hook-precedence.ts @@ -0,0 +1,30 @@ +import type { ParsedAgentStatusPayload } from '../../../../shared/agent-status-types' +import type { EnrichedAgentHookEventPayload } from '../server-types' + +/** Hook silence, while OSC 7501 disagrees, before OSC 7501 takes the pane. Why minutes: a long + * tool call sends no hooks but leaves the program's state unchanged, so only old disagreement counts. */ +export const HOOK_SILENCE_BEFORE_PROGRAM_STATUS_FALLBACK_MS = 2 * 60 * 1000 + +/** Whether an OSC 7501 projection must leave the pane's hook row alone: hooks carry richer identity, + * so they win until silent on a state they no longer describe. A later hook event hands it back. */ +export function programStatusYieldsToHookRow( + previous: EnrichedAgentHookEventPayload | undefined, + payload: ParsedAgentStatusPayload, + now: number +): boolean { + if (previous?.observation?.origin !== 'hook' || previous.providerSessionOnly === true) { + return false + } + const sameAgent = + payload.agentType === undefined || payload.agentType === previous.payload.agentType + // A different app after the hook agent finished is a new program in the pane. Mid-turn, the hook + // row still wins over another app id: forks report upstream's app (OpenClaude says claude-code). + if (!sameAgent && previous.payload.state === 'done') { + return false + } + const hookSilentForMs = now - previous.receivedAt + return ( + payload.state === previous.payload.state || + hookSilentForMs < HOOK_SILENCE_BEFORE_PROGRAM_STATUS_FALLBACK_MS + ) +} diff --git a/src/main/agent-hooks/server/program-status/pane-timers.test.ts b/src/main/agent-hooks/server/program-status/pane-timers.test.ts new file mode 100644 index 00000000000..fee502f992c --- /dev/null +++ b/src/main/agent-hooks/server/program-status/pane-timers.test.ts @@ -0,0 +1,35 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { PaneTimers } from './pane-timers' + +afterEach(() => { + vi.useRealTimers() +}) + +describe('PaneTimers', () => { + it('runs once under the key its pane moved to', () => { + vi.useFakeTimers() + const timers = new PaneTimers() + const run = vi.fn() + timers.defer('a', 10, run) + timers.move('a', 'b') + vi.advanceTimersByTime(10) + expect(run).toHaveBeenCalledExactlyOnceWith('b') + }) + + it('replaces on defer, keeps on deferUnlessPending, and stops on cancel', () => { + vi.useFakeTimers() + const timers = new PaneTimers() + const first = vi.fn() + const second = vi.fn() + timers.defer('a', 10, first) + timers.defer('a', 10, second) + timers.deferUnlessPending('a', 10, first) + vi.advanceTimersByTime(10) + expect(first).not.toHaveBeenCalled() + expect(second).toHaveBeenCalledOnce() + timers.defer('a', 10, first) + timers.cancel('a') + vi.advanceTimersByTime(10) + expect(first).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/agent-hooks/server/program-status/pane-timers.ts b/src/main/agent-hooks/server/program-status/pane-timers.ts new file mode 100644 index 00000000000..b1843878536 --- /dev/null +++ b/src/main/agent-hooks/server/program-status/pane-timers.ts @@ -0,0 +1,60 @@ +type PaneTimer = { timer: ReturnType; paneKey: string } + +/** One pending, unref'd timer per pane; `run` gets the pane's current key, which a remint may move. */ +export class PaneTimers { + private readonly byPaneKey = new Map() + + /** Replaces the pane's pending timer. */ + defer(paneKey: string, delayMs: number, run: (paneKey: string) => void): void { + this.cancel(paneKey) + this.start(paneKey, delayMs, run) + } + + /** Keeps a pending timer, whose `run` reads current state when it fires. */ + deferUnlessPending(paneKey: string, delayMs: number, run: (paneKey: string) => void): void { + if (!this.byPaneKey.has(paneKey)) { + this.start(paneKey, delayMs, run) + } + } + + move(fromPaneKey: string, toPaneKey: string): void { + const entry = this.byPaneKey.get(fromPaneKey) + if (entry === undefined || fromPaneKey === toPaneKey) { + return + } + this.byPaneKey.delete(fromPaneKey) + this.cancel(toPaneKey) + entry.paneKey = toPaneKey + this.byPaneKey.set(toPaneKey, entry) + } + + cancel(paneKey: string): void { + const entry = this.byPaneKey.get(paneKey) + if (entry !== undefined) { + clearTimeout(entry.timer) + this.byPaneKey.delete(paneKey) + } + } + + clear(): void { + for (const entry of this.byPaneKey.values()) { + clearTimeout(entry.timer) + } + this.byPaneKey.clear() + } + + private start(paneKey: string, delayMs: number, run: (paneKey: string) => void): void { + const entry: PaneTimer = { + paneKey, + timer: setTimeout( + () => { + this.byPaneKey.delete(entry.paneKey) + run(entry.paneKey) + }, + Math.max(0, delayMs) + ) + } + entry.timer.unref?.() + this.byPaneKey.set(paneKey, entry) + } +} diff --git a/src/main/agent-hooks/server/server-authority-aliases.ts b/src/main/agent-hooks/server/server-authority-aliases.ts index 30d09811eed..924a0bde76e 100644 --- a/src/main/agent-hooks/server/server-authority-aliases.ts +++ b/src/main/agent-hooks/server/server-authority-aliases.ts @@ -224,6 +224,9 @@ export abstract class AgentHookServerAuthorityAliases extends AgentHookServerAut this.activeHookTurnCompletedAtByPaneKey.delete(previousOwnerPaneKey) this.activeHookTurnCompletedAtByPaneKey.set(toPaneKey, activeTurnCompletedAt) } + this.programStatusFacets.move(previousOwnerPaneKey, toPaneKey) + this.hookSilenceRechecks.move(previousOwnerPaneKey, toPaneKey) + this.programStatusFacetRepublishes.move(previousOwnerPaneKey, toPaneKey) const evidenceObservedAt = this.evidenceObservedAtByPaneKey.get(previousOwnerPaneKey) if (evidenceObservedAt !== undefined) { this.evidenceObservedAtByPaneKey.delete(previousOwnerPaneKey) diff --git a/src/main/agent-hooks/server/server-ingest-terminal.ts b/src/main/agent-hooks/server/server-ingest-terminal.ts index 15537b938f3..a3ecc368c34 100644 --- a/src/main/agent-hooks/server/server-ingest-terminal.ts +++ b/src/main/agent-hooks/server/server-ingest-terminal.ts @@ -9,8 +9,16 @@ import { } from '../../../shared/agent-session-resume' import type { EnrichedAgentHookEventPayload } from './server-types' import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' +import { agentSubagentsEqual } from '../../../shared/agent-status-subagent-snapshot' import { isAgentStatusHeldOpenByChildWork } from '../../../shared/agent-lead-status-fold' import { AgentHookServerIngestNormalization } from './server-ingest-normalization' +import { + HOOK_SILENCE_BEFORE_PROGRAM_STATUS_FALLBACK_MS, + programStatusYieldsToHookRow +} from './program-status/hook-precedence' +import type { ProgramStatusFacet } from '../../../shared/program-status/facet' + +const PROGRAM_STATUS_FACET_REPUBLISH_MS = 250 export abstract class AgentHookServerIngestTerminal extends AgentHookServerIngestNormalization { /** @@ -61,6 +69,31 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges this.applyNormalizedStatus(identity, undefined, 'launch') } + /** The pane's OSC 7501 facet changed. Applies whoever owns the row's state; see ProgramStatusFacetStore. */ + ingestProgramStatusFacet(event: { paneKey: string; facet: ProgramStatusFacet | null }): void { + const paneKey = this.resolvePaneKeyAlias(event.paneKey.trim()) + if ( + paneKey.length > MAX_PANE_KEY_LEN || + (!parsePaneKey(paneKey) && !parseLegacyNumericPaneKey(paneKey)) + ) { + return + } + const before = this.programStatusFacets.get(paneKey) + if (!this.programStatusFacets.set(paneKey, event.facet)) { + return + } + if (before !== undefined && event.facet !== null && before.state === event.facet.state) { + this.programStatusFacetRepublishes.deferUnlessPending( + paneKey, + PROGRAM_STATUS_FACET_REPUBLISH_MS, + (currentPaneKey) => this.republishProgramStatusFacet(currentPaneKey) + ) + return + } + this.programStatusFacetRepublishes.cancel(paneKey) + this.republishProgramStatusFacet(paneKey) + } + ingestTerminalStatus(event: { ptyId?: string paneKey: string @@ -73,6 +106,12 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges origin?: 'process' /** Drop this write when a hook has reported the pane since then (the hook owns that command). */ yieldsToHookSince?: number + /** Projected from OSC 7501: yields to the pane's hook row (programStatusYieldsToHookRow). */ + fromProgramStatus?: true + /** The program left no records: a row only OSC wrote is removed instead. */ + removesProgramStatusRow?: true + /** A repeat that renews a live program row's freshness; the window must see it too. */ + renewsProgramStatusLease?: true }): void { const physicalPaneKey = event.paneKey.trim() let paneKey = this.resolvePaneKeyAlias(physicalPaneKey) @@ -160,9 +199,49 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges event.yieldsToHookSince !== undefined && previous?.observation?.origin === 'hook' && previous.receivedAt >= event.yieldsToHookSince + if (event.removesProgramStatusRow === true) { + // Why: gone records end only the program's own row; they are no verdict on a hook agent. + this.hookSilenceRechecks.cancel(paneKey) + if (previous?.observation?.origin === 'osc') { + // Why a row drop, not pane teardown: the process lives on, and so do its alias and caches. + if (mutationBefore !== undefined) { + this.commitStatusRowMutation(mutationBefore, previous) + } + this.dropStatusEntry(paneKey) + this.emitPaneStatusCleared({ paneKey, programStatusRemoved: true }) + } + return + } + const hookOwnsAgent = + event.fromProgramStatus === true && + programStatusYieldsToHookRow(previous, event.payload, Date.now()) + if (event.fromProgramStatus === true) { + if (hookOwnsAgent && previous && event.payload.state !== previous.payload.state) { + const yieldedTo = previous.receivedAt + const delayMs = yieldedTo + HOOK_SILENCE_BEFORE_PROGRAM_STATUS_FALLBACK_MS - Date.now() + this.hookSilenceRechecks.defer(paneKey, delayMs + 1, (currentPaneKey) => { + const row = this.state.lastStatusByPaneKey.get(currentPaneKey) + // Only against the hook observation it yielded to: a newer turn or a cleared pane drops it. + if ( + row && + row.providerSessionOnly !== true && + 'receivedAt' in row && + row.receivedAt === yieldedTo + ) { + const tabId = parsePaneKey(currentPaneKey)?.tabId ?? event.tabId + this.ingestTerminalStatus({ ...event, paneKey: currentPaneKey, tabId }) + } + }) + } else { + this.hookSilenceRechecks.cancel(paneKey) + } + } if ( hookOwnsCommand || - (previous?.payload.agentType === 'claude' && + hookOwnsAgent || + // OSC 7501 settles child-held rows through its own hook precedence above. + (event.fromProgramStatus !== true && + previous?.payload.agentType === 'claude' && event.payload.agentType === 'claude' && isAgentStatusHeldOpenByChildWork(previous.payload) && previous.payload.subagents?.some((subagent) => subagent.state === 'working') === true) @@ -187,11 +266,18 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges // Why in the unchanged gate: the handle is a join key readers match on, so a pane that // only just acquired one (or moved to another) must still refresh the row it is stamped on. previous.terminalHandle === (terminalHandle ?? previous.terminalHandle) && - terminalStatusPayloadMatchesHook(previous.payload, event.payload, preserveActiveTurnStamp) + terminalStatusPayloadMatchesHook(previous.payload, event.payload, preserveActiveTurnStamp) && + // OSC 7501 does carry children, so a child-only change is news (absent means none left). + (event.fromProgramStatus !== true || + agentSubagentsEqual(previous.payload.subagents, event.payload.subagents)) ) { // A handle-authority transfer is a new pane observation even when its payload is a // duplicate; enriched subscribers must capture the replacement pane identity. - this.refreshTerminalStatusEvidence(previous, mutationBefore, mutationBefore !== undefined) + this.refreshTerminalStatusEvidence( + previous, + mutationBefore, + mutationBefore !== undefined || event.renewsProgramStatusLease === true + ) return } // Why: the OSC 9999 wire payload has no providerSession field at all, so an OSC observation is diff --git a/src/main/agent-hooks/server/server-lifecycle.ts b/src/main/agent-hooks/server/server-lifecycle.ts index 95835546c82..3391b6548dc 100644 --- a/src/main/agent-hooks/server/server-lifecycle.ts +++ b/src/main/agent-hooks/server/server-lifecycle.ts @@ -270,6 +270,8 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerStatusHook clearTimeout(timer) } this.assistantMessageRetryTimers.clear() + this.hookSilenceRechecks.clear() + this.programStatusFacetRepublishes.clear() this.clearAllTranscriptPolls() this.claudeOwedNotificationExpiry.clearAll() this.endpointDir = null @@ -291,6 +293,7 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerStatusHook this.retiredPaneFencesByKey.clear() this.connectionTimestampWatermarkById.clear() this.evidenceObservedAtByPaneKey.clear() + this.programStatusFacets.clear() this.activeHookTurnCompletedAtByPaneKey.clear() this.legacyPaneKeyAliases.clear() this.paneKeyAliasPersistenceListener = null diff --git a/src/main/agent-hooks/server/server-listeners.ts b/src/main/agent-hooks/server/server-listeners.ts index 14b78262734..49ca647a932 100644 --- a/src/main/agent-hooks/server/server-listeners.ts +++ b/src/main/agent-hooks/server/server-listeners.ts @@ -21,11 +21,20 @@ import { toAgentStatusIpcPayload } from './server-status-identity' import { AgentHookServerState } from './server-state' import { serializeAgentStatusSubject } from '../../../shared/agent-status-subject' import { structuredStatusLegacyEvent } from './server-structured-status-row' +import { PaneTimers } from './program-status/pane-timers' +import { ProgramStatusFacetStore } from './program-status/facet-store' // Why: the listing counter starts at 1, so an unassigned row must sort last — never above every ordered row. const UNORDERED_STATUS_ROW = Number.MAX_SAFE_INTEGER export abstract class AgentHookServerListeners extends AgentHookServerState { + // Why here: every publish below stitches from it, and all later layers manage its lifecycle. + protected readonly programStatusFacets = new ProgramStatusFacetStore() + /** Yielded OSC 7501 reports, re-offered when hook precedence lapses: programs never resend a state. */ + protected readonly hookSilenceRechecks = new PaneTimers() + /** Trailing facet-only republishes: progress and message ticks can arrive at PTY chunk rate. */ + protected readonly programStatusFacetRepublishes = new PaneTimers() + setStartupPromptClaimListener( listener: (body: unknown) => boolean | 'pending', clear: () => void @@ -34,7 +43,8 @@ export abstract class AgentHookServerListeners extends AgentHookServerState { this.clearStartupPromptClaims = clear } - protected emitEnrichedStatus(enriched: EnrichedAgentHookEventPayload): void { + protected emitEnrichedStatus(row: EnrichedAgentHookEventPayload): void { + const enriched = this.programStatusFacets.stitch(row) this.onAgentStatus?.(enriched) for (const listener of this.enrichedStatusListeners) { try { @@ -58,7 +68,7 @@ export abstract class AgentHookServerListeners extends AgentHookServerState { for (const [paneKey, entry] of this.state.lastStatusByPaneKey) { rows.push({ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Main admits enriched legacy rows; shared listeners expose only the base event type. - entry: entry as EnrichedAgentHookEventPayload, + entry: this.programStatusFacets.stitch(entry as EnrichedAgentHookEventPayload), order: getLegacyStatusListingOrder(this.state, paneKey) ?? UNORDERED_STATUS_ROW }) } @@ -222,7 +232,8 @@ export abstract class AgentHookServerListeners extends AgentHookServerState { const legacy = this.state.lastStatusByPaneKey.get(paneKey) if (legacy) { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Main admits enriched legacy rows; the shared view declares their base event type. - return [toAgentStatusIpcPayload(legacy as EnrichedAgentHookEventPayload)] + const row = legacy as EnrichedAgentHookEventPayload + return [toAgentStatusIpcPayload(this.programStatusFacets.stitch(row))] } const rows: AgentStatusIpcPayload[] = [] for (const subject of this.canonicalSubjectsByPane.get(paneKey)?.values() ?? []) { diff --git a/src/main/agent-hooks/server/server-persistence.ts b/src/main/agent-hooks/server/server-persistence.ts index 66e58fb7bcc..193fee1fda1 100644 --- a/src/main/agent-hooks/server/server-persistence.ts +++ b/src/main/agent-hooks/server/server-persistence.ts @@ -42,6 +42,9 @@ export abstract class AgentHookServerPersistence extends AgentHookServerHydratio } const { promptInteractionKey: _promptInteractionKey, + // Why: stitched on at publish only; guards a stitched copy that reached the map by mistake. + programStatus: _programStatus, + programStatusOnly: _programStatusOnly, // Why: never persisted — hydrate re-stamps it, so a stored copy could only drift. restoredUnconfirmed: _restoredUnconfirmed, // Why: same — the sequencer that issued it dies with the process (see PersistedAgentHookEventPayload). diff --git a/src/main/agent-hooks/server/server-row-ownership.ts b/src/main/agent-hooks/server/server-row-ownership.ts index 6a778ee2d22..e8115d32661 100644 --- a/src/main/agent-hooks/server/server-row-ownership.ts +++ b/src/main/agent-hooks/server/server-row-ownership.ts @@ -111,6 +111,21 @@ export abstract class AgentHookServerRowOwnership extends AgentHookServerListene return true } + /** Republish a row whose OSC 7501 facet alone changed, to the window and row-mutation readers only: + * enriched listeners (plugins, recorder, titles) must not see progress ticks as transitions. */ + protected republishProgramStatusFacet(paneKey: string): void { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Main admits enriched legacy rows; the shared view declares their base event type. + const row = this.state.lastStatusByPaneKey.get(paneKey) as + | EnrichedAgentHookEventPayload + | undefined + if (!row || row.providerSessionOnly === true) { + return + } + this.onAgentStatus?.({ ...this.programStatusFacets.stitch(row), programStatusOnly: true }) + const identity = toMutationIdentity(row) + this.notifyStatusRowMutationListeners({ before: identity, after: identity }) + } + private notifyStatusRowMutationListeners(mutation: AgentHookStatusRowMutation): void { for (const listener of this.statusRowMutationListeners) { try { diff --git a/src/main/agent-hooks/server/server-status-identity.ts b/src/main/agent-hooks/server/server-status-identity.ts index be824aaf09b..0e798e74230 100644 --- a/src/main/agent-hooks/server/server-status-identity.ts +++ b/src/main/agent-hooks/server/server-status-identity.ts @@ -68,6 +68,7 @@ export function toAgentStatusIpcPayload( ...(entry.observation ? { observation: entry.observation } : {}), ...(entry.structuredHost ? { structuredHost: entry.structuredHost } : {}), ...(entry.terminalHandle ? { terminalHandle: entry.terminalHandle } : {}), + ...(entry.programStatus ? { programStatus: entry.programStatus } : {}), ...entry.payload } } diff --git a/src/main/agent-hooks/server/server-tab-cleanup.ts b/src/main/agent-hooks/server/server-tab-cleanup.ts index fa2729770dd..5966d243368 100644 --- a/src/main/agent-hooks/server/server-tab-cleanup.ts +++ b/src/main/agent-hooks/server/server-tab-cleanup.ts @@ -85,6 +85,9 @@ export abstract class AgentHookServerTabCleanup extends AgentHookServerCleanup { this.promptSentDedupeByPaneKey.delete(paneKey) this.restartedStatusLaunchTokenHashByPaneKey.delete(paneKey) this.evidenceObservedAtByPaneKey.delete(paneKey) + this.programStatusFacets.delete(paneKey) + this.hookSilenceRechecks.cancel(paneKey) + this.programStatusFacetRepublishes.cancel(paneKey) } // Why: an exit outlives its row, so it is matched by tab rather than by the keys cleared above. let exitsChanged = false @@ -143,6 +146,9 @@ export abstract class AgentHookServerTabCleanup extends AgentHookServerCleanup { this.restartedStatusLaunchTokenHashByPaneKey.delete(resolvedPaneKey) // Why: the pane itself is gone, so its observation clock describes nothing a later pane owns. this.evidenceObservedAtByPaneKey.delete(resolvedPaneKey) + this.programStatusFacets.delete(resolvedPaneKey) + this.hookSilenceRechecks.cancel(resolvedPaneKey) + this.programStatusFacetRepublishes.cancel(resolvedPaneKey) let clearedAlias = false for (const [legacyPaneKey, alias] of this.legacyPaneKeyAliases) { if (alias.stablePaneKey === resolvedPaneKey) { @@ -155,6 +161,7 @@ export abstract class AgentHookServerTabCleanup extends AgentHookServerCleanup { this.promptSentDedupeByPaneKey.delete(legacyPaneKey) this.restartedStatusLaunchTokenHashByPaneKey.delete(legacyPaneKey) this.evidenceObservedAtByPaneKey.delete(legacyPaneKey) + this.programStatusFacets.delete(legacyPaneKey) clearedAlias = true } } diff --git a/src/main/agent-hooks/server/server-types.ts b/src/main/agent-hooks/server/server-types.ts index 66324e3cfd4..6b88d4e85ad 100644 --- a/src/main/agent-hooks/server/server-types.ts +++ b/src/main/agent-hooks/server/server-types.ts @@ -1,3 +1,4 @@ +import type { ProgramStatusFacet } from '../../../shared/program-status/facet' import type { ClaudeStatusLineRateLimits } from '../../../shared/claude-statusline-rate-limits' import type { PersistedAgentExitEvidence } from './agent-exit-evidence' import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' @@ -29,6 +30,10 @@ export type EnrichedAgentHookEventPayload = AgentHookEventPayload & { restoredUnconfirmed?: true /** User-hidden resume identity retained solely for destructive liveness checks. */ retainedForLiveness?: true + /** The pane's OSC 7501 facet, stitched on at publish only (ProgramStatusFacetStore); never stored. */ + programStatus?: ProgramStatusFacet + /** Marks a publish where only `programStatus` changed: state subscribers must not read a new state. */ + programStatusOnly?: true } // `claudeRunningNonAgentTask` is persisted on purpose: it is the one child-work fact the row's @@ -40,6 +45,8 @@ export type PersistedAgentHookEventPayload = Omit< | 'launchToken' | 'hostTurnRevision' | 'promptInteractionKey' + | 'programStatus' + | 'programStatusOnly' | 'restoredUnconfirmed' // Why: revision counters are in-memory and the authority id is regenerated per process, so // a stored observation could only rehydrate as a stale ordering claim from a dead authority. diff --git a/src/main/daemon/terminal-view-attribute-responder.test.ts b/src/main/daemon/terminal-view-attribute-responder.test.ts new file mode 100644 index 00000000000..d1811831cd9 --- /dev/null +++ b/src/main/daemon/terminal-view-attribute-responder.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import { Terminal } from '@xterm/headless' +import { PROGRAM_STATUS_QUERY_REPLY } from '../../shared/program-status/protocol' +import { installTerminalViewAttributeResponder } from './terminal-view-attribute-responder' + +function write(terminal: Terminal, data: string): Promise { + return new Promise((resolve) => terminal.write(data, resolve)) +} + +describe('installTerminalViewAttributeResponder', () => { + it('answers OSC 7501 feature detection for a hidden pane, even before a theme push', async () => { + const terminal = new Terminal({ allowProposedApi: true }) + const replies: string[] = [] + installTerminalViewAttributeResponder({ + parser: terminal.parser, + getBaseAttributes: () => null, + emitReply: (reply) => replies.push(reply) + }) + await write(terminal, '\x1b]7501;?\x1b\\\x1b]11;?\x1b\\') + // Why no OSC 11 reply: without a renderer push the color queries stay silent by design. + expect(replies).toEqual([PROGRAM_STATUS_QUERY_REPLY]) + terminal.dispose() + }) +}) diff --git a/src/main/daemon/terminal-view-attribute-responder.ts b/src/main/daemon/terminal-view-attribute-responder.ts index decca3d3f99..b28e0591fc8 100644 --- a/src/main/daemon/terminal-view-attribute-responder.ts +++ b/src/main/daemon/terminal-view-attribute-responder.ts @@ -1,6 +1,6 @@ /** - * Phase 5 slice 2 (View-attribute bridge): OSC 4/10/11/12 and DSR ?996n - * responder handlers for the runtime + * Phase 5 slice 2 (View-attribute bridge): OSC 4/10/11/12, DSR ?996n and OSC 7501 + * feature-detection responder handlers for the runtime * headless emulator. The headless xterm core has no theme service, so these * handlers compute replies from the renderer's pushed attribute snapshot, * with per-PTY OSC SET mutations layered on top — mirroring exactly what the @@ -16,6 +16,7 @@ import { type TerminalViewAttributes, type TerminalViewRgb } from '../../shared/terminal-view-attributes' +import { installProgramStatusQueryResponder } from '../../shared/program-status/query-responder' type ViewAttributeParser = Pick @@ -131,6 +132,7 @@ export function installTerminalViewAttributeResponder( } return true }) + installProgramStatusQueryResponder({ parser: deps.parser, emitReply: deps.emitReply }) deps.parser.registerOscHandler(10, (data) => handleSpecialColor(data, 0)) deps.parser.registerOscHandler(11, (data) => handleSpecialColor(data, 1)) deps.parser.registerOscHandler(12, (data) => handleSpecialColor(data, 2)) diff --git a/src/main/runtime/construction/runtime-construction-dependencies.ts b/src/main/runtime/construction/runtime-construction-dependencies.ts index a5ca429fd39..8c5119ed9dd 100644 --- a/src/main/runtime/construction/runtime-construction-dependencies.ts +++ b/src/main/runtime/construction/runtime-construction-dependencies.ts @@ -1,7 +1,10 @@ import type { ExecutionHostScope } from '../../../shared/execution-host' import type { IPtyProvider } from '../../providers/pty-provider-contract' import type { PrepareClaudeAuth } from '../../ipc/pty/host-env/types' -import type { RuntimeTerminalAgentStatusEvent } from '../runtime-terminal-contracts' +import type { + RuntimeTerminalAgentStatusEvent, + RuntimeTerminalProgramStatusFacetEvent +} from '../runtime-terminal-contracts' import type { TerminalSideEffectBatch } from '../../../shared/terminal-side-effect-facts' import type { ClaudeTerminalEvidence } from '../../../shared/claude-terminal-interrupt' import type { AgentStatusIpcPayload } from '../../../shared/agent-status-types' @@ -22,6 +25,7 @@ export type RuntimeConstructionDependencies = { prepareClaudeAuth?: PrepareClaudeAuth onPtyStopped?: (ptyId: string) => void onTerminalAgentStatus?: (event: RuntimeTerminalAgentStatusEvent) => void + onTerminalProgramStatusFacet?: (event: RuntimeTerminalProgramStatusFacetEvent) => void onTerminalSideEffects?: (batch: TerminalSideEffectBatch) => void onClaudeTerminalEvidence?: (paneKey: string, evidence: ClaudeTerminalEvidence) => void getAgentStatusSnapshot?: () => AgentStatusIpcPayload[] diff --git a/src/main/runtime/construction/runtime-construction.ts b/src/main/runtime/construction/runtime-construction.ts index ce9ae0cf5a6..f3c94858c6f 100644 --- a/src/main/runtime/construction/runtime-construction.ts +++ b/src/main/runtime/construction/runtime-construction.ts @@ -45,6 +45,7 @@ export abstract class OrcaRuntimeWithStateFields extends OrcaRuntimeWithLinearCo declare protected readonly getLocalProviderFn: HostServices['getLocalProviderFn'] declare readonly onPtyStopped: HostServices['onPtyStopped'] declare protected readonly onTerminalAgentStatus: HostServices['onTerminalAgentStatus'] + declare protected readonly onTerminalProgramStatusFacet: HostServices['onTerminalProgramStatusFacet'] declare protected readonly onTerminalSideEffects: HostServices['onTerminalSideEffects'] declare protected readonly onClaudeTerminalEvidence: HostServices['onClaudeTerminalEvidence'] declare readonly getAgentStatusSnapshotFn: HostServices['getAgentStatusSnapshotFn'] @@ -231,6 +232,7 @@ export abstract class OrcaRuntimeWithStateFields extends OrcaRuntimeWithLinearCo this.getLocalProviderFn = deps?.getLocalProvider ?? null this.onPtyStopped = deps?.onPtyStopped ?? null this.onTerminalAgentStatus = deps?.onTerminalAgentStatus ?? null + this.onTerminalProgramStatusFacet = deps?.onTerminalProgramStatusFacet ?? null this.onClaudeTerminalEvidence = deps?.onClaudeTerminalEvidence ?? null this.buildAgentHookPtyEnv = deps?.buildAgentHookPtyEnv ?? null this.getDesktopWindowStatusFn = deps?.getDesktopWindowStatus ?? (() => 'openable') diff --git a/src/main/runtime/construction/runtime-host-services.ts b/src/main/runtime/construction/runtime-host-services.ts index 19d1f32377e..5ec003ab2d5 100644 --- a/src/main/runtime/construction/runtime-host-services.ts +++ b/src/main/runtime/construction/runtime-host-services.ts @@ -96,6 +96,10 @@ export abstract class OrcaRuntimeWithPreservedBranchCleanup extends OrcaRuntimeW protected readonly onTerminalAgentStatus!: HostBindings['onTerminalAgentStatus'] | null + protected readonly onTerminalProgramStatusFacet!: + | HostBindings['onTerminalProgramStatusFacet'] + | null + protected readonly onTerminalSideEffects!: HostBindings['onTerminalSideEffects'] | null protected readonly onClaudeTerminalEvidence!: HostBindings['onClaudeTerminalEvidence'] | null diff --git a/src/main/runtime/orca-runtime-tests/program-status-osc.spec.ts b/src/main/runtime/orca-runtime-tests/program-status-osc.spec.ts new file mode 100644 index 00000000000..e2cfa2cf7b3 --- /dev/null +++ b/src/main/runtime/orca-runtime-tests/program-status-osc.spec.ts @@ -0,0 +1,189 @@ +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from '../orca-runtime-test-mocks.spec' +import { createSideEffectRuntime } from '../orca-runtime-test-scenario-builders.spec' +import type { RuntimeTerminalAgentStatusEvent } from '../orca-runtime-test-mocks.spec' +import { + syncSinglePty, + TEST_WORKTREE_PATH, + expectStablePaneKeyEnv, + spawnedPtyEnv, + store +} from '../orca-runtime-test-fixtures.spec' + +describe('OSC 7501 program status', () => { + it('projects split reports from a rendererless PTY into hook-yielding status events', async () => { + const statuses: RuntimeTerminalAgentStatusEvent[] = [] + const spawn = vi.fn().mockResolvedValue({ id: 'pty-ps' }) + const runtime = new OrcaRuntimeService(store, undefined, { + onTerminalAgentStatus: (event) => statuses.push(event) + }) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { command: 'claude', title: 'w' }) + const paneKey = expectStablePaneKeyEnv(spawnedPtyEnv(spawn.mock.calls[0]?.[0])) + + const question = Buffer.from('answer: Tea or coffee?').toString('base64') + runtime.onPtyData('pty-ps', 'x\x1b]7501;state=working:app=claude-code\x1b\\\x1b]75', 1) + runtime.onPtyData( + 'pty-ps', + `01;state=blocked:kind=question:app=claude-code:msg=${question}\x07`, + 2 + ) + + expect( + statuses.map((event) => [event.paneKey, event.fromProgramStatus, event.payload]) + ).toEqual([ + [paneKey, true, expect.objectContaining({ state: 'working', agentType: 'claude' })], + [ + paneKey, + true, + expect.objectContaining({ + state: 'waiting', + agentType: 'claude', + toolInput: 'answer: Tea or coffee?' + }) + ] + ]) + }) + + it("hands the store each chunk's facet before the rows that chunk publishes", async () => { + const order: string[] = [] + const spawn = vi.fn().mockResolvedValue({ id: 'pty-order' }) + const runtime = new OrcaRuntimeService(store, undefined, { + onTerminalAgentStatus: (event) => order.push(`status:${event.payload.state}`), + onTerminalProgramStatusFacet: (event) => order.push(`facet:${event.facet?.state ?? 'none'}`) + }) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { command: 'brew', title: 'w' }) + + runtime.onPtyData('pty-order', '\x1b]7501;state=working:app=brew:progress=10\x1b\\', 1) + runtime.onPtyData('pty-order', '\x1b]7501;state=done:app=brew\x1b\\', 2) + + // Why it matters: a reader between the two (the 133;D drop) must never see done + a live facet. + expect(order).toEqual(['facet:working', 'status:working', 'facet:done', 'status:done']) + }) + + it('clears a live facet when its PTY exits but keeps a finished one', async () => { + const facets: [string, string | null][] = [] + const spawn = vi + .fn() + .mockResolvedValueOnce({ id: 'pty-live' }) + .mockResolvedValueOnce({ id: 'pty-done' }) + const runtime = new OrcaRuntimeService(store, undefined, { + onTerminalProgramStatusFacet: (event) => + facets.push([event.ptyId, event.facet?.state ?? null]) + }) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { command: 'brew', title: 'a' }) + await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { command: 'brew', title: 'b' }) + + runtime.onPtyData('pty-live', '\x1b]7501;state=working:app=brew:progress=10\x1b\\', 1) + runtime.onPtyData('pty-done', '\x1b]7501;state=done:app=brew\x1b\\', 1) + await runtime.onPtyExit('pty-live', 0) + await runtime.onPtyExit('pty-done', 0) + + expect(facets).toEqual([ + ['pty-live', 'working'], + ['pty-done', 'done'], + ['pty-live', null] + ]) + }) + + it("drives agent edges and the PTY's status from Claude's OSC 7501, not its glyph-less titles", () => { + // Byte order captured from Claude Code 2.1.302 once the terminal answers the OSC 7501 probe: + // its titles lose the ⠂/✳ prefix, so only the program's report says working or idle. + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + const osc7501 = (body: string) => `\x1b]7501;${body}\x1b\\` + type PtyRecords = { ptysById: Map } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: reads the runtime's own PTY record, which the public API does not expose. + const records = runtime as unknown as PtyRecords + const ptyStatus = () => records.ptysById.get('pty-1')?.lastAgentStatus + + runtime.onPtyData('pty-1', `${osc7501('state=idle:app=claude-code')}\x1b]0;Claude Code\x07`, 1) + expect(ptyStatus()).toBe('idle') + runtime.onPtyData('pty-1', osc7501('state=working:app=claude-code'), 2) + expect(ptyStatus()).toBe('working') + runtime.onPtyData('pty-1', '\x1b]0;Fix the parser\x07', 3) + expect(ptyStatus()).toBe('working') + runtime.onPtyData('pty-1', osc7501('state=done:app=claude-code'), 4) + expect(ptyStatus()).toBe('idle') + + const edges = batches.flatMap((batch) => + batch.facts.filter((fact) => fact.kind === 'agent-working' || fact.kind === 'agent-idle') + ) + expect(edges).toEqual([ + { kind: 'agent-working' }, + { kind: 'agent-idle', title: 'Fix the parser', status: 'idle' } + ]) + }) + + it("returns the PTY's status to its titles once the shell prompt follows a program's done", () => { + const { runtime } = createSideEffectRuntime() + syncSinglePty(runtime) + const osc7501 = (body: string) => `\x1b]7501;${body}\x1b\\` + type PtyRecords = { + ptysById: Map< + string, + { lastAgentStatus: string | null; lastExplicitAgentStatus?: { programAtRest?: boolean } } + > + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: reads the runtime's own PTY record, which the public API does not expose. + const records = runtime as unknown as PtyRecords + const pty = () => records.ptysById.get('pty-1') + + runtime.onPtyData('pty-1', `\x1b]0;tf\x07${osc7501('state=done:app=terraform')}`, 1) + expect(pty()?.lastAgentStatus).toBe('idle') + expect(pty()?.lastExplicitAgentStatus?.programAtRest).toBe(true) + runtime.onPtyData('pty-1', '\x1b]133;A\x07', 2) + expect(pty()?.lastAgentStatus).toBeNull() + expect(pty()?.lastExplicitAgentStatus?.programAtRest).toBeUndefined() + runtime.onPtyData('pty-1', '\x1b]0;⠋ Gemini working\x07', 3) + expect(pty()?.lastAgentStatus).toBe('working') + }) + + it('re-classifies on an activity change without restamping the last title', () => { + const { runtime } = createSideEffectRuntime() + syncSinglePty(runtime) + type PtyRecords = { + ptysById: Map + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: reads the runtime's own PTY record, which the public API does not expose. + const records = runtime as unknown as PtyRecords + const pty = () => records.ptysById.get('pty-1') + + runtime.onPtyData('pty-1', '\x1b]0;Fix the parser\x07', 1) + const titledAt = pty()?.lastOscTitleAt + runtime.onPtyData('pty-1', '\x1b]7501;state=working:app=claude-code\x1b\\', 2) + expect(pty()?.lastAgentStatus).toBe('working') + expect(pty()?.lastOscTitleAt).toBe(titledAt) + }) + + it("classifies a chunk's trailing glyph-less title by the program's activity for the prompt lifecycle", () => { + const { runtime } = createSideEffectRuntime() + syncSinglePty(runtime) + type Lifecycle = { agentPromptLifecycleByPtyId: Map } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: reads the runtime's own lifecycle map, which the public API does not expose. + const lifecycle = runtime as unknown as Lifecycle + runtime.onPtyData( + 'pty-1', + '\x1b]7501;state=working:app=claude-code\x1b\\\x1b]0;Fix the parser\x07', + 1 + ) + expect(lifecycle.agentPromptLifecycleByPtyId.get('pty-1')?.status).toBe('working') + }) +}) diff --git a/src/main/runtime/orca-runtime-tests/terminal-side-effect-facts-part-02.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-side-effect-facts-part-02.spec.ts index 3d3f90f1200..e227246f990 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-side-effect-facts-part-02.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-side-effect-facts-part-02.spec.ts @@ -662,7 +662,8 @@ describe('terminal side-effect fact channel', () => { // Without the seed the tracker never saw 'working', so this idle title could not produce a completion fact. expect(batches.flatMap((batch) => batch.facts)).toContainEqual({ kind: 'agent-idle', - title: 'Codex done' + title: 'Codex done', + status: 'idle' }) }) }) diff --git a/src/main/runtime/orca-runtime-tests/terminal-side-effect-facts-part-03.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-side-effect-facts-part-03.spec.ts index 903bc997ad7..008e7ea9d28 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-side-effect-facts-part-03.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-side-effect-facts-part-03.spec.ts @@ -39,7 +39,7 @@ describe('terminal side-effect fact channel', () => { rawTitle: 'Codex', staleWorkingTitleClear: true }, - { kind: 'agent-idle', title: 'Codex', staleWorkingTitleClear: true } + { kind: 'agent-idle', title: 'Codex', status: 'idle', staleWorkingTitleClear: true } ]) } finally { vi.useRealTimers() diff --git a/src/main/runtime/orca-runtime-tests/terminal-side-effect-facts.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-side-effect-facts.spec.ts index a65181043d1..98963d077c0 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-side-effect-facts.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-side-effect-facts.spec.ts @@ -228,7 +228,7 @@ describe('terminal side-effect fact channel', () => { { kind: 'title', normalizedTitle: 'Codex working', rawTitle: 'Codex working' }, { kind: 'agent-working' }, { kind: 'title', normalizedTitle: 'Codex done', rawTitle: 'Codex done' }, - { kind: 'agent-idle', title: 'Codex done' }, + { kind: 'agent-idle', title: 'Codex done', status: 'idle' }, { kind: 'bell' } ]) }) @@ -283,7 +283,7 @@ describe('terminal side-effect fact channel', () => { rawTitle: 'Codex', staleWorkingTitleClear: true }, - { kind: 'agent-idle', title: 'Codex', staleWorkingTitleClear: true } + { kind: 'agent-idle', title: 'Codex', status: 'idle', staleWorkingTitleClear: true } ]) } finally { vi.useRealTimers() diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index d9208c16f2c..71835379b81 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -39,6 +39,7 @@ await import('./orca-runtime-tests/headless-snapshots.spec') await import('./orca-runtime-tests/headless-snapshots-part-02.spec') await import('./orca-runtime-tests/reattach-headless-grid.spec') await import('./orca-runtime-tests/agent-status-and-waits.spec') +await import('./orca-runtime-tests/program-status-osc.spec') await import('./orca-runtime-tests/agent-status-and-waits-part-02.spec') await import('./orca-runtime-tests/agent-status-and-waits-part-03.spec') await import('./orca-runtime-tests/terminal-creation-and-readiness.spec') diff --git a/src/main/runtime/runtime-hook-agent-row-selection.ts b/src/main/runtime/runtime-hook-agent-row-selection.ts index 62caa503c50..fa889ad7815 100644 --- a/src/main/runtime/runtime-hook-agent-row-selection.ts +++ b/src/main/runtime/runtime-hook-agent-row-selection.ts @@ -6,6 +6,7 @@ import { type ParsedAgentStatusPayload } from '../../shared/agent-status-types' import type { AgentProviderSessionMetadata } from '../../shared/agent-session-resume' +import type { ProgramStatusFacet } from '../../shared/program-status/facet' import type { RuntimeTerminalAgentStatus } from '../../shared/runtime-types' import { mapExplicitAgentStateToRuntimeTerminalStatus } from './runtime-worktree-status-projection' @@ -20,6 +21,7 @@ export type RuntimeAgentRowSnapshot = { updatedAt: number evidenceObservedAt?: number providerSession?: AgentProviderSessionMetadata + programStatus?: ProgramStatusFacet } function isLiveObservation(row: AgentStatusIpcPayload): boolean { @@ -134,6 +136,7 @@ export function selectFreshAgentRowForMobileTab(args: { stateStartedAt: match.stateStartedAt ?? match.receivedAt, updatedAt: match.receivedAt, ...(match.providerSession ? { providerSession: match.providerSession } : {}), + ...(match.programStatus ? { programStatus: match.programStatus } : {}), ...(match.evidenceObservedAt !== undefined ? { evidenceObservedAt: match.evidenceObservedAt } : {}) diff --git a/src/main/runtime/runtime-mobile-agent-status-builder.test.ts b/src/main/runtime/runtime-mobile-agent-status-builder.test.ts index acc7b10c97e..ef0480a35a6 100644 --- a/src/main/runtime/runtime-mobile-agent-status-builder.test.ts +++ b/src/main/runtime/runtime-mobile-agent-status-builder.test.ts @@ -38,6 +38,50 @@ describe('mobile agent status builder', () => { }) ) }) + + it('carries the program-status facet from a retained hook row', () => { + const programStatus = { state: 'blocked' as const, kind: 'auth' as const, observedAt: 10 } + const retained: RuntimeAgentRowSnapshot = { + paneKey: 'tab:leaf', + connectionId: null, + payload: { state: 'working', prompt: 'ship it', agentType: 'codex' }, + stateStartedAt: 10, + updatedAt: 10, + programStatus + } + + const result = buildRuntimeMobileAgentStatus(null, TAB, 'term-1', retained, () => [], { + getPaneKey: () => 'tab:leaf', + getLeaf: () => null, + getTrackedTitle: () => null + }) + + expect(result.agentStatus?.programStatus).toEqual(programStatus) + }) + + it('carries the program-status facet from a live hook row with no renderer', () => { + const programStatus = { state: 'working' as const, progress: 42, observedAt: 10 } + const liveRow: AgentStatusIpcPayload = { + paneKey: 'tab:leaf', + state: 'working', + prompt: 'ship it', + agentType: 'codex', + connectionId: null, + receivedAt: Date.now(), + stateStartedAt: Date.now(), + tabId: 'tab', + worktreeId: 'wt-1', + programStatus + } + + const result = buildRuntimeMobileAgentStatus(null, TAB, 'term-1', null, () => [liveRow], { + getPaneKey: () => 'tab:leaf', + getLeaf: () => null, + getTrackedTitle: () => null + }) + + expect(result.agentStatus?.programStatus).toEqual(programStatus) + }) }) // A synced leaf projects with no PTY record of its own, so a shell title it painted after a diff --git a/src/main/runtime/runtime-mobile-agent-status-builder.ts b/src/main/runtime/runtime-mobile-agent-status-builder.ts index fa468cf888a..f5d0cd8d6d1 100644 --- a/src/main/runtime/runtime-mobile-agent-status-builder.ts +++ b/src/main/runtime/runtime-mobile-agent-status-builder.ts @@ -117,7 +117,8 @@ export function buildRuntimeMobileAgentStatus( : {}), tabId: tab.parentTabId, terminalTitle, - ...providerSession + ...providerSession, + ...(liveRow.programStatus ? { programStatus: liveRow.programStatus } : {}) }, ownerAgent, ownerOptions diff --git a/src/main/runtime/runtime-mobile-agent-status-projection.ts b/src/main/runtime/runtime-mobile-agent-status-projection.ts index dea41cc5c7c..f52585c747e 100644 --- a/src/main/runtime/runtime-mobile-agent-status-projection.ts +++ b/src/main/runtime/runtime-mobile-agent-status-projection.ts @@ -194,7 +194,8 @@ function toHookLiveAgentRow(row: AgentStatusIpcPayload): HookLiveAgentRow { updatedAt: row.receivedAt, ...(row.evidenceObservedAt !== undefined ? { evidenceObservedAt: row.evidenceObservedAt } : {}), stateStartedAt: row.stateStartedAt ?? row.receivedAt, - ...(row.worktreeId ? { worktreeId: row.worktreeId } : {}) + ...(row.worktreeId ? { worktreeId: row.worktreeId } : {}), + ...(row.programStatus ? { programStatus: row.programStatus } : {}) } } diff --git a/src/main/runtime/runtime-terminal-contracts.ts b/src/main/runtime/runtime-terminal-contracts.ts index 7745c042af0..289569e0bf0 100644 --- a/src/main/runtime/runtime-terminal-contracts.ts +++ b/src/main/runtime/runtime-terminal-contracts.ts @@ -1,3 +1,4 @@ +import type { ProgramStatusFacet } from '../../shared/program-status/facet' import type { AgentLaunchRuntime } from '../../shared/agent-launch-runtime' import type { AgentLaunchTabViewerRule } from '../../shared/agent-launch-tab-publication' import type { SpawnIntent } from '../../shared/host-spawn-admission' @@ -178,11 +179,24 @@ export type RuntimeTerminalAgentStatusEvent = { /** Set by the process-lifetime producer; see AgentHookServer.ingestTerminalStatus. */ origin?: 'process' yieldsToHookSince?: number + /** Projected from OSC 7501; the store applies it only where hooks do not speak for the pane. */ + fromProgramStatus?: true + /** The program left no records, so a row it owns is removed rather than written. */ + removesProgramStatusRow?: true + /** A repeat of the program's live report that renews its row's freshness lease. */ + renewsProgramStatusLease?: true +} + +/** A pane's OSC 7501 facet changed. `facet: null` means the program's root record is gone. */ +export type RuntimeTerminalProgramStatusFacetEvent = { + ptyId: string + paneKey: string + facet: ProgramStatusFacet | null } export type HookLiveAgentRow = Pick< RuntimeAgentRowSnapshot, - 'payload' | 'updatedAt' | 'evidenceObservedAt' | 'stateStartedAt' | 'worktreeId' + 'payload' | 'updatedAt' | 'evidenceObservedAt' | 'stateStartedAt' | 'worktreeId' | 'programStatus' > export type TerminalHandleRecord = { diff --git a/src/main/runtime/runtime-terminal-state-records.ts b/src/main/runtime/runtime-terminal-state-records.ts index 1f02a443a20..89cd751347e 100644 --- a/src/main/runtime/runtime-terminal-state-records.ts +++ b/src/main/runtime/runtime-terminal-state-records.ts @@ -83,6 +83,9 @@ export type RuntimePtyWorktreeRecord = RuntimeTerminalTailState & { updatedAt: number /** A `done` row that marks a new session owning the pane, not the end of a turn. */ sessionBoundary?: boolean + /** OSC 7501 `done`: the program itself says it is at rest awaiting input, which a hook + * `done` (possibly mid-turn) cannot claim. */ + programAtRest?: boolean } | null lastAgentStatusStartedAtEpochMs: number | null lastAgentStatusRichInvalidatedAtEpochMs: number | null @@ -121,6 +124,10 @@ export type RuntimePtyTitleTrackerEntry = { /** Run once this chunk's facts are emitted: status that readers must see after them. */ afterFacts: (() => void)[] commandCodeDetector: { observe: (data: string) => boolean } | null + /** The program's OSC 7501 activity; while set it, not the title glyph, is the PTY's status. */ + programActivity: AgentStatus | null + /** The last observed title, so a program's activity change can re-classify it. */ + lastTitle: { raw: string; normalized: string } | null } export type RuntimeHeadlessTerminal = { diff --git a/src/main/runtime/runtime-worktree-agent-rows.ts b/src/main/runtime/runtime-worktree-agent-rows.ts index 97c1e0ced87..3b12b07e535 100644 --- a/src/main/runtime/runtime-worktree-agent-rows.ts +++ b/src/main/runtime/runtime-worktree-agent-rows.ts @@ -54,7 +54,8 @@ export function attachRuntimeWorktreeAgentRows(args: { updatedAt: source.updatedAt, ...(source.children !== undefined ? { children: source.children } : {}), ...(source.subagents !== undefined ? { subagents: source.subagents } : {}), - ...(source.structuredHost === 'owned' ? { structuredHostOwned: true as const } : {}) + ...(source.structuredHost === 'owned' ? { structuredHostOwned: true as const } : {}), + ...(source.programStatus ? { programStatus: source.programStatus } : {}) } if (source.children !== undefined || source.subagents !== undefined) { row.subagentObservation = isAgentStatusFresh(row, now) ? 'live' : 'unverifiable' diff --git a/src/main/runtime/runtime-worktree-agent-source.ts b/src/main/runtime/runtime-worktree-agent-source.ts index d86396b32fb..d38d713d87e 100644 --- a/src/main/runtime/runtime-worktree-agent-source.ts +++ b/src/main/runtime/runtime-worktree-agent-source.ts @@ -5,6 +5,7 @@ import type { } from '../../shared/agent-status-types' import type { AgentChildWorkView } from '../../shared/agent-status-child-work-view' import type { AgentMainAgentStatus } from '../../shared/main-agent-status' +import type { ProgramStatusFacet } from '../../shared/program-status/facet' export type RuntimeWorktreeAgentSource = Pick & { children?: AgentChildWorkView[] @@ -26,4 +27,5 @@ export type RuntimeWorktreeAgentSource = Pick { }) expect(providerSessionOnly.size).toBe(0) }) + + it('carries the pane facet from the hook row to the source', () => { + const programStatus = { state: 'working' as const, progress: 42, observedAt: now } + const sources = collectRuntimeWorktreeAgentSources({ + ...connected, + hookSnapshots: [{ ...hookRow, programStatus }] + }) + expect(sources.get(paneKey)?.programStatus).toEqual(programStatus) + expect(collectRuntimeWorktreeAgentSources(connected).get(paneKey)).not.toHaveProperty( + 'programStatus' + ) + }) }) diff --git a/src/main/runtime/runtime-worktree-pty-agent-sources.ts b/src/main/runtime/runtime-worktree-pty-agent-sources.ts index 9b1849a8ee2..951110b88d4 100644 --- a/src/main/runtime/runtime-worktree-pty-agent-sources.ts +++ b/src/main/runtime/runtime-worktree-pty-agent-sources.ts @@ -80,7 +80,8 @@ export function collectRuntimeWorktreePtyAgentSources(args: { updatedAt: entry.evidenceObservedAt ?? entry.receivedAt, ...(children !== undefined ? { children } : {}), ...(entry.subagents !== undefined ? { subagents: entry.subagents } : {}), - ...(entry.structuredHost ? { structuredHost: entry.structuredHost } : {}) + ...(entry.structuredHost ? { structuredHost: entry.structuredHost } : {}), + ...(entry.programStatus ? { programStatus: entry.programStatus } : {}) }) } const sources: RuntimeWorktreeAgentSource[] = [] diff --git a/src/main/runtime/terminal/lifecycle/floating-session-liveness.ts b/src/main/runtime/terminal/lifecycle/floating-session-liveness.ts index e788743eb17..d753d69f73c 100644 --- a/src/main/runtime/terminal/lifecycle/floating-session-liveness.ts +++ b/src/main/runtime/terminal/lifecycle/floating-session-liveness.ts @@ -108,6 +108,7 @@ export abstract class OrcaRuntimeWithRefreshFloatingWorkspacePtyLiveness extends pty?.connectionId === null || this.ptyLivenessVerdictByPtyId.get(ptyId)?.verdict.status === 'exited' if (processDeathCertified) { + this.clearLiveProgramStatusFacet(ptyId) this.reconcileAgentStatusForEndedProcessFn?.(this.collectAgentStatusPaneKeysForPty(ptyId)) } this.advancePtyLifecycleGeneration(ptyId) @@ -128,6 +129,7 @@ export abstract class OrcaRuntimeWithRefreshFloatingWorkspacePtyLiveness extends this.providerVisibleStateByPtyId.delete(ptyId) this.providerVisibleRetryAtByPtyId.delete(ptyId) this.agentStatusOscProcessorsByPtyId.delete(ptyId) + this.liveProgramStatusFacetPtyIds.delete(ptyId) this.terminalSpawnCommandsByPtyId.delete(ptyId) this.terminalRunFacts.delete(ptyId) this.disposePtyTitleTracker(ptyId) diff --git a/src/main/runtime/terminal/lifecycle/pty-exit-lifecycle.ts b/src/main/runtime/terminal/lifecycle/pty-exit-lifecycle.ts index 768e546cc5b..32b0c0af577 100644 --- a/src/main/runtime/terminal/lifecycle/pty-exit-lifecycle.ts +++ b/src/main/runtime/terminal/lifecycle/pty-exit-lifecycle.ts @@ -83,6 +83,10 @@ export abstract class OrcaRuntimeWithOnPtyExit extends OrcaRuntimeWithOnClientDi } const processDeathCertified = exitCode >= 0 || options.hostExitConfirmed === true || options.providerExitObserved === true + if (processDeathCertified) { + // An unverifiable exit keeps the facet tracked until a certified exit or prune. + this.clearLiveProgramStatusFacet(ptyId) + } if (processDeathCertified && exitPaneKeys.size > 0) { this.reconcileAgentStatusForEndedProcessFn?.(exitPaneKeys) } @@ -168,6 +172,7 @@ export abstract class OrcaRuntimeWithOnPtyExit extends OrcaRuntimeWithOnClientDi this.agentPromptExplicitStatusFloorByPtyId.delete(ptyId) this.ptyLifecycleGenerationById.delete(ptyId) this.agentStatusOscProcessorsByPtyId.delete(ptyId) + this.liveProgramStatusFacetPtyIds.delete(ptyId) this.terminalSpawnCommandsByPtyId.delete(ptyId) this.disposePtyTitleTracker(ptyId) this.oscTitleScanTailByPtyId.delete(ptyId) diff --git a/src/main/runtime/terminal/lifecycle/terminal-view-resources.ts b/src/main/runtime/terminal/lifecycle/terminal-view-resources.ts index 83d04b06662..ea4bf41c4ee 100644 --- a/src/main/runtime/terminal/lifecycle/terminal-view-resources.ts +++ b/src/main/runtime/terminal/lifecycle/terminal-view-resources.ts @@ -94,6 +94,9 @@ export abstract class OrcaRuntimeWithFitOverrideListeners extends OrcaRuntimeWit ReturnType >() + // PTYs whose last OSC 7501 facet dies with the program; their exit must clear it. + protected liveProgramStatusFacetPtyIds = new Set() + // Why: per-PTY shared title trackers (all-titles ordering + stale-working // timer) replace last-title-per-chunk scanning so main observes the same // intra-chunk working→idle transitions the renderer does (issue #1083). diff --git a/src/main/runtime/terminal/output/pty-output-ingestion.ts b/src/main/runtime/terminal/output/pty-output-ingestion.ts index 7753710b03b..eb2a89c4fa5 100644 --- a/src/main/runtime/terminal/output/pty-output-ingestion.ts +++ b/src/main/runtime/terminal/output/pty-output-ingestion.ts @@ -297,12 +297,17 @@ export abstract class OrcaRuntimeWithOnPtyData extends OrcaRuntimeWithPreparePty ptyRecord.lastExplicitAgentStatus = { state: latestAgentStatus.state, updatedAt: Date.now(), - sessionBoundary: latestAgentStatus.sessionBoundary + sessionBoundary: latestAgentStatus.sessionBoundary, + ...(latestAgentStatus.state === 'done' && + agentStatusChunk.programStatusPayloads?.has(latestAgentStatus) + ? { programAtRest: true } + : {}) } } } titleTrackerEntry.tracker.handleChunk(agentStatusChunk.cleanData, { - titleScanData: titleInput + titleScanData: titleInput, + programActivity: agentStatusChunk.programActivity }) // Why: the Command Code scrape rides the same per-chunk batch (its facts // trail the tracker's). cleanData keeps OSC 9999 payloads out of the diff --git a/src/main/runtime/terminal/output/terminal-derived-agent-facts.ts b/src/main/runtime/terminal/output/terminal-derived-agent-facts.ts index 9d3d7ac4ff9..47d69fa9991 100644 --- a/src/main/runtime/terminal/output/terminal-derived-agent-facts.ts +++ b/src/main/runtime/terminal/output/terminal-derived-agent-facts.ts @@ -11,6 +11,11 @@ import { isWindowsAbsolutePathLike } from '../../../../shared/cross-platform-pat import type { ProcessedAgentStatusChunk } from '../../../../shared/agent-status-osc' import { mapExplicitAgentStateToRuntimeTerminalStatus } from '../../runtime-worktree-status-projection' import type { RuntimeTerminalAgentStatusEvent } from '../../runtime-terminal-contracts' +import { programStatusStateOutlivesProgram } from '../../../../shared/program-status/protocol' +import type { ParsedAgentStatusPayload } from '../../../../shared/agent-status-types' + +// Why: programs report only changes, so a long `working` renews its row inside AGENT_STATUS_STALE_AFTER_MS. +const PROGRAM_STATUS_LEASE_RENEWAL_MS = 10 * 60 * 1000 type TerminalAgentStatusTarget = Pick< RuntimeTerminalAgentStatusEvent, @@ -20,6 +25,10 @@ type TerminalAgentStatusTarget = Pick< export abstract class OrcaRuntimeWithCreateTerminalSideEffectCommandCodeDetector extends OrcaRuntimeWithApplyTrackedPtyTitle { abstract getAgentStatusTerminalHandleForPaneKey(paneKey: string): string | undefined + /** Each live OSC 7501 program's latest row payload; renewed while its PTY keeps a live facet. */ + private readonly liveProgramStatusPayloadByPtyId = new Map() + private programStatusLeaseTimer: ReturnType | null = null + protected createTerminalSideEffectCommandCodeDetector( ptyId: string ): NonNullable { @@ -95,13 +104,41 @@ export abstract class OrcaRuntimeWithCreateTerminalSideEffectCommandCodeDetector protected emitTerminalAgentStatusEvents( ptyId: string, chunk: Pick & Partial, - provenance?: { origin: 'process'; yieldsToHookSince: number } + provenance?: + | { origin: 'process'; yieldsToHookSince: number } + | { renewsProgramStatusLease: true } ): void { - if (chunk.payloads.length === 0) { + const facet = chunk.programStatusFacet + if (chunk.payloads.length === 0 && facet === undefined) { return } + if (facet !== undefined) { + if (facet && !programStatusStateOutlivesProgram(facet.state)) { + this.liveProgramStatusFacetPtyIds.add(ptyId) + } else { + this.liveProgramStatusFacetPtyIds.delete(ptyId) + } + } const targets = this.resolveTerminalAgentStatusTargets(ptyId) + // Why before the payloads: each row this chunk publishes must already carry the program's + // latest report, or a reader acting between the two (the 133;D drop) sees a stale facet. + if (facet !== undefined && this.onTerminalProgramStatusFacet) { + for (const target of targets) { + try { + this.onTerminalProgramStatusFacet({ ptyId, paneKey: target.paneKey, facet }) + } catch (err) { + console.error('[runtime] terminal program status listener threw', { + ptyId, + paneKey: target.paneKey, + err + }) + } + } + } for (const payload of chunk.payloads) { + if (chunk.programStatusPayloads?.has(payload)) { + this.trackProgramStatusLease(ptyId, payload) + } // Why not gated on a listener: the prompt lifecycle is main's own state, read by // terminal waits that run with no status consumer attached. this.recordAgentPromptLifecycleState( @@ -117,7 +154,11 @@ export abstract class OrcaRuntimeWithCreateTerminalSideEffectCommandCodeDetector ptyId, ...target, payload, - ...provenance + ...provenance, + ...(chunk.programStatusPayloads?.has(payload) ? { fromProgramStatus: true } : {}), + ...(chunk.programStatusRowRemovals?.has(payload) + ? { removesProgramStatusRow: true } + : {}) }) } catch (err) { console.error('[runtime] terminal agent status listener threw', { @@ -132,6 +173,57 @@ export abstract class OrcaRuntimeWithCreateTerminalSideEffectCommandCodeDetector } } + private trackProgramStatusLease(ptyId: string, payload: ParsedAgentStatusPayload): void { + if (payload.state === 'done') { + this.liveProgramStatusPayloadByPtyId.delete(ptyId) + return + } + this.liveProgramStatusPayloadByPtyId.set(ptyId, payload) + if (this.programStatusLeaseTimer === null) { + this.programStatusLeaseTimer = setInterval( + () => this.renewProgramStatusLeases(), + PROGRAM_STATUS_LEASE_RENEWAL_MS + ) + this.programStatusLeaseTimer.unref?.() + } + } + + private renewProgramStatusLeases(): void { + for (const [ptyId, payload] of this.liveProgramStatusPayloadByPtyId) { + // The live facet set is the authority on whether the program still runs. + if (!this.liveProgramStatusFacetPtyIds.has(ptyId) || !this.ptysById.has(ptyId)) { + this.liveProgramStatusPayloadByPtyId.delete(ptyId) + continue + } + this.emitTerminalAgentStatusEvents( + ptyId, + { payloads: [payload], programStatusPayloads: new Set([payload]) }, + { renewsProgramStatusLease: true } + ) + } + if (this.liveProgramStatusPayloadByPtyId.size === 0 && this.programStatusLeaseTimer) { + clearInterval(this.programStatusLeaseTimer) + this.programStatusLeaseTimer = null + } + } + + override notePtyDataGap(ptyId: string, droppedChars = 0): void { + super.notePtyDataGap(ptyId, droppedChars) + // The gap may hold the program's end, and the fresh parser no longer knows its records. + const entry = this.ptyTitleTrackersByPtyId.get(ptyId) + if (entry && entry.programActivity !== null) { + entry.tracker.endProgramActivity() + } + this.clearLiveProgramStatusFacet(ptyId) + } + + /** The PTY's program ended: drop its live facet (done/error stay). Call while its panes resolve. */ + protected clearLiveProgramStatusFacet(ptyId: string): void { + if (this.liveProgramStatusFacetPtyIds.has(ptyId)) { + this.emitTerminalAgentStatusEvents(ptyId, { payloads: [], programStatusFacet: null }) + } + } + /** The panes a PTY's in-band status belongs to: its mounted leaves, else its own pane record. */ private resolveTerminalAgentStatusTargets(ptyId: string): TerminalAgentStatusTarget[] { const targets = new Map() diff --git a/src/main/runtime/terminal/output/terminal-output-generation.ts b/src/main/runtime/terminal/output/terminal-output-generation.ts index 12cf263efed..d20b02353cd 100644 --- a/src/main/runtime/terminal/output/terminal-output-generation.ts +++ b/src/main/runtime/terminal/output/terminal-output-generation.ts @@ -3,7 +3,6 @@ import type { AgentStatus } from '../../../../shared/agent-detection' import { getDisplayPromptLifecycle } from '../../runtime-worktree-status-projection' import { findLastCompleteOscTitleRange } from '../../orca-runtime-core' import { extractLastOscTitle } from '../../../../shared/osc-title-extraction' -import { detectAgentStatusFromTitle } from '../../../../shared/agent-detection' import type { RuntimeTerminalDataMeta } from '../../runtime-terminal-stream-consumers' export abstract class OrcaRuntimeWithRecordAgentPromptLifecycleState extends OrcaRuntimeWithCreateTerminalSideEffectCommandCodeDetector { @@ -58,7 +57,8 @@ export abstract class OrcaRuntimeWithRecordAgentPromptLifecycleState extends Orc if (title === null) { return } - const status = detectAgentStatusFromTitle(title) + // Explicit-first, as the title observer classified it: a program's activity outranks the glyph. + const status = this.classifyPtyTitleStatus(ptyId, title) const current = this.agentPromptLifecycleByPtyId.get(ptyId) if (!current || current.status === status) { return @@ -145,6 +145,8 @@ export abstract class OrcaRuntimeWithRecordAgentPromptLifecycleState extends Orc pty.wslDistro = null } if (replacesExistingRuntimeGeneration && postSpawnSequence === 0) { + // The replaced process's live facet says nothing about its successor. + this.clearLiveProgramStatusFacet(ptyId) this.resetTrackedTerminalStateForProviderGeneration(ptyId) } } diff --git a/src/main/runtime/terminal/output/terminal-title-observation.ts b/src/main/runtime/terminal/output/terminal-title-observation.ts index a2f2da13863..dce03c15495 100644 --- a/src/main/runtime/terminal/output/terminal-title-observation.ts +++ b/src/main/runtime/terminal/output/terminal-title-observation.ts @@ -54,18 +54,58 @@ export abstract class OrcaRuntimeWithApplyTrackedPtyTitle extends OrcaRuntimeWit } } + /** The PTY's agent status under this title: the program's own OSC 7501 activity while it + * reports one, else what the title glyph says. */ + protected classifyPtyTitleStatus(ptyId: string, rawTitle: string): AgentStatus | null { + return ( + this.ptyTitleTrackersByPtyId.get(ptyId)?.programActivity ?? + detectAgentStatusFromTitle(rawTitle) + ) + } + + /** Re-classifies the PTY under new program activity; on null the title's own glyph decides again. */ + protected applyPtyProgramActivity(ptyId: string, activity: AgentStatus | null): void { + const entry = this.ptyTitleTrackersByPtyId.get(ptyId) + if (!entry) { + return + } + entry.programActivity = activity + this.observeClaudeTerminalEvidence(ptyId, { kind: 'activity', status: activity }) + const pty = this.ptysById.get(ptyId) + if (activity === null && pty?.lastExplicitAgentStatus?.programAtRest) { + // The at-rest claim was the program's; a later agent in this pane must earn its own. + const { programAtRest: _ended, ...status } = pty.lastExplicitAgentStatus + pty.lastExplicitAgentStatus = status + } + if (entry.lastTitle) { + // A stale-spinner clear stands for the title it replaced, so a dead spinner is not revived. + const raw = pty?.titleDisplayClear?.title ?? entry.lastTitle.raw + const changed = this.applyTrackedPtyTitle(ptyId, raw, entry.lastTitle.normalized, undefined, { + statusOnly: true + }) + // The chunk's session-tab fan-out carries the new status to mobile. + if (changed && entry.applyingChunk) { + entry.chunkTouchedSessionTabs = true + } + } + } + /** Apply one observed OSC title (raw form) to the PTY and leaf records. * Returns true when what the PTY shows changed: its record, or its display-only clear. */ protected applyTrackedPtyTitle( ptyId: string, rawTitle: string, normalizedTitle: string, - meta?: TerminalTitleFactMeta + meta?: TerminalTitleFactMeta, + /** `statusOnly`: no title was observed, so title evidence, clocks and display stay as they are. */ + { statusOnly = false }: { statusOnly?: boolean } = {} ): boolean { if (meta?.staleWorkingTitleClear) { return this.applyStaleWorkingTitleClear(ptyId, rawTitle, normalizedTitle) } - this.observeClaudeTerminalEvidence(ptyId, { kind: 'title', title: rawTitle }) + if (!statusOnly) { + this.observeClaudeTerminalEvidence(ptyId, { kind: 'title', title: rawTitle }) + } // Why: status is detected from the RAW title (mirrors the renderer tracker), // so working/idle transitions are unaffected by normalization; the records // store the NORMALIZED title so rotating Grok/Pi/Gemini frames collapse to @@ -76,21 +116,27 @@ export abstract class OrcaRuntimeWithApplyTrackedPtyTitle extends OrcaRuntimeWit // for display (#10258). Nulling the status here rather than trusting the detector keeps // that contract local, since every activity-gated effect below is keyed on status. const identityOnlyTitle = this.isLiveCursorNativeTitle(rawTitle, meta) - const recordedTitle = identityOnlyTitle ? null : normalizedTitle - const agentStatus = identityOnlyTitle ? null : detectAgentStatusFromTitle(rawTitle) + const recordedTitle = statusOnly + ? (this.ptysById.get(ptyId)?.lastOscTitle ?? null) + : identityOnlyTitle + ? null + : normalizedTitle + const agentStatus = identityOnlyTitle ? null : this.classifyPtyTitleStatus(ptyId, rawTitle) // Why before retiring the clear: the lifecycle counts a turn from the status the clear showed. this.recordAgentPromptLifecycleState(ptyId, agentStatus) let ptyRecordChanged = false const pty = this.ptysById.get(ptyId) if (pty) { - pty.titleDisplayClear = null const prevStatus = pty.lastAgentStatus const prevTitle = pty.lastOscTitle const observedAt = this.nextTitleObservationSequence() const observedAtEpochMs = identityOnlyTitle ? null : Date.now() - pty.lastOscTitle = recordedTitle - pty.lastOscTitleAt = identityOnlyTitle ? null : observedAt - pty.lastOscTitleEpochMs = observedAtEpochMs + if (!statusOnly) { + pty.titleDisplayClear = null + pty.lastOscTitle = recordedTitle + pty.lastOscTitleAt = identityOnlyTitle ? null : observedAt + pty.lastOscTitleEpochMs = observedAtEpochMs + } pty.lastAgentStatus = agentStatus pty.lastAgentStatusObservedLive = true if (prevStatus === 'working' && agentStatus === null) { @@ -106,10 +152,10 @@ export abstract class OrcaRuntimeWithApplyTrackedPtyTitle extends OrcaRuntimeWit ) { pty.lastAgentStatusRichInvalidatedAtEpochMs = observedAtEpochMs ?? Date.now() } - if (identityOnlyTitle) { + if (!statusOnly && identityOnlyTitle) { pty.managementTitle = null pty.managementTitleAt = null - } else { + } else if (!statusOnly) { this.setPtyManagementTitleFromObservedTitle(pty, normalizedTitle, observedAt) } ptyRecordChanged = prevTitle !== recordedTitle || prevStatus !== agentStatus @@ -157,9 +203,11 @@ export abstract class OrcaRuntimeWithApplyTrackedPtyTitle extends OrcaRuntimeWit // the shell took over the title — the stuck-spinner bug in #1437. const prevStatus = leaf.lastAgentStatus const prevObservedLive = leaf.lastAgentStatusObservedLive - const prevLeafTitle = leaf.lastOscTitle - leaf.lastOscTitle = recordedTitle - leaf.lastOscTitleAt = identityOnlyTitle ? null : this.nextTitleObservationSequence() + const leafTitleChanged = !statusOnly && leaf.lastOscTitle !== recordedTitle + if (!statusOnly) { + leaf.lastOscTitle = recordedTitle + leaf.lastOscTitleAt = identityOnlyTitle ? null : this.nextTitleObservationSequence() + } // Why: when a new OSC title doesn't classify as an agent state (e.g. // bare shell title after the agent exits), clear lastAgentStatus so // it is no longer sticky. Tui-idle waiters that needed the previous @@ -181,7 +229,7 @@ export abstract class OrcaRuntimeWithApplyTrackedPtyTitle extends OrcaRuntimeWit if ( agentStatus === 'idle' && prevStatus !== 'permission' && - (prevStatus !== agentStatus || prevLeafTitle !== recordedTitle) + (prevStatus !== agentStatus || leafTitleChanged) ) { this.resolveTuiIdleWaiters(leaf) } @@ -198,7 +246,7 @@ export abstract class OrcaRuntimeWithApplyTrackedPtyTitle extends OrcaRuntimeWit // waiter branch above already re-offers on that step; the gate makes a repeat harmless. if ( agentStatus === 'idle' && - (prevStatus !== 'idle' || !prevObservedLive || prevLeafTitle !== recordedTitle) && + (prevStatus !== 'idle' || !prevObservedLive || leafTitleChanged) && this.checkDeliverySettledAndArmRecheck(leaf) ) { this.deliverPendingMessagesForLeaf(leaf) @@ -224,7 +272,7 @@ export abstract class OrcaRuntimeWithApplyTrackedPtyTitle extends OrcaRuntimeWit return false } const nativeStatus = pty.lastAgentStatus - const clearedStatus = detectAgentStatusFromTitle(rawTitle) + const clearedStatus = this.classifyPtyTitleStatus(ptyId, rawTitle) const observedAt = this.nextTitleObservationSequence() const previousTitle = pty.titleDisplayClear?.title ?? pty.lastOscTitle pty.titleDisplayClear = { title: normalizedTitle, observedAt, observedAtEpochMs: Date.now() } diff --git a/src/main/runtime/terminal/output/terminal-title-tracker.ts b/src/main/runtime/terminal/output/terminal-title-tracker.ts index 64f19fb5e7d..6058432d9bf 100644 --- a/src/main/runtime/terminal/output/terminal-title-tracker.ts +++ b/src/main/runtime/terminal/output/terminal-title-tracker.ts @@ -4,7 +4,7 @@ import { getDecorativeTitleGateKey } from '../../../../shared/agent-decorative-t import { shouldEmitTitleFactForFrame } from '../../decorative-title-fact-emission' import type { RuntimePtyTitleTrackerEntry } from '../../runtime-terminal-state-records' import { createTerminalTitleTracker } from '../../../../shared/terminal-output-side-effects' -import { detectAgentStatusFromTitle } from '../../../../shared/agent-detection' +import type { AgentStatus } from '../../../../shared/agent-detection' import type { TerminalGitHubPRLink } from '../../../../shared/terminal-github-pr-link-detector' export abstract class OrcaRuntimeWithGetUnpersistedTrackedTitleForPty extends OrcaRuntimeWithEmitDaemonPtyTransientFact { @@ -19,6 +19,8 @@ export abstract class OrcaRuntimeWithGetUnpersistedTrackedTitleForPty extends Or meta?: TerminalTitleFactMeta ): boolean protected abstract confirmPtyAgentExit(ptyId: string, recoverCompletedHook?: boolean): void + protected abstract classifyPtyTitleStatus(ptyId: string, rawTitle: string): AgentStatus | null + protected abstract applyPtyProgramActivity(ptyId: string, activity: AgentStatus | null): void protected abstract createTerminalSideEffectCommandCodeDetector( ptyId: string ): NonNullable @@ -87,6 +89,9 @@ export abstract class OrcaRuntimeWithGetUnpersistedTrackedTitleForPty extends Or } if (live) { live.lastMobileTitleGateKey = gateKey + if (!meta?.staleWorkingTitleClear) { + live.lastTitle = { raw: rawTitle, normalized: normalizedTitle } + } } // Why: the same gate the mobile fan-out below already uses, applied one hop earlier — // a spinner frame the renderer store discards should not cost a pty:sideEffect message @@ -114,7 +119,9 @@ export abstract class OrcaRuntimeWithGetUnpersistedTrackedTitleForPty extends Or const identityOnlyTitle = this.isLiveCursorNativeTitle(rawTitle, meta) const tracksReplicatedStatus = live?.applyingChunk === true && this.mobileSessionTabListeners.size > 0 - const titleStatus = tracksReplicatedStatus ? detectAgentStatusFromTitle(rawTitle) : null + const titleStatus = tracksReplicatedStatus + ? this.classifyPtyTitleStatus(ptyId, rawTitle) + : null if ( tracksReplicatedStatus && decorativeOnly && @@ -154,12 +161,16 @@ export abstract class OrcaRuntimeWithGetUnpersistedTrackedTitleForPty extends Or this.recordTerminalSideEffectFact(ptyId, { kind: 'agent-idle', title, + ...(meta?.status ? { status: meta.status } : {}), ...(meta?.staleWorkingTitleClear ? { staleWorkingTitleClear: true } : {}) }) }, onAgentExited: () => { this.confirmPtyAgentExit(ptyId) }, + onProgramActivity: (activity) => { + this.applyPtyProgramActivity(ptyId, activity) + }, onCommandStarted: () => { this.openCodeRunLifetime.onCommandStarted(ptyId) }, @@ -200,7 +211,10 @@ export abstract class OrcaRuntimeWithGetUnpersistedTrackedTitleForPty extends Or // saw one) mirrors the renderer detector's startupCommand fast-arm. commandCodeDetector: this.terminalSideEffectConsumerAvailable ? this.createTerminalSideEffectCommandCodeDetector(ptyId) - : null + : null, + programActivity: null, + // The persisted title is already normalized, so it stands for both forms. + lastTitle: initialTitle !== null ? { raw: initialTitle, normalized: initialTitle } : null } this.ptyTitleTrackersByPtyId.set(ptyId, entry) return entry diff --git a/src/main/runtime/terminal/wait/terminal-wait-blocked-checks.ts b/src/main/runtime/terminal/wait/terminal-wait-blocked-checks.ts index f45fd2b3f07..4ec2165ea66 100644 --- a/src/main/runtime/terminal/wait/terminal-wait-blocked-checks.ts +++ b/src/main/runtime/terminal/wait/terminal-wait-blocked-checks.ts @@ -17,7 +17,7 @@ import { } from '../../terminal-wait-tail-state' import { ownRetainedString } from '../../../../shared/own-retained-string' import type { ProcessedAgentStatusChunk } from '../../../../shared/agent-status-osc' -import { createAgentStatusOscProcessor } from '../../../../shared/agent-status-osc' +import { createTerminalAgentStatusProcessor } from '../../../../shared/terminal-agent-status-processor' import type { RuntimePtyTitleTrackerEntry } from '../../runtime-terminal-state-records' import type { OrcaRuntimeWithRecordAgentPromptLifecycleState } from '../output/terminal-output-generation' @@ -140,7 +140,7 @@ export abstract class OrcaRuntimeWithScheduleWaitBlockedCheck extends OrcaRuntim protected processAgentStatusOscForPty(ptyId: string, data: string): ProcessedAgentStatusChunk { let processor = this.agentStatusOscProcessorsByPtyId.get(ptyId) if (!processor) { - processor = createAgentStatusOscProcessor() + processor = createTerminalAgentStatusProcessor() this.agentStatusOscProcessorsByPtyId.set(ptyId, processor) } return processor(data) diff --git a/src/main/runtime/tui-idle-evidence.test.ts b/src/main/runtime/tui-idle-evidence.test.ts index 647fd9601eb..40fd8c3356b 100644 --- a/src/main/runtime/tui-idle-evidence.test.ts +++ b/src/main/runtime/tui-idle-evidence.test.ts @@ -344,3 +344,27 @@ describe('evaluateTuiIdle screen input veto', () => { ).toEqual({ kind: 'pending', quietForeground: 'closed' }) }) }) + +describe('evaluateTuiIdle with OSC 7501 program status', () => { + const claude = (overrides: Partial = {}) => + input({ + agent: 'claude', + // Claude drops its ✳ idle glyph once the terminal answers the OSC 7501 probe. + record: record({ lastOscTitle: 'Tea or coffee notes.txt', lastAgentStatus: null }), + ...overrides + }) + + it("settles on the program's own done although its title carries no idle glyph", () => { + expect( + evaluateTuiIdle( + claude({ firstPartyStatus: { state: 'done', updatedAt: Date.now(), programAtRest: true } }) + ) + ).toEqual({ kind: 'ready-strong' }) + }) + + it('does not settle on a done that only a hook-style stream claimed', () => { + expect( + evaluateTuiIdle(claude({ firstPartyStatus: { state: 'done', updatedAt: Date.now() } })) + ).not.toEqual({ kind: 'ready-strong' }) + }) +}) diff --git a/src/main/runtime/tui-idle-evidence.ts b/src/main/runtime/tui-idle-evidence.ts index 8d8218af003..db232d7d8f1 100644 --- a/src/main/runtime/tui-idle-evidence.ts +++ b/src/main/runtime/tui-idle-evidence.ts @@ -68,6 +68,7 @@ export type FirstPartyAgentStatus = { state: AgentStatusState updatedAt: number sessionBoundary?: boolean + programAtRest?: boolean } | null /** Tier 1: an idle marker the agent put in a title itself. */ @@ -83,13 +84,17 @@ export function hasExplicitIdleTitle( /** * Tier 1, first-party: a `sessionBoundary` row, which claims a NEW SESSION owns the pane and waits * for its first input. Producers set it only for a startup/resume/reset boundary, so it cannot - * arrive mid-turn (#6011). An ordinary hook `done` decides only through the hook lane (tier 0). + * arrive mid-turn (#6011). An OSC 7501 `done` is the program's own at-rest claim and counts too. + * An ordinary hook `done` decides only through the hook lane (tier 0). */ export function hasFreshDoneFirstPartyStatus( status: FirstPartyAgentStatus, staleAfterMs = AGENT_STATUS_STALE_AFTER_MS ): boolean { - if (status?.state !== 'done' || status.sessionBoundary !== true) { + if ( + status?.state !== 'done' || + (status.sessionBoundary !== true && status.programAtRest !== true) + ) { return false } return Date.now() - status.updatedAt <= staleAfterMs diff --git a/src/main/startup/main-window-agent-status.ts b/src/main/startup/main-window-agent-status.ts index 8925c597549..fb4e94e2461 100644 --- a/src/main/startup/main-window-agent-status.ts +++ b/src/main/startup/main-window-agent-status.ts @@ -30,7 +30,9 @@ export function installMainWindowAgentStatusListeners(options: MainWindowAgentSt observation, isReplay, authorityRestartId, - structuredHost + structuredHost, + programStatus, + programStatusOnly }) => { if (state.mainWindow?.isDestroyed()) { return @@ -79,12 +81,17 @@ export function installMainWindowAgentStatusListeners(options: MainWindowAgentSt ...(restoredUnconfirmed ? { restoredUnconfirmed: true } : {}), ...(observation ? { observation } : {}), ...(orchestration ? { orchestration } : {}), + ...(programStatus ? { programStatus } : {}), + ...(programStatusOnly === true ? { programStatusOnly: true } : {}), // Why: reconnect replays re-send cached rows; the renderer must not treat them as fresh completions. ...(isReplay === true ? { isReplay: true } : {}) } state.mainWindow?.webContents.send('agentStatus:set', statusEvent) getDashboardPopoutWindow()?.webContents.send('agentStatus:set', statusEvent) - options.onRecordAgentState(payload.agentType ?? 'unknown', payload.state) + // A facet-only republish (progress tick) is not a state the agent entered. + if (programStatusOnly !== true) { + options.onRecordAgentState(payload.agentType ?? 'unknown', payload.state) + } } ) agentHookServer.setPaneStatusClearListener((clear) => { diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts index d82b6eae74e..e4cddfdaf9d 100644 --- a/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts +++ b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts @@ -2,9 +2,10 @@ * Parked terminal side-effect watcher. * Why: parking unmounts TerminalPane, so this replays its bell/title/agent-completion/PR-link side effects while parked. */ +import { isClaudeIdleEdge } from '@/lib/agent-idle-edge' +import type { AgentIdleEdgeMeta } from '../../../../shared/terminal-output-side-effects' import { resolveTerminalNotificationOwner } from '@/attention/notification-subject-owner' import type { NotificationWorkspaceOwner } from '../../../../shared/notification-source' -import { isClaudeAgent } from '../../../../shared/agent-detection' import { makePaneKey } from '../../../../shared/stable-pane-id' import { useAppStore } from '@/store' import { createTerminalGitHubPRLinkDetector } from '../../../../shared/terminal-github-pr-link-detector' @@ -144,7 +145,7 @@ export function startParkedTerminalByteWatcher( scheduleTerminalBellNotification() } }, - onAgentBecameIdle: (title: string, meta?: { staleWorkingTitleClear?: boolean }): void => { + onAgentBecameIdle: (title: string, meta?: AgentIdleEdgeMeta): void => { // Why: stale-derived idles (main's 3s timer, not observed bytes) clear session state but must not schedule a completion a paused agent didn't earn. if (meta?.staleWorkingTitleClear) { useAppStore.getState().setCacheTimerStartedAt(paneKey, null) @@ -153,7 +154,7 @@ export function startParkedTerminalByteWatcher( const state = useAppStore.getState() // Why: null settings means "not hydrated yet"; a spurious timestamp is harmless while a dropped one loses the timer. if ( - isClaudeAgent(title) && + isClaudeIdleEdge(title, state.agentStatusByPaneKey[paneKey]) && (state.settings === null || state.settings.promptCacheTimerEnabled) ) { state.setCacheTimerStartedAt(paneKey, Date.now()) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/agent-task-complete-notify.ts b/src/renderer/src/components/terminal-pane/pty-connection/agent-task-complete-notify.ts index 644076da158..057ea8af6d5 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/agent-task-complete-notify.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/agent-task-complete-notify.ts @@ -1,5 +1,6 @@ +import type { AgentIdleEdgeMeta } from '../../../../../shared/terminal-output-side-effects' +import { idleEdgeSettledAtRest, isClaudeIdleEdge } from '@/lib/agent-idle-edge' import { captureNotificationTransportOwner } from '@/attention/notification-subject-owner' -import { detectAgentStatusFromTitle, isClaudeAgent } from '@/lib/agent-status' import { useAppStore } from '@/store' import { isFreshNonDoneAgentStatus } from '../../../../../shared/agent-status-types' import type { @@ -139,10 +140,7 @@ export function installAgentTaskCompleteNotify(session: ConnectPanePtySession): // findable after the OS banner is gone. Double-firing with a concurrent BEL // is handled by delaying the BEL OS notification below; main still keeps a // 5 s per-worktree dedupe as the final guard. - session.onAgentBecameIdle = ( - title: string, - meta?: { staleWorkingTitleClear?: boolean } - ): void => { + session.onAgentBecameIdle = (title: string, meta?: AgentIdleEdgeMeta): void => { // Why: a stale-derived idle comes from main's UNTHROTTLED 3s timer, not // observed bytes — a merely-paused agent (>3s silent mid-task, window // minimized) would otherwise mint a false task-complete OS notification @@ -173,10 +171,13 @@ export function installAgentTaskCompleteNotify(session: ConnectPanePtySession): // CacheTimer component gates rendering on the enabled flag, so a // spurious write when the feature turns out to be disabled is harmless. const settings = currentState.settings - if (isClaudeAgent(title) && (settings === null || settings.promptCacheTimerEnabled)) { + if ( + isClaudeIdleEdge(title, activeHookStatus) && + (settings === null || settings.promptCacheTimerEnabled) + ) { session.deps.setCacheTimerStartedAt(session.cacheKey, Date.now()) } - if (detectAgentStatusFromTitle(title) === 'idle') { + if (idleEdgeSettledAtRest(title, meta)) { session.setFocusReportSuppressionForAgentCompletion(title, activeHookStatus?.agentType) } if (session.syncAgentTaskCompleteTrackingEnabled()) { diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pty-input-recovery.ts b/src/renderer/src/components/terminal-pane/pty-connection/pty-input-recovery.ts index e3b4bb54f6b..c466ba1bffa 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pty-input-recovery.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pty-input-recovery.ts @@ -30,6 +30,8 @@ import type { ConnectPanePtySession } from './connect-pane-pty-session' import type { PtyTransport } from '../pty-transport-types' import { isPassiveTerminalInputReport } from '../terminal-passive-input-report' import { resolveTerminalInlineImagesEnabled } from '../../../../../shared/terminal-inline-images-settings' +import { PROGRAM_STATUS_RUNTIME_CAPABILITY } from '../../../../../shared/program-status/protocol' +import { readCachedRuntimeEnvironmentCapability } from '../../../runtime/runtime-rpc-client' /** Transport creation, terminal capability replies, viewport claims, and undeliverable-input recovery. */ export function installPtyInputRecovery(session: ConnectPanePtySession): void { @@ -125,6 +127,12 @@ export function installPtyInputRecovery(session: ConnectPanePtySession): void { resolveTerminalInlineImagesEnabled(useAppStore.getState().settings?.terminalInlineImages) && terminalRendersInlineImages(session.pane.terminal), skipOscColorQueryReplies: () => !session.shouldAnswerPaneOscColorQueries(), + answerProgramStatusQueries: () => + !session.runtimeEnvironmentId || + readCachedRuntimeEnvironmentCapability( + session.runtimeEnvironmentId, + PROGRAM_STATUS_RUNTIME_CAPABILITY + ) === true, ...(session.isNativeWindowsConpty ? { da1Response: CONPTY_DA1_RESPONSE } : {}) }) session.respondToTerminalPixelSizeQueries = createTerminalPixelSizeQueryResponder( diff --git a/src/renderer/src/components/terminal-pane/pty-output-effect-ordering.ts b/src/renderer/src/components/terminal-pane/pty-output-effect-ordering.ts new file mode 100644 index 00000000000..bb8860dff2c --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-output-effect-ordering.ts @@ -0,0 +1,95 @@ +import { + isCursorNativeAgentTitle, + normalizeTerminalTitle, + shouldSuppressCursorNativeTitle +} from '../../../../shared/agent-detection' +import type { AgentStatus } from '../../../../shared/agent-title-core' +import type { PlacedProgramActivity } from '../../../../shared/program-status/title-activity-order' +import type { PendingPtySideEffect } from './pty-output-side-effect-queue' + +export function removeSuppressedCursorNativeTitles( + titles: string[], + precedingTitle: string | null, + /** Compacted in lockstep so title offsets stay aligned with the kept titles. */ + titleEnds?: number[] +): boolean { + let writeIndex = 0 + let previousTitle = precedingTitle + for (const [readIndex, title] of titles.entries()) { + if (isCursorNativeAgentTitle(title) && shouldSuppressCursorNativeTitle(previousTitle)) { + continue + } + previousTitle = normalizeTerminalTitle(title) + titles[writeIndex] = title + if (titleEnds) { + titleEnds[writeIndex] = titleEnds[readIndex] + } + writeIndex += 1 + } + const removed = writeIndex < titles.length + titles.length = writeIndex + if (titleEnds) { + titleEnds.length = writeIndex + } + return removed +} + +type OrderedPtyEffects = Pick< + PendingPtySideEffect, + 'payloads' | 'containsBell' | 'suppressAttentionEvents' +> & { + titles: string[] + placedActivity: readonly PlacedProgramActivity[] + emptyTitleScanEffect: PendingPtySideEffect['titleScanEffect'] +} + +/** One queue entry per payload, title and activity change, in byte order, so the drain's per-entry + * budget never splits a chunk's effects out of order. */ +export function enqueueOrderedPtyEffects( + enqueue: (effect: PendingPtySideEffect) => void, + { + payloads, + titles, + placedActivity, + emptyTitleScanEffect, + containsBell, + suppressAttentionEvents + }: OrderedPtyEffects +): void { + const entry = (overrides: Partial): PendingPtySideEffect => ({ + payloads: [], + titles: [], + titleScanEffect: 'none', + containsBell: false, + suppressAttentionEvents, + ...overrides + }) + let activityIndex = 0 + const enqueueActivityBefore = (titleIndex: number): void => { + const due: (AgentStatus | null)[] = [] + while ( + activityIndex < placedActivity.length && + placedActivity[activityIndex].beforeTitle <= titleIndex + ) { + due.push(placedActivity[activityIndex].activity) + activityIndex += 1 + } + if (due.length > 0) { + enqueue(entry({ programActivity: due })) + } + } + for (const payload of payloads) { + enqueue(entry({ payloads: [payload] })) + } + if (titles.length === 0 && emptyTitleScanEffect !== 'none') { + enqueue(entry({ titleScanEffect: emptyTitleScanEffect })) + } + for (const [index, title] of titles.entries()) { + enqueueActivityBefore(index) + enqueue(entry({ titles: [title] })) + } + enqueueActivityBefore(titles.length) + if (containsBell) { + enqueue(entry({ containsBell: true })) + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-output-processor-program-activity.test.ts b/src/renderer/src/components/terminal-pane/pty-output-processor-program-activity.test.ts new file mode 100644 index 00000000000..23938c4e936 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-output-processor-program-activity.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it, vi } from 'vitest' +import { createPtyOutputProcessor } from './pty-output-processor' +import { + createPtyOutputSideEffectQueue, + MAX_PENDING_PTY_SIDE_EFFECTS, + type PendingPtySideEffect +} from './pty-output-side-effect-queue' + +const osc7501 = (body: string): string => `\x1b]7501;${body}\x1b\\` + +describe('renderer-owned PTY output with OSC 7501 activity', () => { + it("fires agent edges from the program's reports while its titles carry no glyph", () => { + const events: string[] = [] + const processor = createPtyOutputProcessor({ + onTitleChange: () => {}, + onAgentBecameIdle: (title) => events.push(`idle:${title}`), + onAgentBecameWorking: () => events.push('working') + }) + const callbacks = { onData: () => {} } + processor.processData( + `${osc7501('state=idle:app=claude-code')}\x1b]0;Claude Code\x07`, + callbacks + ) + processor.processData(osc7501('state=working:app=claude-code'), callbacks) + processor.processData('\x1b]0;Fix the parser\x07', callbacks) + processor.processData(osc7501('state=done:app=claude-code'), callbacks) + processor.flushPendingSideEffects() + + expect(events).toEqual(['working', 'idle:Fix the parser']) + }) + + it('reads the same edges whether reports and titles share a chunk or not', () => { + const run = (chunks: string[]): string[] => { + const events: string[] = [] + const processor = createPtyOutputProcessor({ + onTitleChange: () => {}, + onAgentBecameIdle: (title) => events.push(`idle:${title}`), + onAgentBecameWorking: () => events.push('working') + }) + for (const chunk of chunks) { + processor.processData(chunk, { onData: () => {} }) + } + processor.flushPendingSideEffects() + return events + } + const parts = [ + osc7501('state=idle:app=claude-code'), + '\x1b]0;Claude Code\x07', + osc7501('state=working:app=claude-code'), + '\x1b]0;Fix the parser\x07', + osc7501('state=done:app=claude-code'), + '\x1b]0;Claude Code\x07' + ] + expect(run([parts.join('')])).toEqual(run(parts)) + expect(run(parts)).toEqual(['working', 'idle:Fix the parser']) + }) +}) + +describe('renderer-owned PTY output after an OSC 7501 program ends', () => { + it("hands the pane back to title glyphs once the shell prompt returns after a program's done", () => { + const events: string[] = [] + const processor = createPtyOutputProcessor({ + onTitleChange: () => {}, + onAgentBecameIdle: (title) => events.push(`idle:${title}`), + onAgentBecameWorking: () => events.push('working') + }) + const callbacks = { onData: () => {} } + processor.processData(osc7501('state=working:app=terraform'), callbacks) + processor.processData(osc7501('state=done:app=terraform'), callbacks) + processor.processData('\x1b]133;A\x07\x1b]0;zsh\x07', callbacks) + processor.processData('\x1b]0;⠋ Gemini working\x07', callbacks) + processor.processData('\x1b]0;◇ Gemini ready\x07', callbacks) + processor.flushPendingSideEffects() + expect(events).toEqual(['working', 'idle:', 'working', 'idle:◇ Gemini ready']) + }) + + it('adopts replayed activity so later live reports still move the tracker', () => { + const events: string[] = [] + const processor = createPtyOutputProcessor({ + onTitleChange: () => {}, + onAgentBecameIdle: (title) => events.push(`idle:${title}`), + onAgentBecameWorking: () => events.push('working') + }) + const callbacks = { onData: () => {} } + processor.processData(osc7501('state=working:app=claude-code'), callbacks, { + suppressAttentionEvents: true + }) + processor.processData(osc7501('state=working:app=claude-code'), callbacks) + processor.processData(osc7501('state=done:app=claude-code'), callbacks) + processor.flushPendingSideEffects() + // Restored working is the baseline, so the live done is a completion, not a fresh start. + expect(events).toEqual(['idle:']) + }) +}) + +describe('PTY side-effect queue with program activity', () => { + const effect = (overrides: Partial = {}): PendingPtySideEffect => ({ + payloads: [], + titles: [], + titleScanEffect: 'none', + containsBell: false, + suppressAttentionEvents: false, + ...overrides + }) + + it('never coalesces an activity change away', () => { + const applied: PendingPtySideEffect[] = [] + const queue = createPtyOutputSideEffectQueue({ + countWorkingTitles: () => 0, + apply: (next) => applied.push(next) + }) + queue.enqueue(effect()) + queue.enqueue(effect({ programActivity: ['working'] })) + queue.enqueue(effect()) + queue.flush() + expect(applied.flatMap((next) => next.programActivity ?? [])).toEqual(['working']) + }) + + it('carries the last evicted activity onto the surviving entry', () => { + vi.useFakeTimers() + const applied: PendingPtySideEffect[] = [] + const queue = createPtyOutputSideEffectQueue({ + countWorkingTitles: () => 0, + apply: (next) => applied.push(next) + }) + queue.enqueue(effect({ programActivity: ['working', 'idle'], titles: ['a'] })) + for (let index = 0; index < MAX_PENDING_PTY_SIDE_EFFECTS; index += 1) { + queue.enqueue(effect({ titles: [`t${index}`] })) + } + queue.flush() + expect(applied[0].programActivity).toEqual(['idle']) + vi.useRealTimers() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-output-processor.ts b/src/renderer/src/components/terminal-pane/pty-output-processor.ts index 7333a9fd46d..e11b5ab0908 100644 --- a/src/renderer/src/components/terminal-pane/pty-output-processor.ts +++ b/src/renderer/src/components/terminal-pane/pty-output-processor.ts @@ -1,10 +1,15 @@ +import { extractAllOscTitles } from '../../../../shared/agent-detection' import { - extractAllOscTitles, - isCursorNativeAgentTitle, - normalizeTerminalTitle, - shouldSuppressCursorNativeTitle -} from '../../../../shared/agent-detection' -import { createAgentStatusOscProcessor } from '../../../../shared/agent-status-osc' + createAgentStatusOscProcessor, + type ProgramActivityChange +} from '../../../../shared/agent-status-osc' +import { createProgramStatusSideEffectScanner } from '../../../../shared/terminal-agent-status-processor' +import { extractAllOscTitleEntries } from '../../../../shared/osc-title-extraction' +import { placeProgramActivityAmongTitles } from '../../../../shared/program-status/title-activity-order' +import { + enqueueOrderedPtyEffects, + removeSuppressedCursorNativeTitles +} from './pty-output-effect-ordering' import { createBellDetector } from '../../../../shared/terminal-bell-detector' import type { PtyDataMeta } from './pty-dispatcher' import { @@ -44,25 +49,6 @@ export type ProcessPtyOutputOptions = { carriesHistory?: boolean } -function removeSuppressedCursorNativeTitles( - titles: string[], - precedingTitle: string | null -): boolean { - let writeIndex = 0 - let previousTitle = precedingTitle - for (const title of titles) { - if (isCursorNativeAgentTitle(title) && shouldSuppressCursorNativeTitle(previousTitle)) { - continue - } - previousTitle = normalizeTerminalTitle(title) - titles[writeIndex] = title - writeIndex += 1 - } - const removed = writeIndex < titles.length - titles.length = writeIndex - return removed -} - export function createPtyOutputProcessor({ onTitleChange, onBell, @@ -74,6 +60,7 @@ export function createPtyOutputProcessor({ }: PtyOutputProcessorOptions) { const bellDetector = createBellDetector() let processAgentStatusChunk = createAgentStatusOscProcessor() + let scanProgramStatus = createProgramStatusSideEffectScanner() let partialEscapeTail = '' const titleObserver = createPtyOutputTitleObserver({ onTitleChange, @@ -85,6 +72,15 @@ export function createPtyOutputProcessor({ const sideEffects = createPtyOutputSideEffectQueue({ countWorkingTitles: titleObserver.countWorkingTitles, apply: (effect) => { + // Why seeded on replay: like replayed titles, restored bytes must not move live status, yet the + // tracker must still agree with the scanner, which only reports later changes. + for (const activity of effect.programActivity ?? []) { + if (effect.suppressAttentionEvents) { + titleObserver.seedProgramActivity(activity) + } else { + titleObserver.applyProgramActivity(activity) + } + } if (onAgentStatus) { for (const payload of effect.payloads) { onAgentStatus(payload) @@ -104,14 +100,28 @@ export function createPtyOutputProcessor({ function enqueueSideEffects( data: string, payloads: ReturnType['payloads'], - suppressAttentionEvents: boolean + suppressAttentionEvents: boolean, + programActivity: readonly ProgramActivityChange[] ): void { const scannedForTitles = Boolean(onTitleChange && data.includes('\x1b]')) - const titles = scannedForTitles ? extractAllOscTitles(data) : [] + // Why offsets only with activity: ordinary chunks keep the cheaper title-only scan. + const titleEntries = + scannedForTitles && programActivity.length > 0 ? extractAllOscTitleEntries(data) : null + const titles = titleEntries + ? titleEntries.map((entry) => entry.title) + : scannedForTitles + ? extractAllOscTitles(data) + : [] + const titleEnds = titleEntries?.map((entry) => entry.end) const ignoredCursorNativeTitle = removeSuppressedCursorNativeTitles( titles, - sideEffects.isDrained() ? titleObserver.getLastEmittedTitle() : null + sideEffects.isDrained() ? titleObserver.getLastEmittedTitle() : null, + titleEnds ) + const placedActivity = + programActivity.length > 0 + ? placeProgramActivityAmongTitles(titleEnds ?? [], programActivity) + : [] const deliveredPayloads = onAgentStatus && !suppressAttentionEvents && payloads.length > 0 ? payloads : [] const containsBell = Boolean( @@ -131,12 +141,20 @@ export function createPtyOutputProcessor({ : shouldEmitEmptyTitleScan ? 'stale-probe' : 'none' - if (!shouldEmitEmptyTitleScan && deliveredPayloads.length === 0 && !containsBell) { + if ( + !shouldEmitEmptyTitleScan && + deliveredPayloads.length === 0 && + !containsBell && + placedActivity.length === 0 + ) { return } if (deliveredPayloads.length === 0 && titles.length === 0) { sideEffects.enqueue({ + ...(placedActivity.length > 0 + ? { programActivity: placedActivity.map(({ activity }) => activity) } + : {}), payloads: [], titles: [], titleScanEffect, @@ -144,62 +162,18 @@ export function createPtyOutputProcessor({ suppressAttentionEvents }) } else { - enqueueOrderedEffects( - deliveredPayloads, + enqueueOrderedPtyEffects(sideEffects.enqueue, { + payloads: deliveredPayloads, titles, - shouldEmitEmptyTitleScan ? titleScanEffect : 'none', + placedActivity, + emptyTitleScanEffect: shouldEmitEmptyTitleScan ? titleScanEffect : 'none', containsBell, suppressAttentionEvents - ) + }) } sideEffects.scheduleDrain() } - function enqueueOrderedEffects( - payloads: ReturnType['payloads'], - titles: string[], - emptyTitleScanEffect: PendingPtySideEffect['titleScanEffect'], - containsBell: boolean, - suppressAttentionEvents: boolean - ): void { - for (const payload of payloads) { - sideEffects.enqueue({ - payloads: [payload], - titles: [], - titleScanEffect: 'none', - containsBell: false, - suppressAttentionEvents - }) - } - if (titles.length === 0 && emptyTitleScanEffect !== 'none') { - sideEffects.enqueue({ - payloads: [], - titles: [], - titleScanEffect: emptyTitleScanEffect, - containsBell: false, - suppressAttentionEvents - }) - } - for (const title of titles) { - sideEffects.enqueue({ - payloads: [], - titles: [title], - titleScanEffect: 'none', - containsBell: false, - suppressAttentionEvents - }) - } - if (containsBell) { - sideEffects.enqueue({ - payloads: [], - titles: [], - titleScanEffect: 'none', - containsBell: true, - suppressAttentionEvents - }) - } - } - function processData( data: string, callbacks: PtyOutputCallbacks, @@ -214,6 +188,7 @@ export function createPtyOutputProcessor({ } const processed = processAgentStatusChunk(data) data = processed.cleanData + const programStatus = scanProgramStatus(data) if (options.replayingBufferedData && callbacks.onReplayData) { const replayMeta = { ...(options.clearBeforeReplay === false ? { clearBeforeReplay: false } : {}), @@ -244,7 +219,8 @@ export function createPtyOutputProcessor({ } else { callbacks.onData?.(data) } - enqueueSideEffects(data, processed.payloads, suppressAttentionEvents) + // Why only activity: the execution host owns OSC 7501 rows, their lifetime and hook precedence. + enqueueSideEffects(data, processed.payloads, suppressAttentionEvents, programStatus.activity) } return { @@ -252,6 +228,8 @@ export function createPtyOutputProcessor({ clearAccumulatedState: () => { sideEffects.clear() titleObserver.reset() + // Reset together: the scanner only reports changes against the activity the tracker held. + scanProgramStatus = createProgramStatusSideEffectScanner() bellDetector.reset() partialEscapeTail = '' }, @@ -264,6 +242,7 @@ export function createPtyOutputProcessor({ resetBellDetector: () => bellDetector.reset(), resetAgentStatusCarry: () => { processAgentStatusChunk = createAgentStatusOscProcessor() + scanProgramStatus = createProgramStatusSideEffectScanner() }, getPendingEscapeTailAnsi: () => partialEscapeTail, disposePendingSideEffectGauge: sideEffects.disposeGauge diff --git a/src/renderer/src/components/terminal-pane/pty-output-side-effect-queue.ts b/src/renderer/src/components/terminal-pane/pty-output-side-effect-queue.ts index 9de941be889..68c2c00bfcc 100644 --- a/src/renderer/src/components/terminal-pane/pty-output-side-effect-queue.ts +++ b/src/renderer/src/components/terminal-pane/pty-output-side-effect-queue.ts @@ -3,12 +3,15 @@ import { type PtySideEffectGauge } from './pty-side-effect-pending-census' import type { ProcessedAgentStatusChunk } from '../../../../shared/agent-status-osc' +import type { AgentStatus } from '../../../../shared/agent-title-core' const MAX_PTY_SIDE_EFFECTS_PER_DRAIN = 64 export const MAX_PENDING_PTY_SIDE_EFFECTS = 512 export const MAX_EVICTED_AGENT_STATUS_PAYLOAD_CARRY = 16 export type PendingPtySideEffect = { + /** OSC 7501 activity applied before this effect's titles, already in byte order. */ + programActivity?: (AgentStatus | null)[] payloads: ProcessedAgentStatusChunk['payloads'] titles: string[] titleScanEffect: 'none' | 'stale-probe' | 'ignored-cursor-native' @@ -89,6 +92,11 @@ export function createPtyOutputSideEffectQueue({ if (evicted.containsBell) { survivor.containsBell = true } + // Activity is a state, so only the evicted entry's last value must survive. + const evictedActivity = evicted.programActivity?.at(-1) + if (evictedActivity !== undefined) { + survivor.programActivity = [evictedActivity, ...(survivor.programActivity ?? [])] + } if (evicted.payloads.length > 0) { const merged = evicted.payloads.concat(survivor.payloads) survivor.payloads = @@ -106,6 +114,8 @@ export function createPtyOutputSideEffectQueue({ const prior = pendingEffects.at(-1) if ( prior && + prior.programActivity === undefined && + effect.programActivity === undefined && prior.titles.length === 0 && prior.payloads.length === 0 && !prior.containsBell && diff --git a/src/renderer/src/components/terminal-pane/pty-output-title-observer.ts b/src/renderer/src/components/terminal-pane/pty-output-title-observer.ts index a635b2bdcdd..32fa480798c 100644 --- a/src/renderer/src/components/terminal-pane/pty-output-title-observer.ts +++ b/src/renderer/src/components/terminal-pane/pty-output-title-observer.ts @@ -1,4 +1,5 @@ import { + type AgentStatus, clearWorkingIndicators, createAgentStatusTracker, detectAgentStatusFromTitle, @@ -26,6 +27,8 @@ export type PtyOutputTitleObserver = { titleScanEffect: PendingPtySideEffect['titleScanEffect'], suppressAgentTracker: boolean ) => void + applyProgramActivity: (activity: AgentStatus | null) => void + seedProgramActivity: (activity: AgentStatus | null) => void clearStaleTitleTimer: () => void reset: () => void } @@ -47,7 +50,7 @@ export function createPtyOutputTitleObserver({ const agentTracker = onAgentBecameIdle || onAgentBecameWorking || onAgentExited ? createAgentStatusTracker( - (title) => onAgentBecameIdle?.(title), + (title, status) => onAgentBecameIdle?.(title, { status }), onAgentBecameWorking, onAgentExited, initialTrackerTitle @@ -126,6 +129,8 @@ export function createPtyOutputTitleObserver({ getLastEmittedTitle: () => lastEmittedTitle, countWorkingTitles, processObservedTitles, + applyProgramActivity: (activity) => agentTracker?.handleProgramActivity(activity), + seedProgramActivity: (activity) => agentTracker?.seedProgramActivity(activity), clearStaleTitleTimer, reset: () => { clearStaleTitleTimer() diff --git a/src/renderer/src/components/terminal-pane/pty-transport-types.ts b/src/renderer/src/components/terminal-pane/pty-transport-types.ts index 1041943423a..a58a0465e30 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -1,3 +1,4 @@ +import type { AgentIdleEdgeMeta } from '../../../../shared/terminal-output-side-effects' import type { RestoreSpawnIntent, SpawnDeferralReason @@ -321,7 +322,7 @@ export type IpcPtyTransportOptions = { /** Rebind an existing pane after its provider replaces the PTY identity. */ onPtyRebind?: (ptyId: string, replacedPtyId: string, incarnationId?: string | null) => void onBell?: () => void - onAgentBecameIdle?: (title: string) => void + onAgentBecameIdle?: (title: string, meta?: AgentIdleEdgeMeta) => void onAgentBecameWorking?: () => void onAgentExited?: () => void onAgentStatus?: (payload: ParsedAgentStatusPayload) => void diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index 0e213f1f26d..b6b446abd8a 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -53,9 +53,9 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra const outputProcessor = createPtyOutputProcessor({ onTitleChange, onBell, - onAgentBecameIdle: (title) => { + onAgentBecameIdle: (title, meta) => { if (!suppressAttentionEvents) { - onAgentBecameIdle?.(title) + onAgentBecameIdle?.(title, meta) } }, onAgentBecameWorking, diff --git a/src/renderer/src/components/terminal-pane/terminal-capability-replies.ts b/src/renderer/src/components/terminal-pane/terminal-capability-replies.ts index 504c5067f17..a0d8072fb31 100644 --- a/src/renderer/src/components/terminal-pane/terminal-capability-replies.ts +++ b/src/renderer/src/components/terminal-pane/terminal-capability-replies.ts @@ -7,6 +7,7 @@ import { type TerminalOscColorQuerySlot } from '../../../../shared/terminal-osc-color-reply' import { guardParserHandler } from './terminal-parser-handler-guard' +import { installProgramStatusQueryResponder } from '../../../../shared/program-status/query-responder' export const DEFAULT_DA1_RESPONSE = '\x1b[?1;2c' export const CONPTY_DA1_RESPONSE = '\x1b[?61;4c' @@ -27,6 +28,8 @@ type TerminalCapabilityRepliesDeps = { * answers jcode's burst anyway. Why skip at all: jcode themes itself, and the reply * can land before its composer is ready and render as pre-typed text. */ skipOscColorQueryReplies?: () => boolean + /** False while the pane's execution host cannot read OSC 7501 (see PROGRAM_STATUS_RUNTIME_CAPABILITY). */ + answerProgramStatusQueries?: () => boolean } // Adds Sixel to a DA1 response so DA1-detecting image tools emit Sixel; idempotent. @@ -188,7 +191,12 @@ export function installTerminalCapabilityReplyHandlers( } return sendTerminalOscColorQueryRepliesForSlots(slots, deps.terminal, deps.sendInput) }) - ) + ), + installProgramStatusQueryResponder({ + parser: deps.parser, + emitReply: deps.sendInput, + shouldReply: () => !deps.isReplaying() && deps.answerProgramStatusQueries?.() !== false + }) ] return { diff --git a/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts index e05137ffb64..e56fcc20f31 100644 --- a/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts @@ -353,7 +353,7 @@ describe('registerTerminalSideEffectFactConsumer', () => { ) expect(events).toEqual([ - ['idle', 'Codex done', undefined], + ['idle', 'Codex done', {}], ['title', 'Codex', { staleWorkingTitleClear: true }], ['idle', 'Codex', { staleWorkingTitleClear: true }] ]) diff --git a/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts index 527fbb46b95..5696e5e06f3 100644 --- a/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts +++ b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts @@ -12,6 +12,7 @@ * where pre-mount output produces no attention side effects. The one exception * is a PTY whose consumer just unregistered: see the handoff buffer below. */ +import type { AgentIdleEdgeMeta } from '../../../../shared/terminal-output-side-effects' import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { ParsedAgentStatusPayload } from '../../../../shared/agent-status-types' import type { TerminalGitHubPRLink } from '../../../../shared/terminal-github-pr-link-detector' @@ -74,7 +75,7 @@ export type TerminalSideEffectFactConsumerCallbacks = { meta?: { staleWorkingTitleClear?: boolean } ) => void onBell?: () => void - onAgentBecameIdle?: (title: string, meta?: { staleWorkingTitleClear?: boolean }) => void + onAgentBecameIdle?: (title: string, meta?: AgentIdleEdgeMeta) => void onAgentBecameWorking?: () => void onAgentExited?: () => void /** OSC 133;D — same policy hook the byte-mode commandLifecycle drove @@ -124,10 +125,10 @@ function applyLiveFact(entry: ConsumerEntry, fact: TerminalSideEffectFact, seq: entry.callbacks.onAgentBecameWorking?.() return case 'agent-idle': - entry.callbacks.onAgentBecameIdle?.( - fact.title, - fact.staleWorkingTitleClear ? { staleWorkingTitleClear: true } : undefined - ) + entry.callbacks.onAgentBecameIdle?.(fact.title, { + ...(fact.status ? { status: fact.status } : {}), + ...(fact.staleWorkingTitleClear ? { staleWorkingTitleClear: true } : {}) + }) return case 'agent-exited': entry.callbacks.onAgentExited?.() diff --git a/src/renderer/src/hooks/ipc-events-agent-status-store-test-fixtures.ts b/src/renderer/src/hooks/ipc-events-agent-status-store-test-fixtures.ts index 68263fc3477..ead7bb55e4c 100644 --- a/src/renderer/src/hooks/ipc-events-agent-status-store-test-fixtures.ts +++ b/src/renderer/src/hooks/ipc-events-agent-status-store-test-fixtures.ts @@ -77,6 +77,19 @@ export function applyMockAgentStatusUpdate( | undefined > const existing = statuses[update.paneKey] + if (update.kind === 'programStatus') { + if (!existing) { + return false + } + const { programStatus: _previous, ...rest } = existing + state.agentStatusByPaneKey = { + ...statuses, + [update.paneKey]: update.programStatus + ? { ...rest, programStatus: update.programStatus } + : rest + } + return true + } const updatedAt = update.timing?.updatedAt if ( existing?.updatedAt !== undefined && @@ -174,6 +187,7 @@ export function buildStoreState(overrides: StoreLike): StoreLike { agentStatusByPaneKey: {}, setAgentStatuses: vi.fn(() => []), recordAgentProviderSession: vi.fn(), + setAgentProgramStatus: vi.fn(() => true), clearTransientAgentStatuses: vi.fn(), recentlyClosedAgentStatusTabIds: {}, repos: [], @@ -206,6 +220,11 @@ export function buildStoreState(overrides: StoreLike): StoreLike { update.routing, update.metadata ) + } else if (update.kind === 'programStatus') { + const setProgramStatus = state.setAgentProgramStatus + if (typeof setProgramStatus === 'function') { + setProgramStatus(update.paneKey, update.programStatus) + } } else { const setStatus = state.setAgentStatus as AppState['setAgentStatus'] setStatus( diff --git a/src/renderer/src/hooks/ipc-events/agent-status-event-applicator.ts b/src/renderer/src/hooks/ipc-events/agent-status-event-applicator.ts index 530a5b86405..b9b4a125aa8 100644 --- a/src/renderer/src/hooks/ipc-events/agent-status-event-applicator.ts +++ b/src/renderer/src/hooks/ipc-events/agent-status-event-applicator.ts @@ -7,6 +7,7 @@ import { } from '../../../../shared/agent-status-identity' import { isDecorativeAgentTitleFrameChange } from '../../../../shared/agent-decorative-title-signature' import { parsePaneKey } from '../../../../shared/stable-pane-id' +import { normalizeProgramStatusFacet } from '../../../../shared/program-status/facet' import { resolveAgentStatusTerminalTitle } from '@/lib/agent-status-terminal-title' import { track } from '@/lib/telemetry' import { resolveAgentPaneAuthorityKey } from '@/store/slices/agent-pane-authority' @@ -35,7 +36,11 @@ import { normalizeAgentStatusEvent, normalizeAgentStatusMetadata } from './normalize-agent-status-event' -import { applyProviderSessionOnlyUpdate } from './agent-status-partial-updates' +import { + applyProgramStatusOnlyUpdate, + applyProviderSessionOnlyUpdate, + foldProgramStatusIntoPendingRows +} from './agent-status-partial-updates' export function createAgentStatusEventApplicator(args: { pendingAgentStatusEvents: PendingAgentStatusEvent[] @@ -66,6 +71,7 @@ export function createAgentStatusEventApplicator(args: { if (!payload) { return 'dropped' } + const programStatus = normalizeProgramStatusFacet(data.programStatus) ?? null // Reuse first-match pane ownership without rescanning every worktree per event. const routingIndex = options?.batch?.routingIndex ?? createAgentStatusPaneRoutingIndex(store) let { @@ -105,6 +111,11 @@ export function createAgentStatusEventApplicator(args: { } } if (!exists) { + if (data.programStatusOnly === true) { + // Why not pend: progress ticks would crowd real rows out of the bounded queue. + foldProgramStatusIntoPendingRows(pendingAgentStatusEvents, data.paneKey, programStatus) + return 'dropped' + } if (options?.replay === true) { if (data.worktreeId && hasRuntimeBackedWorktreeAttribution(data)) { if (options?.retry !== true) { @@ -120,7 +131,8 @@ export function createAgentStatusEventApplicator(args: { } return 'pending' } - if (options?.replay !== true && options?.retry !== true) { + // A facet tick does not supersede a pending state row for the pane. + if (options?.replay !== true && options?.retry !== true && data.programStatusOnly !== true) { for (let index = pendingAgentStatusEvents.length - 1; index >= 0; index -= 1) { if (pendingAgentStatusEvents[index].data.paneKey === data.paneKey) { pendingAgentStatusEvents.splice(index, 1) @@ -151,6 +163,10 @@ export function createAgentStatusEventApplicator(args: { return 'dropped' } const existingStatus = store.agentStatusByPaneKey[paneKey] + if (data.programStatusOnly === true) { + // Why after ownership but before staleness: the republished row keeps its old receivedAt. + return applyProgramStatusOnlyUpdate(store, options?.batch, paneKey, programStatus) + } if (existingStatus && data.receivedAt < existingStatus.updatedAt) { return 'dropped' } @@ -181,6 +197,8 @@ export function createAgentStatusEventApplicator(args: { const statusPayloadWithObservation = data.observation ? { ...statusPayloadWithProvenance, observation: data.observation } : statusPayloadWithProvenance + // Why null: main stitches the pane's current facet onto every publish, so absence means cleared. + const statusPayloadWithProgramStatus = { ...statusPayloadWithObservation, programStatus } const identity = resolveAgentStatusIdentity({ existing: existingStatus && @@ -214,7 +232,7 @@ export function createAgentStatusEventApplicator(args: { const statusWorktreeId = data.worktreeId ?? owningWorktreeId const update: AgentStatusUpdate = { paneKey, - payload: statusPayloadWithObservation, + payload: statusPayloadWithProgramStatus, terminalTitle, timing: { updatedAt: data.receivedAt, diff --git a/src/renderer/src/hooks/ipc-events/agent-status-listeners.ts b/src/renderer/src/hooks/ipc-events/agent-status-listeners.ts index 62d9f9c3f03..16e50bce1b5 100644 --- a/src/renderer/src/hooks/ipc-events/agent-status-listeners.ts +++ b/src/renderer/src/hooks/ipc-events/agent-status-listeners.ts @@ -84,6 +84,7 @@ export function registerAgentStatusListeners(args: { const store = useAppStore.getState() if ( data.statusUnavailable !== true && + data.programStatusRemoved !== true && store.agentStatusByPaneKey[data.paneKey]?.state === 'done' ) { return diff --git a/src/renderer/src/hooks/ipc-events/agent-status-partial-updates.ts b/src/renderer/src/hooks/ipc-events/agent-status-partial-updates.ts index 8f257352170..f8dc3bd7815 100644 --- a/src/renderer/src/hooks/ipc-events/agent-status-partial-updates.ts +++ b/src/renderer/src/hooks/ipc-events/agent-status-partial-updates.ts @@ -1,10 +1,45 @@ import type { AgentStatusIpcPayload } from '../../../../shared/agent-status-types' +import type { ProgramStatusFacet } from '../../../../shared/program-status/facet' import type { AppState } from '@/store/types' -import type { AgentProviderSessionUpdate } from '@/store/slices/agent-status' -import type { AgentStatusApplyResult, AgentStatusBatchContext } from './agent-status-bridge-types' +import type { + AgentProgramStatusUpdate, + AgentProviderSessionUpdate +} from '@/store/slices/agent-status' +import type { + AgentStatusApplyResult, + AgentStatusBatchContext, + PendingAgentStatusEvent +} from './agent-status-bridge-types' // Events that patch one aspect of an already-routed row; they never create, re-time or notify one. +/** Carry a facet tick for a not-yet-routable pane on its pending state rows. */ +export function foldProgramStatusIntoPendingRows( + pendingAgentStatusEvents: PendingAgentStatusEvent[], + paneKey: string, + programStatus: ProgramStatusFacet | null +): void { + for (const pending of pendingAgentStatusEvents) { + if (pending.data.paneKey === paneKey && pending.data.programStatusOnly !== true) { + const { programStatus: _stale, ...rest } = pending.data + pending.data = programStatus ? { ...rest, programStatus } : rest + } + } +} + +export function applyProgramStatusOnlyUpdate( + store: AppState, + batch: AgentStatusBatchContext | undefined, + paneKey: string, + programStatus: ProgramStatusFacet | null +): AgentStatusApplyResult { + const update: AgentProgramStatusUpdate = { kind: 'programStatus', paneKey, programStatus } + const applied = batch + ? batch.transaction.apply(update) + : store.setAgentProgramStatus(paneKey, programStatus) + return applied ? 'applied' : 'dropped' +} + export function applyProviderSessionOnlyUpdate(args: { store: AppState batch: AgentStatusBatchContext | undefined diff --git a/src/renderer/src/hooks/ipc-events/agent-status-program-status-only.test.ts b/src/renderer/src/hooks/ipc-events/agent-status-program-status-only.test.ts new file mode 100644 index 00000000000..be5a49d1514 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/agent-status-program-status-only.test.ts @@ -0,0 +1,163 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentStatusIpcPayload } from '../../../../shared/agent-status-types' +import type { ProgramStatusFacet } from '../../../../shared/program-status/facet' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import { buildStoreState, type StoreLike } from '../ipc-events-agent-status-store-test-fixtures' +import type { PendingAgentStatusEvent } from './agent-status-bridge-types' + +const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const PANE_KEY = makePaneKey('tab-1', LEAF_ID) +const FACET: ProgramStatusFacet = { state: 'blocked', kind: 'auth', observedAt: 1 } + +const mocks = vi.hoisted(() => { + const state: { current: StoreLike | null } = { current: null } + return { observe: vi.fn(), state } +}) + +vi.mock('../../store', () => ({ + useAppStore: { getState: () => mocks.state.current } +})) +vi.mock('../agent-hook-completion-notifications', () => ({ + observeAgentHookCompletionForNotification: mocks.observe +})) +vi.mock('@/lib/telemetry', () => ({ track: vi.fn() })) + +import { createAgentStatusEventApplicator } from './agent-status-event-applicator' + +function status(over: Partial): AgentStatusIpcPayload { + return { + paneKey: PANE_KEY, + tabId: 'tab-1', + worktreeId: 'wt-1', + connectionId: null, + state: 'done', + prompt: 'ship it', + agentType: 'claude', + receivedAt: 1_700_000_005_000, + stateStartedAt: 1_700_000_000_000, + ...over + } +} + +describe('agent-status programStatus ingest', () => { + let setAgentStatus: ReturnType + let setAgentProgramStatus: ReturnType + let pending: PendingAgentStatusEvent[] + + function createApplicator(): (data: AgentStatusIpcPayload) => unknown { + return createAgentStatusEventApplicator({ + pendingAgentStatusEvents: pending, + transientClearWatermarkByConnectionId: new Map(), + enqueuePendingAgentStatus: vi.fn() + }) + } + + beforeEach(() => { + mocks.observe.mockReset() + pending = [] + setAgentStatus = vi.fn() + setAgentProgramStatus = vi.fn(() => true) + mocks.state.current = buildStoreState({ + setAgentStatus, + setAgentProgramStatus, + workspaceSessionReady: true, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'Claude' }] + }, + terminalLayoutsByTabId: { + 'tab-1': { root: { type: 'leaf', leafId: LEAF_ID }, activeLeafId: LEAF_ID } + }, + agentStatusByPaneKey: { + [PANE_KEY]: { + paneKey: PANE_KEY, + worktreeId: 'wt-1', + connectionId: null, + agentType: 'claude', + state: 'working', + stateStartedAt: 1_699_999_000_000, + // A renderer-local write newer than main's row. + updatedAt: 1_700_000_009_000 + } + } + }) + }) + + it('passes a full publish facet through, and null when main sent none', () => { + const apply = createApplicator() + apply(status({ receivedAt: 1_700_000_010_000, programStatus: FACET })) + apply(status({ receivedAt: 1_700_000_011_000 })) + + expect(setAgentStatus.mock.calls[0][1].programStatus).toEqual(FACET) + expect(setAgentStatus.mock.calls[1][1].programStatus).toBeNull() + }) + + it('drops a malformed facet whole', () => { + const apply = createApplicator() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Simulates an untrusted IPC value. + const malformed = { state: 'exploded', observedAt: 1 } as unknown as ProgramStatusFacet + apply(status({ receivedAt: 1_700_000_010_000, programStatus: malformed })) + + expect(setAgentStatus.mock.calls[0][1].programStatus).toBeNull() + }) + + it('patches only the facet for a facet-only republish, even with an older receivedAt', () => { + const apply = createApplicator() + + expect(apply(status({ programStatus: FACET, programStatusOnly: true }))).toBe('applied') + + expect(setAgentProgramStatus).toHaveBeenCalledWith(PANE_KEY, FACET) + expect(setAgentStatus).not.toHaveBeenCalled() + expect(mocks.observe).not.toHaveBeenCalled() + }) + + it('clears through a facet-only republish without a facet', () => { + const apply = createApplicator() + apply(status({ programStatusOnly: true })) + + expect(setAgentProgramStatus).toHaveBeenCalledWith(PANE_KEY, null) + }) + + it('reports a facet-only republish the store ignored (no row or unchanged facet) as dropped', () => { + setAgentProgramStatus.mockReturnValue(false) + const apply = createApplicator() + + expect(apply(status({ programStatus: FACET, programStatusOnly: true }))).toBe('dropped') + }) + + it('keeps the pending state row of a known pane when a facet tick lands', () => { + pending.push({ data: status({}), firstSeenAt: 0, replay: false }) + const apply = createApplicator() + + expect(apply(status({ programStatus: FACET, programStatusOnly: true }))).toBe('applied') + + expect(pending).toHaveLength(1) + }) + + it('folds a facet-only republish for an unknown pane into its pending row instead of queueing', () => { + const otherPane = makePaneKey('tab-9', LEAF_ID) + pending.push({ + data: status({ paneKey: otherPane, tabId: 'tab-9' }), + firstSeenAt: 0, + replay: false + }) + const apply = createApplicator() + + expect( + apply( + status({ + paneKey: otherPane, + tabId: 'tab-9', + programStatus: FACET, + programStatusOnly: true + }) + ) + ).toBe('dropped') + + expect(pending).toHaveLength(1) + expect(pending[0].data.programStatus).toEqual(FACET) + expect(setAgentProgramStatus).not.toHaveBeenCalled() + + apply(status({ paneKey: otherPane, tabId: 'tab-9', programStatusOnly: true })) + expect(pending[0].data).not.toHaveProperty('programStatus') + }) +}) diff --git a/src/renderer/src/hooks/ipc-events/agent-status-unavailable.test.ts b/src/renderer/src/hooks/ipc-events/agent-status-unavailable.test.ts index b3924a583ea..7cfaa86ff06 100644 --- a/src/renderer/src/hooks/ipc-events/agent-status-unavailable.test.ts +++ b/src/renderer/src/hooks/ipc-events/agent-status-unavailable.test.ts @@ -13,7 +13,7 @@ afterEach(() => { }) describe('host-selected status unavailable', () => { - it('removes a completed projection only for an explicit host unavailable clear', () => { + it('removes a completed projection only for an explicit unavailable or program-removal clear', () => { let clear: ((event: AgentStatusClearIpcPayload) => void) | undefined vi.stubGlobal('window', { api: { @@ -38,5 +38,8 @@ describe('host-selected status unavailable', () => { expect(store.removeAgentStatus).not.toHaveBeenCalled() clear?.({ paneKey: 'pane', statusUnavailable: true }) expect(store.removeAgentStatus).toHaveBeenCalledWith('pane') + store.removeAgentStatus.mockClear() + clear?.({ paneKey: 'pane', programStatusRemoved: true }) + expect(store.removeAgentStatus).toHaveBeenCalledWith('pane') }) }) diff --git a/src/renderer/src/hooks/useIpcEvents-agent-status-batch-projection.test.ts b/src/renderer/src/hooks/useIpcEvents-agent-status-batch-projection.test.ts index 798fd284fd0..29e1834d4b7 100644 --- a/src/renderer/src/hooks/useIpcEvents-agent-status-batch-projection.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-agent-status-batch-projection.test.ts @@ -282,7 +282,7 @@ describe('useIpcEvents agent status snapshot integration', () => { const next = { ...(storeState.agentStatusByPaneKey as Record) } delete next[update.paneKey] storeState.agentStatusByPaneKey = next - } else { + } else if (update.kind !== 'programStatus') { applyStatus(update.paneKey, update.payload) } } diff --git a/src/renderer/src/lib/agent-idle-edge.test.ts b/src/renderer/src/lib/agent-idle-edge.test.ts new file mode 100644 index 00000000000..0ca78c6d7ce --- /dev/null +++ b/src/renderer/src/lib/agent-idle-edge.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import type { AgentStatusEntry } from '../../../shared/agent-status-types' +import { idleEdgeSettledAtRest, isClaudeIdleEdge } from './agent-idle-edge' + +const row = (agentType: string): AgentStatusEntry => ({ + state: 'done', + prompt: '', + updatedAt: 1, + stateStartedAt: 1, + paneKey: 'tab:leaf', + stateHistory: [], + agentType +}) + +describe('agent idle edges', () => { + it('recognizes Claude from its status row once its title drops the ✳ glyph', () => { + expect(isClaudeIdleEdge('✳ Fix the parser', undefined)).toBe(true) + expect(isClaudeIdleEdge('Fix the parser', row('claude'))).toBe(true) + expect(isClaudeIdleEdge('Fix the parser', row('codex'))).toBe(false) + }) + + it("trusts the tracker's classification and reads the title only without one", () => { + expect(idleEdgeSettledAtRest('Fix the parser', { status: 'idle' })).toBe(true) + expect(idleEdgeSettledAtRest('✳ Claude Code', { status: 'permission' })).toBe(false) + expect(idleEdgeSettledAtRest('✳ Claude Code', undefined)).toBe(true) + }) +}) diff --git a/src/renderer/src/lib/agent-idle-edge.ts b/src/renderer/src/lib/agent-idle-edge.ts new file mode 100644 index 00000000000..8b73fef8c3c --- /dev/null +++ b/src/renderer/src/lib/agent-idle-edge.ts @@ -0,0 +1,13 @@ +import { detectAgentStatusFromTitle, isClaudeAgent } from '../../../shared/agent-detection' +import type { AgentStatusEntry } from '../../../shared/agent-status-types' +import type { AgentIdleEdgeMeta } from '../../../shared/terminal-output-side-effects' + +/** Claude owns the edge: its title says so, or its status row does once the title drops its glyph. */ +export function isClaudeIdleEdge(title: string, entry: AgentStatusEntry | undefined): boolean { + return isClaudeAgent(title) || entry?.agentType === 'claude' +} + +/** The agent settled at rest, not on a prompt; the title decides only for edges from older hosts. */ +export function idleEdgeSettledAtRest(title: string, meta: AgentIdleEdgeMeta | undefined): boolean { + return (meta?.status ?? detectAgentStatusFromTitle(title)) === 'idle' +} diff --git a/src/renderer/src/runtime/remote-agent-row-program-status.test.ts b/src/renderer/src/runtime/remote-agent-row-program-status.test.ts new file mode 100644 index 00000000000..5a0967222e6 --- /dev/null +++ b/src/renderer/src/runtime/remote-agent-row-program-status.test.ts @@ -0,0 +1,140 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' +import type { ProgramStatusFacet } from '../../../shared/program-status/facet' +import { makePaneKey } from '../../../shared/stable-pane-id' +import { toWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id' +import { getDefaultSettings } from '../../../shared/constants' +import { createTestStore, makeWorktree, seedStore } from '../store/slices/store-test-helpers' +import { + markRendererOwnedAgentStatusWrite, + registerRendererOwnedAgentStatusPane, + resetRendererOwnedAgentStatusPanesForTests +} from '../components/terminal-pane/renderer-owned-agent-status-registry' +import { applyFreshWebSessionTabsSnapshot } from './web-session-tabs-sync/snapshot-api' +import { resetWebSessionTabsSnapshotFreshnessForTests } from './web-session-tabs-sync/tracking-lifecycle' + +vi.mock('../store', () => ({ + useAppStore: { + setState: vi.fn(), + getState: vi.fn(() => ({})), + subscribe: vi.fn(() => () => {}) + } +})) + +const WT = 'repo1::/path/wt1' +const ENV = 'remote-env-1' +const T0 = 1_700_000_000_000 +const HOST_TAB_ID = 'host-tab-1' +const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const MIRROR_TAB_ID = toWebTerminalSurfaceTabId(HOST_TAB_ID) +const PANE_KEY = makePaneKey(MIRROR_TAB_ID, LEAF_ID) +const FACET: ProgramStatusFacet = { state: 'working', progress: 40, observedAt: T0 } + +type TestStore = ReturnType + +function hostSnapshot( + snapshotVersion: number, + programStatus?: ProgramStatusFacet +): RuntimeMobileSessionTabsResult { + return { + worktree: WT, + publicationEpoch: 'host-epoch-1', + snapshotVersion, + activeGroupId: 'host-group-1', + activeTabId: `${HOST_TAB_ID}::${LEAF_ID}`, + activeTabType: 'terminal', + tabs: [ + { + type: 'terminal', + id: `${HOST_TAB_ID}::${LEAF_ID}`, + title: 'Claude Code', + parentTabId: HOST_TAB_ID, + leafId: LEAF_ID, + isActive: true, + launchAgent: 'claude', + status: 'ready', + terminal: 'terminal-1', + agentStatus: { + state: 'working', + prompt: '', + updatedAt: T0 - 1_000, + stateStartedAt: T0 - 2_000, + agentType: 'claude', + paneKey: makePaneKey(HOST_TAB_ID, LEAF_ID), + tabId: HOST_TAB_ID, + worktreeId: WT, + stateHistory: [], + ...(programStatus ? { programStatus } : {}) + } + } + ] + } +} + +function applyHostSnapshot(store: TestStore, snapshot: RuntimeMobileSessionTabsResult): void { + const state = store.getState() + store.setState(applyFreshWebSessionTabsSnapshot(state, snapshot, ENV, Date.now())) +} + +/** The renderer's OSC pipeline claims the pane and writes a byte-derived row without a facet. */ +function writeClientByteStatus(store: TestStore): void { + registerRendererOwnedAgentStatusPane(PANE_KEY, ENV) + markRendererOwnedAgentStatusWrite(PANE_KEY) + store + .getState() + .setAgentStatus( + PANE_KEY, + { state: 'working', prompt: '', agentType: 'claude' }, + 'Claude Code', + undefined, + { tabId: MIRROR_TAB_ID, worktreeId: WT } + ) +} + +function seedClientOwnedPane(): TestStore { + const store = createTestStore() + seedStore(store, { + settings: getDefaultSettings('/tmp'), + worktreesByRepo: { repo1: [makeWorktree({ id: WT, repoId: 'repo1', path: '/path/wt1' })] }, + activeWorktreeId: WT + }) + applyHostSnapshot(store, hostSnapshot(1)) + writeClientByteStatus(store) + return store +} + +describe('mirrored program-status facet on a client-owned pane', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(T0) + resetWebSessionTabsSnapshotFreshnessForTests() + resetRendererOwnedAgentStatusPanesForTests() + }) + + afterEach(() => { + vi.useRealTimers() + resetRendererOwnedAgentStatusPanesForTests() + }) + + it("takes the host's facet without bumping the agent-status or sort epochs", () => { + const store = seedClientOwnedPane() + const before = store.getState() + expect(before.agentStatusByPaneKey[PANE_KEY]?.programStatus).toBeUndefined() + + applyHostSnapshot(store, hostSnapshot(2, FACET)) + + const after = store.getState() + expect(after.agentStatusByPaneKey[PANE_KEY]?.programStatus).toEqual(FACET) + expect(after.agentStatusEpoch).toBe(before.agentStatusEpoch) + expect(after.sortEpoch).toBe(before.sortEpoch) + }) + + it('drops the facet key once the host clears it', () => { + const store = seedClientOwnedPane() + applyHostSnapshot(store, hostSnapshot(2, FACET)) + + applyHostSnapshot(store, hostSnapshot(3)) + + expect(store.getState().agentStatusByPaneKey[PANE_KEY]).not.toHaveProperty('programStatus') + }) +}) diff --git a/src/renderer/src/runtime/runtime-rpc-client.ts b/src/renderer/src/runtime/runtime-rpc-client.ts index 157b369669d..9752c9a1df4 100644 --- a/src/renderer/src/runtime/runtime-rpc-client.ts +++ b/src/renderer/src/runtime/runtime-rpc-client.ts @@ -272,6 +272,16 @@ export async function getRuntimeEnvironmentStatus( return entry.status } +/** The capability as last probed, without waiting (for synchronous terminal replies); null before + * the environment's first status probe. */ +export function readCachedRuntimeEnvironmentCapability( + environmentId: string, + capability: RuntimeCapability +): boolean | null { + const status = runtimeCompatibilityChecks.get(environmentId.trim())?.status + return status ? status.capabilities?.includes(capability) === true : null +} + export async function runtimeEnvironmentSupportsCapability( environmentId: string, capability: RuntimeCapability, diff --git a/src/renderer/src/runtime/sync-runtime-graph-agent-status-projection.test.ts b/src/renderer/src/runtime/sync-runtime-graph-agent-status-projection.test.ts index 2963d7d061a..25c9730a016 100644 --- a/src/renderer/src/runtime/sync-runtime-graph-agent-status-projection.test.ts +++ b/src/renderer/src/runtime/sync-runtime-graph-agent-status-projection.test.ts @@ -46,7 +46,15 @@ function referenceProjection(map: AppState['agentStatusByPaneKey']): string { lastAssistantMessage: entry.lastAssistantMessage ?? null, lastAssistantMessageIsToolOutput: entry.lastAssistantMessageIsToolOutput ?? null, interrupted: entry.interrupted ?? null, - mainAgent: mainAgentKey(entry.mainAgent) + mainAgent: mainAgentKey(entry.mainAgent), + programStatus: entry.programStatus + ? [ + entry.programStatus.state, + entry.programStatus.kind ?? null, + entry.programStatus.progress ?? null, + entry.programStatus.message ?? null + ] + : null })) ) } @@ -190,3 +198,21 @@ describe('mobile agent-status projection equivalence', () => { expect(project('failure', 1740000001000)).not.toBe(project('failure')) }) }) + +describe('mobile agent-status projection program-status facet', () => { + it('republishes on facet content but not on a new observedAt alone', () => { + resetRuntimeMobileAgentStatusProjectionCacheForTests() + const facet = { state: 'working', progress: 10, observedAt: 1 } + const base = buildRuntimeMobileAgentStatusProjectionForTests({ + 'tab-0:leaf-0': makeEntry(0, { programStatus: facet }) + }) + const reobserved = buildRuntimeMobileAgentStatusProjectionForTests({ + 'tab-0:leaf-0': makeEntry(0, { programStatus: { ...facet, observedAt: 2 } }) + }) + const progressed = buildRuntimeMobileAgentStatusProjectionForTests({ + 'tab-0:leaf-0': makeEntry(0, { programStatus: { ...facet, progress: 20 } }) + }) + expect(reobserved).toBe(base) + expect(progressed).not.toBe(base) + }) +}) diff --git a/src/renderer/src/runtime/sync-runtime-graph-projection-hot-path.test.ts b/src/renderer/src/runtime/sync-runtime-graph-projection-hot-path.test.ts index 2a13fbee05f..b67cf5f6f37 100644 --- a/src/renderer/src/runtime/sync-runtime-graph-projection-hot-path.test.ts +++ b/src/renderer/src/runtime/sync-runtime-graph-projection-hot-path.test.ts @@ -127,7 +127,15 @@ function referenceAgentStatusProjection(map: AppState['agentStatusByPaneKey']): lastAssistantMessage: entry.lastAssistantMessage ?? null, lastAssistantMessageIsToolOutput: entry.lastAssistantMessageIsToolOutput ?? null, interrupted: entry.interrupted ?? null, - mainAgent: mainAgentKey(entry.mainAgent) + mainAgent: mainAgentKey(entry.mainAgent), + programStatus: entry.programStatus + ? [ + entry.programStatus.state, + entry.programStatus.kind ?? null, + entry.programStatus.progress ?? null, + entry.programStatus.message ?? null + ] + : null })) ) } diff --git a/src/renderer/src/runtime/sync-runtime-graph/agent-status-projection.ts b/src/renderer/src/runtime/sync-runtime-graph/agent-status-projection.ts index 1603dec5668..81e12ad9a59 100644 --- a/src/renderer/src/runtime/sync-runtime-graph/agent-status-projection.ts +++ b/src/renderer/src/runtime/sync-runtime-graph/agent-status-projection.ts @@ -1,5 +1,6 @@ import type { AppState } from '@/store/types' import type { AgentMainAgentStatus } from '../../../../shared/main-agent-status' +import { programStatusFacetContent } from '../../../../shared/program-status/facet' const AGENT_STATUS_SYNC_UPDATED_AT_BUCKET_MS = 30_000 @@ -49,7 +50,8 @@ function serializeAgentStatusEntry( lastAssistantMessageIsToolOutput: entry.lastAssistantMessageIsToolOutput ?? null, interrupted: entry.interrupted ?? null, // A failure changes the verdict and leaves `interrupted` as it was. - mainAgent: mainAgentKey(entry.mainAgent) + mainAgent: mainAgentKey(entry.mainAgent), + programStatus: entry.programStatus ? programStatusFacetContent(entry.programStatus) : null }) } diff --git a/src/renderer/src/runtime/web-session-tabs-sync/agent-status-patch.ts b/src/renderer/src/runtime/web-session-tabs-sync/agent-status-patch.ts index 43a7158c60f..4296ff5fcca 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/agent-status-patch.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/agent-status-patch.ts @@ -153,7 +153,10 @@ export function buildMirroredAgentStatusPatch( : entry.lastAssistantMessageIsToolOutput } : entry - nextByPaneKey.set(entry.paneKey, nextEntry) + nextByPaneKey.set( + entry.paneKey, + nextEntry === entry ? entry : withHostProgramStatus(nextEntry, entry.programStatus) + ) } let nextAgentStatusByPaneKey = state.agentStatusByPaneKey @@ -251,3 +254,12 @@ export function buildMirroredAgentStatusPatch( sortEpoch: sortRelevantChange ? state.sortEpoch + 1 : state.sortEpoch } } + +// Why host-only: client OSC writes have no facet channel, so the host's facet is the current one. +function withHostProgramStatus( + entry: AgentStatusEntry, + programStatus: AgentStatusEntry['programStatus'] +): AgentStatusEntry { + const { programStatus: _clientFacet, ...rest } = entry + return programStatus ? { ...rest, programStatus } : rest +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/state-equality-core.test.ts b/src/renderer/src/runtime/web-session-tabs-sync/state-equality-core.test.ts index aa466c0f9b5..b57ae3c6347 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/state-equality-core.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/state-equality-core.test.ts @@ -45,3 +45,23 @@ describe('agentStatusEntryEqual', () => { expect(agentStatusEntryEqual(entry, { ...entry, turnStartedAt: 1_500 })).toBe(false) }) }) + +describe('agentStatusEntryEqual program-status facet', () => { + it('compares facet content and ignores observedAt', () => { + const entry: AgentStatusEntry = { + state: 'working', + prompt: 'ship it', + updatedAt: 2_000, + stateStartedAt: 1_000, + paneKey: 'tab-1:leaf-1', + stateHistory: [], + programStatus: { state: 'working', progress: 10, observedAt: 1 } + } + const reobserved = { state: 'working' as const, progress: 10, observedAt: 2 } + expect(agentStatusEntryEqual(entry, { ...entry, programStatus: reobserved })).toBe(true) + expect( + agentStatusEntryEqual(entry, { ...entry, programStatus: { ...reobserved, progress: 11 } }) + ).toBe(false) + expect(agentStatusEntryEqual(entry, { ...entry, programStatus: undefined })).toBe(false) + }) +}) diff --git a/src/renderer/src/runtime/web-session-tabs-sync/state-equality-core.ts b/src/renderer/src/runtime/web-session-tabs-sync/state-equality-core.ts index 407b7f6c2f3..de7c54a3b62 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/state-equality-core.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/state-equality-core.ts @@ -5,6 +5,7 @@ import { } from '../../../../shared/agent-status-types' import { agentProviderSessionsEqual } from '../../../../shared/agent-session-resume' import { mainAgentStatusEqual } from '../../../../shared/main-agent-status' +import { programStatusFacetsEqual } from '../../../../shared/program-status/facet' import type { WebSessionTabsBatchContext, WebSessionTabsBatchRecordKey, @@ -63,6 +64,7 @@ export function agentStatusEntryEqual( mainAgentStatusEqual(a.mainAgent, b.mainAgent) && a.promptInteractionKey === b.promptInteractionKey && a.restoredUnconfirmed === b.restoredUnconfirmed && + programStatusFacetsEqual(a.programStatus, b.programStatus) && agentProviderSessionsEqual(a.agentType, a.providerSession, b.providerSession) && sameAgentStateHistory(a.stateHistory, b.stateHistory) ) diff --git a/src/renderer/src/store/slices/agent-status-batch.test.ts b/src/renderer/src/store/slices/agent-status-batch.test.ts index 5504f4d463e..fe1733217c8 100644 --- a/src/renderer/src/store/slices/agent-status-batch.test.ts +++ b/src/renderer/src/store/slices/agent-status-batch.test.ts @@ -166,6 +166,8 @@ describe('setAgentStatuses', () => { update.routing, update.metadata ) + } else if (update.kind === 'programStatus') { + sequentialStore.getState().setAgentProgramStatus(update.paneKey, update.programStatus) } else { sequentialStore .getState() diff --git a/src/renderer/src/store/slices/agent-status-contract.ts b/src/renderer/src/store/slices/agent-status-contract.ts index cd066a63a99..6d79f401340 100644 --- a/src/renderer/src/store/slices/agent-status-contract.ts +++ b/src/renderer/src/store/slices/agent-status-contract.ts @@ -8,6 +8,7 @@ import type { ParsedAgentStatusPayload } from '../../../../shared/agent-status-types' import type { AgentStatusObservation } from '../../../../shared/agent-status-observation' +import type { ProgramStatusFacet } from '../../../../shared/program-status/facet' import type { AgentProviderSessionMetadata, ResumableTuiAgent, @@ -81,6 +82,8 @@ export type AgentStatusPayload = ParsedAgentStatusPayload & { /** Ingress provenance for this write (STA-4293). Read by nothing yet; a caller that omits * it produces exactly the entry it produces today. See agent-status-observation.ts. */ observation?: AgentStatusObservation + /** `null` clears the facet; absent keeps it (the writer has no facet channel, e.g. renderer OSC). */ + programStatus?: ProgramStatusFacet | null } export type AgentStatusTiming = { @@ -141,7 +144,18 @@ export type AgentProviderSessionUpdate = { metadata?: AgentProviderSessionRecordMetadata } -export type AgentStatusBatchUpdate = AgentStatusUpdate | AgentProviderSessionUpdate +/** Patch only a live row's OSC 7501 facet; no state, timing, notification or attention effects. */ +export type AgentProgramStatusUpdate = { + kind: 'programStatus' + paneKey: string + /** `null` clears the facet. */ + programStatus: ProgramStatusFacet | null +} + +export type AgentStatusBatchUpdate = + | AgentStatusUpdate + | AgentProviderSessionUpdate + | AgentProgramStatusUpdate export type AgentStatusBatchTransaction = { getState: () => AppState diff --git a/src/renderer/src/store/slices/agent-status-live-entry-builder.ts b/src/renderer/src/store/slices/agent-status-live-entry-builder.ts index acb7f82a7ed..e8e0cf57538 100644 --- a/src/renderer/src/store/slices/agent-status-live-entry-builder.ts +++ b/src/renderer/src/store/slices/agent-status-live-entry-builder.ts @@ -200,6 +200,8 @@ export function buildAgentStatusLiveEntry( matchedRegistryLaunchConfig ?? matchedSleepingLaunchConfig const mainAgent = resolveAgentStatusLiveEntryMainAgent(existing, payload, identity.agentType) + const programStatus = + payload.programStatus === undefined ? existing?.programStatus : payload.programStatus const entry: AgentStatusEntry = { state: payload.state, workingMode: payload.workingMode, @@ -253,6 +255,7 @@ export function buildAgentStatusLiveEntry( ...(payload.restoredUnconfirmed ? { restoredUnconfirmed: true } : {}), acceptedStatusSeq: (existing?.acceptedStatusSeq ?? 0) + 1, ...(payload.observation ? { observation: payload.observation } : {}), + ...(programStatus ? { programStatus } : {}), interrupted: payload.interrupted, sessionBoundary: payload.sessionBoundary ?? diff --git a/src/renderer/src/store/slices/agent-status-program-status-actions.ts b/src/renderer/src/store/slices/agent-status-program-status-actions.ts new file mode 100644 index 00000000000..64624c38db0 --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-program-status-actions.ts @@ -0,0 +1,39 @@ +import type { AgentStatusSlice } from './agent-status-slice-contract' +import type { AgentStatusRuntime } from './agent-status-runtime' +import { + programStatusFacetsEqual, + type ProgramStatusFacet +} from '../../../../shared/program-status/facet' +import { resolveAgentPaneAuthorityKey } from './agent-pane-authority' + +export function createAgentStatusProgramStatusActions( + runtime: AgentStatusRuntime +): Pick { + const { set } = runtime + return { + setAgentProgramStatus: (paneKey: string, programStatus: ProgramStatusFacet | null) => { + paneKey = resolveAgentPaneAuthorityKey(paneKey) + let changed = false + set((s) => { + const existing = s.agentStatusByPaneKey[paneKey] + // Why no row creation: the facet rides an existing row and never decides one exists. + if ( + !existing || + programStatusFacetsEqual(existing.programStatus, programStatus ?? undefined) + ) { + return s + } + changed = true + const { programStatus: _previous, ...rest } = existing + // Why the entry alone: timing, history, sort and attention must not read a facet tick as news. + return { + agentStatusByPaneKey: { + ...s.agentStatusByPaneKey, + [paneKey]: programStatus ? { ...rest, programStatus } : rest + } + } + }) + return changed + } + } +} diff --git a/src/renderer/src/store/slices/agent-status-program-status.test.ts b/src/renderer/src/store/slices/agent-status-program-status.test.ts new file mode 100644 index 00000000000..c94995ebef3 --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-program-status.test.ts @@ -0,0 +1,94 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { ProgramStatusFacet } from '../../../../shared/program-status/facet' +import { createTestStore } from './store-test-helpers' + +const PANE = 'tab-1:1' +const FACET: ProgramStatusFacet = { state: 'working', progress: 42, observedAt: 1_000 } + +describe('agent status program-status facet', () => { + // Why: setAgentStatus schedules a real freshness timer; fake timers keep it from leaking. + afterEach(() => { + vi.useRealTimers() + }) + + it('stores a main publish facet and clears it when a later main publish sends null', () => { + vi.useFakeTimers() + const store = createTestStore() + store + .getState() + .setAgentStatus(PANE, { state: 'working', prompt: 'p', programStatus: FACET }, undefined, { + updatedAt: 10 + }) + expect(store.getState().agentStatusByPaneKey[PANE].programStatus).toEqual(FACET) + + store + .getState() + .setAgentStatus(PANE, { state: 'working', prompt: 'p', programStatus: null }, undefined, { + updatedAt: 11 + }) + expect(store.getState().agentStatusByPaneKey[PANE]).not.toHaveProperty('programStatus') + }) + + it('keeps the facet across a writer with no facet channel', () => { + vi.useFakeTimers() + const store = createTestStore() + store + .getState() + .setAgentStatus(PANE, { state: 'working', prompt: 'p', programStatus: FACET }, undefined, { + updatedAt: 10 + }) + store.getState().setAgentStatus(PANE, { state: 'done', prompt: 'p' }, undefined, { + updatedAt: 11 + }) + expect(store.getState().agentStatusByPaneKey[PANE].programStatus).toEqual(FACET) + }) + + it('patches only the facet: no timing, history, sequence or epoch change', () => { + vi.useFakeTimers() + const store = createTestStore() + store.getState().setAgentStatus(PANE, { state: 'working', prompt: 'p' }, undefined, { + updatedAt: 10, + stateStartedAt: 5 + }) + const before = store.getState() + const entryBefore = before.agentStatusByPaneKey[PANE] + + expect(store.getState().setAgentProgramStatus(PANE, FACET)).toBe(true) + + const after = store.getState() + const entryAfter = after.agentStatusByPaneKey[PANE] + expect(entryAfter).toEqual({ ...entryBefore, programStatus: FACET }) + expect(after.agentStatusEpoch).toBe(before.agentStatusEpoch) + expect(after.sortEpoch).toBe(before.sortEpoch) + expect(after.unreadAgentCompletionPanes).toBe(before.unreadAgentCompletionPanes) + + expect(store.getState().setAgentProgramStatus(PANE, null)).toBe(true) + expect(store.getState().agentStatusByPaneKey[PANE]).toEqual(entryBefore) + }) + + it('ignores an equal facet and never creates a row', () => { + vi.useFakeTimers() + const store = createTestStore() + expect(store.getState().setAgentProgramStatus(PANE, FACET)).toBe(false) + expect(store.getState().agentStatusByPaneKey[PANE]).toBeUndefined() + + store.getState().setAgentStatus(PANE, { state: 'working', prompt: 'p', programStatus: FACET }) + const map = store.getState().agentStatusByPaneKey + expect(store.getState().setAgentProgramStatus(PANE, { ...FACET, observedAt: 2_000 })).toBe( + false + ) + expect(store.getState().agentStatusByPaneKey).toBe(map) + }) + + it('applies a facet patch inside a batch and reports whether it changed anything', () => { + vi.useFakeTimers() + const store = createTestStore() + store.getState().setAgentStatus(PANE, { state: 'working', prompt: 'p' }) + const outcomes = store.getState().setAgentStatuses([ + { kind: 'programStatus', paneKey: PANE, programStatus: FACET }, + { kind: 'programStatus', paneKey: PANE, programStatus: FACET } + ]) + expect(outcomes).toEqual([true, false]) + expect(store.getState().agentStatusByPaneKey[PANE].programStatus).toEqual(FACET) + }) +}) diff --git a/src/renderer/src/store/slices/agent-status-runtime.ts b/src/renderer/src/store/slices/agent-status-runtime.ts index 0716fa633aa..9e6a37a312c 100644 --- a/src/renderer/src/store/slices/agent-status-runtime.ts +++ b/src/renderer/src/store/slices/agent-status-runtime.ts @@ -26,7 +26,10 @@ export type AgentStatusRuntime = { export function createAgentStatusRuntime( storeSet: Parameters>[0], storeGet: Parameters>[1], - getActions: () => Pick + getActions: () => Pick< + AgentStatusSlice, + 'setAgentStatus' | 'recordAgentProviderSession' | 'setAgentProgramStatus' + > ): AgentStatusRuntime { let batchedAgentStatusState: AppState | null = null let batchedAgentStatusTouchedKeys: Set | null = null @@ -104,6 +107,8 @@ export function createAgentStatusRuntime( update.routing, update.metadata ) + } else if (update.kind === 'programStatus') { + actions.setAgentProgramStatus(update.paneKey, update.programStatus) } else { actions.setAgentStatus( update.paneKey, diff --git a/src/renderer/src/store/slices/agent-status-slice-contract.ts b/src/renderer/src/store/slices/agent-status-slice-contract.ts index e3f5e37445c..bc6575c8e3e 100644 --- a/src/renderer/src/store/slices/agent-status-slice-contract.ts +++ b/src/renderer/src/store/slices/agent-status-slice-contract.ts @@ -28,6 +28,7 @@ import type { SleepingAgentLaunchConfig, SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume' +import type { ProgramStatusFacet } from '../../../../shared/program-status/facet' export type AgentStatusSlice = { /** Explicit agent status entries keyed by `${tabId}:${leafId}`; real-time only, not persisted. */ @@ -93,6 +94,9 @@ export type AgentStatusSlice = { operation: (transaction: AgentStatusBatchTransaction) => Result ) => Result + /** Patch a live row's OSC 7501 facet only (`null` clears it); false when no row or no change. */ + setAgentProgramStatus: (paneKey: string, programStatus: ProgramStatusFacet | null) => boolean + /** Record resume identity without creating a visible turn-status row. */ recordAgentProviderSession: ( paneKey: string, diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index 54b1ad04b87..b08ed80b5ea 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -10,6 +10,7 @@ import { createAgentStatusLaunchActions } from './agent-status-launch-actions' import { createAgentStatusLiveActions } from './agent-status-live-actions' import { createAgentStatusOrchestrationActions } from './agent-status-orchestration-actions' import { createAgentStatusProviderSessionActions } from './agent-status-provider-session-actions' +import { createAgentStatusProgramStatusActions } from './agent-status-program-status-actions' import { createAgentStatusRecoveryActions } from './agent-status-recovery-actions' import { createAgentStatusRetentionActions } from './agent-status-retention-actions' @@ -28,6 +29,7 @@ export type { AgentStatusTiming, AgentStatusUpdate, AgentProviderSessionUpdate, + AgentProgramStatusUpdate, AgentStatusWorktreeShutdownReason, AllAgentSessionCaptureMode, DropAgentStatusByTabPrefixOptions, @@ -66,7 +68,10 @@ export const createAgentStatusSlice: StateCreator { let composedActions: - | Pick + | Pick< + AgentStatusSlice, + 'setAgentStatus' | 'recordAgentProviderSession' | 'setAgentProgramStatus' + > | undefined const runtime = createAgentStatusRuntime(storeSet, storeGet, () => { if (!composedActions) { @@ -74,6 +79,7 @@ export const createAgentStatusSlice: StateCreator { expect(process('"}\x07after')).toEqual({ cleanData: 'after', payloads: [{ state: 'working', prompt: 'fragment' }], - lastPayloadCleanOffset: 0 + lastPayloadCleanOffset: 0, + payloadCleanOffsets: [0] }) } }) @@ -64,7 +65,8 @@ describe('OSC 9999 pending storage', () => { expect(process(`\ude00"}${terminator}after`)).toEqual({ cleanData: 'after', payloads: [{ state: 'working', prompt: '漢字\ud800|\udc00|😀' }], - lastPayloadCleanOffset: 0 + lastPayloadCleanOffset: 0, + payloadCleanOffsets: [0] }) } ) @@ -80,7 +82,8 @@ describe('OSC 9999 pending storage', () => { expect(process('\x07after')).toEqual({ cleanData: 'after', payloads: [{ state: 'working', prompt: '' }], - lastPayloadCleanOffset: 0 + lastPayloadCleanOffset: 0, + payloadCleanOffsets: [0] }) }) }) diff --git a/src/shared/agent-status-osc-scan-budget.test.ts b/src/shared/agent-status-osc-scan-budget.test.ts index d0520056462..2f5c6a59371 100644 --- a/src/shared/agent-status-osc-scan-budget.test.ts +++ b/src/shared/agent-status-osc-scan-budget.test.ts @@ -62,7 +62,8 @@ describe('OSC 9999 scan budget', () => { expect(result).toEqual({ cleanData: 'log'.repeat(count), payloads: statuses, - lastPayloadCleanOffset: count * 3 + lastPayloadCleanOffset: count * 3, + payloadCleanOffsets: Array.from({ length: count }, (_, index) => (index + 1) * 3) }) expect(searchedChars).toBeLessThanOrEqual(data.length * 2) }) diff --git a/src/shared/agent-status-osc.test.ts b/src/shared/agent-status-osc.test.ts index 065721fbce7..fecd5151dac 100644 --- a/src/shared/agent-status-osc.test.ts +++ b/src/shared/agent-status-osc.test.ts @@ -105,7 +105,8 @@ describe('createAgentStatusOscProcessor', () => { { state: 'working', prompt: '' }, { state: 'done', prompt: '' } ], - lastPayloadCleanOffset: '😀A\x1b\\'.length + lastPayloadCleanOffset: '😀A\x1b\\'.length, + payloadCleanOffsets: ['😀'.length, '😀A\x1b\\'.length] }) }) @@ -167,14 +168,16 @@ describe('createAgentStatusOscProcessor', () => { expect(exact('\x07after')).toEqual({ cleanData: 'after', payloads: [{ state: 'working', prompt: '' }], - lastPayloadCleanOffset: 0 + lastPayloadCleanOffset: 0, + payloadCleanOffsets: [0] }) const complete = createAgentStatusOscProcessor() expect(complete(`${atCap} \x1b\\after`)).toEqual({ cleanData: 'after', payloads: [{ state: 'working', prompt: '' }], - lastPayloadCleanOffset: 0 + lastPayloadCleanOffset: 0, + payloadCleanOffsets: [0] }) }) }) diff --git a/src/shared/agent-status-osc.ts b/src/shared/agent-status-osc.ts index 3b13d2de5bc..516c2590ade 100644 --- a/src/shared/agent-status-osc.ts +++ b/src/shared/agent-status-osc.ts @@ -1,6 +1,8 @@ import type { ParsedAgentStatusPayload } from './agent-status-types' import { parseAgentStatusPayload } from './agent-status-types' import { ownRetainedString } from './own-retained-string' +import type { ProgramStatusFacet } from './program-status/facet' +import type { AgentStatus } from './agent-title-core' const OSC_AGENT_STATUS_PREFIX = '\x1b]9999;' @@ -24,8 +26,22 @@ export type ProcessedAgentStatusChunk = { cleanData: string payloads: ParsedAgentStatusPayload[] lastPayloadCleanOffset: number | null + /** Each payload's end offset in `cleanData`, so other in-band status can merge in byte order. */ + payloadCleanOffsets?: number[] + /** The subset of `payloads` projected from OSC 7501 records; these yield to the pane's hook row. */ + programStatusPayloads?: ReadonlySet + /** The subset of those whose program left no records (see ProgramStatusPayload.removesRow). */ + programStatusRowRemovals?: ReadonlySet + /** The chunk's net OSC 7501 facet change: new facet, `null` when cleared, absent when unchanged. */ + programStatusFacet?: ProgramStatusFacet | null + /** Each change in the program's OSC 7501 activity, in byte order with `cleanData` offsets. + * Title trackers classify by it ahead of title glyphs while it is non-null. */ + programActivity?: readonly ProgramActivityChange[] } +/** The program's OSC 7501 activity from `offset` on; `null` once it stops reporting. */ +export type ProgramActivityChange = { offset: number; activity: AgentStatus | null } + function findAgentStatusTerminator( data: string, searchFrom: number, @@ -85,6 +101,7 @@ export function createAgentStatusOscProcessor(): (data: string) => ProcessedAgen const payloads: ParsedAgentStatusPayload[] = [] let lastPayloadCleanOffset: number | null = null + const payloadCleanOffsets: number[] = [] let cleanData = '' let cursor = 0 const nextTerminator = { belIndex: 0, stIndex: 0 } @@ -122,10 +139,16 @@ export function createAgentStatusOscProcessor(): (data: string) => ProcessedAgen if (parsed) { payloads.push(parsed) lastPayloadCleanOffset = cleanData.length + payloadCleanOffsets.push(lastPayloadCleanOffset) } cursor = terminator.index + terminator.length } - return { cleanData, payloads, lastPayloadCleanOffset } + return { + cleanData, + payloads, + lastPayloadCleanOffset, + ...(payloads.length > 0 ? { payloadCleanOffsets } : {}) + } } } diff --git a/src/shared/agent-status-subagent-snapshot.ts b/src/shared/agent-status-subagent-snapshot.ts index 96afdb618bc..4f10b1e89bb 100644 --- a/src/shared/agent-status-subagent-snapshot.ts +++ b/src/shared/agent-status-subagent-snapshot.ts @@ -27,7 +27,7 @@ export type AgentSubagentSnapshot = { /** Maximum subagent child rows carried per status entry. Bounds per-pane cache * and IPC fanout against a runaway spawner. */ export const AGENT_STATUS_MAX_SUBAGENTS = 32 -const AGENT_SUBAGENT_ID_MAX_LENGTH = 64 +export const AGENT_SUBAGENT_ID_MAX_LENGTH = 64 function normalizeSubagentSnapshot( value: unknown, diff --git a/src/shared/agent-status-tracker-program-activity.test.ts b/src/shared/agent-status-tracker-program-activity.test.ts new file mode 100644 index 00000000000..4ae7f7739b7 --- /dev/null +++ b/src/shared/agent-status-tracker-program-activity.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it, vi } from 'vitest' +import { createAgentStatusTracker } from './agent-title-status' + +function tracker() { + const events: string[] = [] + const instance = createAgentStatusTracker( + (title) => events.push(`idle:${title}`), + () => events.push('working'), + () => events.push('exited') + ) + return { instance, events } +} + +describe('createAgentStatusTracker with OSC 7501 activity', () => { + it('drives edges from the program while its titles carry no status glyph', () => { + // Claude Code drops its ⠂/✳ prefix once the terminal answers the OSC 7501 probe. + const { instance, events } = tracker() + instance.handleProgramActivity('idle') + instance.handleTitle('Claude Code') + instance.handleProgramActivity('working') + instance.handleTitle('Fix the parser') + instance.handleProgramActivity('permission') + instance.handleProgramActivity('working') + instance.handleProgramActivity('idle') + expect(events).toEqual(['working', 'idle:Fix the parser', 'working', 'idle:Fix the parser']) + }) + + it('never reads a glyph-less title as an exit while the program reports', () => { + const { instance, events } = tracker() + instance.handleProgramActivity('idle') + instance.handleTitle('some topic title the detector cannot classify ⟂') + expect(events).toEqual([]) + }) + + it('hands classification back to titles once the program stops reporting', () => { + const { instance, events } = tracker() + instance.handleProgramActivity('idle') + instance.handleProgramActivity(null) + instance.handleTitle('') + expect(events).toEqual(['exited']) + }) + + it('leaves agents that never report OSC 7501 on title glyphs', () => { + const onIdle = vi.fn() + const instance = createAgentStatusTracker(onIdle) + instance.handleTitle('⠂ Claude Code') + instance.handleTitle('✳ Claude Code') + expect(onIdle).toHaveBeenCalledWith('✳ Claude Code', 'idle') + }) +}) diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index 88c744aedda..a61ec507be8 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -2,6 +2,7 @@ // Why: status comes from hooks (Claude, Codex, etc.) — never inferred from terminal titles; // a narrow interrupt fallback synthesizes a final `done` when an agent misses its cancellation hook. +import type { ProgramStatusFacet } from './program-status/facet' import type { AgentProviderSessionMetadata } from './agent-session-resume' import type { AgentMainAgentStatus } from './main-agent-status' import type { AgentStateHistoryEntry } from './agent-state-history' @@ -159,6 +160,10 @@ export type AgentStatusEntry = { terminalResumeEligible?: false /** Live-only Command Code turn boundary key; not persisted to last-status.json. */ promptInteractionKey?: string + /** The program's own OSC 7501 report (progress, message, blocks like sign-in), beside whichever + * producer owns `state`. Never decides `state`; readers resolve it through + * resolveProgramStatusPresentation. Live-only. */ + programStatus?: ProgramStatusFacet /** True for a nonterminal state hydrated from last-status.json with no live hook since: * the transition may have been missed while no receiver was up, so freshness gates * treat the row as stale immediately. Cleared by any accepted live event. */ diff --git a/src/shared/agent-title-status.ts b/src/shared/agent-title-status.ts index 7b17a5b03e0..dc54ce7a437 100644 --- a/src/shared/agent-title-status.ts +++ b/src/shared/agent-title-status.ts @@ -65,16 +65,23 @@ export function clearWorkingIndicators(title: string): string { return cleaned || title } +/** Where a working agent settled: at rest, or on a prompt that needs the user. */ +export type AgentIdleStatus = Exclude + /** - * Tracks agent status transitions from terminal title changes. + * Tracks agent status transitions. The program's own OSC 7501 activity, while it reports one, + * outranks title glyphs; titles classify only programs that report nothing explicit. */ export function createAgentStatusTracker( - onBecameIdle: (title: string) => void, + onBecameIdle: (title: string, status: AgentIdleStatus) => void, onBecameWorking?: () => void, onAgentExited?: () => void, initialTitle?: string ): { handleTitle: (title: string) => void + handleProgramActivity: (activity: AgentStatus | null) => void + /** Adopt replayed activity without edges, as replayed titles move no status. */ + seedProgramActivity: (activity: AgentStatus | null) => void seedTitle: (title: string) => void restoreLastExit: (confirmedStatus?: AgentStatus) => AgentStatus | null reset: () => void @@ -84,32 +91,54 @@ export function createAgentStatusTracker( let lastStatus: AgentStatus | null = initialTitle !== undefined ? detectAgentStatusFromTitle(initialTitle) : null let restorableExitStatus: AgentStatus | null = null + let programActivity: AgentStatus | null = null + let lastTitle = initialTitle ?? '' + + function transition(newStatus: AgentStatus | null, title: string): void { + if (newStatus !== null) { + restorableExitStatus = null + } + if (lastStatus === 'working' && newStatus !== null && newStatus !== 'working') { + onBecameIdle(title, newStatus) + } + if (lastStatus !== 'working' && newStatus === 'working') { + onBecameWorking?.() + } + // Why: reverting to a plain shell prompt after idle/permission means the + // agent exited; while working it can just be a transient internal title. + if (lastStatus !== null && lastStatus !== 'working' && newStatus === null) { + restorableExitStatus = lastStatus + lastStatus = null + onAgentExited?.() + } + if (newStatus !== null) { + lastStatus = newStatus + } + } return { handleTitle(title: string): void { - const newStatus = detectAgentStatusFromTitle(title) - if (newStatus !== null) { + lastTitle = title + transition(programActivity ?? detectAgentStatusFromTitle(title), title) + }, + handleProgramActivity(activity: AgentStatus | null): void { + programActivity = activity + // Why no transition on null: the program stopped reporting, so the next title decides. + if (activity !== null) { + transition(activity, lastTitle) + } + }, + seedProgramActivity(activity: AgentStatus | null): void { + programActivity = activity + // The restored state is the baseline later live edges compare against. + if (activity !== null) { + lastStatus = activity restorableExitStatus = null } - if (lastStatus === 'working' && newStatus !== null && newStatus !== 'working') { - onBecameIdle(title) - } - if (lastStatus !== 'working' && newStatus === 'working') { - onBecameWorking?.() - } - // Why: reverting to a plain shell prompt after idle/permission means the - // agent exited; while working it can just be a transient internal title. - if (lastStatus !== null && lastStatus !== 'working' && newStatus === null) { - restorableExitStatus = lastStatus - lastStatus = null - onAgentExited?.() - } - if (newStatus !== null) { - lastStatus = newStatus - } }, seedTitle(title: string): void { - lastStatus = detectAgentStatusFromTitle(title) + lastTitle = title + lastStatus = programActivity ?? detectAgentStatusFromTitle(title) restorableExitStatus = null }, restoreLastExit(confirmedStatus?: AgentStatus): AgentStatus | null { @@ -123,6 +152,8 @@ export function createAgentStatusTracker( reset(): void { lastStatus = null restorableExitStatus = null + programActivity = null + lastTitle = '' } } } diff --git a/src/shared/claude-terminal-interrupt.test.ts b/src/shared/claude-terminal-interrupt.test.ts index 840120b4645..886a6de58ab 100644 --- a/src/shared/claude-terminal-interrupt.test.ts +++ b/src/shared/claude-terminal-interrupt.test.ts @@ -98,4 +98,52 @@ describe('Claude native interruption evidence', () => { tracker.observe('pane', { kind: 'title', title: '✳ Task' }, 262) expect(interrupt).not.toHaveBeenCalled() }) + + it("confirms from Claude's OSC 7501 activity while its titles carry no glyph", () => { + const { tracker, interrupt } = createTracker() + tracker.observe('pane', { kind: 'activity', status: 'working' }, 100) + tracker.observe('pane', { kind: 'title', title: 'Fix the parser' }, 110) + tracker.observe('pane', { kind: 'input', data: '\x1b' }, 200) + tracker.observe('pane', { kind: 'title', title: 'Fix the parser' }, 210) + expect(interrupt).not.toHaveBeenCalled() + tracker.observe('pane', { kind: 'activity', status: 'idle' }, 300) + expect(interrupt).toHaveBeenCalledOnce() + }) + + it('accepts the current row when activity went working before the row did', () => { + const { tracker, interrupt, replace, row } = createTracker() + replace({ ...row, payload: { ...row.payload, state: 'waiting' } }) + tracker.observe('pane', { kind: 'activity', status: 'working' }, 100) + replace({ ...row }) + tracker.observe('pane', { kind: 'input', data: '\x1b' }, 200) + tracker.observe('pane', { kind: 'activity', status: 'idle' }, 300) + expect(interrupt).toHaveBeenCalledOnce() + }) + + it('does not read a later program idle as the Escape landing', () => { + const { tracker, interrupt } = createTracker() + tracker.observe('pane', { kind: 'activity', status: 'working' }, 100) + tracker.observe('pane', { kind: 'input', data: '\x1b' }, 200) + tracker.observe('pane', { kind: 'activity', status: 'idle' }, 1_701) + expect(interrupt).not.toHaveBeenCalled() + }) + + it('leaves the turn running when activity stays working after Escape', () => { + const { tracker, interrupt } = createTracker() + tracker.observe('pane', { kind: 'activity', status: 'working' }, 100) + tracker.observe('pane', { kind: 'input', data: '\x1b' }, 200) + tracker.observe('pane', { kind: 'activity', status: 'working' }, 250) + tracker.observe('pane', { kind: 'activity', status: 'idle' }, 300) + expect(interrupt).not.toHaveBeenCalled() + }) + + it('hands back to title glyphs once Claude stops reporting activity', () => { + const { tracker, interrupt } = createTracker() + tracker.observe('pane', { kind: 'activity', status: 'idle' }, 50) + tracker.observe('pane', { kind: 'activity', status: null }, 60) + tracker.observe('pane', { kind: 'title', title: '◐ Task' }, 100) + tracker.observe('pane', { kind: 'input', data: '\x1b' }, 200) + tracker.observe('pane', { kind: 'title', title: '✳ Task' }, 300) + expect(interrupt).toHaveBeenCalledOnce() + }) }) diff --git a/src/shared/claude-terminal-interrupt.ts b/src/shared/claude-terminal-interrupt.ts index 0dd41abc741..f249b83396c 100644 --- a/src/shared/claude-terminal-interrupt.ts +++ b/src/shared/claude-terminal-interrupt.ts @@ -1,12 +1,17 @@ import { CLAUDE_IDLE, containsAgentSpinnerGlyph } from './agent-title-glyphs' import type { AgentHookEventPayload } from './agent-hook-listener/listener-event' +import type { AgentStatus } from './agent-title-core' export type ClaudeTerminalEvidence = | { kind: 'input'; data: string } | { kind: 'title'; title: string; staleWorkingTitleClear?: boolean } + /** Claude's OSC 7501 activity; null once it stops reporting. Its titles then carry no glyph. */ + | { kind: 'activity'; status: AgentStatus | null } | { kind: 'reset' } const TITLE_CONFIRMATION_WINDOW_MS = 5_000 +// Why shorter: Claude reports idle as Escape lands; a later idle is likelier the turn finishing. +const ACTIVITY_CONFIRMATION_WINDOW_MS = 1_500 // oxlint-disable-next-line no-control-regex -- kitty CSI-u reports the Escape key, not terminal text. const KITTY_ESCAPE_RE = /^\x1b\[27(?:;1(?::([123]))?)?u$/ @@ -26,9 +31,11 @@ type Observation = { working: boolean workingRow?: Row pending?: { row: Row; at: number } + /** While Claude reports OSC 7501 activity, that activity, not the title glyph, says working/idle. */ + programReports?: boolean } -/** Correlates accepted Escape with Claude's live OSC title, on the execution host. */ +/** Correlates accepted Escape with Claude's live activity (OSC 7501, else title), on the execution host. */ export class ClaudeTerminalInterruptTracker { private readonly observations = new Map>() @@ -67,7 +74,9 @@ export class ClaudeTerminalInterruptTracker { observed.pending = escape === 'press' && observed.working && - (observed.workingRow === row || + // Activity is reported only on change, so while it says working the current row is that turn's. + (observed.programReports === true || + observed.workingRow === row || (row.payload.mainAgent?.stateStartedAt !== undefined && observed.workingRow?.payload.mainAgent?.stateStartedAt === row.payload.mainAgent.stateStartedAt && @@ -81,22 +90,52 @@ export class ClaudeTerminalInterruptTracker { : undefined return } - observed.working = containsAgentSpinnerGlyph(evidence.title) - observed.workingRow = observed.working ? row : undefined + if (evidence.kind === 'activity') { + observed.programReports = evidence.status !== null + if (evidence.status !== null) { + this.settle( + observed, + row, + { working: evidence.status === 'working', idle: evidence.status === 'idle' }, + ACTIVITY_CONFIRMATION_WINDOW_MS, + now + ) + } + return + } + if (!observed.programReports) { + this.settle( + observed, + row, + { + working: containsAgentSpinnerGlyph(evidence.title), + idle: evidence.title.trimStart().startsWith(CLAUDE_IDLE) + }, + TITLE_CONFIRMATION_WINDOW_MS, + now + ) + } + } + + private settle( + observed: Observation, + row: Row, + { working, idle }: { working: boolean; idle: boolean }, + confirmationWindowMs: number, + now: number + ): void { + observed.working = working + observed.workingRow = working ? row : undefined const pending = observed.pending if (!pending) { return } // Continued busy output means Escape left the turn running, as when dismissing /usage. - if ( - observed.working || - pending.row !== row || - now - pending.at > TITLE_CONFIRMATION_WINDOW_MS - ) { + if (observed.working || pending.row !== row || now - pending.at > confirmationWindowMs) { observed.pending = undefined return } - if (evidence.title.trimStart().startsWith(CLAUDE_IDLE)) { + if (idle) { observed.pending = undefined this.interrupt(row) } diff --git a/src/shared/osc-title-extraction.ts b/src/shared/osc-title-extraction.ts index 20760457237..953525c901e 100644 --- a/src/shared/osc-title-extraction.ts +++ b/src/shared/osc-title-extraction.ts @@ -100,11 +100,20 @@ export function extractLastOscTitle(data: string): string | null { } export function extractAllOscTitles(data: string): string[] { + return scanAllOscTitles(data, (title) => title) +} + +/** Every complete OSC title with the offset just past it, for ordering titles against other events. */ +export function extractAllOscTitleEntries(data: string): { title: string; end: number }[] { + return scanAllOscTitles(data, (title, end) => ({ title, end })) +} + +function scanAllOscTitles(data: string, make: (title: string, end: number) => T): T[] { if (!data.includes('\x1b]')) { return [] } - const titles: string[] = [] + const titles: T[] = [] let oldestTitleIndex = 0 let searchStart = 0 while (searchStart < data.length) { @@ -117,10 +126,11 @@ export function extractAllOscTitles(data: string): string[] { break } if (parsed.kind === 'title') { + const entry = make(parsed.title, parsed.nextIndex) if (titles.length < MAX_OSC_TITLES_PER_CHUNK) { - titles.push(parsed.title) + titles.push(entry) } else { - titles[oldestTitleIndex] = parsed.title + titles[oldestTitleIndex] = entry oldestTitleIndex = (oldestTitleIndex + 1) % MAX_OSC_TITLES_PER_CHUNK } searchStart = parsed.nextIndex diff --git a/src/shared/program-status/activity.ts b/src/shared/program-status/activity.ts new file mode 100644 index 00000000000..27c26419cd1 --- /dev/null +++ b/src/shared/program-status/activity.ts @@ -0,0 +1,21 @@ +import type { AgentStatus } from '../agent-title-core' +import type { ProgramStatusRecord } from './records' + +/** + * The program's own account of its activity in the vocabulary Orca's title tracker speaks, or null + * once it stops reporting (its root record is gone). While non-null it outranks title glyphs. + */ +export function programStatusActivity(root: ProgramStatusRecord | undefined): AgentStatus | null { + switch (root?.state) { + case undefined: + return null + case 'working': + return 'working' + case 'blocked': + return 'permission' + case 'idle': + case 'done': + case 'error': + return 'idle' + } +} diff --git a/src/shared/program-status/agent-status-projector.test.ts b/src/shared/program-status/agent-status-projector.test.ts new file mode 100644 index 00000000000..edb5b8d44a8 --- /dev/null +++ b/src/shared/program-status/agent-status-projector.test.ts @@ -0,0 +1,262 @@ +import { describe, expect, it } from 'vitest' +import { createProgramStatusProjector } from './agent-status-projector' + +const b64 = (text: string): string => Buffer.from(text, 'utf8').toString('base64') +const osc = (body: string): string => `\x1b]7501;${body}\x1b\\` + +function projector() { + let clock = 1000 + const instance = createProgramStatusProjector(() => clock) + return { + feed: (data: string) => instance.process(data).payloads.map(({ payload }) => payload), + tick: (ms: number) => { + clock += ms + } + } +} + +describe('createProgramStatusProjector', () => { + it('maps a Claude Code turn onto Orca agent states', () => { + const { feed, tick } = projector() + expect(feed(osc('state=idle:app=claude-code'))).toEqual([ + { + state: 'done', + prompt: '', + agentType: 'claude', + sessionBoundary: true, + mainAgent: { state: 'done', stateStartedAt: 1000 } + } + ]) + tick(5) + expect(feed(osc(`state=working:app=claude-code:msg=${b64('Fixing the parser')}`))).toEqual([ + { + state: 'working', + prompt: '', + agentType: 'claude', + toolInput: 'Fixing the parser', + mainAgent: { state: 'working', stateStartedAt: 1005 } + } + ]) + tick(5) + expect( + feed( + osc( + `state=blocked:kind=permission:app=claude-code:msg=${b64('approve Bash: rm -rf build')}` + ) + ) + ).toMatchObject([{ state: 'waiting', toolInput: 'approve Bash: rm -rf build' }]) + tick(5) + expect(feed(osc(`state=done:app=claude-code:msg=${b64('Parser fixed')}`))).toEqual([ + { + state: 'done', + prompt: '', + agentType: 'claude', + lastAssistantMessage: 'Parser fixed', + mainAgent: { state: 'done', outcome: 'success', stateStartedAt: 1015 } + } + ]) + }) + + it('reports auth and unnamed blocks as blocked, errors as failed turns', () => { + const { feed } = projector() + expect(feed(osc('state=blocked:kind=auth:app=pi'))).toMatchObject([ + { state: 'blocked', agentType: 'pi' } + ]) + expect(feed(osc(`state=error:app=pi:msg=${b64('rate limited')}`))).toMatchObject([ + { state: 'done', lastAssistantMessage: 'rate limited', mainAgent: { outcome: 'failure' } } + ]) + }) + + it('reads idle as at rest, never as a cancelled turn, and keeps an unseen done', () => { + const { feed } = projector() + // Codex boots with a short working blip before settling idle. + feed(osc('state=idle:app=codex')) + feed(osc('state=working:app=codex')) + const [settled] = feed(osc('state=idle:app=codex')) + expect(settled).toMatchObject({ state: 'done', sessionBoundary: true }) + expect(settled.interrupted).toBeUndefined() + expect(settled.mainAgent?.outcome).toBeUndefined() + feed(osc('state=working:app=codex')) + feed(osc('state=done:app=codex')) + expect(feed(osc('state=idle:app=codex'))).toEqual([]) + }) + + it('emits only changes, so redraws that resend a report are silent', () => { + const { feed } = projector() + expect(feed(osc('state=working:app=omp'))).toHaveLength(1) + expect(feed(osc('state=working:app=omp') + osc('state=working:app=omp'))).toEqual([]) + }) + + it('ends a live program at the next shell prompt but keeps a finished one', () => { + const { feed } = projector() + feed(osc('state=working:app=pi')) + expect(feed('\x1b]133;A\x07')).toMatchObject([{ state: 'done', sessionBoundary: true }]) + feed(osc('state=done:app=pi')) + expect(feed('\x1b]133;A\x07')).toEqual([]) + }) + + it('turns child records into subagent rows with inherited app and sanitized titles', () => { + const { feed, tick } = projector() + feed(osc('state=working:app=claude-code')) + tick(10) + expect(feed(osc(`state=working:id=task_1:title=${b64('‮Explore the repo')}`))).toMatchObject([ + { + state: 'working', + subagents: [ + { + id: 'task_1', + state: 'working', + agentType: 'claude', + description: 'Explore the repo', + startedAt: 1010 + } + ] + } + ]) + expect(feed(osc('state=blocked:kind=question:id=task_1'))).toMatchObject([ + { subagents: [{ id: 'task_1', state: 'waiting', startedAt: 1010 }] } + ]) + const [cleared] = feed(osc('state=clear:id=task_1')) + expect(cleared.subagents).toBeUndefined() + }) + + it('reads a clear during live work as the program leaving, not a cancelled turn', () => { + const { feed } = projector() + feed(osc('state=working:app=claude-code')) + const [left] = feed(osc('state=clear')) + expect(left).toMatchObject({ state: 'done', sessionBoundary: true }) + expect(left.interrupted).toBeUndefined() + expect(feed(osc('state=clear'))).toEqual([]) + }) + + it("maps harness app ids onto Orca's agent ids so hook precedence can match them", () => { + const { feed } = projector() + expect(feed(osc('state=working:app=grok-build'))).toMatchObject([{ agentType: 'grok' }]) + expect(feed(osc('state=done:app=terraform'))).toMatchObject([{ agentType: 'terraform' }]) + }) + + it('emits the net facet per chunk, including progress the lifecycle payload ignores', () => { + let clock = 1 + const { process } = createProgramStatusProjector(() => clock) + expect(process(osc('state=working:app=claude-code:progress=40')).facet).toEqual({ + state: 'working', + progress: 40, + observedAt: 1 + }) + clock = 2 + const tick = process(osc('state=working:app=claude-code:progress=55')) + expect(tick.payloads).toEqual([]) + expect(tick.facet).toMatchObject({ progress: 55, observedAt: 2 }) + expect(process(osc('state=working:app=claude-code:progress=55')).facet).toBeUndefined() + expect(process(osc('state=clear')).facet).toBeNull() + }) + + it('stays silent on children alone, then lists them once the root reports', () => { + const { feed } = projector() + expect(feed(osc('state=working:id=job'))).toEqual([]) + expect(feed(osc('state=working:app=pi'))).toMatchObject([ + { state: 'working', subagents: [{ id: 'job', state: 'working', agentType: 'pi' }] } + ]) + }) + + it("keeps a failed turn's verdict when the program settles idle", () => { + const { feed } = projector() + feed(osc('state=working:app=pi')) + expect(feed(osc('state=error:app=pi'))).toMatchObject([{ mainAgent: { outcome: 'failure' } }]) + expect(feed(osc('state=idle:app=pi'))).toEqual([]) + }) + + it("reports a child's app change as news", () => { + const { feed } = projector() + feed(osc('state=working:app=pi') + osc('state=working:id=job')) + expect(feed(osc('state=working:id=job:app=codex'))).toMatchObject([ + { subagents: [{ id: 'job', agentType: 'codex' }] } + ]) + }) + + it('drops a finished row when the program clears it or the terminal resets', () => { + const { feed } = projector() + feed(osc('state=done:app=brew')) + expect(feed(osc('state=clear'))).toMatchObject([{ state: 'done', sessionBoundary: true }]) + feed(osc('state=error:app=brew')) + expect(feed('\x1bc')).toMatchObject([{ state: 'done', sessionBoundary: true }]) + }) + + it('reports an identical result from the next invocation as news', () => { + const { feed, tick } = projector() + expect(feed(osc('state=done:app=brew'))).toHaveLength(1) + feed('\x1b]133;A\x07') + tick(10) + expect(feed(osc('state=done:app=brew'))).toMatchObject([ + { state: 'done', mainAgent: { stateStartedAt: 1010 } } + ]) + }) + + it("starts the next invocation's idle as a boundary, not the kept result", () => { + const { feed } = projector() + feed(osc('state=done:app=brew')) + feed('\x1b]133;A\x07') + expect(feed(osc('state=idle:app=codex'))).toMatchObject([ + { state: 'done', sessionBoundary: true, agentType: 'codex' } + ]) + }) + + it('ends the live root at the prompt while keeping the finished record for display', () => { + const instance = createProgramStatusProjector(() => 0) + instance.process(osc('state=done:app=terraform')) + const atPrompt = instance.process('\x1b]133;A\x07') + expect(atPrompt.rootChanges).toEqual([{ offset: 8, root: undefined }]) + expect(atPrompt.facet).toBeUndefined() + expect(instance.process('\x1b]133;A\x07').rootChanges).toEqual([]) + }) + + it('never resolves an app name to an Object.prototype member', () => { + const { feed } = projector() + expect(feed(osc('state=working:app=constructor'))).toMatchObject([{ agentType: 'constructor' }]) + expect(feed(osc('state=working:app=toString'))).toMatchObject([{ agentType: 'toString' }]) + }) + + it('keeps long child ids distinct within the snapshot cap, leaf last', () => { + const { feed } = projector() + const path = (head: string) => [head, 'b'.repeat(30), 'c'.repeat(30)].join('/') + feed(osc('state=working:app=pi')) + const [payload] = feed( + `${osc(`state=working:id=${path('a')}`)}${osc(`state=working:id=${path('z')}`)}` + ).slice(-1) + const ids = payload.subagents?.map((child) => child.id) ?? [] + expect(new Set(ids).size).toBe(2) + expect(ids.every((id) => id.length <= 64 && id.endsWith('c'.repeat(30)))).toBe(true) + }) + + it('marks a projection whose program left no records as removing its row', () => { + const instance = createProgramStatusProjector(() => 0) + instance.process(osc('state=done:app=brew')) + expect(instance.process(osc('state=clear')).payloads).toMatchObject([{ removesRow: true }]) + instance.process(osc('state=idle:app=codex')) + expect(instance.process(osc('state=working:app=codex')).payloads[0]?.removesRow).toBeUndefined() + }) + + it('keeps a result unchanged when the prompt drops a lingering child', () => { + const { feed, tick } = projector() + feed(osc('state=working:app=pi') + osc('state=working:id=child')) + feed(osc('state=done:app=pi')) + tick(5) + // The child drop at the prompt is not a new completion of the kept result. + expect(feed('\x1b]133;A\x07')).toMatchObject([ + { state: 'done', mainAgent: { stateStartedAt: 1000 } } + ]) + tick(5) + expect(feed(osc('state=done:app=pi'))).toMatchObject([ + { state: 'done', mainAgent: { stateStartedAt: 1010 } } + ]) + }) + + it('removes the row when an idle program clears or the terminal resets', () => { + for (const ending of [osc('state=clear'), '\x1bc']) { + const instance = createProgramStatusProjector(() => 0) + instance.process(osc('state=idle:app=brew')) + expect(instance.process(ending).payloads).toMatchObject([{ removesRow: true }]) + expect(instance.process(ending).payloads).toEqual([]) + } + }) +}) diff --git a/src/shared/program-status/agent-status-projector.ts b/src/shared/program-status/agent-status-projector.ts new file mode 100644 index 00000000000..b01fab8853c --- /dev/null +++ b/src/shared/program-status/agent-status-projector.ts @@ -0,0 +1,276 @@ +// Projects OSC 7501 records onto agent-status payloads so they ride the OSC 9999 terminal-status ingress. +import type { AgentStatusState, ParsedAgentStatusPayload } from '../agent-status-types' +import type { AgentSubagentSnapshot, AgentSubagentState } from '../agent-status-subagent-snapshot' +import { + AGENT_STATUS_MAX_SUBAGENTS, + AGENT_SUBAGENT_ID_MAX_LENGTH +} from '../agent-status-subagent-snapshot' +import type { AgentTurnOutcome } from '../agent-turn-outcome' +import { createProgramStatusOscScanner } from './osc-scanner' +import { + programStatusDisplayText, + programStatusFacetFromRecord, + programStatusFacetsEqual, + type ProgramStatusFacet +} from './facet' +import { ProgramStatusRecordSet, type ProgramStatusRecord } from './records' + +/** OSC 7501 `app` ids whose Orca agent id differs. Other apps pass through verbatim. */ +const APP_TO_AGENT_TYPE: Readonly> = { + 'claude-code': 'claude', + 'grok-build': 'grok' +} + +function agentTypeForProgramStatusApp(app: string | undefined): string | undefined { + if (app === undefined) { + return undefined + } + // Why hasOwn: `app=constructor` must not resolve to an Object.prototype member. + return Object.hasOwn(APP_TO_AGENT_TYPE, app) ? APP_TO_AGENT_TYPE[app] : app +} + +/** A child id within the snapshot cap; long ids keep their leaf and a hash of the whole path. */ +function boundedChildId(id: string): string { + if (id.length <= AGENT_SUBAGENT_ID_MAX_LENGTH) { + return id + } + let hash = 0x811c9dc5 + for (let index = 0; index < id.length; index += 1) { + hash = Math.imul(hash ^ id.charCodeAt(index), 0x01000193) >>> 0 + } + const prefix = `${hash.toString(16).padStart(8, '0')}~` + return prefix + id.slice(-(AGENT_SUBAGENT_ID_MAX_LENGTH - prefix.length)) +} + +function blockedState(record: ProgramStatusRecord): 'waiting' | 'blocked' { + // Why: Orca's hooks report approvals and questions as `waiting`; auth and unnamed blocks stay `blocked`. + return record.kind === 'permission' || record.kind === 'question' ? 'waiting' : 'blocked' +} + +function subagentState(record: ProgramStatusRecord): AgentSubagentState | null { + switch (record.state) { + case 'working': + return 'working' + case 'blocked': + return blockedState(record) + case 'idle': + return 'idle' + case 'done': + case 'error': + // A finished child is not a live child row. + return null + } +} + +type RootProjection = { + state: AgentStatusState + outcome?: AgentTurnOutcome + sessionBoundary?: true +} + +/** The root as a live program: undefined once the program ended (prompt start, reset), even while + * its finished record is kept for display. */ +type ProgramStatusRootChange = { offset: number; root: ProgramStatusRecord | undefined } + +/** `removesRow`: no records remain (clear, reset, ended mid-work), so the program's row goes too. */ +export type ProgramStatusPayload = { + payload: ParsedAgentStatusPayload + offset: number + removesRow?: true +} + +type ProgramStatusProjector = { + /** Feed one chunk; returns, in byte order with chunk offsets, the lifecycle payloads it produced and + * every root record change (including ones the store would not see as news), plus the chunk's net + * facet change: the new facet, `null` once the root record is gone, absent when unchanged. */ + process: (data: string) => { + payloads: ProgramStatusPayload[] + rootChanges: ProgramStatusRootChange[] + facet?: ProgramStatusFacet | null + } +} + +export function createProgramStatusProjector(now: () => number = Date.now): ProgramStatusProjector { + const scan = createProgramStatusOscScanner() + const records = new ProgramStatusRecordSet() + let lastRoot: RootProjection | null = null + let lastKey: string | null = null + let rootStateStartedAt = 0 + let lastFacet: ProgramStatusFacet | undefined + // Set when the program ends; the next projection starts a new invocation. + let invocationEnded = false + let reportedSinceEnd = false + const childStartedAt = new Map() + + const projectRoot = ( + root: ProgramStatusRecord | undefined, + newInvocation: boolean + ): RootProjection | null => { + if (!root) { + // Why a boundary, not a verdict: a dropped record proves the program ended, not how. + return lastRoot !== null && lastRoot.sessionBoundary !== true + ? { state: 'done', sessionBoundary: true } + : null + } + switch (root.state) { + case 'working': + return { state: 'working' } + case 'blocked': + return { state: blockedState(root) } + case 'done': + return { state: 'done', outcome: 'success' } + case 'error': + return { state: 'done', outcome: 'failure' } + case 'idle': + // Why no cancellation verdict: idle also follows startup work (Codex reports a working blip + // while it boots), so it only proves the program is at rest. Orca infers a user's cancel + // from the interrupt keystroke itself. Idle after a finished turn keeps the unseen `done`. + return !newInvocation && lastRoot?.state === 'done' && lastRoot.sessionBoundary !== true + ? lastRoot + : { state: 'done', sessionBoundary: true } + } + } + + const snapshotChildren = (): AgentSubagentSnapshot[] => { + const at = now() + const live = new Set() + const children: AgentSubagentSnapshot[] = [] + for (const record of records.descendants()) { + const state = subagentState(record) + if (record.id === null || state === null) { + continue + } + const id = boundedChildId(record.id) + live.add(id) + const startedAt = childStartedAt.get(id) ?? at + childStartedAt.set(id, startedAt) + const agentType = agentTypeForProgramStatusApp(record.app) + const description = + programStatusDisplayText(record.title) ?? programStatusDisplayText(record.msg) + children.push({ + id, + state, + startedAt, + ...(agentType ? { agentType } : {}), + ...(description ? { description } : {}) + }) + } + for (const id of childStartedAt.keys()) { + if (!live.has(id)) { + childStartedAt.delete(id) + } + } + return children.slice(-AGENT_STATUS_MAX_SUBAGENTS) + } + + const project = (): ParsedAgentStatusPayload | null => { + const root = records.root() + const newInvocation = invocationEnded + invocationEnded = false + const projection = projectRoot(root, newInvocation) + if (projection === null) { + lastRoot = null + lastKey = null + return null + } + // Why on a new invocation too: an identical result from the next run is still news. + if ( + newInvocation || + projection.state !== lastRoot?.state || + projection.outcome !== lastRoot?.outcome + ) { + rootStateStartedAt = now() + lastKey = null + } + lastRoot = projection + const agentType = agentTypeForProgramStatusApp(root?.app) + const msg = programStatusDisplayText(root?.msg) + const subagents = snapshotChildren() + const payload: ParsedAgentStatusPayload = { + state: projection.state, + prompt: '', + ...(agentType ? { agentType } : {}), + // Live work names what it is doing or waiting on; a finished turn names its result. + ...(msg && projection.state !== 'done' ? { toolInput: msg } : {}), + ...(msg && projection.state === 'done' && projection.sessionBoundary !== true + ? { lastAssistantMessage: msg } + : {}), + ...(projection.sessionBoundary ? { sessionBoundary: true } : {}), + ...(subagents.length > 0 ? { subagents } : {}), + mainAgent: { + state: projection.state, + ...(projection.outcome ? { outcome: projection.outcome } : {}), + stateStartedAt: rootStateStartedAt + } + } + // Why a key: redraws re-send identical reports, and only changes are news to the store. + const key = JSON.stringify(payload) + if (key === lastKey) { + return null + } + lastKey = key + return payload + } + + return { + process: (data) => { + const events = scan(data) + const payloads: ProgramStatusPayload[] = [] + const rootChanges: ProgramStatusRootChange[] = [] + const facetBefore = lastFacet + for (const event of events) { + let changed: boolean + switch (event.type) { + case 'query': + // Answered by the terminal emulator that owns replies for this chunk. + continue + case 'report': + changed = records.apply(event.report) + reportedSinceEnd = true + break + case 'prompt-start': + changed = records.endProgram() + break + case 'full-reset': + changed = records.reset() + break + } + const ended = event.type !== 'report' + const endsInvocation = ended && reportedSinceEnd + if (endsInvocation) { + // The kept done/error record is for display; the program itself no longer speaks. + reportedSinceEnd = false + rootChanges.push({ offset: event.offset, root: undefined }) + } + if (changed) { + const root = records.root() + if (!ended) { + rootChanges.push({ offset: event.offset, root }) + } + const facet = root ? programStatusFacetFromRecord(root, now()) : undefined + // Keep the earlier facet object (and its observedAt) when the report repeats it. + lastFacet = programStatusFacetsEqual(lastFacet, facet) ? lastFacet : facet + const payload = project() + // A removal is news even when the last projection was already an at-rest boundary. + if (root === undefined && records.descendants().length === 0) { + payloads.push({ + payload: payload ?? { state: 'done', prompt: '', sessionBoundary: true }, + offset: event.offset, + removesRow: true + }) + } else if (payload) { + payloads.push({ payload, offset: event.offset }) + } + } + // After the ending event's own projection, so only the next report opens a new invocation. + if (endsInvocation) { + invocationEnded = true + } + } + if (lastFacet === facetBefore) { + return { payloads, rootChanges } + } + return { payloads, rootChanges, facet: lastFacet ?? null } + } + } +} diff --git a/src/shared/program-status/facet.ts b/src/shared/program-status/facet.ts new file mode 100644 index 00000000000..525b093d963 --- /dev/null +++ b/src/shared/program-status/facet.ts @@ -0,0 +1,109 @@ +// The program's own OSC 7501 report, carried beside a status row for what hooks cannot say +// (progress, its own message, sign-in blocks). Never decides row state; live-only, never persisted. +import { + isProgramStatusKind, + isProgramStatusState, + programStatusStateCarriesProgress, + type ProgramStatusKind, + type ProgramStatusState +} from './protocol' +import type { ProgramStatusRecord } from './records' + +export type ProgramStatusFacet = { + state: ProgramStatusState + /** Only on `blocked`: what the program waits for. */ + kind?: ProgramStatusKind + /** 0–100, only on `working`/`blocked`. Absent means indeterminate. */ + progress?: number + /** Display-safe single line (invisible formatting stripped, bounded). */ + message?: string + /** When the report that produced this facet was read (ms). */ + observedAt: number +} + +const PROGRAM_STATUS_MESSAGE_MAX_LENGTH = 400 + +// Why: messages are shown outside the terminal grid, where bidi overrides and invisible formatting +// could disguise what a program claims (spec: Security). +const INVISIBLE_FORMATTING_RE = /[​-‏‪-‮⁠-⁩]/g + +/** A program-supplied string made safe and bounded for display, or undefined when empty. */ +export function programStatusDisplayText(text: string | undefined): string | undefined { + const cleaned = text?.replace(INVISIBLE_FORMATTING_RE, '').trim() + if (!cleaned) { + return undefined + } + return cleaned.length > PROGRAM_STATUS_MESSAGE_MAX_LENGTH + ? `${cleaned.slice(0, PROGRAM_STATUS_MESSAGE_MAX_LENGTH - 1)}…` + : cleaned +} + +export function programStatusFacetFromRecord( + record: ProgramStatusRecord, + observedAt: number +): ProgramStatusFacet { + const message = programStatusDisplayText(record.msg) + return { + state: record.state, + ...(record.kind !== undefined ? { kind: record.kind } : {}), + ...(record.progress !== undefined ? { progress: record.progress } : {}), + ...(message !== undefined ? { message } : {}), + observedAt + } +} + +/** The fields that make a facet news; `observedAt` is delivery metadata. */ +export function programStatusFacetContent( + facet: ProgramStatusFacet +): readonly [ProgramStatusState, ProgramStatusKind?, number?, string?] { + return [facet.state, facet.kind, facet.progress, facet.message] +} + +export function programStatusFacetsEqual( + a: ProgramStatusFacet | undefined, + b: ProgramStatusFacet | undefined +): boolean { + if (a === b) { + return true + } + if (!a || !b) { + return false + } + const left = programStatusFacetContent(a) + const right = programStatusFacetContent(b) + return left.every((value, index) => value === right[index]) +} + +/** Admit a facet from an untrusted wire or IPC value; anything malformed is dropped whole. */ +export function normalizeProgramStatusFacet(value: unknown): ProgramStatusFacet | undefined { + if (typeof value !== 'object' || value === null) { + return undefined + } + const obj = Object.fromEntries(Object.entries(value)) + const state = obj.state + if ( + !isProgramStatusState(state) || + typeof obj.observedAt !== 'number' || + !Number.isFinite(obj.observedAt) + ) { + return undefined + } + const kind = state === 'blocked' && isProgramStatusKind(obj.kind) ? obj.kind : undefined + const progress = + programStatusStateCarriesProgress(state) && + typeof obj.progress === 'number' && + Number.isInteger(obj.progress) && + obj.progress >= 0 && + obj.progress <= 100 + ? obj.progress + : undefined + const message = + typeof obj.message === 'string' ? programStatusDisplayText(obj.message) : undefined + return { + state, + ...(kind !== undefined ? { kind } : {}), + ...(progress !== undefined ? { progress } : {}), + ...(message !== undefined ? { message } : {}), + observedAt: obj.observedAt + } +} diff --git a/src/shared/program-status/osc-scanner.test.ts b/src/shared/program-status/osc-scanner.test.ts new file mode 100644 index 00000000000..f5858e37d58 --- /dev/null +++ b/src/shared/program-status/osc-scanner.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest' +import { createProgramStatusOscScanner } from './osc-scanner' + +describe('createProgramStatusOscScanner', () => { + it('stays on the fast path for ordinary output', () => { + const scan = createProgramStatusOscScanner() + expect(scan('\x1b[31mred\x1b[0m plain\r\n')).toEqual([]) + }) + + it('reads BEL- and ST-terminated reports with their end offsets', () => { + const scan = createProgramStatusOscScanner() + const data = 'a\x1b]7501;state=working\x07b\x1b]7501;state=done\x1b\\c' + expect(scan(data)).toEqual([ + { type: 'report', report: { id: null, state: 'working' }, offset: data.indexOf('b') }, + { type: 'report', report: { id: null, state: 'done' }, offset: data.indexOf('c') } + ]) + }) + + it('reassembles a report and its introducer split across chunks', () => { + const scan = createProgramStatusOscScanner() + expect(scan('out\x1b]75')).toEqual([]) + expect(scan('01;state=blo')).toEqual([]) + expect(scan('cked:kind=question\x1b')).toEqual([]) + expect(scan('\\tail')).toEqual([ + { type: 'report', report: { id: null, state: 'blocked', kind: 'question' }, offset: 1 } + ]) + }) + + it('reports queries, shell prompt starts and full resets in byte order', () => { + const scan = createProgramStatusOscScanner() + expect(scan('\x1b]7501;?\x1b\\\x1b[c\x1b]133;A\x07\x1bc').map((event) => event.type)).toEqual([ + 'query', + 'prompt-start', + 'full-reset' + ]) + }) + + it('carries a split prompt marker', () => { + const scan = createProgramStatusOscScanner() + expect(scan('$ \x1b]13')).toEqual([]) + expect(scan('3;A\x07')).toEqual([{ type: 'prompt-start', offset: 4 }]) + }) + + it('reads a prompt start only once its whole frame arrives', () => { + const scan = createProgramStatusOscScanner() + expect(scan('$ \x1b]133;A')).toEqual([]) + expect(scan(';cl=m\x1b')).toEqual([]) + expect(scan('\\')).toEqual([{ type: 'prompt-start', offset: 1 }]) + expect(scan('\x1b]133;AB\x07\x1b]133;B\x07')).toEqual([]) + }) + + it('drops frames CAN/SUB cancel, and ends one at the next sequence as xterm does', () => { + const scan = createProgramStatusOscScanner() + expect(scan('\x1b]133;A;x\x18y\x07\x1b]7501;state=working\x1ax\x07')).toEqual([]) + expect(scan('\x1b]133;A\x1b]7501;state=working\x1b[0m')).toMatchObject([ + { type: 'prompt-start' }, + { type: 'report', report: { state: 'working' } } + ]) + }) + + it('discards a frame past the sequence cap and keeps scanning', () => { + const scan = createProgramStatusOscScanner() + const oversized = `\x1b]7501;state=working:junk=${'x'.repeat(5000)}\x07` + expect(scan(`${oversized}\x1b]7501;state=idle\x07`)).toMatchObject([ + { type: 'report', report: { state: 'idle' } } + ]) + }) + + it('drops an unterminated frame once it outgrows the cap', () => { + const scan = createProgramStatusOscScanner() + expect(scan(`\x1b]7501;state=working:junk=${'x'.repeat(5000)}`)).toEqual([]) + expect(scan('\x07\x1b]7501;state=done\x07')).toMatchObject([{ report: { state: 'done' } }]) + }) +}) diff --git a/src/shared/program-status/osc-scanner.ts b/src/shared/program-status/osc-scanner.ts new file mode 100644 index 00000000000..d2bab413a93 --- /dev/null +++ b/src/shared/program-status/osc-scanner.ts @@ -0,0 +1,147 @@ +import { + PROGRAM_STATUS_MAX_SEQUENCE_BYTES, + PROGRAM_STATUS_OSC_PREFIX, + parseProgramStatusBody, + type ProgramStatusMessage +} from './protocol' +import { ownRetainedString } from '../own-retained-string' + +/** Everything in a PTY stream that touches OSC 7501 records, in byte order. `offset` is the index + * in the chunk just past the sequence (0 when it ended inside carried bytes). */ +type ProgramStatusStreamEvent = + | (ProgramStatusMessage & { offset: number }) + | { type: 'prompt-start'; offset: number } + | { type: 'full-reset'; offset: number } + +// Why these two: the spec ties record lifetime to a new shell prompt (OSC 133 A) and a full reset (RIS). +const PROMPT_START_MARKER = '\x1b]133;A' +const FULL_RESET_MARKER = '\x1bc' +const MARKERS = [PROGRAM_STATUS_OSC_PREFIX, PROMPT_START_MARKER, FULL_RESET_MARKER] as const +const MAX_BODY_LENGTH = PROGRAM_STATUS_MAX_SEQUENCE_BYTES - PROGRAM_STATUS_OSC_PREFIX.length - 1 +/** Bounds the `;key=value` options a prompt-start mark may carry before its terminator. */ +const MAX_PROMPT_START_OPTIONS_LENGTH = 512 + +/** Longest suffix of `data` that could still grow into a marker. */ +function markerPrefixCarry(data: string): string { + const lastEsc = data.lastIndexOf('\x1b') + if (lastEsc === -1 || data.length - lastEsc >= PROGRAM_STATUS_OSC_PREFIX.length) { + return '' + } + const suffix = data.slice(lastEsc) + return MARKERS.some((marker) => marker.length > suffix.length && marker.startsWith(suffix)) + ? suffix + : '' +} + +/** BEL, ST or (as xterm does) an unconsumed non-ST ESC; `aborted` by CAN/SUB; null while completable. */ +type OscFrameEnd = { index: number; length: number; aborted?: true } + +/** Scans [from, limit); the bound keeps an unterminated frame's cost fixed. */ +function findOscTerminator(data: string, from: number, limit: number): OscFrameEnd | null { + const end = Math.min(limit, data.length) + for (let index = from; index < end; index++) { + const code = data.charCodeAt(index) + if (code === 0x07) { + return { index, length: 1 } + } + if (code === 0x18 || code === 0x1a) { + return { index, length: 1, aborted: true } + } + if (code === 0x1b && index + 1 < data.length) { + return { index, length: data.charCodeAt(index + 1) === 0x5c ? 2 : 0 } + } + } + return null +} + +/** Stateful OSC 7501 scanner for one PTY stream. Does not strip anything from the stream. */ +export function createProgramStatusOscScanner(): (data: string) => ProgramStatusStreamEvent[] { + let pending = '' + + return (data) => { + if (pending.length === 0 && !data.includes('\x1b')) { + return [] + } + if ( + pending.length === 0 && + !data.includes(PROGRAM_STATUS_OSC_PREFIX) && + !data.includes(PROMPT_START_MARKER) && + !data.includes(FULL_RESET_MARKER) + ) { + pending = markerPrefixCarry(data) + return [] + } + const carried = pending.length + const combined = pending + data + pending = '' + const events: ProgramStatusStreamEvent[] = [] + const offsetOf = (end: number): number => Math.max(0, end - carried) + let cursor = 0 + while (cursor < combined.length) { + const esc = combined.indexOf('\x1b', cursor) + if (esc === -1) { + break + } + if (combined.startsWith(FULL_RESET_MARKER, esc)) { + cursor = esc + FULL_RESET_MARKER.length + events.push({ type: 'full-reset', offset: offsetOf(cursor) }) + continue + } + if (combined.startsWith(PROMPT_START_MARKER, esc)) { + const afterMark = esc + PROMPT_START_MARKER.length + const next = combined.charCodeAt(afterMark) + // Why the whole frame: `133;AB` is another mark, and a split frame must not end the program early. + const isPromptStart = next === 0x07 || next === 0x1b || next === 0x3b + const terminator = isPromptStart + ? findOscTerminator(combined, afterMark, afterMark + MAX_PROMPT_START_OPTIONS_LENGTH) + : null + if (terminator !== null) { + cursor = terminator.index + terminator.length + if (!terminator.aborted) { + events.push({ type: 'prompt-start', offset: offsetOf(cursor) }) + } + continue + } + if ( + (Number.isNaN(next) || isPromptStart) && + combined.length - afterMark < MAX_PROMPT_START_OPTIONS_LENGTH + ) { + pending = ownRetainedString(combined.slice(esc)) + break + } + cursor = afterMark + continue + } + if (!combined.startsWith(PROGRAM_STATUS_OSC_PREFIX, esc)) { + if (combined.length - esc < PROGRAM_STATUS_OSC_PREFIX.length) { + pending = markerPrefixCarry(combined.slice(esc)) + if (pending.length > 0) { + break + } + } + cursor = esc + 1 + continue + } + const bodyStart = esc + PROGRAM_STATUS_OSC_PREFIX.length + const terminator = findOscTerminator(combined, bodyStart, bodyStart + MAX_BODY_LENGTH + 1) + if (terminator === null) { + if (combined.length - bodyStart <= MAX_BODY_LENGTH) { + // Own the partial frame so it stops pinning the chunk it was sliced from. + pending = ownRetainedString(combined.slice(esc)) + break + } + // Over the sequence cap: discard whole and resume after the introducer. + cursor = bodyStart + continue + } + cursor = terminator.index + terminator.length + const message = terminator.aborted + ? null + : parseProgramStatusBody(combined.slice(bodyStart, terminator.index)) + if (message) { + events.push({ ...message, offset: offsetOf(cursor) }) + } + } + return events + } +} diff --git a/src/shared/program-status/presentation.test.ts b/src/shared/program-status/presentation.test.ts new file mode 100644 index 00000000000..3407e054e40 --- /dev/null +++ b/src/shared/program-status/presentation.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' +import { resolveProgramStatusPresentation } from './presentation' +import type { ProgramStatusFacet } from './facet' + +const facet = (overrides: Partial): ProgramStatusFacet => ({ + state: 'working', + observedAt: 1, + ...overrides +}) + +describe('resolveProgramStatusPresentation', () => { + it('shows nothing without a facet', () => { + expect(resolveProgramStatusPresentation({ state: 'working' })).toEqual({}) + }) + + it('shows progress and the program line on a live row', () => { + expect( + resolveProgramStatusPresentation({ + state: 'working', + programStatus: facet({ progress: 40, message: 'Pushing image' }) + }) + ).toEqual({ progress: 40, message: 'Pushing image' }) + }) + + it("lets a hook's structured tool preview win over the program's free text", () => { + expect( + resolveProgramStatusPresentation({ + state: 'working', + toolName: 'Edit', + programStatus: facet({ progress: 40, message: 'Editing files' }) + }) + ).toEqual({ progress: 40 }) + }) + + it('never paints a finished row with a stale live facet', () => { + expect( + resolveProgramStatusPresentation({ + state: 'done', + programStatus: facet({ progress: 90, message: 'Almost there' }) + }) + ).toEqual({}) + }) + + it('raises attention for a block the row state does not show (sign-in after a hook turn)', () => { + expect( + resolveProgramStatusPresentation({ + state: 'done', + programStatus: facet({ state: 'blocked', kind: 'auth', message: 'Session expired' }) + }) + ).toEqual({ attention: { kind: 'auth', message: 'Session expired' } }) + expect( + resolveProgramStatusPresentation({ + state: 'waiting', + programStatus: facet({ state: 'blocked', kind: 'permission' }) + }).attention + ).toBeUndefined() + }) +}) diff --git a/src/shared/program-status/presentation.ts b/src/shared/program-status/presentation.ts new file mode 100644 index 00000000000..b66c2d05712 --- /dev/null +++ b/src/shared/program-status/presentation.ts @@ -0,0 +1,73 @@ +// The one place deciding what a row shows from its OSC 7501 facet: the row owns `state`, the facet +// adds what the row's producer cannot say. Shared by desktop, dashboard and mobile. +import type { AgentStatusEntry } from '../agent-status-types' +import type { ProgramStatusFacet } from './facet' +import { programStatusStateCarriesProgress, type ProgramStatusKind } from './protocol' + +type ProgramStatusAttention = { + /** What the program waits for; `undefined` when it did not say. */ + kind?: ProgramStatusKind + message?: string +} + +export type ProgramStatusPresentation = { + /** Determinate progress (0–100) for a live row. Absent means show no percentage. */ + progress?: number + /** The program's own line for a live row whose producer has no structured tool preview. */ + message?: string + /** A block the program reports that the row's own state does not show (e.g. sign-in after a + * hook-owned turn ended). Present only while the row reads as not needing the user. */ + attention?: ProgramStatusAttention +} + +const EMPTY: ProgramStatusPresentation = Object.freeze({}) + +type PresentableEntry = Pick + +function rowIsLive(state: AgentStatusEntry['state']): boolean { + return state === 'working' || state === 'waiting' || state === 'blocked' +} + +function facetIsLive(facet: ProgramStatusFacet): boolean { + return programStatusStateCarriesProgress(facet.state) +} + +export function resolveProgramStatusPresentation( + entry: PresentableEntry | null | undefined +): ProgramStatusPresentation { + const facet = entry?.programStatus + if (!entry || !facet) { + return EMPTY + } + const presentation: ProgramStatusPresentation = {} + // Why both live: a stale facet from the program's last run must not paint a finished row. + const bothLive = facetIsLive(facet) && rowIsLive(entry.state) + if (bothLive && facet.progress !== undefined) { + presentation.progress = facet.progress + } + // Structured hook previews ("Edit: src/app.ts") beat the program's free text. + if (bothLive && facet.message && !entry.toolName?.trim()) { + presentation.message = facet.message + } + if (facet.state === 'blocked' && entry.state !== 'waiting' && entry.state !== 'blocked') { + presentation.attention = { + ...(facet.kind ? { kind: facet.kind } : {}), + ...(facet.message ? { message: facet.message } : {}) + } + } + return presentation +} + +/** Short label for an attention block, e.g. in a row's secondary line or a tooltip. */ +export function programStatusAttentionLabel(attention: ProgramStatusAttention): string { + switch (attention.kind) { + case 'auth': + return 'Sign-in required' + case 'permission': + return 'Needs approval' + case 'question': + return 'Needs an answer' + case undefined: + return 'Needs you' + } +} diff --git a/src/shared/program-status/protocol.test.ts b/src/shared/program-status/protocol.test.ts new file mode 100644 index 00000000000..f9491414672 --- /dev/null +++ b/src/shared/program-status/protocol.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest' +import { PROGRAM_STATUS_QUERY_REPLY, parseProgramStatusBody } from './protocol' + +const b64 = (text: string): string => Buffer.from(text, 'utf8').toString('base64') + +describe('parseProgramStatusBody', () => { + it('recognizes the feature-detection query', () => { + expect(parseProgramStatusBody('?')).toEqual({ type: 'query' }) + expect(PROGRAM_STATUS_QUERY_REPLY).toBe( + '\x1b]7501;?:states=idle,working,done,blocked,error:kinds=permission,question,auth\x1b\\' + ) + }) + + it('parses the spec example with a base64 message', () => { + const msg = 'Apply 3 to add, 1 to change, 0 to destroy?' + expect( + parseProgramStatusBody(`state=blocked:kind=permission:app=terraform:msg=${b64(msg)}`) + ).toEqual({ + type: 'report', + report: { id: null, state: 'blocked', kind: 'permission', app: 'terraform', msg } + }) + }) + + it('decodes unpadded base64 and UTF-8 text', () => { + const encoded = b64('déjà vu ✓').replace(/=+$/, '') + expect(parseProgramStatusBody(`state=done:msg=${encoded}`)).toMatchObject({ + report: { msg: 'déjà vu ✓' } + }) + }) + + it('ignores reports without a recognized state or with a malformed id', () => { + expect(parseProgramStatusBody('app=x')).toBeNull() + expect(parseProgramStatusBody('state=paused')).toBeNull() + expect(parseProgramStatusBody('state=working:id=a//b')).toBeNull() + expect(parseProgramStatusBody(`state=working:id=${'a/'.repeat(8)}a`)).toBeNull() + }) + + it('discards a report with an over-long key or any over-limit text occurrence', () => { + expect(parseProgramStatusBody(`state=working:${'k'.repeat(17)}=v`)).toBeNull() + const oversized = b64('x'.repeat(2049)) + expect(parseProgramStatusBody(`state=done:msg=${oversized}:msg=${b64('ok')}`)).toBeNull() + expect(parseProgramStatusBody(`state=done:title=${b64('t'.repeat(193))}:title=e`)).toBeNull() + expect(parseProgramStatusBody(`state=clear:app=${'a'.repeat(33)}`)).toBeNull() + expect(parseProgramStatusBody(`state=clear:id=${'a'.repeat(129)}:id=ok`)).toBeNull() + }) + + it('skips malformed pairs, trims whitespace, and lets the last repeat win', () => { + expect(parseProgramStatusBody(' state = idle :junk:K=v:app=a:app=b:x=has space')).toEqual({ + type: 'report', + report: { id: null, state: 'idle', app: 'b' } + }) + }) + + it('keeps kind only on blocked and progress only on working/blocked within 0-100', () => { + expect(parseProgramStatusBody('state=working:kind=permission:progress=40')).toMatchObject({ + report: { state: 'working', progress: 40 } + }) + expect(parseProgramStatusBody('state=working:progress=101')).toEqual({ + type: 'report', + report: { id: null, state: 'working' } + }) + expect(parseProgramStatusBody('state=done:progress=50')).toEqual({ + type: 'report', + report: { id: null, state: 'done' } + }) + expect(parseProgramStatusBody('state=blocked:kind=unknown')).toEqual({ + type: 'report', + report: { id: null, state: 'blocked' } + }) + }) + + it('treats an out-of-charset app as absent', () => { + expect(parseProgramStatusBody('state=idle:app=a,b')).toEqual({ + type: 'report', + report: { id: null, state: 'idle' } + }) + }) + + it('discards the whole report when text has a control character or bad base64', () => { + expect(parseProgramStatusBody(`state=done:msg=${b64('two\nlines')}`)).toBeNull() + expect(parseProgramStatusBody(`state=done:title=${b64('\u0085')}`)).toBeNull() + expect(parseProgramStatusBody('state=done:msg=a-b_')).toBeNull() + expect(parseProgramStatusBody('state=done:msg=abcde')).toBeNull() + // Out-of-charset bytes make a malformed pair, which is skipped rather than fatal. + expect(parseProgramStatusBody('state=done:msg=@@@@')).toEqual({ + type: 'report', + report: { id: null, state: 'done' } + }) + expect( + parseProgramStatusBody(`state=done:msg=${Buffer.from([0xff, 0xfe]).toString('base64')}`) + ).toBeNull() + }) + + it('enforces the encoded and decoded text limits', () => { + expect(parseProgramStatusBody(`state=done:title=${b64('t'.repeat(192))}`)).not.toBeNull() + expect(parseProgramStatusBody(`state=done:title=${b64('t'.repeat(193))}`)).toBeNull() + expect(parseProgramStatusBody(`state=done:msg=${b64('m'.repeat(2048))}`)).not.toBeNull() + expect(parseProgramStatusBody(`state=done:msg=${b64('m'.repeat(2049))}`)).toBeNull() + }) + + it('accepts clear with and without an id', () => { + expect(parseProgramStatusBody('state=clear')).toEqual({ + type: 'report', + report: { id: null, state: 'clear' } + }) + expect(parseProgramStatusBody('state=clear:id=build/test')).toEqual({ + type: 'report', + report: { id: 'build/test', state: 'clear' } + }) + }) +}) diff --git a/src/shared/program-status/protocol.ts b/src/shared/program-status/protocol.ts new file mode 100644 index 00000000000..65595be5f4c --- /dev/null +++ b/src/shared/program-status/protocol.ts @@ -0,0 +1,190 @@ +// Program Status Protocol (OSC 7501) wire grammar: a program reports idle/working/blocked/done/error +// for itself (root record) or for ids beneath it. Spec: https://www.superlogical.com/rex/docs/build/program-status + +export const PROGRAM_STATUS_OSC_PREFIX = '\x1b]7501;' + +/** The host parses OSC 7501. Why negotiated: answering the probe changes what programs paint + * (Claude Code drops its title glyphs), so a client must not answer for a host that reads glyphs. */ +export const PROGRAM_STATUS_RUNTIME_CAPABILITY = 'terminal.program-status.v1' as const + +const PROGRAM_STATUS_STATES = ['idle', 'working', 'done', 'blocked', 'error'] as const +export type ProgramStatusState = (typeof PROGRAM_STATUS_STATES)[number] + +const PROGRAM_STATUS_KINDS = ['permission', 'question', 'auth'] as const +export type ProgramStatusKind = (typeof PROGRAM_STATUS_KINDS)[number] + +/** The only bytes a supporting terminal ever writes back. Explicit lists so a later-revision + * program can tell exactly which states and kinds this terminal accepts. */ +export const PROGRAM_STATUS_QUERY_REPLY = `${PROGRAM_STATUS_OSC_PREFIX}?:states=${PROGRAM_STATUS_STATES.join(',')}:kinds=${PROGRAM_STATUS_KINDS.join(',')}\x1b\\` + +/** Whole sequence cap, OSC through ST. */ +export const PROGRAM_STATUS_MAX_SEQUENCE_BYTES = 4096 +const MAX_KEY_BYTES = 16 +const MAX_MSG_ENCODED = 2732 +const MAX_MSG_DECODED = 2048 +const MAX_TITLE_ENCODED = 256 +const MAX_TITLE_DECODED = 192 +const MAX_ID_LENGTH = 128 +const MAX_ID_DEPTH = 8 +const MAX_APP_BYTES = 32 + +export type ProgramStatusReport = { + /** Null addresses the root record. */ + id: string | null + state: ProgramStatusState | 'clear' + kind?: ProgramStatusKind + progress?: number + app?: string + title?: string + msg?: string +} + +export type ProgramStatusMessage = + | { type: 'query' } + | { type: 'report'; report: ProgramStatusReport } + +const KEY_RE = /^[a-z]+$/ +const VALUE_RE = /^[A-Za-z0-9_.,+/=-]*$/ +const SEGMENT_RE = /^[A-Za-z0-9_.+-]{1,32}$/ +const APP_RE = /^[A-Za-z0-9_.+-]+$/ +const PROGRESS_RE = /^\d{1,3}$/ +const BASE64_RE = /^[A-Za-z0-9+/]*={0,2}$/ +// oxlint-disable-next-line no-control-regex -- the spec refuses decoded C0/DEL/C1 controls +const CONTROL_RE = /[\u0000-\u001f\u007f-\u009f]/ + +const utf8Decoder = new TextDecoder('utf-8', { fatal: true }) + +export function isProgramStatusState(value: unknown): value is ProgramStatusState { + return PROGRAM_STATUS_STATES.some((state) => state === value) +} + +export function isProgramStatusKind(value: unknown): value is ProgramStatusKind { + return PROGRAM_STATUS_KINDS.some((kind) => kind === value) +} + +/** Live states: the only ones whose reports may carry `progress`. */ +export function programStatusStateCarriesProgress(state: ProgramStatusState | 'clear'): boolean { + return state === 'working' || state === 'blocked' +} + +/** done/error outlive the program that reported them, so the user still finds the result. */ +export function programStatusStateOutlivesProgram(state: ProgramStatusState): boolean { + return state === 'done' || state === 'error' +} + +export function isProgramStatusQueryBody(body: string): boolean { + return body.trim() === '?' +} + +function isValidId(id: string): boolean { + if (id.length > MAX_ID_LENGTH) { + return false + } + const segments = id.split('/') + return segments.length <= MAX_ID_DEPTH && segments.every((segment) => SEGMENT_RE.test(segment)) +} + +/** Decoded single-line text, or null when the report must be discarded whole. */ +function decodeText(encoded: string, maxEncoded: number, maxDecoded: number): string | null { + // Why before decoding: the encoded cap bounds the work a hostile program can cause. + if (encoded.length > maxEncoded || !BASE64_RE.test(encoded)) { + return null + } + const unpadded = encoded.replace(/=+$/, '') + if (unpadded.length % 4 === 1) { + return null + } + let binary: string + try { + binary = atob(unpadded + '='.repeat((4 - (unpadded.length % 4)) % 4)) + } catch { + return null + } + if (binary.length > maxDecoded) { + return null + } + const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0)) + let text: string + try { + text = utf8Decoder.decode(bytes) + } catch { + return null + } + return CONTROL_RE.test(text) ? null : text +} + +/** Parse the body between `OSC 7501;` and ST. Null means ignore (no state, bad id, over a limit). */ +export function parseProgramStatusBody(body: string): ProgramStatusMessage | null { + // Why: the scanner already caps bodies, but this is exported and must stay bounded on its own. + if (body.length > PROGRAM_STATUS_MAX_SEQUENCE_BYTES) { + return null + } + if (isProgramStatusQueryBody(body)) { + return { type: 'query' } + } + const pairs = new Map() + const texts: Partial> = {} + for (const pair of body.split(':')) { + const equals = pair.indexOf('=') + if (equals === -1) { + continue + } + const key = pair.slice(0, equals).trim() + const value = pair.slice(equals + 1).trim() + // Why per occurrence: a hard cap broken anywhere voids the report, even if a duplicate follows. + if ( + key.length > MAX_KEY_BYTES || + (key === 'app' && value.length > MAX_APP_BYTES) || + (key === 'id' && !isValidId(value)) + ) { + return null + } + // Malformed pairs are skipped; the rest of the report still applies. + if (!KEY_RE.test(key) || !VALUE_RE.test(value)) { + continue + } + if (key === 'msg' || key === 'title') { + // Every occurrence is checked, so a later duplicate cannot launder an over-limit one. + const [maxEncoded, maxDecoded] = + key === 'msg' ? [MAX_MSG_ENCODED, MAX_MSG_DECODED] : [MAX_TITLE_ENCODED, MAX_TITLE_DECODED] + const text = decodeText(value, maxEncoded, maxDecoded) + if (text === null) { + return null + } + texts[key] = text + continue + } + pairs.set(key, value) + } + + const state = pairs.get('state') + if (state === undefined || (state !== 'clear' && !isProgramStatusState(state))) { + return null + } + // Every id occurrence was validated above: a malformed id must not overwrite the root record. + const rawId = pairs.get('id') + const report: ProgramStatusReport = { id: rawId ?? null, state } + + const kind = pairs.get('kind') + if (state === 'blocked' && kind !== undefined && isProgramStatusKind(kind)) { + report.kind = kind + } + const progress = pairs.get('progress') + if (programStatusStateCarriesProgress(state) && progress && PROGRESS_RE.test(progress)) { + const value = Number.parseInt(progress, 10) + if (value <= 100) { + report.progress = value + } + } + const app = pairs.get('app') + if (app !== undefined && APP_RE.test(app)) { + report.app = app + } + if (texts.msg) { + report.msg = texts.msg + } + if (texts.title) { + report.title = texts.title + } + return { type: 'report', report } +} diff --git a/src/shared/program-status/query-responder.test.ts b/src/shared/program-status/query-responder.test.ts new file mode 100644 index 00000000000..713d72dd3fa --- /dev/null +++ b/src/shared/program-status/query-responder.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import { Terminal } from '@xterm/headless' +import { installProgramStatusQueryResponder } from './query-responder' +import { PROGRAM_STATUS_QUERY_REPLY } from './protocol' + +function write(terminal: Terminal, data: string): Promise { + return new Promise((resolve) => terminal.write(data, resolve)) +} + +describe('installProgramStatusQueryResponder', () => { + it('answers the query ahead of the DA1 sentinel that follows it', async () => { + const terminal = new Terminal({ allowProposedApi: true }) + const replies: string[] = [] + terminal.onData((data) => replies.push(data)) + installProgramStatusQueryResponder({ + parser: terminal.parser, + emitReply: (reply) => replies.push(reply) + }) + await write(terminal, '\x1b]7501;?\x1b\\\x1b[c') + expect(replies[0]).toBe(PROGRAM_STATUS_QUERY_REPLY) + expect(replies[1].startsWith('\x1b[?')).toBe(true) + terminal.dispose() + }) + + it('swallows reports without replying and stays silent while replaying', async () => { + const terminal = new Terminal({ allowProposedApi: true }) + const replies: string[] = [] + installProgramStatusQueryResponder({ + parser: terminal.parser, + emitReply: (reply) => replies.push(reply), + shouldReply: () => false + }) + await write(terminal, '\x1b]7501;state=working\x07\x1b]7501;?\x07') + expect(replies).toEqual([]) + terminal.dispose() + }) +}) diff --git a/src/shared/program-status/query-responder.ts b/src/shared/program-status/query-responder.ts new file mode 100644 index 00000000000..0d15cb20ca6 --- /dev/null +++ b/src/shared/program-status/query-responder.ts @@ -0,0 +1,29 @@ +import { isProgramStatusQueryBody, PROGRAM_STATUS_QUERY_REPLY } from './protocol' + +type OscHandlerParser = { + registerOscHandler: ( + ident: number, + callback: (data: string) => boolean + ) => { dispose: () => void } +} + +/** + * Answers `OSC 7501 ; ?` so Program Status Protocol programs (Claude Code, pi) start reporting. + * Why one shared handler: the visible renderer xterm and main's hidden-pane emulator each own + * replies for different chunks, and both must answer before the DA1 sentinel that follows. + */ +export function installProgramStatusQueryResponder(deps: { + parser: OscHandlerParser + /** Must already be gated to the reply owner for the chunk being parsed. */ + emitReply: (reply: string) => void + /** False while replaying restored bytes, whose queries answer no one. */ + shouldReply?: () => boolean +}): { dispose: () => void } { + return deps.parser.registerOscHandler(7501, (data) => { + if (isProgramStatusQueryBody(data) && deps.shouldReply?.() !== false) { + deps.emitReply(PROGRAM_STATUS_QUERY_REPLY) + } + // Reports are read from the PTY stream upstream; nothing here draws. + return true + }) +} diff --git a/src/shared/program-status/records.test.ts b/src/shared/program-status/records.test.ts new file mode 100644 index 00000000000..23befe30785 --- /dev/null +++ b/src/shared/program-status/records.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest' +import { PROGRAM_STATUS_MAX_RECORDS, ProgramStatusRecordSet } from './records' + +describe('ProgramStatusRecordSet', () => { + it('replaces a record whole on each report', () => { + const records = new ProgramStatusRecordSet() + records.apply({ id: null, state: 'working', app: 'brew', msg: 'Installing' }) + records.apply({ id: null, state: 'done' }) + expect(records.root()).toEqual({ id: null, state: 'done' }) + }) + + it('inherits app from the nearest ancestor that has one', () => { + const records = new ProgramStatusRecordSet() + records.apply({ id: null, state: 'working', app: 'deploy' }) + records.apply({ id: 'us-east', state: 'working', title: 'US East' }) + records.apply({ id: 'us-east/push', state: 'blocked', kind: 'permission' }) + records.apply({ id: 'other', state: 'working', app: 'own' }) + expect(records.descendants().map((record) => [record.id, record.app])).toEqual([ + ['us-east', 'deploy'], + ['us-east/push', 'deploy'], + ['other', 'own'] + ]) + }) + + it('clears an id and everything beneath it, and a bare clear removes all', () => { + const records = new ProgramStatusRecordSet() + records.apply({ id: null, state: 'working' }) + records.apply({ id: 'build', state: 'working' }) + records.apply({ id: 'build/test', state: 'working' }) + records.apply({ id: 'builder', state: 'working' }) + expect(records.apply({ id: 'build', state: 'clear' })).toBe(true) + expect(records.descendants().map((record) => record.id)).toEqual(['builder']) + expect(records.root()?.state).toBe('working') + expect(records.apply({ id: null, state: 'clear' })).toBe(true) + expect(records.root()).toBeUndefined() + expect(records.descendants()).toEqual([]) + }) + + it('drops working, blocked and idle when the program ends but keeps done and error', () => { + const records = new ProgramStatusRecordSet() + records.apply({ id: null, state: 'blocked', kind: 'question' }) + records.apply({ id: 'a', state: 'done' }) + records.apply({ id: 'b', state: 'error' }) + records.apply({ id: 'c', state: 'idle' }) + expect(records.endProgram()).toBe(true) + expect(records.root()).toBeUndefined() + expect(records.descendants().map((record) => record.id)).toEqual(['a', 'b']) + expect(records.endProgram()).toBe(false) + }) + + it('evicts the least recently updated record past the cap', () => { + const records = new ProgramStatusRecordSet() + records.apply({ id: null, state: 'working' }) + for (let i = 0; i < PROGRAM_STATUS_MAX_RECORDS; i += 1) { + records.apply({ id: `r${i}`, state: 'working' }) + } + expect(records.root()).toBeUndefined() + expect(records.descendants()).toHaveLength(PROGRAM_STATUS_MAX_RECORDS) + expect(records.descendants()[0].id).toBe('r0') + }) +}) diff --git a/src/shared/program-status/records.ts b/src/shared/program-status/records.ts new file mode 100644 index 00000000000..6ed693af0ef --- /dev/null +++ b/src/shared/program-status/records.ts @@ -0,0 +1,131 @@ +import { + programStatusStateOutlivesProgram, + type ProgramStatusKind, + type ProgramStatusReport, + type ProgramStatusState +} from './protocol' + +export type ProgramStatusRecord = { + /** Null is the root record. */ + id: string | null + state: ProgramStatusState + kind?: ProgramStatusKind + progress?: number + /** Own `app`, or the nearest ancestor's when the report omitted it. */ + app?: string + title?: string + msg?: string +} + +/** Spec ceiling; LRU-evicted past it so a runaway program cannot grow memory. */ +export const PROGRAM_STATUS_MAX_RECORDS = 256 + +const ROOT_KEY = '' + +function isAtOrBeneath(id: string, ancestor: string): boolean { + return id === ancestor || id.startsWith(`${ancestor}/`) +} + +/** One terminal's OSC 7501 records. Mutators return whether anything changed. */ +export class ProgramStatusRecordSet { + // Map order doubles as least-recently-updated order: every write re-inserts its key. + private readonly records = new Map< + string, + Omit & { ownApp?: string } + >() + + apply(report: ProgramStatusReport): boolean { + const key = report.id ?? ROOT_KEY + if (report.state === 'clear') { + if (report.id === null) { + return this.reset() + } + let changed = false + for (const existing of this.records.keys()) { + if (existing !== ROOT_KEY && isAtOrBeneath(existing, key)) { + this.records.delete(existing) + changed = true + } + } + return changed + } + // Each report replaces its record whole; a key missing here is missing afterwards. + this.records.delete(key) + if (this.records.size >= PROGRAM_STATUS_MAX_RECORDS) { + const oldest = this.records.keys().next().value + if (oldest !== undefined) { + this.records.delete(oldest) + } + } + this.records.set(key, { + id: report.id, + state: report.state, + ...(report.kind !== undefined ? { kind: report.kind } : {}), + ...(report.progress !== undefined ? { progress: report.progress } : {}), + ...(report.app !== undefined ? { ownApp: report.app } : {}), + ...(report.title !== undefined ? { title: report.title } : {}), + ...(report.msg !== undefined ? { msg: report.msg } : {}) + }) + return true + } + + /** The program ended (OSC 133 A; PTY exit clears the runtime's live facet alike). */ + endProgram(): boolean { + let changed = false + for (const [key, record] of this.records) { + if (!programStatusStateOutlivesProgram(record.state)) { + this.records.delete(key) + changed = true + } + } + return changed + } + + /** RIS or a root `clear`: every record goes. */ + reset(): boolean { + const changed = this.records.size > 0 + this.records.clear() + return changed + } + + root(): ProgramStatusRecord | undefined { + return this.resolve(ROOT_KEY) + } + + /** Non-root records, oldest update first. */ + descendants(): ProgramStatusRecord[] { + const result: ProgramStatusRecord[] = [] + for (const key of this.records.keys()) { + if (key !== ROOT_KEY) { + const record = this.resolve(key) + if (record) { + result.push(record) + } + } + } + return result + } + + private resolve(key: string): ProgramStatusRecord | undefined { + const stored = this.records.get(key) + if (!stored) { + return undefined + } + const { ownApp, ...record } = stored + const app = ownApp ?? this.inheritedApp(key) + return app !== undefined ? { ...record, app } : record + } + + private inheritedApp(key: string): string | undefined { + let cursor = key + while (cursor !== ROOT_KEY) { + const slash = cursor.lastIndexOf('/') + cursor = slash === -1 ? ROOT_KEY : cursor.slice(0, slash) + const app = this.records.get(cursor)?.ownApp + if (app !== undefined) { + return app + } + } + return undefined + } +} diff --git a/src/shared/program-status/title-activity-order.ts b/src/shared/program-status/title-activity-order.ts new file mode 100644 index 00000000000..3682b5815c5 --- /dev/null +++ b/src/shared/program-status/title-activity-order.ts @@ -0,0 +1,22 @@ +import type { AgentStatus } from '../agent-title-core' +import type { ProgramActivityChange } from '../agent-status-osc' + +/** An activity change to apply before the title at `beforeTitle` (`titles.length`: after them all). */ +export type PlacedProgramActivity = { beforeTitle: number; activity: AgentStatus | null } + +/** + * Orders a chunk's OSC 7501 activity changes among its titles. Why: explicit activity outranks a + * title only while it exists, so a title read before a later Clear must not be judged after it. + */ +export function placeProgramActivityAmongTitles( + titleEnds: readonly number[], + activity: readonly ProgramActivityChange[] +): PlacedProgramActivity[] { + let titleIndex = 0 + return activity.map((change) => { + while (titleIndex < titleEnds.length && titleEnds[titleIndex] <= change.offset) { + titleIndex += 1 + } + return { beforeTitle: titleIndex, activity: change.activity } + }) +} diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 6c57f0c6f50..2c79ff6a567 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -1,5 +1,6 @@ import { AGENT_SESSION_CURRENT_REQUESTS_CLIENT_CAPABILITY } from './agent-session-current-requests-capability' import { AI_VAULT_CHAT_FORK_SOURCE_CAPABILITY } from './ai-vault-resume-preparation' +import { PROGRAM_STATUS_RUNTIME_CAPABILITY } from './program-status/protocol' import { JIRA_AGENT_ACCESS_RUNTIME_CAPABILITY } from './jira-agent-capability' import { JIRA_STATUS_FILTER_RUNTIME_CAPABILITY } from './jira-quick-filters' import { JIRA_SAVED_FILTER_RUNTIME_CAPABILITY } from './jira-saved-filter-capability' @@ -202,6 +203,7 @@ export const RUNTIME_CAPABILITIES = [ BROWSER_NETWORK_TUNNEL_RUNTIME_CAPABILITY, BROWSER_NETWORK_EXECUTION_HOSTS_RUNTIME_CAPABILITY, 'terminal.binary-stream.v1', + PROGRAM_STATUS_RUNTIME_CAPABILITY, 'terminal.multiplex.v1', 'workspace-ports.v1', 'mobile.tasks.v1', diff --git a/src/shared/runtime-worktree-contracts.ts b/src/shared/runtime-worktree-contracts.ts index 6fa03833ba7..c95800ac201 100644 --- a/src/shared/runtime-worktree-contracts.ts +++ b/src/shared/runtime-worktree-contracts.ts @@ -1,6 +1,7 @@ import type { SmartTier } from './sidebar-list/sort-stamps' import type { AgentStatusState, AgentType, AgentWorkingMode } from './agent-status-types' import type { AgentMainAgentStatus } from './main-agent-status' +import type { ProgramStatusFacet } from './program-status/facet' import type { CheckStatus } from './github/pull-request-types' import type { AgentChildWorkView } from './agent-status-child-work-view' import type { AgentSubagentSnapshot } from './agent-status-subagent-snapshot' @@ -49,6 +50,8 @@ export type RuntimeWorktreeAgentRow = { /** The structured session host still runs this row's provider child, so it is fresh regardless * of age. Optional on the wire: old hosts never send it. */ structuredHostOwned?: true + /** The program's own OSC 7501 report; never decides `state`. Optional: old hosts never send it. */ + programStatus?: ProgramStatusFacet } export type RuntimeWorktreePsSummary = { diff --git a/src/shared/terminal-agent-status-processor.test.ts b/src/shared/terminal-agent-status-processor.test.ts new file mode 100644 index 00000000000..ff4fdd33e7e --- /dev/null +++ b/src/shared/terminal-agent-status-processor.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest' +import { createTerminalAgentStatusProcessor } from './terminal-agent-status-processor' + +describe('createTerminalAgentStatusProcessor', () => { + it('returns OSC 9999 chunks untouched when no OSC 7501 record changed', () => { + const process = createTerminalAgentStatusProcessor() + const result = process('a\x1b]9999;{"state":"working","prompt":"p"}\x07b') + expect(result.cleanData).toBe('ab') + expect(result.programStatusPayloads).toBeUndefined() + }) + + it('appends OSC 7501 projections, marks them, and leaves the sequence in the stream', () => { + const process = createTerminalAgentStatusProcessor() + const data = 'x\x1b]7501;state=working:app=codex\x1b\\y' + const result = process(data) + expect(result.cleanData).toBe(data) + expect(result.payloads).toMatchObject([{ state: 'working', agentType: 'codex' }]) + expect(result.programStatusPayloads?.has(result.payloads[0])).toBe(true) + expect(result.lastPayloadCleanOffset).toBe(data.length - 1) + }) + + it('merges OSC 9999 and OSC 7501 in one chunk, leaving titles untouched', () => { + const process = createTerminalAgentStatusProcessor() + const report = '\x1b]7501;state=working:app=claude-code\x1b\\' + const result = process( + `a\x1b]9999;{"state":"done","prompt":"p"}\x07${report}\x1b]0;Refactor\x07b` + ) + expect(result.cleanData).toBe(`a${report}\x1b]0;Refactor\x07b`) + expect(result.payloads).toMatchObject([{ state: 'done' }, { state: 'working' }]) + expect(result.programStatusPayloads?.has(result.payloads[1])).toBe(true) + expect(result.programStatusPayloads?.has(result.payloads[0])).toBe(false) + expect(result.lastPayloadCleanOffset).toBe(1 + report.length) + expect(result.programStatusFacet).toMatchObject({ state: 'working' }) + expect(result.programActivity?.map((change) => change.activity)).toEqual(['working']) + }) + + it('reports each activity change once, and null when the program stops reporting', () => { + const process = createTerminalAgentStatusProcessor() + const osc = (body: string) => `\x1b]7501;${body}\x1b\\` + const activity = (data: string) => + process(data).programActivity?.map((change) => change.activity) + expect(activity(osc('state=idle:app=x') + osc('state=done:app=x'))).toEqual(['idle']) + expect(activity(osc('state=blocked:kind=auth:app=x'))).toEqual(['permission']) + expect(activity(osc('state=clear'))).toEqual([null]) + }) + + it('places each activity change at the end of its report in the clean output', () => { + const process = createTerminalAgentStatusProcessor() + const report = '\x1b]7501;state=working:app=x\x1b\\' + expect(process(`ab${report}cd`).programActivity).toEqual([ + { offset: 2 + report.length, activity: 'working' } + ]) + }) + + it('keeps OSC 9999 and OSC 7501 payloads in byte order', () => { + const process = createTerminalAgentStatusProcessor() + const osc9999Report = (state: string) => `\x1b]9999;{"state":"${state}"}\x07` + const result = process( + `${osc9999Report('working')}\x1b]7501;state=blocked:kind=question:app=x\x1b\\${osc9999Report('done')}` + ) + expect(result.payloads.map((payload) => payload.state)).toEqual(['working', 'waiting', 'done']) + }) +}) diff --git a/src/shared/terminal-agent-status-processor.ts b/src/shared/terminal-agent-status-processor.ts new file mode 100644 index 00000000000..772f3b2cee3 --- /dev/null +++ b/src/shared/terminal-agent-status-processor.ts @@ -0,0 +1,97 @@ +import type { AgentStatus } from './agent-title-core' +import type { ParsedAgentStatusPayload } from './agent-status-types' +import { + createAgentStatusOscProcessor, + type ProcessedAgentStatusChunk, + type ProgramActivityChange +} from './agent-status-osc' +import { programStatusActivity } from './program-status/activity' +import { + createProgramStatusProjector, + type ProgramStatusPayload +} from './program-status/agent-status-projector' +import type { ProgramStatusFacet } from './program-status/facet' + +type OffsetPayload = { payload: ParsedAgentStatusPayload; offset: number } + +type ProgramStatusSideEffectScan = { + payloads: ProgramStatusPayload[] + activity: ProgramActivityChange[] + /** See ProcessedAgentStatusChunk.programStatusFacet. */ + facet?: ProgramStatusFacet | null +} + +/** Stateful per-PTY OSC 7501 reader for side effects: status payloads, activity and the facet. */ +export function createProgramStatusSideEffectScanner(): ( + data: string +) => ProgramStatusSideEffectScan { + const programStatus = createProgramStatusProjector() + let lastActivity: AgentStatus | null = null + return (data) => { + const { payloads, rootChanges, facet } = programStatus.process(data) + const activity: ProgramActivityChange[] = [] + for (const change of rootChanges) { + const next = programStatusActivity(change.root) + if (next !== lastActivity) { + activity.push({ offset: change.offset, activity: next }) + lastActivity = next + } + } + return { payloads, activity, ...(facet !== undefined ? { facet } : {}) } + } +} + +/** A chunk's OSC 9999 and OSC 7501 payloads in byte order, since either can follow the other. */ +function mergeInBandStatusPayloads( + osc9999: ProcessedAgentStatusChunk, + osc7501: readonly OffsetPayload[] +): OffsetPayload[] { + const merged: OffsetPayload[] = osc9999.payloads.map((payload, index) => ({ + payload, + offset: osc9999.payloadCleanOffsets?.[index] ?? 0 + })) + for (const entry of osc7501) { + let index = merged.length + // `>=`: a stripped 9999 frame at the 7501 frame's end offset was written after it. + while (index > 0 && merged[index - 1].offset >= entry.offset) { + index -= 1 + } + merged.splice(index, 0, entry) + } + return merged +} + +/** + * Every in-band agent status a PTY stream carries, for main's side-effect scan: Orca's OSC 9999 + * payloads (stripped from `cleanData`) and the Program Status Protocol, OSC 7501 (left in place). + */ +export function createTerminalAgentStatusProcessor(): (data: string) => ProcessedAgentStatusChunk { + const processAgentStatusOsc = createAgentStatusOscProcessor() + const scanProgramStatus = createProgramStatusSideEffectScanner() + return (data) => { + const chunk = processAgentStatusOsc(data) + const scan = scanProgramStatus(chunk.cleanData) + if (scan.payloads.length === 0 && scan.activity.length === 0 && scan.facet === undefined) { + return chunk + } + const payloads = mergeInBandStatusPayloads(chunk, scan.payloads) + return { + ...chunk, + payloads: payloads.map(({ payload }) => payload), + lastPayloadCleanOffset: payloads.at(-1)?.offset ?? null, + payloadCleanOffsets: payloads.map(({ offset }) => offset), + ...(scan.payloads.length > 0 + ? { programStatusPayloads: new Set(scan.payloads.map(({ payload }) => payload)) } + : {}), + ...(scan.payloads.some((entry) => entry.removesRow) + ? { + programStatusRowRemovals: new Set( + scan.payloads.filter((entry) => entry.removesRow).map(({ payload }) => payload) + ) + } + : {}), + ...(scan.activity.length > 0 ? { programActivity: scan.activity } : {}), + ...(scan.facet !== undefined ? { programStatusFacet: scan.facet } : {}) + } + } +} diff --git a/src/shared/terminal-output-side-effects.ts b/src/shared/terminal-output-side-effects.ts index 2c7729a411a..ce5c27aa824 100644 --- a/src/shared/terminal-output-side-effects.ts +++ b/src/shared/terminal-output-side-effects.ts @@ -6,6 +6,7 @@ import { type AgentStatus, + type AgentIdleStatus, clearWorkingIndicators, createAgentStatusTracker, detectAgentStatusFromTitle, @@ -14,6 +15,9 @@ import { normalizeTerminalTitle, shouldSuppressCursorNativeTitle } from './agent-detection' +import type { ProgramActivityChange } from './agent-status-osc' +import { extractAllOscTitleEntries } from './osc-title-extraction' +import { placeProgramActivityAmongTitles } from './program-status/title-activity-order' import { createBellDetector } from './terminal-bell-detector' import { INITIAL_MODE_2031_REPLY_SCAN_STATE, @@ -33,17 +37,24 @@ export type TerminalTitleFactMeta = { staleWorkingTitleClear?: boolean } +/** An idle edge's provenance plus the status the tracker classified, so readers need not re-read the title. */ +export type AgentIdleEdgeMeta = TerminalTitleFactMeta & { status?: AgentIdleStatus } + type TerminalTitleTrackerChunkOptions = { titleScanData?: string mode2031PendingSubscribe?: boolean + /** The chunk's OSC 7501 activity changes, offsets in `data`; applied in byte order with its titles. */ + programActivity?: readonly ProgramActivityChange[] } export type TerminalTitleTrackerCallbacks = { /** Fired once per observed OSC title, in byte order — including the synthesized cleared title when the stale-working timer fires. */ onTitle?: (normalizedTitle: string, rawTitle: string, meta?: TerminalTitleFactMeta) => void - onAgentBecameIdle?: (title: string, meta?: TerminalTitleFactMeta) => void + onAgentBecameIdle?: (title: string, meta?: AgentIdleEdgeMeta) => void onAgentBecameWorking?: () => void onAgentExited?: () => void + /** Fired per change in the program's OSC 7501 activity (see applyProgramActivity). */ + onProgramActivity?: (activity: AgentStatus | null) => void /** Fired once per chunk containing a real BEL (OSC-aware, cross-chunk escape state), after the chunk's titles (renderer drain order). */ onBell?: () => void /** @@ -82,6 +93,8 @@ export type TerminalTitleTracker = { * No-ops once any title has been observed or seeded (live state wins); fires no callbacks. */ seedInitialTitle: (rawTitle: string) => void + /** The stream lost the program's OSC 7501 reports (a data gap): titles classify again. */ + endProgramActivity: () => void /** Restore the status consumed by the latest exit candidate when process evidence disproves it. */ restoreLastAgentExit: (confirmedStatus?: AgentStatus) => AgentStatus | null /** Last title surfaced through onTitle, after normalization. */ @@ -107,6 +120,7 @@ export function createTerminalTitleTracker( onAgentBecameIdle, onAgentBecameWorking, onAgentExited, + onProgramActivity, onBell, onCommandFinished, onCommandStarted, @@ -140,11 +154,11 @@ export function createTerminalTitleTracker( const agentTracker = onAgentBecameIdle || onAgentBecameWorking || onAgentExited ? createAgentStatusTracker( - (title) => { - onAgentBecameIdle?.( - title, - applyingStaleWorkingTitleClear ? { staleWorkingTitleClear: true } : undefined - ) + (title, status) => { + onAgentBecameIdle?.(title, { + status, + ...(applyingStaleWorkingTitleClear ? { staleWorkingTitleClear: true } : {}) + }) }, onAgentBecameWorking, onAgentExited, @@ -176,6 +190,11 @@ export function createTerminalTitleTracker( agentTracker?.handleTitle(rawTitle) } + function applyProgramActivity(activity: AgentStatus | null): void { + agentTracker?.handleProgramActivity(activity) + onProgramActivity?.(activity) + } + function handleChunk(data: string, options: TerminalTitleTrackerChunkOptions = {}): void { const titleScanData = options.titleScanData ?? data // Why: hot path — scan for the OSC introducer once and share it with the bell detector's fast-path gate. @@ -186,13 +205,32 @@ export function createTerminalTitleTracker( ? bellDetector.chunkContainsBell(data, { containsOscIntroducer }) : false // Why: feed every OSC title in byte order; a last-title reader drops intra-chunk working→idle transitions in coalesced payloads (issue #1083). - const titles = titleScanData.includes('\x1b]') ? extractAllOscTitles(titleScanData) : [] + const titles = titleScanData.includes('\x1b]') ? extractAllOscTitleEntries(titleScanData) : [] + // Title ends move into `data`'s offsets: titleScanData is the previous scan tail plus `data`. + const tailLength = titleScanData.length - data.length + const activity = options.programActivity + ? placeProgramActivityAmongTitles( + titles.map((entry) => entry.end - tailLength), + options.programActivity + ) + : [] + let activityIndex = 0 + const applyActivityBefore = (titleIndex: number): void => { + while (activityIndex < activity.length && activity[activityIndex].beforeTitle <= titleIndex) { + applyProgramActivity(activity[activityIndex].activity) + activityIndex += 1 + } + } if (titles.length > 0) { clearStaleTitleTimer() - for (const title of titles) { - applyObservedTitle(title) - } - } else if ( + titles.forEach((entry, index) => { + applyActivityBefore(index) + applyObservedTitle(entry.title) + }) + } + applyActivityBefore(titles.length) + if ( + titles.length === 0 && // Why: an agent exiting without resetting its title leaves a stale spinner; title-less output while working arms the 3s clear timer. data.length > 0 && lastEmittedTitle !== null && @@ -264,6 +302,7 @@ export function createTerminalTitleTracker( return { handleChunk, applySyntheticTitleFrame, + endProgramActivity: () => applyProgramActivity(null), seedInitialTitle(rawTitle: string): void { if (lastEmittedTitle !== null || !rawTitle) { return diff --git a/src/shared/terminal-side-effect-facts.ts b/src/shared/terminal-side-effect-facts.ts index 47c416aff5d..3726f629422 100644 --- a/src/shared/terminal-side-effect-facts.ts +++ b/src/shared/terminal-side-effect-facts.ts @@ -5,6 +5,7 @@ * renderer store handler owns notification/unread policy. */ +import type { AgentIdleStatus } from './agent-title-status' import type { ParsedAgentStatusPayload } from './agent-status-types' import type { TerminalGitHubPRLink } from './terminal-github-pr-link-detector' @@ -17,7 +18,12 @@ export type TerminalSideEffectFact = | { kind: 'title'; normalizedTitle: string; rawTitle: string; staleWorkingTitleClear?: boolean } | { kind: 'bell' } | { kind: 'agent-working' } - | { kind: 'agent-idle'; title: string; staleWorkingTitleClear?: boolean } + | { + kind: 'agent-idle' + title: string + staleWorkingTitleClear?: boolean + status?: AgentIdleStatus + } | { kind: 'agent-exited' } /** OSC 133;D — foreground shell command exited (exit code best-effort). */ | { kind: 'command-finished'; exitCode: number | null }