From fe1e54201baaa388ee84b6d093f6950d20dd7476 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 23:02:19 -0700 Subject: [PATCH 1/7] fix(opencode): clear an `opencode run` row when its process exits Done means an agent's TUI is still alive and finished its turn. When `opencode run` (the OpenCode 2 one-shot) exits back to the shell nothing runs in the pane anymore, but the run detector posted Done into the host status store at command end. Whether that Done survived depended on timing: after a terminal-list refresh the pane's launch authority was retired on command end and the Done was refused, otherwise a stale Done stayed in the sidebar, `orca worktree ps` and mobile. At command end the detector now posts no status. It retires the row it created through the store's ended-process reconciliation (reconcileEndedProcessForPaneKeys, keeping resume identity because the shell outlived the run), which emits the pane clear every reader gets. It runs before launch-authority retirement, which deletes rows without telling the renderer, so visible and hidden panes clear alike. Removes what existed only for the run's Done: the interrupted flag on Ctrl+C exits and the after-facts deferral that ordered the Done behind the command-finished fact. --- src/main/orcad/orcad-entry.ts | 4 +- .../agent-status-store-wiring.test-fixture.ts | 6 +- .../opencode-run-lifetime-runtime.test.ts | 156 ++++++++++++++---- .../opencode-run-lifetime-status.test.ts | 78 +++++---- .../runtime/opencode-run-lifetime-status.ts | 24 +-- ...-runtime-emit-daemon-pty-transient-fact.ts | 3 +- ...e-get-unpersisted-tracked-title-for-pty.ts | 4 +- .../orca-runtime-preserved-branch-cleanup.ts | 2 +- ...rca-runtime-schedule-wait-blocked-check.ts | 22 +-- src/main/runtime/orca-runtime-state-fields.ts | 5 +- .../orca-runtime-stop-requested-pty-ids.ts | 18 +- .../runtime/runtime-terminal-state-records.ts | 2 - .../startup/main-process-runtime-service.ts | 4 +- ...nection-command-finished-late-done.test.ts | 4 +- 14 files changed, 205 insertions(+), 127 deletions(-) diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index 6c0e9bbad227..06e8f1112ca6 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -237,8 +237,8 @@ async function startOrcadRuntime( readChildWork: (subject) => agentHookServer.getStructuredChildWorkViews(subject) }, checkHookAgentPresence: (paneKey) => agentHookServer.checkAgentPresence(paneKey), - reconcileAgentStatusForEndedProcess: (paneKeys) => - agentHookServer.reconcileEndedProcessForPaneKeys(paneKeys), + reconcileAgentStatusForEndedProcess: (paneKeys, options) => + agentHookServer.reconcileEndedProcessForPaneKeys(paneKeys, options), buildAgentHookPtyEnv: () => isAgentStatusHooksEnabled(profileStore.getSettings()) ? agentHookServer.buildPtyEnv() : {}, // Why the dedupe here and not in the instance: `apply` closes and reconstructs diff --git a/src/main/runtime/agent-status-store-wiring.test-fixture.ts b/src/main/runtime/agent-status-store-wiring.test-fixture.ts index 1f399e0464f0..7f4f2c05fa98 100644 --- a/src/main/runtime/agent-status-store-wiring.test-fixture.ts +++ b/src/main/runtime/agent-status-store-wiring.test-fixture.ts @@ -26,7 +26,7 @@ export function makeAgentStatusStoreWiring(): { paneKey: string ) => ReturnType reconcileAgentStatusForEndedProcess: ( - paneKeys: Parameters[0] + ...args: Parameters ) => void } /** Call once the runtime exists; returns the republish teardown. */ @@ -42,8 +42,8 @@ export function makeAgentStatusStoreWiring(): { getAgentProviderSessionSnapshot: () => statusStore.getStatusSnapshot(), getAgentProviderSessionRowsForPane: (paneKey) => statusStore.getStatusSnapshotForPane(paneKey), - reconcileAgentStatusForEndedProcess: (paneKeys) => { - statusStore.reconcileEndedProcessForPaneKeys(paneKeys) + reconcileAgentStatusForEndedProcess: (...args) => { + statusStore.reconcileEndedProcessForPaneKeys(...args) } }, attach: (runtime) => installHookStatusSessionTabsRepublish(statusStore, () => runtime) diff --git a/src/main/runtime/opencode-run-lifetime-runtime.test.ts b/src/main/runtime/opencode-run-lifetime-runtime.test.ts index 4475abdb49fc..f1f28d5729bb 100644 --- a/src/main/runtime/opencode-run-lifetime-runtime.test.ts +++ b/src/main/runtime/opencode-run-lifetime-runtime.test.ts @@ -9,6 +9,7 @@ vi.mock('./local-pty-foreground-command-line', () => ({ import { OrcaRuntimeService } from './orca-runtime' import { FOREGROUND_COMMAND_READS } from '../../shared/foreground-command-settle' import type { RuntimeTerminalAgentStatusEvent } from './runtime-terminal-contracts' +import { makeAgentStatusStoreWiring } from './agent-status-store-wiring.test-fixture' const WORKTREE_ID = 'repo::/worktree' const LEAF_ID = '11111111-1111-4111-8111-111111111111' @@ -18,13 +19,23 @@ function createRuntime(): { runtime: OrcaRuntimeService statuses: RuntimeTerminalAgentStatusEvent[] channelOrder: string[] + statusStore: ReturnType['statusStore'] } { const statuses: RuntimeTerminalAgentStatusEvent[] = [] const channelOrder: string[] = [] + // Why the real store: it is what the sidebar, `worktree ps` and mobile all read. + const wiring = makeAgentStatusStoreWiring() + wiring.statusStore.subscribePaneStatusClear((clear) => + channelOrder.push(`clear:${'paneKey' in clear ? clear.paneKey : ''}`) + ) const runtime = new OrcaRuntimeService(undefined, undefined, { + ...wiring.deps, + retireAgentHookCompatibilityAuthority: (paneKey) => + wiring.statusStore.retirePaneAuthority(paneKey), onTerminalAgentStatus: (event) => { statuses.push(event) channelOrder.push(`status:${event.payload.state}`) + wiring.deps.onTerminalAgentStatus(event) }, onTerminalSideEffects: (batch) => channelOrder.push(...batch.facts.map((fact) => `fact:${fact.kind}`)) @@ -50,14 +61,17 @@ function createRuntime(): { { tabId: 'tab-1', worktreeId: WORKTREE_ID, leafId: LEAF_ID, paneRuntimeId: 1, ptyId: 'pty-1' } ] }) - return { runtime, statuses, channelOrder } + return { runtime, statuses, channelOrder, statusStore: wiring.statusStore } } +const hostRows = (statusStore: ReturnType['statusStore']): string[] => + statusStore + .getStatusSnapshot() + .filter((row) => row.paneKey === PANE_KEY && row.providerSessionOnly !== true) + .map((row) => row.state) + const summary = (statuses: RuntimeTerminalAgentStatusEvent[]): string[] => - statuses.map( - (event) => - `${event.paneKey}:${event.payload.state}${event.payload.interrupted ? ':interrupted' : ''}:${event.origin}` - ) + statuses.map((event) => `${event.paneKey}:${event.payload.state}:${event.origin}`) // `opencode run` typed in a pane: the pane's own command boundaries and foreground drive its row. describe('OpenCode run process lifetime in the runtime', () => { @@ -71,41 +85,15 @@ describe('OpenCode run process lifetime in the runtime', () => { vi.useRealTimers() }) - it('posts Working after the command starts and Done when it finishes, on its own pane', async () => { + it('posts Working after the command starts, on its own pane, and no status when it finishes', async () => { const { runtime, statuses } = createRuntime() runtime.onPtyData('pty-1', '\x1b]133;C\x07', 100) await vi.advanceTimersByTimeAsync(FOREGROUND_COMMAND_READS.settleMs) runtime.onPtyData('pty-1', 'done\x1b]133;D;0\x07', 101) - expect(summary(statuses)).toEqual([`${PANE_KEY}:working:process`, `${PANE_KEY}:done:process`]) + expect(summary(statuses)).toEqual([`${PANE_KEY}:working:process`]) expect(readCommandLineMock).toHaveBeenCalledWith('pty-1', 'opencode') - expect(statuses[0]?.yieldsToHookSince).toBe(statuses[1]?.yieldsToHookSince) - }) - - // Why: the renderer drops an exited agent's row on command-finished unless it changed after. - it("publishes the run's Done after the command-finished fact of the same chunk", async () => { - const { runtime, channelOrder } = createRuntime() - - runtime.onPtyData('pty-1', '\x1b]133;C\x07', 100) - await vi.advanceTimersByTimeAsync(FOREGROUND_COMMAND_READS.settleMs) - runtime.onPtyData('pty-1', 'done\x1b]133;D;0\x07', 101) - - expect(channelOrder).toEqual(['status:working', 'fact:command-finished', 'status:done']) - }) - - it('posts Done from the daemon fact when the pane finished while hidden', async () => { - const { runtime, statuses, channelOrder } = createRuntime() - - runtime.onPtyData('pty-1', '\x1b]133;C\x07', 100) - await vi.advanceTimersByTimeAsync(FOREGROUND_COMMAND_READS.settleMs) - runtime.emitDaemonPtyTransientFact('pty-1', { kind: 'command-finished', exitCode: 130 }) - - expect(channelOrder.slice(-2)).toEqual(['fact:command-finished', 'status:done']) - expect(summary(statuses)).toEqual([ - `${PANE_KEY}:working:process`, - `${PANE_KEY}:done:interrupted:process` - ]) }) it('stays silent for an SSH pane', async () => { @@ -131,7 +119,7 @@ describe('OpenCode run process lifetime in the runtime', () => { await vi.advanceTimersByTimeAsync(FOREGROUND_COMMAND_READS.settleMs) runtime.onPtyData('pty-1', '\x1b]133;D;0\x07', 101) - expect(summary(statuses)).toEqual([`${PANE_KEY}:working:process`, `${PANE_KEY}:done:process`]) + expect(summary(statuses)).toEqual([`${PANE_KEY}:working:process`]) } finally { if (platform) { Object.defineProperty(process, 'platform', platform) @@ -139,3 +127,103 @@ describe('OpenCode run process lifetime in the runtime', () => { } }) }) + +// Why: Done means a live TUI finished its turn. Once `opencode run` exits nothing runs in the pane, +// so its row must leave the host store every reader subscribes to, as any exited agent's does. +describe('OpenCode run exit in the host status store', () => { + beforeEach(() => { + vi.useFakeTimers() + readCommandLineMock.mockReset() + readCommandLineMock.mockResolvedValue('opencode run fix the bug') + }) + + afterEach(() => { + vi.useRealTimers() + }) + + async function startRun(): Promise> { + const wired = createRuntime() + wired.runtime.onPtyData('pty-1', '\x1b]133;C\x07', 100) + await vi.advanceTimersByTimeAsync(FOREGROUND_COMMAND_READS.settleMs) + expect(hostRows(wired.statusStore)).toEqual(['working']) + return wired + } + + it.each([ + ['exits', 0], + ['is interrupted with Ctrl+C', 130] + ])('clears the row of a visible pane whose run %s', async (_label, exitCode) => { + const { runtime, statusStore, channelOrder } = await startRun() + + runtime.onPtyData('pty-1', `done\x1b]133;D;${exitCode}\x07`, 101) + + expect(hostRows(statusStore)).toEqual([]) + expect(channelOrder).not.toContain('status:done') + expect(channelOrder.slice(-2)).toEqual([`clear:${PANE_KEY}`, 'fact:command-finished']) + }) + + it.each([ + ['exits', 0], + ['is interrupted with Ctrl+C', 130] + ])('clears the row of a hidden pane whose run %s', async (_label, exitCode) => { + const { runtime, statusStore, channelOrder } = await startRun() + + runtime.emitDaemonPtyTransientFact('pty-1', { kind: 'command-finished', exitCode }) + + expect(hostRows(statusStore)).toEqual([]) + expect(channelOrder).not.toContain('status:done') + expect(channelOrder.slice(-2)).toEqual([`clear:${PANE_KEY}`, 'fact:command-finished']) + }) + + // Why: a terminal-list refresh marks live panes as restored launch authority, and retiring that + // authority on command-finished deletes the pane's rows without telling the renderer. + it.each([ + ['visible', 'chunk'], + ['hidden', 'daemon fact'] + ])('clears the %s pane on the renderer too when a refresh had marked it', async (_l, path) => { + const { runtime, statusStore, channelOrder } = await startRun() + runtime['restoredOrchestrationAuthorityByPtyId'].set('pty-1', { + ptyId: 'pty-1', + worktreeId: WORKTREE_ID, + terminalHandle: 'term-1', + paneKey: PANE_KEY, + processIncarnation: 'pty-1:1', + hostScope: { kind: 'local', hostId: 'local' } + }) + + if (path === 'chunk') { + runtime.onPtyData('pty-1', 'done\x1b]133;D;0\x07', 101) + } else { + runtime.emitDaemonPtyTransientFact('pty-1', { kind: 'command-finished', exitCode: 0 }) + } + + expect(hostRows(statusStore)).toEqual([]) + expect(channelOrder).toContain(`clear:${PANE_KEY}`) + }) + + it('clears the row when the next command starts without the run’s command-finished', async () => { + const { runtime, statusStore, channelOrder } = await startRun() + readCommandLineMock.mockResolvedValue('ls') + + runtime.onPtyData('pty-1', '\x1b]133;C\x07', 101) + + expect(hostRows(statusStore)).toEqual([]) + expect(channelOrder).toContain(`clear:${PANE_KEY}`) + }) + + it('leaves the row of a pane whose foreground was never `opencode run` to its own agent', async () => { + readCommandLineMock.mockResolvedValue('opencode') + const { runtime, statusStore } = createRuntime() + runtime.onPtyData( + 'pty-1', + '\x1b]9999;{"state":"done","prompt":"ship it","agentType":"opencode"}\x07', + 99 + ) + runtime.onPtyData('pty-1', '\x1b]133;C\x07', 100) + await vi.advanceTimersByTimeAsync(FOREGROUND_COMMAND_READS.settleMs) + + runtime.emitDaemonPtyTransientFact('pty-1', { kind: 'command-finished', exitCode: 0 }) + + expect(hostRows(statusStore)).toEqual(['done']) + }) +}) diff --git a/src/main/runtime/opencode-run-lifetime-status.test.ts b/src/main/runtime/opencode-run-lifetime-status.test.ts index efaf0d8f0c44..29202a1f4d3e 100644 --- a/src/main/runtime/opencode-run-lifetime-status.test.ts +++ b/src/main/runtime/opencode-run-lifetime-status.test.ts @@ -14,6 +14,7 @@ function setup( } = {} ) { const published: Published[] = [] + const events: string[] = [] const readForegroundProcessName = vi.fn(async () => options.name === undefined ? 'opencode' : options.name ) @@ -27,15 +28,24 @@ function setup( (options.enabledAgents ?? ['opencode', 'opencode2']).includes(agent), readForegroundProcessName, readForegroundCommandLine, - publish: (ptyId, payload, yieldsToHookSince) => - published.push({ ptyId, payload, yieldsToHookSince }), + publish: (ptyId, payload, yieldsToHookSince) => { + published.push({ ptyId, payload, yieldsToHookSince }) + events.push(`${ptyId}:${payload.state}`) + }, + clearEndedRun: (ptyId) => events.push(`${ptyId}:cleared`), now: () => clock++ }) + // pty-1's writes and clears in order, without the pane prefix. const states = (): string[] => - published.map(({ payload }) => - payload.interrupted ? `${payload.state}:interrupted` : payload.state - ) - return { lifetime, published, states, readForegroundProcessName, readForegroundCommandLine } + events.filter((event) => event.startsWith('pty-1:')).map((event) => event.slice(6)) + return { + lifetime, + published, + events, + states, + readForegroundProcessName, + readForegroundCommandLine + } } async function settle(): Promise { @@ -51,18 +61,18 @@ describe('OpenCodeRunLifetimeStatus', () => { vi.useRealTimers() }) - it('shows Working while `opencode run` holds the foreground and Done when it exits', async () => { + it('shows Working while `opencode run` holds the foreground and clears it when it exits', async () => { const { lifetime, published, states } = setup() lifetime.onCommandStarted('pty-1') expect(states()).toEqual([]) await settle() expect(states()).toEqual(['working']) - lifetime.onCommandFinished('pty-1', 0) - expect(states()).toEqual(['working', 'done']) - expect(published.map((entry) => entry.ptyId)).toEqual(['pty-1', 'pty-1']) - expect(published.map((entry) => entry.payload.agentType)).toEqual(['opencode', 'opencode']) - // Both writes yield to a hook that reports after this command started. - expect(new Set(published.map((entry) => entry.yieldsToHookSince))).toEqual(new Set([1_000])) + lifetime.onCommandFinished('pty-1') + // Why no Done: Done means a live TUI finished its turn; the run's process has exited. + expect(states()).toEqual(['working', 'cleared']) + expect(published.map((entry) => entry.payload.agentType)).toEqual(['opencode']) + // The write yields to a hook that reports after this command started. + expect(published[0]?.yieldsToHookSince).toBe(1_000) }) it('reports `opencode2 run` as OpenCode 2', async () => { @@ -75,22 +85,23 @@ describe('OpenCodeRunLifetimeStatus', () => { expect(published[0]?.payload.agentType).toBe('opencode2') }) - it('marks a Ctrl-C exit as interrupted and any other exit as a plain Done', async () => { + it('clears each run once, so a second run in the pane shows Working again', async () => { const { lifetime, states } = setup() lifetime.onCommandStarted('pty-1') await settle() - lifetime.onCommandFinished('pty-1', 130) + lifetime.onCommandFinished('pty-1') + lifetime.onCommandFinished('pty-1') lifetime.onCommandStarted('pty-1') await settle() - lifetime.onCommandFinished('pty-1', 1) - expect(states()).toEqual(['working', 'done:interrupted', 'working', 'done']) + lifetime.onCommandFinished('pty-1') + expect(states()).toEqual(['working', 'cleared', 'working', 'cleared']) }) it('reads no argv unless the foreground process is OpenCode', async () => { const { lifetime, states, readForegroundCommandLine } = setup({ name: 'npm' }) lifetime.onCommandStarted('pty-1') await settle() - lifetime.onCommandFinished('pty-1', 0) + lifetime.onCommandFinished('pty-1') expect(readForegroundCommandLine).not.toHaveBeenCalled() expect(states()).toEqual([]) }) @@ -137,7 +148,7 @@ describe('OpenCodeRunLifetimeStatus', () => { const { lifetime, states } = setup({ commandLine }) lifetime.onCommandStarted('pty-1') await settle() - lifetime.onCommandFinished('pty-1', 0) + lifetime.onCommandFinished('pty-1') expect(states()).toEqual([]) } }) @@ -152,7 +163,7 @@ describe('OpenCodeRunLifetimeStatus', () => { it('stays silent for a command that finishes before the settle read (`&`, fast exit)', async () => { const { lifetime, states, readForegroundProcessName } = setup() lifetime.onCommandStarted('pty-1') - lifetime.onCommandFinished('pty-1', 0) + lifetime.onCommandFinished('pty-1') await settle() expect(readForegroundProcessName).not.toHaveBeenCalled() expect(states()).toEqual([]) @@ -169,7 +180,7 @@ describe('OpenCodeRunLifetimeStatus', () => { ) lifetime.onCommandStarted('pty-1') await settle() - lifetime.onCommandFinished('pty-1', 0) + lifetime.onCommandFinished('pty-1') resolveName('opencode') await vi.runAllTimersAsync() expect(states()).toEqual([]) @@ -179,7 +190,7 @@ describe('OpenCodeRunLifetimeStatus', () => { const { lifetime, states, readForegroundCommandLine } = setup({ enabledAgents: ['opencode2'] }) lifetime.onCommandStarted('pty-1') await settle() - lifetime.onCommandFinished('pty-1', 0) + lifetime.onCommandFinished('pty-1') expect(readForegroundCommandLine).not.toHaveBeenCalled() expect(states()).toEqual([]) }) @@ -195,40 +206,37 @@ describe('OpenCodeRunLifetimeStatus', () => { const { lifetime, states, readForegroundProcessName } = setup({ observable: false }) lifetime.onCommandStarted('pty-1') await settle() - lifetime.onCommandFinished('pty-1', 0) + lifetime.onCommandFinished('pty-1') expect(readForegroundProcessName).not.toHaveBeenCalled() expect(states()).toEqual([]) }) - it('ends an armed run with Done when the next command starts without its 133;D', async () => { + it('clears an armed run when the next command starts without its 133;D', async () => { const { lifetime, states } = setup() lifetime.onCommandStarted('pty-1') await settle() lifetime.onCommandStarted('pty-1') - expect(states()).toEqual(['working', 'done']) + expect(states()).toEqual(['working', 'cleared']) await settle() - expect(states()).toEqual(['working', 'done', 'working']) + expect(states()).toEqual(['working', 'cleared', 'working']) }) - it('posts no Done for a pane torn down mid-run', async () => { + // Why: the PTY's own exit already retires the pane's row through the runtime. + it('clears nothing for a pane torn down mid-run', async () => { const { lifetime, states } = setup() lifetime.onCommandStarted('pty-1') await settle() lifetime.forgetPty('pty-1') - lifetime.onCommandFinished('pty-1', 0) + lifetime.onCommandFinished('pty-1') expect(states()).toEqual(['working']) }) it('keeps each pane to its own command', async () => { - const { lifetime, published } = setup() + const { lifetime, events } = setup() lifetime.onCommandStarted('pty-1') lifetime.onCommandStarted('pty-2') await settle() - lifetime.onCommandFinished('pty-2', 0) - expect(published.map((entry) => `${entry.ptyId}:${entry.payload.state}`)).toEqual([ - 'pty-1:working', - 'pty-2:working', - 'pty-2:done' - ]) + lifetime.onCommandFinished('pty-2') + expect(events).toEqual(['pty-1:working', 'pty-2:working', 'pty-2:cleared']) }) }) diff --git a/src/main/runtime/opencode-run-lifetime-status.ts b/src/main/runtime/opencode-run-lifetime-status.ts index 304e020f31ef..eae1459a4a2e 100644 --- a/src/main/runtime/opencode-run-lifetime-status.ts +++ b/src/main/runtime/opencode-run-lifetime-status.ts @@ -8,7 +8,6 @@ import { FOREGROUND_COMMAND_READS } from '../../shared/foreground-command-settle import { isOpenCodeRunCommand } from '../../shared/opencode-headless-command' import { isShellProcess } from '../../shared/shell-process-detection' -const SIGINT_EXIT_CODE = 130 // Launchers that can still exec OpenCode after the first read (`npx`/`bunx opencode-ai run`). const OPENCODE_LAUNCHERS = new Set(['node', 'bun', 'bunx', 'npx', 'npm', 'pnpm', 'pnpx', 'yarn']) @@ -33,6 +32,8 @@ type Dependencies = { readForegroundCommandLine(ptyId: string, foregroundProcess: string): Promise /** `yieldsToHookSince`: the store drops this write once a hook reported the pane since then. */ publish(ptyId: string, payload: ParsedAgentStatusPayload, yieldsToHookSince: number): void + /** The run's process exited back to the shell: retire the pane's row as any ended agent's. */ + clearEndedRun(ptyId: string): void now(): number } @@ -45,8 +46,8 @@ type CommandState = { /** * Reports `opencode run` from its own process lifetime: Working once the pane's foreground - * command is an OpenCode `run`, Done when that command finishes. OpenCode 2's `run` loads no - * plugin, so nothing else can say which pane it runs in. + * command is an OpenCode `run`, and no row once that command finishes, since nothing runs there + * anymore. OpenCode 2's `run` loads no plugin, so nothing else can say which pane it runs in. */ export class OpenCodeRunLifetimeStatus { private readonly commands = new Map() @@ -56,7 +57,7 @@ export class OpenCodeRunLifetimeStatus { onCommandStarted(ptyId: string): void { // Why: a new command proves the armed one ended even though its 133;D never arrived. - this.onCommandFinished(ptyId, null) + this.onCommandFinished(ptyId) if ( !this.deps.isObservablePty(ptyId) || (!this.deps.isStatusEnabled('opencode') && !this.deps.isStatusEnabled('opencode2')) @@ -73,20 +74,11 @@ export class OpenCodeRunLifetimeStatus { this.scheduleInspect(ptyId, state, FOREGROUND_COMMAND_READS.settleMs, 0) } - onCommandFinished(ptyId: string, exitCode: number | null): void { + onCommandFinished(ptyId: string): void { const state = this.commands.get(ptyId) this.forgetPty(ptyId) - if (!state?.armed) { - return - } - const payload = normalizeAgentStatusPayload({ - state: 'done', - prompt: '', - agentType: state.armed, - ...(exitCode === SIGINT_EXIT_CODE ? { interrupted: true } : {}) - }) - if (payload) { - this.deps.publish(ptyId, payload, state.startedAt) + if (state?.armed) { + this.deps.clearEndedRun(ptyId) } } diff --git a/src/main/runtime/orca-runtime-emit-daemon-pty-transient-fact.ts b/src/main/runtime/orca-runtime-emit-daemon-pty-transient-fact.ts index dbbb124d34aa..4854bfb84320 100644 --- a/src/main/runtime/orca-runtime-emit-daemon-pty-transient-fact.ts +++ b/src/main/runtime/orca-runtime-emit-daemon-pty-transient-fact.ts @@ -18,12 +18,13 @@ export class OrcaRuntimeWithEmitDaemonPtyTransientFact extends OrcaRuntimeWithSc this.recordTerminalSideEffectFact(ptyId, { kind: 'bell' }) return case 'command-finished': + // Why before retiring: retirement deletes the pane's rows without telling the renderer. + this.openCodeRunLifetime.onCommandFinished(ptyId) this.retirePtyAgentLaunchAuthority(ptyId) this.recordTerminalSideEffectFact(ptyId, { kind: 'command-finished', exitCode: fact.exitCode }) - this.openCodeRunLifetime.onCommandFinished(ptyId, fact.exitCode) return case 'pr-link': this.recordTerminalSideEffectFact(ptyId, { kind: 'pr-link', link: fact.link }) diff --git a/src/main/runtime/orca-runtime-get-unpersisted-tracked-title-for-pty.ts b/src/main/runtime/orca-runtime-get-unpersisted-tracked-title-for-pty.ts index 2e6f30770b0f..77648ba7c3f5 100644 --- a/src/main/runtime/orca-runtime-get-unpersisted-tracked-title-for-pty.ts +++ b/src/main/runtime/orca-runtime-get-unpersisted-tracked-title-for-pty.ts @@ -147,9 +147,10 @@ export class OrcaRuntimeWithGetUnpersistedTrackedTitleForPty extends OrcaRuntime }, onCommandFinished: (exitCode: number | null) => { void this.recheckHookAgentPresenceForPty(ptyId) + // Why before retiring: retirement deletes the pane's rows without telling the renderer. + this.openCodeRunLifetime.onCommandFinished(ptyId) this.retirePtyAgentLaunchAuthority(ptyId) this.recordTerminalSideEffectFact(ptyId, { kind: 'command-finished', exitCode }) - this.openCodeRunLifetime.onCommandFinished(ptyId, exitCode) }, onBell: () => { this.recordTerminalSideEffectFact(ptyId, { kind: 'bell' }) @@ -175,7 +176,6 @@ export class OrcaRuntimeWithGetUnpersistedTrackedTitleForPty extends OrcaRuntime lastTitleFactAtMs: null, chunkTouchedSessionTabs: false, pendingFacts: [], - afterFacts: [], // Why: command-code facts exist only for the pty:sideEffect channel — // headless serve skips the per-chunk scrape entirely. The detector // self-arms on the Command Code banner; the spawn command (when main diff --git a/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts b/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts index 37f8121f99ae..81e4acf2e209 100644 --- a/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts +++ b/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts @@ -95,7 +95,7 @@ export class OrcaRuntimeWithPreservedBranchCleanup extends OrcaRuntimeWithTermin | null protected readonly reconcileAgentStatusForEndedProcessFn: - | ((paneKeys: Iterable) => void) + | ((paneKeys: Iterable, options?: { preserveResumeIdentity?: boolean }) => void) | null protected readonly canRecoverPersistentLocalPtysFn: () => boolean diff --git a/src/main/runtime/orca-runtime-schedule-wait-blocked-check.ts b/src/main/runtime/orca-runtime-schedule-wait-blocked-check.ts index 5f874cbc182a..f62192179251 100644 --- a/src/main/runtime/orca-runtime-schedule-wait-blocked-check.ts +++ b/src/main/runtime/orca-runtime-schedule-wait-blocked-check.ts @@ -131,26 +131,12 @@ export class OrcaRuntimeWithScheduleWaitBlockedCheck extends OrcaRuntimeWithOnPt ptyId: string, entry: RuntimePtyTitleTrackerEntry ): void { - if (entry.pendingFacts.length > 0) { - const facts = entry.pendingFacts - entry.pendingFacts = [] - this.emitTerminalSideEffectBatch(ptyId, facts) - } - const afterFacts = entry.afterFacts - entry.afterFacts = [] - for (const run of afterFacts) { - run() - } - } - - /** Runs `run` after the chunk being applied has emitted its facts, or now between chunks. */ - protected runAfterPendingTerminalSideEffectFacts(ptyId: string, run: () => void): void { - const entry = this.ptyTitleTrackersByPtyId.get(ptyId) - if (entry?.applyingChunk) { - entry.afterFacts.push(run) + if (entry.pendingFacts.length === 0) { return } - run() + const facts = entry.pendingFacts + entry.pendingFacts = [] + this.emitTerminalSideEffectBatch(ptyId, facts) } /** Feed a main-fabricated OSC title/BEL frame (agent hook spinners) through diff --git a/src/main/runtime/orca-runtime-state-fields.ts b/src/main/runtime/orca-runtime-state-fields.ts index ca95bf9dc270..ab43cd300afa 100644 --- a/src/main/runtime/orca-runtime-state-fields.ts +++ b/src/main/runtime/orca-runtime-state-fields.ts @@ -85,7 +85,10 @@ export class OrcaRuntimeWithStateFields extends OrcaRuntimeWithLinearCommands { checkHookAgentPresence?: ( paneKey: string ) => Promise<'live' | 'unverifiable' | 'exited' | null> - reconcileAgentStatusForEndedProcess?: (paneKeys: Iterable) => void + reconcileAgentStatusForEndedProcess?: ( + paneKeys: Iterable, + options?: { preserveResumeIdentity?: boolean } + ) => void canRecoverPersistentLocalPtys?: () => boolean // Why: the device registry lives on the RPC server, which is constructed with this runtime; // a closure defers the lookup past that ordering instead of inverting ownership. diff --git a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts index 1e54583435c6..ddbfde3464b5 100644 --- a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts +++ b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts @@ -179,16 +179,18 @@ export class OrcaRuntimeWithStopRequestedPtyIds extends OrcaRuntimeWithRuntimeId }, readForegroundCommandLine: (ptyId, foregroundProcess) => readLocalPtyForegroundCommandLine(ptyId, foregroundProcess), - // Why after the chunk's facts: the renderer drops an exited agent's row on command-finished - // unless the row changed after it, so the run's Done must arrive after that fact. publish: (ptyId, payload, yieldsToHookSince) => - this.runAfterPendingTerminalSideEffectFacts(ptyId, () => - this.emitTerminalAgentStatusEvents( - ptyId, - { payloads: [payload] }, - { origin: 'process', yieldsToHookSince } - ) + this.emitTerminalAgentStatusEvents( + ptyId, + { payloads: [payload] }, + { origin: 'process', yieldsToHookSince } ), + // Why the ended-process path: it clears every reader, renderer included. The shell outlived + // the run, so the pane keeps its resume identity, as for a confirmed shell foreground. + clearEndedRun: (ptyId) => + this.reconcileAgentStatusForEndedProcessFn?.(this.collectAgentStatusPaneKeysForPty(ptyId), { + preserveResumeIdentity: true + }), now: () => Date.now() }) diff --git a/src/main/runtime/runtime-terminal-state-records.ts b/src/main/runtime/runtime-terminal-state-records.ts index d3fd9a0d7b34..265bdf1712a8 100644 --- a/src/main/runtime/runtime-terminal-state-records.ts +++ b/src/main/runtime/runtime-terminal-state-records.ts @@ -112,8 +112,6 @@ export type RuntimePtyTitleTrackerEntry = { lastTitleFactAtMs: number | null chunkTouchedSessionTabs: boolean pendingFacts: TerminalSideEffectFact[] - /** Run once this chunk's facts are emitted: status that readers must see after them. */ - afterFacts: (() => void)[] commandCodeDetector: { observe: (data: string) => boolean } | null } diff --git a/src/main/startup/main-process-runtime-service.ts b/src/main/startup/main-process-runtime-service.ts index cfd0fa294020..a63c3f272007 100644 --- a/src/main/startup/main-process-runtime-service.ts +++ b/src/main/startup/main-process-runtime-service.ts @@ -118,8 +118,8 @@ export function initializeMainProcessRuntime(): OrcaRuntimeService { retireAgentHookCompatibilityAuthority: (paneKey) => agentHookServer.retirePaneAuthority(paneKey), checkHookAgentPresence: (paneKey) => agentHookServer.checkAgentPresence(paneKey), - reconcileAgentStatusForEndedProcess: (paneKeys) => - agentHookServer.reconcileEndedProcessForPaneKeys(paneKeys), + reconcileAgentStatusForEndedProcess: (paneKeys, options) => + agentHookServer.reconcileEndedProcessForPaneKeys(paneKeys, options), canRecoverPersistentLocalPtys: () => getDaemonProvider() !== null, // Why: evaluated per call, not captured — the RPC server that owns the device registry is // constructed with this runtime and does not exist yet at this point. diff --git a/src/renderer/src/components/terminal-pane/pty-connection-command-finished-late-done.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-command-finished-late-done.test.ts index 2b92e607d227..19af8539b0ef 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-command-finished-late-done.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-command-finished-late-done.test.ts @@ -129,8 +129,8 @@ function createDeps(overrides: Record = {}) { return buildPaneConnectionDeps(() => mockStoreState, overrides) } -// A process-lifetime producer (OpenCode 2 `opencode run`) posts the run's Done after the -// command-finished fact; the exited-agent drop must keep it, as it keeps a late hook Done. +// The exited-agent drop removes only the row it captured at command-finished; a late hook Done +// that changed the row after that fact is kept. describe('command-finished drop and a Done that lands after it', () => { beforeEach(() => { vi.resetModules() From 24a08833652579fb1dadb66014fbbe707f1ba74d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 23:58:42 -0700 Subject: [PATCH 2/7] fix(opencode): keep the finished notification for an `opencode run` with no row Clearing the row when `opencode run` exits removed the run's Done, and that Done was what announced the finished run: the desktop notification, the worktree unread marker and the phone push. OpenCode 2's `run` loads no plugin, so its exit is its only turn end. Without the Done a run in a hidden or background tab, or one that ended before the visible pane's first process poll, finished silently, while hook-reporting one-shots still notified through their own Done. The run detector now announces the ended run as a terminal side-effect fact (`agent-run-ended`, with Ctrl+C read as interrupted) beside the row clear, on the visible and hidden command-finished paths and when a new command supersedes the run. The renderer routes it, before the per-PTY consumer lookup, into the pane's existing hook-lane completion coordinator, so focus suppression, unread and mobile push follow the same policy as a hook Done and no status row is written. It completes only a turn that lane saw open, so an OpenCode 1 `run`, whose plugin Done already announced the turn, gets one notification. Paired clients receive the host's facts with host tab ids; they are now mapped to the mirrored pane keys the client's status rows use. Also ends the runtime's prompt lifecycle with the run, so a later prompt send is no longer verified as if the pane still held a working agent, and pins that a hook-owned run keeps its resume identity. --- .../opencode-run-lifetime-runtime.test.ts | 105 +++++++++- .../opencode-run-lifetime-status.test.ts | 54 ++++-- .../runtime/opencode-run-lifetime-status.ts | 16 +- ...-runtime-emit-daemon-pty-transient-fact.ts | 2 +- ...e-get-unpersisted-tracked-title-for-pty.ts | 2 +- .../orca-runtime-stop-requested-pty-ids.ts | 13 +- .../agent-completion-coordinator-types.ts | 2 + .../agent-completion-coordinator.ts | 1 + .../agent-completion-hook-observer.ts | 6 + .../terminal-side-effect-facts-handler.ts | 19 ++ .../agent-hook-completion-notifications.ts | 8 + .../agent-run-ended-notifications.test.ts | 182 ++++++++++++++++++ .../hooks/agent-run-ended-notifications.ts | 26 +++ .../ipc-events/runtime-client-ipc-bridge.ts | 7 +- .../remote-terminal-side-effect-batch.test.ts | 65 +++++++ .../remote-terminal-side-effect-batch.ts | 26 +++ src/shared/terminal-side-effect-facts.ts | 5 +- 17 files changed, 501 insertions(+), 38 deletions(-) create mode 100644 src/renderer/src/hooks/agent-run-ended-notifications.test.ts create mode 100644 src/renderer/src/hooks/agent-run-ended-notifications.ts create mode 100644 src/renderer/src/runtime/remote-terminal-side-effect-batch.test.ts create mode 100644 src/renderer/src/runtime/remote-terminal-side-effect-batch.ts diff --git a/src/main/runtime/opencode-run-lifetime-runtime.test.ts b/src/main/runtime/opencode-run-lifetime-runtime.test.ts index f1f28d5729bb..1baac0c843a9 100644 --- a/src/main/runtime/opencode-run-lifetime-runtime.test.ts +++ b/src/main/runtime/opencode-run-lifetime-runtime.test.ts @@ -9,6 +9,7 @@ vi.mock('./local-pty-foreground-command-line', () => ({ import { OrcaRuntimeService } from './orca-runtime' import { FOREGROUND_COMMAND_READS } from '../../shared/foreground-command-settle' import type { RuntimeTerminalAgentStatusEvent } from './runtime-terminal-contracts' +import type { TerminalSideEffectFact } from '../../shared/terminal-side-effect-facts' import { makeAgentStatusStoreWiring } from './agent-status-store-wiring.test-fixture' const WORKTREE_ID = 'repo::/worktree' @@ -19,10 +20,12 @@ function createRuntime(): { runtime: OrcaRuntimeService statuses: RuntimeTerminalAgentStatusEvent[] channelOrder: string[] + endedRunFacts: { paneKey?: string; fact: TerminalSideEffectFact }[] statusStore: ReturnType['statusStore'] } { const statuses: RuntimeTerminalAgentStatusEvent[] = [] const channelOrder: string[] = [] + const endedRunFacts: { paneKey?: string; fact: TerminalSideEffectFact }[] = [] // Why the real store: it is what the sidebar, `worktree ps` and mobile all read. const wiring = makeAgentStatusStoreWiring() wiring.statusStore.subscribePaneStatusClear((clear) => @@ -37,8 +40,14 @@ function createRuntime(): { channelOrder.push(`status:${event.payload.state}`) wiring.deps.onTerminalAgentStatus(event) }, - onTerminalSideEffects: (batch) => + onTerminalSideEffects: (batch) => { channelOrder.push(...batch.facts.map((fact) => `fact:${fact.kind}`)) + for (const fact of batch.facts) { + if (fact.kind === 'agent-run-ended') { + endedRunFacts.push({ paneKey: batch.paneKey, fact }) + } + } + } }) runtime.setPtyController({ spawn: vi.fn(), @@ -61,7 +70,7 @@ function createRuntime(): { { tabId: 'tab-1', worktreeId: WORKTREE_ID, leafId: LEAF_ID, paneRuntimeId: 1, ptyId: 'pty-1' } ] }) - return { runtime, statuses, channelOrder, statusStore: wiring.statusStore } + return { runtime, statuses, channelOrder, endedRunFacts, statusStore: wiring.statusStore } } const hostRows = (statusStore: ReturnType['statusStore']): string[] => @@ -70,6 +79,39 @@ const hostRows = (statusStore: ReturnType['statusStore']): .filter((row) => row.paneKey === PANE_KEY && row.providerSessionOnly !== true) .map((row) => row.state) +const resumeRemnants = (statusStore: ReturnType['statusStore']) => + statusStore + .getStatusSnapshot() + .filter((row) => row.paneKey === PANE_KEY && row.providerSessionOnly === true) + .map((row) => row.providerSession?.id) + +// The OpenCode 1 plugin reporting its own `run` over the hook server. +const pluginReport = ( + statusStore: ReturnType['statusStore'], + state: 'working' | 'done' +): void => + statusStore.ingestRemote( + { + paneKey: PANE_KEY, + tabId: 'tab-1', + worktreeId: WORKTREE_ID, + source: 'opencode', + hookEventName: state === 'working' ? 'session.status' : 'session.idle', + providerSession: { key: 'session_id', id: 'ses_resume' }, + payload: { state, prompt: 'fix the bug', agentType: 'opencode' } + }, + null + ) + +const endedRunFact = (exitCode: number | null) => ({ + paneKey: PANE_KEY, + fact: { + kind: 'agent-run-ended', + agentType: 'opencode', + ...(exitCode === 130 ? { interrupted: true } : {}) + } +}) + const summary = (statuses: RuntimeTerminalAgentStatusEvent[]): string[] => statuses.map((event) => `${event.paneKey}:${event.payload.state}:${event.origin}`) @@ -153,26 +195,36 @@ describe('OpenCode run exit in the host status store', () => { ['exits', 0], ['is interrupted with Ctrl+C', 130] ])('clears the row of a visible pane whose run %s', async (_label, exitCode) => { - const { runtime, statusStore, channelOrder } = await startRun() + const { runtime, statusStore, channelOrder, endedRunFacts } = await startRun() runtime.onPtyData('pty-1', `done\x1b]133;D;${exitCode}\x07`, 101) expect(hostRows(statusStore)).toEqual([]) expect(channelOrder).not.toContain('status:done') - expect(channelOrder.slice(-2)).toEqual([`clear:${PANE_KEY}`, 'fact:command-finished']) + expect(channelOrder.slice(-3)).toEqual([ + `clear:${PANE_KEY}`, + 'fact:agent-run-ended', + 'fact:command-finished' + ]) + expect(endedRunFacts).toEqual([endedRunFact(exitCode)]) }) it.each([ ['exits', 0], ['is interrupted with Ctrl+C', 130] ])('clears the row of a hidden pane whose run %s', async (_label, exitCode) => { - const { runtime, statusStore, channelOrder } = await startRun() + const { runtime, statusStore, channelOrder, endedRunFacts } = await startRun() runtime.emitDaemonPtyTransientFact('pty-1', { kind: 'command-finished', exitCode }) expect(hostRows(statusStore)).toEqual([]) expect(channelOrder).not.toContain('status:done') - expect(channelOrder.slice(-2)).toEqual([`clear:${PANE_KEY}`, 'fact:command-finished']) + expect(channelOrder.slice(-3)).toEqual([ + `clear:${PANE_KEY}`, + 'fact:agent-run-ended', + 'fact:command-finished' + ]) + expect(endedRunFacts).toEqual([endedRunFact(exitCode)]) }) // Why: a terminal-list refresh marks live panes as restored launch authority, and retiring that @@ -202,18 +254,54 @@ describe('OpenCode run exit in the host status store', () => { }) it('clears the row when the next command starts without the run’s command-finished', async () => { - const { runtime, statusStore, channelOrder } = await startRun() + const { runtime, statusStore, channelOrder, endedRunFacts } = await startRun() readCommandLineMock.mockResolvedValue('ls') runtime.onPtyData('pty-1', '\x1b]133;C\x07', 101) expect(hostRows(statusStore)).toEqual([]) expect(channelOrder).toContain(`clear:${PANE_KEY}`) + expect(endedRunFacts).toEqual([endedRunFact(null)]) + }) + + it('ends the pane’s prompt lifecycle with the run', async () => { + const { runtime } = await startRun() + const lifecycle = () => runtime['agentPromptLifecycleByPtyId'].get('pty-1')?.status + expect(lifecycle()).toBe('working') + + runtime.emitDaemonPtyTransientFact('pty-1', { kind: 'command-finished', exitCode: 0 }) + + expect(lifecycle()).toBeNull() + }) + + // OpenCode 1 `run` loads its plugin, so a hook owns the row; it still ends with the process. + it.each([ + ['visible', 'chunk'], + ['hidden', 'daemon fact'] + ])('clears a %s hook-owned run on exit and keeps its resume identity', async (_l, path) => { + const { runtime, statusStore, channelOrder } = createRuntime() + runtime.onPtyData('pty-1', '\x1b]133;C\x07', 100) + await vi.advanceTimersByTimeAsync(1) + pluginReport(statusStore, 'working') + await vi.advanceTimersByTimeAsync(FOREGROUND_COMMAND_READS.settleMs) + pluginReport(statusStore, 'done') + expect(hostRows(statusStore)).toEqual(['done']) + + if (path === 'chunk') { + runtime.onPtyData('pty-1', 'done\x1b]133;D;0\x07', 101) + } else { + runtime.emitDaemonPtyTransientFact('pty-1', { kind: 'command-finished', exitCode: 0 }) + } + + expect(hostRows(statusStore)).toEqual([]) + expect(channelOrder).toContain(`clear:${PANE_KEY}`) + // Why: the shell outlived the run, so the pane can still resume that session. + expect(resumeRemnants(statusStore)).toEqual(['ses_resume']) }) it('leaves the row of a pane whose foreground was never `opencode run` to its own agent', async () => { readCommandLineMock.mockResolvedValue('opencode') - const { runtime, statusStore } = createRuntime() + const { runtime, statusStore, endedRunFacts } = createRuntime() runtime.onPtyData( 'pty-1', '\x1b]9999;{"state":"done","prompt":"ship it","agentType":"opencode"}\x07', @@ -225,5 +313,6 @@ describe('OpenCode run exit in the host status store', () => { runtime.emitDaemonPtyTransientFact('pty-1', { kind: 'command-finished', exitCode: 0 }) expect(hostRows(statusStore)).toEqual(['done']) + expect(endedRunFacts).toEqual([]) }) }) diff --git a/src/main/runtime/opencode-run-lifetime-status.test.ts b/src/main/runtime/opencode-run-lifetime-status.test.ts index 29202a1f4d3e..fc3a27eee3b0 100644 --- a/src/main/runtime/opencode-run-lifetime-status.test.ts +++ b/src/main/runtime/opencode-run-lifetime-status.test.ts @@ -15,6 +15,7 @@ function setup( ) { const published: Published[] = [] const events: string[] = [] + const announced: { ptyId: string; agentType: string; interrupted: boolean }[] = [] const readForegroundProcessName = vi.fn(async () => options.name === undefined ? 'opencode' : options.name ) @@ -33,6 +34,7 @@ function setup( events.push(`${ptyId}:${payload.state}`) }, clearEndedRun: (ptyId) => events.push(`${ptyId}:cleared`), + announceEndedRun: (ptyId, run) => announced.push({ ptyId, ...run }), now: () => clock++ }) // pty-1's writes and clears in order, without the pane prefix. @@ -42,6 +44,7 @@ function setup( lifetime, published, events, + announced, states, readForegroundProcessName, readForegroundCommandLine @@ -67,7 +70,7 @@ describe('OpenCodeRunLifetimeStatus', () => { expect(states()).toEqual([]) await settle() expect(states()).toEqual(['working']) - lifetime.onCommandFinished('pty-1') + lifetime.onCommandFinished('pty-1', 0) // Why no Done: Done means a live TUI finished its turn; the run's process has exited. expect(states()).toEqual(['working', 'cleared']) expect(published.map((entry) => entry.payload.agentType)).toEqual(['opencode']) @@ -85,23 +88,39 @@ describe('OpenCodeRunLifetimeStatus', () => { expect(published[0]?.payload.agentType).toBe('opencode2') }) + // Why: the exit is the run's only turn end, so it carries the completion notification. + it('announces each ended run, as interrupted only for a Ctrl+C exit', async () => { + const { lifetime, announced } = setup() + lifetime.onCommandStarted('pty-1') + await settle() + lifetime.onCommandFinished('pty-1', 130) + lifetime.onCommandStarted('pty-1') + await settle() + lifetime.onCommandFinished('pty-1', 1) + expect(announced).toEqual([ + { ptyId: 'pty-1', agentType: 'opencode', interrupted: true }, + { ptyId: 'pty-1', agentType: 'opencode', interrupted: false } + ]) + }) + it('clears each run once, so a second run in the pane shows Working again', async () => { - const { lifetime, states } = setup() + const { lifetime, states, announced } = setup() lifetime.onCommandStarted('pty-1') await settle() - lifetime.onCommandFinished('pty-1') - lifetime.onCommandFinished('pty-1') + lifetime.onCommandFinished('pty-1', 0) + lifetime.onCommandFinished('pty-1', 0) lifetime.onCommandStarted('pty-1') await settle() - lifetime.onCommandFinished('pty-1') + lifetime.onCommandFinished('pty-1', 0) expect(states()).toEqual(['working', 'cleared', 'working', 'cleared']) + expect(announced).toHaveLength(2) }) it('reads no argv unless the foreground process is OpenCode', async () => { const { lifetime, states, readForegroundCommandLine } = setup({ name: 'npm' }) lifetime.onCommandStarted('pty-1') await settle() - lifetime.onCommandFinished('pty-1') + lifetime.onCommandFinished('pty-1', 0) expect(readForegroundCommandLine).not.toHaveBeenCalled() expect(states()).toEqual([]) }) @@ -145,11 +164,12 @@ describe('OpenCodeRunLifetimeStatus', () => { it('leaves the OpenCode TUI and its other subcommands to their own reporters', async () => { for (const commandLine of ['opencode', 'opencode serve', 'opencode attach http://x', null]) { - const { lifetime, states } = setup({ commandLine }) + const { lifetime, states, announced } = setup({ commandLine }) lifetime.onCommandStarted('pty-1') await settle() - lifetime.onCommandFinished('pty-1') + lifetime.onCommandFinished('pty-1', 0) expect(states()).toEqual([]) + expect(announced).toEqual([]) } }) @@ -161,12 +181,13 @@ describe('OpenCodeRunLifetimeStatus', () => { }) it('stays silent for a command that finishes before the settle read (`&`, fast exit)', async () => { - const { lifetime, states, readForegroundProcessName } = setup() + const { lifetime, states, announced, readForegroundProcessName } = setup() lifetime.onCommandStarted('pty-1') - lifetime.onCommandFinished('pty-1') + lifetime.onCommandFinished('pty-1', 0) await settle() expect(readForegroundProcessName).not.toHaveBeenCalled() expect(states()).toEqual([]) + expect(announced).toEqual([]) }) it('never arms from a read that a newer command superseded', async () => { @@ -180,7 +201,7 @@ describe('OpenCodeRunLifetimeStatus', () => { ) lifetime.onCommandStarted('pty-1') await settle() - lifetime.onCommandFinished('pty-1') + lifetime.onCommandFinished('pty-1', 0) resolveName('opencode') await vi.runAllTimersAsync() expect(states()).toEqual([]) @@ -190,7 +211,7 @@ describe('OpenCodeRunLifetimeStatus', () => { const { lifetime, states, readForegroundCommandLine } = setup({ enabledAgents: ['opencode2'] }) lifetime.onCommandStarted('pty-1') await settle() - lifetime.onCommandFinished('pty-1') + lifetime.onCommandFinished('pty-1', 0) expect(readForegroundCommandLine).not.toHaveBeenCalled() expect(states()).toEqual([]) }) @@ -206,17 +227,18 @@ describe('OpenCodeRunLifetimeStatus', () => { const { lifetime, states, readForegroundProcessName } = setup({ observable: false }) lifetime.onCommandStarted('pty-1') await settle() - lifetime.onCommandFinished('pty-1') + lifetime.onCommandFinished('pty-1', 0) expect(readForegroundProcessName).not.toHaveBeenCalled() expect(states()).toEqual([]) }) it('clears an armed run when the next command starts without its 133;D', async () => { - const { lifetime, states } = setup() + const { lifetime, states, announced } = setup() lifetime.onCommandStarted('pty-1') await settle() lifetime.onCommandStarted('pty-1') expect(states()).toEqual(['working', 'cleared']) + expect(announced).toEqual([{ ptyId: 'pty-1', agentType: 'opencode', interrupted: false }]) await settle() expect(states()).toEqual(['working', 'cleared', 'working']) }) @@ -227,7 +249,7 @@ describe('OpenCodeRunLifetimeStatus', () => { lifetime.onCommandStarted('pty-1') await settle() lifetime.forgetPty('pty-1') - lifetime.onCommandFinished('pty-1') + lifetime.onCommandFinished('pty-1', 0) expect(states()).toEqual(['working']) }) @@ -236,7 +258,7 @@ describe('OpenCodeRunLifetimeStatus', () => { lifetime.onCommandStarted('pty-1') lifetime.onCommandStarted('pty-2') await settle() - lifetime.onCommandFinished('pty-2') + lifetime.onCommandFinished('pty-2', 0) expect(events).toEqual(['pty-1:working', 'pty-2:working', 'pty-2:cleared']) }) }) diff --git a/src/main/runtime/opencode-run-lifetime-status.ts b/src/main/runtime/opencode-run-lifetime-status.ts index eae1459a4a2e..bdb69534e65b 100644 --- a/src/main/runtime/opencode-run-lifetime-status.ts +++ b/src/main/runtime/opencode-run-lifetime-status.ts @@ -8,6 +8,7 @@ import { FOREGROUND_COMMAND_READS } from '../../shared/foreground-command-settle import { isOpenCodeRunCommand } from '../../shared/opencode-headless-command' import { isShellProcess } from '../../shared/shell-process-detection' +const SIGINT_EXIT_CODE = 130 // Launchers that can still exec OpenCode after the first read (`npx`/`bunx opencode-ai run`). const OPENCODE_LAUNCHERS = new Set(['node', 'bun', 'bunx', 'npx', 'npm', 'pnpm', 'pnpx', 'yarn']) @@ -34,6 +35,8 @@ type Dependencies = { publish(ptyId: string, payload: ParsedAgentStatusPayload, yieldsToHookSince: number): void /** The run's process exited back to the shell: retire the pane's row as any ended agent's. */ clearEndedRun(ptyId: string): void + /** The exit is the run's only turn end, so it is what completion notifications announce. */ + announceEndedRun(ptyId: string, run: { agentType: OpenCodeAgent; interrupted: boolean }): void now(): number } @@ -57,7 +60,7 @@ export class OpenCodeRunLifetimeStatus { onCommandStarted(ptyId: string): void { // Why: a new command proves the armed one ended even though its 133;D never arrived. - this.onCommandFinished(ptyId) + this.onCommandFinished(ptyId, null) if ( !this.deps.isObservablePty(ptyId) || (!this.deps.isStatusEnabled('opencode') && !this.deps.isStatusEnabled('opencode2')) @@ -74,12 +77,17 @@ export class OpenCodeRunLifetimeStatus { this.scheduleInspect(ptyId, state, FOREGROUND_COMMAND_READS.settleMs, 0) } - onCommandFinished(ptyId: string): void { + onCommandFinished(ptyId: string, exitCode: number | null): void { const state = this.commands.get(ptyId) this.forgetPty(ptyId) - if (state?.armed) { - this.deps.clearEndedRun(ptyId) + if (!state?.armed) { + return } + this.deps.clearEndedRun(ptyId) + this.deps.announceEndedRun(ptyId, { + agentType: state.armed, + interrupted: exitCode === SIGINT_EXIT_CODE + }) } forgetPty(ptyId: string): void { diff --git a/src/main/runtime/orca-runtime-emit-daemon-pty-transient-fact.ts b/src/main/runtime/orca-runtime-emit-daemon-pty-transient-fact.ts index 4854bfb84320..9d948c51caea 100644 --- a/src/main/runtime/orca-runtime-emit-daemon-pty-transient-fact.ts +++ b/src/main/runtime/orca-runtime-emit-daemon-pty-transient-fact.ts @@ -19,7 +19,7 @@ export class OrcaRuntimeWithEmitDaemonPtyTransientFact extends OrcaRuntimeWithSc return case 'command-finished': // Why before retiring: retirement deletes the pane's rows without telling the renderer. - this.openCodeRunLifetime.onCommandFinished(ptyId) + this.openCodeRunLifetime.onCommandFinished(ptyId, fact.exitCode) this.retirePtyAgentLaunchAuthority(ptyId) this.recordTerminalSideEffectFact(ptyId, { kind: 'command-finished', diff --git a/src/main/runtime/orca-runtime-get-unpersisted-tracked-title-for-pty.ts b/src/main/runtime/orca-runtime-get-unpersisted-tracked-title-for-pty.ts index 77648ba7c3f5..0dfd8888d447 100644 --- a/src/main/runtime/orca-runtime-get-unpersisted-tracked-title-for-pty.ts +++ b/src/main/runtime/orca-runtime-get-unpersisted-tracked-title-for-pty.ts @@ -148,7 +148,7 @@ export class OrcaRuntimeWithGetUnpersistedTrackedTitleForPty extends OrcaRuntime onCommandFinished: (exitCode: number | null) => { void this.recheckHookAgentPresenceForPty(ptyId) // Why before retiring: retirement deletes the pane's rows without telling the renderer. - this.openCodeRunLifetime.onCommandFinished(ptyId) + this.openCodeRunLifetime.onCommandFinished(ptyId, exitCode) this.retirePtyAgentLaunchAuthority(ptyId) this.recordTerminalSideEffectFact(ptyId, { kind: 'command-finished', exitCode }) }, diff --git a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts index ddbfde3464b5..29277abe18ee 100644 --- a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts +++ b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts @@ -186,10 +186,19 @@ export class OrcaRuntimeWithStopRequestedPtyIds extends OrcaRuntimeWithRuntimeId { origin: 'process', yieldsToHookSince } ), // Why the ended-process path: it clears every reader, renderer included. The shell outlived - // the run, so the pane keeps its resume identity, as for a confirmed shell foreground. - clearEndedRun: (ptyId) => + // the run, so a row that carries a resume identity (a hook-owned OpenCode 1 run) keeps it. + clearEndedRun: (ptyId) => { + // Why: while this reads `working`, prompt-send verification takes any output as delivery. + this.recordAgentPromptLifecycleState(ptyId, null) this.reconcileAgentStatusForEndedProcessFn?.(this.collectAgentStatusPaneKeysForPty(ptyId), { preserveResumeIdentity: true + }) + }, + announceEndedRun: (ptyId, run) => + this.recordTerminalSideEffectFact(ptyId, { + kind: 'agent-run-ended', + agentType: run.agentType, + ...(run.interrupted ? { interrupted: true } : {}) }), now: () => Date.now() }) diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-types.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-types.ts index c56e77b5bc7b..c0c34cdd2a45 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-types.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-types.ts @@ -65,6 +65,8 @@ export type AgentCompletionCoordinator = { observeTitleWorking: () => void observeOutputActivity: () => void observeHookStatus: (payload: AgentCompletionStatusSnapshot) => void + /** A run reported only by its process lifetime ended: a `done` for the turn still open, if any. */ + observeAgentRunEnded: (payload: AgentCompletionStatusSnapshot) => void seedHookStatus: (payload: AgentCompletionStatusSnapshot) => void startProcessTracking: () => void /** Another reader saw this agent in the foreground; lets the monitor confirm its exit. */ diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts index 24df1dce1aea..50e644d85744 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts @@ -315,6 +315,7 @@ export function createAgentCompletionCoordinator( observeTitleWorking, observeOutputActivity, observeHookStatus: hookObserver.observeHookStatus, + observeAgentRunEnded: hookObserver.observeAgentRunEnded, seedHookStatus: hookObserver.seedHookStatus, startProcessTracking: () => processMonitor.start(), observeForegroundAgentProcess: processMonitor.observeRecognizedProcess, diff --git a/src/renderer/src/components/terminal-pane/agent-completion-hook-observer.ts b/src/renderer/src/components/terminal-pane/agent-completion-hook-observer.ts index 698747a612eb..95269e8bbc5a 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-hook-observer.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-hook-observer.ts @@ -198,6 +198,12 @@ export function createAgentCompletionHookObserver({ return { observeHookStatus, + observeAgentRunEnded: (payload: AgentCompletionStatusSnapshot) => { + // Why: a run's exit completes only a turn this lane saw open; a hook Done may have announced it. + if (state.workingStatusObserved) { + observeHookStatus(payload) + } + }, seedHookStatus: (payload: AgentCompletionStatusSnapshot) => { const { turnCompletedAt, ...unstampedPayload } = payload if (isFiniteTurnCompletedAt(turnCompletedAt)) { diff --git a/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts index 527fbb46b95c..d55932ac2f10 100644 --- a/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts +++ b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts @@ -19,6 +19,7 @@ import type { TerminalSideEffectBatch, TerminalSideEffectFact } from '../../../../shared/terminal-side-effect-facts' +import { observeEndedAgentRunForNotification } from '@/hooks/agent-run-ended-notifications' // Why: cached once per session — the blocking read should only ever run on // the pre-hydration startup path, never per pane bind. @@ -257,7 +258,25 @@ function drainHandoffFactBuffer(ptyId: string, entry: ConsumerEntry): void { } } +// Why before the consumer lookup: the run's end must notify like a hook Done does, in panes +// that have no fact consumer (unmounted, never shown, or a visible remote-runtime pane). +function announceEndedAgentRuns(batch: TerminalSideEffectBatch): void { + if (batch.replay || !batch.paneKey) { + return + } + for (const fact of batch.facts) { + if (fact.kind === 'agent-run-ended') { + observeEndedAgentRunForNotification({ + paneKey: batch.paneKey, + agentType: fact.agentType, + interrupted: fact.interrupted === true + }) + } + } +} + export function dispatchTerminalSideEffectBatch(batch: TerminalSideEffectBatch): void { + announceEndedAgentRuns(batch) const entry = consumersByPtyId.get(batch.ptyId) if (!entry) { bufferHandoffFactBatch(batch) diff --git a/src/renderer/src/hooks/agent-hook-completion-notifications.ts b/src/renderer/src/hooks/agent-hook-completion-notifications.ts index 8b43478818c8..65337997a8cf 100644 --- a/src/renderer/src/hooks/agent-hook-completion-notifications.ts +++ b/src/renderer/src/hooks/agent-hook-completion-notifications.ts @@ -320,6 +320,14 @@ export function observeAgentHookCompletionForNotification({ } } +/** The coordinator a live pane's hook lane already has; never creates one. */ +export function getLiveAgentHookCompletionCoordinator( + paneKey: string +): AgentCompletionCoordinator | null { + const entry = coordinatorsByPaneKey.get(paneKey) + return entry && paneCanReceiveHookCompletion(paneKey) ? entry.coordinator : null +} + export function resetAgentHookCompletionNotificationCoordinators(): void { for (const entry of coordinatorsByPaneKey.values()) { entry.coordinator.dispose() diff --git a/src/renderer/src/hooks/agent-run-ended-notifications.test.ts b/src/renderer/src/hooks/agent-run-ended-notifications.test.ts new file mode 100644 index 000000000000..73f7602b1d68 --- /dev/null +++ b/src/renderer/src/hooks/agent-run-ended-notifications.test.ts @@ -0,0 +1,182 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ParsedAgentStatusPayload } from '../../../shared/agent-status-types' +import type { TerminalSideEffectBatch } from '../../../shared/terminal-side-effect-facts' + +const dispatchTerminalNotification = vi.fn() +const dispatchAgentHookTerminalLifecycle = vi.fn() + +let mockStoreState: { + settings: { notifications: { enabled: boolean; agentTaskComplete: boolean } } + ptyIdsByTabId: Record + suppressedPtyExitIds: Record + tabsByWorktree: Record + terminalLayoutsByTabId: Record + agentStatusByPaneKey: Record +} + +vi.mock('@/store', () => ({ + useAppStore: { getState: () => mockStoreState } +})) + +vi.mock('@/components/terminal-pane/use-notification-dispatch', () => ({ + dispatchTerminalNotification +})) + +vi.mock('@/components/terminal-pane/agent-hook-terminal-lifecycle', () => ({ + dispatchAgentHookTerminalLifecycle +})) + +const HOOK_DONE_QUIET_MS = 1_500 +const PANE_KEY = 'tab-1:11111111-1111-4111-8111-111111111111' + +function hookStatus( + state: ParsedAgentStatusPayload['state'], + agentType: string +): ParsedAgentStatusPayload { + return { state, prompt: 'fix the bug', agentType } +} + +function endedRunBatch( + agentType: string, + options: { interrupted?: true; replay?: true } = {} +): TerminalSideEffectBatch { + return { + ptyId: 'pty-1', + seq: 1, + paneKey: PANE_KEY, + worktreeId: 'wt-1', + tabId: 'tab-1', + ...(options.replay ? { replay: true } : {}), + facts: [ + { + kind: 'agent-run-ended', + agentType, + ...(options.interrupted ? { interrupted: true } : {}) + }, + { kind: 'command-finished', exitCode: options.interrupted ? 130 : 0 } + ] + } +} + +async function load() { + const notifications = await import('./agent-hook-completion-notifications') + const facts = await import('@/components/terminal-pane/terminal-side-effect-facts-handler') + const observe = (payload: ParsedAgentStatusPayload): void => + notifications.observeAgentHookCompletionForNotification({ + paneKey: PANE_KEY, + worktreeId: 'wt-1', + payload + }) + return { observe, dispatch: facts._dispatchTerminalSideEffectBatchForTest } +} + +// Why: `opencode run` posts no Done (its process exit clears the row), so its end reaches the +// renderer only as a side-effect fact; it must notify like a hook Done, with no consumer bound. +describe('ended agent run notifications', () => { + beforeEach(() => { + vi.resetModules() + vi.useFakeTimers() + dispatchTerminalNotification.mockClear() + dispatchAgentHookTerminalLifecycle.mockClear() + mockStoreState = { + settings: { notifications: { enabled: true, agentTaskComplete: true } }, + ptyIdsByTabId: { 'tab-1': ['pty-1'] }, + suppressedPtyExitIds: {}, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'pty-1' }] }, + terminalLayoutsByTabId: {}, + agentStatusByPaneKey: {} + } + }) + + afterEach(() => vi.useRealTimers()) + + it('announces a finished run in a pane with no fact consumer, after the quiet window', async () => { + const { observe, dispatch } = await load() + observe(hookStatus('working', 'opencode2')) + + dispatch(endedRunBatch('opencode2')) + expect(dispatchTerminalNotification).not.toHaveBeenCalled() + vi.advanceTimersByTime(HOOK_DONE_QUIET_MS) + + expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1) + expect(dispatchTerminalNotification).toHaveBeenCalledWith( + 'wt-1', + expect.objectContaining({ + source: 'agent-task-complete', + paneKey: PANE_KEY, + agentStatusSnapshot: expect.objectContaining({ state: 'done', agentType: 'opencode2' }) + }) + ) + }) + + it('reads a Ctrl+C exit as an interrupted turn', async () => { + const { observe, dispatch } = await load() + observe(hookStatus('working', 'opencode2')) + + dispatch(endedRunBatch('opencode2', { interrupted: true })) + vi.advanceTimersByTime(HOOK_DONE_QUIET_MS) + + expect(dispatchTerminalNotification).toHaveBeenCalledWith( + 'wt-1', + expect.objectContaining({ + agentStatusSnapshot: expect.objectContaining({ state: 'done', interrupted: true }) + }) + ) + }) + + it('announces nothing for a pane that never showed the run working', async () => { + const { dispatch } = await load() + + dispatch(endedRunBatch('opencode2')) + vi.advanceTimersByTime(HOOK_DONE_QUIET_MS) + + expect(dispatchTerminalNotification).not.toHaveBeenCalled() + }) + + it('never announces from a replayed batch', async () => { + const { observe, dispatch } = await load() + observe(hookStatus('working', 'opencode2')) + + dispatch(endedRunBatch('opencode2', { replay: true })) + vi.advanceTimersByTime(HOOK_DONE_QUIET_MS) + + expect(dispatchTerminalNotification).not.toHaveBeenCalled() + }) + + // OpenCode 1 `run` loads its plugin, whose own Done announces the turn before the process exits. + it('adds nothing when the plugin Done already announced the run', async () => { + const { observe, dispatch } = await load() + observe(hookStatus('working', 'opencode')) + observe(hookStatus('done', 'opencode')) + vi.advanceTimersByTime(HOOK_DONE_QUIET_MS) + expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1) + + dispatch(endedRunBatch('opencode')) + vi.advanceTimersByTime(HOOK_DONE_QUIET_MS) + + expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1) + }) + + it('announces once when the run exits inside the plugin Done’s quiet window', async () => { + const { observe, dispatch } = await load() + observe(hookStatus('working', 'opencode')) + observe(hookStatus('done', 'opencode')) + vi.advanceTimersByTime(HOOK_DONE_QUIET_MS / 2) + + dispatch(endedRunBatch('opencode')) + vi.advanceTimersByTime(HOOK_DONE_QUIET_MS * 2) + + expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1) + }) + + it('lets a new turn in the pane cancel the pending announcement', async () => { + const { observe, dispatch } = await load() + observe(hookStatus('working', 'opencode2')) + + dispatch(endedRunBatch('opencode2')) + observe(hookStatus('working', 'opencode2')) + vi.advanceTimersByTime(HOOK_DONE_QUIET_MS) + + expect(dispatchTerminalNotification).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/hooks/agent-run-ended-notifications.ts b/src/renderer/src/hooks/agent-run-ended-notifications.ts new file mode 100644 index 000000000000..9e57b428876c --- /dev/null +++ b/src/renderer/src/hooks/agent-run-ended-notifications.ts @@ -0,0 +1,26 @@ +import { + getLiveAgentHookCompletionCoordinator, + syncAgentHookCompletionNotificationSettings +} from './agent-hook-completion-notifications' + +/** Completes the turn of a run reported only by its process lifetime (`opencode run`), which + * posts no Done: its exit is the turn end. Writes no status row. */ +export function observeEndedAgentRunForNotification({ + paneKey, + agentType, + interrupted +}: { + paneKey: string + agentType: string + interrupted: boolean +}): void { + syncAgentHookCompletionNotificationSettings() + // Why: only a lane that saw the run's Working has a turn to complete. + getLiveAgentHookCompletionCoordinator(paneKey)?.observeAgentRunEnded({ + state: 'done', + prompt: '', + agentType, + stateStartedAt: Date.now(), + ...(interrupted ? { interrupted: true } : {}) + }) +} diff --git a/src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge.ts index ae014d702300..88df1059b731 100644 --- a/src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge.ts @@ -9,7 +9,7 @@ import { refreshRuntimeEnvironmentSshTargetMetadata } from '@/runtime/runtime-environment-ssh-state' import { subscribeRuntimeClientEvents } from '@/runtime/runtime-client-events' -import { toRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream' +import { toClientTerminalSideEffectBatch } from '@/runtime/remote-terminal-side-effect-batch' import { getEnvironmentSshStateGeneration } from '@/store/slices/runtime-environment-ssh' import { getRuntimeEnvironmentConnectionGeneration } from '@/store/slices/runtime-status' import { toRuntimeExecutionHostId } from '../../../../shared/execution-host' @@ -82,10 +82,7 @@ export function registerRuntimeClientIpcBridge( return } if (event.type === 'terminalSideEffects') { - dispatchTerminalSideEffectBatch({ - ...event.batch, - ptyId: toRemoteRuntimePtyId(event.batch.ptyId, environmentId) - }) + dispatchTerminalSideEffectBatch(toClientTerminalSideEffectBatch(event.batch, environmentId)) return } if (event.type === 'nativeChatLaunchDraftResolved') { diff --git a/src/renderer/src/runtime/remote-terminal-side-effect-batch.test.ts b/src/renderer/src/runtime/remote-terminal-side-effect-batch.test.ts new file mode 100644 index 000000000000..3cd27a822d19 --- /dev/null +++ b/src/renderer/src/runtime/remote-terminal-side-effect-batch.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest' +import { makePaneKey } from '../../../shared/stable-pane-id' +import { toRemoteRuntimePtyId } from '../../../shared/remote-runtime-pty-id' +import { toWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id' +import { remapHostAgentStatus } from './web-session-tabs-sync/agent-status-primitives' +import { + toClientTerminalSideEffectBatch, + toMirroredHostPaneKey +} from './remote-terminal-side-effect-batch' + +const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const HOST_PANE_KEY = makePaneKey('host-tab', LEAF_ID) + +// Why: a host fact's completion must reach the coordinator that this client's mirrored status +// row for the same pane feeds, so both must name the pane alike. +describe('remote terminal side-effect batches', () => { + it('maps a host pane key to the key of the mirrored status row for that pane', () => { + const mirrored = remapHostAgentStatus({ + type: 'terminal', + id: `host-tab::${LEAF_ID}`, + title: 'Terminal', + parentTabId: 'host-tab', + leafId: LEAF_ID, + isActive: true, + agentStatus: { + state: 'working', + prompt: '', + agentType: 'opencode2', + paneKey: HOST_PANE_KEY, + updatedAt: 1, + stateStartedAt: 1, + stateHistory: [] + } + }) + + expect(toMirroredHostPaneKey(HOST_PANE_KEY)).toBe(mirrored?.paneKey) + }) + + it('maps no key that names no terminal leaf', () => { + expect(toMirroredHostPaneKey('host-tab:1')).toBeNull() + }) + + it('names the batch’s PTY, tab and pane in this client’s ids', () => { + expect( + toClientTerminalSideEffectBatch( + { + ptyId: 'host-pty', + seq: 3, + worktreeId: 'wt-1', + tabId: 'host-tab', + paneKey: HOST_PANE_KEY, + facts: [{ kind: 'agent-run-ended', agentType: 'opencode2' }] + }, + 'env-1' + ) + ).toEqual({ + ptyId: toRemoteRuntimePtyId('host-pty', 'env-1'), + seq: 3, + worktreeId: 'wt-1', + tabId: toWebTerminalSurfaceTabId('host-tab'), + paneKey: toMirroredHostPaneKey(HOST_PANE_KEY), + facts: [{ kind: 'agent-run-ended', agentType: 'opencode2' }] + }) + }) +}) diff --git a/src/renderer/src/runtime/remote-terminal-side-effect-batch.ts b/src/renderer/src/runtime/remote-terminal-side-effect-batch.ts new file mode 100644 index 000000000000..2e5e42decb40 --- /dev/null +++ b/src/renderer/src/runtime/remote-terminal-side-effect-batch.ts @@ -0,0 +1,26 @@ +import { toRemoteRuntimePtyId } from '../../../shared/remote-runtime-pty-id' +import { makePaneKey, parsePaneKey } from '../../../shared/stable-pane-id' +import { toWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id' +import type { TerminalSideEffectBatch } from '../../../shared/terminal-side-effect-facts' + +/** A host pane key (`hostTabId:leafId`) as this client's key for the same mirrored pane. */ +export function toMirroredHostPaneKey(hostPaneKey: string): string | null { + const parsed = parsePaneKey(hostPaneKey) + return parsed ? makePaneKey(toWebTerminalSurfaceTabId(parsed.tabId), parsed.leafId) : null +} + +/** A remote host's batch in this client's ids. The host attributes it to host tabs; this client + * keys the same panes by their mirrors, as it does the host's status rows. */ +export function toClientTerminalSideEffectBatch( + batch: TerminalSideEffectBatch, + environmentId: string +): TerminalSideEffectBatch { + const { paneKey, tabId, ...rest } = batch + const mirroredPaneKey = paneKey ? toMirroredHostPaneKey(paneKey) : null + return { + ...rest, + ptyId: toRemoteRuntimePtyId(batch.ptyId, environmentId), + ...(mirroredPaneKey ? { paneKey: mirroredPaneKey } : {}), + ...(tabId ? { tabId: toWebTerminalSurfaceTabId(tabId) } : {}) + } +} diff --git a/src/shared/terminal-side-effect-facts.ts b/src/shared/terminal-side-effect-facts.ts index 47c416aff5d2..e2b643ba5193 100644 --- a/src/shared/terminal-side-effect-facts.ts +++ b/src/shared/terminal-side-effect-facts.ts @@ -5,7 +5,7 @@ * renderer store handler owns notification/unread policy. */ -import type { ParsedAgentStatusPayload } from './agent-status-types' +import type { AgentType, ParsedAgentStatusPayload } from './agent-status-types' import type { TerminalGitHubPRLink } from './terminal-github-pr-link-detector' /** Why tagged: stale-clear facts come from main's unthrottled 3s timer, not @@ -21,6 +21,9 @@ export type TerminalSideEffectFact = | { kind: 'agent-exited' } /** OSC 133;D — foreground shell command exited (exit code best-effort). */ | { kind: 'command-finished'; exitCode: number | null } + /** An agent run reported only by its process lifetime (`opencode run`) ended. Its row is + * already gone; the exit is the turn's only end, so the renderer announces completion. */ + | { kind: 'agent-run-ended'; agentType: AgentType; interrupted?: true } /** Carries the parsed link so the renderer store consumer never re-parses * the URL (parse drift would break the per-PTY dedupe contract). */ | { kind: 'pr-link'; link: TerminalGitHubPRLink } From 54f85b1b7a86cb7cc79daa1991196d44623c07f6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 00:02:05 -0700 Subject: [PATCH 3/7] test(opencode): pin a late run exit after the plugin Done, and the mirror tab shape The OpenCode 1 dedupe test now lets the process exit two seconds after the plugin's Done was announced, the case that would notify twice without the open-turn guard. The mirrored pane-key test builds a ready terminal surface so it typechecks. --- src/renderer/src/hooks/agent-run-ended-notifications.test.ts | 2 ++ .../src/runtime/remote-terminal-side-effect-batch.test.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/renderer/src/hooks/agent-run-ended-notifications.test.ts b/src/renderer/src/hooks/agent-run-ended-notifications.test.ts index 73f7602b1d68..f324df7a0d36 100644 --- a/src/renderer/src/hooks/agent-run-ended-notifications.test.ts +++ b/src/renderer/src/hooks/agent-run-ended-notifications.test.ts @@ -150,6 +150,8 @@ describe('ended agent run notifications', () => { observe(hookStatus('done', 'opencode')) vi.advanceTimersByTime(HOOK_DONE_QUIET_MS) expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1) + // The process tears down after its plugin reported idle. + vi.advanceTimersByTime(2_000) dispatch(endedRunBatch('opencode')) vi.advanceTimersByTime(HOOK_DONE_QUIET_MS) diff --git a/src/renderer/src/runtime/remote-terminal-side-effect-batch.test.ts b/src/renderer/src/runtime/remote-terminal-side-effect-batch.test.ts index 3cd27a822d19..f982273f0eb0 100644 --- a/src/renderer/src/runtime/remote-terminal-side-effect-batch.test.ts +++ b/src/renderer/src/runtime/remote-terminal-side-effect-batch.test.ts @@ -22,6 +22,8 @@ describe('remote terminal side-effect batches', () => { parentTabId: 'host-tab', leafId: LEAF_ID, isActive: true, + status: 'ready', + terminal: 'host-terminal', agentStatus: { state: 'working', prompt: '', From 7b5342c09081b3d9cbccafdf823fb3306187b3a8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 00:22:18 -0700 Subject: [PATCH 4/7] test(opencode): pin one notification across the hook and pane lanes A visible pane's own process monitor also completes the run when the process exits. Pin that the ended-run announcement and that process-exit completion share the pane's completion identity, so whichever fires first, the other adds nothing. --- .../agent-run-ended-cross-lane.test.ts | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 src/renderer/src/components/terminal-pane/agent-run-ended-cross-lane.test.ts diff --git a/src/renderer/src/components/terminal-pane/agent-run-ended-cross-lane.test.ts b/src/renderer/src/components/terminal-pane/agent-run-ended-cross-lane.test.ts new file mode 100644 index 000000000000..20f0b697aa9a --- /dev/null +++ b/src/renderer/src/components/terminal-pane/agent-run-ended-cross-lane.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it, vi } from 'vitest' +import { createAgentCompletionCoordinator } from './agent-completion-coordinator' +import { + processResult, + useAgentCompletionCoordinatorLifecycle +} from './agent-completion-coordinator-test-harness' + +const PANE_KEY = 'tab-1:11111111-1111-4111-8111-111111111111' + +// A visible pane polling its foreground, beside the hook lane that announces the run's end. +function createLanes() { + let foreground: string | null = 'opencode' + const paneDispatch = vi.fn() + const hookDispatch = vi.fn() + const paneLane = createAgentCompletionCoordinator({ + paneKey: PANE_KEY, + statusLane: 'pty', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(async () => processResult(foreground)), + dispatchCompletion: paneDispatch, + isLive: () => true, + shouldPollProcessCadence: () => true + }) + paneLane.startProcessTracking() + const hookLane = createAgentCompletionCoordinator({ + paneKey: PANE_KEY, + statusLane: 'hook', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(async () => processResult(null)), + dispatchCompletion: hookDispatch, + isLive: () => true + }) + hookLane.observeHookStatus({ + state: 'working', + prompt: '', + agentType: 'opencode', + stateStartedAt: Date.now() + }) + return { + paneDispatch, + hookDispatch, + exitProcess: () => { + foreground = null + }, + announceEnd: () => + hookLane.observeAgentRunEnded({ + state: 'done', + prompt: '', + agentType: 'opencode', + stateStartedAt: Date.now() + }) + } +} + +// Why: the pane's own process-exit completion shares the per-pane identity with the hook lane, +// so whichever lane announces the run first, the other adds nothing. +describe('ended-run announcement beside the pane process monitor', () => { + useAgentCompletionCoordinatorLifecycle() + + it('keeps the process-exit completion silent after the run’s end was announced', async () => { + const lanes = createLanes() + await vi.advanceTimersByTimeAsync(10_000) + + lanes.exitProcess() + lanes.announceEnd() + await vi.advanceTimersByTimeAsync(10_000) + + expect(lanes.hookDispatch).toHaveBeenCalledTimes(1) + expect(lanes.paneDispatch).not.toHaveBeenCalled() + }) + + it('announces nothing more when the process-exit completion came first', async () => { + const lanes = createLanes() + await vi.advanceTimersByTimeAsync(10_000) + + lanes.exitProcess() + await vi.advanceTimersByTimeAsync(10_000) + expect(lanes.paneDispatch).toHaveBeenCalledTimes(1) + lanes.announceEnd() + await vi.advanceTimersByTimeAsync(10_000) + + expect(lanes.hookDispatch).not.toHaveBeenCalled() + }) +}) From ab541d5c7337719271d1d24f927b2dd6286bdce7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 00:43:24 -0700 Subject: [PATCH 5/7] fix(opencode): keep the plugin Done's notification when a `run` exits in its quiet window An OpenCode 1 `run` loads its plugin, which posts its own Done with the prompt and the assistant's last message. The process exits right after, so the run-ended announcement usually lands inside that Done's 1.5 s quiet window. The open-turn guard let it through, and the pending payload was swapped for the announcement's empty one: the notification body lost its message preview ("OpenCode finished." instead), and the notification id moved to the renderer's clock, so acknowledging the row no longer cleared it. The run's end now completes a turn only while no hook Done is pending. A pending Done already completes that turn, as main's plugin Done did. A newer turn that cancels the pending Done still drops it, as for any hook agent. OpenCode 2 never has a pending hook Done and is unchanged. --- .../agent-completion-hook-observer.ts | 5 +- .../agent-run-ended-notifications.test.ts | 47 ++++++++++++++++++- 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/agent-completion-hook-observer.ts b/src/renderer/src/components/terminal-pane/agent-completion-hook-observer.ts index 95269e8bbc5a..9788a962231b 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-hook-observer.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-hook-observer.ts @@ -199,8 +199,9 @@ export function createAgentCompletionHookObserver({ return { observeHookStatus, observeAgentRunEnded: (payload: AgentCompletionStatusSnapshot) => { - // Why: a run's exit completes only a turn this lane saw open; a hook Done may have announced it. - if (state.workingStatusObserved) { + // Why: a run's exit completes only a turn this lane saw open and no hook Done is completing; + // a Done in its quiet window carries the richer payload and the notification id. + if (state.workingStatusObserved && state.pendingHookDoneTimer === null) { observeHookStatus(payload) } }, diff --git a/src/renderer/src/hooks/agent-run-ended-notifications.test.ts b/src/renderer/src/hooks/agent-run-ended-notifications.test.ts index f324df7a0d36..b1f129eed25e 100644 --- a/src/renderer/src/hooks/agent-run-ended-notifications.test.ts +++ b/src/renderer/src/hooks/agent-run-ended-notifications.test.ts @@ -36,6 +36,15 @@ function hookStatus( return { state, prompt: 'fix the bug', agentType } } +// The OpenCode 1 plugin's own Done for the run's turn, as the hook lane receives it. +const pluginDone = { + state: 'done' as const, + prompt: 'fix the bug', + agentType: 'opencode', + lastAssistantMessage: 'Fixed the off-by-one in parser.ts', + stateStartedAt: 1_000 +} + function endedRunBatch( agentType: string, options: { interrupted?: true; replay?: true } = {} @@ -159,16 +168,50 @@ describe('ended agent run notifications', () => { expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1) }) - it('announces once when the run exits inside the plugin Done’s quiet window', async () => { + // Why the payload: it carries the body preview and the notification id that acknowledging the row clears. + it('announces the plugin Done’s own turn when the run exits inside its quiet window', async () => { const { observe, dispatch } = await load() observe(hookStatus('working', 'opencode')) - observe(hookStatus('done', 'opencode')) + observe(pluginDone) vi.advanceTimersByTime(HOOK_DONE_QUIET_MS / 2) dispatch(endedRunBatch('opencode')) vi.advanceTimersByTime(HOOK_DONE_QUIET_MS * 2) expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1) + expect(dispatchTerminalNotification.mock.calls[0]?.[1].agentStatusSnapshot).toEqual(pluginDone) + }) + + it('announces the plugin Done’s turn when the run’s Ctrl+C exit lands inside its quiet window', async () => { + const { observe, dispatch } = await load() + observe(hookStatus('working', 'opencode')) + observe(pluginDone) + + dispatch(endedRunBatch('opencode', { interrupted: true })) + vi.advanceTimersByTime(HOOK_DONE_QUIET_MS) + + expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1) + expect(dispatchTerminalNotification.mock.calls[0]?.[1].agentStatusSnapshot).toEqual(pluginDone) + }) + + // Why: a newer turn drops a pending hook Done for every agent; the run's end must not revive it. + it('announces only the newer turn when one cancels the plugin Done the run exited under', async () => { + const { observe, dispatch } = await load() + observe(hookStatus('working', 'opencode')) + observe(pluginDone) + dispatch(endedRunBatch('opencode')) + + observe(hookStatus('working', 'opencode')) + vi.advanceTimersByTime(HOOK_DONE_QUIET_MS * 2) + expect(dispatchTerminalNotification).not.toHaveBeenCalled() + dispatch(endedRunBatch('opencode')) + vi.advanceTimersByTime(HOOK_DONE_QUIET_MS) + + expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1) + expect(dispatchTerminalNotification.mock.calls[0]?.[1].agentStatusSnapshot).toMatchObject({ + state: 'done', + agentType: 'opencode' + }) }) it('lets a new turn in the pane cancel the pending announcement', async () => { From 27517ee2ba1e3d2300d3cdcd06cc6e66671ea6aa Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 00:58:00 -0700 Subject: [PATCH 6/7] refactor(renderer): map a host fact's pane key with the status rows' own rule The remote side-effect batch re-derived the mirrored pane key that the mirrored status rows get from toMirroredPaneKey. If either copy changed alone, an ended-run announcement from a remote host would name a pane no hook-lane coordinator knows, and the notification would silently stop. toMirroredPaneKey now takes just the tab and leaf it reads, and the batch mapping calls it. --- .../src/runtime/remote-terminal-side-effect-batch.ts | 6 ++++-- .../web-session-tabs-sync/agent-status-primitives.ts | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/runtime/remote-terminal-side-effect-batch.ts b/src/renderer/src/runtime/remote-terminal-side-effect-batch.ts index 2e5e42decb40..7a554f8a53cd 100644 --- a/src/renderer/src/runtime/remote-terminal-side-effect-batch.ts +++ b/src/renderer/src/runtime/remote-terminal-side-effect-batch.ts @@ -1,12 +1,14 @@ import { toRemoteRuntimePtyId } from '../../../shared/remote-runtime-pty-id' -import { makePaneKey, parsePaneKey } from '../../../shared/stable-pane-id' +import { parsePaneKey } from '../../../shared/stable-pane-id' import { toWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id' import type { TerminalSideEffectBatch } from '../../../shared/terminal-side-effect-facts' +import { toMirroredPaneKey } from './web-session-tabs-sync/agent-status-primitives' /** A host pane key (`hostTabId:leafId`) as this client's key for the same mirrored pane. */ export function toMirroredHostPaneKey(hostPaneKey: string): string | null { const parsed = parsePaneKey(hostPaneKey) - return parsed ? makePaneKey(toWebTerminalSurfaceTabId(parsed.tabId), parsed.leafId) : null + // Why the status rows' own rule: a fact and its pane's row must name the pane alike. + return parsed ? toMirroredPaneKey({ parentTabId: parsed.tabId, leafId: parsed.leafId }) : null } /** A remote host's batch in this client's ids. The host attributes it to host tabs; this client diff --git a/src/renderer/src/runtime/web-session-tabs-sync/agent-status-primitives.ts b/src/renderer/src/runtime/web-session-tabs-sync/agent-status-primitives.ts index a3587232e8eb..0b85702de976 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/agent-status-primitives.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/agent-status-primitives.ts @@ -22,7 +22,7 @@ export function isMirroredAgentStatusOwnedBy( } export function toMirroredPaneKey( - surface: TerminalSurface, + surface: Pick, leafId = surface.leafId ): string | null { if (!isTerminalLeafId(leafId)) { From 3f1a83e4d8aac04dfcbf6f1e7900da79ac4b3f5e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 02:24:10 -0700 Subject: [PATCH 7/7] fix(renderer): name the ended-run fact in the consumer fact switch The type-aware code-quality audit requires the fact switch to cover every fact kind. `agent-run-ended` is announced once per batch before the consumer lookup, so consumers take no action on it; say so with an explicit empty case. --- .../terminal-pane/terminal-side-effect-facts-handler.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts index d55932ac2f10..1bc25a658793 100644 --- a/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts +++ b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts @@ -107,6 +107,9 @@ let channelUnsubscribe: (() => void) | null = null function applyLiveFact(entry: ConsumerEntry, fact: TerminalSideEffectFact, seq: number): void { switch (fact.kind) { + case 'agent-run-ended': + // Why: announced once per batch before the consumer lookup, not per consumer. + return case 'agent-status': entry.callbacks.onAgentStatus?.(fact.payload) return