diff --git a/src/main/agent-hooks/server-opencode-binder.test.ts b/src/main/agent-hooks/server-opencode-binder.test.ts index 261de8888ac..eaa5dff073e 100644 --- a/src/main/agent-hooks/server-opencode-binder.test.ts +++ b/src/main/agent-hooks/server-opencode-binder.test.ts @@ -29,8 +29,8 @@ class BinderTestServer extends AgentHookServer { this.startOpenCodeBinderLoop() } - public ingest(source: AgentHookSource, body: unknown): void { - this.normalizeLocalHookPayload(source, body) + public ingest(source: AgentHookSource, body: unknown): string | undefined { + return this.normalizeLocalHookPayload(source, body).event?.paneKey } public readRegistry(sessionId: string): string | undefined { @@ -38,16 +38,16 @@ class BinderTestServer extends AgentHookServer { } } -function writeDb(dbPath: string, table: 'session_v2' | 'session'): void { +function writeDb(dbPath: string, table: 'session_v2' | 'session', id = 'ses_live'): void { const db = new SyncDatabase(dbPath) try { db.exec( - `CREATE TABLE ${table} (id TEXT PRIMARY KEY, directory TEXT NOT NULL, time_created INTEGER NOT NULL, parent_id TEXT)` + `CREATE TABLE IF NOT EXISTS ${table} (id TEXT PRIMARY KEY, directory TEXT NOT NULL, time_created INTEGER NOT NULL, parent_id TEXT)` ) const insert = db.prepare( `INSERT INTO ${table} (id, directory, time_created, parent_id) VALUES (?, ?, ?, ?)` ) - insert.run('ses_live', DIR, Date.now() - 60_000, null) + insert.run(id, DIR, Date.now() - 60_000, null) } finally { db.close() } @@ -85,22 +85,21 @@ describe('opencode binder loop', () => { rmSync(dir, { recursive: true, force: true }) }) - it('binds a fresh session to its pane', async () => { - writeDb(dbPath, 'session_v2') - const applied = await server.runBinderRound() - expect(applied).toBe(1) - expect(server.readRegistry('ses_live')).toBe(PANE_A) - }) - - it('falls back to the v1 session table', async () => { + it('binds a fresh OpenCode 1 session to its pane', async () => { writeDb(dbPath, 'session') const applied = await server.runBinderRound() expect(applied).toBe(1) expect(server.readRegistry('ses_live')).toBe(PANE_A) }) - it('an opencode SessionStart kicks a round that binds before the poll', async () => { + it('never binds an OpenCode 2 session', async () => { writeDb(dbPath, 'session_v2') + expect(await server.runBinderRound()).toBe(0) + expect(server.readRegistry('ses_live')).toBeUndefined() + }) + + it('an opencode SessionStart kicks a round that binds before the poll', async () => { + writeDb(dbPath, 'session') vi.useFakeTimers() try { // Birth arrives stamped with the wrong (server-starter) pane. @@ -117,8 +116,27 @@ describe('opencode binder loop', () => { } }) + it('an OpenCode 2 SessionStart kicks no round', async () => { + writeDb(dbPath, 'session') + const sweep = vi.fn(async () => []) + server.bindDeps({ sweep }) + vi.useFakeTimers() + try { + server.ingest('opencode', { + paneKey: PANE_B, + launchToken: '', + opencodeMajor: 2, + payload: { hook_event_name: 'SessionStart', sessionID: 'ses_live' } + }) + await vi.advanceTimersByTimeAsync(10_000) + expect(sweep).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + it('pane teardown unbinds its sessions', async () => { - writeDb(dbPath, 'session_v2') + writeDb(dbPath, 'session') await server.runBinderRound() expect(server.readRegistry('ses_live')).toBe(PANE_A) server.clearPaneState(PANE_A) @@ -126,12 +144,12 @@ describe('opencode binder loop', () => { }) it('stops the loop without hanging the process', () => { - writeDb(dbPath, 'session_v2') + writeDb(dbPath, 'session') expect(() => server.stop()).not.toThrow() }) it('runs a round immediately on loop start', async () => { - writeDb(dbPath, 'session_v2') + writeDb(dbPath, 'session') server.startBinderLoop() try { await vi.waitFor(() => expect(server.readRegistry('ses_live')).toBe(PANE_A)) @@ -141,7 +159,7 @@ describe('opencode binder loop', () => { }) it('discards a round that was in flight across stop', async () => { - writeDb(dbPath, 'session_v2') + writeDb(dbPath, 'session') let releaseSweep!: () => void const sweepGate = new Promise((resolve) => { releaseSweep = resolve @@ -168,7 +186,7 @@ describe('opencode binder loop', () => { }) it('an obsolete round does not clear the new round running flag', async () => { - writeDb(dbPath, 'session_v2') + writeDb(dbPath, 'session') let releaseFirst!: () => void let releaseLater!: () => void const firstGate = new Promise((resolve) => { @@ -212,6 +230,61 @@ describe('opencode binder loop', () => { }) }) +// OpenCode 1 `serve` in pane A stamps every post with pane A; `attach` in pane B drives the session. +describe('OpenCode 1 serve + attach', () => { + let dir = '' + let dbPath = '' + let server: BinderTestServer + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'binder-attach-')) + dbPath = join(dir, 'opencode.db') + writeDb(dbPath, 'session') + const startedAtMs = Date.now() - 120_000 + server = new BinderTestServer() + server.bindDeps({ + now: () => Date.now(), + dbPath: () => dbPath, + listPanes: () => [ + { paneKey: PANE_A, directory: DIR, worktreeId: `repo::${DIR}`, shellPid: 111 }, + { paneKey: PANE_B, directory: DIR, worktreeId: `repo::${DIR}`, shellPid: 211 } + ], + sweep: async () => [ + { pid: 112, ppid: 111, startedAtMs, executable: 'opencode', argv: ['opencode', 'serve'] }, + { + pid: 212, + ppid: 211, + startedAtMs, + executable: 'opencode', + argv: ['opencode', 'attach', 'http://127.0.0.1:4096'] + } + ] + }) + }) + + afterEach(() => { + server.stop() + rmSync(dir, { recursive: true, force: true }) + }) + + const busy = (extra: Record = {}): Record => ({ + paneKey: PANE_A, + launchToken: '', + ...extra, + payload: { hook_event_name: 'SessionBusy', sessionID: 'ses_live' } + }) + + it("reports the session on the attaching pane, not the server's", async () => { + expect(await server.runBinderRound()).toBe(1) + expect(server.ingest('opencode', busy())).toBe(PANE_B) + }) + + it('never moves an OpenCode 2 post with the same shape', async () => { + await server.runBinderRound() + expect(server.ingest('opencode', busy({ opencodeMajor: 2 }))).toBe(PANE_A) + }) +}) + describe('listOpenCodeDbSessions', () => { let dir = '' let dbPath = '' @@ -225,14 +298,22 @@ describe('listOpenCodeDbSessions', () => { rmSync(dir, { recursive: true, force: true }) }) - it('reads session_v2 rows newer than the watermark', () => { - writeDb(dbPath, 'session_v2') + it('reads OpenCode 1 session rows newer than the watermark', () => { + writeDb(dbPath, 'session') const rows = listOpenCodeDbSessions(dbPath, { ms: 0, id: '' }) expect(rows).toHaveLength(1) expect(rows[0]).toMatchObject({ id: 'ses_live', directory: DIR, parentId: null }) expect(listOpenCodeDbSessions(dbPath, { ms: Date.now(), id: '' })).toEqual([]) }) + it('skips OpenCode 2 rows in a database both versions wrote', () => { + writeDb(dbPath, 'session', 'ses_v1') + writeDb(dbPath, 'session_v2', 'ses_v2') + expect(listOpenCodeDbSessions(dbPath, { ms: 0, id: '' }).map((row) => row.id)).toEqual([ + 'ses_v1' + ]) + }) + it('returns [] for a missing database instead of throwing', () => { expect(listOpenCodeDbSessions(join(dir, 'absent.db'), { ms: 0, id: '' })).toEqual([]) }) diff --git a/src/main/agent-hooks/server-process-lifetime-status.test.ts b/src/main/agent-hooks/server-process-lifetime-status.test.ts new file mode 100644 index 00000000000..e0f489299af --- /dev/null +++ b/src/main/agent-hooks/server-process-lifetime-status.test.ts @@ -0,0 +1,112 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { AgentHookServer } from './server' +import { buildBody, PANE, postHookEvent } from './server.test-fixtures' + +let dir: string +let server: AgentHookServer + +beforeEach(async () => { + dir = mkdtempSync(join(tmpdir(), 'orca-process-lifetime-')) + server = new AgentHookServer() + await server.start({ env: 'production', userDataPath: dir }) +}) + +afterEach(() => { + server.stop() + rmSync(dir, { recursive: true, force: true }) +}) + +function processLifetime(state: 'working' | 'done', yieldsToHookSince: number): void { + server.ingestTerminalStatus({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + connectionId: null, + origin: 'process', + yieldsToHookSince, + payload: { state, prompt: '', agentType: 'opencode' } + }) +} + +async function openCodeHook(hookEventName: string): Promise { + const response = await postHookEvent( + server, + buildBody({ hook_event_name: hookEventName, sessionID: 'ses_1' }), + '/hook/opencode' + ) + expect(response.status).toBe(204) +} + +function paneState(): string { + return server.getStatusSnapshotForPane(PANE)[0]?.state ?? 'missing' +} + +async function nextMillisecond(): Promise { + await new Promise((resolve) => setTimeout(resolve, 5)) +} + +// One `opencode run` command in one pane: the host reports it from the process lifetime unless a +// hook producer (OpenCode 1's in-process plugin) reports the same pane during that command. +describe('process-lifetime status', () => { + it('reports Working, then Done, when no hook speaks for the command', () => { + const commandStartedAt = Date.now() + processLifetime('working', commandStartedAt) + expect(paneState()).toBe('working') + processLifetime('done', commandStartedAt) + expect(paneState()).toBe('done') + }) + + it('yields the rest of the command once a hook reports the pane', async () => { + const commandStartedAt = Date.now() + processLifetime('working', commandStartedAt) + await openCodeHook('SessionBusy') + // The hook is still Working, so the process exit must not write a second Done over it. + processLifetime('done', commandStartedAt) + expect(paneState()).toBe('working') + await openCodeHook('SessionIdle') + expect(paneState()).toBe('done') + }) + + it('writes no Working over a hook that already claimed the command', async () => { + const commandStartedAt = Date.now() + await openCodeHook('SessionIdle') + processLifetime('working', commandStartedAt) + expect(paneState()).toBe('done') + }) + + it('does not yield to a hook row from before the command started', async () => { + await openCodeHook('SessionIdle') + await nextMillisecond() + const commandStartedAt = Date.now() + processLifetime('working', commandStartedAt) + expect(paneState()).toBe('working') + processLifetime('done', commandStartedAt) + expect(paneState()).toBe('done') + }) + + // Why: a pane where an Orca-launched agent exited is retired; a new `opencode run` there is a new run. + it('revives a retired pane on its Working, as a hook new-turn event does', () => { + server.retirePaneAuthority(PANE) + const commandStartedAt = Date.now() + processLifetime('working', commandStartedAt) + expect(paneState()).toBe('working') + processLifetime('done', commandStartedAt) + expect(paneState()).toBe('done') + }) + + it('keeps OSC status and a lone process Done out of a retired pane', () => { + server.retirePaneAuthority(PANE) + server.ingestTerminalStatus({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + connectionId: null, + payload: { state: 'working', prompt: '', agentType: 'opencode' } + }) + processLifetime('done', Date.now()) + expect(paneState()).toBe('missing') + }) +}) diff --git a/src/main/agent-hooks/server/server-ingest-normalization.ts b/src/main/agent-hooks/server/server-ingest-normalization.ts index 171a15ac5cc..5e71e6ef033 100644 --- a/src/main/agent-hooks/server/server-ingest-normalization.ts +++ b/src/main/agent-hooks/server/server-ingest-normalization.ts @@ -1,6 +1,7 @@ import { buildSpoolHookBody, type SpoolRecord } from '../../../shared/agent-hook-spool' import { normalizeHookPayload } from '../../../shared/agent-hook-listener' import { isAgentHookSource, type AgentHookSource } from '../../../shared/agent-hook-relay' +import { isOpenCodeSharedServerPost } from '../../../shared/agent-hook-listener/opencode-session-registry' import type { NormalizedLocalHook } from './server-types' import { AgentHookServerOpenCodeBinder } from './server-opencode-binder' @@ -27,8 +28,8 @@ export abstract class AgentHookServerIngestNormalization extends AgentHookServer const event = normalizeHookPayload(this.state, source, body, this.env) if ( event && - (source === 'opencode' || source === 'mimo-code') && - event.hookEventName === 'SessionStart' + event.hookEventName === 'SessionStart' && + isOpenCodeSharedServerPost(source, body) ) { // Why: a birth just arrived; bind it now instead of waiting out the poll interval. this.kickOpenCodeBinder() diff --git a/src/main/agent-hooks/server/server-ingest-terminal.ts b/src/main/agent-hooks/server/server-ingest-terminal.ts index 9a83465d3b4..63355e3592b 100644 --- a/src/main/agent-hooks/server/server-ingest-terminal.ts +++ b/src/main/agent-hooks/server/server-ingest-terminal.ts @@ -16,6 +16,10 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges connectionId?: string | null terminalHandle?: string payload: ParsedAgentStatusPayload + /** `process`: derived from the pane's foreground process rather than parsed from its bytes. */ + origin?: 'process' + /** Drop this write when a hook has reported the pane since then (the hook owns that command). */ + yieldsToHookSince?: number }): void { const physicalPaneKey = event.paneKey.trim() let paneKey = this.resolvePaneKeyAlias(physicalPaneKey) @@ -43,9 +47,19 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges return } const tabId = paneKey !== physicalPaneKey ? parsedPaneKey?.tabId : reportedTabId - if (this.getAgentStatusDisposition(paneKey) !== 'accept') { + // Why: a verified process-lifetime Working proves a new agent run, as a hook new-turn event does. + const disposition = this.getAgentStatusDisposition( + paneKey, + event.origin === 'process' && event.payload.state === 'working' + ? { processNewTurn: true } + : undefined + ) + if (disposition === 'suppress') { return } + if (disposition === 'restart') { + this.observations.rebind(paneKey) + } const worktreeId = event.worktreeId !== undefined && event.worktreeId.trim().length > 0 ? event.worktreeId.trim() @@ -82,15 +96,21 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges const previous = this.state.lastStatusByPaneKey.get(paneKey) as | EnrichedAgentHookEventPayload | undefined + // Why: a hook that reported during this command owns it (OpenCode 1 `run` loads its plugin in-process). + const hookOwnsCommand = + event.yieldsToHookSince !== undefined && + previous?.observation?.origin === 'hook' && + previous.receivedAt >= event.yieldsToHookSince if ( - previous?.payload.agentType === 'claude' && - event.payload.agentType === 'claude' && - isAgentStatusHeldOpenByChildWork(previous.payload) && - previous.payload.subagents?.some((subagent) => subagent.state === 'working') === true + hookOwnsCommand || + (previous?.payload.agentType === 'claude' && + event.payload.agentType === 'claude' && + isAgentStatusHeldOpenByChildWork(previous.payload) && + previous.payload.subagents?.some((subagent) => subagent.state === 'working') === true) ) { // Why: OSC carries no child identity, so it cannot settle or repaint a row child agents hold open // (working, or waiting on a child's prompt); their lifecycle hooks will. - if (mutationBefore !== undefined) { + if (previous && mutationBefore !== undefined) { this.commitStatusRowMutation(mutationBefore, previous) this.emitEnrichedStatus(previous) } @@ -155,7 +175,7 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges : event.payload }, undefined, - 'osc', + event.origin ?? 'osc', undefined, mutationBefore ) diff --git a/src/main/agent-hooks/server/server-opencode-binder.ts b/src/main/agent-hooks/server/server-opencode-binder.ts index 32f8e677539..48a75ba5483 100644 --- a/src/main/agent-hooks/server/server-opencode-binder.ts +++ b/src/main/agent-hooks/server/server-opencode-binder.ts @@ -35,7 +35,8 @@ export type OpenCodeBinderLoopDeps = { } /** - * Session→pane binder loop for the shared OpenCode server (#21359). + * Session→pane binder loop for the OpenCode 1 `serve` process shared by + * `attach` panes (#21359). OpenCode-1-only: remove with OpenCode 1 support. * * Sits just above persistence in the chain so ingest layers can kick a round * when a birth arrives early, and lifecycle can start/stop the timer. All diff --git a/src/main/agent-hooks/server/server-status-disposition.ts b/src/main/agent-hooks/server/server-status-disposition.ts index e8ac4ddd31e..ef633d7c2c8 100644 --- a/src/main/agent-hooks/server/server-status-disposition.ts +++ b/src/main/agent-hooks/server/server-status-disposition.ts @@ -50,6 +50,8 @@ export abstract class AgentHookServerStatusDisposition extends AgentHookServerSt isReplay?: boolean hasExplicitPrompt?: boolean launchToken?: string + /** A process-lifetime Working: a fresh command whose foreground argv proves a new agent run. */ + processNewTurn?: boolean } ): 'accept' | 'restart' | 'suppress' { const ownerPaneKey = this.resolvePaneKeyAlias(paneKey) @@ -94,7 +96,7 @@ export abstract class AgentHookServerStatusDisposition extends AgentHookServerSt return 'accept' } } - if (event && tokenFence) { + if (event && event.processNewTurn !== true && tokenFence) { const launchToken = event.launchToken?.trim() if (!launchToken || createHash('sha256').update(launchToken).digest('hex') !== tokenFence) { return 'suppress' @@ -134,7 +136,10 @@ export abstract class AgentHookServerStatusDisposition extends AgentHookServerSt // 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) { + if ( + (isNewTurn || freshOpenCodeFamilyPrompt || event?.processNewTurn === true) && + event?.isReplay !== true + ) { this.closedAgentStatusPaneKeys.delete(paneKey) this.closedAgentStatusPaneKeys.delete(ownerPaneKey) const launchToken = event?.launchToken?.trim() diff --git a/src/main/opencode/hook-plugin-opencode2-setup.test.ts b/src/main/opencode/hook-plugin-opencode2-setup.test.ts index e9e38334d3e..1ada5ea1a53 100644 --- a/src/main/opencode/hook-plugin-opencode2-setup.test.ts +++ b/src/main/opencode/hook-plugin-opencode2-setup.test.ts @@ -205,6 +205,7 @@ describe.each(['opencode', 'opencode2'] as const)('%s plugin on OpenCode 2', (ag expect(posts).toEqual( expect.arrayContaining([ expect.objectContaining({ + opencodeMajor: 2, payload: expect.objectContaining({ hook_event_name: 'SessionBusy' }) }), expect.objectContaining({ @@ -222,6 +223,33 @@ describe.each(['opencode', 'opencode2'] as const)('%s plugin on OpenCode 2', (ag expect(subscriptionSignal?.aborted).toBe(true) }) + // Why: the host's OpenCode 1 session binder must stay off OpenCode 2 posts only. + it('declares no OpenCode major on posts from the OpenCode 1 server() entry', async () => { + process.env.ORCA_PANE_KEY = 'tab-1:leaf-1' + const bodies: Record[] = [] + globalThis.fetch = vi.fn(async (_input, init) => { + bodies.push(record(JSON.parse(String(init?.body))) ?? {}) + return new Response('{}', { status: 200 }) + }) + const module = await loadPluginModule( + agent === 'opencode2' + ? _internals.getOpenCode2PluginSource() + : _internals.getOpenCodePluginSource() + ) + const hooks = await module.default?.server?.({ + client: { session: { get: async () => ({ data: { id: 'ses_root' } }) } } + }) + await hooks?.event({ + event: { + type: 'session.status', + properties: { sessionID: 'ses_root', status: { type: 'busy' } } + } + }) + await vi.waitFor(() => expect(bodies.length).toBeGreaterThan(0)) + expect(bodies.filter((body) => 'opencodeMajor' in body)).toEqual([]) + await hooks?.dispose?.() + }) + it('maps permission, form, and text events through the live setup bridge', async () => { process.env.ORCA_PANE_KEY = 'tab-1:leaf-1' const posts: { body: PostBody }[] = [] diff --git a/src/main/opencode/hook-plugin-opencode2-tui-ownership.test.ts b/src/main/opencode/hook-plugin-opencode2-tui-ownership.test.ts new file mode 100644 index 00000000000..29eefd77482 --- /dev/null +++ b/src/main/opencode/hook-plugin-opencode2-tui-ownership.test.ts @@ -0,0 +1,772 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' + +const { getPathMock } = vi.hoisted(() => ({ + getPathMock: vi.fn<(name: string) => string>() +})) + +vi.mock('electron', () => ({ + app: { + getPath: getPathMock + } +})) + +import { _internals } from './hook-service' + +type Post = { + paneKey?: string + opencodeMajor?: number + payload?: { hook_event_name?: string; sessionID?: string } +} +type BusEvent = { type: string; data: Record } +type PluginModule = { + default?: { setup?: (ctx: unknown) => Promise<(() => Promise) | undefined> } +} + +const PANE_A = 'tabA:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' +const PANE_B = 'tabB:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' +const SES_A = 'ses_f161fd85fffeieT0zYt80ZKorS' +const SES_B = 'ses_f161fcd7affeffV0OOrMCrTxtQ' +const ENV_KEYS = [ + 'ORCA_PANE_KEY', + 'ORCA_OPENCODE_AGENT', + 'ORCA_AGENT_HOOK_ENDPOINT', + 'ORCA_AGENT_HOOK_PORT', + 'ORCA_AGENT_HOOK_TOKEN' +] as const + +// Event shapes as OpenCode 2.0.14 delivers them to both server and TUI plugins. +function turn(sessionID: string, text: string): { start: BusEvent[]; finish: BusEvent[] } { + const assistantMessageID = `msg_${sessionID}_a` + return { + start: [ + { + type: 'session.created', + data: { sessionID, projectID: 'global', location: { directory: '/proj' }, subpath: '' } + }, + { + type: 'session.inbox.enqueued', + data: { + sessionID, + inboxID: `msg_${sessionID}_u`, + item: { type: 'user', payload: { text, files: [] }, delivery: 'steer' } + } + }, + { type: 'session.execution.started', data: { sessionID } }, + { type: 'session.step.started', data: { sessionID, assistantMessageID, agent: 'build' } }, + { type: 'session.text.started', data: { sessionID, assistantMessageID, ordinal: 0 } } + ], + finish: [ + { + type: 'session.text.ended', + data: { sessionID, assistantMessageID, ordinal: 0, text: 'tick0 tick1 ' } + }, + { type: 'session.step.ended', data: { sessionID, assistantMessageID, finish: 'stop' } }, + { type: 'session.execution.succeeded', data: { sessionID } } + ] + } +} + +type Blocker = { id: string; sessionID: string; [key: string]: unknown } + +function toBlocker(value: unknown): Blocker { + const record = typeof value === 'object' && value !== null ? { ...value } : {} + const id = 'id' in record ? String(record.id) : '' + const sessionID = 'sessionID' in record ? String(record.sessionID) : '' + return { ...record, id, sessionID } +} + +/** One pane's TUI: its route, its view of the shared session store, and the shared event bus. */ +function fakeTui(version = '2.0.14') { + const listeners = new Set<(event: { details: BusEvent }) => void>() + const sessions = new Map() + const running = new Set() + const permissions = new Map() + const forms = new Map() + // What the server answers when the TUI re-fetches a permission list (reconnect). + const serverPermissions = new Map() + let permissionFetch: Promise = Promise.resolve() + // Why: OpenCode keeps storage.memory across plugin hot reloads within one TUI process. + const memories = new Map() + let route: { type: string; sessionID?: string } = { type: 'home' } + const rootOf = (id: string): string => { + let current = sessions.get(id) + while (current?.parentID && sessions.has(current.parentID)) { + current = sessions.get(current.parentID) + } + return current?.id ?? id + } + const without = (map: Map, sessionID: string, id: unknown): void => { + map.set( + sessionID, + (map.get(sessionID) ?? []).filter((item) => item.id !== id) + ) + } + const listen = vi.fn((handler: (event: { details: BusEvent }) => void) => { + listeners.add(handler) + return () => listeners.delete(handler) + }) + const ctx = { + app: { version, channel: 'latest' }, + ui: { router: { current: () => route } }, + storage: { + memory: (key: string, options: { initial: Record }) => { + if (!memories.has(key)) { + const value = structuredClone(options.initial) + memories.set(key, [value, (mutate: (draft: typeof value) => void) => mutate(value)]) + } + return memories.get(key) + } + }, + client: { + session: { get: async ({ sessionID }: { sessionID: string }) => sessions.get(sessionID) } + }, + data: { + listen, + session: { + get: (id: string) => sessions.get(id), + root: rootOf, + family: (id: string) => + [...sessions.keys()].filter((member) => rootOf(member) === rootOf(id)), + status: (id: string) => (running.has(id) ? 'running' : 'idle'), + permission: { + list: (id: string) => permissions.get(id), + sync: async (id: string) => { + await permissionFetch + permissions.set(id, [...(serverPermissions.get(id) ?? [])]) + } + }, + form: { list: (id: string) => forms.get(id), sync: async () => {} } + } + } + } + return { + ctx, + listen, + serverPermissions, + permissions, + navigate(sessionID: string) { + route = { type: 'session', sessionID } + }, + // The session data stops reporting a run without this TUI seeing its end event. + loseEnd(sessionID: string) { + running.delete(sessionID) + }, + // The session data reports a run whose start this TUI never saw. + loseStart(sessionID: string) { + running.add(sessionID) + }, + // Holds the next permission list fetch until the returned release is called. + holdPermissionFetch(): () => void { + let release = (): void => {} + permissionFetch = new Promise((resolve) => { + release = resolve + }) + return release + }, + // Applies an event to the session data first, then to plugin listeners, as OpenCode does. + emit(event: BusEvent) { + const sessionID = String(event.data.sessionID) + if (event.type === 'session.created') { + const parentID = typeof event.data.parentID === 'string' ? event.data.parentID : undefined + sessions.set(sessionID, { id: sessionID, parentID }) + } else if (event.type === 'session.execution.started') { + running.add(sessionID) + } else if (event.type === 'session.execution.succeeded') { + running.delete(sessionID) + } else if (event.type === 'permission.asked') { + permissions.set(sessionID, [...(permissions.get(sessionID) ?? []), toBlocker(event.data)]) + } else if (event.type === 'permission.replied') { + without(permissions, sessionID, event.data.requestID) + } else if (event.type === 'form.created') { + const form = toBlocker(event.data.form) + forms.set(form.sessionID, [...(forms.get(form.sessionID) ?? []), form]) + } else if (event.type === 'form.replied' || event.type === 'form.cancelled') { + without(forms, sessionID, event.data.id) + } + for (const handler of listeners) { + handler({ details: event }) + } + } + } +} + +describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { + let tempDir: string + let savedFetch: typeof globalThis.fetch + let savedEnv: Record + let savedArgv: string[] + let posts: Post[] + // Every post in arrival order, including the start boundary each fresh TUI lands. + let allPosts: Post[] + let failPosts: boolean + let postDelayMs: number + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'orca-opencode-tui-adapter-')) + savedFetch = globalThis.fetch + savedArgv = process.argv + savedEnv = {} + for (const key of ENV_KEYS) { + savedEnv[key] = process.env[key] + } + process.env.ORCA_OPENCODE_AGENT = 'opencode' + delete process.env.ORCA_AGENT_HOOK_ENDPOINT + process.env.ORCA_AGENT_HOOK_PORT = '59999' + process.env.ORCA_AGENT_HOOK_TOKEN = 'test-token' + posts = [] + allPosts = [] + failPosts = false + postDelayMs = 0 + globalThis.fetch = vi.fn(async (_input, init) => { + const body = JSON.parse(String(init?.body)) + if (postDelayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, postDelayMs)) + } + if (failPosts) { + return new Response('{}', { status: 500 }) + } + allPosts.push(body) + // Why apart: a fresh TUI's start boundary names no session; per-session cases assert what follows it. + if ( + body.payload?.hook_event_name !== 'SessionStart' || + body.payload.sessionID !== undefined + ) { + posts.push(body) + } + return new Response('{}', { status: 200 }) + }) + }) + + afterEach(() => { + globalThis.fetch = savedFetch + process.argv = savedArgv + for (const key of ENV_KEYS) { + if (savedEnv[key] === undefined) { + delete process.env[key] + } else { + process.env[key] = savedEnv[key] + } + } + rmSync(tempDir, { recursive: true, force: true }) + }) + + async function loadPlugin(dir = tempDir): Promise { + // Why: a unique basename per load defeats the ESM module cache, like a separate process. + const pluginPath = join(dir, `orca-opencode-status-${Math.random().toString(36).slice(2)}.mjs`) + writeFileSync(pluginPath, _internals.getOpenCodePluginSource()) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the generated module's default export is exercised below and fails the test if absent. + return (await import(pathToFileURL(pluginPath).href)) as PluginModule + } + + const summary = (list: Post[]): string[] => + list.map((post) => `${post.payload?.hook_event_name}:${post.payload?.sessionID}`) + + async function runPane( + paneKey: string, + ownSession: string, + script: (tui: ReturnType) => Promise + ): Promise { + process.env.ORCA_PANE_KEY = paneKey + const start = posts.length + const tui = fakeTui() + const cleanup = await (await loadPlugin()).default?.setup?.(tui.ctx) + await script(tui) + await vi.waitFor(() => { + expect(summary(posts.slice(start)).at(-1)).toBe(`SessionIdle:${ownSession}`) + }) + await cleanup?.() + return posts.slice(start) + } + + const tick = (ms = 20): Promise => new Promise((resolve) => setTimeout(resolve, ms)) + const pump = async (tui: ReturnType, events: BusEvent[]): Promise => { + for (const event of events) { + tui.emit(event) + await tick() + } + } + + // Captured s2 shape: pane A's long turn overlapped by pane B's short one on one server. + it('gives each overlapping pane only its own session and its own Idle', async () => { + const a = turn(SES_A, 'A long SLEEP=12') + const b = turn(SES_B, 'B short SLEEP=3') + const bus = [...a.start, ...b.start, ...b.finish, ...a.finish] + const paneA = await runPane(PANE_A, SES_A, async (tui) => { + tui.navigate(SES_A) + await pump(tui, bus) + }) + const paneB = await runPane(PANE_B, SES_B, async (tui) => { + await pump(tui, [...a.start]) + tui.navigate(SES_B) + await pump(tui, [...b.start, ...b.finish, ...a.finish]) + }) + + expect(paneA.every((post) => post.paneKey === PANE_A)).toBe(true) + expect(paneB.every((post) => post.paneKey === PANE_B)).toBe(true) + // Keeps the host's OpenCode 1 session binder off these posts. + expect([...paneA, ...paneB].every((post) => post.opencodeMajor === 2)).toBe(true) + expect(new Set(paneA.map((post) => post.payload?.sessionID))).toEqual(new Set([SES_A])) + expect(new Set(paneB.map((post) => post.payload?.sessionID))).toEqual(new Set([SES_B])) + for (const [list, session] of [ + [paneA, SES_A], + [paneB, SES_B] + ] as const) { + const names = summary(list) + expect(names[0]).toBe(`SessionStart:${session}`) + expect(names).toContain(`SessionBusy:${session}`) + expect(names.at(-1)).toBe(`SessionIdle:${session}`) + } + }) + + // Captured s5 shape: the TUI's route reached the new session after execution had started. + it('hydrates a session whose route switch lands after execution started', async () => { + const b = turn(SES_B, 'B tui SLEEP=4') + const paneB = await runPane(PANE_B, SES_B, async (tui) => { + await pump(tui, b.start) + expect(posts).toHaveLength(0) + tui.navigate(SES_B) + await vi.waitFor(() => { + expect(summary(posts)).toContain(`SessionBusy:${SES_B}`) + }) + await pump(tui, b.finish) + }) + + const names = summary(paneB) + expect(names.slice(0, 3)).toEqual([ + `SessionStart:${SES_B}`, + `MessagePart:${SES_B}`, + `SessionBusy:${SES_B}` + ]) + expect(names.at(-1)).toBe(`SessionIdle:${SES_B}`) + }) + + it('keeps a started turn until it settles after the route moves on', async () => { + const a = turn(SES_A, 'A long') + const paneA = await runPane(PANE_A, SES_A, async (tui) => { + tui.navigate(SES_A) + await pump(tui, a.start) + tui.navigate('ses_other_idle_session') + await tick(250) + await pump(tui, a.finish) + }) + expect(summary(paneA)).toContain(`SessionBusy:${SES_A}`) + expect(summary(paneA).at(-1)).toBe(`SessionIdle:${SES_A}`) + }) + + it('reports nothing for sessions the pane never showed', async () => { + process.env.ORCA_PANE_KEY = PANE_A + const tui = fakeTui() + const cleanup = await (await loadPlugin()).default?.setup?.(tui.ctx) + const b = turn(SES_B, 'another pane') + await pump(tui, [...b.start, ...b.finish]) + await tick(250) + await cleanup?.() + expect(posts).toEqual([]) + }) + + // Why: a turn the TUI did not see end must not hold the pane Working (e.g. across a reconnect). + it('settles an owned turn once the session data says it ended without an end event', async () => { + process.env.ORCA_PANE_KEY = PANE_A + const tui = fakeTui() + const cleanup = await (await loadPlugin()).default?.setup?.(tui.ctx) + tui.navigate(SES_A) + await pump(tui, turn(SES_A, 'A').start) + tui.loseEnd(SES_A) + await vi.waitFor(() => { + expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`) + }) + await cleanup?.() + }) + + // Why: a turn that fails fast arrives as one burst; each level must post once, not flicker. + it('does not settle a turn early while its events are still queued', async () => { + process.env.ORCA_PANE_KEY = PANE_A + const tui = fakeTui() + const cleanup = await (await loadPlugin()).default?.setup?.(tui.ctx) + tui.navigate(SES_A) + await tick() + const a = turn(SES_A, 'A fails fast') + for (const event of [...a.start, ...a.finish]) { + tui.emit(event) + } + await vi.waitFor(() => { + expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`) + }) + await tick(250) + await cleanup?.() + const statuses = posts + .map((post) => post.payload?.hook_event_name) + .filter((name) => name === 'SessionBusy' || name === 'SessionIdle') + expect(statuses).toEqual(['SessionBusy', 'SessionIdle']) + }) + + it('re-derives Working for an owned session whose start it missed', async () => { + process.env.ORCA_PANE_KEY = PANE_A + const tui = fakeTui() + const cleanup = await (await loadPlugin()).default?.setup?.(tui.ctx) + tui.navigate(SES_A) + const first = turn(SES_A, 'A') + await pump(tui, [...first.start, ...first.finish]) + await vi.waitFor(() => { + expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`) + }) + tui.loseStart(SES_A) + await vi.waitFor(() => { + expect(summary(posts).at(-1)).toBe(`SessionBusy:${SES_A}`) + }) + tui.loseEnd(SES_A) + await vi.waitFor(() => { + expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`) + }) + await cleanup?.() + }) + + describe('derived from the session data', () => { + const SES_CHILD = 'ses_f161fe00affeChildSubagent1' + const statuses = (list: Post[]): string[] => + summary(list).filter((name) => + /^(SessionBusy|SessionIdle|PermissionRequest|AskUserQuestion):/.test(name) + ) + const start = async ( + tui: ReturnType + ): Promise<(() => Promise) | undefined> => { + process.env.ORCA_PANE_KEY = PANE_A + return (await loadPlugin()).default?.setup?.(tui.ctx) + } + const childStart = (parentID: string): BusEvent[] => [ + { type: 'session.created', data: { sessionID: SES_CHILD, parentID } }, + { type: 'session.execution.started', data: { sessionID: SES_CHILD } } + ] + const permission = (sessionID: string): BusEvent => ({ + type: 'permission.asked', + data: { id: 'per_1', sessionID, action: 'bash', resources: ['rm -rf build'] } + }) + + // Why: #23700 left this for TUI panes; a reload that misses the end must still reach Done. + it('shows Done for a turn that ended while the plugin was reloading', async () => { + const tui = fakeTui() + const firstGeneration = await start(tui) + tui.navigate(SES_A) + const a = turn(SES_A, 'A') + await pump(tui, a.start) + await vi.waitFor(() => expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`])) + const beforeReload = posts.length + await firstGeneration?.() + expect(posts).toHaveLength(beforeReload) + await pump(tui, a.finish) + const secondGeneration = await start(tui) + await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`)) + await secondGeneration?.() + expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`, `SessionIdle:${SES_A}`]) + }) + + it('keeps a navigated-away turn across a reload and settles it after', async () => { + const tui = fakeTui() + const firstGeneration = await start(tui) + tui.navigate(SES_A) + const a = turn(SES_A, 'A') + await pump(tui, a.start) + tui.navigate(SES_B) + await tick(150) + await firstGeneration?.() + const secondGeneration = await start(tui) + await tick(150) + expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`]) + await pump(tui, a.finish) + await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`)) + await secondGeneration?.() + }) + + it('re-posts a level the previous generation could not deliver', async () => { + const tui = fakeTui() + failPosts = true + const firstGeneration = await start(tui) + tui.navigate(SES_A) + await pump(tui, turn(SES_A, 'A').start) + await tick(50) + await firstGeneration?.() + failPosts = false + const secondGeneration = await start(tui) + await vi.waitFor(() => expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`])) + await secondGeneration?.() + }) + + // Why: with posts slower than events, the old adapter's decisions trailed the data and flickered. + it('posts each level once and in order while posts are slow', async () => { + postDelayMs = 120 + const tui = fakeTui() + const cleanup = await start(tui) + tui.navigate(SES_A) + const a = turn(SES_A, 'A') + const b = turn(SES_B, 'another pane') + for (const event of [...a.start, ...b.start, ...b.finish, ...a.finish]) { + tui.emit(event) + } + await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`), { + timeout: 3000 + }) + await tick(300) + await cleanup?.() + // The reply text lands after Busy and before Idle, so Done shows it. + expect(summary(posts)).toEqual([ + `SessionStart:${SES_A}`, + `MessagePart:${SES_A}`, + `SessionBusy:${SES_A}`, + `MessagePart:${SES_A}`, + `SessionIdle:${SES_A}` + ]) + expect(posts.at(-2)?.payload).toMatchObject({ role: 'assistant', text: 'tick0 tick1 ' }) + expect(summary(allPosts)[0]).toBe('SessionStart:undefined') + }) + + // Why: Orca may still show this pane a status another process left, e.g. an older shared-service + // plugin that posted another pane's turn here before an upgrade. + it('lands one start boundary in a freshly started TUI, and none on a reload after Done', async () => { + const tui = fakeTui() + const firstGeneration = await start(tui) + await vi.waitFor(() => expect(summary(allPosts)).toEqual(['SessionStart:undefined'])) + tui.navigate(SES_A) + const a = turn(SES_A, 'A') + await pump(tui, [...a.start, ...a.finish]) + await vi.waitFor(() => expect(summary(allPosts).at(-1)).toBe(`SessionIdle:${SES_A}`)) + await firstGeneration?.() + const secondGeneration = await start(tui) + await tick(250) + await secondGeneration?.() + expect(summary(allPosts).at(-1)).toBe(`SessionIdle:${SES_A}`) + expect(summary(allPosts).filter((name) => name.startsWith('SessionStart:'))).toEqual([ + 'SessionStart:undefined', + `SessionStart:${SES_A}` + ]) + }) + + it('lands the start boundary only once across reloads of an idle pane', async () => { + const tui = fakeTui() + const firstGeneration = await start(tui) + await vi.waitFor(() => expect(summary(allPosts)).toEqual(['SessionStart:undefined'])) + await firstGeneration?.() + const secondGeneration = await start(tui) + await tick(150) + await secondGeneration?.() + expect(summary(allPosts)).toEqual(['SessionStart:undefined']) + }) + + it('names the idle session a freshly started TUI shows', async () => { + const tui = fakeTui() + tui.navigate(SES_B) + const cleanup = await start(tui) + await vi.waitFor(() => expect(summary(allPosts)).toEqual([`SessionStart:${SES_B}`])) + await cleanup?.() + }) + + it('posts Working instead of the start boundary when the route shows a running turn', async () => { + const tui = fakeTui() + tui.navigate(SES_A) + tui.loseStart(SES_A) + const cleanup = await start(tui) + await vi.waitFor(() => expect(summary(allPosts)).toEqual([`SessionBusy:${SES_A}`])) + await tick(150) + await cleanup?.() + expect(summary(allPosts)).toEqual([`SessionBusy:${SES_A}`]) + }) + + // Why: without plugin memory a reload cannot be told from a start, so it must not reset a Done. + it('lands no start boundary where OpenCode keeps no plugin memory', async () => { + const tui = fakeTui() + Reflect.deleteProperty(tui.ctx, 'storage') + const cleanup = await start(tui) + await tick(150) + await cleanup?.() + expect(allPosts).toEqual([]) + }) + + it('stays Working while a child session holds the turn, even if its end is missed', async () => { + const tui = fakeTui() + const cleanup = await start(tui) + tui.navigate(SES_A) + const a = turn(SES_A, 'A spawns a background task') + await pump(tui, [...a.start, ...childStart(SES_A), ...a.finish]) + await tick(250) + expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`]) + tui.loseEnd(SES_CHILD) + await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`)) + await cleanup?.() + expect(posts.every((post) => post.payload?.sessionID === SES_A)).toBe(true) + }) + + it("shows a child's permission as Needs input on the root, then Working after the reply", async () => { + const tui = fakeTui() + const cleanup = await start(tui) + tui.navigate(SES_A) + const a = turn(SES_A, 'A') + await pump(tui, [...a.start, ...childStart(SES_A), permission(SES_CHILD)]) + await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`PermissionRequest:${SES_A}`)) + await pump(tui, [ + { type: 'permission.replied', data: { sessionID: SES_CHILD, requestID: 'per_1' } }, + { type: 'session.execution.succeeded', data: { sessionID: SES_CHILD } }, + ...a.finish + ]) + await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`)) + await cleanup?.() + expect(statuses(posts)).toEqual([ + `SessionBusy:${SES_A}`, + `PermissionRequest:${SES_A}`, + `SessionBusy:${SES_A}`, + `SessionIdle:${SES_A}` + ]) + }) + + // Why: Orca reads every MessagePart as Working, so reply text must not bury an open request. + it('keeps Needs input while the root streams text beside a waiting background child', async () => { + const tui = fakeTui() + const cleanup = await start(tui) + tui.navigate(SES_A) + const a = turn(SES_A, 'A spawns a background task') + await pump(tui, [...a.start, ...childStart(SES_A), permission(SES_CHILD)]) + await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`PermissionRequest:${SES_A}`)) + await pump(tui, a.finish) + await tick(300) + expect(summary(posts).at(-1)).toBe(`PermissionRequest:${SES_A}`) + await cleanup?.() + }) + + it('clears Needs input answered while disconnected once the TUI reconnects', async () => { + const tui = fakeTui() + const cleanup = await start(tui) + tui.navigate(SES_A) + await pump(tui, [...turn(SES_A, 'A').start, permission(SES_A)]) + await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`PermissionRequest:${SES_A}`)) + // The user browses away; the reply lands while this TUI is disconnected. + tui.navigate(SES_B) + await pump(tui, [{ type: 'server.connected', data: {} }]) + await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionBusy:${SES_A}`)) + await cleanup?.() + }) + + it('ignores a reply that a reconnect fetch restores', async () => { + const tui = fakeTui() + const cleanup = await start(tui) + tui.navigate(SES_A) + await pump(tui, [...turn(SES_A, 'A').start, permission(SES_A)]) + await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`PermissionRequest:${SES_A}`)) + const release = tui.holdPermissionFetch() + tui.serverPermissions.set(SES_A, [...(tui.permissions.get(SES_A) ?? [])]) + await pump(tui, [ + { type: 'server.connected', data: {} }, + { type: 'permission.replied', data: { sessionID: SES_A, requestID: 'per_1' } } + ]) + release() + await tick(250) + expect(tui.permissions.get(SES_A)).toHaveLength(1) + expect(summary(posts).at(-1)).toBe(`SessionBusy:${SES_A}`) + await cleanup?.() + }) + + // Why: an Orca restart moves the hook endpoint while the pane's level stays the same. + it('re-posts the current level once the hook endpoint moves', async () => { + vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] }) + try { + const tui = fakeTui() + const cleanup = await start(tui) + tui.navigate(SES_A) + await pump(tui, turn(SES_A, 'A').start) + vi.advanceTimersByTime(200) + await vi.waitFor(() => expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`])) + process.env.ORCA_AGENT_HOOK_PORT = '59998' + vi.advanceTimersByTime(5000) + await vi.waitFor(() => + expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`, `SessionBusy:${SES_A}`]) + ) + vi.advanceTimersByTime(5000) + await tick(50) + expect(statuses(posts)).toHaveLength(2) + await cleanup?.() + } finally { + vi.useRealTimers() + } + }) + + it('does not pin Needs input on a request the data kept after its turn ended', async () => { + const tui = fakeTui() + const cleanup = await start(tui) + tui.navigate(SES_A) + const a = turn(SES_A, 'A') + await pump(tui, [...a.start, permission(SES_A)]) + await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`PermissionRequest:${SES_A}`)) + await pump(tui, a.finish) + await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`)) + await tick(250) + expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`) + await cleanup?.() + }) + }) + + it.each([ + ['an OpenCode 1 TUI', () => fakeTui('1.18.33')], + ['the other pane variant', () => fakeTui()], + ['a TUI outside an Orca pane', () => fakeTui()] + ])('stays silent in %s', async (label, make) => { + if (label === 'the other pane variant') { + process.env.ORCA_OPENCODE_AGENT = 'opencode2' + } + process.env.ORCA_PANE_KEY = PANE_A + if (label === 'a TUI outside an Orca pane') { + delete process.env.ORCA_PANE_KEY + } + const tui = make() + const cleanup = await (await loadPlugin()).default?.setup?.(tui.ctx) + expect(tui.listen).not.toHaveBeenCalled() + await cleanup?.() + }) + + describe('server plugin stand-down', () => { + function serverContext() { + const subscribe = vi.fn(async function* () {}) + const hook = vi.fn(async () => ({ dispose: vi.fn() })) + return { + subscribe, + hook, + ctx: { + session: { hook, get: async () => undefined }, + event: { subscribe } + } + } + } + const installTuiCopy = (): void => { + mkdirSync(join(tempDir, 'orca-opencode-status-tui')) + writeFileSync(join(tempDir, 'orca-opencode-status-tui', 'tui.js'), '') + } + + it('reports nothing from a serve process when the TUI copy is installed beside it', async () => { + installTuiCopy() + process.argv = [...savedArgv, 'serve', '--service'] + const server = serverContext() + const cleanup = await (await loadPlugin()).default?.setup?.(server.ctx) + expect(server.subscribe).not.toHaveBeenCalled() + expect(server.hook).not.toHaveBeenCalled() + await cleanup?.() + }) + + it('stands down in a --standalone private server too', async () => { + installTuiCopy() + process.argv = [...savedArgv, 'serve', '--stdio', '--port', '0'] + const server = serverContext() + const cleanup = await (await loadPlugin()).default?.setup?.(server.ctx) + expect(server.subscribe).not.toHaveBeenCalled() + await cleanup?.() + }) + + // Why: an older SSH relay installs this file but not the TUI copy; nothing else would report. + it('keeps reporting from a serve process whose installer wrote no TUI copy', async () => { + process.argv = [...savedArgv, 'serve', '--service'] + const server = serverContext() + const cleanup = await (await loadPlugin()).default?.setup?.(server.ctx) + expect(server.subscribe).toHaveBeenCalled() + await cleanup?.() + }) + }) +}) diff --git a/src/main/opencode/hook-plugin-run-process-session-start.test.ts b/src/main/opencode/hook-plugin-run-process-session-start.test.ts new file mode 100644 index 00000000000..0854b8ddfa2 --- /dev/null +++ b/src/main/opencode/hook-plugin-run-process-session-start.test.ts @@ -0,0 +1,97 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { getPathMock } = vi.hoisted(() => ({ + getPathMock: vi.fn<(name: string) => string>() +})) + +vi.mock('electron', () => ({ + app: { getPath: getPathMock } +})) + +import { _internals } from './hook-service' + +type PluginHooks = { + event: (input: { event: { type: string; properties?: unknown } }) => Promise +} +type PluginModule = { OrcaOpenCodeStatusPlugin?: (ctx: unknown) => Promise } + +const ENV_KEYS = ['ORCA_PANE_KEY', 'ORCA_AGENT_HOOK_PORT', 'ORCA_AGENT_HOOK_TOKEN'] as const + +// OpenCode 1 `run` loads the plugin in-process; the host already shows the run as Working from the +// process, so a session-start idle row there would only blink the pane. +describe('OpenCode plugin session start inside a `run` process', () => { + let tempDir: string + let events: string[] + let savedEnv: Record + let savedArgv: string[] + let savedFetch: typeof globalThis.fetch + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'orca-opencode-run-process-')) + events = [] + savedEnv = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]])) + savedArgv = process.argv + savedFetch = globalThis.fetch + process.env.ORCA_PANE_KEY = 'tab-1:leaf-1' + process.env.ORCA_AGENT_HOOK_PORT = '45678' + process.env.ORCA_AGENT_HOOK_TOKEN = 'test-token' + globalThis.fetch = vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => { + const body: unknown = JSON.parse(String(init?.body)) + const payload = + typeof body === 'object' && body !== null && 'payload' in body ? body.payload : null + if (typeof payload === 'object' && payload !== null && 'hook_event_name' in payload) { + events.push(String(payload.hook_event_name)) + } + return new Response(null, { status: 204 }) + }) + }) + + afterEach(() => { + process.argv = savedArgv + globalThis.fetch = savedFetch + for (const key of ENV_KEYS) { + if (savedEnv[key] === undefined) { + delete process.env[key] + } else { + process.env[key] = savedEnv[key] + } + } + rmSync(tempDir, { recursive: true, force: true }) + }) + + async function sessionStartEvents(argv: string[]): Promise { + events = [] + process.argv = argv + const pluginPath = join(tempDir, `plugin-${Math.random().toString(36).slice(2)}.mjs`) + writeFileSync(pluginPath, _internals.getOpenCodePluginSource()) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the generated plugin module exports this factory; the test calls it once. + const module = (await import(pathToFileURL(pluginPath).href)) as PluginModule + const hooks = await module.OrcaOpenCodeStatusPlugin?.({}) + await hooks?.event({ + event: { type: 'session.created', properties: { info: { id: 'root' } } } + }) + return events + } + + it('posts no session start from an `opencode run` process', async () => { + await expect( + sessionStartEvents(['bun', '/$bunfs/root/opencode', 'run', 'fix the bug']) + ).resolves.toEqual([]) + await expect( + sessionStartEvents(['/usr/local/bin/opencode', '--log-level', 'INFO', 'run', 'hi']) + ).resolves.toEqual([]) + }) + + it('still posts it from the TUI and from `serve`', async () => { + await expect(sessionStartEvents(['bun', '/$bunfs/root/opencode'])).resolves.toEqual([ + 'SessionStart' + ]) + await expect( + sessionStartEvents(['bun', '/$bunfs/root/opencode', 'serve', '--port', '4096']) + ).resolves.toEqual(['SessionStart']) + }) +}) diff --git a/src/main/opencode/hook-service-startup-refresh.test.ts b/src/main/opencode/hook-service-startup-refresh.test.ts new file mode 100644 index 00000000000..b4b4b444f9b --- /dev/null +++ b/src/main/opencode/hook-service-startup-refresh.test.ts @@ -0,0 +1,134 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + utimesSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { setAppEnvironment } from '../../shared/app-environment' +import { OpenCodeHookService, openCode2HookService, getOpenCode2PluginSource } from './hook-service' +import { resolveOpenCodeConfigDirectory } from '../../shared/opencode-config-directory' + +const PLUGIN = 'orca-opencode2-status.js' +const TUI_ENTRY = join('orca-opencode2-status-tui', 'tui.js') + +describe('OpenCodeHookService.refreshInstalledPlugins (app start)', () => { + let userDataDir: string + const originalXdgConfigHome = process.env.XDG_CONFIG_HOME + + beforeAll(() => { + userDataDir = mkdtempSync(join(tmpdir(), 'orca-opencode-startup-refresh-')) + process.env.XDG_CONFIG_HOME = join(userDataDir, 'xdg') + }) + + beforeEach(() => { + setAppEnvironment({ + getPath: (name: string) => { + if (name === 'userData') { + return userDataDir + } + throw new Error(`unexpected getPath(${name})`) + }, + getAppPath: () => process.cwd(), + getVersion: () => '0.0.0-test', + isPackaged: () => false, + onWillQuit: () => {}, + exit: () => {}, + getAppMetrics: () => [] + }) + }) + + afterEach(() => { + rmSync(join(userDataDir, 'xdg'), { recursive: true, force: true }) + rmSync(join(userDataDir, 'opencode2-config-overlays'), { recursive: true, force: true }) + }) + + afterAll(() => { + if (originalXdgConfigHome === undefined) { + delete process.env.XDG_CONFIG_HOME + } else { + process.env.XDG_CONFIG_HOME = originalXdgConfigHome + } + rmSync(userDataDir, { recursive: true, force: true }) + }) + + // Why: QA saw an upgraded app keep a running service on the old plugin until any pane opened. + it('upgrades an existing install, TUI copy included, so a running service reloads it', () => { + const pluginsDir = join(resolveOpenCodeConfigDirectory(), 'plugins') + mkdirSync(pluginsDir, { recursive: true }) + writeFileSync(join(pluginsDir, PLUGIN), '// plugin from the previous Orca release') + + openCode2HookService.refreshInstalledPlugins() + + expect(readFileSync(join(pluginsDir, PLUGIN), 'utf8')).toBe(getOpenCode2PluginSource()) + expect(readFileSync(join(pluginsDir, TUI_ENTRY), 'utf8')).toBe(getOpenCode2PluginSource()) + }) + + it('never creates an install the user did not already have', () => { + openCode2HookService.refreshInstalledPlugins() + + expect(existsSync(resolveOpenCodeConfigDirectory())).toBe(false) + expect(existsSync(join(userDataDir, 'opencode2-config-overlays'))).toBe(false) + }) + + it('leaves a current install untouched so nothing reloads', () => { + const pluginsDir = join(resolveOpenCodeConfigDirectory(), 'plugins') + mkdirSync(join(pluginsDir, 'orca-opencode2-status-tui'), { recursive: true }) + writeFileSync(join(pluginsDir, PLUGIN), getOpenCode2PluginSource()) + writeFileSync(join(pluginsDir, TUI_ENTRY), getOpenCode2PluginSource()) + const past = new Date('2020-01-01T00:00:00Z') + utimesSync(join(pluginsDir, PLUGIN), past, past) + utimesSync(join(pluginsDir, TUI_ENTRY), past, past) + + openCode2HookService.refreshInstalledPlugins() + + expect(statSync(join(pluginsDir, PLUGIN)).mtimeMs).toBe(past.getTime()) + expect(statSync(join(pluginsDir, TUI_ENTRY)).mtimeMs).toBe(past.getTime()) + }) + + it('upgrades existing source overlays and skips overlays without Orca plugin', () => { + const overlays = join(userDataDir, 'opencode2-config-overlays') + const stale = join(overlays, 'stale', 'plugins') + const foreign = join(overlays, 'foreign', 'plugins') + mkdirSync(stale, { recursive: true }) + mkdirSync(foreign, { recursive: true }) + writeFileSync(join(stale, PLUGIN), '// plugin from the previous Orca release') + + openCode2HookService.refreshInstalledPlugins() + + expect(readFileSync(join(stale, PLUGIN), 'utf8')).toBe(getOpenCode2PluginSource()) + expect(readFileSync(join(stale, TUI_ENTRY), 'utf8')).toBe(getOpenCode2PluginSource()) + expect(existsSync(join(foreign, PLUGIN))).toBe(false) + }) + + it('keeps refreshing other installs when one write fails', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const service = new OpenCodeHookService({ + pluginFileName: PLUGIN, + legacyHooksDir: 'opencode2-hooks', + overlayDir: 'opencode2-config-overlays', + pluginSource: () => '// next release', + installsTuiPlugin: true + }) + const pluginsDir = join(resolveOpenCodeConfigDirectory(), 'plugins') + mkdirSync(pluginsDir, { recursive: true }) + writeFileSync(join(pluginsDir, PLUGIN), '// old') + // A file where the TUI copy's directory belongs makes the config-dir write fail. + writeFileSync(join(pluginsDir, 'orca-opencode2-status-tui'), 'obstruction') + const overlayPlugins = join(userDataDir, 'opencode2-config-overlays', 'a', 'plugins') + mkdirSync(overlayPlugins, { recursive: true }) + writeFileSync(join(overlayPlugins, PLUGIN), '// old') + + service.refreshInstalledPlugins() + + expect(readFileSync(join(overlayPlugins, PLUGIN), 'utf8')).toBe('// next release') + expect(warn).toHaveBeenCalled() + warn.mockRestore() + }) +}) diff --git a/src/main/opencode/hook-service.test.ts b/src/main/opencode/hook-service.test.ts index 2375cf02fd4..ff124eb0b95 100644 --- a/src/main/opencode/hook-service.test.ts +++ b/src/main/opencode/hook-service.test.ts @@ -129,6 +129,18 @@ describe('OpenCodeHookService buildPtyEnv / clearPty round-trip', () => { const pluginSource = readFileSync(pluginPath, 'utf8') expect(pluginSource).toContain('OrcaOpenCodeStatusPlugin') expect(pluginSource).toContain('messageID: part.messageID') + // Why: OpenCode 2 reports pane lifecycle from each TUI, which loads only plugin directories. + const tuiEntry = join( + resolveOpenCodeConfigDirectory(), + 'plugins', + 'orca-opencode-status-tui', + 'tui.js' + ) + expect(readFileSync(tuiEntry, 'utf8')).toBe(pluginSource) + const past = new Date('2020-01-01T00:00:00Z') + utimesSync(tuiEntry, past, past) + service.buildPtyEnv(daemonSessionId) + expect(statSync(tuiEntry).mtimeMs).toBe(past.getTime()) }) // Why: OpenCode 2 reloads a plugin whose file mtime changed, which restarted status mid-turn. @@ -176,6 +188,24 @@ describe('OpenCodeHookService buildPtyEnv / clearPty round-trip', () => { } ) + // Why: a service that reloads between the two writes must already find the TUI copy and stand down. + it('writes the TUI copy before the server plugin file', () => { + const pluginsDir = join(resolveOpenCodeConfigDirectory(), 'plugins') + const serverPath = join(pluginsDir, 'orca-opencode2-status.js') + const tuiDir = join(pluginsDir, 'orca-opencode2-status-tui') + rmSync(serverPath, { recursive: true, force: true }) + rmSync(tuiDir, { recursive: true, force: true }) + // A directory in the server file's place makes that write fail. + mkdirSync(serverPath, { recursive: true }) + try { + openCode2HookService.buildPtyEnv(daemonSessionId) + expect(existsSync(join(tuiDir, 'tui.js'))).toBe(true) + } finally { + rmSync(serverPath, { recursive: true, force: true }) + rmSync(tuiDir, { recursive: true, force: true }) + } + }) + // Why: #22234 — OpenCode 2 installs under the plain `opencode` name, and its loader // rejects a default export that only has server(). Asserting the emitted *source* is // not enough; the installed file is what the v2 server validates, so load it. @@ -227,6 +257,10 @@ describe('OpenCodeHookService buildPtyEnv / clearPty round-trip', () => { expect(module.default?.id).toBe('orca-opencode-status') expect(module.default?.server).toBeTypeOf('function') expect(module.default?.setup).toBeTypeOf('function') + // A service loading this dir stands down only when the TUI copy sits beside it. + expect( + readFileSync(join(legacyPluginPath, '..', 'orca-opencode-status-tui', 'tui.js'), 'utf8') + ).toBe(getOpenCodePluginSource()) }) it('repairs late and overwritten legacy plugins atomically on the same service', () => { @@ -241,7 +275,10 @@ describe('OpenCodeHookService buildPtyEnv / clearPty round-trip', () => { service.refreshLegacySharedPlugin() expect(readFileSync(path, 'utf8')).toBe(getOpenCodePluginSource()) expect(readFileSync(reader, 'utf8')).toBe(stale) - expect(readdirSync(join(path, '..'))).toEqual(['orca-opencode-status.js']) + expect(readdirSync(join(path, '..')).sort()).toEqual([ + 'orca-opencode-status-tui', + 'orca-opencode-status.js' + ]) } finally { closeSync(reader) } @@ -471,6 +508,21 @@ describe('OpenCodeHookService overlay mode (user OPENCODE_CONFIG_DIR set)', () = expectUserConfigIntact() }) + it('installs the TUI copy in the overlay without mirroring a user dir of the same name', () => { + const userTuiDir = join(userConfigDir, 'plugins', 'orca-opencode-status-tui') + mkdirSync(userTuiDir) + writeFileSync(join(userTuiDir, 'tui.js'), 'USER OWNED') + + const env = new OpenCodeHookService().buildPtyEnv(ptyId, userConfigDir) + + const overlayTui = join(env.OPENCODE_CONFIG_DIR!, 'plugins', 'orca-opencode-status-tui') + expect(lstatSync(overlayTui).isSymbolicLink()).toBe(false) + expect(readFileSync(join(overlayTui, 'tui.js'), 'utf8')).toBe( + readFileSync(join(env.OPENCODE_CONFIG_DIR!, 'plugins', 'orca-opencode-status.js'), 'utf8') + ) + expect(readFileSync(join(userTuiDir, 'tui.js'), 'utf8')).toBe('USER OWNED') + }) + it.skipIf(process.platform === 'win32')( 'does not write through a symlinked plugins/ directory into the user filesystem', () => { diff --git a/src/main/opencode/hook-service.ts b/src/main/opencode/hook-service.ts index 74f24577c6e..88007d05c88 100644 --- a/src/main/opencode/hook-service.ts +++ b/src/main/opencode/hook-service.ts @@ -13,15 +13,11 @@ import { } from 'node:fs' import { createHash } from 'node:crypto' import { mirrorEntry, safeRemoveTree } from '../pty/overlay-mirror' -import { getStatusPluginEndpointSource } from './status-plugin-endpoint-source' -import { getStatusPluginRuntimeStateSource } from './status-plugin-runtime-state-source' -import { getStatusPluginMessagePreviewSource } from './status-plugin-message-preview-source' -import { getStatusPluginSessionLineageSource } from './status-plugin-session-lineage-source' -import { getStatusPluginPostSource } from './status-plugin-post-source' -import { getStatusPluginDeliverySource } from './status-plugin-delivery-source' -import { getStatusPluginOwnershipSource } from './status-plugin-ownership-source' -import { getStatusPluginLifecycleSource } from './status-plugin-lifecycle-source' -import { getStatusPluginFactorySource } from './status-plugin-factory-source' +import { + getOpenCode2PluginSource, + getOpenCodeFamilyPluginSource, + getOpenCodePluginSource +} from './status-plugin-module-source' import { resolveOpenCodeConfigDirectory } from '../../shared/opencode-config-directory' import { getOpenCodeLegacySharedConfigDir, @@ -32,6 +28,12 @@ import { isInstalledOpenCodePluginCurrent, isOverlayOpenCodePluginCurrent } from '../../shared/opencode-installed-plugin' +import { + openCodeTuiPluginDirName, + writeOpenCodeTuiPlugin +} from '../../shared/opencode-tui-plugin-install' + +export { getOpenCode2PluginSource, getOpenCodeFamilyPluginSource, getOpenCodePluginSource } const ORCA_OPENCODE_PLUGIN_FILE = 'orca-opencode-status.js' const OPENCODE_OVERLAY_DIR = 'opencode-config-overlays' @@ -47,6 +49,8 @@ type OpenCodeHookVariant = { legacyHooksDir: string overlayDir: string pluginSource: () => string + /** Also install the module as an OpenCode 2 TUI plugin (never for forks without one). */ + installsTuiPlugin?: boolean } // Why: session IDs may contain path separators and are hashed downstream; cap pathological input. @@ -59,50 +63,13 @@ function toSafeDirName(id: string): string { return createHash('sha256').update(id).digest('hex').slice(0, 32) } -// Both major versions install as `opencode`; let the loader choose server() or setup(). -export function getOpenCodePluginSource(): string { - return getOpenCodeFamilyPluginSource('/hook/opencode', { - emitSessionStart: true, - emitNextEvents: true, - expectedAgent: 'opencode' - }) -} - -export function getOpenCode2PluginSource(): string { - return getOpenCodeFamilyPluginSource('/hook/opencode2', { - emitSessionStart: true, - emitNextEvents: true - }) -} - -export function getOpenCodeFamilyPluginSource( - hookPathname: string, - options: { - emitSessionStart: boolean - emitNextEvents?: boolean - expectedAgent?: 'opencode' | 'opencode2' - } -): string { - // Why: the plugin posts PTY environment data from OpenCode to the shared hooks server. - return [ - ...getStatusPluginEndpointSource(), - ...getStatusPluginRuntimeStateSource(), - ...getStatusPluginMessagePreviewSource(), - ...getStatusPluginSessionLineageSource(), - ...getStatusPluginPostSource(hookPathname), - ...getStatusPluginDeliverySource(), - ...getStatusPluginOwnershipSource(), - ...getStatusPluginLifecycleSource(), - ...getStatusPluginFactorySource(options) - ].join('\n') -} - // Why: installs the plugin into OpenCode's config discovery path so it POSTs to the shared agent-hooks server, unifying OpenCode status with Claude/Codex/Gemini. export class OpenCodeHookService { private readonly pluginSource: () => string private readonly pluginFileName: string private readonly legacyHooksDir: string private readonly overlayDir: string + private readonly installsTuiPlugin: boolean constructor(variant?: OpenCodeHookVariant | (() => string)) { const config: OpenCodeHookVariant = @@ -117,9 +84,11 @@ export class OpenCodeHookService { pluginFileName: ORCA_OPENCODE_PLUGIN_FILE, legacyHooksDir: OPENCODE_LEGACY_HOOKS_DIR, overlayDir: OPENCODE_OVERLAY_DIR, - pluginSource: getOpenCodePluginSource + pluginSource: getOpenCodePluginSource, + installsTuiPlugin: true }) this.pluginSource = config.pluginSource + this.installsTuiPlugin = config.installsTuiPlugin === true this.pluginFileName = config.pluginFileName this.legacyHooksDir = config.legacyHooksDir this.overlayDir = config.overlayDir @@ -162,10 +131,14 @@ export class OpenCodeHookService { // Why: pre-1.4.209 Orca left a server()-only plugin here that OpenCode 2 rejects. Only helps // processes that load it later; a running OpenCode 2 service keeps its cached module until restarted. refreshLegacySharedPlugin(): void { - const pluginPath = join(this.getSharedConfigDir(), 'plugins', this.pluginFileName) + const pluginsDir = join(this.getSharedConfigDir(), 'plugins') + const pluginPath = join(pluginsDir, this.pluginFileName) try { const source = this.pluginSource() - if (readFileSync(pluginPath, 'utf8') !== source) { + const installed = readFileSync(pluginPath, 'utf8') + // Why: a TUI or service still loading this dir needs the TUI copy too, or the service keeps reporting under its starter pane. + this.writeTuiPlugin(pluginsDir, source) + if (installed !== source) { writeFileAtomically(pluginPath, source) } } catch (error) { @@ -176,6 +149,31 @@ export class OpenCodeHookService { } } + // Why: a running OpenCode 2 service reloads a changed plugin file, so refreshing Orca's existing + // installs at app start upgrades it without waiting for the next pane. Never creates an install. + refreshInstalledPlugins(): void { + this.refreshLegacySharedPlugin() + const overlayRoot = this.getOverlayRoot() + const overlays = existsSync(overlayRoot) + ? readdirSync(overlayRoot).map((name) => join(overlayRoot, name)) + : [] + const configDir = resolveOpenCodeConfigDirectory() + for (const dir of [configDir, ...overlays]) { + if (!existsSync(join(dir, 'plugins', this.pluginFileName))) { + continue + } + try { + if (dir === configDir) { + this.writePluginToConfigDir(dir) + } else { + this.writePluginIntoOverlay(dir) + } + } catch (error) { + console.warn('[OpenCode] Failed to refresh status plugin:', dir, error) + } + } + } + private getOverlayRoot(): string { return join(getAppEnvironment().getPath('userData'), this.overlayDir) } @@ -257,7 +255,11 @@ export class OpenCodeHookService { mkdirSync(overlayPluginsDir, { recursive: true }) for (const pluginEntry of readdirSync(resolvedSource, { withFileTypes: true })) { // Why: skip a user plugin sharing Orca's filename; mirroring it would let writePluginIntoOverlay clobber the user's file. - if (pluginEntry.name === this.pluginFileName) { + if ( + pluginEntry.name === this.pluginFileName || + (this.installsTuiPlugin && + pluginEntry.name === openCodeTuiPluginDirName(this.pluginFileName)) + ) { continue } mirrorEntry( @@ -283,6 +285,7 @@ export class OpenCodeHookService { mkdirSync(pluginsDir, { recursive: true }) const pluginPath = join(pluginsDir, this.pluginFileName) const source = this.pluginSource() + this.writeTuiPlugin(pluginsDir, source) if (!isOverlayOpenCodePluginCurrent(pluginPath, source)) { try { unlinkSync(pluginPath) @@ -298,10 +301,17 @@ export class OpenCodeHookService { mkdirSync(pluginsDir, { recursive: true }) const pluginPath = join(pluginsDir, this.pluginFileName) const source = this.pluginSource() + this.writeTuiPlugin(pluginsDir, source) if (!isInstalledOpenCodePluginCurrent(pluginPath, source)) { writeFileSync(pluginPath, source) } } + + private writeTuiPlugin(pluginsDir: string, source: string): void { + if (this.installsTuiPlugin) { + writeOpenCodeTuiPlugin(pluginsDir, this.pluginFileName, source) + } + } } export const openCodeHookService = new OpenCodeHookService() @@ -309,7 +319,8 @@ export const openCode2HookService = new OpenCodeHookService({ pluginFileName: 'orca-opencode2-status.js', legacyHooksDir: OPENCODE2_LEGACY_HOOKS_DIR, overlayDir: 'opencode2-config-overlays', - pluginSource: getOpenCode2PluginSource + pluginSource: getOpenCode2PluginSource, + installsTuiPlugin: true }) export const _internals = { getOpenCodePluginSource, diff --git a/src/main/opencode/opencode-session-binder.ts b/src/main/opencode/opencode-session-binder.ts index 9d40ccb278c..0a693e6e928 100644 --- a/src/main/opencode/opencode-session-binder.ts +++ b/src/main/opencode/opencode-session-binder.ts @@ -14,12 +14,12 @@ import { readOpenCodeDatabase } from '../ai-vault/session-scanner-opencode-sqlit import { columnExists, tableExists } from '../opencode-usage/schema-helpers' import { splitWorktreeIdForFilesystem } from '../../shared/worktree/id' import { listRegisteredPtys } from '../memory/pty-registry' -import type SyncDatabase from '../sqlite/sync-database' import { isOpenCodeClientProcess, type ProcessIdentityRow } from './opencode-client-sweep' import type { HookListenerState } from '../../shared/agent-hook-listener/listener-state' /** - * Main-process binder feeding the session→pane registry (#21359). + * Main-process binder feeding the session→pane registry (#21359), for OpenCode 1 + * `serve` + `attach` only (see isOpenCodeSharedServerPost). * * Each round: read new sessions from the shared server's SQLite store, * snapshot panes, sweep for live clients, correlate, bind. Everything the @@ -212,15 +212,6 @@ export function runOpenCodeBinderRound(deps: BinderRoundDeps): BinderRoundResult return { ownerships } } -/** True when the v2 session table has every column the binder reads. */ -function canReadSessionV2(db: SyncDatabase): boolean { - return ( - tableExists(db, 'session_v2') && - columnExists(db, 'session_v2', 'directory') && - columnExists(db, 'session_v2', 'time_created') - ) -} - /** * Sessions newer than `cursor`, oldest first. The composite * `(time_created, id)` position means rows sharing a millisecond with the @@ -237,7 +228,9 @@ export function listOpenCodeDbSessions( return readOpenCodeDatabase({ dbPath, read: (db) => { - const table = canReadSessionV2(db) ? 'session_v2' : 'session' + // Why `session` only: OpenCode 1 writes it; OpenCode 2 writes `session_v2`, and its + // posts name their own pane, so its sessions must never bind. + const table = 'session' if ( !tableExists(db, table) || !columnExists(db, table, 'directory') || diff --git a/src/main/opencode/opencode-status-plugin-startup-refresh.test.ts b/src/main/opencode/opencode-status-plugin-startup-refresh.test.ts new file mode 100644 index 00000000000..da588a3f689 --- /dev/null +++ b/src/main/opencode/opencode-status-plugin-startup-refresh.test.ts @@ -0,0 +1,37 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { refreshV1, refreshV2 } = vi.hoisted(() => ({ + refreshV1: vi.fn<() => void>(), + refreshV2: vi.fn<() => void>() +})) +vi.mock('./hook-service', () => ({ + openCodeHookService: { refreshInstalledPlugins: refreshV1 }, + openCode2HookService: { refreshInstalledPlugins: refreshV2 } +})) + +import { refreshInstalledOpenCodeStatusPlugins } from './opencode-status-plugin-startup-refresh' + +describe('refreshInstalledOpenCodeStatusPlugins', () => { + beforeEach(() => { + refreshV1.mockClear() + refreshV2.mockClear() + }) + + it('refreshes both variants by default', () => { + refreshInstalledOpenCodeStatusPlugins(null) + expect(refreshV1).toHaveBeenCalledTimes(1) + expect(refreshV2).toHaveBeenCalledTimes(1) + }) + + it('touches nothing while agent status hooks are off', () => { + refreshInstalledOpenCodeStatusPlugins({ agentStatusHooksEnabled: false }) + expect(refreshV1).not.toHaveBeenCalled() + expect(refreshV2).not.toHaveBeenCalled() + }) + + it('skips a disabled variant only', () => { + refreshInstalledOpenCodeStatusPlugins({ disabledTuiAgents: ['opencode2'] }) + expect(refreshV1).toHaveBeenCalledTimes(1) + expect(refreshV2).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/opencode/opencode-status-plugin-startup-refresh.ts b/src/main/opencode/opencode-status-plugin-startup-refresh.ts new file mode 100644 index 00000000000..af88a0666a1 --- /dev/null +++ b/src/main/opencode/opencode-status-plugin-startup-refresh.ts @@ -0,0 +1,16 @@ +import { isAgentStatusHooksEnabledForAgent } from '../../shared/agent-status-hooks-setting' +import type { ManagedHookDetectionSettings } from '../agent-hooks/managed-hook-detection-commands' +import { openCode2HookService, openCodeHookService } from './hook-service' + +// Why: an Orca upgrade must reach an OpenCode 2 service that is already running, which reloads a +// changed plugin file, instead of waiting for the next pane spawn to rewrite it. +export function refreshInstalledOpenCodeStatusPlugins( + settings: ManagedHookDetectionSettings +): void { + if (isAgentStatusHooksEnabledForAgent(settings, 'opencode')) { + openCodeHookService.refreshInstalledPlugins() + } + if (isAgentStatusHooksEnabledForAgent(settings, 'opencode2')) { + openCode2HookService.refreshInstalledPlugins() + } +} diff --git a/src/main/opencode/status-plugin-factory-source.ts b/src/main/opencode/status-plugin-factory-source.ts index 218d15c3e41..43f063dde81 100644 --- a/src/main/opencode/status-plugin-factory-source.ts +++ b/src/main/opencode/status-plugin-factory-source.ts @@ -1,5 +1,5 @@ import { - getOpenCode2SetupSource, + getOpenCode2ModuleSource, getOpenCode2EventNormalizationSource } from '../opencode2/status-plugin-setup-source' @@ -103,6 +103,7 @@ export function getStatusPluginFactorySource(options: { ' const info = event.properties?.info;', ' if (!info?.id || info.parentID) return;', ' rememberSessionRoot(info.id, info.id);', + ' if (isOpenCodeRunProcess()) return; // a `run` goes Busy at once; its start row only blinks idle', ' await enqueueLifecycle(() =>', ' disposed ? undefined : post("SessionStart", { sessionID: info.id })', ' );', @@ -286,7 +287,7 @@ export function getStatusPluginFactorySource(options: { ' },', ' };', '};', - ...(options.emitNextEvents ? getOpenCode2SetupSource() : []), + ...(options.emitNextEvents ? getOpenCode2ModuleSource(pluginID, expectedAgent) : []), '', '// Why: OpenCode also resolves plugins through the module default export, and that', '// loader rejects the module unless the default exposes `server()` ("must default', diff --git a/src/main/opencode/status-plugin-module-source.ts b/src/main/opencode/status-plugin-module-source.ts new file mode 100644 index 00000000000..34030e3b1bf --- /dev/null +++ b/src/main/opencode/status-plugin-module-source.ts @@ -0,0 +1,47 @@ +import { getStatusPluginEndpointSource } from './status-plugin-endpoint-source' +import { getStatusPluginRuntimeStateSource } from './status-plugin-runtime-state-source' +import { getStatusPluginMessagePreviewSource } from './status-plugin-message-preview-source' +import { getStatusPluginSessionLineageSource } from './status-plugin-session-lineage-source' +import { getStatusPluginPostSource } from './status-plugin-post-source' +import { getStatusPluginDeliverySource } from './status-plugin-delivery-source' +import { getStatusPluginOwnershipSource } from './status-plugin-ownership-source' +import { getStatusPluginLifecycleSource } from './status-plugin-lifecycle-source' +import { getStatusPluginFactorySource } from './status-plugin-factory-source' + +// Both major versions install as `opencode`; let the loader choose server() or setup(). +export function getOpenCodePluginSource(): string { + return getOpenCodeFamilyPluginSource('/hook/opencode', { + emitSessionStart: true, + emitNextEvents: true, + expectedAgent: 'opencode' + }) +} + +export function getOpenCode2PluginSource(): string { + return getOpenCodeFamilyPluginSource('/hook/opencode2', { + emitSessionStart: true, + emitNextEvents: true + }) +} + +export function getOpenCodeFamilyPluginSource( + hookPathname: string, + options: { + emitSessionStart: boolean + emitNextEvents?: boolean + expectedAgent?: 'opencode' | 'opencode2' + } +): string { + // Why: the plugin posts PTY environment data from OpenCode to the shared hooks server. + return [ + ...getStatusPluginEndpointSource(), + ...getStatusPluginRuntimeStateSource(), + ...getStatusPluginMessagePreviewSource(), + ...getStatusPluginSessionLineageSource(), + ...getStatusPluginPostSource(hookPathname), + ...getStatusPluginDeliverySource(), + ...getStatusPluginOwnershipSource(), + ...getStatusPluginLifecycleSource(), + ...getStatusPluginFactorySource(options) + ].join('\n') +} diff --git a/src/main/opencode/status-plugin-post-source.ts b/src/main/opencode/status-plugin-post-source.ts index 0afb7826468..7c219857bca 100644 --- a/src/main/opencode/status-plugin-post-source.ts +++ b/src/main/opencode/status-plugin-post-source.ts @@ -16,6 +16,8 @@ export function getStatusPluginPostSource(hookPathname: string): string[] { ' worktreeId: process.env.ORCA_WORKTREE_ID || "",', ' env: coords.env,', ' version: coords.version,', + // opencodeMajor is set only by the OpenCode 2 setup() path, never read from OpenCode's version. + ' ...(reportingOpenCodeMajor ? { opencodeMajor: reportingOpenCodeMajor } : {}),', ' payload: { hook_event_name: hookEventName, ...(extraProperties || {}) },', ' });', ' const controller = new AbortController();', diff --git a/src/main/opencode/status-plugin-runtime-state-source.ts b/src/main/opencode/status-plugin-runtime-state-source.ts index 058ee2cdd07..071385e29cd 100644 --- a/src/main/opencode/status-plugin-runtime-state-source.ts +++ b/src/main/opencode/status-plugin-runtime-state-source.ts @@ -21,6 +21,8 @@ export function getStatusPluginRuntimeStateSource(): string[] { 'let busyRecoveryUsed = false;', 'let busyRecoveryEndpointKey = "";', 'let stateArrivalRevision = 0;', + '// Why: only OpenCode 2 calls setup(); its posts tell the host to keep its OpenCode 1 binder off them.', + 'let reportingOpenCodeMajor = 0;', '// Why: OpenCode can create directory-scoped factories and concurrent root', '// sessions in one pane; module ownership lets waiting/busy aggregate safely.', 'let nextFactoryID = 0;', @@ -52,6 +54,22 @@ export function getStatusPluginRuntimeStateSource(): string[] { 'let assistantPartFlushTimer = null;', 'let messagePartPostInFlight = null;', 'let deliveredMessagePartFactoryID = null;', - 'let lastAssistantPartPostAt = 0;' + 'let lastAssistantPartPostAt = 0;', + ...getRunProcessSource() ] } + +// Mirrors isOpenCodeRunCommand (src/shared/opencode-headless-command.ts) over this process's argv. +function getRunProcessSource(): string[] { + return String.raw` +function isOpenCodeRunProcess() { + // Why drop a leading path: a compiled binary reports its embedded entry script as argv[1]. + const args = process.argv.slice(1); + if (args.length > 0 && /[\\/]/.test(args[0])) args.shift(); + for (let index = 0; index < args.length; index += 1) { + if (!args[index].startsWith("-")) return args[index] === "run"; + if (args[index] === "--log-level") index += 1; + } + return false; +}`.split('\n') +} diff --git a/src/main/opencode2/status-plugin-setup-source.ts b/src/main/opencode2/status-plugin-setup-source.ts index 7b825056ecd..ab7c1a11f86 100644 --- a/src/main/opencode2/status-plugin-setup-source.ts +++ b/src/main/opencode2/status-plugin-setup-source.ts @@ -1,3 +1,15 @@ +import { getOpenCode2TuiSource } from './status-plugin-tui-source' +import { openCodeTuiPluginDirName } from '../../shared/opencode-tui-plugin-install' + +/** The OpenCode 2 entry points (server setup and TUI reporter) plus the constants they share. */ +export function getOpenCode2ModuleSource(pluginID: string, expectedAgent: string): string[] { + return [ + `const ORCA_TUI_PLUGIN_ENTRY = new URL("./${openCodeTuiPluginDirName(`${pluginID}.js`)}/tui.js", import.meta.url);`, + `const ORCA_STATUS_AGENT = "${expectedAgent}";`, + ...getOpenCode2SetupSource() + ] +} + export function getOpenCode2SetupSource(): string[] { return String.raw` // Why: OpenCode owns a form under a session id, and Orca retires a blocker when @@ -8,8 +20,70 @@ export function getOpenCode2SetupSource(): string[] { // session ids; when it does, this set stops matching and those forms block. const NON_SESSION_FORM_OWNERS = new Set(["global"]); +// Why one translation: the TUI reporter builds its Needs input payloads with it, so a blocker +// reaches Orca in the same shape whichever process reported it. +function translateOpenCode2Event(inputType, data) { + let type = inputType; + let properties = data || {}; + if (type === "session.created") { + properties = { info: { ...properties, id: properties.sessionID } }; + } else if (type === "session.execution.started") { + type = "session.status"; + properties = { ...properties, status: { type: "busy" } }; + } else if (type === "session.execution.succeeded" || type === "session.execution.failed" || type === "session.execution.interrupted") { + type = "session.status"; + properties = { ...properties, status: { type: "idle" } }; + } else if (type === "permission.asked") { + properties = { ...properties, permission: properties.action, patterns: properties.resources }; + } else if (type === "form.created") { + const form = properties.form; + // Why: block on every form whose owner is a real session. "metadata" is + // optional in OpenCode's schema and its "kind" is a convention no + // producer is obliged to stamp, so an unknown shape must surface a + // blocker the user can clear rather than vanish while OpenCode waits. + if (!form || NON_SESSION_FORM_OWNERS.has(form.sessionID)) return null; + // A malformed form must not throw: that would kill the subscription. + const fields = Array.isArray(form.fields) ? form.fields : []; + type = "question.asked"; + properties = { + ...form, + questions: fields.map((field) => ({ + header: field.title || form.title, + question: field.description || field.title || form.title, + options: (field.options || []).map((option) => ({ label: option.label || option.value, description: option.description || "" })), + multiple: field.type === "multiselect", + })), + }; + } else if (type === "form.replied" || type === "form.cancelled") { + // A resolution for an ignored form is inert: the blocker key carries the + // form id, so it simply matches nothing. + type = type === "form.replied" ? "question.replied" : "question.rejected"; + properties = { ...properties, requestID: properties.id }; + } else if (type === "session.text.started" || type === "session.text.delta" || type === "session.text.ended") { + type = type.replace("session.", "session.next."); + } + return { type, properties }; +} + +// Why: every OpenCode 2 server runs as a "serve" process (the shared service or a +// --standalone child) whose env names only the pane that spawned it, so each full TUI +// reports its own pane through the TUI copy of this plugin instead. +async function tuiReportsPaneLifecycle() { + if (!process.argv.includes("serve")) return false; + try { + const { statSync } = await import("node:fs"); + // Why: an installer that predates the TUI copy (e.g. an older SSH relay) writes only + // this file; without the TUI copy nothing else would report, so keep reporting. + return statSync(ORCA_TUI_PLUGIN_ENTRY).isFile(); + } catch { + return false; + } +} + async function setupOpenCode2Status(ctx) { + reportingOpenCodeMajor = 2; const noop = async () => {}; + if (isOpenCode2TuiContext(ctx)) return setupOpenCode2Tui(ctx); let hooks; // Why: OpenCode may probe setup() with no context during startup, and the setup // API shape can drift between releases. Never throw from setup — a throw surfaces @@ -17,6 +91,7 @@ async function setupOpenCode2Status(ctx) { // than silently running without status reporting. try { if (!ctx || typeof ctx.session?.hook !== "function" || typeof ctx.event?.subscribe !== "function") return noop; + if (await tuiReportsPaneLifecycle()) return noop; const controller = new AbortController(); // Why the envelope: OpenCode 2's plugin adapter unwraps a single-property // { data } success schema, so ctx.session.get resolves to the bare record — @@ -33,46 +108,9 @@ async function setupOpenCode2Status(ctx) { const consume = async () => { for await (const input of ctx.event.subscribe({ signal: controller.signal })) { if (controller.signal.aborted) break; - let type = input.type; - let properties = input.data; - if (type === "session.created") { - properties = { info: { ...properties, id: properties.sessionID } }; - } else if (type === "session.execution.started") { - type = "session.status"; - properties = { ...properties, status: { type: "busy" } }; - } else if (type === "session.execution.succeeded" || type === "session.execution.failed" || type === "session.execution.interrupted") { - type = "session.status"; - properties = { ...properties, status: { type: "idle" } }; - } else if (type === "permission.asked") { - properties = { ...properties, permission: properties.action, patterns: properties.resources }; - } else if (type === "form.created") { - const form = properties.form; - // Why: block on every form whose owner is a real session. "metadata" is - // optional in OpenCode's schema and its "kind" is a convention no - // producer is obliged to stamp, so an unknown shape must surface a - // blocker the user can clear rather than vanish while OpenCode waits. - if (!form || NON_SESSION_FORM_OWNERS.has(form.sessionID)) continue; - // A malformed form must not throw: that would kill the subscription. - const fields = Array.isArray(form.fields) ? form.fields : []; - type = "question.asked"; - properties = { - ...form, - questions: fields.map((field) => ({ - header: field.title || form.title, - question: field.description || field.title || form.title, - options: (field.options || []).map((option) => ({ label: option.label || option.value, description: option.description || "" })), - multiple: field.type === "multiselect", - })), - }; - } else if (type === "form.replied" || type === "form.cancelled") { - // A resolution for an ignored form is inert: the blocker key carries the - // form id, so it simply matches nothing. - type = type === "form.replied" ? "question.replied" : "question.rejected"; - properties = { ...properties, requestID: properties.id }; - } else if (type === "session.text.started" || type === "session.text.delta" || type === "session.text.ended") { - type = type.replace("session.", "session.next."); - } - await hooks.event({ event: { type, properties } }); + const translated = translateOpenCode2Event(input.type, input.data); + if (!translated) continue; + await hooks.event({ event: translated }); } }; const consuming = consume().catch((error) => { @@ -100,7 +138,9 @@ async function setupOpenCode2Status(ctx) { return noop; } } -`.split('\n') +` + .split('\n') + .concat(getOpenCode2TuiSource()) } export function getOpenCode2EventNormalizationSource(): string[] { diff --git a/src/main/opencode2/status-plugin-tui-source.ts b/src/main/opencode2/status-plugin-tui-source.ts new file mode 100644 index 00000000000..aff056d45de --- /dev/null +++ b/src/main/opencode2/status-plugin-tui-source.ts @@ -0,0 +1,281 @@ +/** + * TUI reporter for the OpenCode 2 status plugin. The module Orca writes as a server plugin is + * also installed as a TUI plugin; in a TUI process setup() lands here. The TUI runs in its + * pane's PTY, so every post names the right pane. It derives the pane's level from the TUI's + * own session data (which OpenCode keeps current, re-hydrating it on reconnect) instead of + * inferring it from events, and reuses only the plugin's delivery functions. + */ +export function getOpenCode2TuiSource(): string[] { + return String.raw` +const TUI_TICK_MS = 100; +const TUI_EARLY_ROOTS_MAX = 32; +const TUI_RESOLVED_REQUESTS_MAX = 256; +const TUI_ENDPOINT_CHECK_TICKS = 50; + +function isOpenCode2TuiContext(ctx) { + return typeof ctx?.ui?.router?.current === "function" && typeof ctx?.data?.listen === "function"; +} + +function boundedSet(set, value, max) { + set.delete(value); + set.add(value); + if (set.size > max) set.delete(set.values().next().value); +} + +// Why storage.memory: OpenCode keeps it across plugin hot reloads and drops it when the TUI +// exits, so a reload mid-turn keeps this pane's sessions and what it last reported. +function paneStatusMemory(ctx) { + const initial = { owned: [], last: "idle:", lastRoot: "", started: false }; + if (typeof ctx.storage?.memory === "function") return ctx.storage.memory("pane-status", { initial }); + // Why started: without memory a reload looks like a TUI start, and must not reset the pane. + const local = { ...initial, started: true }; + return [local, (mutate) => mutate(local)]; +} + +async function setupOpenCode2Tui(ctx) { + const noop = async () => {}; + // Why: post() needs this pane's key, so a TUI outside an Orca pane has nothing to report. + if (!process.env.ORCA_PANE_KEY) return noop; + if (process.env.ORCA_OPENCODE_AGENT && process.env.ORCA_OPENCODE_AGENT !== ORCA_STATUS_AGENT) return noop; + // Why: OpenCode 1 loads no plugin directories, but refusing it here keeps a future 1.x + // loader from running a second producer beside the 1.x server plugin. + if (/^1\./.test(String(ctx.app?.version || ""))) return noop; + const data = ctx.data.session; + if (typeof data?.status !== "function" || typeof data.root !== "function") return noop; + let factoryID; + try { + const [memory, setMemory] = paneStatusMemory(ctx); + factoryID = ++nextFactoryID; + activeFactoryIDs.add(factoryID); + let disposed = false; + let lastLevel = null; + let ticks = 0; + // Root sessions this TUI saw start before it owned them: their SessionStart and prompt. + const early = new Map(); + // Why: a permission/form list fetched on reconnect can land after the reply event and restore it. + const resolved = new Set(); + + const rootOf = (sessionID) => data.root(sessionID) || sessionID; + const family = (root) => new Set([root, ...(typeof data.family === "function" ? data.family(root) || [] : [])]); + const running = (root) => [...family(root)].some((id) => data.status(id) === "running"); + const currentRoute = () => { + const route = ctx.ui.router.current(); + return route?.type === "session" && typeof route.sessionID === "string" ? rootOf(route.sessionID) : undefined; + }; + // First open permission, else first form, across the root's family. + const blocker = (root) => { + let form; + for (const member of family(root)) { + const permission = (data.permission?.list?.(member) || []).find((request) => !resolved.has(request.id)); + if (permission) return { request: permission, isPermission: true }; + form ??= (data.form?.list?.(member) || []).find((request) => !resolved.has(request.id)); + } + return form ? { request: form, isPermission: false } : null; + }; + const levelKey = (level) => + level.kind === "waiting" ? "waiting:" + level.blocker.request.id + ":" + level.root : level.kind + ":" + level.root; + + function own(root, owned) { + const seen = early.get(root); + early.delete(root); + if (seen?.created) void enqueueLifecycle(() => post("SessionStart", { sessionID: root })); + if (seen?.prompt) postPrompt(root, seen.prompt); + return [...owned, root]; + } + + // Why derive, not infer: OpenCode's session data is the single copy of running/blocked/lineage + // and self-corrects on reconnect; nothing here latches a start or end event. + function derive() { + const route = currentRoute(); + let owned = [...memory.owned]; + if (route && !owned.includes(route) && running(route)) owned = own(route, owned); + // Why keep a root past navigation: a pane that started a turn must still reach Done + // when the user browses to another session mid-turn. + owned = owned.filter((root) => root === route || running(root)); + if (owned.join("\n") !== memory.owned.join("\n")) setMemory((draft) => { draft.owned = owned; }); + const active = owned.filter(running); + for (const root of active) { + // Why only while running: a blocker the session data kept after its turn ended is stale. + const found = blocker(root); + if (found) return { kind: "waiting", root, blocker: found }; + } + const busy = active.at(-1); + return busy ? { kind: "busy", root: busy } : { kind: "idle", root: memory.lastRoot }; + } + + function publish() { + if (disposed) return; + let level; + try { + level = derive(); + } catch { + // Why: a data read that throws must not kill the listener or the tick. + return; + } + const key = levelKey(level); + if (key === memory.last) return; + setMemory((draft) => { + draft.last = key; + if (level.kind !== "idle") draft.lastRoot = level.root; + }); + lastLevel = level; + // Why the lifecycle queue: posts keep the order the levels were derived in. + void enqueueLifecycle(() => deliver(level, true)); + } + + async function deliver(level, changed) { + // Retires assistant text queued under the previous level (see flushPendingAssistantPart). + if (changed) stateArrivalRevision += 1; + const properties = level.root ? { sessionID: level.root } : {}; + if (level.kind === "waiting") { + const { request, isPermission } = level.blocker; + const translated = isPermission + ? translateOpenCode2Event("permission.asked", request) + : translateOpenCode2Event("form.created", { form: request }); + if (!translated) return; + const hookEventName = isPermission ? "PermissionRequest" : "AskUserQuestion"; + await setAttention(hookEventName, { ...translated.properties, ...properties }, factoryID, request.sessionID); + return; + } + // Why flush first: the done-state preview must show the completed reply. + if (level.kind === "idle") await flushPendingAssistantPart(true); + await setStatus(level.kind, properties, factoryID); + } + + function postPrompt(root, prompt) { + void enqueueLifecycle(() => + postMessagePart({ role: "user", text: capMessagePartText(prompt.text), messageID: prompt.messageID, sessionID: root }, factoryID), + ); + } + + function remember(root, update) { + const seen = { ...(early.get(root) || {}), ...update }; + early.delete(root); + early.set(root, seen); + if (early.size > TUI_EARLY_ROOTS_MAX) early.delete(early.keys().next().value); + } + + // Content only; status comes from derive(). + function observe(event) { + const properties = event.data || {}; + const sessionID = properties.sessionID; + if (event.type === "server.connected") { + // Why: OpenCode re-syncs blockers only for the sessions it displays; an owned session the + // user navigated away from would otherwise keep a request answered while disconnected. + for (const root of memory.owned) { + for (const member of family(root)) { + void data.permission?.sync?.(member)?.catch?.(() => {}); + void data.form?.sync?.(member)?.catch?.(() => {}); + } + } + return; + } + if (event.type === "permission.replied") return boundedSet(resolved, properties.requestID, TUI_RESOLVED_REQUESTS_MAX); + if (event.type === "form.replied" || event.type === "form.cancelled") { + return boundedSet(resolved, properties.id, TUI_RESOLVED_REQUESTS_MAX); + } + if (typeof sessionID !== "string" || !sessionID) return; + if (event.type === "session.deleted") { + early.delete(sessionID); + if (memory.owned.includes(sessionID)) { + setMemory((draft) => { draft.owned = draft.owned.filter((id) => id !== sessionID); }); + } + return; + } + if (event.type === "session.created") { + if (!properties.parentID) remember(sessionID, { created: true }); + return; + } + const root = rootOf(sessionID); + const isOwned = memory.owned.includes(root); + if (event.type === "session.inbox.enqueued") { + // Why TUI-only: the server takes the prompt from session.hook("prompt"), which a TUI lacks. + const item = properties.item; + if (sessionID !== root || item?.type !== "user" || typeof item.payload?.text !== "string" || !item.payload.text) return; + const prompt = { text: item.payload.text, messageID: properties.inboxID }; + if (!isOwned) return remember(root, { prompt }); + if (!memory.last.startsWith("waiting:")) postPrompt(root, prompt); + return; + } + if (event.type === "session.text.ended" && isOwned && sessionID === root && typeof properties.text === "string" && properties.text) { + // Why: Orca reads any MessagePart as Working, which would bury this pane's Needs input. + if (memory.last.startsWith("waiting:")) return; + const part = { role: "assistant", text: properties.text, messageID: properties.assistantMessageID, sessionID: root, factoryID }; + // Why queued: the reply must not overtake this turn's SessionStart, prompt or Busy. + void enqueueLifecycle(() => queueAssistantPart({ ...part, authorityRevision: stateArrivalRevision })); + } + } + + // Why synchronous: OpenCode applies each event to its session data before plugin listeners + // run, so deciding here never trails the data, and no queue of events can build up. + const unsubscribe = ctx.data.listen(({ details } = {}) => { + if (disposed || !details || typeof details.type !== "string") return; + try { + observe(details); + publish(); + } catch { + // A malformed event must not break the listener. + } + }); + // Why a tick: a route change has no event, and a reconnect re-hydrates the data without one. + const tick = setInterval(() => { + try { + publish(); + // Why: an Orca restart moves the hook endpoint; the delivery layer re-posts an unchanged + // level only when asked, which the old event-driven path did on every lifecycle event. + if (++ticks % TUI_ENDPOINT_CHECK_TICKS === 0 && lastLevel && desiredFactoryID === factoryID && deliveredEndpointKey !== hookEndpointKey()) { + const level = lastLevel; + void enqueueLifecycle(() => deliver(level, false)); + } + } catch {} + }, TUI_TICK_MS); + if (tick.unref) tick.unref(); + publish(); + if (!memory.started) { + setMemory((draft) => { draft.started = true; }); + // Why: clears a status an earlier process left on this pane (e.g. a pre-upgrade shared service + // posting another pane's turn here). Connected idle, never a completion; reloads skip it. + if (memory.last === "idle:") { + const route = currentRoute(); + void enqueueLifecycle(() => post("SessionStart", route ? { sessionID: route } : {})); + } + } + return async () => { + try { + disposed = true; + clearInterval(tick); + if (typeof unsubscribe === "function") unsubscribe(); + // Why publish nothing: a hot reload disposes this mid-turn and the next generation + // re-derives from the same memory. Unconfirmed delivery is re-derived by that generation. + await releaseTuiStatusDelivery(factoryID, () => setMemory((draft) => { draft.last = ""; })); + } catch { + // Why: cleanup runs during plugin unload; a throw here also fails the plugin. + } + }; + } catch { + if (factoryID !== undefined) activeFactoryIDs.delete(factoryID); + return noop; + } +} + +// Why queued: levels derived before disposal still post, in order, before the identity retires. +function releaseTuiStatusDelivery(factoryID, forgetUndelivered) { + return enqueueLifecycle(async () => { + disposingFactoryIDs.add(factoryID); + while (messagePartPostInFlight) await messagePartPostInFlight; + if (pendingAssistantPart?.factoryID === factoryID) { + if (assistantPartFlushTimer) clearTimeout(assistantPartFlushTimer); + assistantPartFlushTimer = null; + pendingAssistantPart = null; + } + if (desiredFactoryID === factoryID) { + if (statusDeliveryDirty) forgetUndelivered(); + clearStatusRetry(); + statusRevision += 1; + } + activeFactoryIDs.delete(factoryID); + disposingFactoryIDs.delete(factoryID); + }); +} +`.split('\n') +} diff --git a/src/main/providers/agent-foreground-command-line.test.ts b/src/main/providers/agent-foreground-command-line.test.ts new file mode 100644 index 00000000000..b7479d15461 --- /dev/null +++ b/src/main/providers/agent-foreground-command-line.test.ts @@ -0,0 +1,72 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { execFileMock } = vi.hoisted(() => ({ + execFileMock: vi.fn() +})) + +vi.mock('child_process', () => ({ + execFile: execFileMock +})) + +import { resetProcessTableSnapshotForTests } from '../../shared/process-table-snapshot-reader' +import { resolveAgentForegroundCommandLine } from './agent-foreground-process' + +// Why: the POSIX reader wraps execFile with promisify, so the mock honors the Node callback contract. +function mockPs(rows: string[]): void { + execFileMock.mockImplementation( + ( + _cmd: string, + _args: string[], + _opts: unknown, + callback: (err: unknown, result: { stdout: string; stderr: string }) => void + ) => callback(null, { stdout: rows.join('\n'), stderr: '' }) + ) +} + +describe('resolveAgentForegroundCommandLine', () => { + let platform: PropertyDescriptor | undefined + + beforeEach(() => { + execFileMock.mockReset() + resetProcessTableSnapshotForTests() + platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) + }) + + afterEach(() => { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + }) + + it("returns the foreground agent's full command line", async () => { + mockPs([ + '100 99 100 101 Ss /bin/zsh -l', + '101 100 101 101 S+ /opt/homebrew/bin/opencode run fix the bug', + '102 100 102 101 S node background.js' + ]) + + await expect(resolveAgentForegroundCommandLine(100)).resolves.toBe( + '/opt/homebrew/bin/opencode run fix the bug' + ) + }) + + it('returns null when no agent holds the foreground', async () => { + mockPs([ + '100 99 100 102 Ss /bin/zsh -l', + '101 100 101 102 T opencode run paused', + '102 100 102 102 S+ vim notes.txt' + ]) + + await expect(resolveAgentForegroundCommandLine(100)).resolves.toBeNull() + }) + + it('returns null when the capture fails', async () => { + execFileMock.mockImplementation( + (_cmd: string, _args: string[], _opts: unknown, callback: (err: unknown) => void) => + callback(new Error('ps failed')) + ) + + await expect(resolveAgentForegroundCommandLine(100)).resolves.toBeNull() + }) +}) diff --git a/src/main/providers/agent-foreground-process.ts b/src/main/providers/agent-foreground-process.ts index 84b147ca3e9..da6929ff410 100644 --- a/src/main/providers/agent-foreground-process.ts +++ b/src/main/providers/agent-foreground-process.ts @@ -13,7 +13,10 @@ import { type AgentForegroundResolutionOptions } from './windows-agent-foreground-process' import { isShellProcess } from '../../shared/shell-process-detection' -import { selectForegroundProcessCandidate } from '../../shared/foreground-process-selection' +import { + selectForegroundProcessCandidate, + type SelectedForegroundProcess +} from '../../shared/foreground-process-selection' import { isWindowsShellAloneInJob } from './windows-shell-alone-in-job' import { readWindowsProcessIdentityTableFresh, @@ -180,6 +183,35 @@ export function resolveAgentForegroundProcessFromPs( rows: readonly ProcessTableRow[], shellPid: number ): string | null { + const found = selectAgentForegroundFromPs(rows, shellPid) + // Why: return the outer wrapper (omp) rather than the deeper wrapped child + // (pi) of a shell→omp→pi tree — see resolveOuterWrapperForegroundProcess. + return found + ? resolveOuterWrapperForegroundProcess( + found.selected.recognized, + found.selected.candidate, + found.candidates + ) + : null +} + +/** The full command line of the pane's foreground agent process, for argv-level policy. */ +export async function resolveAgentForegroundCommandLine(shellPid: number): Promise { + try { + const rows = await getFreshShellForegroundSnapshot() + return selectAgentForegroundFromPs(rows, shellPid)?.selected.candidate.command ?? null + } catch { + return null + } +} + +function selectAgentForegroundFromPs( + rows: readonly ProcessTableRow[], + shellPid: number +): { + selected: SelectedForegroundProcess + candidates: (ProcessTableRow & { depth: number })[] +} | null { // Memoized per snapshot identity, so the caller's own index build is reused. const index = getProcessTableIndex(rows) const shellRow = index.byPid.get(shellPid) @@ -198,10 +230,5 @@ export function resolveAgentForegroundProcessFromPs( // helper is filtered from selection but must remain traversable. const ancestryCandidates = shellRow ? [{ ...shellRow, depth: 0 }, ...candidates] : candidates const selected = selectForegroundProcessCandidate(foregroundCandidates, ancestryCandidates) - if (selected) { - // Why: return the outer wrapper (omp) rather than the deeper wrapped child - // (pi) of a shell→omp→pi tree — see resolveOuterWrapperForegroundProcess. - return resolveOuterWrapperForegroundProcess(selected.recognized, selected.candidate, candidates) - } - return null + return selected ? { selected, candidates } : null } diff --git a/src/main/providers/windows-agent-foreground-command-line.test.ts b/src/main/providers/windows-agent-foreground-command-line.test.ts new file mode 100644 index 00000000000..1c2599dea44 --- /dev/null +++ b/src/main/providers/windows-agent-foreground-command-line.test.ts @@ -0,0 +1,60 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { __setWindowsProcessTreeLoaderForTests } from '../windows/windows-process-table' +import { resetWindowsProcessRowsSnapshotForTests } from './windows-foreground-process-rows' +import { resolveWindowsAgentForegroundCommandLine } from './windows-agent-foreground-command-line' + +type NativeProcessRow = { pid: number; ppid: number; name: string; commandLine?: string } + +const getAllProcessesMock = vi.fn() + +// Why: the native reader rejects a table without the querying process (a blocked snapshot). +function mockWindowsRows(rows: NativeProcessRow[]): void { + getAllProcessesMock.mockImplementation((cb: (snapshot: NativeProcessRow[]) => void) => { + cb([{ pid: process.pid, ppid: 0, name: 'vitest.exe', commandLine: 'vitest' }, ...rows]) + }) +} + +describe('resolveWindowsAgentForegroundCommandLine', () => { + let platform: PropertyDescriptor | undefined + + beforeEach(() => { + getAllProcessesMock.mockReset() + resetWindowsProcessRowsSnapshotForTests() + __setWindowsProcessTreeLoaderForTests(() => ({ + ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2, CreationTime: 4 }, + getAllProcesses: getAllProcessesMock + })) + platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + }) + + afterEach(() => { + __setWindowsProcessTreeLoaderForTests() + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + }) + + it("returns the pane's foreground agent command line from the native table", async () => { + mockWindowsRows([ + { pid: 100, ppid: 99, name: 'pwsh.exe', commandLine: 'pwsh.exe -NoLogo' }, + { + pid: 101, + ppid: 100, + name: 'opencode.exe', + commandLine: '"C:\\Program Files\\OpenCode\\opencode.exe" run fix the bug' + } + ]) + + await expect(resolveWindowsAgentForegroundCommandLine(100, 'opencode.exe')).resolves.toBe( + '"C:\\Program Files\\OpenCode\\opencode.exe" run fix the bug' + ) + }) + + it('returns null when the pane holds no such agent or the table misses the pane', async () => { + mockWindowsRows([{ pid: 100, ppid: 99, name: 'pwsh.exe', commandLine: 'pwsh.exe' }]) + await expect(resolveWindowsAgentForegroundCommandLine(100, 'opencode.exe')).resolves.toBeNull() + resetWindowsProcessRowsSnapshotForTests() + await expect(resolveWindowsAgentForegroundCommandLine(555, 'opencode.exe')).resolves.toBeNull() + }) +}) diff --git a/src/main/providers/windows-agent-foreground-command-line.ts b/src/main/providers/windows-agent-foreground-command-line.ts new file mode 100644 index 00000000000..a0858642d77 --- /dev/null +++ b/src/main/providers/windows-agent-foreground-command-line.ts @@ -0,0 +1,21 @@ +import { resolveWindowsForegroundIdentity } from './windows-agent-foreground-process' +import { queryWindowsPaneProcessInventory } from './windows-foreground-process-rows' + +/** Command line of the pane's foreground agent process, from one fresh native table read. */ +export async function resolveWindowsAgentForegroundCommandLine( + shellPid: number, + foregroundProcess: string +): Promise { + const inventory = await queryWindowsPaneProcessInventory(shellPid, { fresh: true }) + if (!inventory) { + return null + } + const { processId } = resolveWindowsForegroundIdentity( + inventory.candidates, + foregroundProcess, + undefined + ) + return processId === undefined + ? null + : (inventory.candidates.find((candidate) => candidate.pid === processId)?.command ?? null) +} diff --git a/src/main/providers/windows-agent-foreground-process.ts b/src/main/providers/windows-agent-foreground-process.ts index 8ffba73c24d..8a439562f71 100644 --- a/src/main/providers/windows-agent-foreground-process.ts +++ b/src/main/providers/windows-agent-foreground-process.ts @@ -143,7 +143,7 @@ function windowsCandidatesContainRecognizedAgent( ) } -function resolveWindowsForegroundIdentity( +export function resolveWindowsForegroundIdentity( candidates: readonly WindowsProcessCandidate[], fallbackProcess: string, contextPaths: readonly string[] | undefined diff --git a/src/main/runtime/local-pty-foreground-command-line.ts b/src/main/runtime/local-pty-foreground-command-line.ts new file mode 100644 index 00000000000..aa836d6f0f2 --- /dev/null +++ b/src/main/runtime/local-pty-foreground-command-line.ts @@ -0,0 +1,17 @@ +import { listRegisteredPtys } from '../memory/pty-registry' +import { resolveAgentForegroundCommandLine } from '../providers/agent-foreground-process' +import { resolveWindowsAgentForegroundCommandLine } from '../providers/windows-agent-foreground-command-line' + +/** Command line of a local PTY's foreground agent, read from this host's process table. */ +export async function readLocalPtyForegroundCommandLine( + ptyId: string, + foregroundProcess: string +): Promise { + const shellPid = listRegisteredPtys().find((pty) => pty.ptyId === ptyId)?.pid + if (!shellPid) { + return null + } + return process.platform === 'win32' + ? resolveWindowsAgentForegroundCommandLine(shellPid, foregroundProcess) + : resolveAgentForegroundCommandLine(shellPid) +} diff --git a/src/main/runtime/opencode-run-lifetime-runtime.test.ts b/src/main/runtime/opencode-run-lifetime-runtime.test.ts new file mode 100644 index 00000000000..4475abdb49f --- /dev/null +++ b/src/main/runtime/opencode-run-lifetime-runtime.test.ts @@ -0,0 +1,141 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { readCommandLineMock } = vi.hoisted(() => ({ readCommandLineMock: vi.fn() })) + +vi.mock('./local-pty-foreground-command-line', () => ({ + readLocalPtyForegroundCommandLine: readCommandLineMock +})) + +import { OrcaRuntimeService } from './orca-runtime' +import { FOREGROUND_COMMAND_READS } from '../../shared/foreground-command-settle' +import type { RuntimeTerminalAgentStatusEvent } from './runtime-terminal-contracts' + +const WORKTREE_ID = 'repo::/worktree' +const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const PANE_KEY = `tab-1:${LEAF_ID}` + +function createRuntime(): { + runtime: OrcaRuntimeService + statuses: RuntimeTerminalAgentStatusEvent[] + channelOrder: string[] +} { + const statuses: RuntimeTerminalAgentStatusEvent[] = [] + const channelOrder: string[] = [] + const runtime = new OrcaRuntimeService(undefined, undefined, { + onTerminalAgentStatus: (event) => { + statuses.push(event) + channelOrder.push(`status:${event.payload.state}`) + }, + onTerminalSideEffects: (batch) => + channelOrder.push(...batch.facts.map((fact) => `fact:${fact.kind}`)) + }) + runtime.setPtyController({ + spawn: vi.fn(), + write: () => true, + kill: () => true, + getForegroundProcess: async () => 'opencode' + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-1', + worktreeId: WORKTREE_ID, + title: 'Terminal', + activeLeafId: LEAF_ID, + layout: null + } + ], + leaves: [ + { tabId: 'tab-1', worktreeId: WORKTREE_ID, leafId: LEAF_ID, paneRuntimeId: 1, ptyId: 'pty-1' } + ] + }) + return { runtime, statuses, channelOrder } +} + +const summary = (statuses: RuntimeTerminalAgentStatusEvent[]): string[] => + statuses.map( + (event) => + `${event.paneKey}:${event.payload.state}${event.payload.interrupted ? ':interrupted' : ''}:${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', () => { + beforeEach(() => { + vi.useFakeTimers() + readCommandLineMock.mockReset() + readCommandLineMock.mockResolvedValue('opencode run fix the bug') + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('posts Working after the command starts and Done when it finishes, on its own pane', 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(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 () => { + const { runtime, statuses } = createRuntime() + runtime.registerPty('pty-1', WORKTREE_ID, 'ssh-conn-1') + + runtime.onPtyData('pty-1', '\x1b]133;C\x07', 100) + await vi.advanceTimersByTimeAsync(FOREGROUND_COMMAND_READS.settleMs) + runtime.onPtyData('pty-1', '\x1b]133;D;0\x07', 101) + + expect(statuses).toEqual([]) + expect(readCommandLineMock).not.toHaveBeenCalled() + }) + + it('reports a local Windows pane from its own foreground', async () => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + try { + const { runtime, statuses } = createRuntime() + readCommandLineMock.mockResolvedValue('"C:\\Tools\\opencode.exe" run fix it') + + runtime.onPtyData('pty-1', '\x1b]133;C\x07', 100) + 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`]) + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + }) +}) diff --git a/src/main/runtime/opencode-run-lifetime-status.test.ts b/src/main/runtime/opencode-run-lifetime-status.test.ts new file mode 100644 index 00000000000..efaf0d8f0c4 --- /dev/null +++ b/src/main/runtime/opencode-run-lifetime-status.test.ts @@ -0,0 +1,234 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ParsedAgentStatusPayload } from '../../shared/agent-status-types' +import { FOREGROUND_COMMAND_READS } from '../../shared/foreground-command-settle' +import { OpenCodeRunLifetimeStatus } from './opencode-run-lifetime-status' + +type Published = { ptyId: string; payload: ParsedAgentStatusPayload; yieldsToHookSince: number } + +function setup( + options: { + name?: string | null + commandLine?: string | null + observable?: boolean + enabledAgents?: readonly string[] + } = {} +) { + const published: Published[] = [] + const readForegroundProcessName = vi.fn(async () => + options.name === undefined ? 'opencode' : options.name + ) + const readForegroundCommandLine = vi.fn(async () => + options.commandLine === undefined ? 'opencode run fix the bug' : options.commandLine + ) + let clock = 1_000 + const lifetime = new OpenCodeRunLifetimeStatus({ + isObservablePty: () => options.observable ?? true, + isStatusEnabled: (agent) => + (options.enabledAgents ?? ['opencode', 'opencode2']).includes(agent), + readForegroundProcessName, + readForegroundCommandLine, + publish: (ptyId, payload, yieldsToHookSince) => + published.push({ ptyId, payload, yieldsToHookSince }), + now: () => clock++ + }) + const states = (): string[] => + published.map(({ payload }) => + payload.interrupted ? `${payload.state}:interrupted` : payload.state + ) + return { lifetime, published, states, readForegroundProcessName, readForegroundCommandLine } +} + +async function settle(): Promise { + await vi.advanceTimersByTimeAsync(FOREGROUND_COMMAND_READS.settleMs) +} + +describe('OpenCodeRunLifetimeStatus', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('shows Working while `opencode run` holds the foreground and Done 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])) + }) + + it('reports `opencode2 run` as OpenCode 2', async () => { + const { lifetime, published } = setup({ + name: 'opencode2', + commandLine: '/usr/local/bin/opencode2 run --model a/b hi' + }) + lifetime.onCommandStarted('pty-1') + await settle() + expect(published[0]?.payload.agentType).toBe('opencode2') + }) + + it('marks a Ctrl-C exit as interrupted and any other exit as a plain Done', async () => { + const { lifetime, states } = setup() + lifetime.onCommandStarted('pty-1') + await settle() + lifetime.onCommandFinished('pty-1', 130) + lifetime.onCommandStarted('pty-1') + await settle() + lifetime.onCommandFinished('pty-1', 1) + expect(states()).toEqual(['working', 'done:interrupted', 'working', 'done']) + }) + + 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) + expect(readForegroundCommandLine).not.toHaveBeenCalled() + expect(states()).toEqual([]) + }) + + it.each(['zsh', 'npx', '/usr/local/bin/node', 'bun.exe'])( + 're-reads on the shared ladder while the foreground is %s, until OpenCode execs', + async (firstName) => { + const { lifetime, states, readForegroundProcessName } = setup() + readForegroundProcessName.mockResolvedValueOnce(firstName) + lifetime.onCommandStarted('pty-1') + await settle() + expect(states()).toEqual([]) + await vi.advanceTimersByTimeAsync(FOREGROUND_COMMAND_READS.retryDelaysMs[0]) + expect(states()).toEqual(['working']) + expect(readForegroundProcessName).toHaveBeenCalledTimes(2) + } + ) + + it('stops re-reading after the last rung for a shell or launcher that never becomes OpenCode', async () => { + for (const name of ['node', 'bash']) { + const { lifetime, readForegroundProcessName } = setup({ name }) + lifetime.onCommandStarted('pty-1') + await vi.advanceTimersByTimeAsync(60_000) + expect(readForegroundProcessName).toHaveBeenCalledTimes( + 1 + FOREGROUND_COMMAND_READS.retryDelaysMs.length + ) + } + }) + + // Why: another agent, an editor or a dev server never execs OpenCode as the pane's foreground. + it.each(['claude', 'codex', 'vim', 'sleep', 'python3'])( + 'reads %s once and stops', + async (name) => { + const { lifetime, states, readForegroundProcessName } = setup({ name }) + lifetime.onCommandStarted('pty-1') + await vi.advanceTimersByTimeAsync(60_000) + expect(readForegroundProcessName).toHaveBeenCalledTimes(1) + expect(states()).toEqual([]) + } + ) + + 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 }) + lifetime.onCommandStarted('pty-1') + await settle() + lifetime.onCommandFinished('pty-1', 0) + expect(states()).toEqual([]) + } + }) + + it('ignores a command line whose agent is not the foreground name', async () => { + const { lifetime, states } = setup({ name: 'opencode2', commandLine: 'opencode run hi' }) + lifetime.onCommandStarted('pty-1') + await settle() + expect(states()).toEqual([]) + }) + + 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) + await settle() + expect(readForegroundProcessName).not.toHaveBeenCalled() + expect(states()).toEqual([]) + }) + + it('never arms from a read that a newer command superseded', async () => { + let resolveName: (name: string) => void = () => {} + const { lifetime, states, readForegroundProcessName } = setup() + readForegroundProcessName.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveName = resolve + }) + ) + lifetime.onCommandStarted('pty-1') + await settle() + lifetime.onCommandFinished('pty-1', 0) + resolveName('opencode') + await vi.runAllTimersAsync() + expect(states()).toEqual([]) + }) + + it('stays silent when OpenCode status is turned off for that agent', async () => { + const { lifetime, states, readForegroundCommandLine } = setup({ enabledAgents: ['opencode2'] }) + lifetime.onCommandStarted('pty-1') + await settle() + lifetime.onCommandFinished('pty-1', 0) + expect(readForegroundCommandLine).not.toHaveBeenCalled() + expect(states()).toEqual([]) + }) + + it('sets no timer and reads nothing while status is off for both OpenCode agents', () => { + const { lifetime, readForegroundProcessName } = setup({ enabledAgents: [] }) + lifetime.onCommandStarted('pty-1') + expect(vi.getTimerCount()).toBe(0) + expect(readForegroundProcessName).not.toHaveBeenCalled() + }) + + it('reads nothing for a pane whose foreground is on another host', async () => { + const { lifetime, states, readForegroundProcessName } = setup({ observable: false }) + lifetime.onCommandStarted('pty-1') + await settle() + lifetime.onCommandFinished('pty-1', 0) + expect(readForegroundProcessName).not.toHaveBeenCalled() + expect(states()).toEqual([]) + }) + + it('ends an armed run with Done 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']) + await settle() + expect(states()).toEqual(['working', 'done', 'working']) + }) + + it('posts no Done 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) + expect(states()).toEqual(['working']) + }) + + it('keeps each pane to its own command', async () => { + const { lifetime, published } = 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' + ]) + }) +}) diff --git a/src/main/runtime/opencode-run-lifetime-status.ts b/src/main/runtime/opencode-run-lifetime-status.ts new file mode 100644 index 00000000000..304e020f31e --- /dev/null +++ b/src/main/runtime/opencode-run-lifetime-status.ts @@ -0,0 +1,158 @@ +import { tokenizeCommandLine } from '../../shared/agent-command-line-entrypoint' +import { recognizeAgentProcess } from '../../shared/agent-process-recognition' +import { + normalizeAgentStatusPayload, + type ParsedAgentStatusPayload +} from '../../shared/agent-status-types' +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']) + +// Why these only: a shell means the command has not exec'd yet, and a launcher may still exec +// OpenCode; any other program (another agent, vim, a dev server) never becomes OpenCode. +function mayStillBecomeOpenCode(processName: string): boolean { + if (recognizeAgentProcess(processName) !== null) { + return false + } + const base = (processName.split(/[\\/]/).pop() ?? '').toLowerCase().replace(/\.(exe|cmd)$/, '') + return isShellProcess(processName) || OPENCODE_LAUNCHERS.has(base) +} + +type OpenCodeAgent = 'opencode' | 'opencode2' + +type Dependencies = { + /** Local PTYs only: SSH and WSL foregrounds cannot be read on this host. */ + isObservablePty(ptyId: string): boolean + /** The per-agent status switch the plugin install honours (#23667). */ + isStatusEnabled(agent: OpenCodeAgent): boolean + readForegroundProcessName(ptyId: string): Promise + 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 + now(): number +} + +type CommandState = { + generation: number + startedAt: number + timer: ReturnType | null + armed: OpenCodeAgent | null +} + +/** + * 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. + */ +export class OpenCodeRunLifetimeStatus { + private readonly commands = new Map() + private nextGeneration = 0 + + constructor(private readonly deps: Dependencies) {} + + onCommandStarted(ptyId: string): void { + // Why: a new command proves the armed one ended even though its 133;D never arrived. + this.onCommandFinished(ptyId, null) + if ( + !this.deps.isObservablePty(ptyId) || + (!this.deps.isStatusEnabled('opencode') && !this.deps.isStatusEnabled('opencode2')) + ) { + return + } + const state: CommandState = { + generation: ++this.nextGeneration, + startedAt: this.deps.now(), + timer: null, + armed: null + } + this.commands.set(ptyId, state) + this.scheduleInspect(ptyId, state, FOREGROUND_COMMAND_READS.settleMs, 0) + } + + onCommandFinished(ptyId: string, exitCode: number | null): 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) + } + } + + forgetPty(ptyId: string): void { + const state = this.commands.get(ptyId) + if (state?.timer) { + clearTimeout(state.timer) + } + this.commands.delete(ptyId) + } + + private scheduleInspect( + ptyId: string, + state: CommandState, + delayMs: number, + retryIndex: number + ): void { + state.timer = setTimeout(() => { + state.timer = null + void this.inspect(ptyId, state, retryIndex) + }, delayMs) + } + + private isCurrent(ptyId: string, state: CommandState): boolean { + return this.commands.get(ptyId)?.generation === state.generation + } + + private async inspect(ptyId: string, state: CommandState, retryIndex: number): Promise { + try { + const name = await this.deps.readForegroundProcessName(ptyId) + const agent = recognizeAgentProcess(name)?.agent + if (!name || !this.isCurrent(ptyId, state)) { + return + } + if (agent !== 'opencode' && agent !== 'opencode2') { + const retryDelay = FOREGROUND_COMMAND_READS.retryDelaysMs[retryIndex] + if (retryDelay !== undefined && mayStillBecomeOpenCode(name)) { + this.scheduleInspect(ptyId, state, retryDelay, retryIndex + 1) + } + return + } + if (!this.deps.isStatusEnabled(agent)) { + return + } + const tokens = tokenizeCommandLine( + (await this.deps.readForegroundCommandLine(ptyId, name)) ?? '' + ) + if ( + !this.isCurrent(ptyId, state) || + recognizeAgentProcess(tokens[0])?.agent !== agent || + !isOpenCodeRunCommand(tokens) + ) { + return + } + const payload = normalizeAgentStatusPayload({ + state: 'working', + prompt: '', + agentType: agent + }) + if (!payload) { + return + } + state.armed = agent + this.deps.publish(ptyId, payload, state.startedAt) + } catch { + // Why: a failed read is missing evidence; the pane stays silent rather than guessed. + } + } +} diff --git a/src/main/runtime/orca-runtime-apply-tracked-pty-title.ts b/src/main/runtime/orca-runtime-apply-tracked-pty-title.ts index f4f8826e6e7..2976c4dff9b 100644 --- a/src/main/runtime/orca-runtime-apply-tracked-pty-title.ts +++ b/src/main/runtime/orca-runtime-apply-tracked-pty-title.ts @@ -158,6 +158,7 @@ export class OrcaRuntimeWithApplyTrackedPtyTitle extends OrcaRuntimeWithGetUnper this.ptyTitleTrackersByPtyId.get(ptyId)?.tracker.dispose() this.ptyTitleTrackersByPtyId.delete(ptyId) this.ptyForegroundAgent.clearDelayedSnapshot(ptyId) + this.openCodeRunLifetime.forgetPty(ptyId) this.mobileSessionTabsAgentStatusHeartbeat.removePty(ptyId) this.clientEvents.clearPtyTitleGate(ptyId) } diff --git a/src/main/runtime/orca-runtime-create-terminal-side-effect-command-code-detector.ts b/src/main/runtime/orca-runtime-create-terminal-side-effect-command-code-detector.ts index 5486e953989..38ccd1b6ce7 100644 --- a/src/main/runtime/orca-runtime-create-terminal-side-effect-command-code-detector.ts +++ b/src/main/runtime/orca-runtime-create-terminal-side-effect-command-code-detector.ts @@ -85,7 +85,11 @@ export class OrcaRuntimeWithCreateTerminalSideEffectCommandCodeDetector extends return worktreePath && isWindowsAbsolutePathLike(worktreePath) ? 'win32' : 'posix' } - protected emitTerminalAgentStatusEvents(ptyId: string, chunk: ProcessedAgentStatusChunk): void { + protected emitTerminalAgentStatusEvents( + ptyId: string, + chunk: Pick, + provenance?: { origin: 'process'; yieldsToHookSince: number } + ): void { if (chunk.payloads.length === 0) { return } @@ -146,7 +150,8 @@ export class OrcaRuntimeWithCreateTerminalSideEffectCommandCodeDetector extends this.onTerminalAgentStatus({ ptyId, ...target, - payload + payload, + ...provenance }) } catch (err) { console.error('[runtime] terminal agent status listener threw', { 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 cc112a78ade..dbbb124d34a 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 @@ -23,6 +23,7 @@ export class OrcaRuntimeWithEmitDaemonPtyTransientFact extends OrcaRuntimeWithSc 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 3ece144bc24..ded7a86eed5 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 @@ -143,9 +143,13 @@ export class OrcaRuntimeWithGetUnpersistedTrackedTitleForPty extends OrcaRuntime onAgentExited: () => { this.confirmPtyAgentExit(ptyId) }, + onCommandStarted: () => { + this.openCodeRunLifetime.onCommandStarted(ptyId) + }, onCommandFinished: (exitCode: number | null) => { this.retirePtyAgentLaunchAuthority(ptyId) this.recordTerminalSideEffectFact(ptyId, { kind: 'command-finished', exitCode }) + this.openCodeRunLifetime.onCommandFinished(ptyId, exitCode) }, onBell: () => { this.recordTerminalSideEffectFact(ptyId, { kind: 'bell' }) @@ -171,6 +175,7 @@ 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-schedule-wait-blocked-check.ts b/src/main/runtime/orca-runtime-schedule-wait-blocked-check.ts index f6219217925..5f874cbc182 100644 --- a/src/main/runtime/orca-runtime-schedule-wait-blocked-check.ts +++ b/src/main/runtime/orca-runtime-schedule-wait-blocked-check.ts @@ -131,12 +131,26 @@ export class OrcaRuntimeWithScheduleWaitBlockedCheck extends OrcaRuntimeWithOnPt ptyId: string, entry: RuntimePtyTitleTrackerEntry ): void { - if (entry.pendingFacts.length === 0) { + 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) return } - const facts = entry.pendingFacts - entry.pendingFacts = [] - this.emitTerminalSideEffectBatch(ptyId, facts) + run() } /** Feed a main-fabricated OSC title/BEL frame (agent hook spinners) through 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 a7175383df0..1e54583435c 100644 --- a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts +++ b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts @@ -24,6 +24,9 @@ import { RuntimeAgentOrchestrationProjection } from './runtime-agent-orchestrati import { RuntimeTerminalList } from './runtime-terminal-list' import { RuntimeManagedWorktreeQueries } from './runtime-managed-worktree-queries' import { RuntimePtyForegroundAgent } from './runtime-pty-foreground-agent' +import { OpenCodeRunLifetimeStatus } from './opencode-run-lifetime-status' +import { readLocalPtyForegroundCommandLine } from './local-pty-foreground-command-line' +import { isAgentStatusHooksEnabledForAgent } from '../../shared/agent-status-hooks-setting' import { RuntimeTerminalAgentStatusQuery } from './runtime-terminal-agent-status-query' import type { OrchestrationDb } from './orchestration/db' import { OrchestrationMailboxOwner } from './orchestration/mailbox-owner' @@ -162,6 +165,33 @@ export class OrcaRuntimeWithStopRequestedPtyIds extends OrcaRuntimeWithRuntimeId } }) + protected readonly openCodeRunLifetime = new OpenCodeRunLifetimeStatus({ + isObservablePty: (ptyId) => { + const pty = this.ptysById.get(ptyId) + // Why: SSH and WSL foregrounds live on another host or in the guest. + return !!pty && !pty.connectionId && !pty.wslDistro && !this.wslDistroByPtyId.has(ptyId) + }, + isStatusEnabled: (agent) => + isAgentStatusHooksEnabledForAgent(this.store?.getSettings?.(), agent), + readForegroundProcessName: async (ptyId) => { + const read = await this.ptyForegroundAgent.read(ptyId) + return read?.available ? read.process : null + }, + 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 } + ) + ), + now: () => Date.now() + }) + protected readonly terminalAgentStatus = new RuntimeTerminalAgentStatusQuery({ getController: () => this.ptyController, getLivePty: (handle) => this.getLivePtyForHandle(handle), diff --git a/src/main/runtime/runtime-terminal-contracts.ts b/src/main/runtime/runtime-terminal-contracts.ts index e08fffa273c..d623a3f1fd1 100644 --- a/src/main/runtime/runtime-terminal-contracts.ts +++ b/src/main/runtime/runtime-terminal-contracts.ts @@ -132,6 +132,9 @@ export type RuntimeTerminalAgentStatusEvent = { * reader can rejoin it to the terminal after the pane key moved. */ terminalHandle?: string payload: ParsedAgentStatusPayload + /** Set by the process-lifetime producer; see AgentHookServer.ingestTerminalStatus. */ + origin?: 'process' + yieldsToHookSince?: number } export type HookLiveAgentRow = Pick< diff --git a/src/main/runtime/runtime-terminal-state-records.ts b/src/main/runtime/runtime-terminal-state-records.ts index 32ebf1a423b..085947e45ed 100644 --- a/src/main/runtime/runtime-terminal-state-records.ts +++ b/src/main/runtime/runtime-terminal-state-records.ts @@ -112,6 +112,8 @@ 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-ready-runtime.ts b/src/main/startup/main-process-ready-runtime.ts index 4c0b3116a75..ce0141697d6 100644 --- a/src/main/startup/main-process-ready-runtime.ts +++ b/src/main/startup/main-process-ready-runtime.ts @@ -34,6 +34,7 @@ import { initializeMainProcessPlugins } from './main-process-plugins' import { collectWorktreeTrashSweepRoots, sweepStaleWorktreeTrash } from '../worktree-trash' import { runAfterFirstWindowShown } from './first-window-deferral' import { logStartupMilestone } from './startup-diagnostics' +import { refreshInstalledOpenCodeStatusPlugins } from '../opencode/opencode-status-plugin-startup-refresh' // Headless serve never opens a window, so the sweep still has to run off a timer there. const WORKTREE_TRASH_SWEEP_FALLBACK_MS = 15_000 @@ -88,6 +89,10 @@ export async function initializeReadyRuntimeServices(): Promise { console.warn('[worktrees] Failed to sweep leftover worktree directories:', error) }) }, WORKTREE_TRASH_SWEEP_FALLBACK_MS) + // Why deferred: nothing on the startup path needs it, and it only rewrites plugin files that changed. + runAfterFirstWindowShown(() => { + refreshInstalledOpenCodeStatusPlugins(store.getSettings()) + }, WORKTREE_TRASH_SWEEP_FALLBACK_MS) nativeTheme.themeSource = store.getSettings().theme ?? 'system' // Why: the real-home ensure stays ordered before managed-hook reconciliation, so its // in-slot conversion lands before the managed install's retired-form sweep removes diff --git a/src/relay/opencode-canonical-config.ts b/src/relay/opencode-canonical-config.ts index 38682d3f274..8cdfa7d5536 100644 --- a/src/relay/opencode-canonical-config.ts +++ b/src/relay/opencode-canonical-config.ts @@ -1,7 +1,8 @@ -import { mkdirSync, unlinkSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs' import { isAbsolute, join, relative, resolve } from 'node:path' import { resolveOpenCodeConfigDirectory } from '../shared/opencode-config-directory' import { isInstalledOpenCodePluginCurrent } from '../shared/opencode-installed-plugin' +import { writeOpenCodeTuiPlugin } from '../shared/opencode-tui-plugin-install' const RELAY_HOOKS_DIR = '.orca-relay' @@ -11,14 +12,19 @@ export function installOpenCodePluginInCanonicalConfig( source: string, agent: OpenCodeAgent, environment: NodeJS.ProcessEnv | Record, - homeDir: string + homeDir: string, + onlyIfInstalled = false ): boolean { try { const configDir = resolveOpenCodeConfigDirectory(environment, homeDir) const pluginFileName = agent === 'opencode2' ? 'orca-opencode2-status.js' : 'orca-opencode-status.js' const pluginPath = join(configDir, 'plugins', pluginFileName) + if (onlyIfInstalled && !existsSync(pluginPath)) { + return false + } mkdirSync(join(configDir, 'plugins'), { recursive: true }) + writeOpenCodeTuiPlugin(join(configDir, 'plugins'), pluginFileName, source) if (!isInstalledOpenCodePluginCurrent(pluginPath, source)) { try { unlinkSync(pluginPath) diff --git a/src/relay/opencode-hook-selection.test.ts b/src/relay/opencode-hook-selection.test.ts index 336db284a47..6e47c28bc36 100644 --- a/src/relay/opencode-hook-selection.test.ts +++ b/src/relay/opencode-hook-selection.test.ts @@ -135,6 +135,17 @@ describe('relay OpenCode source selection on real fixture files', () => { expect((await spawn()).ORCA_OPENCODE_AGENT).toBe('opencode') expect(readFileSync(original, 'utf8')).toBe('// refreshed v1') }) + // Why: a running OpenCode 2 service reloads a changed plugin file, so connecting must upgrade it. + it('refreshes an existing canonical plugin on install without creating a new one', async () => { + const dir = join(root, 'xdg', 'opencode') + mkdirSync(join(dir, 'plugins'), { recursive: true }) + writeFileSync(plugin(dir, 'opencode2'), '// old v2') + await install('// v1', '// v2') + expect(readFileSync(plugin(dir, 'opencode2'), 'utf8')).toBe('// v2') + const tuiEntry = join(dir, 'plugins', 'orca-opencode2-status-tui', 'tui.js') + expect(readFileSync(tuiEntry, 'utf8')).toBe('// v2') + expect(existsSync(plugin(dir, 'opencode'))).toBe(false) + }) it('restores the real custom source when all OpenCode sources are revoked', async () => { await install('// v1', '// v2') const first = await spawn({ env: { OPENCODE_CONFIG_DIR: custom } }) diff --git a/src/relay/plugin-overlay.test.ts b/src/relay/plugin-overlay.test.ts index d4d38a31fba..9129e202287 100644 --- a/src/relay/plugin-overlay.test.ts +++ b/src/relay/plugin-overlay.test.ts @@ -75,6 +75,28 @@ describe('PluginOverlayManager', () => { expect( readFileSync(join(homeDir, 'xdg', 'opencode', 'plugins', 'orca-opencode2-status.js'), 'utf8') ).toBe('v2 plugin') + const tuiEntry = join( + homeDir, + 'xdg', + 'opencode', + 'plugins', + 'orca-opencode2-status-tui', + 'tui.js' + ) + expect(readFileSync(tuiEntry, 'utf8')).toBe('v2 plugin') + expect( + readFileSync( + join(homeDir, 'xdg', 'opencode', 'plugins', 'orca-opencode-status-tui', 'tui.js'), + 'utf8' + ) + ).toBe('v1 plugin') + + // Why: OpenCode 2 reloads plugins when a plugins/ entry is rewritten, so an unchanged + // TUI copy must not be touched on the next launch. + const past = new Date('2020-01-01T00:00:00Z') + utimesSync(tuiEntry, past, past) + manager.installOpenCodePlugin('opencode2', { XDG_CONFIG_HOME: join(homeDir, 'xdg') }) + expect(statSync(tuiEntry).mtimeMs).toBe(past.getTime()) }) // Why: OpenCode 2 reloads a plugin whose file mtime changed, even with unchanged bytes. @@ -121,6 +143,21 @@ describe('PluginOverlayManager', () => { } ) + // Why: a service that reloads between the two writes must already find the TUI copy and stand down. + it('writes the TUI copy before the server plugin file', () => { + manager.setSources({ opencode2PluginSource: 'v2 plugin' }) + const pluginsDir = join(homeDir, 'xdg', 'opencode', 'plugins') + // A directory in the server file's place makes that write fail. + mkdirSync(join(pluginsDir, 'orca-opencode2-status.js'), { recursive: true }) + + expect( + manager.installOpenCodePlugin('opencode2', { XDG_CONFIG_HOME: join(homeDir, 'xdg') }) + ).toBe(false) + expect(readFileSync(join(pluginsDir, 'orca-opencode2-status-tui', 'tui.js'), 'utf8')).toBe( + 'v2 plugin' + ) + }) + it('mirrors a preexisting remote OpenCode config dir before adding Orca plugin', () => { const userConfigDir = join(homeDir, 'company-opencode') mkdirSync(join(userConfigDir, 'plugins'), { recursive: true }) @@ -154,13 +191,23 @@ describe('PluginOverlayManager', () => { const userConfigDir = join(homeDir, '.config', 'opencode') mkdirSync(join(userConfigDir, 'plugins'), { recursive: true }) writeFileSync(join(userConfigDir, 'plugins', stale), 'stale other-major plugin') + const staleTui = stale.replace(/\.js$/, '-tui') + mkdirSync(join(userConfigDir, 'plugins', staleTui)) + writeFileSync(join(userConfigDir, 'plugins', staleTui, 'tui.js'), 'stale other-major plugin') writeFileSync(join(userConfigDir, 'plugins', 'user-plugin.js'), 'user plugin') manager.setSources({ opencodePluginSource: 'v1', opencode2PluginSource: 'v2' }) const dir = manager.materializeOpenCode('tab-1:0', userConfigDir, agent) expect(dir).not.toBeNull() - expect(readdirSync(join(dir!, 'plugins')).sort()).toEqual([own, 'user-plugin.js'].sort()) + const ownTui = own.replace(/\.js$/, '-tui') + expect(readdirSync(join(dir!, 'plugins')).sort()).toEqual( + [own, ownTui, 'user-plugin.js'].sort() + ) + // The TUI copy is the same module; its setup() tells a TUI context from a server one. + expect(readFileSync(join(dir!, 'plugins', ownTui, 'tui.js'), 'utf8')).toBe( + agent === 'opencode2' ? 'v2' : 'v1' + ) } ) diff --git a/src/relay/plugin-overlay.ts b/src/relay/plugin-overlay.ts index 001309c1f93..35455d06859 100644 --- a/src/relay/plugin-overlay.ts +++ b/src/relay/plugin-overlay.ts @@ -37,6 +37,10 @@ import { type OpenCodeAgent } from './opencode-canonical-config' import { writeRelayOmpStatusExtension } from './omp-status-extension' +import { + openCodeTuiPluginDirName, + writeOpenCodeTuiPlugin +} from '../shared/opencode-tui-plugin-install' type LegacyOverlayAgentKind = Exclude const RELAY_HOOKS_DIR = '.orca-relay' const OPENCODE_OVERLAY_SUBDIR = 'opencode-overlays' @@ -47,6 +51,10 @@ const PI_OVERLAY_SUBDIR_BY_KIND: Record = { } const OPENCODE_PLUGIN_FILE = 'orca-opencode-status.js' const OPENCODE2_PLUGIN_FILE = 'orca-opencode2-status.js' +// Orca's own entries (either major, file and TUI copy) are never mirrored from user config. +const ORCA_OPENCODE_PLUGIN_ENTRIES = new Set( + [OPENCODE_PLUGIN_FILE, OPENCODE2_PLUGIN_FILE].flatMap((f) => [f, openCodeTuiPluginDirName(f)]) +) const PI_EXTENSION_FILE = 'orca-agent-status.ts' const PI_AGENT_SUBDIR = 'agent' const OMP_MANAGED_STATUS_EXTENSION_DIR = 'omp-managed-status-extension' @@ -170,11 +178,7 @@ export class PluginOverlayManager { const source = this.piExtensionSources[kind] return source ?? (kind === 'omp' ? this.piExtensionSources.pi : null) } - private mirrorOpenCodeConfig( - sourceDir: string, - overlayDir: string, - pluginFileName: string - ): void { + private mirrorOpenCodeConfig(sourceDir: string, overlayDir: string): void { for (const entry of readdirSync(sourceDir, { withFileTypes: true })) { const sourcePath = join(sourceDir, entry.name) if (entry.name === 'plugins') { @@ -192,11 +196,7 @@ export class PluginOverlayManager { const overlayPluginsDir = join(overlayDir, 'plugins') mkdirSync(overlayPluginsDir, { recursive: true }) for (const pluginEntry of readdirSync(resolvedSource, { withFileTypes: true })) { - if ( - pluginEntry.name === pluginFileName || - pluginEntry.name === OPENCODE_PLUGIN_FILE || - pluginEntry.name === OPENCODE2_PLUGIN_FILE - ) { + if (ORCA_OPENCODE_PLUGIN_ENTRIES.has(pluginEntry.name)) { continue } mirrorEntry( @@ -214,6 +214,7 @@ export class PluginOverlayManager { const pluginsDir = join(overlayDir, 'plugins') mkdirSync(pluginsDir, { recursive: true }) const pluginPath = join(pluginsDir, pluginFileName) + writeOpenCodeTuiPlugin(pluginsDir, pluginFileName, source) try { unlinkSync(pluginPath) } catch { @@ -250,7 +251,7 @@ export class PluginOverlayManager { // Why: OPENCODE_CONFIG_DIR is a single config root. Mirror the user's // remote root into the overlay before adding Orca's plugin so status // reporting does not hide their auth, models, keybinds, or plugins. - this.mirrorOpenCodeConfig(existingConfigDir, dir, pluginFileName) + this.mirrorOpenCodeConfig(existingConfigDir, dir) } this.writeOpenCodePlugin(dir, pluginFileName, source) return dir diff --git a/src/relay/relay-agent-hook-runtime.ts b/src/relay/relay-agent-hook-runtime.ts index bc1cef97137..de25c01608a 100644 --- a/src/relay/relay-agent-hook-runtime.ts +++ b/src/relay/relay-agent-hook-runtime.ts @@ -1,8 +1,10 @@ +import { homedir } from 'node:os' import type { RelayDispatcher } from './dispatcher' import type { PtyEnvAugmenter, PtyHandler } from './pty-handler' import { RelayAgentHookServer } from './agent-hook-server' import { endpointDirForRelaySocket } from './agent-hook-endpoint-coordinates' import { PluginOverlayManager } from './plugin-overlay' +import { installOpenCodePluginInCanonicalConfig } from './opencode-canonical-config' import { AGENT_HOOK_INSTALL_PLUGINS_METHOD, AGENT_HOOK_REQUEST_REPLAY_METHOD @@ -195,6 +197,16 @@ export class RelayAgentHookRuntime { ompExtensionSource: typeof omp === 'string' ? omp : undefined, primeAgentExtensionSource: typeof primeAgent === 'string' ? primeAgent : undefined }) + // Why: a running OpenCode 2 service reloads a changed plugin file, so an Orca upgrade + // reaches it on connect instead of at the next pane spawn. Never creates an install. + for (const [agent, source] of [ + ['opencode', opencode], + ['opencode2', opencode2] + ] as const) { + if (typeof source === 'string' && source) { + installOpenCodePluginInCanonicalConfig(source, agent, process.env, homedir(), true) + } + } return { installed: { opencode: this.pluginOverlay.hasOpenCodeSource(), diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-process-exit-turn-boundary.test.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-process-exit-turn-boundary.test.ts new file mode 100644 index 00000000000..aeedd4f133b --- /dev/null +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-process-exit-turn-boundary.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it, vi } from 'vitest' +import { createAgentCompletionCoordinator } from './agent-completion-coordinator' +import { + flushAsyncTicks, + HOOK_DONE_QUIET_MS, + processResult, + useAgentCompletionCoordinatorLifecycle +} from './agent-completion-coordinator-test-harness' + +describe('agent completion coordinator process-exit turn boundary', () => { + useAgentCompletionCoordinatorLifecycle() + + const paneKey = 'tab-1:leaf-1' + + // The pane's own lane confirms an agent process exit and notifies for it. + async function notifyProcessExit(): Promise<{ + ptyLane: ReturnType + showForeground: (process: string) => void + }> { + const ptyDispatch = vi.fn() + let result = processResult('opencode') + const ptyLane = createAgentCompletionCoordinator({ + paneKey, + statusLane: 'pty', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(async () => result), + dispatchCompletion: ptyDispatch, + isLive: () => true + }) + ptyLane.startProcessTracking() + await vi.advanceTimersByTimeAsync(2_000) + await flushAsyncTicks() + result = processResult('zsh', false) + await vi.advanceTimersByTimeAsync(3_000) + await flushAsyncTicks() + expect(ptyDispatch).toHaveBeenCalledTimes(1) + return { + ptyLane, + showForeground: (process) => { + result = processResult(process) + } + } + } + + function createHookLane(dispatchCompletion: () => void) { + return createAgentCompletionCoordinator({ + paneKey, + statusLane: 'hook', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(async () => processResult(null)), + dispatchCompletion, + isLive: () => true + }) + } + + it('still treats a hook Done with no new turn as the exited process completion', async () => { + const { ptyLane } = await notifyProcessExit() + const hookDispatch = vi.fn() + const hookLane = createHookLane(hookDispatch) + hookLane.observeHookStatus({ + state: 'done', + prompt: '', + agentType: 'opencode', + stateStartedAt: 1_700_000_000_000 + }) + vi.advanceTimersByTime(HOOK_DONE_QUIET_MS) + + expect(hookDispatch).not.toHaveBeenCalled() + ptyLane.dispose() + hookLane.dispose() + }) + + it('notifies later hook turns after an unreported process exit in the same pane', async () => { + // Status turned off for the exited agent (or an idle client quit), so no hook reported it. + const { ptyLane, showForeground } = await notifyProcessExit() + + // A later agent in the same pane reports its turns through hooks only. + showForeground('opencode') + await vi.advanceTimersByTimeAsync(2_000) + await flushAsyncTicks() + const hookDispatch = vi.fn() + const hookLane = createHookLane(hookDispatch) + for (const turnStartedAt of [1_700_000_000_000, 1_700_000_100_000]) { + hookLane.observeHookStatus({ + state: 'working', + prompt: 'Reply with the single word pong.', + agentType: 'opencode', + stateStartedAt: turnStartedAt + }) + hookLane.observeHookStatus({ + state: 'done', + prompt: 'Reply with the single word pong.', + agentType: 'opencode', + stateStartedAt: turnStartedAt + 20_000 + }) + vi.advanceTimersByTime(HOOK_DONE_QUIET_MS) + } + + expect(hookDispatch).toHaveBeenCalledTimes(2) + ptyLane.dispose() + hookLane.dispose() + }) +}) 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 37b4430a9b6..24df1dce1ae 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts @@ -285,6 +285,7 @@ export function createAgentCompletionCoordinator( consumePendingStampedTailForAgent, consumeStampedTailForCurrentCoordinator, clearOriginStampedTail: () => identityScope.clearOriginStampedTail(), + clearProcessExitCompletion: () => identityScope.clearProcessExitCompletion(), recordWorkingBoundary, dropPendingTitle }) 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 e6e64ade5a8..698747a612e 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 @@ -36,6 +36,7 @@ type HookObserverOptions = { consumePendingStampedTailForAgent: (agent: string | null, identity: string | null) => boolean consumeStampedTailForCurrentCoordinator: (timestamp: number) => void clearOriginStampedTail: () => void + clearProcessExitCompletion: () => void recordWorkingBoundary: (timestamp: number | undefined) => void dropPendingTitle: () => void } @@ -67,6 +68,7 @@ export function createAgentCompletionHookObserver({ consumePendingStampedTailForAgent, consumeStampedTailForCurrentCoordinator, clearOriginStampedTail, + clearProcessExitCompletion, recordWorkingBoundary, dropPendingTitle }: HookObserverOptions) { @@ -114,6 +116,9 @@ export function createAgentCompletionHookObserver({ return } clearOriginStampedTail() + // Why: an exit identity names no turn, so kept past a new turn it matches (by agent) and + // swallows every later hook Done of that agent in this pane. + clearProcessExitCompletion() recordWorkingBoundary(payload.stateStartedAt) clearPendingHookDone() state.workingStatusObserved = true diff --git a/src/renderer/src/components/terminal-pane/agent-completion-identity-store.ts b/src/renderer/src/components/terminal-pane/agent-completion-identity-store.ts index 70a1f57a32b..14d2f736126 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-identity-store.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-identity-store.ts @@ -41,6 +41,7 @@ export type AgentCompletionIdentityScope = { hasUnconsumedStampedTail: () => boolean hasConsumedIdentity: (identity: string) => boolean clearOriginStampedTail: () => void + clearProcessExitCompletion: () => void clearStampedTail: () => void dispose: (isLive: boolean) => void } @@ -177,6 +178,11 @@ export function createAgentCompletionIdentityScope( } } }, + clearProcessExitCompletion: () => { + if (lastCompletionByPane.get(paneKey)?.source === 'process-exit') { + lastCompletionByPane.delete(paneKey) + } + }, clearStampedTail: () => { pendingStampedTailByPane.delete(paneKey) }, diff --git a/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.ts b/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.ts index 584f82797c2..308ca14919a 100644 --- a/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.ts +++ b/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.ts @@ -9,12 +9,9 @@ import type { TuiAgent } from '../../../../shared/tui-agent' import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent' import type { RuntimeTerminalProcessInspection } from '@/runtime/runtime-terminal-inspection' import { createPaneForegroundProcessReader } from './pane-foreground-process-reader' +import { FOREGROUND_COMMAND_READS } from '../../../../shared/foreground-command-settle' -// Why: settle after exec, then place the final generic retry beyond sequential -// 3s PowerShell and WMIC enrichment scans. -const COMMAND_SETTLE_MS = 350 const VISIBLE_PTY_SETTLE_MS = 350 -const WRAPPER_RESOLVE_RETRY_DELAYS_MS = [1200, 6000] as const type ForegroundReadReason = 'command' | 'visible-pty' | 'command-finished' type PaneForegroundAgentTrackerDeps = { @@ -204,7 +201,7 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke // so a still-live generation means the command is running and the shell is // a nested one (sh/bash without integration); marking shell-foreground // would suppress live title identity. Only 133;D proves the prompt. - const retryDelay = WRAPPER_RESOLVE_RETRY_DELAYS_MS[retryIndex] + const retryDelay = FOREGROUND_COMMAND_READS.retryDelaysMs[retryIndex] const hasConfirmationExpectation = hasForegroundAgentEvidence || hasKnownAgentEvidence || hasAgentExpectation const shouldRetryExpectedIdentity = @@ -322,7 +319,7 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke if (deps.isRemotePtyId?.(ptyId) !== true) { deps.publish({ agent: null, shellForeground: false }) } - scheduleRead(COMMAND_SETTLE_MS, 0, 'command') + scheduleRead(FOREGROUND_COMMAND_READS.settleMs, 0, 'command') }, onCommandFinished() { if (deps.hasKnownAgentIdentity?.() === true) { @@ -356,7 +353,7 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke } // Why: confirm the foreground before clearing — if the agent still owns it, // the read republishes its identity; only a genuine shell result clears it. - scheduleRead(COMMAND_SETTLE_MS, 0, 'command-finished') + scheduleRead(FOREGROUND_COMMAND_READS.settleMs, 0, 'command-finished') return true }, onProcessExitConfirmed(process) { 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 new file mode 100644 index 00000000000..2b92e607d22 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-command-finished-late-done.test.ts @@ -0,0 +1,212 @@ +import type * as React from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import { flushAsyncTicks } from './pty-connection-test-async' +import { + LEAF_1, + createMockTransport, + createPane, + createManager, + type ConnectCallbacks, + type MockTransport +} from './pty-connection-test-pane-fixtures' +import type { StoreState } from './pty-connection-test-store-state' +import { buildPaneConnectionDeps } from './pty-connection-test-deps' +import { createInitialStoreState } from './pty-connection-test-store-fixtures' +import { + installTerminalTestGlobals, + restoreTerminalTestGlobals +} from './pty-connection-test-environment' + +const { + resetAndRefreshAllTerminalWebglAtlases, + scheduleTerminalWebglAtlasRecovery, + scheduleRuntimeGraphSync, + shouldSeedCacheTimerOnInitialTitle, + toastInfo, + notifyCodexPaneBoundForStaleSweep +} = vi.hoisted(() => ({ + resetAndRefreshAllTerminalWebglAtlases: vi.fn(), + scheduleTerminalWebglAtlasRecovery: vi.fn(), + scheduleRuntimeGraphSync: vi.fn(), + shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false), + toastInfo: vi.fn(), + notifyCodexPaneBoundForStaleSweep: vi.fn() +})) + +let mockStoreState: StoreState +let transportFactoryQueue: MockTransport[] = [] +let createdTransportOptions: Record[] = [] +let storeSubscribers: ((state: StoreState) => void)[] = [] + +vi.mock('@/runtime/sync-runtime-graph', () => ({ + scheduleRuntimeGraphSync +})) + +vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({ + ...(await importOriginal>()), + resetAndRefreshAllTerminalWebglAtlases +})) + +vi.mock('./terminal-webgl-atlas-recovery', () => ({ + scheduleTerminalWebglAtlasRecovery +})) + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => mockStoreState, + subscribe: (listener: (state: StoreState) => void) => { + storeSubscribers.push(listener) + return () => { + storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener) + } + } + } +})) + +vi.mock('@/lib/agent-status', async (importOriginal) => { + const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment') + return buildAgentStatusModuleMock(await importOriginal>()) +}) + +vi.mock('./cache-timer-seeding', () => ({ + shouldSeedCacheTimerOnInitialTitle +})) + +vi.mock('sonner', () => ({ + toast: { + info: toastInfo + } +})) + +vi.mock('@/lib/codex-stale-pane-sweep', () => ({ + notifyCodexPaneBoundForStaleSweep +})) + +// Why: useCallback must pass through; no test here renders React. +vi.mock('react', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useCallback: unknown>(fn: T): T => fn + } +}) + +vi.mock('./pty-transport', () => ({ + createIpcPtyTransport: vi.fn((options: Record) => { + createdTransportOptions.push(options) + const nextTransport = transportFactoryQueue.shift() + if (!nextTransport) { + throw new Error('No mock transport queued') + } + return nextTransport + }) +})) + +vi.mock('./remote-runtime-pty-transport', () => ({ + createRemoteRuntimePtyTransport: vi.fn( + (_environmentId: string, options: Record) => { + createdTransportOptions.push(options) + const nextTransport = transportFactoryQueue.shift() + if (!nextTransport) { + throw new Error('No mock transport queued') + } + return nextTransport + } + ) +})) + +// Why: stub only getEagerPtyBufferHandle so tests can simulate a live eager buffer (adopt path) without standing up the real IPC dispatcher. +vi.mock('./pty-dispatcher', async (importOriginal) => { + const actual = await importOriginal>() + return { + ...actual, + getEagerPtyBufferHandle: vi.fn(() => undefined) + } +}) + +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. +describe('command-finished drop and a Done that lands after it', () => { + beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + transportFactoryQueue = [] + createdTransportOptions = [] + storeSubscribers = [] + mockStoreState = createInitialStoreState(() => mockStoreState) + installTerminalTestGlobals() + }) + + afterEach(async () => { + await restoreTerminalTestGlobals() + }) + + async function connectRunPane(): Promise<{ + finishCommand: () => void + setStatus: (state: 'working' | 'done') => void + paneKey: string + }> { + vi.useFakeTimers() + const { connectPanePty } = await import('./pty-connection') + const { createTestStore } = await import('@/store/slices/store-test-helpers') + vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('zsh') + const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null } + const transport = createMockTransport('pty-opencode-run') + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + dataCallbackRef.current = callbacks.onData ?? null + return { id: 'pty-opencode-run' } + }) + transportFactoryQueue.push(transport) + const paneKey = makePaneKey('tab-1', LEAF_1) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixtures implement the pane, manager and deps members connectPanePty reads. + const args = [ + createPane(1), + createManager(1), + createDeps({ isVisibleRef: { current: false } }) + ] as unknown as Parameters + connectPanePty(...args) + await vi.advanceTimersByTimeAsync(20) + await flushAsyncTicks() + const realStore = createTestStore() + mockStoreState.dropAgentStatus.mockImplementation((key: string) => { + delete mockStoreState.agentStatusByPaneKey[key] + }) + return { + paneKey, + finishCommand: () => dataCallbackRef.current?.('\x1b]133;D;0\x07'), + setStatus: (state) => { + realStore.getState().setAgentStatus(paneKey, { state, prompt: '', agentType: 'opencode' }) + mockStoreState.agentStatusByPaneKey[paneKey] = + realStore.getState().agentStatusByPaneKey[paneKey] + } + } + } + + it('keeps a Done that lands after the command-finished fact', async () => { + const pane = await connectRunPane() + pane.setStatus('working') + + pane.finishCommand() + pane.setStatus('done') + await vi.advanceTimersByTimeAsync(350 + 1200 + 6000) + + expect(mockStoreState.dropAgentStatus).not.toHaveBeenCalled() + expect(mockStoreState.agentStatusByPaneKey[pane.paneKey]).toMatchObject({ state: 'done' }) + }) + + it('drops a Done that landed before the command-finished fact', async () => { + const pane = await connectRunPane() + pane.setStatus('working') + pane.setStatus('done') + + pane.finishCommand() + await vi.advanceTimersByTimeAsync(350 + 1200 + 6000) + + expect(mockStoreState.dropAgentStatus).toHaveBeenCalledWith(pane.paneKey) + }) +}) diff --git a/src/shared/agent-hook-listener-opencode-reattribution.test.ts b/src/shared/agent-hook-listener-opencode-reattribution.test.ts index 676b569f495..a9ce55c51e8 100644 --- a/src/shared/agent-hook-listener-opencode-reattribution.test.ts +++ b/src/shared/agent-hook-listener-opencode-reattribution.test.ts @@ -25,7 +25,7 @@ function opencodeBusy( ) } -describe('opencode shared-server reattribution (#21359)', () => { +describe('OpenCode 1 shared-server reattribution (#21359)', () => { it('reattributes a bound session to its real pane', () => { const state = createHookListenerState() bindOpenCodeSession(state, 'ses_1', { @@ -64,6 +64,28 @@ describe('opencode shared-server reattribution (#21359)', () => { expect(result?.launchToken).toBe('token-b-live') }) + it('leaves an OpenCode 2 post on the pane it names', () => { + const state = createHookListenerState() + bindOpenCodeSession(state, 'ses_1', { + paneKey: PANE_B, + boundAt: 1, + basis: 'argv' + }) + const result = normalizeHookPayload( + state, + 'opencode', + { + paneKey: PANE_A, + launchToken: 'token-a', + opencodeMajor: 2, + payload: { hook_event_name: 'SessionBusy', sessionID: 'ses_1' } + }, + 'production' + ) + expect(result?.paneKey).toBe(PANE_A) + expect(result?.launchToken).toBe('token-a') + }) + it('leaves other sources untouched', () => { const state = createHookListenerState() bindOpenCodeSession(state, 'ses_1', { diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index 73f05c1d763..480b6d55139 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -55,7 +55,7 @@ export function normalizeHookPayload( source === 'codex' && readString(hookPayloadRecord, 'agent_id') ? null : extractAgentProviderSession(source, hookPayloadRecord) - // Why (#21359): the shared OpenCode server stamps every post with its own + // Why (#21359): an OpenCode 1 `serve` process stamps every post with its own // frozen pane. When the binder has mapped this session to its real pane, // the stamp is replaced before anything downstream (status lookup, dispatch, // fences) can act on the wrong owner. Unbound sessions keep the stamp. @@ -68,7 +68,8 @@ export function normalizeHookPayload( worktreeId: stampedWorktreeId, launchToken: stampedLaunchToken }, - sessionId: providerSession?.id + sessionId: providerSession?.id, + body: record }) // Why after the resolve: tracking the stamped token first would let a stale // shared-server stamp overwrite the pane's live token; the resolved envelope diff --git a/src/shared/agent-hook-listener/opencode-session-registry.ts b/src/shared/agent-hook-listener/opencode-session-registry.ts index 0d6183197b5..8fade351510 100644 --- a/src/shared/agent-hook-listener/opencode-session-registry.ts +++ b/src/shared/agent-hook-listener/opencode-session-registry.ts @@ -5,9 +5,9 @@ import type { HookListenerState } from './listener-state' /** * Which pane owns one OpenCode session, as observed from the client side. * - * Why this exists: OpenCode v2 serves every pane from a single shared server - * process, so the status plugin's per-post stamp (`process.env.ORCA_PANE_KEY`) - * is frozen to whichever pane started the server. The session id is the only + * Why this exists: OpenCode 1 `opencode serve` serves every `opencode attach` + * pane from one server process, so the status plugin's per-post stamp + * (`process.env.ORCA_PANE_KEY`) is frozen to the pane that started it. The session id is the only * per-event truth that survives — every post carries it — but nothing maps it * back to a pane. This registry is that map, filled by the main-process binder * (client argv, then creation-correlation against the session store) and read @@ -139,6 +139,19 @@ export function lookupOpenCodePaneLaunchToken( return state.lastLaunchTokenByPaneKey.get(paneKey) } +/** + * OpenCode-1-only: remove with OpenCode 1 support. OpenCode 2 posts declare `opencodeMajor: 2` + * and always name their own pane; posts without it (OpenCode 1, mimo-code, older plugins) may + * come from a shared server. + */ +export function isOpenCodeSharedServerPost(source: AgentHookSource, body: unknown): boolean { + if ((source !== 'opencode' && source !== 'mimo-code') || typeof body !== 'object' || !body) { + return false + } + const major = 'opencodeMajor' in body ? body.opencodeMajor : undefined + return !(typeof major === 'number' && major >= 2) +} + /** Envelope fields the rewrite may substitute, as stamped by the poster. */ export type OpenCodeStampedEnvelope = { paneKey: string @@ -159,9 +172,11 @@ export function resolveOpenCodeSharedServerEnvelope(args: { source: AgentHookSource stamped: OpenCodeStampedEnvelope sessionId: string | undefined + /** The raw post; see isOpenCodeSharedServerPost. */ + body: unknown }): OpenCodeStampedEnvelope { const { state, source, stamped, sessionId } = args - if ((source !== 'opencode' && source !== 'mimo-code') || !sessionId) { + if (!isOpenCodeSharedServerPost(source, args.body) || !sessionId) { return stamped } const binding = lookupOpenCodeSessionPane(state, sessionId) diff --git a/src/shared/foreground-command-settle.ts b/src/shared/foreground-command-settle.ts new file mode 100644 index 00000000000..b2a20041dd7 --- /dev/null +++ b/src/shared/foreground-command-settle.ts @@ -0,0 +1,4 @@ +// Why: settle after exec, then place the final generic retry beyond sequential +// 3s PowerShell and WMIC enrichment scans. Shared by the renderer's pane tracker +// and main's `opencode run` producer so both read a command's foreground alike. +export const FOREGROUND_COMMAND_READS = { settleMs: 350, retryDelaysMs: [1200, 6000] } as const diff --git a/src/shared/opencode-headless-command.test.ts b/src/shared/opencode-headless-command.test.ts new file mode 100644 index 00000000000..408895a1dbb --- /dev/null +++ b/src/shared/opencode-headless-command.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { tokenizeCommandLine } from './agent-command-line-entrypoint' +import { isOpenCodeRunCommand } from './opencode-headless-command' + +const matches = (commandLine: string): boolean => + isOpenCodeRunCommand(tokenizeCommandLine(commandLine)) + +describe('isOpenCodeRunCommand', () => { + it('matches the run subcommand, after global options too', () => { + expect(matches('opencode run fix the bug')).toBe(true) + expect(matches('/opt/homebrew/bin/opencode2 run --model x/y hi')).toBe(true) + expect(matches('opencode --print-logs run hi')).toBe(true) + expect(matches('opencode --log-level DEBUG run hi')).toBe(true) + expect(matches('opencode --log-level=DEBUG run hi')).toBe(true) + }) + + it('does not match the TUI or any other subcommand', () => { + expect(matches('opencode')).toBe(false) + expect(matches('opencode .')).toBe(false) + expect(matches('opencode2 --standalone')).toBe(false) + expect(matches('opencode serve --port 4096')).toBe(false) + expect(matches('opencode attach http://127.0.0.1:4096')).toBe(false) + expect(matches('opencode mini')).toBe(false) + expect(matches('opencode --log-level run')).toBe(false) + }) +}) diff --git a/src/shared/opencode-headless-command.ts b/src/shared/opencode-headless-command.ts new file mode 100644 index 00000000000..5328f7931fc --- /dev/null +++ b/src/shared/opencode-headless-command.ts @@ -0,0 +1,16 @@ +// Why: `opencode run` answers one prompt and exits, so its process lifetime is its turn. +// Not in agent-headless-command's table: that would also drop OpenCode 1 `run`'s identity, +// whose in-process plugin reports it. Only `--log-level` takes a separate value before the +// subcommand; any other valued option makes the value the first positional, which fails safe. +export function isOpenCodeRunCommand(tokens: readonly string[]): boolean { + for (let index = 1; index < tokens.length; index += 1) { + const token = tokens[index] + if (!token.startsWith('-')) { + return token === 'run' + } + if (token === '--log-level') { + index += 1 + } + } + return false +} diff --git a/src/shared/opencode-tui-plugin-install.ts b/src/shared/opencode-tui-plugin-install.ts new file mode 100644 index 00000000000..db05aa8834d --- /dev/null +++ b/src/shared/opencode-tui-plugin-install.ts @@ -0,0 +1,36 @@ +import { mkdirSync, unlinkSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { isInstalledOpenCodePluginCurrent } from './opencode-installed-plugin' + +/** + * Directory holding the TUI copy of a status plugin file. OpenCode 2 loads a + * `tui` entrypoint only from a plugins/ subdirectory, and OpenCode 1 loads only + * plugins/*.js files, so this entry is invisible to 1.x. + */ +export function openCodeTuiPluginDirName(pluginFileName: string): string { + return `${pluginFileName.replace(/\.js$/, '')}-tui` +} + +/** + * Install the TUI copy beside the server plugin file. The same module serves + * both: its setup() tells a TUI context from a server context. Call it before + * writing the server file, which decides at load whether to stand down. + */ +export function writeOpenCodeTuiPlugin( + pluginsDir: string, + pluginFileName: string, + source: string +): void { + const dir = join(pluginsDir, openCodeTuiPluginDirName(pluginFileName)) + const entry = join(dir, 'tui.js') + if (isInstalledOpenCodePluginCurrent(entry, source)) { + return + } + mkdirSync(dir, { recursive: true }) + try { + unlinkSync(entry) + } catch { + // First install, or nothing to replace. + } + writeFileSync(entry, source) +} diff --git a/src/shared/terminal-output-side-effects.test.ts b/src/shared/terminal-output-side-effects.test.ts index 876e4dc12cd..22f68baeed7 100644 --- a/src/shared/terminal-output-side-effects.test.ts +++ b/src/shared/terminal-output-side-effects.test.ts @@ -70,6 +70,19 @@ describe('createTerminalTitleTracker command-finished facts', () => { ]) }) + it('reports command starts in byte order with command finishes', () => { + const events: string[] = [] + const tracker = createTerminalTitleTracker({ + onCommandStarted: () => events.push('started'), + onCommandFinished: (exitCode) => events.push(`finished:${exitCode}`) + }) + + tracker.handleChunk(`${ESC}]133;C${BEL}running${ESC}]133;D;0${BEL}${ESC}]13`) + tracker.handleChunk(`3;C${BEL}`) + + expect(events).toEqual(['started', 'finished:0', 'started']) + }) + it('orders chunk facts titles → command-finished → bell', () => { const { events, tracker } = createRecordingTracker() diff --git a/src/shared/terminal-output-side-effects.ts b/src/shared/terminal-output-side-effects.ts index 20128c63234..993f3bcabb9 100644 --- a/src/shared/terminal-output-side-effects.ts +++ b/src/shared/terminal-output-side-effects.ts @@ -63,6 +63,8 @@ export type TerminalTitleTrackerCallbacks = { * mirrors renderer command-lifecycle semantics so the fact path drops stale agent rows like byte mode. */ onCommandFinished?: (bestEffortExitCode: number | null) => void + /** Fired per complete OSC 133;C: the shell exec'd a command, so the pane's foreground changed. */ + onCommandStarted?: () => void /** Fired once per newly observed GitHub PR URL (chunk-boundary-safe, deduplicated per tracker). */ onPrLink?: (link: TerminalGitHubPRLink) => void /** @@ -119,15 +121,20 @@ export function createTerminalTitleTracker( onAgentExited, onBell, onCommandFinished, + onCommandStarted, onPrLink, onMode2031Subscribe, onMode2031Unsubscribe } = callbacks let bellDetector = onBell ? createBellDetector() : null // Why: created only when a consumer exists so headless serve never pays the per-chunk 133/URL scans. - const commandFinishedScanner = onCommandFinished - ? createOsc133CommandFinishedScanner(onCommandFinished) - : null + const commandFinishedScanner = + onCommandFinished || onCommandStarted + ? createOsc133CommandFinishedScanner( + (exitCode) => onCommandFinished?.(exitCode), + onCommandStarted ? () => onCommandStarted() : undefined + ) + : null let prLinkDetector = onPrLink ? createTerminalGitHubPRLinkDetector() : null let transientSideEffectScanningEnabled = true let transientFactScanningSuppressed = false