mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
feat(agent-status): read OSC 7501 program status beside hooks (#30261)
Programs can tell Orca what they are doing through the Program Status Protocol (OSC 7501); Claude Code, Codex, Grok, pi and oh-my-pi already do. Orca answers the protocol's feature-detection probe (visible xterm and the hidden-pane emulator), parses reports from PTY output on the execution host (src/shared/program-status/), and keeps two lanes: - Lifecycle state keeps one owner per pane. Hooks win; OSC 7501 drives the row when hooks are off, missing, or silent on a state the program has left (server/program-status/hook-precedence). - The program's own report (progress, its one-line message, blocks such as sign-in) is a live-only `programStatus` facet kept per pane (server/program-status/facet-store), stitched onto rows at publish time and carried to the renderer store, mobile, paired clients and `worktree ps`. Facet-only changes patch just the facet. Title-driven readers (idle edges, delivery, tui-idle, exit inference) classify by the program's OSC 7501 activity while it reports one and fall back to title glyphs only for agents that report nothing explicit, so they keep working when Claude stops painting its title glyph after the probe.
This commit is contained in:
@@ -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<RuntimeWorktreeP
|
||||
const body = result.worktrees
|
||||
.map(
|
||||
(worktree) =>
|
||||
`${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}`
|
||||
|
||||
@@ -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>): 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('')
|
||||
})
|
||||
})
|
||||
@@ -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}%`
|
||||
}
|
||||
@@ -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/<agent>/`. | 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.
|
||||
@@ -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: () =>
|
||||
|
||||
@@ -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<ParsedAgentStatusPayload, 'prompt'>,
|
||||
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<void> {
|
||||
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<AgentHookServer['subscribeStatusRowMutations']>[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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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<string, ProgramStatusFacet>()
|
||||
|
||||
/** 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<T extends { paneKey: string; programStatus?: ProgramStatusFacet }>(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 }
|
||||
}
|
||||
}
|
||||
@@ -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<ParsedAgentStatusPayload, 'prompt'>,
|
||||
overrides: Partial<EnrichedAgentHookEventPayload> = {},
|
||||
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'>): 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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
type PaneTimer = { timer: ReturnType<typeof setTimeout>; 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<string, PaneTimer>()
|
||||
|
||||
/** 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() ?? []) {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<void> {
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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<Terminal['parser'], 'registerOscHandler' | 'registerCsiHandler'>
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, { lastAgentStatus: string | null }> }
|
||||
// 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<string, { lastAgentStatus: string | null; lastOscTitleAt: number | null }>
|
||||
}
|
||||
// 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<string, { status: string | null }> }
|
||||
// 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')
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 }
|
||||
: {})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -117,7 +117,8 @@ export function buildRuntimeMobileAgentStatus(
|
||||
: {}),
|
||||
tabId: tab.parentTabId,
|
||||
terminalTitle,
|
||||
...providerSession
|
||||
...providerSession,
|
||||
...(liveRow.programStatus ? { programStatus: liveRow.programStatus } : {})
|
||||
},
|
||||
ownerAgent,
|
||||
ownerOptions
|
||||
|
||||
@@ -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 } : {})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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<AgentStatusIpcPayload, 'subagents'> & {
|
||||
children?: AgentChildWorkView[]
|
||||
@@ -26,4 +27,5 @@ export type RuntimeWorktreeAgentSource = Pick<AgentStatusIpcPayload, 'subagents'
|
||||
updatedAt: number
|
||||
/** Projected by the structured session host; `owned` rows stay fresh past the staleness window. */
|
||||
structuredHost?: StructuredHostStatus
|
||||
programStatus?: ProgramStatusFacet
|
||||
}
|
||||
|
||||
@@ -84,4 +84,16 @@ describe('worktree agent source admission', () => {
|
||||
})
|
||||
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'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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[] = []
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -94,6 +94,9 @@ export abstract class OrcaRuntimeWithFitOverrideListeners extends OrcaRuntimeWit
|
||||
ReturnType<typeof createAgentStatusOscProcessor>
|
||||
>()
|
||||
|
||||
// PTYs whose last OSC 7501 facet dies with the program; their exit must clear it.
|
||||
protected liveProgramStatusFacetPtyIds = new Set<string>()
|
||||
|
||||
// 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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, ParsedAgentStatusPayload>()
|
||||
private programStatusLeaseTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
protected createTerminalSideEffectCommandCodeDetector(
|
||||
ptyId: string
|
||||
): NonNullable<RuntimePtyTitleTrackerEntry['commandCodeDetector']> {
|
||||
@@ -95,13 +104,41 @@ export abstract class OrcaRuntimeWithCreateTerminalSideEffectCommandCodeDetector
|
||||
protected emitTerminalAgentStatusEvents(
|
||||
ptyId: string,
|
||||
chunk: Pick<ProcessedAgentStatusChunk, 'payloads'> & Partial<ProcessedAgentStatusChunk>,
|
||||
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<string, TerminalAgentStatusTarget>()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -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<RuntimePtyTitleTrackerEntry['commandCodeDetector']>
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<TuiIdleEvaluationInput> = {}) =>
|
||||
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' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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())
|
||||
|
||||
+8
-7
@@ -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()) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>): 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 }))
|
||||
}
|
||||
}
|
||||
+134
@@ -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> = {}): 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()
|
||||
})
|
||||
})
|
||||
@@ -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<typeof processAgentStatusChunk>['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<typeof processAgentStatusChunk>['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
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+1
-1
@@ -353,7 +353,7 @@ describe('registerTerminalSideEffectFactConsumer', () => {
|
||||
)
|
||||
|
||||
expect(events).toEqual([
|
||||
['idle', 'Codex done', undefined],
|
||||
['idle', 'Codex done', {}],
|
||||
['title', 'Codex', { staleWorkingTitleClear: true }],
|
||||
['idle', 'Codex', { staleWorkingTitleClear: true }]
|
||||
])
|
||||
|
||||
@@ -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?.()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>): 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<typeof vi.fn>
|
||||
let setAgentProgramStatus: ReturnType<typeof vi.fn>
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -282,7 +282,7 @@ describe('useIpcEvents agent status snapshot integration', () => {
|
||||
const next = { ...(storeState.agentStatusByPaneKey as Record<string, unknown>) }
|
||||
delete next[update.paneKey]
|
||||
storeState.agentStatusByPaneKey = next
|
||||
} else {
|
||||
} else if (update.kind !== 'programStatus') {
|
||||
applyStatus(update.paneKey, update.payload)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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<typeof createTestStore>
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ??
|
||||
|
||||
@@ -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<AgentStatusSlice, 'setAgentProgramStatus'> {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -26,7 +26,10 @@ export type AgentStatusRuntime = {
|
||||
export function createAgentStatusRuntime(
|
||||
storeSet: Parameters<StateCreator<AppState>>[0],
|
||||
storeGet: Parameters<StateCreator<AppState>>[1],
|
||||
getActions: () => Pick<AgentStatusSlice, 'setAgentStatus' | 'recordAgentProviderSession'>
|
||||
getActions: () => Pick<
|
||||
AgentStatusSlice,
|
||||
'setAgentStatus' | 'recordAgentProviderSession' | 'setAgentProgramStatus'
|
||||
>
|
||||
): AgentStatusRuntime {
|
||||
let batchedAgentStatusState: AppState | null = null
|
||||
let batchedAgentStatusTouchedKeys: Set<keyof AppState> | 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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<AppState, [], [], AgentStatusS
|
||||
storeGet
|
||||
) => {
|
||||
let composedActions:
|
||||
| Pick<AgentStatusSlice, 'setAgentStatus' | 'recordAgentProviderSession'>
|
||||
| Pick<
|
||||
AgentStatusSlice,
|
||||
'setAgentStatus' | 'recordAgentProviderSession' | 'setAgentProgramStatus'
|
||||
>
|
||||
| undefined
|
||||
const runtime = createAgentStatusRuntime(storeSet, storeGet, () => {
|
||||
if (!composedActions) {
|
||||
@@ -74,6 +79,7 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
|
||||
}
|
||||
return composedActions
|
||||
})
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The spread creators together supply every AgentStatusSlice action; state fields are added below.
|
||||
const actions = {
|
||||
...createAgentStatusAuthorityActions(runtime),
|
||||
...createAgentStatusCleanupActions(runtime),
|
||||
@@ -83,12 +89,14 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
|
||||
...createAgentStatusLiveActions(runtime),
|
||||
...createAgentStatusOrchestrationActions(runtime),
|
||||
...createAgentStatusProviderSessionActions(runtime),
|
||||
...createAgentStatusProgramStatusActions(runtime),
|
||||
...createAgentStatusRecoveryActions(runtime),
|
||||
...createAgentStatusRetentionActions(runtime)
|
||||
} as AgentStatusSlice
|
||||
composedActions = {
|
||||
setAgentStatus: actions.setAgentStatus,
|
||||
recordAgentProviderSession: actions.recordAgentProviderSession
|
||||
recordAgentProviderSession: actions.recordAgentProviderSession,
|
||||
setAgentProgramStatus: actions.setAgentProgramStatus
|
||||
}
|
||||
return {
|
||||
...actions,
|
||||
|
||||
@@ -26,7 +26,8 @@ export {
|
||||
createAgentStatusTracker,
|
||||
detectAgentStatusFromTitle,
|
||||
isQuarterCircleSpinnerOnlyAgentTitle,
|
||||
normalizeTerminalTitle
|
||||
normalizeTerminalTitle,
|
||||
type AgentIdleStatus
|
||||
} from './agent-title-status'
|
||||
|
||||
// Re-export so existing `agent-detection` importers keep working.
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
// other. Re-exported from agent-status-types, so existing import sites are unchanged.
|
||||
|
||||
import type { StructuredHostStatus } from './agent-hook-listener/listener-event'
|
||||
import type { ProgramStatusFacet } from './program-status/facet'
|
||||
import type { AgentProviderSessionMetadata } from './agent-session-resume'
|
||||
import type { WithAgentStatusObservation } from './agent-status-observation'
|
||||
import type {
|
||||
@@ -65,6 +66,10 @@ export type AgentStatusIpcPayload = ParsedAgentStatusPayload & {
|
||||
providerSessionOnly?: boolean
|
||||
/** Live-only Command Code turn boundary key; not persisted to last-status.json. */
|
||||
promptInteractionKey?: string
|
||||
/** See AgentStatusEntry.programStatus. Live-only; older peers ignore it. */
|
||||
programStatus?: ProgramStatusFacet
|
||||
/** Only `programStatus` changed: readers patch the facet and treat nothing else as news. */
|
||||
programStatusOnly?: true
|
||||
/** See AgentStatusEntry.restoredUnconfirmed — hydrated nonterminal provenance. */
|
||||
restoredUnconfirmed?: boolean
|
||||
/** A re-send of a cached row (reconnect or listener replay), not a new transition. */
|
||||
@@ -86,7 +91,12 @@ export type AgentStatusCacheIdentity = {
|
||||
|
||||
/** Wire shape for ordinary pane teardown or a stamped SSH disconnect batch. */
|
||||
export type AgentStatusClearIpcPayload =
|
||||
| { paneKey: string; statusUnavailable?: true }
|
||||
| {
|
||||
paneKey: string
|
||||
statusUnavailable?: true
|
||||
/** An OSC 7501 program took back its own row, so even a `done` row goes. */
|
||||
programStatusRemoved?: true
|
||||
}
|
||||
| {
|
||||
transient: true
|
||||
connectionId: string
|
||||
|
||||
@@ -14,7 +14,7 @@ import { createNonSecureContextUuid } from './non-secure-context-uuid'
|
||||
export const AGENT_STATUS_OBSERVATION_ORIGINS = [
|
||||
/** Provider hook event (loopback HTTP or relayed), run through a provider normalizer. */
|
||||
'hook',
|
||||
/** OSC 9999 structured payload parsed out of PTY bytes. Canonical payload, no provider normalizer. */
|
||||
/** In-band status parsed out of PTY bytes: OSC 9999 payloads or OSC 7501 program status. */
|
||||
'osc',
|
||||
/** Inferred from a terminal title. The weakest evidence Orca acts on. */
|
||||
'title',
|
||||
|
||||
@@ -44,7 +44,8 @@ describe('OSC 9999 pending storage', () => {
|
||||
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]
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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]
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<ParsedAgentStatusPayload>
|
||||
/** The subset of those whose program left no records (see ProgramStatusPayload.removesRow). */
|
||||
programStatusRowRemovals?: ReadonlySet<ParsedAgentStatusPayload>
|
||||
/** 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 } : {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user