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 }