From ab3b1d07cd6c3161e061ea56a25753bc033dda3b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 23 Aug 2026 15:24:25 -0700 Subject: [PATCH] reland(opencode): session continuity without the command-finished deferral (STA-4557) (#15350) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * reland(opencode): session continuity without the command-finished deferral (STA-4557) Relands #14866 (reverted in #14943) minus its `orca-runtime.ts` change, which is what caused the revert. ## Why the original runtime change was wrong `retirePtyAgentLaunchAuthorityAfterCommandFinished` deferred launch-authority retirement behind an async foreground read, on the premise that OpenCode emits `command-finished` while still in the foreground. Raw PTY capture disproves it: OpenCode emits no OSC 133 of its own, and Orca's shell wrappers emit exactly one `133;D` per pane — at OpenCode's exit — under both zsh and bash. The event being deferred past only ever fires at exit, which is exactly when authority should be retired. Both call sites stay on the synchronous `retirePtyAgentLaunchAuthority`. ## Why the deferral was unsafe `confirmPtyAgentExit` uses the same async-foreground pattern four lines away, but its early return means "don't record an exit" — conservative. The deferral copied that shape into a site where the early return means "don't revoke a secret". Same code, inverted consequence: every guard failed open, so a stale or racing read silently kept a finished session's authority alive, and the pane's persisted `launchTokenHash` was never scrubbed — so it rehydrated as `restored` authority after an app restart. ## Why the deferral's guards could not have worked `ORCA_AGENT_LAUNCH_TOKEN` lives in the PTY environment, so every process started in that shell inherits it — both sessions in a reused pane post the same token. A pane-lifetime bearer secret cannot be a session identity baseline, by construction, and `incarnationId` tracks the PTY, not the agent. The only field that separates sessions is the provider `sessionID`. ## What lands - Status/session-boundary work from #14866: opencode emits `SessionStart` for root sessions (mimo-code does not), launch-token fencing, and `SessionStart` as an opencode turn boundary. - The two `server.ts` fixes from #14941: re-fence a still-authorized pane on a tokened `SessionStart`, and restore mimo-code's explicit-prompt restart boundary (mimo emits no `SessionStart`, so opencode-only stranded its panes). #14941's re-poll hunk is dropped along with the code it patched. - Five regression tests in `opencode-finished-session-authority.test.ts`. They pass here and all five go red if the deferral is re-added. * chore: drop incidental reformatting of files unrelated to this PR --- .../server-opencode-lifecycle.test.ts | 261 ++++++++++++++ .../server-retired-pane-new-turn.test.ts | 14 +- src/main/agent-hooks/server.ts | 67 +++- src/main/mimo/hook-service.test.ts | 4 +- src/main/mimo/hook-service.ts | 2 +- .../hook-plugin-lifecycle-delivery.test.ts | 16 + src/main/opencode/hook-service.ts | 21 +- ...pencode-finished-session-authority.test.ts | 329 ++++++++++++++++++ ...listener-claude-compatible-vendors.test.ts | 10 + src/shared/agent-hook-listener.ts | 26 +- src/shared/opencode-permission-status.test.ts | 6 +- 11 files changed, 729 insertions(+), 27 deletions(-) create mode 100644 src/main/agent-hooks/server-opencode-lifecycle.test.ts create mode 100644 src/main/runtime/opencode-finished-session-authority.test.ts diff --git a/src/main/agent-hooks/server-opencode-lifecycle.test.ts b/src/main/agent-hooks/server-opencode-lifecycle.test.ts new file mode 100644 index 00000000000..93e31908818 --- /dev/null +++ b/src/main/agent-hooks/server-opencode-lifecycle.test.ts @@ -0,0 +1,261 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { makePaneKey } from '../../shared/stable-pane-id' +import { AgentHookServer } from './server' + +const PANE = makePaneKey('tab-opencode', '11111111-1111-4111-8111-111111111111') +const TARGET_PANE = makePaneKey('tab-opencode', '22222222-2222-4222-8222-222222222222') + +describe('AgentHookServer OpenCode lifecycle', () => { + const servers: AgentHookServer[] = [] + + afterEach(() => { + for (const server of servers) { + server.stop() + } + servers.length = 0 + }) + + async function setup(): Promise<{ + server: AgentHookServer + post: ( + payload: Record, + launchToken: string, + paneKey?: string, + source?: 'opencode' | 'mimo-code' + ) => Promise + }> { + const server = new AgentHookServer() + servers.push(server) + await server.start({ env: 'production' }) + const env = server.buildPtyEnv() + return { + server, + post: (payload, launchToken, paneKey = PANE, source = 'opencode') => + fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/${source}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify({ + paneKey, + launchToken, + tabId: 'tab-opencode', + worktreeId: 'wt-opencode', + env: 'production', + payload + }) + }) + } + } + + it('accepts Busy after a retired pane receives a root SessionStart', async () => { + const { server, post } = await setup() + await post({ hook_event_name: 'SessionBusy', sessionID: 'old' }, 'old-token') + server.retirePaneAuthority(PANE) + + await post({ hook_event_name: 'SessionStart', sessionID: 'fresh' }, 'fresh-token') + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: PANE, + state: 'done', + sessionBoundary: true, + providerSession: { key: 'session_id', id: 'fresh' } + }) + ]) + + await post({ hook_event_name: 'SessionBusy', sessionID: 'fresh' }, 'fresh-token') + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ paneKey: PANE, state: 'working', agentType: 'opencode' }) + ]) + }) + + it('accepts a resumed fresh user MessagePart but not arbitrary Busy', async () => { + const { server, post } = await setup() + await post({ hook_event_name: 'SessionBusy', sessionID: 'old' }, 'old-token') + server.retirePaneAuthority(PANE) + + await post({ hook_event_name: 'SessionBusy', sessionID: 'resumed' }, 'resume-token') + expect(server.getStatusSnapshot()).toEqual([]) + + await post( + { + hook_event_name: 'MessagePart', + role: 'user', + text: 'continue the task', + messageID: 'message-resumed', + sessionID: 'resumed' + }, + 'resume-token' + ) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ state: 'working', prompt: 'continue the task' }) + ]) + }) + + it('maps question.asked attention to Waiting after restart', async () => { + const { server, post } = await setup() + await post({ hook_event_name: 'SessionBusy', sessionID: 'old' }, 'old-token') + server.retirePaneAuthority(PANE) + await post({ hook_event_name: 'SessionStart', sessionID: 'fresh' }, 'fresh-token') + + await post( + { hook_event_name: 'AskUserQuestion', id: 'question-1', sessionID: 'fresh' }, + 'fresh-token' + ) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ state: 'waiting', agentType: 'opencode' }) + ]) + }) + + it('suppresses stale old-token Busy after a fresh restart', async () => { + const { server, post } = await setup() + await post({ hook_event_name: 'SessionBusy', sessionID: 'old' }, 'old-token') + server.retirePaneAuthority(PANE) + await post({ hook_event_name: 'SessionStart', sessionID: 'fresh' }, 'fresh-token') + await post({ hook_event_name: 'SessionBusy', sessionID: 'fresh' }, 'fresh-token') + + await post( + { hook_event_name: 'SessionBusy', sessionID: 'old', prompt: 'stale prompt' }, + 'old-token' + ) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ state: 'working', prompt: '' }) + ]) + }) + + it('clears the destination token fence when pane authority transfers', async () => { + const { server, post } = await setup() + await post( + { hook_event_name: 'SessionBusy', sessionID: 'target-old' }, + 'target-old-token', + TARGET_PANE + ) + server.retirePaneAuthority(TARGET_PANE) + await post( + { hook_event_name: 'SessionStart', sessionID: 'target-fresh' }, + 'target-fresh-token', + TARGET_PANE + ) + await post({ hook_event_name: 'SessionBusy', sessionID: 'source' }, 'source-token') + + // The destination fence rejects the source token until the transfer removes it. + await post( + { hook_event_name: 'SessionBusy', sessionID: 'source-before-transfer' }, + 'source-token', + TARGET_PANE + ) + expect( + server.getStatusSnapshot().find((entry) => entry.paneKey === TARGET_PANE)?.providerSession + ).toEqual({ key: 'session_id', id: 'target-fresh' }) + + server.transferPaneAuthority(PANE, TARGET_PANE, 'pty-opencode') + await post( + { hook_event_name: 'SessionBusy', sessionID: 'source-after-transfer' }, + 'source-token', + TARGET_PANE + ) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: TARGET_PANE, + providerSession: { key: 'session_id', id: 'source-after-transfer' } + }) + ]) + }) + + it('re-fences on a new SessionStart while the pane stays authorized', async () => { + const { server, post } = await setup() + await post({ hook_event_name: 'SessionBusy', sessionID: 'old' }, 'old-token') + server.retirePaneAuthority(PANE) + await post({ hook_event_name: 'SessionStart', sessionID: 'restart' }, 'restart-token') + await post({ hook_event_name: 'SessionBusy', sessionID: 'restart' }, 'restart-token') + // The stale old-token follow-up stays fenced out. + await post({ hook_event_name: 'SessionBusy', sessionID: 'old' }, 'old-token') + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ providerSession: { key: 'session_id', id: 'restart' } }) + ]) + + // Why: the runtime defers retirement while an agent stays in the foreground, so a + // genuinely new process can start in a still-authorized pane. Its SessionStart must + // replace the stale fence — not be swallowed as a stale event (rowless reuse). + await post({ hook_event_name: 'SessionStart', sessionID: 'fresh' }, 'fresh-token') + await post({ hook_event_name: 'SessionBusy', sessionID: 'fresh' }, 'fresh-token') + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ providerSession: { key: 'session_id', id: 'fresh' } }) + ]) + }) + + it('keeps the fence when a live pane sees a tokenless SessionStart', async () => { + const { server, post } = await setup() + await post({ hook_event_name: 'SessionBusy', sessionID: 'old' }, 'old-token') + server.retirePaneAuthority(PANE) + await post({ hook_event_name: 'SessionStart', sessionID: 'restart' }, 'restart-token') + await post({ hook_event_name: 'SessionBusy', sessionID: 'restart' }, 'restart-token') + + // Why: an untokened boundary cannot prove which process it belongs to, so dropping + // the fence for it would reopen the pane to every stale token. + await post({ hook_event_name: 'SessionStart', sessionID: 'tokenless' }, '') + await post({ hook_event_name: 'SessionBusy', sessionID: 'old' }, 'old-token') + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ providerSession: { key: 'session_id', id: 'restart' } }) + ]) + }) + + it('does not let a stale explicit prompt re-fence a live pane', async () => { + const { server, post } = await setup() + await post({ hook_event_name: 'SessionBusy', sessionID: 'old' }, 'old-token') + server.retirePaneAuthority(PANE) + await post({ hook_event_name: 'SessionStart', sessionID: 'restart' }, 'restart-token') + await post({ hook_event_name: 'SessionBusy', sessionID: 'restart' }, 'restart-token') + + // Why: prompts recur mid-session, so honoring one as a process boundary would hand + // the pane back to any still-live stale process. + await post( + { + hook_event_name: 'MessagePart', + role: 'user', + text: 'stale prompt', + messageID: 'message-stale', + sessionID: 'old' + }, + 'old-token' + ) + await post({ hook_event_name: 'SessionBusy', sessionID: 'old' }, 'old-token') + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ providerSession: { key: 'session_id', id: 'restart' }, prompt: '' }) + ]) + }) + + it('restarts a retired mimo-code pane on an explicit user prompt', async () => { + const { server, post } = await setup() + await post({ hook_event_name: 'SessionBusy', sessionID: 'old' }, 'old-token', PANE, 'mimo-code') + server.retirePaneAuthority(PANE) + + // Why: mimo-code emits no SessionStart, so the explicit prompt is its only restart + // boundary — excluding it would strand every retired mimo-code pane. + await post( + { + hook_event_name: 'MessagePart', + role: 'user', + text: 'continue the task', + messageID: 'message-resumed', + sessionID: 'resumed' + }, + 'resume-token', + PANE, + 'mimo-code' + ) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ state: 'working', prompt: 'continue the task' }) + ]) + }) +}) diff --git a/src/main/agent-hooks/server-retired-pane-new-turn.test.ts b/src/main/agent-hooks/server-retired-pane-new-turn.test.ts index bda9b7713e3..117fa50970b 100644 --- a/src/main/agent-hooks/server-retired-pane-new-turn.test.ts +++ b/src/main/agent-hooks/server-retired-pane-new-turn.test.ts @@ -19,7 +19,8 @@ beforeEach(() => { afterEach(() => vi.restoreAllMocks()) /** Each source's own new-turn boundary, as `isNewTurnEvent` classifies it. `null` means the - * provider has no turn boundary at all, so a retired pane there stays retired by design. */ + * classifier names no boundary for that source. That is not the same as "can never revive": + * mimo-code's boundary is an explicit-prompt MessagePart, which the gate handles separately. */ const NEW_TURN_EVENT: Record = { claude: 'SessionStart', kimi: 'UserPromptSubmit', @@ -36,9 +37,7 @@ const NEW_TURN_EVENT: Record = { copilot: 'sessionStart', hermes: 'pre_llm_call', devin: 'UserPromptSubmit', - // Why null for opencode today: its plugin emits no SessionStart, so it has no reachable - // boundary. A pending change adds one; this entry moves with that change, not before it. - opencode: null, + opencode: 'SessionStart', 'mimo-code': null, 'command-code': null } @@ -111,10 +110,9 @@ describe("retired pane un-retires on each provider's own new-turn event", () => ) it('leaves the pane retired for a source with no turn boundary', () => { - // Why mimo-code and command-code rather than opencode: these two have no boundary event in - // any planned state. Opencode is deliberately excluded — its plugin emits no SessionStart on - // main (verified: zero occurrences in opencode/hook-service.ts), so asserting either outcome - // for it would pin a synthetic event, and a pending change gives it a real one. + // Why mimo-code and command-code: neither names a boundary through `isNewTurnEvent`, so + // SessionStart must not open the gate for them. Mimo-code still revives on its own + // explicit-prompt MessagePart — that path is covered in server-opencode-lifecycle.test.ts. expect(reviveRetiredPane('mimo-code', 'SessionStart')).toBe(false) expect(reviveRetiredPane('command-code', 'SessionStart')).toBe(false) }) diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 2b0457e064a..65b7b1479fd 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -741,6 +741,7 @@ export class AgentHookServer { private promptSentHashSalt = randomBytes(16).toString('hex') private closedAgentStatusTabIds = new Set() private closedAgentStatusPaneKeys = new Set() + private restartedStatusLaunchTokenHashByPaneKey = new Map() private connectionTimestampWatermarkById = new Map() // Why: skip disk writes when the JSON exactly matches the last write; guards against re-firing trailing timers when nothing changed. private lastWrittenJson: string | null = null @@ -1129,6 +1130,8 @@ export class AgentHookServer { rawSource?: unknown hookEventName?: string isReplay?: boolean + hasExplicitPrompt?: boolean + launchToken?: string } ): 'accept' | 'restart' | 'suppress' { const ownerPaneKey = this.resolvePaneKeyAlias(paneKey) @@ -1140,6 +1143,29 @@ export class AgentHookServer { return 'suppress' } if (!paneRetired) { + const tokenFence = this.restartedStatusLaunchTokenHashByPaneKey.get(ownerPaneKey) + // Why: deferred retirement lets a new process start in a still-authorized pane, so + // its tokened SessionStart re-fences; prompts recur, so a stale process would win. + if ( + event?.hookEventName === 'SessionStart' && + event.isReplay !== true && + tokenFence !== undefined + ) { + const startedLaunchToken = event.launchToken?.trim() + if (startedLaunchToken) { + this.restartedStatusLaunchTokenHashByPaneKey.set( + ownerPaneKey, + createHash('sha256').update(startedLaunchToken).digest('hex') + ) + return 'accept' + } + } + if (event && tokenFence) { + const launchToken = event.launchToken?.trim() + if (!launchToken || createHash('sha256').update(launchToken).digest('hex') !== tokenFence) { + return 'suppress' + } + } return 'accept' } // Why: command completion retires launch authority but leaves its shell pane reusable. @@ -1164,9 +1190,28 @@ export class AgentHookServer { // cannot revive a provider whose boundary event is named anything else. event?.hookEventName === 'UserPromptSubmit' || event?.hookEventName === 'SessionStart' : false - if (isNewTurn && event?.isReplay !== true) { + // Why in addition to the classifier: the OpenCode family carries its mid-session boundary in + // an explicit-prompt MessagePart, which isNewTurnEvent cannot name — and mimo-code has no + // SessionStart at all, so without this its retired panes never come back. + const freshOpenCodeFamilyPrompt = + (event?.source === 'opencode' || event?.source === 'mimo-code') && + event.hookEventName === 'MessagePart' && + event.hasExplicitPrompt === true + // Why the token is minted here: a revive proves a live lifecycle, and fencing follow-up + // status on that launch token stops a stale process reclaiming the pane's row without + // restoring retired orchestration authority. + if ((isNewTurn || freshOpenCodeFamilyPrompt) && event?.isReplay !== true) { this.closedAgentStatusPaneKeys.delete(paneKey) this.closedAgentStatusPaneKeys.delete(ownerPaneKey) + const launchToken = event?.launchToken?.trim() + if (launchToken) { + this.restartedStatusLaunchTokenHashByPaneKey.set( + ownerPaneKey, + createHash('sha256').update(launchToken).digest('hex') + ) + } else { + this.restartedStatusLaunchTokenHashByPaneKey.delete(ownerPaneKey) + } return 'restart' } return 'suppress' @@ -1824,6 +1869,13 @@ export class AgentHookServer { if (this.runtimeObservedStatusPaneKeys.delete(previousOwnerPaneKey)) { this.runtimeObservedStatusPaneKeys.add(toPaneKey) } + const restartedTokenHash = + this.restartedStatusLaunchTokenHashByPaneKey.get(previousOwnerPaneKey) + this.restartedStatusLaunchTokenHashByPaneKey.delete(previousOwnerPaneKey) + this.restartedStatusLaunchTokenHashByPaneKey.delete(toPaneKey) + if (restartedTokenHash) { + this.restartedStatusLaunchTokenHashByPaneKey.set(toPaneKey, restartedTokenHash) + } const activeTurnCompletedAt = this.activeHookTurnCompletedAtByPaneKey.get(previousOwnerPaneKey) if (activeTurnCompletedAt !== undefined) { this.activeHookTurnCompletedAtByPaneKey.delete(previousOwnerPaneKey) @@ -1884,6 +1936,7 @@ export class AgentHookServer { const hadStatus = [...paneKeys].some((key) => this.state.lastStatusByPaneKey.has(key)) for (const key of paneKeys) { this.markPaneClosedForAgentStatus(key) + this.restartedStatusLaunchTokenHashByPaneKey.delete(key) this.clearAssistantMessageRetry(key) this.clearCodexSubagentPoll(key) clearPaneCacheState(this.state, key) @@ -2274,7 +2327,9 @@ export class AgentHookServer { source, rawSource: envelope.source, hookEventName, - isReplay: envelope.isReplay === true + isReplay: envelope.isReplay === true, + hasExplicitPrompt: envelope.hasExplicitPrompt === true, + launchToken: envelope.launchToken }) if (statusDisposition === 'suppress') { return @@ -2493,7 +2548,9 @@ export class AgentHookServer { ? this.getAgentStatusDisposition(normalized.event.paneKey, { source, hookEventName: normalized.event.hookEventName, - isReplay: normalized.event.isReplay + isReplay: normalized.event.isReplay, + hasExplicitPrompt: normalized.event.hasExplicitPrompt, + launchToken: normalized.event.launchToken }) : 'suppress' if (normalized.event && statusDisposition !== 'suppress') { @@ -2587,6 +2644,7 @@ export class AgentHookServer { this.promptSentDedupeByPaneKey.clear() this.closedAgentStatusTabIds.clear() this.closedAgentStatusPaneKeys.clear() + this.restartedStatusLaunchTokenHashByPaneKey.clear() this.retiredPaneFencesByKey.clear() this.connectionTimestampWatermarkById.clear() this.legacyPaneKeyAliases.clear() @@ -2764,6 +2822,7 @@ export class AgentHookServer { this.runtimeObservedStatusPaneKeys.delete(paneKey) this.currentAuthorityObservations.delete(paneKey) this.promptSentDedupeByPaneKey.delete(paneKey) + this.restartedStatusLaunchTokenHashByPaneKey.delete(paneKey) } if (aliasChanged) { this.notifyPaneKeyAliasPersistenceListener() @@ -2785,6 +2844,7 @@ export class AgentHookServer { this.activeHookTurnCompletedAtByPaneKey.delete(resolvedPaneKey) this.currentAuthorityObservations.delete(resolvedPaneKey) this.promptSentDedupeByPaneKey.delete(resolvedPaneKey) + this.restartedStatusLaunchTokenHashByPaneKey.delete(resolvedPaneKey) let clearedAlias = false for (const [legacyPaneKey, stablePaneKey] of this.legacyPaneKeyAliases) { if (stablePaneKey.stablePaneKey === resolvedPaneKey) { @@ -2795,6 +2855,7 @@ export class AgentHookServer { this.activeHookTurnCompletedAtByPaneKey.delete(legacyPaneKey) this.currentAuthorityObservations.delete(legacyPaneKey) this.promptSentDedupeByPaneKey.delete(legacyPaneKey) + this.restartedStatusLaunchTokenHashByPaneKey.delete(legacyPaneKey) clearedAlias = true } } diff --git a/src/main/mimo/hook-service.test.ts b/src/main/mimo/hook-service.test.ts index 4f84d3ea12c..f1ef11e3949 100644 --- a/src/main/mimo/hook-service.test.ts +++ b/src/main/mimo/hook-service.test.ts @@ -60,7 +60,9 @@ describe('MimoCodeHookService buildPtyEnv', () => { const orcaPlugin = join(overlayHome, 'config', 'plugins', 'orca-mimocode-status.js') expect(existsSync(orcaPlugin)).toBe(true) - expect(readFileSync(orcaPlugin, 'utf8')).toContain('/hook/mimo-code') + const pluginSource = readFileSync(orcaPlugin, 'utf8') + expect(pluginSource).toContain('/hook/mimo-code') + expect(pluginSource).not.toContain('post("SessionStart"') expect( readFileSync(join(mimocodeHome, 'config', 'plugins', 'orca-mimocode-status.js'), 'utf8') diff --git a/src/main/mimo/hook-service.ts b/src/main/mimo/hook-service.ts index 2c78c41a454..9e617e1eb6c 100644 --- a/src/main/mimo/hook-service.ts +++ b/src/main/mimo/hook-service.ts @@ -72,7 +72,7 @@ export class MimoCodeHookService { mkdirSync(pluginsDir, { recursive: true }) writeFileSync( join(pluginsDir, ORCA_MIMOCODE_PLUGIN_FILE), - getOpenCodeFamilyPluginSource('/hook/mimo-code') + getOpenCodeFamilyPluginSource('/hook/mimo-code', { emitSessionStart: false }) ) } catch { return existingMimocodeHome ? { MIMOCODE_HOME: existingMimocodeHome } : {} diff --git a/src/main/opencode/hook-plugin-lifecycle-delivery.test.ts b/src/main/opencode/hook-plugin-lifecycle-delivery.test.ts index b6684ea38af..5e87a90991a 100644 --- a/src/main/opencode/hook-plugin-lifecycle-delivery.test.ts +++ b/src/main/opencode/hook-plugin-lifecycle-delivery.test.ts @@ -131,6 +131,22 @@ describe('OpenCode plugin lifecycle delivery', () => { }) } + it('maps only root session.created to SessionStart', async () => { + const handler = await loadHandler() + + await handler({ + event: { type: 'session.created', properties: { info: { id: 'root' } } } + }) + await handler({ + event: { + type: 'session.created', + properties: { info: { id: 'child', parentID: 'root' } } + } + }) + + expect(posts).toEqual([{ hook_event_name: 'SessionStart', sessionID: 'root' }]) + }) + it('preserves FIFO lifecycle order while the first session lookup is delayed', async () => { let releaseFirstLookup: (() => void) | undefined const firstLookup = new Promise((resolve) => { diff --git a/src/main/opencode/hook-service.ts b/src/main/opencode/hook-service.ts index 60c4fbe9ead..09966c7cda3 100644 --- a/src/main/opencode/hook-service.ts +++ b/src/main/opencode/hook-service.ts @@ -36,10 +36,13 @@ function toSafeDirName(id: string): string { } export function getOpenCodePluginSource(): string { - return getOpenCodeFamilyPluginSource('/hook/opencode') + return getOpenCodeFamilyPluginSource('/hook/opencode', { emitSessionStart: true }) } -export function getOpenCodeFamilyPluginSource(hookPathname: string): string { +export function getOpenCodeFamilyPluginSource( + hookPathname: string, + options: { emitSessionStart: boolean } +): string { // Why: the plugin posts PTY environment data from OpenCode to the shared hooks server. return [ '// Why: process-lifetime guard so a recurring parse error on a malformed', @@ -875,6 +878,20 @@ export function getOpenCodeFamilyPluginSource(hookPathname: string): string { '', ' const sessionID = event.properties?.sessionID;', ' const updatedPart = event.properties?.part;', + ...(options.emitSessionStart + ? [ + ' if (event.type === "session.created") {', + ' const info = event.properties?.info;', + ' if (!info?.id || info.parentID) return;', + ' rememberSessionRoot(info.id, info.id);', + ' await enqueueLifecycle(() =>', + ' disposed ? undefined : post("SessionStart", { sessionID: info.id })', + ' );', + ' return;', + ' }', + '' + ] + : []), ' if (', ' event.type === "message.part.updated" &&', ' updatedPart?.type === "tool" &&', diff --git a/src/main/runtime/opencode-finished-session-authority.test.ts b/src/main/runtime/opencode-finished-session-authority.test.ts new file mode 100644 index 00000000000..ded271c0617 --- /dev/null +++ b/src/main/runtime/opencode-finished-session-authority.test.ts @@ -0,0 +1,329 @@ +import { createHash } from 'node:crypto' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { AgentHookServer } from '../agent-hooks/server' +import { OrcaRuntimeService } from './orca-runtime' +import { makeStore } from './runtime-rpc-worktree-store-fixtures' + +// STA-4557: #14866 deferred command-finished retirement for OpenCode panes behind an +// async "is the foreground still OpenCode?" read. These pin the two ways a finished +// session then kept or regained orchestration authority (the #14943 revert reason). + +const WORKTREE_PATH = '/tmp/worktree-a' +const WORKTREE = { + path: WORKTREE_PATH, + head: 'abc', + branch: 'feature/opencode-authority', + isBare: false, + isMainWorktree: false +} + +vi.mock('../git/worktree', () => ({ + listWorktrees: vi.fn().mockResolvedValue([ + { + path: '/tmp/worktree-a', + head: 'abc', + branch: 'feature/opencode-authority', + isBare: false, + isMainWorktree: false + } + ]), + listWorktreesStrict: vi.fn().mockResolvedValue([ + { + path: '/tmp/worktree-a', + head: 'abc', + branch: 'feature/opencode-authority', + isBare: false, + isMainWorktree: false + } + ]) +})) + +type LaunchedOpenCodePane = { + runtime: OrcaRuntimeService + ptyId: string + paneKey: string + tabId: string + launchToken: string + evidence: { terminalHandle: string; paneKey: string; launchToken: string } +} + +async function launchOpenCodePane(options: { + ptyId: string + getForegroundProcess: () => Promise + retireAgentHookCompatibilityAuthority?: (paneKey: string) => void + attestAgentHookCompatibilityAuthority?: OrcaRuntimeServiceDeps['attestAgentHookCompatibilityAuthority'] +}): Promise { + const spawn = vi.fn().mockResolvedValue({ id: options.ptyId, incarnationId: 'incarnation-1' }) + const runtime = new OrcaRuntimeService(makeStore() as never, undefined, { + attestAgentHookCompatibilityAuthority: + options.attestAgentHookCompatibilityAuthority ?? + ((candidate) => ({ paneKey: candidate.paneKey, source: 'current_hook' as const })), + ...(options.retireAgentHookCompatibilityAuthority + ? { retireAgentHookCompatibilityAuthority: options.retireAgentHookCompatibilityAuthority } + : {}) + }) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: options.getForegroundProcess + }) + const terminal = await runtime.createTerminal(`path:${WORKTREE.path}`, { + command: 'opencode', + launchConfig: { agentCommand: 'opencode', agentArgs: '', agentEnv: {} }, + launchAgent: 'opencode' + }) + const env = (spawn.mock.calls[0]?.[0] as { env?: Record } | undefined)?.env ?? {} + const paneKey = env.ORCA_PANE_KEY as string + const launchToken = env.ORCA_AGENT_LAUNCH_TOKEN as string + expect(paneKey).toBeTruthy() + expect(launchToken).toBeTruthy() + return { + runtime, + ptyId: options.ptyId, + paneKey, + tabId: paneKey.split(':')[0]!, + launchToken, + evidence: { terminalHandle: terminal.handle, paneKey, launchToken } + } +} + +type OrcaRuntimeServiceDeps = NonNullable[2]> + +/** Drain the microtask + timer queues the deferred foreground read chains through. */ +async function settle(ticks = 40): Promise { + for (let tick = 0; tick < ticks; tick += 1) { + await new Promise((resolve) => setTimeout(resolve, 0)) + } +} + +describe('OpenCode finished-session launch authority (STA-4557)', () => { + const servers: AgentHookServer[] = [] + const tempDirs: string[] = [] + + afterEach(() => { + for (const server of servers) { + server.stop() + } + servers.length = 0 + for (const dir of tempDirs) { + rmSync(dir, { recursive: true, force: true }) + } + tempDirs.length = 0 + vi.restoreAllMocks() + }) + + it('retires authority when command-finished proves OpenCode left the foreground, even if a title raced the read', async () => { + let resolveForeground: ((process: string | null) => void) | undefined + const foreground = new Promise((resolve) => { + resolveForeground = resolve + }) + const getForegroundProcess = vi.fn(() => foreground) + const pane = await launchOpenCodePane({ + ptyId: 'pty-opencode-exit', + getForegroundProcess + }) + + expect(pane.runtime.verifyOrchestrationCompatibilityCaller(pane.evidence)).not.toBeNull() + + // OpenCode exits; the shell prints its OSC 133;D and repaints its title while the + // foreground read is still in flight, then the read lands proving a plain shell. + pane.runtime.onPtyData(pane.ptyId, '\x1b]133;D;0\x07', 100) + pane.runtime.onPtyData(pane.ptyId, '\x1b]0;~/worktree-a\x07', 101) + resolveForeground?.('zsh') + await settle() + + expect(pane.runtime.verifyOrchestrationCompatibilityCaller(pane.evidence)).toBeNull() + }) + + it('retires authority when every foreground re-poll keeps racing a fresh title', async () => { + let titleSequence = 0 + const getForegroundProcess = vi.fn( + () => + new Promise((resolve) => { + setTimeout(() => { + titleSequence += 1 + pane.runtime.onPtyData( + pane.ptyId, + `\x1b]0;~/worktree-a (${titleSequence})\x07`, + 200 + titleSequence + ) + resolve('zsh') + }, 0) + }) + ) + const pane = await launchOpenCodePane({ + ptyId: 'pty-opencode-title-storm', + getForegroundProcess + }) + + pane.runtime.onPtyData(pane.ptyId, '\x1b]133;D;0\x07', 100) + await settle() + + expect(pane.runtime.verifyOrchestrationCompatibilityCaller(pane.evidence)).toBeNull() + }) + + it('stops attesting the finished session token for the reused pane', async () => { + const server = new AgentHookServer() + servers.push(server) + await server.start({ env: 'production' }) + const pane = await launchOpenCodePane({ + ptyId: 'pty-opencode-reuse', + // OpenCode is a TUI: it is still the foreground process when its command completes. + getForegroundProcess: async () => 'opencode', + retireAgentHookCompatibilityAuthority: (paneKey) => server.retirePaneAuthority(paneKey), + attestAgentHookCompatibilityAuthority: (candidate) => + server.attestCompatibilityAuthority(candidate) + }) + const hookEnv = server.buildPtyEnv() + const post = (payload: Record): Promise => + fetch(`http://127.0.0.1:${hookEnv.ORCA_AGENT_HOOK_PORT}/hook/opencode`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': hookEnv.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify({ + paneKey: pane.paneKey, + launchToken: pane.launchToken, + tabId: pane.tabId, + worktreeId: 'wt-opencode', + env: 'production', + payload + }) + }) + + await post({ hook_event_name: 'SessionStart', sessionID: 'session-1' }) + await post({ hook_event_name: 'SessionBusy', sessionID: 'session-1' }) + expect( + server.attestCompatibilityAuthority({ + paneKey: pane.paneKey, + launchTokenHash: createHash('sha256').update(pane.launchToken).digest('hex'), + connectionId: null, + terminalProvenance: 'current_runtime' + }) + ).not.toBeNull() + + pane.runtime.onPtyData(pane.ptyId, '\x1b]133;D;0\x07', 100) + await settle() + + // Every later process in this shell inherits ORCA_AGENT_LAUNCH_TOKEN from the PTY env, + // so the finished session's token must stop attesting once its command completed. + expect( + server.attestCompatibilityAuthority({ + paneKey: pane.paneKey, + launchTokenHash: createHash('sha256').update(pane.launchToken).digest('hex'), + connectionId: null, + terminalProvenance: 'current_runtime' + }) + ).toBeNull() + }) + + it('does not let a later OpenCode session satisfy the previous pending retirement', async () => { + const server = new AgentHookServer() + servers.push(server) + await server.start({ env: 'production' }) + let resolveForeground: ((process: string | null) => void) | undefined + const foreground = new Promise((resolve) => { + resolveForeground = resolve + }) + const pane = await launchOpenCodePane({ + ptyId: 'pty-opencode-session-boundary', + getForegroundProcess: () => foreground, + retireAgentHookCompatibilityAuthority: (paneKey) => server.retirePaneAuthority(paneKey), + attestAgentHookCompatibilityAuthority: (candidate) => + server.attestCompatibilityAuthority(candidate) + }) + const hookEnv = server.buildPtyEnv() + // Both sessions post the same launchToken: it lives in the PTY env, so every + // process started in this shell inherits it. Only sessionID separates them. + const post = (sessionId: string): Promise => + fetch(`http://127.0.0.1:${hookEnv.ORCA_AGENT_HOOK_PORT}/hook/opencode`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': hookEnv.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify({ + paneKey: pane.paneKey, + launchToken: pane.launchToken, + tabId: pane.tabId, + worktreeId: 'wt-opencode', + env: 'production', + payload: { hook_event_name: 'SessionBusy', sessionID: sessionId } + }) + }) + const attestCurrent = (): unknown => + server.attestCompatibilityAuthority({ + paneKey: pane.paneKey, + launchTokenHash: createHash('sha256').update(pane.launchToken).digest('hex'), + connectionId: null, + terminalProvenance: 'current_runtime' + }) + + await post('session-1') + expect(attestCurrent()).not.toBeNull() + + // Session 1 ends. Nothing about the PTY changes across an agent session boundary: + // same record, same incarnation, no title write — so every guard on the deferred + // read still matches the baseline captured for session 1. + pane.runtime.onPtyData(pane.ptyId, '\x1b]133;D;0\x07', 100) + await post('session-2') + resolveForeground?.('opencode') + await settle() + + expect(attestCurrent()).toBeNull() + }) + + it('does not rehydrate a finished session token as restored authority after a restart', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-sta4557-')) + tempDirs.push(userDataPath) + const first = new AgentHookServer() + servers.push(first) + await first.start({ env: 'production', userDataPath }) + const pane = await launchOpenCodePane({ + ptyId: 'pty-opencode-restart', + getForegroundProcess: async () => 'opencode', + retireAgentHookCompatibilityAuthority: (paneKey) => first.retirePaneAuthority(paneKey) + }) + const hookEnv = first.buildPtyEnv() + await fetch(`http://127.0.0.1:${hookEnv.ORCA_AGENT_HOOK_PORT}/hook/opencode`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': hookEnv.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify({ + paneKey: pane.paneKey, + launchToken: pane.launchToken, + tabId: pane.tabId, + worktreeId: 'wt-opencode', + env: 'production', + payload: { hook_event_name: 'SessionStart', sessionID: 'session-1' } + }) + }) + + pane.runtime.onPtyData(pane.ptyId, '\x1b]133;D;0\x07', 100) + await settle() + first.flushStatusPersistSync() + first.stop() + + const restarted = new AgentHookServer() + servers.push(restarted) + await restarted.start({ env: 'production', userDataPath }) + + // After a restart the PTY survives with ORCA_AGENT_LAUNCH_TOKEN still in its env and + // pty.launchToken gone, so a persisted commitment is the whole proof of authority. + expect( + restarted.attestCompatibilityAuthority({ + paneKey: pane.paneKey, + launchTokenHash: createHash('sha256').update(pane.launchToken).digest('hex'), + connectionId: null, + terminalProvenance: 'restored' + }) + ).toBeNull() + }) +}) diff --git a/src/shared/agent-hook-listener-claude-compatible-vendors.test.ts b/src/shared/agent-hook-listener-claude-compatible-vendors.test.ts index b7e3dc362db..64390067fcf 100644 --- a/src/shared/agent-hook-listener-claude-compatible-vendors.test.ts +++ b/src/shared/agent-hook-listener-claude-compatible-vendors.test.ts @@ -216,6 +216,15 @@ describe('shared agent-hook-listener', () => { }, 'production' ) + const sessionStart = normalizeHookPayload( + state, + 'mimo-code', + { + paneKey: PANE_KEY, + payload: { hook_event_name: 'SessionStart', sessionID: 'mimo-session' } + }, + 'production' + ) expect(message?.payload).toMatchObject({ agentType: 'mimo-code', @@ -226,6 +235,7 @@ describe('shared agent-hook-listener', () => { expect(message?.providerSession).toMatchObject({ key: 'session_id', id: 'mimo-session' }) expect(tool?.payload).toMatchObject({ agentType: 'mimo-code', state: 'working' }) expect(idle?.payload).toMatchObject({ agentType: 'mimo-code', state: 'done' }) + expect(sessionStart).toBeNull() }) it('maps Kimi AskUserQuestion PreToolUse to waiting, then back to working on answer', () => { diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index e6a7be4e2df..ccfabb603df 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -1789,9 +1789,10 @@ function extractAmpToolFields( /** * Retires any cached tool fields. PermissionRequest is the only OpenCode-family event that - * carries them, and isNewTurnEvent is false for this family, so nothing else ever resets the - * cache — without an explicit retire, resolveToolState inherits one answered permission onto - * every later frame in the pane and the row reads a resolved command as the live tool. + * carries them, and the only isNewTurnEvent boundary this family has is opencode's + * SessionStart — which a resumed session never re-emits — so nothing else resets the cache + * mid-session. Without an explicit retire, resolveToolState inherits one answered permission + * onto every later frame in the pane and the row reads a resolved command as the live tool. */ const OPENCODE_TOOL_FIELDS_RETIRED: ToolSnapshot = { hasToolUpdate: true, @@ -2495,6 +2496,7 @@ export function isNewTurnEvent(source: AgentHookSource, eventName: unknown): boo case 'amp': return eventName === 'agent.start' case 'opencode': + return eventName === 'SessionStart' case 'mimo-code': return false case 'cursor': @@ -3899,14 +3901,19 @@ function normalizeOpenCodeFamilyEvent( paneKey: string, hookPayload: Record ): ParsedAgentStatusPayload | null { + const resetsTurn = + isNewTurnEvent(source, eventName) || + (eventName === 'MessagePart' && hookPayload.role === 'user') const stateName = eventName === 'SessionBusy' || eventName === 'MessagePart' ? 'working' : eventName === 'SessionIdle' ? 'done' - : eventName === 'PermissionRequest' || eventName === 'AskUserQuestion' - ? 'waiting' - : null + : source === 'opencode' && eventName === 'SessionStart' + ? 'done' + : eventName === 'PermissionRequest' || eventName === 'AskUserQuestion' + ? 'waiting' + : null if (!stateName) { return null @@ -3916,19 +3923,20 @@ function normalizeOpenCodeFamilyEvent( state, paneKey, extractToolFields(source, eventName, hookPayload), - { resetOnNewTurn: isNewTurnEvent(source, eventName) } + { resetOnNewTurn: resetsTurn } ) return normalizeAgentStatusPayload({ state: stateName, prompt: resolvePrompt(state, paneKey, promptText, { - resetOnNewTurn: isNewTurnEvent(source, eventName) + resetOnNewTurn: resetsTurn }), agentType: source, toolName: snapshot.toolName, toolInput: snapshot.toolInput, interactivePrompt: snapshot.interactivePrompt, - lastAssistantMessage: snapshot.lastAssistantMessage + lastAssistantMessage: snapshot.lastAssistantMessage, + sessionBoundary: source === 'opencode' && eventName === 'SessionStart' ? true : undefined }) } diff --git a/src/shared/opencode-permission-status.test.ts b/src/shared/opencode-permission-status.test.ts index 6ed57e71fac..6dfb86a7c4d 100644 --- a/src/shared/opencode-permission-status.test.ts +++ b/src/shared/opencode-permission-status.test.ts @@ -239,9 +239,9 @@ describe('OpenCode-family permission request status', () => { }) it.each(SOURCES)('does not carry an answered permission into a later turn for %s', (source) => { - // Why: isNewTurnEvent is false for this family, so nothing else ever resets the cached - // tool. Without an explicit retire, one permission pins its command to every later - // working frame in the pane — the exact stale-tool-line the row gate guards against. + // Why: no isNewTurnEvent boundary fires mid-session for this family, so nothing else + // resets the cached tool. Without an explicit retire, one permission pins its command to + // every later working frame in the pane — the exact stale-tool-line the row gate guards against. permissionEvent(source, BASH_PERMISSION) lifecycleEvent(source, 'SessionBusy') lifecycleEvent(source, 'SessionIdle')