From 29cb4b6f8cd86cafcac5dc95e3e9ccc686199178 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:05:18 -0700 Subject: [PATCH 01/25] fix(opencode): report OpenCode 2 status from each pane's TUI OpenCode 2 serves every pane from one shared server whose env names only the pane that started it, so every same-folder pane's work showed on that pane, and the plugin's single aggregate swallowed the Idle of a pane whose turn ended while another pane was busy. Install the status plugin a second time as an OpenCode 2 TUI plugin (plugins/-tui/tui.js, written only when its bytes differ, locally, in overlays and in the SSH/WSL relay installs). In a TUI process setup() runs the same engine, fed through the same event translation as the server, but only for root sessions this pane owns: the route's session from when it starts (or when the route reaches it while running, hydrated from the TUI's session status) until it settles, is deleted, or the TUI exits. The server plugin stands down in any serve process when the TUI copy is installed beside it; a relay that predates the TUI copy keeps the old behavior. OpenCode 1 loads no plugin directories and is unchanged. Delete the session-to-pane binder, registry, client sweep and ingest reattribution: with every post stamped by its own pane nothing is left for them to correct. Known gap: OpenCode 2 `opencode run` in a pane has no TUI, so it reports no pane status. --- .../server-opencode-binder.test.ts | 251 ------------- .../server/server-ingest-normalization.ts | 15 +- .../agent-hooks/server/server-lifecycle.ts | 2 - .../server/server-opencode-binder.ts | 213 ----------- ...ook-plugin-opencode2-tui-ownership.test.ts | 336 +++++++++++++++++ src/main/opencode/hook-service.test.ts | 27 ++ src/main/opencode/hook-service.ts | 28 +- .../opencode/opencode-client-sweep.test.ts | 215 ----------- src/main/opencode/opencode-client-sweep.ts | 263 -------------- .../opencode/opencode-session-binder.test.ts | 176 --------- src/main/opencode/opencode-session-binder.ts | 341 ------------------ .../opencode/status-plugin-factory-source.ts | 8 +- .../opencode2/status-plugin-setup-source.ts | 115 +++--- .../opencode2/status-plugin-tui-source.ts | 204 +++++++++++ src/relay/opencode-canonical-config.ts | 2 + src/relay/plugin-overlay.test.ts | 34 +- src/relay/plugin-overlay.ts | 23 +- ...ok-listener-opencode-reattribution.test.ts | 127 ------- ...tener-opencode-session-correlation.test.ts | 228 ------------ ...listener-opencode-session-registry.test.ts | 89 ----- src/shared/agent-hook-listener.ts | 34 +- .../agent-hook-listener/listener-state.ts | 24 +- .../opencode-session-correlation.ts | 252 ------------- .../opencode-session-registry.ts | 187 ---------- src/shared/opencode-tui-plugin-install.ts | 35 ++ 25 files changed, 761 insertions(+), 2468 deletions(-) delete mode 100644 src/main/agent-hooks/server-opencode-binder.test.ts delete mode 100644 src/main/agent-hooks/server/server-opencode-binder.ts create mode 100644 src/main/opencode/hook-plugin-opencode2-tui-ownership.test.ts delete mode 100644 src/main/opencode/opencode-client-sweep.test.ts delete mode 100644 src/main/opencode/opencode-client-sweep.ts delete mode 100644 src/main/opencode/opencode-session-binder.test.ts delete mode 100644 src/main/opencode/opencode-session-binder.ts create mode 100644 src/main/opencode2/status-plugin-tui-source.ts delete mode 100644 src/shared/agent-hook-listener-opencode-reattribution.test.ts delete mode 100644 src/shared/agent-hook-listener-opencode-session-correlation.test.ts delete mode 100644 src/shared/agent-hook-listener-opencode-session-registry.test.ts delete mode 100644 src/shared/agent-hook-listener/opencode-session-correlation.ts delete mode 100644 src/shared/agent-hook-listener/opencode-session-registry.ts create mode 100644 src/shared/opencode-tui-plugin-install.ts diff --git a/src/main/agent-hooks/server-opencode-binder.test.ts b/src/main/agent-hooks/server-opencode-binder.test.ts deleted file mode 100644 index 328dc98c04c6..000000000000 --- a/src/main/agent-hooks/server-opencode-binder.test.ts +++ /dev/null @@ -1,251 +0,0 @@ -import { mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { AgentHookSource } from '../../shared/agent-hook-relay' -import { createHookListenerState } from '../../shared/agent-hook-listener/listener-state' -import { lookupOpenCodeSessionPane } from '../../shared/agent-hook-listener/opencode-session-registry' -import { makePaneKey } from '../../shared/stable-pane-id' -import SyncDatabase from '../sqlite/sync-database' -import { AgentHookServer } from './server' -import type { OpenCodeBinderLoopDeps } from './server/server-opencode-binder' -import { defaultOpenCodeDbPath, listOpenCodeDbSessions } from '../opencode/opencode-session-binder' - -const LEAF_A = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' -const LEAF_B = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' -const PANE_A = makePaneKey('binder-a', LEAF_A) -const PANE_B = makePaneKey('binder-b', LEAF_B) -const DIR = '/tmp/binder-worktree-a' - -class BinderTestServer extends AgentHookServer { - public bindDeps(deps: Partial): void { - this._setOpenCodeBinderDepsForTests(deps) - } - - public runBinderRound(): Promise { - return this.runOpenCodeBinderRoundOnce() - } - - public startBinderLoop(): void { - this.startOpenCodeBinderLoop() - } - - public ingest(source: AgentHookSource, body: unknown): void { - this.normalizeLocalHookPayload(source, body) - } - - public readRegistry(sessionId: string): string | undefined { - return lookupOpenCodeSessionPane(this._getStateForTests(), sessionId)?.paneKey - } -} - -function writeDb(dbPath: string, table: 'session_v2' | 'session'): 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)` - ) - const insert = db.prepare( - `INSERT INTO ${table} (id, directory, time_created, parent_id) VALUES (?, ?, ?, ?)` - ) - insert.run('ses_live', DIR, Date.now() - 60_000, null) - } finally { - db.close() - } -} - -describe('opencode binder loop', () => { - let dir = '' - let dbPath = '' - let server: BinderTestServer - - beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), 'binder-db-')) - dbPath = join(dir, 'opencode.db') - server = new BinderTestServer() - server.bindDeps({ - now: () => Date.now(), - dbPath: () => dbPath, - listPanes: () => [ - { paneKey: PANE_A, directory: DIR, worktreeId: `repo::${DIR}`, shellPid: 111 } - ], - sweep: async () => [ - { - pid: 112, - ppid: 111, - startedAtMs: Date.now() - 120_000, - executable: 'opencode', - argv: ['opencode'] - } - ] - }) - }) - - afterEach(() => { - server.stop() - 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 () => { - 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 () => { - writeDb(dbPath, 'session_v2') - vi.useFakeTimers() - try { - // Birth arrives stamped with the wrong (server-starter) pane. - server.ingest('opencode', { - paneKey: PANE_B, - launchToken: '', - payload: { hook_event_name: 'SessionStart', sessionID: 'ses_live' } - }) - expect(server.readRegistry('ses_live')).toBeUndefined() - await vi.advanceTimersByTimeAsync(10_000) - expect(server.readRegistry('ses_live')).toBe(PANE_A) - } finally { - vi.useRealTimers() - } - }) - - it('pane teardown unbinds its sessions', async () => { - writeDb(dbPath, 'session_v2') - await server.runBinderRound() - expect(server.readRegistry('ses_live')).toBe(PANE_A) - server.clearPaneState(PANE_A) - expect(server.readRegistry('ses_live')).toBeUndefined() - }) - - it('stops the loop without hanging the process', () => { - writeDb(dbPath, 'session_v2') - expect(() => server.stop()).not.toThrow() - }) - - it('runs a round immediately on loop start', async () => { - writeDb(dbPath, 'session_v2') - server.startBinderLoop() - try { - await vi.waitFor(() => expect(server.readRegistry('ses_live')).toBe(PANE_A)) - } finally { - server.stop() - } - }) - - it('discards a round that was in flight across stop', async () => { - writeDb(dbPath, 'session_v2') - let releaseSweep!: () => void - const sweepGate = new Promise((resolve) => { - releaseSweep = resolve - }) - server.bindDeps({ - sweep: async () => { - await sweepGate - return [ - { - pid: 112, - ppid: 111, - startedAtMs: Date.now() - 120_000, - executable: 'opencode', - argv: ['opencode'] - } - ] - } - }) - const round = server.runBinderRound() - server.stop() - releaseSweep() - expect(await round).toBe(0) - expect(server.readRegistry('ses_live')).toBeUndefined() - }) - - it('an obsolete round does not clear the new round running flag', async () => { - writeDb(dbPath, 'session_v2') - let releaseFirst!: () => void - let releaseLater!: () => void - const firstGate = new Promise((resolve) => { - releaseFirst = resolve - }) - const laterGate = new Promise((resolve) => { - releaseLater = resolve - }) - const clientRow = { - pid: 112, - ppid: 111, - startedAtMs: Date.now() - 120_000, - executable: 'opencode', - argv: ['opencode'] - } - let sweepCalls = 0 - server.bindDeps({ - sweep: async () => { - sweepCalls += 1 - await (sweepCalls === 1 ? firstGate : laterGate) - return [clientRow] - } - }) - server.startBinderLoop() - await vi.waitFor(() => expect(sweepCalls).toBe(1)) - server.stop() - server.startBinderLoop() - await vi.waitFor(() => expect(sweepCalls).toBe(2)) - // The obsolete round finishes while the new round is still parked: its - // finally must not clear the flag the new round holds. - releaseFirst() - await new Promise((resolve) => setTimeout(resolve, 20)) - // A third round attempted now must be refused at the flag check, calling - // no sweep. With the unguarded finally it would be admitted instead. - const extraRound = server.runBinderRound() - expect(sweepCalls).toBe(2) - releaseLater() - await vi.waitFor(() => expect(server.readRegistry('ses_live')).toBe(PANE_A)) - await extraRound - server.stop() - }) -}) - -describe('listOpenCodeDbSessions', () => { - let dir = '' - let dbPath = '' - - beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), 'binder-reader-')) - dbPath = join(dir, 'opencode.db') - }) - - afterEach(() => { - rmSync(dir, { recursive: true, force: true }) - }) - - it('reads session_v2 rows newer than the watermark', () => { - writeDb(dbPath, 'session_v2') - 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('returns [] for a missing database instead of throwing', () => { - expect(listOpenCodeDbSessions(join(dir, 'absent.db'), { ms: 0, id: '' })).toEqual([]) - }) - - it('the default path points at the local opencode store', () => { - expect(defaultOpenCodeDbPath()).toMatch(/opencode\.db$/) - }) -}) - -describe('binder registry isolation', () => { - it('a fresh listener state starts unbound', () => { - const state = createHookListenerState() - expect(lookupOpenCodeSessionPane(state, 'ses_live')).toBeUndefined() - }) -}) diff --git a/src/main/agent-hooks/server/server-ingest-normalization.ts b/src/main/agent-hooks/server/server-ingest-normalization.ts index 171a15ac5cc8..0a1e8d761d64 100644 --- a/src/main/agent-hooks/server/server-ingest-normalization.ts +++ b/src/main/agent-hooks/server/server-ingest-normalization.ts @@ -2,9 +2,9 @@ import { buildSpoolHookBody, type SpoolRecord } from '../../../shared/agent-hook import { normalizeHookPayload } from '../../../shared/agent-hook-listener' import { isAgentHookSource, type AgentHookSource } from '../../../shared/agent-hook-relay' import type { NormalizedLocalHook } from './server-types' -import { AgentHookServerOpenCodeBinder } from './server-opencode-binder' +import { AgentHookServerPersistence } from './server-persistence' -export abstract class AgentHookServerIngestNormalization extends AgentHookServerOpenCodeBinder { +export abstract class AgentHookServerIngestNormalization extends AgentHookServerPersistence { protected setClaudeBackgroundEvidence( paneKey: string, hasRunningTask: boolean, @@ -24,16 +24,7 @@ export abstract class AgentHookServerIngestNormalization extends AgentHookServer protected normalizeLocalHookPayload(source: AgentHookSource, body: unknown): NormalizedLocalHook { if (source !== 'claude' || typeof body !== 'object' || body === null) { - const event = normalizeHookPayload(this.state, source, body, this.env) - if ( - event && - (source === 'opencode' || source === 'mimo-code') && - event.hookEventName === 'SessionStart' - ) { - // Why: a birth just arrived; bind it now instead of waiting out the poll interval. - this.kickOpenCodeBinder() - } - return { event } + return { event: normalizeHookPayload(this.state, source, body, this.env) } } const rawPaneKey = (body as Record).paneKey const paneKey = typeof rawPaneKey === 'string' ? rawPaneKey.trim() : '' diff --git a/src/main/agent-hooks/server/server-lifecycle.ts b/src/main/agent-hooks/server/server-lifecycle.ts index 816058e55fdd..b3b02208fac4 100644 --- a/src/main/agent-hooks/server/server-lifecycle.ts +++ b/src/main/agent-hooks/server/server-lifecycle.ts @@ -178,7 +178,6 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv this.rollbackTransportStart() throw error } - this.startOpenCodeBinderLoop() } private rollbackTransportStart(): void { @@ -192,7 +191,6 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv stop(): void { // Why: flush the pending debounced write before clearing the map, else a hook <250ms before quit is lost on relaunch. this.flushStatusPersistSync() - this.stopOpenCodeBinderLoop() this.rollbackTransportStart() this.env = 'production' this.onAgentStatus = null diff --git a/src/main/agent-hooks/server/server-opencode-binder.ts b/src/main/agent-hooks/server/server-opencode-binder.ts deleted file mode 100644 index 32f8e6775399..000000000000 --- a/src/main/agent-hooks/server/server-opencode-binder.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { - advanceBinderCursor, - applyBinderOwnerships, - defaultOpenCodeDbPath, - listBinderPaneSnapshots, - listOpenCodeDbSessions, - OPENCODE_SESSION_CURSOR_START, - runOpenCodeBinderRound, - type BinderPaneSnapshot, - type BinderSessionRow, - type OpenCodeSessionCursor -} from '../../opencode/opencode-session-binder' -import { - sweepProcessIdentities, - type ProcessIdentityRow -} from '../../opencode/opencode-client-sweep' -import { lookupOpenCodeSessionPane } from '../../../shared/agent-hook-listener/opencode-session-registry' -import { AgentHookServerPersistence } from './server-persistence' - -/** Poll cadence; hook-triggered kicks cover births between polls. */ -const OPENCODE_BINDER_INTERVAL_MS = 60_000 -const OPENCODE_BINDER_KICK_DEBOUNCE_MS = 10_000 -/** Unbound sessions get re-correlated this long (pane inventory may lag births). */ -const OPENCODE_BINDER_UNBOUND_RETRY_MS = 10 * 60_000 -const OPENCODE_BINDER_PARENTS_MAX = 2_000 -const OPENCODE_BINDER_UNBOUND_MAX = 500 - -/** Injectable I/O for the binder loop; real singletons by default, fakes in tests. */ -export type OpenCodeBinderLoopDeps = { - now: () => number - dbPath: () => string - listSessions: (dbPath: string, cursor: OpenCodeSessionCursor) => BinderSessionRow[] - listPanes: () => BinderPaneSnapshot[] - sweep: () => Promise -} - -/** - * Session→pane binder loop for the shared OpenCode server (#21359). - * - * 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 - * I/O rides injectable deps (real singletons by default) so tests drive the - * whole loop without touching the user's opencode.db or process table. - */ -export abstract class AgentHookServerOpenCodeBinder extends AgentHookServerPersistence { - private openCodeBinderTimer: ReturnType | null = null - private openCodeBinderKickTimer: ReturnType | null = null - private openCodeBinderRunning = false - private openCodeBinderGeneration = 0 - private openCodeBinderWatermark: OpenCodeSessionCursor = { ...OPENCODE_SESSION_CURSOR_START } - private openCodeBinderParents = new Map() - private openCodeBinderUnbound = new Map() - private openCodeBinderDeps: OpenCodeBinderLoopDeps = { - now: () => Date.now(), - dbPath: () => defaultOpenCodeDbPath(), - listSessions: (dbPath, sinceMs) => listOpenCodeDbSessions(dbPath, sinceMs), - listPanes: () => listBinderPaneSnapshots(), - sweep: () => sweepProcessIdentities() - } - - /** Test seam: drive the loop without the user's database or process table. */ - protected _setOpenCodeBinderDepsForTests(deps: Partial): void { - this.openCodeBinderDeps = { ...this.openCodeBinderDeps, ...deps } - } - - /** Start the 60s poll loop plus one immediate round, idempotently. */ - protected startOpenCodeBinderLoop(): void { - if (this.openCodeBinderTimer) { - return - } - this.openCodeBinderTimer = setInterval(() => { - void this.runOpenCodeBinderRoundOnce() - }, OPENCODE_BINDER_INTERVAL_MS) - if (this.openCodeBinderTimer.unref) { - this.openCodeBinderTimer.unref() - } - // Why immediately: existing sessions would otherwise keep the frozen - // stamp for up to a full interval after launch or restart. - void this.runOpenCodeBinderRoundOnce() - } - - /** Stop timers and drop ephemeral binder state; in-flight rounds are discarded by generation. */ - protected stopOpenCodeBinderLoop(): void { - // Why the generation bump: a round awaiting the process sweep must not - // apply ownerships — or resurrect the watermark — after the loop stopped. - this.openCodeBinderGeneration += 1 - if (this.openCodeBinderTimer) { - clearInterval(this.openCodeBinderTimer) - this.openCodeBinderTimer = null - } - if (this.openCodeBinderKickTimer) { - clearTimeout(this.openCodeBinderKickTimer) - this.openCodeBinderKickTimer = null - } - this.openCodeBinderRunning = false - this.openCodeBinderWatermark = { ...OPENCODE_SESSION_CURSOR_START } - this.openCodeBinderParents.clear() - this.openCodeBinderUnbound.clear() - } - - /** - * A birth may have arrived (opencode SessionStart): run one round soon so - * the session binds before its first busy stretch, instead of waiting out - * the poll interval. Trailing-edge debounced; concurrent rounds collapse. - */ - protected kickOpenCodeBinder(): void { - if (this.openCodeBinderKickTimer) { - return - } - this.openCodeBinderKickTimer = setTimeout(() => { - this.openCodeBinderKickTimer = null - void this.runOpenCodeBinderRoundOnce() - }, OPENCODE_BINDER_KICK_DEBOUNCE_MS) - if (this.openCodeBinderKickTimer.unref) { - this.openCodeBinderKickTimer.unref() - } - } - - /** Run one correlate-and-bind round; returns applied binding count. */ - protected async runOpenCodeBinderRoundOnce(): Promise { - if (this.openCodeBinderRunning) { - return 0 - } - this.openCodeBinderRunning = true - // Why capture before the try: if stop() lands while the sweep is in - // flight and a restart begins a new round, the obsolete round must not - // clear the new round's running flag (or two rounds overlap and apply - // ownership snapshots out of order). - const generation = this.openCodeBinderGeneration - try { - const deps = this.openCodeBinderDeps - const nowMs = deps.now() - const fresh = deps.listSessions(deps.dbPath(), this.openCodeBinderWatermark) - const sessions = [...fresh] - for (const [id, entry] of this.openCodeBinderUnbound) { - if (nowMs - entry.firstSeenMs > OPENCODE_BINDER_UNBOUND_RETRY_MS) { - this.openCodeBinderUnbound.delete(id) - continue - } - if (!fresh.some((row) => row.id === id)) { - sessions.push(entry.row) - } - } - if (sessions.length === 0) { - return 0 - } - const panes = deps.listPanes() - const processes = await deps.sweep() - if (generation !== this.openCodeBinderGeneration) { - return 0 - } - const knownOwners = new Map() - for (const session of sessions) { - const bound = lookupOpenCodeSessionPane(this.state, session.id) - if (bound) { - knownOwners.set(session.id, bound.paneKey) - } - this.openCodeBinderParents.delete(session.id) - this.openCodeBinderParents.set(session.id, session.parentId) - } - while (this.openCodeBinderParents.size > OPENCODE_BINDER_PARENTS_MAX) { - const oldest = this.openCodeBinderParents.keys().next().value - if (oldest === undefined) { - break - } - this.openCodeBinderParents.delete(oldest) - } - const { ownerships } = runOpenCodeBinderRound({ - nowMs, - sessions, - panes, - processes, - knownOwners, - parentBySessionId: this.openCodeBinderParents - }) - const boundIds = new Set(ownerships.map((ownership) => ownership.sessionId)) - const applied = applyBinderOwnerships(this.state, panes, ownerships, nowMs) - for (const session of sessions) { - if (knownOwners.has(session.id) || boundIds.has(session.id)) { - this.openCodeBinderUnbound.delete(session.id) - continue - } - if (!this.openCodeBinderUnbound.has(session.id)) { - if (this.openCodeBinderUnbound.size >= OPENCODE_BINDER_UNBOUND_MAX) { - break - } - this.openCodeBinderUnbound.set(session.id, { row: session, firstSeenMs: nowMs }) - } - } - // Why from handled rows only: a session the full map could not track - // must stay re-listable next round instead of being silently passed by - // the watermark. - this.openCodeBinderWatermark = advanceBinderCursor({ - fresh, - isHandled: (sessionId) => - knownOwners.has(sessionId) || - boundIds.has(sessionId) || - this.openCodeBinderUnbound.has(sessionId), - current: this.openCodeBinderWatermark - }) - return applied - } catch (err) { - // Why swallow: a binder failure must never break hook serving; the next - // round retries, and unbound sessions keep today's stamped behavior. - console.warn('[opencode-binder] round failed; keeping stamped attribution', err) - return 0 - } finally { - if (generation === this.openCodeBinderGeneration) { - this.openCodeBinderRunning = false - } - } - } -} 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 000000000000..c4703a52a206 --- /dev/null +++ b/src/main/opencode/hook-plugin-opencode2-tui-ownership.test.ts @@ -0,0 +1,336 @@ +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; 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 } } + ] + } +} + +/** 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() + let route: { type: string; sessionID?: string } = { type: 'home' } + 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 } }, + client: { + session: { get: async ({ sessionID }: { sessionID: string }) => sessions.get(sessionID) } + }, + data: { + listen, + session: { + get: (id: string) => sessions.get(id), + root: (id: string) => { + let current = sessions.get(id) + while (current?.parentID && sessions.has(current.parentID)) { + current = sessions.get(current.parentID) + } + return current?.id ?? id + }, + family: () => [], + status: (id: string) => (running.has(id) ? 'running' : 'idle'), + permission: { list: () => [] }, + form: { list: () => [] } + } + } + } + return { + ctx, + listen, + navigate(sessionID: string) { + route = { type: 'session', sessionID } + }, + emit(event: BusEvent) { + const sessionID = String(event.data.sessionID) + if (event.type === 'session.created') { + sessions.set(sessionID, { id: sessionID }) + } else if (event.type === 'session.execution.started') { + running.add(sessionID) + } else if (event.type === 'session.execution.succeeded') { + running.delete(sessionID) + } + for (const handler of listeners) { + handler({ details: event }) + } + } + } +} + +describe('OpenCode 2 TUI adapter: each pane reports its own sessions', () => { + let tempDir: string + let savedFetch: typeof globalThis.fetch + let savedEnv: Record + let savedArgv: string[] + let posts: Post[] + + 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 = [] + globalThis.fetch = vi.fn(async (_input, init) => { + posts.push(JSON.parse(String(init?.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) + 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([]) + }) + + it.each([ + ['an OpenCode 1 TUI', () => fakeTui('1.18.33')], + ['the other pane variant', () => 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 + 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-service.test.ts b/src/main/opencode/hook-service.test.ts index 2375cf02fd48..36c145d6f853 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. @@ -471,6 +483,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 74f24577c6ed..3099a89fbf09 100644 --- a/src/main/opencode/hook-service.ts +++ b/src/main/opencode/hook-service.ts @@ -32,6 +32,10 @@ import { isInstalledOpenCodePluginCurrent, isOverlayOpenCodePluginCurrent } from '../../shared/opencode-installed-plugin' +import { + openCodeTuiPluginDirName, + writeOpenCodeTuiPlugin +} from '../../shared/opencode-tui-plugin-install' const ORCA_OPENCODE_PLUGIN_FILE = 'orca-opencode-status.js' const OPENCODE_OVERLAY_DIR = 'opencode-config-overlays' @@ -47,6 +51,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. @@ -103,6 +109,7 @@ export class OpenCodeHookService { 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 +124,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 @@ -257,7 +266,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( @@ -291,6 +304,7 @@ export class OpenCodeHookService { } writeFileSync(pluginPath, source) } + this.writeTuiPlugin(pluginsDir, source) } private writePluginToConfigDir(configDir: string): void { @@ -301,6 +315,13 @@ export class OpenCodeHookService { if (!isInstalledOpenCodePluginCurrent(pluginPath, source)) { writeFileSync(pluginPath, source) } + this.writeTuiPlugin(pluginsDir, source) + } + + private writeTuiPlugin(pluginsDir: string, source: string): void { + if (this.installsTuiPlugin) { + writeOpenCodeTuiPlugin(pluginsDir, this.pluginFileName, source) + } } } @@ -309,7 +330,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-client-sweep.test.ts b/src/main/opencode/opencode-client-sweep.test.ts deleted file mode 100644 index 72f5106c8f6d..000000000000 --- a/src/main/opencode/opencode-client-sweep.test.ts +++ /dev/null @@ -1,215 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { ProcessResult, ProcessSpec } from '../../shared/child-process/process-spec' -import { - isOpenCodeClientArgv, - isOpenCodeClientProcess, - nativeWindowsRowToIdentity, - parsePsArgsLine, - parsePsCommLine, - parsePsElapsedToMs, - splitCommandLineArgv, - sweepProcessIdentities -} from './opencode-client-sweep' - -const NOW = 1_700_000_000_000 - -describe('parsePsElapsedToMs', () => { - it('reads mm:ss, hh:mm:ss and dd-hh:mm:ss', () => { - expect(parsePsElapsedToMs('02:11', NOW)).toBe(NOW - 131_000) - expect(parsePsElapsedToMs('1:02:11', NOW)).toBe(NOW - 3_731_000) - expect(parsePsElapsedToMs('2-01:02:11', NOW)).toBe(NOW - 176_531_000) - }) - - it('rejects unknown shapes', () => { - expect(parsePsElapsedToMs('', NOW)).toBeNull() - expect(parsePsElapsedToMs('yesterday', NOW)).toBeNull() - }) -}) - -describe('parsePsArgsLine', () => { - it('parses a client row', () => { - const row = parsePsArgsLine('23487 22618 2:11:51 opencode', NOW) - expect(row).toMatchObject({ pid: 23487, ppid: 22618, argv: ['opencode'] }) - expect(row?.startedAtMs).toBe(NOW - (2 * 3_600 + 11 * 60 + 51) * 1000) - }) - - it('keeps session flags in argv', () => { - const row = parsePsArgsLine('999 100 00:05 opencode --session ses_abc', NOW) - expect(row?.argv).toEqual(['opencode', '--session', 'ses_abc']) - }) - - it('keeps a quoted executable path as argv[0]', () => { - const row = parsePsArgsLine('999 100 00:05 "/opt/my tools/opencode" --session ses_abc', NOW) - expect(row?.argv).toEqual(['/opt/my tools/opencode', '--session', 'ses_abc']) - expect(row?.executable).toBe('') - }) - - it('drops header-shaped and truncated rows', () => { - expect(parsePsArgsLine('PID PPID ELAPSED COMMAND', NOW)).toBeNull() - expect(parsePsArgsLine('1 0', NOW)).toBeNull() - expect(parsePsArgsLine('', NOW)).toBeNull() - }) -}) - -describe('parsePsCommLine', () => { - it('reads the executable name past the pid', () => { - expect(parsePsCommLine('23487 opencode')).toEqual({ pid: 23487, executable: 'opencode' }) - }) - - it('keeps executable names containing spaces whole', () => { - expect(parsePsCommLine(' 999 My App Helper ')).toEqual({ - pid: 999, - executable: 'My App Helper' - }) - }) - - it('drops header-shaped and truncated rows', () => { - expect(parsePsCommLine('PID COMMAND')).toBeNull() - expect(parsePsCommLine('1')).toBeNull() - expect(parsePsCommLine('')).toBeNull() - }) -}) - -describe('splitCommandLineArgv', () => { - it('groups double-quoted spans', () => { - expect( - splitCommandLineArgv('"C:\\Program Files\\OpenCode\\opencode.exe" --session ses_1') - ).toEqual(['C:\\Program Files\\OpenCode\\opencode.exe', '--session', 'ses_1']) - }) - - it('splits plain argv on whitespace', () => { - expect(splitCommandLineArgv('opencode --session ses_1')).toEqual([ - 'opencode', - '--session', - 'ses_1' - ]) - }) - - it('drops empties', () => { - expect(splitCommandLineArgv('')).toEqual([]) - }) -}) - -describe('nativeWindowsRowToIdentity', () => { - it('maps pid, creation time and quoted command line', () => { - const row = nativeWindowsRowToIdentity({ - pid: 23487, - ppid: 22618, - name: 'opencode.exe', - creationTimeMs: NOW - 60_000, - command: '"C:\\Program Files\\OpenCode\\opencode.exe" --session ses_1' - }) - expect(row).toMatchObject({ - pid: 23487, - ppid: 22618, - startedAtMs: NOW - 60_000, - executable: 'opencode.exe', - argv: ['C:\\Program Files\\OpenCode\\opencode.exe', '--session', 'ses_1'] - }) - }) - - it('skips rows without a creation time or command line', () => { - expect(nativeWindowsRowToIdentity({ pid: 4, ppid: 0, name: 'System', command: '' })).toBeNull() - expect( - nativeWindowsRowToIdentity({ pid: 4, ppid: 0, name: 'System', command: 'opencode' }) - ).toBeNull() - }) -}) - -describe('isOpenCodeClientArgv', () => { - it('matches clients and rejects the serve daemon', () => { - expect(isOpenCodeClientArgv(['opencode'])).toBe(true) - expect(isOpenCodeClientArgv(['/opt/homebrew/bin/opencode', '--session', 'ses_1'])).toBe(true) - expect(isOpenCodeClientArgv(['C:\\tools\\opencode.exe'])).toBe(true) - expect( - isOpenCodeClientArgv(['C:\\Program Files\\OpenCode\\opencode.exe', '--session', 'ses_1']) - ).toBe(true) - expect(isOpenCodeClientArgv(['opencode.exe', 'serve', '--service'])).toBe(false) - expect(isOpenCodeClientArgv(['node', 'server.js'])).toBe(false) - expect(isOpenCodeClientArgv([])).toBe(false) - }) -}) - -describe('isOpenCodeClientProcess', () => { - it('trusts the executable when argv[0] is truncated by spaces', () => { - expect( - isOpenCodeClientProcess({ executable: 'opencode', argv: ['/opt/Open', 'Code/opencode'] }) - ).toBe(true) - }) - - it('still rejects the serve daemon', () => { - expect( - isOpenCodeClientProcess({ executable: 'opencode', argv: ['opencode', 'serve', '--service'] }) - ).toBe(false) - }) - - it('falls back to argv[0] without an executable', () => { - expect(isOpenCodeClientProcess({ executable: '', argv: ['opencode'] })).toBe(true) - expect(isOpenCodeClientProcess({ executable: '', argv: ['node', 'server.js'] })).toBe(false) - }) -}) - -describe('sweepProcessIdentities', () => { - function psRunner( - outputs: Record<'args' | 'comm', string | Error> - ): (spec: ProcessSpec) => Promise { - return async (spec: ProcessSpec): Promise => { - const kind = spec.args?.some((arg) => arg.includes('comm=')) ? 'comm' : 'args' - const output = outputs[kind] - if (output instanceof Error) { - throw output - } - return { - code: 0, - signal: null, - stdout: output, - stderr: '', - timedOut: false - } - } - } - - it('joins the comm executable onto args rows on POSIX', async () => { - const rows = await sweepProcessIdentities({ - platform: 'darwin', - nowMs: NOW, - run: psRunner({ - args: '23487 22618 00:05 /opt/Open Code/opencode --session ses_1\n', - comm: '23487 opencode\n999 My App Helper\n' - }) - }) - expect(rows).toHaveLength(1) - expect(rows[0]).toMatchObject({ pid: 23487, executable: 'opencode' }) - expect(rows[0]?.argv).toEqual(['/opt/Open', 'Code/opencode', '--session', 'ses_1']) - }) - - it('degrades to argv[0] matching when the comm sweep fails', async () => { - const rows = await sweepProcessIdentities({ - platform: 'darwin', - nowMs: NOW, - run: psRunner({ - args: '23487 22618 00:05 opencode --session ses_1\n', - comm: new Error('comm unavailable') - }) - }) - expect(rows).toHaveLength(1) - expect(rows[0]).toMatchObject({ pid: 23487, executable: '' }) - }) - - it('reads the Windows table through the injected reader', async () => { - const rows = await sweepProcessIdentities({ - platform: 'win32', - readWindowsTable: async () => [ - { - pid: 23487, - ppid: 22618, - name: 'opencode.exe', - creationTimeMs: NOW - 60_000, - command: '"C:\\Program Files\\OpenCode\\opencode.exe"' - } - ] - }) - expect(rows).toHaveLength(1) - expect(rows[0]).toMatchObject({ pid: 23487, ppid: 22618, startedAtMs: NOW - 60_000 }) - }) -}) diff --git a/src/main/opencode/opencode-client-sweep.ts b/src/main/opencode/opencode-client-sweep.ts deleted file mode 100644 index bb37e504caaa..000000000000 --- a/src/main/opencode/opencode-client-sweep.ts +++ /dev/null @@ -1,263 +0,0 @@ -import { runProcess } from '../../shared/child-process/run-process' -import { - readWindowsProcessTable, - type WindowsProcessRow as NativeWindowsProcessRow -} from '../windows/windows-process-table' - -/** - * Host-wide sweep locating live OpenCode client processes for the - * session→pane binder (#21359). - * - * Why a dedicated sweep instead of reusing the memory collector's: that - * index carries pid/ppid/cpu/rss but no argv or start times, and importing - * the memory subsystem here would drag its Electron app-metrics dependency - * into the hook path. On Windows the table is read only through the native - * reader (`windows-process-table.ts`); on macOS/Linux through one `ps` call. - * The invocation pattern (5 s timeout, 10 MB cap, fail-open []) mirrors - * `windows-process-resource-collector.ts`. - */ - -/** One process identity row from a host sweep. */ -export type ProcessIdentityRow = { - pid: number - ppid: number - /** ms epoch the process started. */ - startedAtMs: number - /** - * Kernel-reported executable name (`comm=` on POSIX, `name` on Windows). - * Unlike `args=`, this is not a reconstructed string, so an install path - * containing spaces cannot split it. Empty when the sweep could not read it; - * classification then falls back to argv[0]. - */ - executable: string - /** argv approximation; see parsePsArgsLine. */ - argv: string[] -} - -const SWEEP_TIMEOUT_MS = 5_000 -const SWEEP_MAX_BYTES = 10 * 1024 * 1024 - -/** `[[dd-]hh:]mm:ss` → elapsed ms, or null when the shape is unknown. */ -export function parsePsElapsedToMs(etime: string, nowMs: number): number | null { - const match = etime.trim().match(/^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)$/) - if (!match) { - return null - } - const days = Number.parseInt(match[1] ?? '0', 10) - const hours = Number.parseInt(match[2] ?? '0', 10) - const minutes = Number.parseInt(match[3] ?? '0', 10) - const seconds = Number.parseInt(match[4] ?? '0', 10) - if ([days, hours, minutes, seconds].some((n) => !Number.isFinite(n) || n < 0)) { - return null - } - return nowMs - ((days * 24 + hours) * 3_600 + minutes * 60 + seconds) * 1000 -} - -/** - * Split a command line into argv, grouping `"..."` so a quoted executable - * path survives as argv[0]. Covers the shapes that matter here (a quoted - * install path plus plain flags); it is not a full shell parser — an escaped - * quote inside a quoted span still splits. Downstream only flag-adjacent - * values (`--session `) are read from this argv; classification uses the - * kernel executable name, because `ps` `args=` cannot preserve argv - * boundaries for unquoted paths. - */ -export function splitCommandLineArgv(commandLine: string): string[] { - const argv: string[] = [] - const pattern = /"([^"]*)"|(\S+)/g - let match: RegExpExecArray | null - while ((match = pattern.exec(commandLine)) !== null) { - argv.push(match[1] ?? match[2] ?? '') - } - return argv.filter((part) => part.length > 0) -} - -/** - * One `ps -eo pid=,ppid=,etime=,args=` line. `args` is a reconstructed - * command-and-arguments string: argv boundaries are lost, so a path with an - * unquoted space (e.g. `/opt/Open Code/opencode`) splits argv[0] in two. - * The executable name therefore comes from a separate `comm=` sweep; - * this parser records the flags it can still read reliably (`--session` - * values never contain spaces) and leaves `executable` empty for the join. - */ -export function parsePsArgsLine(line: string, nowMs: number): ProcessIdentityRow | null { - // Why a regex instead of split-with-limit: split discards everything past - // the limit, which would truncate argv to its first token. - const match = line.trim().match(/^(\S+)\s+(\S+)\s+(\S+)\s+([\s\S]*\S)\s*$/) - if (!match) { - return null - } - const [, pidText, ppidText, etimeText, argsText] = match - if (!pidText || !ppidText || !etimeText || !argsText) { - return null - } - const pid = Number.parseInt(pidText, 10) - const ppid = Number.parseInt(ppidText, 10) - const startedAtMs = parsePsElapsedToMs(etimeText, nowMs) - const argv = splitCommandLineArgv(argsText) - if ( - !Number.isFinite(pid) || - !Number.isFinite(ppid) || - startedAtMs === null || - argv.length === 0 - ) { - return null - } - return { pid, ppid, startedAtMs, executable: '', argv } -} - -/** - * One `ps -eo pid=,comm=` line. `comm` is the kernel's executable name as a - * trailing field, so it may itself contain spaces — everything past the pid - * is the name. Empty names are dropped; the join then falls back to argv[0]. - */ -export function parsePsCommLine(line: string): { pid: number; executable: string } | null { - const match = line.trim().match(/^(\S+)\s+([\s\S]*\S)\s*$/) - if (!match) { - return null - } - const [, pidText, executable] = match - const pid = Number.parseInt(pidText ?? '', 10) - if (!Number.isFinite(pid) || !executable) { - return null - } - return { pid, executable } -} - -/** - * One native Windows process-table row. Rows without a kernel creation time - * cannot bracket a session creation, so they are skipped rather than guessed. - */ -export function nativeWindowsRowToIdentity( - row: NativeWindowsProcessRow -): ProcessIdentityRow | null { - if (!Number.isFinite(row.pid) || !Number.isFinite(row.ppid)) { - return null - } - if (typeof row.creationTimeMs !== 'number' || !Number.isFinite(row.creationTimeMs)) { - return null - } - const argv = splitCommandLineArgv(row.command) - if (argv.length === 0) { - return null - } - return { - pid: row.pid, - ppid: row.ppid, - startedAtMs: row.creationTimeMs, - executable: row.name, - argv - } -} - -function executableBaseName(value: string): string { - const bare = value.split(/[\\/]/).at(-1) ?? '' - return bare.toLowerCase().replace(/\.exe$/, '') -} - -function argvZeroBase(argv: readonly string[]): string { - return executableBaseName(argv[0] ?? '') -} - -/** True for an OpenCode TUI/CLI client process (not the `serve` daemon). */ -export function isOpenCodeClientArgv(argv: readonly string[]): boolean { - return isOpenCodeClientProcess({ executable: '', argv }) -} - -/** - * True for an OpenCode TUI/CLI client process (not the `serve` daemon). - * The kernel-reported executable wins when present: `ps` `args=` cannot - * preserve argv boundaries, so a truncated argv[0] must not veto a matching - * executable. With no executable recorded this degrades to argv[0] matching. - */ -export function isOpenCodeClientProcess(row: { - executable: string - argv: readonly string[] -}): boolean { - const classified = - row.executable && row.executable.length > 0 - ? executableBaseName(row.executable) - : argvZeroBase(row.argv) - if (classified !== 'opencode') { - return false - } - // Why exclude: the shared server's posts are the ones being reattributed; - // mistaking the daemon for a pane client would bind sessions to its pane. - return !row.argv.some((part) => part === 'serve' || part === '--service') -} - -/** Every process identity row on this host; fail-open [] like the memory sweeps. */ -export async function sweepProcessIdentities( - deps: { - platform?: NodeJS.Platform - run?: typeof runProcess - nowMs?: number - readWindowsTable?: () => Promise - } = {} -): Promise { - const platform = deps.platform ?? process.platform - const run = deps.run ?? runProcess - const nowMs = deps.nowMs ?? Date.now() - try { - if (platform === 'win32') { - // Why the native table and nothing else: it is the only sanctioned - // Windows process-table reader (see windows-process-enumeration.md); - // forking powershell.exe for a whole-table CIM scan is exactly the - // pattern it retired. - const readTable = deps.readWindowsTable ?? readWindowsProcessTable - const rows = await readTable() - return rows - .map((row) => nativeWindowsRowToIdentity(row)) - .filter((row): row is ProcessIdentityRow => row !== null) - } - const stdout = await execFileText(run, 'ps', ['-eo', 'pid=,ppid=,etime=,args=']) - const rows = stdout - .split('\n') - .map((line) => parsePsArgsLine(line, nowMs)) - .filter((row): row is ProcessIdentityRow => row !== null) - // Why a second sweep: `args=` is one reconstructed string, so the - // executable name for classification comes from `comm=` instead. A - // failed comm sweep degrades to argv[0] matching rather than dropping - // the whole round. - try { - const commOut = await execFileText(run, 'ps', ['-eo', 'pid=,comm=']) - const executables = new Map() - for (const line of commOut.split('\n')) { - const parsed = parsePsCommLine(line) - if (parsed && !executables.has(parsed.pid)) { - executables.set(parsed.pid, parsed.executable) - } - } - for (const row of rows) { - const executable = executables.get(row.pid) - if (executable) { - row.executable = executable - } - } - } catch (err) { - console.warn('[opencode-binder] comm sweep failed; classifying from argv', err) - } - return rows - } catch (err) { - console.warn('[opencode-binder] process sweep failed; skipping round', err) - return [] - } -} - -/** Run one child process to text, throwing on timeout or nonzero exit. */ -async function execFileText( - run: typeof runProcess, - program: string, - args: string[] -): Promise { - const result = await run({ - program, - args, - timeoutMs: SWEEP_TIMEOUT_MS, - maxOutputBytes: SWEEP_MAX_BYTES - }) - if (result.timedOut || result.code !== 0) { - throw new Error(`${program} exited ${result.code ?? 'on timeout'}`) - } - return result.stdout -} diff --git a/src/main/opencode/opencode-session-binder.test.ts b/src/main/opencode/opencode-session-binder.test.ts deleted file mode 100644 index 547ca9d01ba3..000000000000 --- a/src/main/opencode/opencode-session-binder.test.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { createHookListenerState } from '../../shared/agent-hook-listener/listener-state' -import { lookupOpenCodeSessionPane } from '../../shared/agent-hook-listener/opencode-session-registry' -import { makePaneKey } from '../../shared/stable-pane-id' -import type { ProcessIdentityRow } from './opencode-client-sweep' -import { - advanceBinderCursor, - applyBinderOwnerships, - OPENCODE_SESSION_CURSOR_START, - runOpenCodeBinderRound, - type BinderPaneSnapshot -} from './opencode-session-binder' - -const NOW = 1_700_000_100_000 -const DIR = '/Users/jin/work/mocitec' -const PANE_A = makePaneKey('tab-a', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa') -const PANE_B = makePaneKey('tab-b', 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb') - -function proc( - pid: number, - ppid: number, - argv: string[], - startedAtMs = NOW - 120_000 -): ProcessIdentityRow { - return { pid, ppid, startedAtMs, executable: argv[0] ?? '', argv } -} - -function pane(paneKey: string, shellPid: number | null): BinderPaneSnapshot { - return { paneKey, directory: DIR, worktreeId: 'repo::/Users/jin/work/mocitec', shellPid } -} - -describe('runOpenCodeBinderRound', () => { - it('attributes a client to its pane subtree and binds the session', () => { - const { ownerships } = runOpenCodeBinderRound({ - nowMs: NOW, - sessions: [{ id: 'ses_1', directory: DIR, createdAtMs: NOW - 60_000, parentId: null }], - panes: [pane(PANE_A, 100), pane(PANE_B, 200)], - processes: [ - proc(100, 1, ['zsh']), - proc(200, 1, ['zsh']), - proc(210, 200, ['opencode'], NOW - 90_000) - ], - knownOwners: new Map(), - parentBySessionId: new Map() - }) - expect(ownerships).toEqual([ - { sessionId: 'ses_1', paneKey: PANE_B, basis: 'creation-correlation' } - ]) - }) - - it('ignores clients outside every pane subtree', () => { - const { ownerships } = runOpenCodeBinderRound({ - nowMs: NOW, - sessions: [{ id: 'ses_1', directory: DIR, createdAtMs: NOW - 60_000, parentId: null }], - panes: [pane(PANE_A, 100)], - processes: [proc(100, 1, ['zsh']), proc(999, 1, ['opencode'], NOW - 90_000)], - knownOwners: new Map(), - parentBySessionId: new Map() - }) - expect(ownerships).toEqual([]) - }) - - it('inherits a root owner across the watermark via the parent map', () => { - const { ownerships } = runOpenCodeBinderRound({ - nowMs: NOW, - sessions: [ - { id: 'ses_child', directory: DIR, createdAtMs: NOW - 30_000, parentId: 'ses_root' } - ], - panes: [pane(PANE_A, 100)], - processes: [proc(100, 1, ['zsh']), proc(101, 100, ['opencode'], NOW - 3_600_000)], - knownOwners: new Map([['ses_root', PANE_A]]), - parentBySessionId: new Map([['ses_child', 'ses_root']]) - }) - expect(ownerships).toEqual([ - { sessionId: 'ses_child', paneKey: PANE_A, basis: 'creation-correlation' } - ]) - }) - - it('dedupes same-key snapshots newest-wins', () => { - const { ownerships } = runOpenCodeBinderRound({ - nowMs: NOW, - sessions: [{ id: 'ses_1', directory: DIR, createdAtMs: NOW - 60_000, parentId: null }], - panes: [ - { ...pane(PANE_A, 100), directory: '/elsewhere' }, - { ...pane(PANE_A, 101), directory: DIR } - ], - processes: [ - proc(100, 1, ['zsh']), - proc(101, 1, ['zsh']), - proc(102, 101, ['opencode'], NOW - 90_000) - ], - knownOwners: new Map(), - parentBySessionId: new Map() - }) - expect(ownerships).toEqual([ - { sessionId: 'ses_1', paneKey: PANE_A, basis: 'single-pane-directory' } - ]) - }) - - it('advances the cursor past handled rows only', () => { - const fresh = [ - { id: 'ses_1', directory: DIR, createdAtMs: NOW - 60_000, parentId: null }, - { id: 'ses_2', directory: DIR, createdAtMs: NOW - 10_000, parentId: null } - ] - expect( - advanceBinderCursor({ - fresh, - isHandled: () => true, - current: OPENCODE_SESSION_CURSOR_START - }) - ).toEqual({ ms: NOW - 10_000, id: 'ses_2' }) - }) - - it('freezes the cursor before the first unhandled row so it is re-listed', () => { - const fresh = [ - { id: 'ses_1', directory: DIR, createdAtMs: NOW - 60_000, parentId: null }, - { id: 'ses_2', directory: DIR, createdAtMs: NOW - 10_000, parentId: null } - ] - expect( - advanceBinderCursor({ - fresh, - isHandled: (id) => id === 'ses_1', - current: OPENCODE_SESSION_CURSOR_START - }) - ).toEqual({ ms: NOW - 60_000, id: 'ses_1' }) - }) - - it('keeps the cursor when nothing was handled', () => { - const current = { ms: NOW - 120_000, id: 'ses_0' } - expect( - advanceBinderCursor({ - fresh: [{ id: 'ses_1', directory: DIR, createdAtMs: NOW - 60_000, parentId: null }], - isHandled: () => false, - current - }) - ).toBe(current) - }) -}) - -describe('applyBinderOwnerships', () => { - it('writes bindings with the pane worktree into the registry', () => { - const state = createHookListenerState() - const applied = applyBinderOwnerships( - state, - [pane(PANE_A, 100)], - [{ sessionId: 'ses_1', paneKey: PANE_A, basis: 'argv' }], - NOW - ) - expect(applied).toBe(1) - expect(lookupOpenCodeSessionPane(state, 'ses_1')).toMatchObject({ - paneKey: PANE_A, - worktreeId: 'repo::/Users/jin/work/mocitec' - }) - }) - - it('takes the newest row worktree when a pane remints', () => { - const state = createHookListenerState() - // Registry insertion order puts the stale row first; the live remint row - // carries a different worktree and must win, matching the round's - // newest-wins pane dedupe. - const applied = applyBinderOwnerships( - state, - [ - { paneKey: PANE_A, directory: '/elsewhere', worktreeId: 'repo::/elsewhere', shellPid: 100 }, - { ...pane(PANE_A, 101) } - ], - [{ sessionId: 'ses_1', paneKey: PANE_A, basis: 'argv' }], - NOW - ) - expect(applied).toBe(1) - expect(lookupOpenCodeSessionPane(state, 'ses_1')).toMatchObject({ - paneKey: PANE_A, - worktreeId: 'repo::/Users/jin/work/mocitec' - }) - }) -}) diff --git a/src/main/opencode/opencode-session-binder.ts b/src/main/opencode/opencode-session-binder.ts deleted file mode 100644 index 9d40ccb278c9..000000000000 --- a/src/main/opencode/opencode-session-binder.ts +++ /dev/null @@ -1,341 +0,0 @@ -import { resolveOpenCodeDataDirectory } from './opencode-data-directory' -import { - bindOpenCodeSession, - type OpenCodeSessionBinding -} from '../../shared/agent-hook-listener/opencode-session-registry' -import { - correlateOpenCodeSessionOwners, - type CorrelatedClient, - type CorrelatedPane, - type CorrelatedSession, - type SessionOwnership -} from '../../shared/agent-hook-listener/opencode-session-correlation' -import { readOpenCodeDatabase } from '../ai-vault/session-scanner-opencode-sqlite-open' -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). - * - * Each round: read new sessions from the shared server's SQLite store, - * snapshot panes, sweep for live clients, correlate, bind. Everything the - * round needs is injected so the decision core stays unit-testable; only - * the SQLite reader below touches disk, reusing ai-vault's guarded open - * (read-only + query_only + busy timeout). - */ - -/** One pane snapshot feeding a binder round. */ -export type BinderPaneSnapshot = { - paneKey: string - /** Worktree root backing the pane (null when unknown); sessions beneath it are candidates. */ - directory: string | null - worktreeId: string | null - shellPid: number | null -} - -/** One session store row feeding a binder round. */ -export type BinderSessionRow = { - id: string - directory: string - createdAtMs: number - parentId: string | null -} - -/** Everything one binder round needs, injected for tests. */ -export type BinderRoundDeps = { - nowMs: number - sessions: readonly BinderSessionRow[] - panes: readonly BinderPaneSnapshot[] - processes: readonly ProcessIdentityRow[] - knownOwners: ReadonlyMap - parentBySessionId: ReadonlyMap -} - -/** Ownership decisions from one binder round. */ -export type BinderRoundResult = { - ownerships: SessionOwnership[] -} - -/** Position in the session store; composite so same-millisecond rows are never skipped. */ -export type OpenCodeSessionCursor = { - ms: number - id: string -} - -/** Cursor before anything was ever read. */ -export const OPENCODE_SESSION_CURSOR_START: OpenCodeSessionCursor = { ms: 0, id: '' } - -/** Order cursors the way the store lists rows: oldest first, id as tiebreak. */ -function compareSessionRows(left: OpenCodeSessionCursor, right: OpenCodeSessionCursor): number { - return left.ms - right.ms || (left.id < right.id ? -1 : left.id > right.id ? 1 : 0) -} - -/** - * Advance the store cursor past handled rows only. `fresh` arrives in store - * order; handling is prefix-closed (the unbound-cap break only ever skips the - * tail), so the first unhandled row freezes the cursor and every row at or - * past it is re-listed next round instead of silently dropped. - */ -export function advanceBinderCursor(args: { - fresh: readonly BinderSessionRow[] - isHandled: (sessionId: string) => boolean - current: OpenCodeSessionCursor -}): OpenCodeSessionCursor { - let cursor = args.current - for (const session of args.fresh) { - if (!args.isHandled(session.id)) { - break - } - const candidate: OpenCodeSessionCursor = { ms: session.createdAtMs, id: session.id } - if (compareSessionRows(candidate, cursor) > 0) { - cursor = candidate - } - } - return cursor -} - -/** pid→ppid index for one sweep; first row wins on duplicate pids. */ -function childrenIndex(processes: readonly ProcessIdentityRow[]): Map { - const ppidByPid = new Map() - for (const row of processes) { - if (!ppidByPid.has(row.pid)) { - ppidByPid.set(row.pid, row.ppid) - } - } - return ppidByPid -} - -/** Nearest pane shell at or above this pid; external terminals stay unattributed. */ -function owningPane( - ppidByPid: Map, - shellPidByPid: Map, - pid: number -): string | null { - const seen = new Set() - let current: number | undefined = pid - while (current !== undefined && !seen.has(current)) { - seen.add(current) - const owner = shellPidByPid.get(current) - if (owner) { - return owner - } - current = ppidByPid.get(current) - } - return null -} - -/** Attribute opencode client rows to panes via shell-subtree walks. */ -function toCorrelatedClients( - processes: readonly ProcessIdentityRow[], - panes: readonly BinderPaneSnapshot[], - nowMs: number -): CorrelatedClient[] { - const ppidByPid = childrenIndex(processes) - const shellPidByPid = new Map() - for (const pane of panes) { - if (pane.shellPid !== null && !shellPidByPid.has(pane.shellPid)) { - shellPidByPid.set(pane.shellPid, pane.paneKey) - } - } - const clients: CorrelatedClient[] = [] - for (const row of processes) { - if (!isOpenCodeClientProcess(row)) { - continue - } - const paneKey = owningPane(ppidByPid, shellPidByPid, row.pid) - if (!paneKey) { - continue - } - // Why lastSeenAlive = now: the sweep just observed it. Bracketing uses - // startedAt for the lower bound and this observation for the upper. - clients.push({ paneKey, startedAtMs: row.startedAtMs, lastSeenAliveMs: nowMs, argv: row.argv }) - } - return clients -} - -/** Pure round core: correlate unbound sessions against panes and clients. */ -export function runOpenCodeBinderRound(deps: BinderRoundDeps): BinderRoundResult { - // Why dedupe by key, newest wins: remints and reattachments can leave a - // stale registry row beside the live one; counting rows instead of panes - // would turn every same-pane tie into a false ambiguous and nothing would - // ever bind, while the oldest row would point the candidate set at a dead - // worktree. - const paneByKey = new Map() - for (const pane of deps.panes) { - paneByKey.set(pane.paneKey, { paneKey: pane.paneKey, directory: pane.directory }) - } - const panes = [...paneByKey.values()] - const clients = toCorrelatedClients(deps.processes, deps.panes, deps.nowMs) - const sessions: CorrelatedSession[] = deps.sessions.map((row) => ({ - id: row.id, - directory: row.directory, - createdAtMs: row.createdAtMs, - parentId: row.parentId - })) - // Why resolve inheritance here: the SQLite round only carries new rows, so - // an old root is invisible to the correlator; the binder's parent map walks - // the chain and the registry supplies the known root owner. Resolved heirs - // are emitted as binds (the registry lacks them) and fed back as known so - // the correlator skips what is already decided. - const knownOwners = new Map(deps.knownOwners) - const inherited: SessionOwnership[] = [] - for (const session of sessions) { - if (knownOwners.has(session.id) || !session.parentId) { - continue - } - let parent: string | null | undefined = session.parentId - const seen = new Set([session.id]) - while (parent && !seen.has(parent)) { - seen.add(parent) - const owner = knownOwners.get(parent) - if (owner) { - knownOwners.set(session.id, owner) - inherited.push({ sessionId: session.id, paneKey: owner, basis: 'creation-correlation' }) - break - } - parent = deps.parentBySessionId.get(parent) - } - } - const ownerships = [ - ...inherited, - ...correlateOpenCodeSessionOwners({ - sessions, - panes, - clients, - knownOwners - }) - ] - 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 - * cursor — including rows the LIMIT cut off last round — are re-listed - * instead of permanently skipped. Unknown shapes read as empty so an opencode - * schema move degrades to unbound sessions, never a crash. Fail-open [] on - * any read error for the same reason. - */ -export function listOpenCodeDbSessions( - dbPath: string, - cursor: OpenCodeSessionCursor -): BinderSessionRow[] { - try { - return readOpenCodeDatabase({ - dbPath, - read: (db) => { - const table = canReadSessionV2(db) ? 'session_v2' : 'session' - if ( - !tableExists(db, table) || - !columnExists(db, table, 'directory') || - !columnExists(db, table, 'time_created') - ) { - return [] - } - const parent = columnExists(db, table, 'parent_id') ? 'parent_id' : 'NULL' - const rows: unknown[] = db - .prepare( - `SELECT id, directory, time_created, ${parent} AS parent_id FROM ${table} WHERE time_created > ? OR (time_created = ? AND id > ?) ORDER BY time_created ASC, id ASC LIMIT 500` - ) - .all(cursor.ms, cursor.ms, cursor.id) - const sessions: BinderSessionRow[] = [] - for (const row of rows) { - if (typeof row !== 'object' || row === null) { - continue - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: node:sqlite returns plain row objects; the object check above plus the per-field validation below reject anything else. - const record = row as Record - if ( - typeof record.id !== 'string' || - typeof record.directory !== 'string' || - typeof record.time_created !== 'number' - ) { - continue - } - sessions.push({ - id: record.id, - directory: record.directory, - createdAtMs: record.time_created, - parentId: typeof record.parent_id === 'string' ? record.parent_id : null - }) - } - return sessions - } - }) - } catch (err) { - console.warn('[opencode-binder] session store read failed; skipping round', err) - return [] - } -} - -/** Default database path for the local shared server. */ -export function defaultOpenCodeDbPath(): string { - return `${resolveOpenCodeDataDirectory()}/opencode.db` -} - -/** - * Live local panes from the PTY registry: pane key, worktree root and shell - * pid. Panes without a key or pid (hydrated gaps, remote panes) cannot own a - * client subtree, so they are skipped — their sessions stay unbound rather - * than guessed. - */ -export function listBinderPaneSnapshots(): BinderPaneSnapshot[] { - const snapshots: BinderPaneSnapshot[] = [] - for (const pty of listRegisteredPtys()) { - if (!pty.paneKey || pty.pid === null) { - continue - } - const parsed = pty.worktreeId ? splitWorktreeIdForFilesystem(pty.worktreeId) : null - snapshots.push({ - paneKey: pty.paneKey, - directory: parsed?.worktreePath ?? null, - worktreeId: pty.worktreeId, - shellPid: pty.pid - }) - } - return snapshots -} - -/** Apply one round's decisions to the listener registry. */ -export function applyBinderOwnerships( - state: HookListenerState, - panes: readonly BinderPaneSnapshot[], - ownerships: readonly SessionOwnership[], - nowMs: number -): number { - const worktreeByPane = new Map() - for (const pane of panes) { - // Why overwrite: matching the round's newest-wins pane dedupe, so a - // remint's live row wins over a stale row with a different worktree. - worktreeByPane.set(pane.paneKey, pane.worktreeId) - } - let applied = 0 - for (const ownership of ownerships) { - const binding: OpenCodeSessionBinding = { - paneKey: ownership.paneKey, - boundAt: nowMs, - basis: ownership.basis - } - const worktreeId = worktreeByPane.get(ownership.paneKey) - if (worktreeId) { - binding.worktreeId = worktreeId - } - if (bindOpenCodeSession(state, ownership.sessionId, binding)) { - applied += 1 - } - } - return applied -} diff --git a/src/main/opencode/status-plugin-factory-source.ts b/src/main/opencode/status-plugin-factory-source.ts index 218d15c3e41f..65fbcb35c091 100644 --- a/src/main/opencode/status-plugin-factory-source.ts +++ b/src/main/opencode/status-plugin-factory-source.ts @@ -2,6 +2,7 @@ import { getOpenCode2SetupSource, getOpenCode2EventNormalizationSource } from '../opencode2/status-plugin-setup-source' +import { openCodeTuiPluginDirName } from '../../shared/opencode-tui-plugin-install' export function getStatusPluginFactorySource(options: { emitSessionStart: boolean @@ -286,7 +287,12 @@ export function getStatusPluginFactorySource(options: { ' },', ' };', '};', - ...(options.emitNextEvents ? getOpenCode2SetupSource() : []), + ...(options.emitNextEvents + ? [ + `const ORCA_TUI_PLUGIN_ENTRY = new URL("./${openCodeTuiPluginDirName(`${pluginID}.js`)}/tui.js", import.meta.url);`, + ...getOpenCode2SetupSource() + ] + : []), '', '// 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/opencode2/status-plugin-setup-source.ts b/src/main/opencode2/status-plugin-setup-source.ts index 7b825056ecd8..513b0c16e4bf 100644 --- a/src/main/opencode2/status-plugin-setup-source.ts +++ b/src/main/opencode2/status-plugin-setup-source.ts @@ -1,3 +1,5 @@ +import { getOpenCode2TuiSource } from './status-plugin-tui-source' + export function getOpenCode2SetupSource(): string[] { return String.raw` // Why: OpenCode owns a form under a session id, and Orca retires a blocker when @@ -8,8 +10,73 @@ 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 server subscription and the TUI listener must feed the engine +// identically, or a pane's status would depend on which process observed the event. +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."); + } else if (type === "session.step.started") { + // Why: re-derives Busy for a plugin reloaded mid-turn, which never saw execution.started. + // Tool events stay out: a parallel tool reports progress while a sibling form still blocks. + type = "session.next.step.started"; + } + 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) { 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 +84,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 +101,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 +131,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 000000000000..570cb302db90 --- /dev/null +++ b/src/main/opencode2/status-plugin-tui-source.ts @@ -0,0 +1,204 @@ +/** + * TUI adapter 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 the engine's env stamp names the right pane, and the engine sees only + * sessions this pane owns — the one-pane-per-engine invariant it was built on. + */ +export function getOpenCode2TuiSource(): string[] { + return String.raw` +const TUI_ROUTE_POLL_MS = 100; +const TUI_EARLY_EVENT_ROOTS_MAX = 32; + +function isOpenCode2TuiContext(ctx) { + return typeof ctx?.ui?.router?.current === "function" && typeof ctx?.data?.listen === "function"; +} + +// Engine state that still needs this root's events before it can settle. +function engineHoldsRoot(rootSessionID) { + if (busyRootOwnerBySessionID.has(rootSessionID)) return true; + for (const busyChild of busyChildRootByKey.values()) { + if (busyChild.sessionID === rootSessionID) return true; + } + for (const provisional of provisionalBusyByKey.values()) { + if (provisional.sessionID === rootSessionID) return true; + } + for (const attention of pendingAttentionByKey.values()) { + if ((attention.properties?.sessionID || attention.sourceSessionID) === rootSessionID) return true; + } + return false; +} + +async function setupOpenCode2Tui(ctx) { + const noop = async () => {}; + let hooks; + try { + const data = ctx.data.session; + // 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; + if (typeof data?.get !== "function" || typeof data.status !== "function") return noop; + const client = { + session: { + get: async (input, options) => { + const sessionID = input?.sessionID ?? input?.path?.id; + const cached = data.get(sessionID); + if (cached && cached.id === sessionID) return { data: cached }; + const result = await ctx.client?.session?.get?.({ sessionID }, options); + const info = result && typeof result.id === "string" ? result : result?.data; + return info && info.id === sessionID ? { data: info } : undefined; + }, + }, + }; + hooks = await OrcaOpenCodeStatusPlugin({ client }); + if (!hooks || typeof hooks.event !== "function") return noop; + const engine = hooks; + + let disposed = false; + let chain = Promise.resolve(); + let syncQueued = false; + let routeRoot; + const owned = new Set(); + // Root events seen before this pane owned the root (the route can switch after them). + const early = new Map(); + + const run = (task) => { + chain = chain.then(async () => { + if (!disposed) await task(); + }).catch(() => {}); + return chain; + }; + const forward = (event) => engine.event({ event }); + const rootOf = async (sessionID) => { + const resolved = await resolveRootSessionID(client, sessionID); + if (resolved) return resolved; + return typeof data.root === "function" ? data.root(sessionID) || sessionID : sessionID; + }; + const currentRoute = () => { + const route = ctx.ui.router.current(); + if (route?.type !== "session" || typeof route.sessionID !== "string") return undefined; + return typeof data.root === "function" ? data.root(route.sessionID) || route.sessionID : route.sessionID; + }; + const remember = (rootSessionID, event) => { + const held = early.get(rootSessionID) || []; + early.delete(rootSessionID); + early.set(rootSessionID, [...held.filter((item) => item.type !== event.type), event]); + if (early.size > TUI_EARLY_EVENT_ROOTS_MAX) early.delete(early.keys().next().value); + }; + + async function own(rootSessionID) { + if (owned.has(rootSessionID)) return; + owned.add(rootSessionID); + const held = early.get(rootSessionID) || []; + early.delete(rootSessionID); + for (const event of held) await forward(event); + if (data.status(rootSessionID) !== "running") return; + await forward({ type: "session.status", properties: { sessionID: rootSessionID, status: { type: "busy" } } }); + const members = typeof data.family === "function" ? data.family(rootSessionID) : []; + for (const member of new Set([rootSessionID, ...members])) { + for (const request of data.permission?.list?.(member) || []) { + const translated = translateOpenCode2Event("permission.asked", request); + if (translated) await forward(translated); + } + for (const form of data.form?.list?.(member) || []) { + const translated = translateOpenCode2Event("form.created", { form }); + if (translated) await forward(translated); + } + } + } + + async function sync() { + routeRoot = currentRoute(); + if (routeRoot && !owned.has(routeRoot) && data.status(routeRoot) === "running") { + await own(routeRoot); + } + // 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, as it did with one server per pane. + for (const rootSessionID of owned) { + if ( + rootSessionID !== routeRoot && + data.status(rootSessionID) !== "running" && + !engineHoldsRoot(rootSessionID) + ) { + owned.delete(rootSessionID); + } + } + } + + async function handle(input) { + await sync(); + if (input.type === "session.deleted") { + const sessionID = input.data?.sessionID; + early.delete(sessionID); + if (!owned.has(sessionID)) return; + owned.delete(sessionID); + // A deleted root never settles on its own; retire whatever the engine still holds. + await forward({ type: "session.status", properties: { sessionID, status: { type: "idle" } } }); + return; + } + let translated; + if (input.type === "session.inbox.enqueued") { + // Why TUI-only: the server takes the prompt from session.hook("prompt"), which a TUI lacks. + const item = input.data?.item; + if (item?.type !== "user" || typeof item.payload?.text !== "string") return; + translated = { + type: "session.next.prompt.admitted", + properties: { sessionID: input.data.sessionID, messageID: input.data.inboxID, prompt: { text: item.payload.text } }, + }; + } else { + translated = translateOpenCode2Event(input.type, input.data); + } + if (!translated) return; + const sessionID = translated.properties?.sessionID ?? translated.properties?.info?.id; + if (typeof sessionID !== "string" || !sessionID) return; + const rootSessionID = await rootOf(sessionID); + if (!owned.has(rootSessionID)) { + if (input.type === "session.execution.started" && sessionID === rootSessionID && rootSessionID === routeRoot) { + await own(rootSessionID); + } else { + if (sessionID === rootSessionID && (translated.type === "session.created" || translated.type === "session.next.prompt.admitted")) { + remember(rootSessionID, translated); + } + return; + } + } + await forward(translated); + } + + const unsubscribe = ctx.data.listen(({ details } = {}) => { + if (disposed || !details || typeof details.type !== "string") return; + void run(() => handle(details)); + }); + const poll = setInterval(() => { + if (syncQueued || disposed) return; + syncQueued = true; + void run(async () => { + syncQueued = false; + await sync(); + }); + }, TUI_ROUTE_POLL_MS); + if (poll.unref) poll.unref(); + void run(sync); + return async () => { + try { + disposed = true; + clearInterval(poll); + try { + if (typeof unsubscribe === "function") unsubscribe(); + } finally { + try { + await chain; + } finally { + await engine.dispose?.(); + } + } + } catch { + // Why: cleanup runs during plugin unload; a throw here also fails the plugin. + } + }; + } catch { + try { await hooks?.dispose?.(); } catch {} + return noop; + } +} +`.split('\n') +} diff --git a/src/relay/opencode-canonical-config.ts b/src/relay/opencode-canonical-config.ts index 38682d3f2746..05c6a3fa85cb 100644 --- a/src/relay/opencode-canonical-config.ts +++ b/src/relay/opencode-canonical-config.ts @@ -2,6 +2,7 @@ import { 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' @@ -27,6 +28,7 @@ export function installOpenCodePluginInCanonicalConfig( } writeFileSync(pluginPath, source) } + writeOpenCodeTuiPlugin(join(configDir, 'plugins'), pluginFileName, source) return true } catch (err) { process.stderr.write( diff --git a/src/relay/plugin-overlay.test.ts b/src/relay/plugin-overlay.test.ts index d4d38a31fba5..09f7c0dde2ed 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. @@ -154,13 +176,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 001309c1f931..914f7851b9fd 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( @@ -220,6 +220,7 @@ export class PluginOverlayManager { // Fresh overlay or no same-named stale symlink. } writeFileSync(pluginPath, source) + writeOpenCodeTuiPlugin(pluginsDir, pluginFileName, source) } /** Materialize the OpenCode plugin overlay for `id` (typically the @@ -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/shared/agent-hook-listener-opencode-reattribution.test.ts b/src/shared/agent-hook-listener-opencode-reattribution.test.ts deleted file mode 100644 index 676b569f4955..000000000000 --- a/src/shared/agent-hook-listener-opencode-reattribution.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { normalizeHookPayload } from './agent-hook-listener' -import { createHookListenerState } from './agent-hook-listener/listener-state' -import { bindOpenCodeSession } from './agent-hook-listener/opencode-session-registry' -import { makePaneKey } from './stable-pane-id' - -import type { HookListenerState } from './agent-hook-listener/listener-state' - -const LEAF_A = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' -const LEAF_B = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' -const PANE_A = makePaneKey('tab-a', LEAF_A) -const PANE_B = makePaneKey('tab-b', LEAF_B) - -function opencodeBusy( - state: HookListenerState, - paneKey: string, - sessionId: string, - launchToken = '' -): ReturnType { - return normalizeHookPayload( - state, - 'opencode', - { paneKey, launchToken, payload: { hook_event_name: 'SessionBusy', sessionID: sessionId } }, - 'production' - ) -} - -describe('opencode shared-server reattribution (#21359)', () => { - it('reattributes a bound session to its real pane', () => { - const state = createHookListenerState() - bindOpenCodeSession(state, 'ses_1', { - paneKey: PANE_B, - boundAt: 1, - basis: 'creation-correlation' - }) - const result = opencodeBusy(state, PANE_A, 'ses_1') - expect(result?.paneKey).toBe(PANE_B) - expect(result?.tabId).toBe('tab-b') - expect(result?.payload.state).toBe('working') - }) - - it('keeps the stamped pane for unbound sessions', () => { - const state = createHookListenerState() - const result = opencodeBusy(state, PANE_A, 'ses_unknown') - expect(result?.paneKey).toBe(PANE_A) - }) - - it('substitutes the bound pane live token so its fence passes', () => { - const state = createHookListenerState() - // A tokened post teaches the listener pane B's live token. - normalizeHookPayload( - state, - 'claude', - { paneKey: PANE_B, launchToken: 'token-b-live', payload: { hook_event_name: 'Stop' } }, - 'production' - ) - bindOpenCodeSession(state, 'ses_1', { - paneKey: PANE_B, - boundAt: 1, - basis: 'argv' - }) - const result = opencodeBusy(state, PANE_A, 'ses_1') - expect(result?.paneKey).toBe(PANE_B) - expect(result?.launchToken).toBe('token-b-live') - }) - - it('leaves other sources untouched', () => { - const state = createHookListenerState() - bindOpenCodeSession(state, 'ses_1', { - paneKey: PANE_B, - boundAt: 1, - basis: 'argv' - }) - const result = normalizeHookPayload( - state, - 'claude', - { paneKey: PANE_A, payload: { hook_event_name: 'Stop', session_id: 'ses_1' } }, - 'production' - ) - expect(result?.paneKey).toBe(PANE_A) - }) - - it('never lets a stale same-pane stamp overwrite the live token', () => { - const state = createHookListenerState() - // A tokened post teaches the listener pane B's live token. - normalizeHookPayload( - state, - 'claude', - { paneKey: PANE_B, launchToken: 'token-b-live', payload: { hook_event_name: 'Stop' } }, - 'production' - ) - bindOpenCodeSession(state, 'ses_1', { - paneKey: PANE_B, - boundAt: 1, - basis: 'argv' - }) - // The shared server's frozen stamp carries a stale token for the same pane. - const result = opencodeBusy(state, PANE_B, 'ses_1', 'token-b-stale') - expect(result?.paneKey).toBe(PANE_B) - expect(result?.launchToken).toBe('token-b-live') - // And the stale stamp must not have poisoned the cache: a later lookup - // still returns the live token. - const again = opencodeBusy(state, PANE_B, 'ses_1', 'token-b-stale') - expect(again?.launchToken).toBe('token-b-live') - }) - - it('drops the stamped worktree when the binding has none', () => { - const state = createHookListenerState() - bindOpenCodeSession(state, 'ses_1', { - paneKey: PANE_B, - boundAt: 1, - basis: 'argv' - }) - const result = normalizeHookPayload( - state, - 'opencode', - { - paneKey: PANE_A, - worktreeId: 'repo::/stamped-worktree', - payload: { hook_event_name: 'SessionBusy', sessionID: 'ses_1' } - }, - 'production' - ) - expect(result?.paneKey).toBe(PANE_B) - expect(result?.worktreeId).toBeUndefined() - }) -}) diff --git a/src/shared/agent-hook-listener-opencode-session-correlation.test.ts b/src/shared/agent-hook-listener-opencode-session-correlation.test.ts deleted file mode 100644 index c0bc1d0ef37a..000000000000 --- a/src/shared/agent-hook-listener-opencode-session-correlation.test.ts +++ /dev/null @@ -1,228 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - correlateOpenCodeSessionOwners, - sessionIdFromArgv -} from './agent-hook-listener/opencode-session-correlation' - -const NOW = 1_000_000_000 -const DIR = '/Users/jin/work/mocitec' - -function session(id: string, createdAtMs: number, parentId: string | null = null) { - return { id, directory: DIR, createdAtMs, parentId } -} - -function client( - paneKey: string, - startedAtMs: number, - lastSeenAliveMs = NOW, - argv: readonly string[] = ['opencode'] -) { - return { paneKey, startedAtMs, lastSeenAliveMs, argv } -} - -describe('sessionIdFromArgv', () => { - it('reads --session forms', () => { - expect(sessionIdFromArgv(['opencode', '--session', 'ses_1'])).toBe('ses_1') - expect(sessionIdFromArgv(['opencode', '-s', 'ses_2'])).toBe('ses_2') - expect(sessionIdFromArgv(['opencode', '--session=ses_3'])).toBe('ses_3') - }) - - it('ignores bare launches and flag-like values', () => { - expect(sessionIdFromArgv(['opencode'])).toBeNull() - expect(sessionIdFromArgv(['opencode', '--session'])).toBeNull() - expect(sessionIdFromArgv(['opencode', '--session', '--port'])).toBeNull() - }) -}) - -describe('correlateOpenCodeSessionOwners', () => { - it('binds a lone pane in the directory', () => { - const results = correlateOpenCodeSessionOwners({ - sessions: [session('ses_1', NOW - 60_000)], - panes: [{ paneKey: 'pane-a', directory: DIR }], - clients: [client('pane-a', NOW - 120_000)], - knownOwners: new Map() - }) - expect(results).toEqual([ - { sessionId: 'ses_1', paneKey: 'pane-a', basis: 'single-pane-directory' } - ]) - }) - - it('contains sessions beneath the worktree root', () => { - const sub = correlateOpenCodeSessionOwners({ - sessions: [ - { - id: 'ses_sub', - directory: `${DIR}/packages/app`, - createdAtMs: NOW - 60_000, - parentId: null - } - ], - panes: [{ paneKey: 'pane-a', directory: DIR }], - clients: [client('pane-a', NOW - 120_000)], - knownOwners: new Map() - }) - expect(sub).toEqual([ - { sessionId: 'ses_sub', paneKey: 'pane-a', basis: 'single-pane-directory' } - ]) - }) - - it('strips the macOS /tmp alias so both spellings meet', () => { - const results = correlateOpenCodeSessionOwners({ - sessions: [ - { id: 'ses_1', directory: '/tmp/binder-e2e', createdAtMs: NOW - 60_000, parentId: null } - ], - panes: [{ paneKey: 'pane-a', directory: '/private/tmp/binder-e2e' }], - clients: [client('pane-a', NOW - 120_000)], - knownOwners: new Map() - }) - expect(results).toEqual([ - { sessionId: 'ses_1', paneKey: 'pane-a', basis: 'single-pane-directory' } - ]) - }) - - it('keeps genuinely distinct /private roots apart', () => { - const results = correlateOpenCodeSessionOwners({ - sessions: [ - { id: 'ses_1', directory: '/private/repo', createdAtMs: NOW - 60_000, parentId: null } - ], - panes: [{ paneKey: 'pane-a', directory: '/repo' }], - clients: [client('pane-a', NOW - 120_000)], - knownOwners: new Map() - }) - expect(results).toEqual([]) - }) - - it('folds Windows case differences', () => { - const results = correlateOpenCodeSessionOwners({ - sessions: [ - { id: 'ses_1', directory: 'c:\\users\\repo', createdAtMs: NOW - 60_000, parentId: null } - ], - panes: [{ paneKey: 'pane-a', directory: 'C:\\Users\\Repo' }], - clients: [client('pane-a', NOW - 120_000)], - knownOwners: new Map() - }) - expect(results).toEqual([ - { sessionId: 'ses_1', paneKey: 'pane-a', basis: 'single-pane-directory' } - ]) - }) - - it('keeps POSIX backslashes literal', () => { - const results = correlateOpenCodeSessionOwners({ - sessions: [ - { id: 'ses_1', directory: '/repo/a\\b', createdAtMs: NOW - 60_000, parentId: null } - ], - panes: [{ paneKey: 'pane-a', directory: '/repo/a/b' }], - clients: [client('pane-a', NOW - 120_000)], - knownOwners: new Map() - }) - expect(results).toEqual([]) - }) - - it('resolves dot segments before containment', () => { - const results = correlateOpenCodeSessionOwners({ - sessions: [ - { id: 'ses_1', directory: '/repo/../other', createdAtMs: NOW - 60_000, parentId: null } - ], - panes: [{ paneKey: 'pane-a', directory: '/repo' }], - clients: [client('pane-a', NOW - 120_000)], - knownOwners: new Map() - }) - expect(results).toEqual([]) - }) - - it('leaves a session unbound when no client brackets it', () => { - const results = correlateOpenCodeSessionOwners({ - sessions: [session('ses_1', NOW - 60_000)], - panes: [{ paneKey: 'pane-a', directory: DIR }], - clients: [], - knownOwners: new Map() - }) - expect(results).toEqual([]) - }) - - it('breaks a same-directory tie by client evidence', () => { - const results = correlateOpenCodeSessionOwners({ - sessions: [session('ses_1', NOW - 60_000)], - panes: [ - { paneKey: 'pane-a', directory: DIR }, - { paneKey: 'pane-b', directory: DIR } - ], - clients: [client('pane-b', NOW - 120_000)], - knownOwners: new Map() - }) - expect(results).toEqual([ - { sessionId: 'ses_1', paneKey: 'pane-b', basis: 'creation-correlation' } - ]) - }) - - it('stays unbound when both same-directory panes evidence a client', () => { - const results = correlateOpenCodeSessionOwners({ - sessions: [session('ses_1', NOW - 60_000)], - panes: [ - { paneKey: 'pane-a', directory: DIR }, - { paneKey: 'pane-b', directory: DIR } - ], - clients: [client('pane-a', NOW - 120_000), client('pane-b', NOW - 90_000)], - knownOwners: new Map() - }) - expect(results).toEqual([]) - }) - - it('rejects a client that started after creation beyond skew', () => { - const results = correlateOpenCodeSessionOwners({ - sessions: [session('ses_1', NOW - 3_600_000)], - panes: [{ paneKey: 'pane-a', directory: DIR }], - clients: [client('pane-a', NOW - 60_000, NOW)], - knownOwners: new Map() - }) - expect(results).toEqual([]) - }) - - it('accepts a long-lived client that brackets creation', () => { - const results = correlateOpenCodeSessionOwners({ - sessions: [session('ses_1', NOW - 60_000)], - panes: [{ paneKey: 'pane-a', directory: DIR }], - clients: [client('pane-a', NOW - 86_400_000)], - knownOwners: new Map() - }) - expect(results).toHaveLength(1) - }) - - it('binds by argv even in a crowded directory', () => { - const results = correlateOpenCodeSessionOwners({ - sessions: [session('ses_9', NOW - 60_000)], - panes: [ - { paneKey: 'pane-a', directory: DIR }, - { paneKey: 'pane-b', directory: DIR } - ], - clients: [ - client('pane-a', NOW - 120_000), - client('pane-b', NOW - 110_000, NOW, ['opencode', '--session', 'ses_9']) - ], - knownOwners: new Map() - }) - expect(results).toEqual([{ sessionId: 'ses_9', paneKey: 'pane-b', basis: 'argv' }]) - }) - - it('a child session inherits its bound root', () => { - const results = correlateOpenCodeSessionOwners({ - sessions: [session('ses_child', NOW - 30_000, 'ses_root')], - panes: [{ paneKey: 'pane-a', directory: DIR }], - clients: [client('pane-a', NOW - 120_000)], - knownOwners: new Map([['ses_root', 'pane-a']]) - }) - expect(results).toEqual([ - { sessionId: 'ses_child', paneKey: 'pane-a', basis: 'creation-correlation' } - ]) - }) - - it('skips already-known sessions', () => { - const results = correlateOpenCodeSessionOwners({ - sessions: [session('ses_1', NOW - 60_000)], - panes: [{ paneKey: 'pane-a', directory: DIR }], - clients: [client('pane-a', NOW - 120_000)], - knownOwners: new Map([['ses_1', 'pane-a']]) - }) - expect(results).toEqual([]) - }) -}) diff --git a/src/shared/agent-hook-listener-opencode-session-registry.test.ts b/src/shared/agent-hook-listener-opencode-session-registry.test.ts deleted file mode 100644 index c5d4874a4231..000000000000 --- a/src/shared/agent-hook-listener-opencode-session-registry.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { createHookListenerState } from './agent-hook-listener/listener-state' -import { - bindOpenCodeSession, - lookupOpenCodePaneLaunchToken, - lookupOpenCodeSessionPane, - moveOpenCodeSessionBindings, - OPENCODE_SESSION_BINDINGS_MAX, - trackOpenCodePaneLaunchToken, - unbindOpenCodeSessionsOfPane -} from './agent-hook-listener/opencode-session-registry' -import { makePaneKey } from './stable-pane-id' - -const LEAF_A = '11111111-1111-4111-8111-111111111111' -const LEAF_B = '22222222-2222-4222-8222-222222222222' -const PANE_A = makePaneKey('tab-a', LEAF_A) -const PANE_B = makePaneKey('tab-b', LEAF_B) - -describe('opencode session registry', () => { - it('binds and looks up a session owner', () => { - const state = createHookListenerState() - expect( - bindOpenCodeSession(state, 'ses_1', { paneKey: PANE_A, boundAt: 1, basis: 'argv' }) - ).toBe(true) - expect(lookupOpenCodeSessionPane(state, 'ses_1')?.paneKey).toBe(PANE_A) - expect(lookupOpenCodeSessionPane(state, 'ses_unknown')).toBeUndefined() - }) - - it('refuses blank ids and malformed pane keys', () => { - const state = createHookListenerState() - expect(bindOpenCodeSession(state, ' ', { paneKey: PANE_A, boundAt: 1, basis: 'argv' })).toBe( - false - ) - expect( - bindOpenCodeSession(state, 'ses_1', { paneKey: 'not-a-pane', boundAt: 1, basis: 'argv' }) - ).toBe(false) - expect(state.opencodeSessionPaneBySessionId.size).toBe(0) - }) - - it('rebinds a session to a new owner and refreshes eviction order', () => { - const state = createHookListenerState() - bindOpenCodeSession(state, 'ses_1', { paneKey: PANE_A, boundAt: 1, basis: 'argv' }) - bindOpenCodeSession(state, 'ses_1', { - paneKey: PANE_B, - boundAt: 2, - basis: 'creation-correlation' - }) - expect(lookupOpenCodeSessionPane(state, 'ses_1')?.paneKey).toBe(PANE_B) - expect(lookupOpenCodeSessionPane(state, 'ses_1')?.basis).toBe('creation-correlation') - }) - - it('evicts oldest-bound first once capped', () => { - const state = createHookListenerState() - for (let i = 0; i < OPENCODE_SESSION_BINDINGS_MAX + 5; i += 1) { - bindOpenCodeSession(state, `ses_${i}`, { paneKey: PANE_A, boundAt: i, basis: 'argv' }) - } - expect(state.opencodeSessionPaneBySessionId.size).toBe(OPENCODE_SESSION_BINDINGS_MAX) - expect(lookupOpenCodeSessionPane(state, 'ses_0')).toBeUndefined() - expect( - lookupOpenCodeSessionPane(state, `ses_${OPENCODE_SESSION_BINDINGS_MAX + 4}`) - ).toBeDefined() - }) - - it('unbinds every session of a closed pane', () => { - const state = createHookListenerState() - bindOpenCodeSession(state, 'ses_1', { paneKey: PANE_A, boundAt: 1, basis: 'argv' }) - bindOpenCodeSession(state, 'ses_2', { paneKey: PANE_A, boundAt: 2, basis: 'argv' }) - bindOpenCodeSession(state, 'ses_3', { paneKey: PANE_B, boundAt: 3, basis: 'argv' }) - expect(unbindOpenCodeSessionsOfPane(state, PANE_A)).toBe(2) - expect(lookupOpenCodeSessionPane(state, 'ses_1')).toBeUndefined() - expect(lookupOpenCodeSessionPane(state, 'ses_3')?.paneKey).toBe(PANE_B) - }) - - it('moves bindings with a pane', () => { - const state = createHookListenerState() - bindOpenCodeSession(state, 'ses_1', { paneKey: PANE_A, boundAt: 1, basis: 'argv' }) - moveOpenCodeSessionBindings(state, PANE_A, PANE_B) - expect(lookupOpenCodeSessionPane(state, 'ses_1')?.paneKey).toBe(PANE_B) - }) - - it('tracks last-seen launch tokens per pane', () => { - const state = createHookListenerState() - trackOpenCodePaneLaunchToken(state, PANE_A, ' ') - expect(lookupOpenCodePaneLaunchToken(state, PANE_A)).toBeUndefined() - trackOpenCodePaneLaunchToken(state, PANE_A, 'token-1') - trackOpenCodePaneLaunchToken(state, PANE_A, 'token-2') - expect(lookupOpenCodePaneLaunchToken(state, PANE_A)).toBe('token-2') - }) -}) diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index 73f05c1d7637..7df46e74cbc0 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -18,10 +18,6 @@ import { extractPromptText } from './agent-hook-listener/prompt-fields' import { normalizeProviderEvent } from './agent-hook-listener/provider-dispatch' import { hasExplicitUserPrompt } from './agent-hook-listener/provider-event-routing' import { hasExplicitAmpPrompt } from './agent-hook-listener/providers/amp-events' -import { - resolveOpenCodeSharedServerEnvelope, - trackOpenCodePaneLaunchToken -} from './agent-hook-listener/opencode-session-registry' import { readString } from './agent-hook-listener/tool-input-preview' /** Canonical transport-agnostic normalization entry shared by main and relay listeners. */ export function normalizeHookPayload( @@ -35,16 +31,9 @@ export function normalizeHookPayload( if (!envelope) { return null } - const { - record, - paneKey: stampedPaneKey, - hookPayloadRecord, - tabId: stampedTabId, - worktreeId: stampedWorktreeId, - launchToken: stampedLaunchToken - } = envelope + const { record, paneKey, hookPayloadRecord, tabId, worktreeId, launchToken } = envelope if (source === 'claude') { - state.claudeUnconfirmedRestoredStatusPaneKeys.delete(stampedPaneKey) + state.claudeUnconfirmedRestoredStatusPaneKeys.delete(paneKey) } const eventName = readFirstString(record, ['hook_event_name', 'hookEventName', 'hook_type', 'hookType']) ?? @@ -55,25 +44,6 @@ 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 - // 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. - const { paneKey, tabId, worktreeId, launchToken } = resolveOpenCodeSharedServerEnvelope({ - state, - source, - stamped: { - paneKey: stampedPaneKey, - tabId: stampedTabId, - worktreeId: stampedWorktreeId, - launchToken: stampedLaunchToken - }, - sessionId: providerSession?.id - }) - // 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 - // carries the stored token (or nothing) for bound sessions instead. - trackOpenCodePaneLaunchToken(state, paneKey, launchToken) const providerPromptId = source === 'claude' ? normalizeClaudePromptId(hookPayloadRecord.prompt_id) diff --git a/src/shared/agent-hook-listener/listener-state.ts b/src/shared/agent-hook-listener/listener-state.ts index 53848ca707a8..128502fdece5 100644 --- a/src/shared/agent-hook-listener/listener-state.ts +++ b/src/shared/agent-hook-listener/listener-state.ts @@ -15,11 +15,6 @@ import type { CodexSubagentRoster } from '../codex-subagent-roster' import type { CodexSubagentTranscriptState } from '../codex-subagent-transcript' import type { MuseSessionLogState } from '../muse-session-log' import type { AgentHookEventPayload, ToolSnapshot } from './listener-event' -import { - moveOpenCodeSessionBindings, - unbindOpenCodeSessionsOfPane, - type OpenCodeSessionBinding -} from './opencode-session-registry' /** Per-listener-instance caches needing per-PTY teardown; Orca's main process and the relay each get their own, never shared. */ export type HookListenerState = { @@ -59,15 +54,6 @@ export type HookListenerState = { grokMainAgentStatusByPaneKey: Map /** Muse child-session filter and session-log cursor per pane. */ musePaneStateByPaneKey: Map - /** - * OpenCode session id -> owning pane, observed from the client side. The - * shared v2 server stamps every post with its own frozen pane, so ingest - * reattributes bound sessions before disposition. Not a state claim itself — - * it names no row — so paneHasStateClaims ignores it. - */ - opencodeSessionPaneBySessionId: Map - /** Last launch token seen per pane; a rewritten shared-server post needs the bound pane's live token to pass its fence. */ - lastLaunchTokenByPaneKey: Map } export type MusePaneState = { @@ -117,9 +103,7 @@ export function createHookListenerState( codexLeadStateByPaneKey: new Map(), grokActiveTurnByPaneKey: new Map(), grokMainAgentStatusByPaneKey: new Map(), - musePaneStateByPaneKey: new Map(), - opencodeSessionPaneBySessionId: new Map(), - lastLaunchTokenByPaneKey: new Map() + musePaneStateByPaneKey: new Map() } legacyStatusAdapterByState.set(state, adapter) return state @@ -208,8 +192,6 @@ export function clearPaneCacheState(state: HookListenerState, paneKey: string): state.grokActiveTurnByPaneKey.delete(paneKey) state.grokMainAgentStatusByPaneKey.delete(paneKey) state.musePaneStateByPaneKey.delete(paneKey) - unbindOpenCodeSessionsOfPane(state, paneKey) - deletePaneScopedCacheEntry(state.lastLaunchTokenByPaneKey, paneKey) } /** Does this pane still hold anything that can ASSERT a state — a stored row, or a Claude latch that @@ -286,8 +268,6 @@ export function movePaneCacheState( movePaneScopedMapEntries(state.grokActiveTurnByPaneKey, fromPaneKey, toPaneKey) movePaneScopedMapEntries(state.grokMainAgentStatusByPaneKey, fromPaneKey, toPaneKey) movePaneScopedMapEntries(state.musePaneStateByPaneKey, fromPaneKey, toPaneKey) - moveOpenCodeSessionBindings(state, fromPaneKey, toPaneKey) - movePaneScopedMapEntries(state.lastLaunchTokenByPaneKey, fromPaneKey, toPaneKey) } export function clearPaneTurnCacheState(state: HookListenerState, paneKey: string): void { @@ -339,6 +319,4 @@ export function clearAllListenerCaches(state: HookListenerState): void { state.codexLeadStateByPaneKey.clear() state.grokActiveTurnByPaneKey.clear() state.grokMainAgentStatusByPaneKey.clear() - state.opencodeSessionPaneBySessionId.clear() - state.lastLaunchTokenByPaneKey.clear() } diff --git a/src/shared/agent-hook-listener/opencode-session-correlation.ts b/src/shared/agent-hook-listener/opencode-session-correlation.ts deleted file mode 100644 index 069769ab07bc..000000000000 --- a/src/shared/agent-hook-listener/opencode-session-correlation.ts +++ /dev/null @@ -1,252 +0,0 @@ -/** - * Pure session→pane correlation for the shared OpenCode server (#21359). - * - * The server stamps every post with its own frozen pane, so the binder must - * decide ownership from client-side evidence: which panes sit in the - * session's directory, and which of those panes runs an OpenCode client that - * could have created it. No platform APIs here — the main-process binder - * supplies sessions (SQLite), panes (PTY registry + CWD) and clients - * (argv-aware process sweep); this module only decides. - */ - -/** One session row as the binder sees it. */ -export type CorrelatedSession = { - id: string - directory: string - /** ms epoch from the session store. */ - createdAtMs: number - parentId: string | null -} - -/** One pane as the binder sees it. */ -export type CorrelatedPane = { - paneKey: string - /** - * Worktree root backing the pane (from the PTY registry), or null when - * unknown. A session belongs to a pane's candidate set when its directory - * is the root or beneath it; same-worktree panes tie and the client sweep - * breaks the tie. - */ - directory: string | null -} - -/** One live client process as the binder sees it. */ -export type CorrelatedClient = { - /** Pane whose subtree holds this client. */ - paneKey: string - /** ms epoch the client process started. */ - startedAtMs: number - /** ms epoch the client was last observed alive. */ - lastSeenAliveMs: number - /** Full argv; a `--session ` hit binds deterministically. */ - argv: readonly string[] -} - -/** One decided session owner for this round. */ -export type SessionOwnership = { - sessionId: string - paneKey: string - basis: 'argv' | 'creation-correlation' | 'single-pane-directory' -} - -/** Allow for stamp skew between the process table and the session store. */ -export const OPENCODE_CREATE_SKEW_MS = 2 * 60 * 1000 - -import { normalizeRuntimePathForComparison } from '../cross-platform-path' - -/** - * macOS symlinks /tmp, /var and /etc into /private; opencode records - * whichever spelling the process saw, so fold the alias to the real root - * before comparing. Narrow on purpose: a blanket `/private` strip would merge - * genuinely distinct POSIX roots (`/private/repo` vs `/repo`). - */ -function foldMacOsPrivateAlias(directory: string): string { - for (const name of ['tmp', 'var', 'etc']) { - if (directory === `/${name}`) { - return `/private/${name}` - } - if (directory.startsWith(`/${name}/`)) { - return `/private/${directory.slice(1)}` - } - } - return directory -} - -/** Lexically resolve `.` and `..` so prefix containment cannot be fooled by dot segments. */ -function resolveDotSegments(normalized: string): string { - const isAbsolute = normalized.startsWith('/') - const parts: string[] = [] - for (const part of normalized.split('/')) { - if (part === '' || part === '.') { - continue - } - if (part === '..') { - if (parts.length > 0 && parts.at(-1) !== '..') { - parts.pop() - continue - } - if (!isAbsolute) { - parts.push(part) - } - continue - } - parts.push(part) - } - const joined = parts.join('/') - if (isAbsolute) { - return `/${joined}` - } - return joined === '' ? '.' : joined -} - -/** - * Comparison key for session/pane directories. NFC + Windows-only backslash - * folding and case folding come from the shared helper (a backslash stays a - * literal filename character on POSIX); dot segments resolve lexically. - */ -function normalizeDir(directory: string): string { - return resolveDotSegments( - normalizeRuntimePathForComparison(foldMacOsPrivateAlias(directory.trim())) - ) -} - -/** `--session `, `-s `, `--session=` or a trailing attach target. */ -export function sessionIdFromArgv(argv: readonly string[]): string | null { - for (let i = 0; i < argv.length; i += 1) { - const arg = argv[i] - if (arg === '--session' || arg === '-s') { - const next = argv[i + 1] - if (next && !next.startsWith('-')) { - return next - } - } else if (arg.startsWith('--session=')) { - const value = arg.slice('--session='.length) - if (value) { - return value - } - } - } - return null -} - -function clientCouldCreate(client: CorrelatedClient, createdAtMs: number): boolean { - // Why lifetime-overlap instead of a start window: a TUI opened days ago - // creates today's session from the same process. Started-before plus - // seen-alive-after brackets the creation; a start window alone would miss - // every long-lived client. - return ( - client.startedAtMs <= createdAtMs + OPENCODE_CREATE_SKEW_MS && - client.lastSeenAliveMs >= createdAtMs - ) -} - -/** - * Decide owners for sessions the registry has not bound yet. Child sessions - * inherit their root's owner (they roll up to the same pane); argv hits bind - * immediately; otherwise exactly one pane must have both the directory and a - * client that brackets the creation. Anything else stays unbound — today's - * behavior — rather than guessing. - */ -export function correlateOpenCodeSessionOwners(args: { - sessions: readonly CorrelatedSession[] - panes: readonly CorrelatedPane[] - clients: readonly CorrelatedClient[] - /** Owners already known (registry + earlier binds this round). */ - knownOwners: ReadonlyMap -}): SessionOwnership[] { - const { sessions, panes, clients, knownOwners } = args - const owners = new Map(knownOwners) - const results: SessionOwnership[] = [] - - /** Record one ownership decision, visible to later sessions in this round. */ - const claim = (sessionId: string, paneKey: string, basis: SessionOwnership['basis']): void => { - owners.set(sessionId, paneKey) - results.push({ sessionId, paneKey, basis }) - } - - // Why argv first: a command line names its session outright, so it outranks - // every heuristic even when the directory is crowded. - for (const client of clients) { - const named = sessionIdFromArgv(client.argv) - if (named && !owners.has(named)) { - claim(named, client.paneKey, 'argv') - } - } - - const panesByDirectory = new Map() - for (const pane of panes) { - if (!pane.directory) { - continue - } - const key = normalizeDir(pane.directory) - const list = panesByDirectory.get(key) - if (list) { - list.push(pane) - } else { - panesByDirectory.set(key, [pane]) - } - } - - /** Panes whose worktree root contains this directory (exact or beneath). */ - function containingPanes(directory: string): CorrelatedPane[] { - const target = normalizeDir(directory) - const found: CorrelatedPane[] = [] - for (const [root, list] of panesByDirectory) { - if (target === root || target.startsWith(`${root}/`)) { - found.push(...list) - } - } - return found - } - - /** Walk the parent chain for an already-known root owner. */ - const rootOwner = (session: CorrelatedSession): string | undefined => { - let current: CorrelatedSession | undefined = session - const seen = new Set() - while (current?.parentId && !seen.has(current.id)) { - seen.add(current.id) - const owner = owners.get(current.parentId) - if (owner) { - return owner - } - current = sessions.find((s) => s.id === current?.parentId) - } - return current && current !== session ? owners.get(current.id) : undefined - } - - for (const session of sessions) { - if (owners.has(session.id)) { - continue - } - const inherited = session.parentId - ? (rootOwner(session) ?? owners.get(session.parentId)) - : undefined - if (inherited) { - claim(session.id, inherited, 'creation-correlation') - continue - } - const candidates = containingPanes(session.directory) - if (candidates.length === 0) { - continue - } - const evidencing = candidates.filter((pane) => - clients.some( - (client) => - client.paneKey === pane.paneKey && clientCouldCreate(client, session.createdAtMs) - ) - ) - const [only] = evidencing - if (evidencing.length !== 1 || !only) { - continue - } - // Why the basis split: a lone containing pane needs no client evidence - // to be unambiguous; a shared worktree always resolves through it. - claim( - session.id, - only.paneKey, - candidates.length === 1 ? 'single-pane-directory' : 'creation-correlation' - ) - } - - return results -} diff --git a/src/shared/agent-hook-listener/opencode-session-registry.ts b/src/shared/agent-hook-listener/opencode-session-registry.ts deleted file mode 100644 index 0d6183197b57..000000000000 --- a/src/shared/agent-hook-listener/opencode-session-registry.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { parsePaneKey } from '../stable-pane-id' -import type { AgentHookSource } from '../agent-hook-relay' -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 - * 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 - * at ingest to reattribute posts before disposition. - */ -export type OpenCodeSessionBinding = { - paneKey: string - worktreeId?: string - /** ms epoch of the bind; oldest-bound evicts first once capped. */ - boundAt: number - /** How the binder learned this owner. */ - basis: 'argv' | 'creation-correlation' | 'single-pane-directory' -} - -/** Upper bound; sessions are cheap rows but the map must not grow forever. */ -export const OPENCODE_SESSION_BINDINGS_MAX = 1000 - -/** Per-pane launch-token cache; keyed differently from bindings but shares the same bound. */ -export const OPENCODE_PANE_LAUNCH_TOKENS_MAX = 1000 - -/** Per-listener session→pane map; the binder writes, ingest reads. */ -function bindings(state: HookListenerState): Map { - return state.opencodeSessionPaneBySessionId -} - -/** - * Record a session owner. Ignores blank ids and malformed pane keys so a - * corrupt binder observation can never poison ingest. - */ -export function bindOpenCodeSession( - state: HookListenerState, - sessionId: string, - binding: OpenCodeSessionBinding -): boolean { - const id = sessionId.trim() - if (!id || parsePaneKey(binding.paneKey) === null) { - return false - } - const map = bindings(state) - // Why delete-then-set: Map evicts in insertion order, so a refreshed bind - // must move to the back or a hot session would evict as if it were the - // oldest one. - map.delete(id) - map.set(id, binding) - while (map.size > OPENCODE_SESSION_BINDINGS_MAX) { - const oldest = map.keys().next().value - if (oldest === undefined) { - break - } - map.delete(oldest) - } - return true -} - -/** Pane that owns this session, if the binder has seen it. */ -export function lookupOpenCodeSessionPane( - state: HookListenerState, - sessionId: string -): OpenCodeSessionBinding | undefined { - return bindings(state).get(sessionId.trim()) -} - -/** - * Drop every binding owned by a pane: teardown, reuse and close must not let - * a dead pane keep claiming a live session's dots. - * @returns Number of bindings removed. - */ -export function unbindOpenCodeSessionsOfPane(state: HookListenerState, paneKey: string): number { - let removed = 0 - for (const [sessionId, binding] of bindings(state)) { - // Why exact match only: bindings store validated `tabId:uuid` pane keys, - // which cannot carry the `\0` subscopes the hierarchical pane caches use. - if (binding.paneKey === paneKey) { - bindings(state).delete(sessionId) - removed += 1 - } - } - return removed -} - -/** Rewrite bindings when a pane moves (e.g. detached into another tab). */ -export function moveOpenCodeSessionBindings( - state: HookListenerState, - fromPaneKey: string, - toPaneKey: string -): void { - if (fromPaneKey === toPaneKey || parsePaneKey(toPaneKey) === null) { - return - } - for (const binding of bindings(state).values()) { - if (binding.paneKey === fromPaneKey) { - binding.paneKey = toPaneKey - } - } -} - -/** - * Last launch token seen per pane, from any source's envelope. The shared - * server's posts carry a frozen (usually empty) token, so a rewritten post - * for a fenced pane would be suppressed without the bound pane's live token. - * Updated on every tokened post; the fence comparison hashes it the same way. - */ -export function trackOpenCodePaneLaunchToken( - state: HookListenerState, - paneKey: string, - launchToken: string | undefined -): void { - const token = launchToken?.trim() - if (!token) { - return - } - const map = state.lastLaunchTokenByPaneKey - map.delete(paneKey) - map.set(paneKey, token) - while (map.size > OPENCODE_PANE_LAUNCH_TOKENS_MAX) { - const oldest = map.keys().next().value - if (oldest === undefined) { - break - } - map.delete(oldest) - } -} - -/** Live token for a pane, if any tokened post has arrived since startup. */ -export function lookupOpenCodePaneLaunchToken( - state: HookListenerState, - paneKey: string -): string | undefined { - return state.lastLaunchTokenByPaneKey.get(paneKey) -} - -/** Envelope fields the rewrite may substitute, as stamped by the poster. */ -export type OpenCodeStampedEnvelope = { - paneKey: string - tabId?: string - worktreeId?: string - launchToken?: string -} - -/** - * Reattribute one shared-server post to the bound pane (#21359). Reads - * nothing but the registry: unbound sessions and other sources pass through - * untouched, so this is a no-op everywhere the binder has said nothing. A - * bound session always takes the stored pane token — which may be absent, - * in which case the post carries no token rather than the frozen stamp. - */ -export function resolveOpenCodeSharedServerEnvelope(args: { - state: HookListenerState - source: AgentHookSource - stamped: OpenCodeStampedEnvelope - sessionId: string | undefined -}): OpenCodeStampedEnvelope { - const { state, source, stamped, sessionId } = args - if ((source !== 'opencode' && source !== 'mimo-code') || !sessionId) { - return stamped - } - const binding = lookupOpenCodeSessionPane(state, sessionId) - if (!binding) { - return stamped - } - return { - paneKey: binding.paneKey, - // Why derive: the envelope rejects a tabId that disagrees with the pane - // key, so the substituted tab must come from the substituted pane. - tabId: parsePaneKey(binding.paneKey)?.tabId ?? stamped.tabId, - // Why no stamped fallback: a binding without a worktree (its pane row - // carried none) combined with the stamped pane's worktree would file the - // row under the wrong worktree. Absent is honest; wrong is not. - worktreeId: binding.worktreeId, - // Why the stored token or nothing: the frozen stamp carries the - // server-starter's (usually empty) token, which a fenced pane would - // suppress — and recording that stale token as live would poison the - // cache for the pane's real posts. The session is the authority here, - // not the posting process. - launchToken: lookupOpenCodePaneLaunchToken(state, binding.paneKey) - } -} diff --git a/src/shared/opencode-tui-plugin-install.ts b/src/shared/opencode-tui-plugin-install.ts new file mode 100644 index 000000000000..0fc1a41bfe7e --- /dev/null +++ b/src/shared/opencode-tui-plugin-install.ts @@ -0,0 +1,35 @@ +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. + */ +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) +} From 14948dcf7517aadccfc3a2958fbc194bbaf0385e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:16:42 -0700 Subject: [PATCH 02/25] fix(opencode): settle missed OpenCode 2 turn ends and order TUI installs - The TUI copy now settles an owned root whose run the TUI's session data reports ended, with no pending permission or form, when the engine still holds it busy. An execution end missed across a service restart or reconnect no longer leaves the pane Working until the TUI exits. - The TUI copy stays idle without ORCA_PANE_KEY. post() cannot report without it, so OpenCode 2 TUIs outside Orca no longer run the route poll and engine. - Installers write the TUI copy before the server plugin file. The server decides at load whether to stand down, so a reload between the two writes now finds the TUI copy. --- ...ook-plugin-opencode2-tui-ownership.test.ts | 24 ++++++++++++++++++- src/main/opencode/hook-service.test.ts | 18 ++++++++++++++ src/main/opencode/hook-service.ts | 4 ++-- .../opencode2/status-plugin-tui-source.ts | 18 ++++++++++++-- src/relay/opencode-canonical-config.ts | 2 +- src/relay/plugin-overlay.test.ts | 15 ++++++++++++ src/relay/plugin-overlay.ts | 2 +- src/shared/opencode-tui-plugin-install.ts | 3 ++- 8 files changed, 78 insertions(+), 8 deletions(-) diff --git a/src/main/opencode/hook-plugin-opencode2-tui-ownership.test.ts b/src/main/opencode/hook-plugin-opencode2-tui-ownership.test.ts index c4703a52a206..6c3a280b2f70 100644 --- a/src/main/opencode/hook-plugin-opencode2-tui-ownership.test.ts +++ b/src/main/opencode/hook-plugin-opencode2-tui-ownership.test.ts @@ -106,6 +106,10 @@ function fakeTui(version = '2.0.14') { 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) + }, emit(event: BusEvent) { const sessionID = String(event.data.sessionID) if (event.type === 'session.created') { @@ -273,14 +277,32 @@ describe('OpenCode 2 TUI adapter: each pane reports its own sessions', () => { 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?.() + }) + it.each([ ['an OpenCode 1 TUI', () => fakeTui('1.18.33')], - ['the other pane variant', () => fakeTui()] + ['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() diff --git a/src/main/opencode/hook-service.test.ts b/src/main/opencode/hook-service.test.ts index 36c145d6f853..5c4297fbb023 100644 --- a/src/main/opencode/hook-service.test.ts +++ b/src/main/opencode/hook-service.test.ts @@ -188,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. diff --git a/src/main/opencode/hook-service.ts b/src/main/opencode/hook-service.ts index 3099a89fbf09..995ade13a8b3 100644 --- a/src/main/opencode/hook-service.ts +++ b/src/main/opencode/hook-service.ts @@ -296,6 +296,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) @@ -304,7 +305,6 @@ export class OpenCodeHookService { } writeFileSync(pluginPath, source) } - this.writeTuiPlugin(pluginsDir, source) } private writePluginToConfigDir(configDir: string): void { @@ -312,10 +312,10 @@ 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) } - this.writeTuiPlugin(pluginsDir, source) } private writeTuiPlugin(pluginsDir: string, source: string): void { diff --git a/src/main/opencode2/status-plugin-tui-source.ts b/src/main/opencode2/status-plugin-tui-source.ts index 570cb302db90..fe3b573bd8e1 100644 --- a/src/main/opencode2/status-plugin-tui-source.ts +++ b/src/main/opencode2/status-plugin-tui-source.ts @@ -30,6 +30,8 @@ function engineHoldsRoot(rootSessionID) { 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; let hooks; try { const data = ctx.data.session; @@ -78,6 +80,12 @@ async function setupOpenCode2Tui(ctx) { if (route?.type !== "session" || typeof route.sessionID !== "string") return undefined; return typeof data.root === "function" ? data.root(route.sessionID) || route.sessionID : route.sessionID; }; + const family = (rootSessionID) => + new Set([rootSessionID, ...(typeof data.family === "function" ? data.family(rootSessionID) || [] : [])]); + const familyRunning = (rootSessionID) => + [...family(rootSessionID)].some((id) => data.status(id) === "running"); + const familyBlocked = (rootSessionID) => + [...family(rootSessionID)].some((id) => (data.permission?.list?.(id) || []).length > 0 || (data.form?.list?.(id) || []).length > 0); const remember = (rootSessionID, event) => { const held = early.get(rootSessionID) || []; early.delete(rootSessionID); @@ -93,8 +101,7 @@ async function setupOpenCode2Tui(ctx) { for (const event of held) await forward(event); if (data.status(rootSessionID) !== "running") return; await forward({ type: "session.status", properties: { sessionID: rootSessionID, status: { type: "busy" } } }); - const members = typeof data.family === "function" ? data.family(rootSessionID) : []; - for (const member of new Set([rootSessionID, ...members])) { + for (const member of family(rootSessionID)) { for (const request of data.permission?.list?.(member) || []) { const translated = translateOpenCode2Event("permission.asked", request); if (translated) await forward(translated); @@ -122,6 +129,13 @@ async function setupOpenCode2Tui(ctx) { owned.delete(rootSessionID); } } + for (const rootSessionID of owned) { + // Why: an execution end missed across a reconnect would hold the pane Working; the TUI's + // session data re-hydrates on reconnect, so settle against it. + if (busyRootOwnerBySessionID.has(rootSessionID) && !familyRunning(rootSessionID) && !familyBlocked(rootSessionID)) { + await forward({ type: "session.status", properties: { sessionID: rootSessionID, status: { type: "idle" } } }); + } + } } async function handle(input) { diff --git a/src/relay/opencode-canonical-config.ts b/src/relay/opencode-canonical-config.ts index 05c6a3fa85cb..86900b3b36d1 100644 --- a/src/relay/opencode-canonical-config.ts +++ b/src/relay/opencode-canonical-config.ts @@ -20,6 +20,7 @@ export function installOpenCodePluginInCanonicalConfig( agent === 'opencode2' ? 'orca-opencode2-status.js' : 'orca-opencode-status.js' const pluginPath = join(configDir, 'plugins', pluginFileName) mkdirSync(join(configDir, 'plugins'), { recursive: true }) + writeOpenCodeTuiPlugin(join(configDir, 'plugins'), pluginFileName, source) if (!isInstalledOpenCodePluginCurrent(pluginPath, source)) { try { unlinkSync(pluginPath) @@ -28,7 +29,6 @@ export function installOpenCodePluginInCanonicalConfig( } writeFileSync(pluginPath, source) } - writeOpenCodeTuiPlugin(join(configDir, 'plugins'), pluginFileName, source) return true } catch (err) { process.stderr.write( diff --git a/src/relay/plugin-overlay.test.ts b/src/relay/plugin-overlay.test.ts index 09f7c0dde2ed..9129e202287b 100644 --- a/src/relay/plugin-overlay.test.ts +++ b/src/relay/plugin-overlay.test.ts @@ -143,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 }) diff --git a/src/relay/plugin-overlay.ts b/src/relay/plugin-overlay.ts index 914f7851b9fd..35455d06859e 100644 --- a/src/relay/plugin-overlay.ts +++ b/src/relay/plugin-overlay.ts @@ -214,13 +214,13 @@ 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 { // Fresh overlay or no same-named stale symlink. } writeFileSync(pluginPath, source) - writeOpenCodeTuiPlugin(pluginsDir, pluginFileName, source) } /** Materialize the OpenCode plugin overlay for `id` (typically the diff --git a/src/shared/opencode-tui-plugin-install.ts b/src/shared/opencode-tui-plugin-install.ts index 0fc1a41bfe7e..db05aa8834d3 100644 --- a/src/shared/opencode-tui-plugin-install.ts +++ b/src/shared/opencode-tui-plugin-install.ts @@ -13,7 +13,8 @@ export function openCodeTuiPluginDirName(pluginFileName: string): string { /** * Install the TUI copy beside the server plugin file. The same module serves - * both: its setup() tells a TUI context from a server context. + * 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, From 2ff1fcc3b1ad292ef72a7eb1beab90de2e99a823 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:36:03 -0700 Subject: [PATCH 03/25] fix(opencode): reconcile OpenCode 2 TUI status only once queued events drain The TUI's session data applies each event before this plugin's queued handling reaches it, so settling against it mid-backlog published a false Done before a fast turn's later steps (Working, Done, Working, Done). Reconcile only when no event is queued; a mismatch then is a start or end missed across a reconnect, and both directions are now re-derived (a missed start left the pane on Done). Also install the TUI copy beside the server plugin in the retired shared hooks dir, so a TUI or service still loading that dir reports per pane instead of leaving the service reporting under its starter pane. --- ...ook-plugin-opencode2-tui-ownership.test.ts | 47 +++++++++++++++++++ src/main/opencode/hook-service.test.ts | 9 +++- src/main/opencode/hook-service.ts | 8 +++- .../opencode2/status-plugin-tui-source.ts | 25 ++++++++-- 4 files changed, 82 insertions(+), 7 deletions(-) diff --git a/src/main/opencode/hook-plugin-opencode2-tui-ownership.test.ts b/src/main/opencode/hook-plugin-opencode2-tui-ownership.test.ts index 6c3a280b2f70..d1b695b4fbea 100644 --- a/src/main/opencode/hook-plugin-opencode2-tui-ownership.test.ts +++ b/src/main/opencode/hook-plugin-opencode2-tui-ownership.test.ts @@ -110,6 +110,10 @@ function fakeTui(version = '2.0.14') { loseEnd(sessionID: string) { running.delete(sessionID) }, + // The session data reports a run whose start this TUI never saw. + loseStart(sessionID: string) { + running.add(sessionID) + }, emit(event: BusEvent) { const sessionID = String(event.data.sessionID) if (event.type === 'session.created') { @@ -291,6 +295,49 @@ describe('OpenCode 2 TUI adapter: each pane reports its own sessions', () => { await cleanup?.() }) + // Why: the session data applies events before this plugin's queued handling catches up. + 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?.() + }) + it.each([ ['an OpenCode 1 TUI', () => fakeTui('1.18.33')], ['the other pane variant', () => fakeTui()], diff --git a/src/main/opencode/hook-service.test.ts b/src/main/opencode/hook-service.test.ts index 5c4297fbb023..ff124eb0b951 100644 --- a/src/main/opencode/hook-service.test.ts +++ b/src/main/opencode/hook-service.test.ts @@ -257,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', () => { @@ -271,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) } diff --git a/src/main/opencode/hook-service.ts b/src/main/opencode/hook-service.ts index 995ade13a8b3..d963e7e61d51 100644 --- a/src/main/opencode/hook-service.ts +++ b/src/main/opencode/hook-service.ts @@ -171,10 +171,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) { diff --git a/src/main/opencode2/status-plugin-tui-source.ts b/src/main/opencode2/status-plugin-tui-source.ts index fe3b573bd8e1..e74bd275bfbb 100644 --- a/src/main/opencode2/status-plugin-tui-source.ts +++ b/src/main/opencode2/status-plugin-tui-source.ts @@ -58,6 +58,7 @@ async function setupOpenCode2Tui(ctx) { let disposed = false; let chain = Promise.resolve(); let syncQueued = false; + let queuedEvents = 0; let routeRoot; const owned = new Set(); // Root events seen before this pane owned the root (the route can switch after them). @@ -99,6 +100,11 @@ async function setupOpenCode2Tui(ctx) { const held = early.get(rootSessionID) || []; early.delete(rootSessionID); for (const event of held) await forward(event); + await reassert(rootSessionID); + } + + // Re-derives a running root's Busy and open blockers from the session data. + async function reassert(rootSessionID) { if (data.status(rootSessionID) !== "running") return; await forward({ type: "session.status", properties: { sessionID: rootSessionID, status: { type: "busy" } } }); for (const member of family(rootSessionID)) { @@ -129,10 +135,14 @@ async function setupOpenCode2Tui(ctx) { owned.delete(rootSessionID); } } + // Why wait for an empty queue: the session data applies each event before it reaches this + // plugin, so it runs ahead of the engine until the queue drains; a mismatch after that is + // an execution start or end missed across a reconnect, which the data re-hydrates. + if (queuedEvents > 0) return; for (const rootSessionID of owned) { - // Why: an execution end missed across a reconnect would hold the pane Working; the TUI's - // session data re-hydrates on reconnect, so settle against it. - if (busyRootOwnerBySessionID.has(rootSessionID) && !familyRunning(rootSessionID) && !familyBlocked(rootSessionID)) { + if (!engineHoldsRoot(rootSessionID)) { + await reassert(rootSessionID); + } else if (busyRootOwnerBySessionID.has(rootSessionID) && !familyRunning(rootSessionID) && !familyBlocked(rootSessionID)) { await forward({ type: "session.status", properties: { sessionID: rootSessionID, status: { type: "idle" } } }); } } @@ -180,7 +190,14 @@ async function setupOpenCode2Tui(ctx) { const unsubscribe = ctx.data.listen(({ details } = {}) => { if (disposed || !details || typeof details.type !== "string") return; - void run(() => handle(details)); + queuedEvents += 1; + void run(async () => { + try { + await handle(details); + } finally { + queuedEvents -= 1; + } + }); }); const poll = setInterval(() => { if (syncQueued || disposed) return; From 6db4708fb34e6062c1be9612e2b0ca9426a3a0ce Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:24:54 -0700 Subject: [PATCH 04/25] fix(opencode): keep OpenCode 2 TUI panes silent on plugin dispose A TUI plugin hot reload disposes the plugin while the pane's turn keeps running. The server path already passes sessionsOutliveDispose so dispose publishes nothing; the TUI adapter now does the same, or every TUI reload would still show a false Done. Also refreshes the generated-bytes digest after rebasing onto the write-if-changed installers. --- src/main/opencode2/status-plugin-tui-source.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/opencode2/status-plugin-tui-source.ts b/src/main/opencode2/status-plugin-tui-source.ts index e74bd275bfbb..afe275a26b89 100644 --- a/src/main/opencode2/status-plugin-tui-source.ts +++ b/src/main/opencode2/status-plugin-tui-source.ts @@ -51,7 +51,8 @@ async function setupOpenCode2Tui(ctx) { }, }, }; - hooks = await OrcaOpenCodeStatusPlugin({ client }); + // Why: a TUI plugin hot reload disposes it while turns keep running. + hooks = await OrcaOpenCodeStatusPlugin({ client, sessionsOutliveDispose: true }); if (!hooks || typeof hooks.event !== "function") return noop; const engine = hooks; From 85e9b7bdd9e9f911f068ad5d930d8b52caf3b6cd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:40:24 -0700 Subject: [PATCH 05/25] fix(opencode): refresh installed OpenCode status plugins at app start After an Orca upgrade, an OpenCode 2 service that was already running kept the previous plugin, and with it the old wrong-pane status, until any new terminal pane rewrote the file. OpenCode 2 reloads a plugin whose file changes, so Orca now refreshes its existing installs once after the first window shows: the global config dir, source overlays and the retired shared dir, TUI copy first. It reuses the per-pane writers, which skip unchanged files, never creates an install the user did not have, and honours the status-hook and per-agent switches. An SSH relay does the same for its canonical install when Orca connects and ships the plugin sources. The plugin source assembly moves to its own module (re-exported unchanged) to keep hook-service.ts under the line limit. --- .../hook-service-startup-refresh.test.ts | 134 ++++++++++++++++++ src/main/opencode/hook-service.ts | 79 +++++------ ...code-status-plugin-startup-refresh.test.ts | 37 +++++ .../opencode-status-plugin-startup-refresh.ts | 16 +++ .../opencode/status-plugin-module-source.ts | 47 ++++++ .../startup/main-process-ready-runtime.ts | 5 + src/relay/opencode-canonical-config.ts | 8 +- src/relay/opencode-hook-selection.test.ts | 11 ++ src/relay/relay-agent-hook-runtime.ts | 12 ++ 9 files changed, 300 insertions(+), 49 deletions(-) create mode 100644 src/main/opencode/hook-service-startup-refresh.test.ts create mode 100644 src/main/opencode/opencode-status-plugin-startup-refresh.test.ts create mode 100644 src/main/opencode/opencode-status-plugin-startup-refresh.ts create mode 100644 src/main/opencode/status-plugin-module-source.ts 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 000000000000..b4b4b444f9b0 --- /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.ts b/src/main/opencode/hook-service.ts index d963e7e61d51..88007d05c88b 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, @@ -37,6 +33,8 @@ import { 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' const OPENCODE_OVERLAY_MANIFEST_FILE = '.orca-opencode-overlay-manifest.json' @@ -65,44 +63,6 @@ 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 @@ -189,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) } 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 000000000000..da588a3f6896 --- /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 000000000000..af88a0666a13 --- /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-module-source.ts b/src/main/opencode/status-plugin-module-source.ts new file mode 100644 index 000000000000..34030e3b1bfe --- /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/startup/main-process-ready-runtime.ts b/src/main/startup/main-process-ready-runtime.ts index 26b920652df1..bc7101d5bf8c 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 (#16441): the real-home grant runs a codex app-server session. It stays // ordered before managed-hook reconciliation — an incapable host must re-arm diff --git a/src/relay/opencode-canonical-config.ts b/src/relay/opencode-canonical-config.ts index 86900b3b36d1..8cdfa7d5536d 100644 --- a/src/relay/opencode-canonical-config.ts +++ b/src/relay/opencode-canonical-config.ts @@ -1,4 +1,4 @@ -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' @@ -12,13 +12,17 @@ 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)) { diff --git a/src/relay/opencode-hook-selection.test.ts b/src/relay/opencode-hook-selection.test.ts index 336db284a476..6e47c28bc364 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/relay-agent-hook-runtime.ts b/src/relay/relay-agent-hook-runtime.ts index bc1cef971370..de25c01608a7 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(), From 7ae65a98de64299eb9b794ae55e9513cc6aa9186 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:05:33 -0700 Subject: [PATCH 06/25] fix(opencode): derive each OpenCode 2 pane's status from its TUI session data The TUI copy of the status plugin translated OpenCode events into the server-side engine and then patched the engine's latches back toward the TUI's own session data: a settle on every tick, a queued-event counter, a re-assert, synthetic Busy and Idle. Each review found another place where the two copies disagreed: a missed end left the pane Working, a backlog of slow posts flickered Done, a missed start showed Done mid-turn, a turn held only by a subagent never settled, and a request answered while disconnected pinned Needs input. The TUI reporter now reads the pane's level straight from the session data OpenCode keeps current (and re-hydrates on reconnect) on every event and on a 100 ms tick: for each root session this pane owns, Needs input (an open permission, else form, anywhere in its family while it runs) outranks Working (any family member running) outranks Done. It posts one status per level change through the plugin's existing delivery functions (retry, dedupe, message-part throttle, ordering), so the wire and Orca's ingest are unchanged. Ownership is kept in OpenCode's storage.memory, which survives a plugin hot reload, so a turn that ends during a reload still shows Done; dispose publishes nothing, and a level the old generation could not deliver is re-posted by the next. On reconnect it re-syncs blockers for the owned sessions OpenCode would not re-sync itself, ignoring requests already answered. Events are handled synchronously, so a slow post can no longer hold up event processing. The server path, OpenCode 1 and mimo are unchanged. --- ...ook-plugin-opencode2-tui-ownership.test.ts | 301 +++++++++++++- .../opencode/status-plugin-factory-source.ts | 10 +- .../opencode2/status-plugin-setup-source.ts | 14 +- .../opencode2/status-plugin-tui-source.ts | 391 ++++++++++-------- 4 files changed, 514 insertions(+), 202 deletions(-) diff --git a/src/main/opencode/hook-plugin-opencode2-tui-ownership.test.ts b/src/main/opencode/hook-plugin-opencode2-tui-ownership.test.ts index d1b695b4fbea..02474292ef4c 100644 --- a/src/main/opencode/hook-plugin-opencode2-tui-ownership.test.ts +++ b/src/main/opencode/hook-plugin-opencode2-tui-ownership.test.ts @@ -66,12 +66,41 @@ function turn(sessionID: string, text: string): { start: BusEvent[]; finish: Bus } } +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) @@ -79,6 +108,15 @@ function fakeTui(version = '2.0.14') { 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) } }, @@ -86,23 +124,26 @@ function fakeTui(version = '2.0.14') { listen, session: { get: (id: string) => sessions.get(id), - root: (id: string) => { - let current = sessions.get(id) - while (current?.parentID && sessions.has(current.parentID)) { - current = sessions.get(current.parentID) + 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) ?? [])]) } - return current?.id ?? id }, - family: () => [], - status: (id: string) => (running.has(id) ? 'running' : 'idle'), - permission: { list: () => [] }, - form: { list: () => [] } + form: { list: (id: string) => forms.get(id), sync: async () => {} } } } } return { ctx, listen, + serverPermissions, + permissions, navigate(sessionID: string) { route = { type: 'session', sessionID } }, @@ -114,14 +155,33 @@ function fakeTui(version = '2.0.14') { 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') { - sessions.set(sessionID, { id: sessionID }) + 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 }) @@ -130,12 +190,14 @@ function fakeTui(version = '2.0.14') { } } -describe('OpenCode 2 TUI adapter: each pane reports its own sessions', () => { +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[] + let failPosts: boolean + let postDelayMs: number beforeEach(() => { tempDir = mkdtempSync(join(tmpdir(), 'orca-opencode-tui-adapter-')) @@ -150,8 +212,17 @@ describe('OpenCode 2 TUI adapter: each pane reports its own sessions', () => { process.env.ORCA_AGENT_HOOK_PORT = '59999' process.env.ORCA_AGENT_HOOK_TOKEN = 'test-token' posts = [] + failPosts = false + postDelayMs = 0 globalThis.fetch = vi.fn(async (_input, init) => { - posts.push(JSON.parse(String(init?.body))) + const body = JSON.parse(String(init?.body)) + if (postDelayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, postDelayMs)) + } + if (failPosts) { + return new Response('{}', { status: 500 }) + } + posts.push(body) return new Response('{}', { status: 200 }) }) }) @@ -295,7 +366,7 @@ describe('OpenCode 2 TUI adapter: each pane reports its own sessions', () => { await cleanup?.() }) - // Why: the session data applies events before this plugin's queued handling catches up. + // 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() @@ -338,6 +409,210 @@ describe('OpenCode 2 TUI adapter: each pane reports its own sessions', () => { 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 ' }) + }) + + 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}` + ]) + }) + + 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()], diff --git a/src/main/opencode/status-plugin-factory-source.ts b/src/main/opencode/status-plugin-factory-source.ts index 65fbcb35c091..8775a223f1de 100644 --- a/src/main/opencode/status-plugin-factory-source.ts +++ b/src/main/opencode/status-plugin-factory-source.ts @@ -1,8 +1,7 @@ import { - getOpenCode2SetupSource, + getOpenCode2ModuleSource, getOpenCode2EventNormalizationSource } from '../opencode2/status-plugin-setup-source' -import { openCodeTuiPluginDirName } from '../../shared/opencode-tui-plugin-install' export function getStatusPluginFactorySource(options: { emitSessionStart: boolean @@ -287,12 +286,7 @@ export function getStatusPluginFactorySource(options: { ' },', ' };', '};', - ...(options.emitNextEvents - ? [ - `const ORCA_TUI_PLUGIN_ENTRY = new URL("./${openCodeTuiPluginDirName(`${pluginID}.js`)}/tui.js", import.meta.url);`, - ...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/opencode2/status-plugin-setup-source.ts b/src/main/opencode2/status-plugin-setup-source.ts index 513b0c16e4bf..f81b971a4762 100644 --- a/src/main/opencode2/status-plugin-setup-source.ts +++ b/src/main/opencode2/status-plugin-setup-source.ts @@ -1,4 +1,14 @@ 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` @@ -10,8 +20,8 @@ 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 server subscription and the TUI listener must feed the engine -// identically, or a pane's status would depend on which process observed the event. +// 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 || {}; diff --git a/src/main/opencode2/status-plugin-tui-source.ts b/src/main/opencode2/status-plugin-tui-source.ts index afe275a26b89..f5c52707da60 100644 --- a/src/main/opencode2/status-plugin-tui-source.ts +++ b/src/main/opencode2/status-plugin-tui-source.ts @@ -1,236 +1,269 @@ /** - * TUI adapter 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 the engine's env stamp names the right pane, and the engine sees only - * sessions this pane owns — the one-pane-per-engine invariant it was built on. + * 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_ROUTE_POLL_MS = 100; -const TUI_EARLY_EVENT_ROOTS_MAX = 32; +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"; } -// Engine state that still needs this root's events before it can settle. -function engineHoldsRoot(rootSessionID) { - if (busyRootOwnerBySessionID.has(rootSessionID)) return true; - for (const busyChild of busyChildRootByKey.values()) { - if (busyChild.sessionID === rootSessionID) return true; - } - for (const provisional of provisionalBusyByKey.values()) { - if (provisional.sessionID === rootSessionID) return true; - } - for (const attention of pendingAttentionByKey.values()) { - if ((attention.properties?.sessionID || attention.sourceSessionID) === rootSessionID) return true; - } - return false; +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: "" }; + if (typeof ctx.storage?.memory === "function") return ctx.storage.memory("pane-status", { initial }); + const local = initial; + 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; - let hooks; + 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 data = ctx.data.session; - // 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; - if (typeof data?.get !== "function" || typeof data.status !== "function") return noop; - const client = { - session: { - get: async (input, options) => { - const sessionID = input?.sessionID ?? input?.path?.id; - const cached = data.get(sessionID); - if (cached && cached.id === sessionID) return { data: cached }; - const result = await ctx.client?.session?.get?.({ sessionID }, options); - const info = result && typeof result.id === "string" ? result : result?.data; - return info && info.id === sessionID ? { data: info } : undefined; - }, - }, - }; - // Why: a TUI plugin hot reload disposes it while turns keep running. - hooks = await OrcaOpenCodeStatusPlugin({ client, sessionsOutliveDispose: true }); - if (!hooks || typeof hooks.event !== "function") return noop; - const engine = hooks; - + const [memory, setMemory] = paneStatusMemory(ctx); + factoryID = ++nextFactoryID; + activeFactoryIDs.add(factoryID); let disposed = false; - let chain = Promise.resolve(); - let syncQueued = false; - let queuedEvents = 0; - let routeRoot; - const owned = new Set(); - // Root events seen before this pane owned the root (the route can switch after them). + 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 run = (task) => { - chain = chain.then(async () => { - if (!disposed) await task(); - }).catch(() => {}); - return chain; - }; - const forward = (event) => engine.event({ event }); - const rootOf = async (sessionID) => { - const resolved = await resolveRootSessionID(client, sessionID); - if (resolved) return resolved; - return typeof data.root === "function" ? data.root(sessionID) || sessionID : sessionID; - }; + 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(); - if (route?.type !== "session" || typeof route.sessionID !== "string") return undefined; - return typeof data.root === "function" ? data.root(route.sessionID) || route.sessionID : route.sessionID; + return route?.type === "session" && typeof route.sessionID === "string" ? rootOf(route.sessionID) : undefined; }; - const family = (rootSessionID) => - new Set([rootSessionID, ...(typeof data.family === "function" ? data.family(rootSessionID) || [] : [])]); - const familyRunning = (rootSessionID) => - [...family(rootSessionID)].some((id) => data.status(id) === "running"); - const familyBlocked = (rootSessionID) => - [...family(rootSessionID)].some((id) => (data.permission?.list?.(id) || []).length > 0 || (data.form?.list?.(id) || []).length > 0); - const remember = (rootSessionID, event) => { - const held = early.get(rootSessionID) || []; - early.delete(rootSessionID); - early.set(rootSessionID, [...held.filter((item) => item.type !== event.type), event]); - if (early.size > TUI_EARLY_EVENT_ROOTS_MAX) early.delete(early.keys().next().value); + // 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; - async function own(rootSessionID) { - if (owned.has(rootSessionID)) return; - owned.add(rootSessionID); - const held = early.get(rootSessionID) || []; - early.delete(rootSessionID); - for (const event of held) await forward(event); - await reassert(rootSessionID); + 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]; } - // Re-derives a running root's Busy and open blockers from the session data. - async function reassert(rootSessionID) { - if (data.status(rootSessionID) !== "running") return; - await forward({ type: "session.status", properties: { sessionID: rootSessionID, status: { type: "busy" } } }); - for (const member of family(rootSessionID)) { - for (const request of data.permission?.list?.(member) || []) { - const translated = translateOpenCode2Event("permission.asked", request); - if (translated) await forward(translated); - } - for (const form of data.form?.list?.(member) || []) { - const translated = translateOpenCode2Event("form.created", { form }); - if (translated) await forward(translated); - } + // 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 }; } - async function sync() { - routeRoot = currentRoute(); - if (routeRoot && !owned.has(routeRoot) && data.status(routeRoot) === "running") { - await own(routeRoot); - } - // 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, as it did with one server per pane. - for (const rootSessionID of owned) { - if ( - rootSessionID !== routeRoot && - data.status(rootSessionID) !== "running" && - !engineHoldsRoot(rootSessionID) - ) { - owned.delete(rootSessionID); - } + 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; } - // Why wait for an empty queue: the session data applies each event before it reaches this - // plugin, so it runs ahead of the engine until the queue drains; a mismatch after that is - // an execution start or end missed across a reconnect, which the data re-hydrates. - if (queuedEvents > 0) return; - for (const rootSessionID of owned) { - if (!engineHoldsRoot(rootSessionID)) { - await reassert(rootSessionID); - } else if (busyRootOwnerBySessionID.has(rootSessionID) && !familyRunning(rootSessionID) && !familyBlocked(rootSessionID)) { - await forward({ type: "session.status", properties: { sessionID: rootSessionID, status: { type: "idle" } } }); - } + 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); } - async function handle(input) { - await sync(); - if (input.type === "session.deleted") { - const sessionID = input.data?.sessionID; - early.delete(sessionID); - if (!owned.has(sessionID)) return; - owned.delete(sessionID); - // A deleted root never settles on its own; retire whatever the engine still holds. - await forward({ type: "session.status", properties: { sessionID, status: { type: "idle" } } }); + 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; } - let translated; - if (input.type === "session.inbox.enqueued") { - // Why TUI-only: the server takes the prompt from session.hook("prompt"), which a TUI lacks. - const item = input.data?.item; - if (item?.type !== "user" || typeof item.payload?.text !== "string") return; - translated = { - type: "session.next.prompt.admitted", - properties: { sessionID: input.data.sessionID, messageID: input.data.inboxID, prompt: { text: item.payload.text } }, - }; - } else { - translated = translateOpenCode2Event(input.type, input.data); + 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 (!translated) return; - const sessionID = translated.properties?.sessionID ?? translated.properties?.info?.id; if (typeof sessionID !== "string" || !sessionID) return; - const rootSessionID = await rootOf(sessionID); - if (!owned.has(rootSessionID)) { - if (input.type === "session.execution.started" && sessionID === rootSessionID && rootSessionID === routeRoot) { - await own(rootSessionID); - } else { - if (sessionID === rootSessionID && (translated.type === "session.created" || translated.type === "session.next.prompt.admitted")) { - remember(rootSessionID, translated); - } - 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) { + 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 })); } - await forward(translated); } + // 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; - queuedEvents += 1; - void run(async () => { - try { - await handle(details); - } finally { - queuedEvents -= 1; - } - }); + try { + observe(details); + publish(); + } catch { + // A malformed event must not break the listener. + } }); - const poll = setInterval(() => { - if (syncQueued || disposed) return; - syncQueued = true; - void run(async () => { - syncQueued = false; - await sync(); - }); - }, TUI_ROUTE_POLL_MS); - if (poll.unref) poll.unref(); - void run(sync); + // 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(); return async () => { try { disposed = true; - clearInterval(poll); - try { - if (typeof unsubscribe === "function") unsubscribe(); - } finally { - try { - await chain; - } finally { - await engine.dispose?.(); - } - } + 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 { - try { await hooks?.dispose?.(); } 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') } From 59ef3ecf07de9e227430b0124d0b9cfa652af179 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:24:25 -0700 Subject: [PATCH 07/25] fix(opencode): keep an OpenCode 2 pane on Needs input while its root streams text Orca treats every OpenCode MessagePart as Working. While a background subagent waits on a permission or form, the root session can keep streaming reply text, and the TUI reporter forwarded that text as a MessagePart. The pane then flipped from Needs input to Working, and nothing restored it until the level changed, so the user could miss the open request. Skip reply text while the pane's level is Needs input, as the reporter already does for queued prompts. --- .../hook-plugin-opencode2-tui-ownership.test.ts | 14 ++++++++++++++ src/main/opencode2/status-plugin-tui-source.ts | 2 ++ 2 files changed, 16 insertions(+) diff --git a/src/main/opencode/hook-plugin-opencode2-tui-ownership.test.ts b/src/main/opencode/hook-plugin-opencode2-tui-ownership.test.ts index 02474292ef4c..4d790623bb68 100644 --- a/src/main/opencode/hook-plugin-opencode2-tui-ownership.test.ts +++ b/src/main/opencode/hook-plugin-opencode2-tui-ownership.test.ts @@ -542,6 +542,20 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { ]) }) + // 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) diff --git a/src/main/opencode2/status-plugin-tui-source.ts b/src/main/opencode2/status-plugin-tui-source.ts index f5c52707da60..c14bb6377689 100644 --- a/src/main/opencode2/status-plugin-tui-source.ts +++ b/src/main/opencode2/status-plugin-tui-source.ts @@ -197,6 +197,8 @@ async function setupOpenCode2Tui(ctx) { 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 })); From a0307fc635561d8248694453ec83f0b1cbe8452a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:31:08 -0700 Subject: [PATCH 08/25] fix(opencode): leave OpenCode 2 step events to the server path's own change The shared-server translation re-derived Working from session.step.started. The pane reporter no longer uses it, so it only changed the server path and duplicated a separate open change. Drop it; server behaviour matches main. --- src/main/opencode2/status-plugin-setup-source.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/main/opencode2/status-plugin-setup-source.ts b/src/main/opencode2/status-plugin-setup-source.ts index f81b971a4762..93d6840220db 100644 --- a/src/main/opencode2/status-plugin-setup-source.ts +++ b/src/main/opencode2/status-plugin-setup-source.ts @@ -61,10 +61,6 @@ function translateOpenCode2Event(inputType, data) { 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."); - } else if (type === "session.step.started") { - // Why: re-derives Busy for a plugin reloaded mid-turn, which never saw execution.started. - // Tool events stay out: a parallel tool reports progress while a sibling form still blocks. - type = "session.next.step.started"; } return { type, properties }; } From 1e05e1ec492b77e22f12f6de7d2fdcd0a53c2532 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:38:51 -0700 Subject: [PATCH 09/25] fix(opencode): reset an OpenCode 2 pane to idle when its TUI starts Before this change, a pane could keep a status an earlier process left on it. The case that matters is an upgrade mid-turn: the old shared-service plugin posted pane B's turn under pane A's key, then stood down, and pane A's TUI loaded the new reporter owning nothing, so it never posted and A showed a wrong Working until its own next turn. A freshly started reporter that owns no running turn now posts the host's existing session-start boundary once, which the host shows as connected idle: no completion, no notification, and an unseen Done it lands on stays unread. A plugin hot reload keeps its memory and skips it, and where OpenCode keeps no plugin memory it is never sent. --- ...ook-plugin-opencode2-tui-ownership.test.ts | 74 ++++++++++++++++++- .../opencode2/status-plugin-tui-source.ts | 14 +++- 2 files changed, 85 insertions(+), 3 deletions(-) diff --git a/src/main/opencode/hook-plugin-opencode2-tui-ownership.test.ts b/src/main/opencode/hook-plugin-opencode2-tui-ownership.test.ts index 4d790623bb68..efda31bb9c0d 100644 --- a/src/main/opencode/hook-plugin-opencode2-tui-ownership.test.ts +++ b/src/main/opencode/hook-plugin-opencode2-tui-ownership.test.ts @@ -196,6 +196,8 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { 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 @@ -212,6 +214,7 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { 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) => { @@ -222,7 +225,14 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { if (failPosts) { return new Response('{}', { status: 500 }) } - posts.push(body) + 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 }) }) }) @@ -504,6 +514,68 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { `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 () => { diff --git a/src/main/opencode2/status-plugin-tui-source.ts b/src/main/opencode2/status-plugin-tui-source.ts index c14bb6377689..aff056d45de2 100644 --- a/src/main/opencode2/status-plugin-tui-source.ts +++ b/src/main/opencode2/status-plugin-tui-source.ts @@ -25,9 +25,10 @@ function boundedSet(set, value, max) { // 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: "" }; + const initial = { owned: [], last: "idle:", lastRoot: "", started: false }; if (typeof ctx.storage?.memory === "function") return ctx.storage.memory("pane-status", { initial }); - const local = 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)]; } @@ -230,6 +231,15 @@ async function setupOpenCode2Tui(ctx) { }, 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; From 218e66a4de1c181aee0d68f1779faa769da80d22 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:59:05 -0700 Subject: [PATCH 10/25] fix(opencode): keep OpenCode 1 serve + attach sessions on the attaching pane OpenCode 1 `opencode serve` in one pane plus `opencode attach` in others reports every session from the serve process, whose environment names only the serve pane. Before this branch the session-to-pane binder moved those posts to the attaching pane; deleting it for OpenCode 2 put them back on the serve pane. Restore the binder, registry, correlation and client sweep for OpenCode 1 (and mimo-code, which it also served), gated so OpenCode 2 never uses it: - The status plugin now sends `opencodeMajor: 2` on every post from a process whose loader called setup(), which only OpenCode 2 does. The host skips the binder (no rewrite, no kicked round) for any post that carries it. OpenCode 1 and older plugins send nothing and keep the previous behaviour. - The binder reads only OpenCode 1's `session` table. OpenCode 2 writes `session_v2`, so its sessions never bind; on a database both versions wrote, OpenCode 1 sessions are no longer hidden behind the v2 table. OpenCode-1-only; it goes when OpenCode 1 support is removed. --- .../server-opencode-binder.test.ts | 332 +++++++++++++++++ .../server/server-ingest-normalization.ts | 16 +- .../agent-hooks/server/server-lifecycle.ts | 2 + .../server/server-opencode-binder.ts | 214 +++++++++++ .../hook-plugin-opencode2-setup.test.ts | 28 ++ ...ook-plugin-opencode2-tui-ownership.test.ts | 8 +- .../opencode/opencode-client-sweep.test.ts | 215 +++++++++++ src/main/opencode/opencode-client-sweep.ts | 263 ++++++++++++++ .../opencode/opencode-session-binder.test.ts | 176 +++++++++ src/main/opencode/opencode-session-binder.ts | 334 ++++++++++++++++++ .../opencode/status-plugin-post-source.ts | 1 + .../status-plugin-runtime-state-source.ts | 2 + .../opencode2/status-plugin-setup-source.ts | 1 + ...ok-listener-opencode-reattribution.test.ts | 149 ++++++++ ...tener-opencode-session-correlation.test.ts | 228 ++++++++++++ ...listener-opencode-session-registry.test.ts | 89 +++++ src/shared/agent-hook-listener.ts | 35 +- .../agent-hook-listener/listener-state.ts | 24 +- .../opencode-session-correlation.ts | 252 +++++++++++++ .../opencode-session-registry.ts | 202 +++++++++++ 20 files changed, 2564 insertions(+), 7 deletions(-) create mode 100644 src/main/agent-hooks/server-opencode-binder.test.ts create mode 100644 src/main/agent-hooks/server/server-opencode-binder.ts create mode 100644 src/main/opencode/opencode-client-sweep.test.ts create mode 100644 src/main/opencode/opencode-client-sweep.ts create mode 100644 src/main/opencode/opencode-session-binder.test.ts create mode 100644 src/main/opencode/opencode-session-binder.ts create mode 100644 src/shared/agent-hook-listener-opencode-reattribution.test.ts create mode 100644 src/shared/agent-hook-listener-opencode-session-correlation.test.ts create mode 100644 src/shared/agent-hook-listener-opencode-session-registry.test.ts create mode 100644 src/shared/agent-hook-listener/opencode-session-correlation.ts create mode 100644 src/shared/agent-hook-listener/opencode-session-registry.ts diff --git a/src/main/agent-hooks/server-opencode-binder.test.ts b/src/main/agent-hooks/server-opencode-binder.test.ts new file mode 100644 index 000000000000..bf739a659923 --- /dev/null +++ b/src/main/agent-hooks/server-opencode-binder.test.ts @@ -0,0 +1,332 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentHookSource } from '../../shared/agent-hook-relay' +import { createHookListenerState } from '../../shared/agent-hook-listener/listener-state' +import { lookupOpenCodeSessionPane } from '../../shared/agent-hook-listener/opencode-session-registry' +import { makePaneKey } from '../../shared/stable-pane-id' +import SyncDatabase from '../sqlite/sync-database' +import { AgentHookServer } from './server' +import type { OpenCodeBinderLoopDeps } from './server/server-opencode-binder' +import { defaultOpenCodeDbPath, listOpenCodeDbSessions } from '../opencode/opencode-session-binder' + +const LEAF_A = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' +const LEAF_B = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' +const PANE_A = makePaneKey('binder-a', LEAF_A) +const PANE_B = makePaneKey('binder-b', LEAF_B) +const DIR = '/tmp/binder-worktree-a' + +class BinderTestServer extends AgentHookServer { + public bindDeps(deps: Partial): void { + this._setOpenCodeBinderDepsForTests(deps) + } + + public runBinderRound(): Promise { + return this.runOpenCodeBinderRoundOnce() + } + + public startBinderLoop(): void { + this.startOpenCodeBinderLoop() + } + + public ingest(source: AgentHookSource, body: unknown): string | undefined { + return this.normalizeLocalHookPayload(source, body).event?.paneKey + } + + public readRegistry(sessionId: string): string | undefined { + return lookupOpenCodeSessionPane(this._getStateForTests(), sessionId)?.paneKey + } +} + +function writeDb(dbPath: string, table: 'session_v2' | 'session', id = 'ses_live'): void { + const db = new SyncDatabase(dbPath) + try { + db.exec( + `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(id, DIR, Date.now() - 60_000, null) + } finally { + db.close() + } +} + +describe('opencode binder loop', () => { + let dir = '' + let dbPath = '' + let server: BinderTestServer + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'binder-db-')) + dbPath = join(dir, 'opencode.db') + server = new BinderTestServer() + server.bindDeps({ + now: () => Date.now(), + dbPath: () => dbPath, + listPanes: () => [ + { paneKey: PANE_A, directory: DIR, worktreeId: `repo::${DIR}`, shellPid: 111 } + ], + sweep: async () => [ + { + pid: 112, + ppid: 111, + startedAtMs: Date.now() - 120_000, + executable: 'opencode', + argv: ['opencode'] + } + ] + }) + }) + + afterEach(() => { + server.stop() + rmSync(dir, { recursive: true, force: true }) + }) + + 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('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. + server.ingest('opencode', { + paneKey: PANE_B, + launchToken: '', + payload: { hook_event_name: 'SessionStart', sessionID: 'ses_live' } + }) + expect(server.readRegistry('ses_live')).toBeUndefined() + await vi.advanceTimersByTimeAsync(10_000) + expect(server.readRegistry('ses_live')).toBe(PANE_A) + } finally { + vi.useRealTimers() + } + }) + + 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') + await server.runBinderRound() + expect(server.readRegistry('ses_live')).toBe(PANE_A) + server.clearPaneState(PANE_A) + expect(server.readRegistry('ses_live')).toBeUndefined() + }) + + it('stops the loop without hanging the process', () => { + writeDb(dbPath, 'session') + expect(() => server.stop()).not.toThrow() + }) + + it('runs a round immediately on loop start', async () => { + writeDb(dbPath, 'session') + server.startBinderLoop() + try { + await vi.waitFor(() => expect(server.readRegistry('ses_live')).toBe(PANE_A)) + } finally { + server.stop() + } + }) + + it('discards a round that was in flight across stop', async () => { + writeDb(dbPath, 'session') + let releaseSweep!: () => void + const sweepGate = new Promise((resolve) => { + releaseSweep = resolve + }) + server.bindDeps({ + sweep: async () => { + await sweepGate + return [ + { + pid: 112, + ppid: 111, + startedAtMs: Date.now() - 120_000, + executable: 'opencode', + argv: ['opencode'] + } + ] + } + }) + const round = server.runBinderRound() + server.stop() + releaseSweep() + expect(await round).toBe(0) + expect(server.readRegistry('ses_live')).toBeUndefined() + }) + + it('an obsolete round does not clear the new round running flag', async () => { + writeDb(dbPath, 'session') + let releaseFirst!: () => void + let releaseLater!: () => void + const firstGate = new Promise((resolve) => { + releaseFirst = resolve + }) + const laterGate = new Promise((resolve) => { + releaseLater = resolve + }) + const clientRow = { + pid: 112, + ppid: 111, + startedAtMs: Date.now() - 120_000, + executable: 'opencode', + argv: ['opencode'] + } + let sweepCalls = 0 + server.bindDeps({ + sweep: async () => { + sweepCalls += 1 + await (sweepCalls === 1 ? firstGate : laterGate) + return [clientRow] + } + }) + server.startBinderLoop() + await vi.waitFor(() => expect(sweepCalls).toBe(1)) + server.stop() + server.startBinderLoop() + await vi.waitFor(() => expect(sweepCalls).toBe(2)) + // The obsolete round finishes while the new round is still parked: its + // finally must not clear the flag the new round holds. + releaseFirst() + await new Promise((resolve) => setTimeout(resolve, 20)) + // A third round attempted now must be refused at the flag check, calling + // no sweep. With the unguarded finally it would be admitted instead. + const extraRound = server.runBinderRound() + expect(sweepCalls).toBe(2) + releaseLater() + await vi.waitFor(() => expect(server.readRegistry('ses_live')).toBe(PANE_A)) + await extraRound + server.stop() + }) +}) + +// 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 = '' + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'binder-reader-')) + dbPath = join(dir, 'opencode.db') + }) + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + }) + + 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([]) + }) + + it('the default path points at the local opencode store', () => { + expect(defaultOpenCodeDbPath()).toMatch(/opencode\.db$/) + }) +}) + +describe('binder registry isolation', () => { + it('a fresh listener state starts unbound', () => { + const state = createHookListenerState() + expect(lookupOpenCodeSessionPane(state, 'ses_live')).toBeUndefined() + }) +}) diff --git a/src/main/agent-hooks/server/server-ingest-normalization.ts b/src/main/agent-hooks/server/server-ingest-normalization.ts index 0a1e8d761d64..5e71e6ef033f 100644 --- a/src/main/agent-hooks/server/server-ingest-normalization.ts +++ b/src/main/agent-hooks/server/server-ingest-normalization.ts @@ -1,10 +1,11 @@ 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 { AgentHookServerPersistence } from './server-persistence' +import { AgentHookServerOpenCodeBinder } from './server-opencode-binder' -export abstract class AgentHookServerIngestNormalization extends AgentHookServerPersistence { +export abstract class AgentHookServerIngestNormalization extends AgentHookServerOpenCodeBinder { protected setClaudeBackgroundEvidence( paneKey: string, hasRunningTask: boolean, @@ -24,7 +25,16 @@ export abstract class AgentHookServerIngestNormalization extends AgentHookServer protected normalizeLocalHookPayload(source: AgentHookSource, body: unknown): NormalizedLocalHook { if (source !== 'claude' || typeof body !== 'object' || body === null) { - return { event: normalizeHookPayload(this.state, source, body, this.env) } + const event = normalizeHookPayload(this.state, source, body, this.env) + if ( + event && + event.hookEventName === 'SessionStart' && + isOpenCodeSharedServerPost(source, body) + ) { + // Why: a birth just arrived; bind it now instead of waiting out the poll interval. + this.kickOpenCodeBinder() + } + return { event } } const rawPaneKey = (body as Record).paneKey const paneKey = typeof rawPaneKey === 'string' ? rawPaneKey.trim() : '' diff --git a/src/main/agent-hooks/server/server-lifecycle.ts b/src/main/agent-hooks/server/server-lifecycle.ts index b3b02208fac4..816058e55fdd 100644 --- a/src/main/agent-hooks/server/server-lifecycle.ts +++ b/src/main/agent-hooks/server/server-lifecycle.ts @@ -178,6 +178,7 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv this.rollbackTransportStart() throw error } + this.startOpenCodeBinderLoop() } private rollbackTransportStart(): void { @@ -191,6 +192,7 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv stop(): void { // Why: flush the pending debounced write before clearing the map, else a hook <250ms before quit is lost on relaunch. this.flushStatusPersistSync() + this.stopOpenCodeBinderLoop() this.rollbackTransportStart() this.env = 'production' this.onAgentStatus = null diff --git a/src/main/agent-hooks/server/server-opencode-binder.ts b/src/main/agent-hooks/server/server-opencode-binder.ts new file mode 100644 index 000000000000..48a75ba54832 --- /dev/null +++ b/src/main/agent-hooks/server/server-opencode-binder.ts @@ -0,0 +1,214 @@ +import { + advanceBinderCursor, + applyBinderOwnerships, + defaultOpenCodeDbPath, + listBinderPaneSnapshots, + listOpenCodeDbSessions, + OPENCODE_SESSION_CURSOR_START, + runOpenCodeBinderRound, + type BinderPaneSnapshot, + type BinderSessionRow, + type OpenCodeSessionCursor +} from '../../opencode/opencode-session-binder' +import { + sweepProcessIdentities, + type ProcessIdentityRow +} from '../../opencode/opencode-client-sweep' +import { lookupOpenCodeSessionPane } from '../../../shared/agent-hook-listener/opencode-session-registry' +import { AgentHookServerPersistence } from './server-persistence' + +/** Poll cadence; hook-triggered kicks cover births between polls. */ +const OPENCODE_BINDER_INTERVAL_MS = 60_000 +const OPENCODE_BINDER_KICK_DEBOUNCE_MS = 10_000 +/** Unbound sessions get re-correlated this long (pane inventory may lag births). */ +const OPENCODE_BINDER_UNBOUND_RETRY_MS = 10 * 60_000 +const OPENCODE_BINDER_PARENTS_MAX = 2_000 +const OPENCODE_BINDER_UNBOUND_MAX = 500 + +/** Injectable I/O for the binder loop; real singletons by default, fakes in tests. */ +export type OpenCodeBinderLoopDeps = { + now: () => number + dbPath: () => string + listSessions: (dbPath: string, cursor: OpenCodeSessionCursor) => BinderSessionRow[] + listPanes: () => BinderPaneSnapshot[] + sweep: () => Promise +} + +/** + * 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 + * I/O rides injectable deps (real singletons by default) so tests drive the + * whole loop without touching the user's opencode.db or process table. + */ +export abstract class AgentHookServerOpenCodeBinder extends AgentHookServerPersistence { + private openCodeBinderTimer: ReturnType | null = null + private openCodeBinderKickTimer: ReturnType | null = null + private openCodeBinderRunning = false + private openCodeBinderGeneration = 0 + private openCodeBinderWatermark: OpenCodeSessionCursor = { ...OPENCODE_SESSION_CURSOR_START } + private openCodeBinderParents = new Map() + private openCodeBinderUnbound = new Map() + private openCodeBinderDeps: OpenCodeBinderLoopDeps = { + now: () => Date.now(), + dbPath: () => defaultOpenCodeDbPath(), + listSessions: (dbPath, sinceMs) => listOpenCodeDbSessions(dbPath, sinceMs), + listPanes: () => listBinderPaneSnapshots(), + sweep: () => sweepProcessIdentities() + } + + /** Test seam: drive the loop without the user's database or process table. */ + protected _setOpenCodeBinderDepsForTests(deps: Partial): void { + this.openCodeBinderDeps = { ...this.openCodeBinderDeps, ...deps } + } + + /** Start the 60s poll loop plus one immediate round, idempotently. */ + protected startOpenCodeBinderLoop(): void { + if (this.openCodeBinderTimer) { + return + } + this.openCodeBinderTimer = setInterval(() => { + void this.runOpenCodeBinderRoundOnce() + }, OPENCODE_BINDER_INTERVAL_MS) + if (this.openCodeBinderTimer.unref) { + this.openCodeBinderTimer.unref() + } + // Why immediately: existing sessions would otherwise keep the frozen + // stamp for up to a full interval after launch or restart. + void this.runOpenCodeBinderRoundOnce() + } + + /** Stop timers and drop ephemeral binder state; in-flight rounds are discarded by generation. */ + protected stopOpenCodeBinderLoop(): void { + // Why the generation bump: a round awaiting the process sweep must not + // apply ownerships — or resurrect the watermark — after the loop stopped. + this.openCodeBinderGeneration += 1 + if (this.openCodeBinderTimer) { + clearInterval(this.openCodeBinderTimer) + this.openCodeBinderTimer = null + } + if (this.openCodeBinderKickTimer) { + clearTimeout(this.openCodeBinderKickTimer) + this.openCodeBinderKickTimer = null + } + this.openCodeBinderRunning = false + this.openCodeBinderWatermark = { ...OPENCODE_SESSION_CURSOR_START } + this.openCodeBinderParents.clear() + this.openCodeBinderUnbound.clear() + } + + /** + * A birth may have arrived (opencode SessionStart): run one round soon so + * the session binds before its first busy stretch, instead of waiting out + * the poll interval. Trailing-edge debounced; concurrent rounds collapse. + */ + protected kickOpenCodeBinder(): void { + if (this.openCodeBinderKickTimer) { + return + } + this.openCodeBinderKickTimer = setTimeout(() => { + this.openCodeBinderKickTimer = null + void this.runOpenCodeBinderRoundOnce() + }, OPENCODE_BINDER_KICK_DEBOUNCE_MS) + if (this.openCodeBinderKickTimer.unref) { + this.openCodeBinderKickTimer.unref() + } + } + + /** Run one correlate-and-bind round; returns applied binding count. */ + protected async runOpenCodeBinderRoundOnce(): Promise { + if (this.openCodeBinderRunning) { + return 0 + } + this.openCodeBinderRunning = true + // Why capture before the try: if stop() lands while the sweep is in + // flight and a restart begins a new round, the obsolete round must not + // clear the new round's running flag (or two rounds overlap and apply + // ownership snapshots out of order). + const generation = this.openCodeBinderGeneration + try { + const deps = this.openCodeBinderDeps + const nowMs = deps.now() + const fresh = deps.listSessions(deps.dbPath(), this.openCodeBinderWatermark) + const sessions = [...fresh] + for (const [id, entry] of this.openCodeBinderUnbound) { + if (nowMs - entry.firstSeenMs > OPENCODE_BINDER_UNBOUND_RETRY_MS) { + this.openCodeBinderUnbound.delete(id) + continue + } + if (!fresh.some((row) => row.id === id)) { + sessions.push(entry.row) + } + } + if (sessions.length === 0) { + return 0 + } + const panes = deps.listPanes() + const processes = await deps.sweep() + if (generation !== this.openCodeBinderGeneration) { + return 0 + } + const knownOwners = new Map() + for (const session of sessions) { + const bound = lookupOpenCodeSessionPane(this.state, session.id) + if (bound) { + knownOwners.set(session.id, bound.paneKey) + } + this.openCodeBinderParents.delete(session.id) + this.openCodeBinderParents.set(session.id, session.parentId) + } + while (this.openCodeBinderParents.size > OPENCODE_BINDER_PARENTS_MAX) { + const oldest = this.openCodeBinderParents.keys().next().value + if (oldest === undefined) { + break + } + this.openCodeBinderParents.delete(oldest) + } + const { ownerships } = runOpenCodeBinderRound({ + nowMs, + sessions, + panes, + processes, + knownOwners, + parentBySessionId: this.openCodeBinderParents + }) + const boundIds = new Set(ownerships.map((ownership) => ownership.sessionId)) + const applied = applyBinderOwnerships(this.state, panes, ownerships, nowMs) + for (const session of sessions) { + if (knownOwners.has(session.id) || boundIds.has(session.id)) { + this.openCodeBinderUnbound.delete(session.id) + continue + } + if (!this.openCodeBinderUnbound.has(session.id)) { + if (this.openCodeBinderUnbound.size >= OPENCODE_BINDER_UNBOUND_MAX) { + break + } + this.openCodeBinderUnbound.set(session.id, { row: session, firstSeenMs: nowMs }) + } + } + // Why from handled rows only: a session the full map could not track + // must stay re-listable next round instead of being silently passed by + // the watermark. + this.openCodeBinderWatermark = advanceBinderCursor({ + fresh, + isHandled: (sessionId) => + knownOwners.has(sessionId) || + boundIds.has(sessionId) || + this.openCodeBinderUnbound.has(sessionId), + current: this.openCodeBinderWatermark + }) + return applied + } catch (err) { + // Why swallow: a binder failure must never break hook serving; the next + // round retries, and unbound sessions keep today's stamped behavior. + console.warn('[opencode-binder] round failed; keeping stamped attribution', err) + return 0 + } finally { + if (generation === this.openCodeBinderGeneration) { + this.openCodeBinderRunning = false + } + } + } +} diff --git a/src/main/opencode/hook-plugin-opencode2-setup.test.ts b/src/main/opencode/hook-plugin-opencode2-setup.test.ts index e9e38334d3e6..1ada5ea1a53d 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 index efda31bb9c0d..29eefd774827 100644 --- a/src/main/opencode/hook-plugin-opencode2-tui-ownership.test.ts +++ b/src/main/opencode/hook-plugin-opencode2-tui-ownership.test.ts @@ -16,7 +16,11 @@ vi.mock('electron', () => ({ import { _internals } from './hook-service' -type Post = { paneKey?: string; payload?: { hook_event_name?: string; sessionID?: string } } +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> } @@ -303,6 +307,8 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { 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 [ diff --git a/src/main/opencode/opencode-client-sweep.test.ts b/src/main/opencode/opencode-client-sweep.test.ts new file mode 100644 index 000000000000..72f5106c8f6d --- /dev/null +++ b/src/main/opencode/opencode-client-sweep.test.ts @@ -0,0 +1,215 @@ +import { describe, expect, it } from 'vitest' +import type { ProcessResult, ProcessSpec } from '../../shared/child-process/process-spec' +import { + isOpenCodeClientArgv, + isOpenCodeClientProcess, + nativeWindowsRowToIdentity, + parsePsArgsLine, + parsePsCommLine, + parsePsElapsedToMs, + splitCommandLineArgv, + sweepProcessIdentities +} from './opencode-client-sweep' + +const NOW = 1_700_000_000_000 + +describe('parsePsElapsedToMs', () => { + it('reads mm:ss, hh:mm:ss and dd-hh:mm:ss', () => { + expect(parsePsElapsedToMs('02:11', NOW)).toBe(NOW - 131_000) + expect(parsePsElapsedToMs('1:02:11', NOW)).toBe(NOW - 3_731_000) + expect(parsePsElapsedToMs('2-01:02:11', NOW)).toBe(NOW - 176_531_000) + }) + + it('rejects unknown shapes', () => { + expect(parsePsElapsedToMs('', NOW)).toBeNull() + expect(parsePsElapsedToMs('yesterday', NOW)).toBeNull() + }) +}) + +describe('parsePsArgsLine', () => { + it('parses a client row', () => { + const row = parsePsArgsLine('23487 22618 2:11:51 opencode', NOW) + expect(row).toMatchObject({ pid: 23487, ppid: 22618, argv: ['opencode'] }) + expect(row?.startedAtMs).toBe(NOW - (2 * 3_600 + 11 * 60 + 51) * 1000) + }) + + it('keeps session flags in argv', () => { + const row = parsePsArgsLine('999 100 00:05 opencode --session ses_abc', NOW) + expect(row?.argv).toEqual(['opencode', '--session', 'ses_abc']) + }) + + it('keeps a quoted executable path as argv[0]', () => { + const row = parsePsArgsLine('999 100 00:05 "/opt/my tools/opencode" --session ses_abc', NOW) + expect(row?.argv).toEqual(['/opt/my tools/opencode', '--session', 'ses_abc']) + expect(row?.executable).toBe('') + }) + + it('drops header-shaped and truncated rows', () => { + expect(parsePsArgsLine('PID PPID ELAPSED COMMAND', NOW)).toBeNull() + expect(parsePsArgsLine('1 0', NOW)).toBeNull() + expect(parsePsArgsLine('', NOW)).toBeNull() + }) +}) + +describe('parsePsCommLine', () => { + it('reads the executable name past the pid', () => { + expect(parsePsCommLine('23487 opencode')).toEqual({ pid: 23487, executable: 'opencode' }) + }) + + it('keeps executable names containing spaces whole', () => { + expect(parsePsCommLine(' 999 My App Helper ')).toEqual({ + pid: 999, + executable: 'My App Helper' + }) + }) + + it('drops header-shaped and truncated rows', () => { + expect(parsePsCommLine('PID COMMAND')).toBeNull() + expect(parsePsCommLine('1')).toBeNull() + expect(parsePsCommLine('')).toBeNull() + }) +}) + +describe('splitCommandLineArgv', () => { + it('groups double-quoted spans', () => { + expect( + splitCommandLineArgv('"C:\\Program Files\\OpenCode\\opencode.exe" --session ses_1') + ).toEqual(['C:\\Program Files\\OpenCode\\opencode.exe', '--session', 'ses_1']) + }) + + it('splits plain argv on whitespace', () => { + expect(splitCommandLineArgv('opencode --session ses_1')).toEqual([ + 'opencode', + '--session', + 'ses_1' + ]) + }) + + it('drops empties', () => { + expect(splitCommandLineArgv('')).toEqual([]) + }) +}) + +describe('nativeWindowsRowToIdentity', () => { + it('maps pid, creation time and quoted command line', () => { + const row = nativeWindowsRowToIdentity({ + pid: 23487, + ppid: 22618, + name: 'opencode.exe', + creationTimeMs: NOW - 60_000, + command: '"C:\\Program Files\\OpenCode\\opencode.exe" --session ses_1' + }) + expect(row).toMatchObject({ + pid: 23487, + ppid: 22618, + startedAtMs: NOW - 60_000, + executable: 'opencode.exe', + argv: ['C:\\Program Files\\OpenCode\\opencode.exe', '--session', 'ses_1'] + }) + }) + + it('skips rows without a creation time or command line', () => { + expect(nativeWindowsRowToIdentity({ pid: 4, ppid: 0, name: 'System', command: '' })).toBeNull() + expect( + nativeWindowsRowToIdentity({ pid: 4, ppid: 0, name: 'System', command: 'opencode' }) + ).toBeNull() + }) +}) + +describe('isOpenCodeClientArgv', () => { + it('matches clients and rejects the serve daemon', () => { + expect(isOpenCodeClientArgv(['opencode'])).toBe(true) + expect(isOpenCodeClientArgv(['/opt/homebrew/bin/opencode', '--session', 'ses_1'])).toBe(true) + expect(isOpenCodeClientArgv(['C:\\tools\\opencode.exe'])).toBe(true) + expect( + isOpenCodeClientArgv(['C:\\Program Files\\OpenCode\\opencode.exe', '--session', 'ses_1']) + ).toBe(true) + expect(isOpenCodeClientArgv(['opencode.exe', 'serve', '--service'])).toBe(false) + expect(isOpenCodeClientArgv(['node', 'server.js'])).toBe(false) + expect(isOpenCodeClientArgv([])).toBe(false) + }) +}) + +describe('isOpenCodeClientProcess', () => { + it('trusts the executable when argv[0] is truncated by spaces', () => { + expect( + isOpenCodeClientProcess({ executable: 'opencode', argv: ['/opt/Open', 'Code/opencode'] }) + ).toBe(true) + }) + + it('still rejects the serve daemon', () => { + expect( + isOpenCodeClientProcess({ executable: 'opencode', argv: ['opencode', 'serve', '--service'] }) + ).toBe(false) + }) + + it('falls back to argv[0] without an executable', () => { + expect(isOpenCodeClientProcess({ executable: '', argv: ['opencode'] })).toBe(true) + expect(isOpenCodeClientProcess({ executable: '', argv: ['node', 'server.js'] })).toBe(false) + }) +}) + +describe('sweepProcessIdentities', () => { + function psRunner( + outputs: Record<'args' | 'comm', string | Error> + ): (spec: ProcessSpec) => Promise { + return async (spec: ProcessSpec): Promise => { + const kind = spec.args?.some((arg) => arg.includes('comm=')) ? 'comm' : 'args' + const output = outputs[kind] + if (output instanceof Error) { + throw output + } + return { + code: 0, + signal: null, + stdout: output, + stderr: '', + timedOut: false + } + } + } + + it('joins the comm executable onto args rows on POSIX', async () => { + const rows = await sweepProcessIdentities({ + platform: 'darwin', + nowMs: NOW, + run: psRunner({ + args: '23487 22618 00:05 /opt/Open Code/opencode --session ses_1\n', + comm: '23487 opencode\n999 My App Helper\n' + }) + }) + expect(rows).toHaveLength(1) + expect(rows[0]).toMatchObject({ pid: 23487, executable: 'opencode' }) + expect(rows[0]?.argv).toEqual(['/opt/Open', 'Code/opencode', '--session', 'ses_1']) + }) + + it('degrades to argv[0] matching when the comm sweep fails', async () => { + const rows = await sweepProcessIdentities({ + platform: 'darwin', + nowMs: NOW, + run: psRunner({ + args: '23487 22618 00:05 opencode --session ses_1\n', + comm: new Error('comm unavailable') + }) + }) + expect(rows).toHaveLength(1) + expect(rows[0]).toMatchObject({ pid: 23487, executable: '' }) + }) + + it('reads the Windows table through the injected reader', async () => { + const rows = await sweepProcessIdentities({ + platform: 'win32', + readWindowsTable: async () => [ + { + pid: 23487, + ppid: 22618, + name: 'opencode.exe', + creationTimeMs: NOW - 60_000, + command: '"C:\\Program Files\\OpenCode\\opencode.exe"' + } + ] + }) + expect(rows).toHaveLength(1) + expect(rows[0]).toMatchObject({ pid: 23487, ppid: 22618, startedAtMs: NOW - 60_000 }) + }) +}) diff --git a/src/main/opencode/opencode-client-sweep.ts b/src/main/opencode/opencode-client-sweep.ts new file mode 100644 index 000000000000..bb37e504caaa --- /dev/null +++ b/src/main/opencode/opencode-client-sweep.ts @@ -0,0 +1,263 @@ +import { runProcess } from '../../shared/child-process/run-process' +import { + readWindowsProcessTable, + type WindowsProcessRow as NativeWindowsProcessRow +} from '../windows/windows-process-table' + +/** + * Host-wide sweep locating live OpenCode client processes for the + * session→pane binder (#21359). + * + * Why a dedicated sweep instead of reusing the memory collector's: that + * index carries pid/ppid/cpu/rss but no argv or start times, and importing + * the memory subsystem here would drag its Electron app-metrics dependency + * into the hook path. On Windows the table is read only through the native + * reader (`windows-process-table.ts`); on macOS/Linux through one `ps` call. + * The invocation pattern (5 s timeout, 10 MB cap, fail-open []) mirrors + * `windows-process-resource-collector.ts`. + */ + +/** One process identity row from a host sweep. */ +export type ProcessIdentityRow = { + pid: number + ppid: number + /** ms epoch the process started. */ + startedAtMs: number + /** + * Kernel-reported executable name (`comm=` on POSIX, `name` on Windows). + * Unlike `args=`, this is not a reconstructed string, so an install path + * containing spaces cannot split it. Empty when the sweep could not read it; + * classification then falls back to argv[0]. + */ + executable: string + /** argv approximation; see parsePsArgsLine. */ + argv: string[] +} + +const SWEEP_TIMEOUT_MS = 5_000 +const SWEEP_MAX_BYTES = 10 * 1024 * 1024 + +/** `[[dd-]hh:]mm:ss` → elapsed ms, or null when the shape is unknown. */ +export function parsePsElapsedToMs(etime: string, nowMs: number): number | null { + const match = etime.trim().match(/^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)$/) + if (!match) { + return null + } + const days = Number.parseInt(match[1] ?? '0', 10) + const hours = Number.parseInt(match[2] ?? '0', 10) + const minutes = Number.parseInt(match[3] ?? '0', 10) + const seconds = Number.parseInt(match[4] ?? '0', 10) + if ([days, hours, minutes, seconds].some((n) => !Number.isFinite(n) || n < 0)) { + return null + } + return nowMs - ((days * 24 + hours) * 3_600 + minutes * 60 + seconds) * 1000 +} + +/** + * Split a command line into argv, grouping `"..."` so a quoted executable + * path survives as argv[0]. Covers the shapes that matter here (a quoted + * install path plus plain flags); it is not a full shell parser — an escaped + * quote inside a quoted span still splits. Downstream only flag-adjacent + * values (`--session `) are read from this argv; classification uses the + * kernel executable name, because `ps` `args=` cannot preserve argv + * boundaries for unquoted paths. + */ +export function splitCommandLineArgv(commandLine: string): string[] { + const argv: string[] = [] + const pattern = /"([^"]*)"|(\S+)/g + let match: RegExpExecArray | null + while ((match = pattern.exec(commandLine)) !== null) { + argv.push(match[1] ?? match[2] ?? '') + } + return argv.filter((part) => part.length > 0) +} + +/** + * One `ps -eo pid=,ppid=,etime=,args=` line. `args` is a reconstructed + * command-and-arguments string: argv boundaries are lost, so a path with an + * unquoted space (e.g. `/opt/Open Code/opencode`) splits argv[0] in two. + * The executable name therefore comes from a separate `comm=` sweep; + * this parser records the flags it can still read reliably (`--session` + * values never contain spaces) and leaves `executable` empty for the join. + */ +export function parsePsArgsLine(line: string, nowMs: number): ProcessIdentityRow | null { + // Why a regex instead of split-with-limit: split discards everything past + // the limit, which would truncate argv to its first token. + const match = line.trim().match(/^(\S+)\s+(\S+)\s+(\S+)\s+([\s\S]*\S)\s*$/) + if (!match) { + return null + } + const [, pidText, ppidText, etimeText, argsText] = match + if (!pidText || !ppidText || !etimeText || !argsText) { + return null + } + const pid = Number.parseInt(pidText, 10) + const ppid = Number.parseInt(ppidText, 10) + const startedAtMs = parsePsElapsedToMs(etimeText, nowMs) + const argv = splitCommandLineArgv(argsText) + if ( + !Number.isFinite(pid) || + !Number.isFinite(ppid) || + startedAtMs === null || + argv.length === 0 + ) { + return null + } + return { pid, ppid, startedAtMs, executable: '', argv } +} + +/** + * One `ps -eo pid=,comm=` line. `comm` is the kernel's executable name as a + * trailing field, so it may itself contain spaces — everything past the pid + * is the name. Empty names are dropped; the join then falls back to argv[0]. + */ +export function parsePsCommLine(line: string): { pid: number; executable: string } | null { + const match = line.trim().match(/^(\S+)\s+([\s\S]*\S)\s*$/) + if (!match) { + return null + } + const [, pidText, executable] = match + const pid = Number.parseInt(pidText ?? '', 10) + if (!Number.isFinite(pid) || !executable) { + return null + } + return { pid, executable } +} + +/** + * One native Windows process-table row. Rows without a kernel creation time + * cannot bracket a session creation, so they are skipped rather than guessed. + */ +export function nativeWindowsRowToIdentity( + row: NativeWindowsProcessRow +): ProcessIdentityRow | null { + if (!Number.isFinite(row.pid) || !Number.isFinite(row.ppid)) { + return null + } + if (typeof row.creationTimeMs !== 'number' || !Number.isFinite(row.creationTimeMs)) { + return null + } + const argv = splitCommandLineArgv(row.command) + if (argv.length === 0) { + return null + } + return { + pid: row.pid, + ppid: row.ppid, + startedAtMs: row.creationTimeMs, + executable: row.name, + argv + } +} + +function executableBaseName(value: string): string { + const bare = value.split(/[\\/]/).at(-1) ?? '' + return bare.toLowerCase().replace(/\.exe$/, '') +} + +function argvZeroBase(argv: readonly string[]): string { + return executableBaseName(argv[0] ?? '') +} + +/** True for an OpenCode TUI/CLI client process (not the `serve` daemon). */ +export function isOpenCodeClientArgv(argv: readonly string[]): boolean { + return isOpenCodeClientProcess({ executable: '', argv }) +} + +/** + * True for an OpenCode TUI/CLI client process (not the `serve` daemon). + * The kernel-reported executable wins when present: `ps` `args=` cannot + * preserve argv boundaries, so a truncated argv[0] must not veto a matching + * executable. With no executable recorded this degrades to argv[0] matching. + */ +export function isOpenCodeClientProcess(row: { + executable: string + argv: readonly string[] +}): boolean { + const classified = + row.executable && row.executable.length > 0 + ? executableBaseName(row.executable) + : argvZeroBase(row.argv) + if (classified !== 'opencode') { + return false + } + // Why exclude: the shared server's posts are the ones being reattributed; + // mistaking the daemon for a pane client would bind sessions to its pane. + return !row.argv.some((part) => part === 'serve' || part === '--service') +} + +/** Every process identity row on this host; fail-open [] like the memory sweeps. */ +export async function sweepProcessIdentities( + deps: { + platform?: NodeJS.Platform + run?: typeof runProcess + nowMs?: number + readWindowsTable?: () => Promise + } = {} +): Promise { + const platform = deps.platform ?? process.platform + const run = deps.run ?? runProcess + const nowMs = deps.nowMs ?? Date.now() + try { + if (platform === 'win32') { + // Why the native table and nothing else: it is the only sanctioned + // Windows process-table reader (see windows-process-enumeration.md); + // forking powershell.exe for a whole-table CIM scan is exactly the + // pattern it retired. + const readTable = deps.readWindowsTable ?? readWindowsProcessTable + const rows = await readTable() + return rows + .map((row) => nativeWindowsRowToIdentity(row)) + .filter((row): row is ProcessIdentityRow => row !== null) + } + const stdout = await execFileText(run, 'ps', ['-eo', 'pid=,ppid=,etime=,args=']) + const rows = stdout + .split('\n') + .map((line) => parsePsArgsLine(line, nowMs)) + .filter((row): row is ProcessIdentityRow => row !== null) + // Why a second sweep: `args=` is one reconstructed string, so the + // executable name for classification comes from `comm=` instead. A + // failed comm sweep degrades to argv[0] matching rather than dropping + // the whole round. + try { + const commOut = await execFileText(run, 'ps', ['-eo', 'pid=,comm=']) + const executables = new Map() + for (const line of commOut.split('\n')) { + const parsed = parsePsCommLine(line) + if (parsed && !executables.has(parsed.pid)) { + executables.set(parsed.pid, parsed.executable) + } + } + for (const row of rows) { + const executable = executables.get(row.pid) + if (executable) { + row.executable = executable + } + } + } catch (err) { + console.warn('[opencode-binder] comm sweep failed; classifying from argv', err) + } + return rows + } catch (err) { + console.warn('[opencode-binder] process sweep failed; skipping round', err) + return [] + } +} + +/** Run one child process to text, throwing on timeout or nonzero exit. */ +async function execFileText( + run: typeof runProcess, + program: string, + args: string[] +): Promise { + const result = await run({ + program, + args, + timeoutMs: SWEEP_TIMEOUT_MS, + maxOutputBytes: SWEEP_MAX_BYTES + }) + if (result.timedOut || result.code !== 0) { + throw new Error(`${program} exited ${result.code ?? 'on timeout'}`) + } + return result.stdout +} diff --git a/src/main/opencode/opencode-session-binder.test.ts b/src/main/opencode/opencode-session-binder.test.ts new file mode 100644 index 000000000000..547ca9d01ba3 --- /dev/null +++ b/src/main/opencode/opencode-session-binder.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, it } from 'vitest' +import { createHookListenerState } from '../../shared/agent-hook-listener/listener-state' +import { lookupOpenCodeSessionPane } from '../../shared/agent-hook-listener/opencode-session-registry' +import { makePaneKey } from '../../shared/stable-pane-id' +import type { ProcessIdentityRow } from './opencode-client-sweep' +import { + advanceBinderCursor, + applyBinderOwnerships, + OPENCODE_SESSION_CURSOR_START, + runOpenCodeBinderRound, + type BinderPaneSnapshot +} from './opencode-session-binder' + +const NOW = 1_700_000_100_000 +const DIR = '/Users/jin/work/mocitec' +const PANE_A = makePaneKey('tab-a', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa') +const PANE_B = makePaneKey('tab-b', 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb') + +function proc( + pid: number, + ppid: number, + argv: string[], + startedAtMs = NOW - 120_000 +): ProcessIdentityRow { + return { pid, ppid, startedAtMs, executable: argv[0] ?? '', argv } +} + +function pane(paneKey: string, shellPid: number | null): BinderPaneSnapshot { + return { paneKey, directory: DIR, worktreeId: 'repo::/Users/jin/work/mocitec', shellPid } +} + +describe('runOpenCodeBinderRound', () => { + it('attributes a client to its pane subtree and binds the session', () => { + const { ownerships } = runOpenCodeBinderRound({ + nowMs: NOW, + sessions: [{ id: 'ses_1', directory: DIR, createdAtMs: NOW - 60_000, parentId: null }], + panes: [pane(PANE_A, 100), pane(PANE_B, 200)], + processes: [ + proc(100, 1, ['zsh']), + proc(200, 1, ['zsh']), + proc(210, 200, ['opencode'], NOW - 90_000) + ], + knownOwners: new Map(), + parentBySessionId: new Map() + }) + expect(ownerships).toEqual([ + { sessionId: 'ses_1', paneKey: PANE_B, basis: 'creation-correlation' } + ]) + }) + + it('ignores clients outside every pane subtree', () => { + const { ownerships } = runOpenCodeBinderRound({ + nowMs: NOW, + sessions: [{ id: 'ses_1', directory: DIR, createdAtMs: NOW - 60_000, parentId: null }], + panes: [pane(PANE_A, 100)], + processes: [proc(100, 1, ['zsh']), proc(999, 1, ['opencode'], NOW - 90_000)], + knownOwners: new Map(), + parentBySessionId: new Map() + }) + expect(ownerships).toEqual([]) + }) + + it('inherits a root owner across the watermark via the parent map', () => { + const { ownerships } = runOpenCodeBinderRound({ + nowMs: NOW, + sessions: [ + { id: 'ses_child', directory: DIR, createdAtMs: NOW - 30_000, parentId: 'ses_root' } + ], + panes: [pane(PANE_A, 100)], + processes: [proc(100, 1, ['zsh']), proc(101, 100, ['opencode'], NOW - 3_600_000)], + knownOwners: new Map([['ses_root', PANE_A]]), + parentBySessionId: new Map([['ses_child', 'ses_root']]) + }) + expect(ownerships).toEqual([ + { sessionId: 'ses_child', paneKey: PANE_A, basis: 'creation-correlation' } + ]) + }) + + it('dedupes same-key snapshots newest-wins', () => { + const { ownerships } = runOpenCodeBinderRound({ + nowMs: NOW, + sessions: [{ id: 'ses_1', directory: DIR, createdAtMs: NOW - 60_000, parentId: null }], + panes: [ + { ...pane(PANE_A, 100), directory: '/elsewhere' }, + { ...pane(PANE_A, 101), directory: DIR } + ], + processes: [ + proc(100, 1, ['zsh']), + proc(101, 1, ['zsh']), + proc(102, 101, ['opencode'], NOW - 90_000) + ], + knownOwners: new Map(), + parentBySessionId: new Map() + }) + expect(ownerships).toEqual([ + { sessionId: 'ses_1', paneKey: PANE_A, basis: 'single-pane-directory' } + ]) + }) + + it('advances the cursor past handled rows only', () => { + const fresh = [ + { id: 'ses_1', directory: DIR, createdAtMs: NOW - 60_000, parentId: null }, + { id: 'ses_2', directory: DIR, createdAtMs: NOW - 10_000, parentId: null } + ] + expect( + advanceBinderCursor({ + fresh, + isHandled: () => true, + current: OPENCODE_SESSION_CURSOR_START + }) + ).toEqual({ ms: NOW - 10_000, id: 'ses_2' }) + }) + + it('freezes the cursor before the first unhandled row so it is re-listed', () => { + const fresh = [ + { id: 'ses_1', directory: DIR, createdAtMs: NOW - 60_000, parentId: null }, + { id: 'ses_2', directory: DIR, createdAtMs: NOW - 10_000, parentId: null } + ] + expect( + advanceBinderCursor({ + fresh, + isHandled: (id) => id === 'ses_1', + current: OPENCODE_SESSION_CURSOR_START + }) + ).toEqual({ ms: NOW - 60_000, id: 'ses_1' }) + }) + + it('keeps the cursor when nothing was handled', () => { + const current = { ms: NOW - 120_000, id: 'ses_0' } + expect( + advanceBinderCursor({ + fresh: [{ id: 'ses_1', directory: DIR, createdAtMs: NOW - 60_000, parentId: null }], + isHandled: () => false, + current + }) + ).toBe(current) + }) +}) + +describe('applyBinderOwnerships', () => { + it('writes bindings with the pane worktree into the registry', () => { + const state = createHookListenerState() + const applied = applyBinderOwnerships( + state, + [pane(PANE_A, 100)], + [{ sessionId: 'ses_1', paneKey: PANE_A, basis: 'argv' }], + NOW + ) + expect(applied).toBe(1) + expect(lookupOpenCodeSessionPane(state, 'ses_1')).toMatchObject({ + paneKey: PANE_A, + worktreeId: 'repo::/Users/jin/work/mocitec' + }) + }) + + it('takes the newest row worktree when a pane remints', () => { + const state = createHookListenerState() + // Registry insertion order puts the stale row first; the live remint row + // carries a different worktree and must win, matching the round's + // newest-wins pane dedupe. + const applied = applyBinderOwnerships( + state, + [ + { paneKey: PANE_A, directory: '/elsewhere', worktreeId: 'repo::/elsewhere', shellPid: 100 }, + { ...pane(PANE_A, 101) } + ], + [{ sessionId: 'ses_1', paneKey: PANE_A, basis: 'argv' }], + NOW + ) + expect(applied).toBe(1) + expect(lookupOpenCodeSessionPane(state, 'ses_1')).toMatchObject({ + paneKey: PANE_A, + worktreeId: 'repo::/Users/jin/work/mocitec' + }) + }) +}) diff --git a/src/main/opencode/opencode-session-binder.ts b/src/main/opencode/opencode-session-binder.ts new file mode 100644 index 000000000000..0a693e6e928d --- /dev/null +++ b/src/main/opencode/opencode-session-binder.ts @@ -0,0 +1,334 @@ +import { resolveOpenCodeDataDirectory } from './opencode-data-directory' +import { + bindOpenCodeSession, + type OpenCodeSessionBinding +} from '../../shared/agent-hook-listener/opencode-session-registry' +import { + correlateOpenCodeSessionOwners, + type CorrelatedClient, + type CorrelatedPane, + type CorrelatedSession, + type SessionOwnership +} from '../../shared/agent-hook-listener/opencode-session-correlation' +import { readOpenCodeDatabase } from '../ai-vault/session-scanner-opencode-sqlite-open' +import { columnExists, tableExists } from '../opencode-usage/schema-helpers' +import { splitWorktreeIdForFilesystem } from '../../shared/worktree/id' +import { listRegisteredPtys } from '../memory/pty-registry' +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), 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 + * round needs is injected so the decision core stays unit-testable; only + * the SQLite reader below touches disk, reusing ai-vault's guarded open + * (read-only + query_only + busy timeout). + */ + +/** One pane snapshot feeding a binder round. */ +export type BinderPaneSnapshot = { + paneKey: string + /** Worktree root backing the pane (null when unknown); sessions beneath it are candidates. */ + directory: string | null + worktreeId: string | null + shellPid: number | null +} + +/** One session store row feeding a binder round. */ +export type BinderSessionRow = { + id: string + directory: string + createdAtMs: number + parentId: string | null +} + +/** Everything one binder round needs, injected for tests. */ +export type BinderRoundDeps = { + nowMs: number + sessions: readonly BinderSessionRow[] + panes: readonly BinderPaneSnapshot[] + processes: readonly ProcessIdentityRow[] + knownOwners: ReadonlyMap + parentBySessionId: ReadonlyMap +} + +/** Ownership decisions from one binder round. */ +export type BinderRoundResult = { + ownerships: SessionOwnership[] +} + +/** Position in the session store; composite so same-millisecond rows are never skipped. */ +export type OpenCodeSessionCursor = { + ms: number + id: string +} + +/** Cursor before anything was ever read. */ +export const OPENCODE_SESSION_CURSOR_START: OpenCodeSessionCursor = { ms: 0, id: '' } + +/** Order cursors the way the store lists rows: oldest first, id as tiebreak. */ +function compareSessionRows(left: OpenCodeSessionCursor, right: OpenCodeSessionCursor): number { + return left.ms - right.ms || (left.id < right.id ? -1 : left.id > right.id ? 1 : 0) +} + +/** + * Advance the store cursor past handled rows only. `fresh` arrives in store + * order; handling is prefix-closed (the unbound-cap break only ever skips the + * tail), so the first unhandled row freezes the cursor and every row at or + * past it is re-listed next round instead of silently dropped. + */ +export function advanceBinderCursor(args: { + fresh: readonly BinderSessionRow[] + isHandled: (sessionId: string) => boolean + current: OpenCodeSessionCursor +}): OpenCodeSessionCursor { + let cursor = args.current + for (const session of args.fresh) { + if (!args.isHandled(session.id)) { + break + } + const candidate: OpenCodeSessionCursor = { ms: session.createdAtMs, id: session.id } + if (compareSessionRows(candidate, cursor) > 0) { + cursor = candidate + } + } + return cursor +} + +/** pid→ppid index for one sweep; first row wins on duplicate pids. */ +function childrenIndex(processes: readonly ProcessIdentityRow[]): Map { + const ppidByPid = new Map() + for (const row of processes) { + if (!ppidByPid.has(row.pid)) { + ppidByPid.set(row.pid, row.ppid) + } + } + return ppidByPid +} + +/** Nearest pane shell at or above this pid; external terminals stay unattributed. */ +function owningPane( + ppidByPid: Map, + shellPidByPid: Map, + pid: number +): string | null { + const seen = new Set() + let current: number | undefined = pid + while (current !== undefined && !seen.has(current)) { + seen.add(current) + const owner = shellPidByPid.get(current) + if (owner) { + return owner + } + current = ppidByPid.get(current) + } + return null +} + +/** Attribute opencode client rows to panes via shell-subtree walks. */ +function toCorrelatedClients( + processes: readonly ProcessIdentityRow[], + panes: readonly BinderPaneSnapshot[], + nowMs: number +): CorrelatedClient[] { + const ppidByPid = childrenIndex(processes) + const shellPidByPid = new Map() + for (const pane of panes) { + if (pane.shellPid !== null && !shellPidByPid.has(pane.shellPid)) { + shellPidByPid.set(pane.shellPid, pane.paneKey) + } + } + const clients: CorrelatedClient[] = [] + for (const row of processes) { + if (!isOpenCodeClientProcess(row)) { + continue + } + const paneKey = owningPane(ppidByPid, shellPidByPid, row.pid) + if (!paneKey) { + continue + } + // Why lastSeenAlive = now: the sweep just observed it. Bracketing uses + // startedAt for the lower bound and this observation for the upper. + clients.push({ paneKey, startedAtMs: row.startedAtMs, lastSeenAliveMs: nowMs, argv: row.argv }) + } + return clients +} + +/** Pure round core: correlate unbound sessions against panes and clients. */ +export function runOpenCodeBinderRound(deps: BinderRoundDeps): BinderRoundResult { + // Why dedupe by key, newest wins: remints and reattachments can leave a + // stale registry row beside the live one; counting rows instead of panes + // would turn every same-pane tie into a false ambiguous and nothing would + // ever bind, while the oldest row would point the candidate set at a dead + // worktree. + const paneByKey = new Map() + for (const pane of deps.panes) { + paneByKey.set(pane.paneKey, { paneKey: pane.paneKey, directory: pane.directory }) + } + const panes = [...paneByKey.values()] + const clients = toCorrelatedClients(deps.processes, deps.panes, deps.nowMs) + const sessions: CorrelatedSession[] = deps.sessions.map((row) => ({ + id: row.id, + directory: row.directory, + createdAtMs: row.createdAtMs, + parentId: row.parentId + })) + // Why resolve inheritance here: the SQLite round only carries new rows, so + // an old root is invisible to the correlator; the binder's parent map walks + // the chain and the registry supplies the known root owner. Resolved heirs + // are emitted as binds (the registry lacks them) and fed back as known so + // the correlator skips what is already decided. + const knownOwners = new Map(deps.knownOwners) + const inherited: SessionOwnership[] = [] + for (const session of sessions) { + if (knownOwners.has(session.id) || !session.parentId) { + continue + } + let parent: string | null | undefined = session.parentId + const seen = new Set([session.id]) + while (parent && !seen.has(parent)) { + seen.add(parent) + const owner = knownOwners.get(parent) + if (owner) { + knownOwners.set(session.id, owner) + inherited.push({ sessionId: session.id, paneKey: owner, basis: 'creation-correlation' }) + break + } + parent = deps.parentBySessionId.get(parent) + } + } + const ownerships = [ + ...inherited, + ...correlateOpenCodeSessionOwners({ + sessions, + panes, + clients, + knownOwners + }) + ] + return { ownerships } +} + +/** + * Sessions newer than `cursor`, oldest first. The composite + * `(time_created, id)` position means rows sharing a millisecond with the + * cursor — including rows the LIMIT cut off last round — are re-listed + * instead of permanently skipped. Unknown shapes read as empty so an opencode + * schema move degrades to unbound sessions, never a crash. Fail-open [] on + * any read error for the same reason. + */ +export function listOpenCodeDbSessions( + dbPath: string, + cursor: OpenCodeSessionCursor +): BinderSessionRow[] { + try { + return readOpenCodeDatabase({ + dbPath, + read: (db) => { + // 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') || + !columnExists(db, table, 'time_created') + ) { + return [] + } + const parent = columnExists(db, table, 'parent_id') ? 'parent_id' : 'NULL' + const rows: unknown[] = db + .prepare( + `SELECT id, directory, time_created, ${parent} AS parent_id FROM ${table} WHERE time_created > ? OR (time_created = ? AND id > ?) ORDER BY time_created ASC, id ASC LIMIT 500` + ) + .all(cursor.ms, cursor.ms, cursor.id) + const sessions: BinderSessionRow[] = [] + for (const row of rows) { + if (typeof row !== 'object' || row === null) { + continue + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: node:sqlite returns plain row objects; the object check above plus the per-field validation below reject anything else. + const record = row as Record + if ( + typeof record.id !== 'string' || + typeof record.directory !== 'string' || + typeof record.time_created !== 'number' + ) { + continue + } + sessions.push({ + id: record.id, + directory: record.directory, + createdAtMs: record.time_created, + parentId: typeof record.parent_id === 'string' ? record.parent_id : null + }) + } + return sessions + } + }) + } catch (err) { + console.warn('[opencode-binder] session store read failed; skipping round', err) + return [] + } +} + +/** Default database path for the local shared server. */ +export function defaultOpenCodeDbPath(): string { + return `${resolveOpenCodeDataDirectory()}/opencode.db` +} + +/** + * Live local panes from the PTY registry: pane key, worktree root and shell + * pid. Panes without a key or pid (hydrated gaps, remote panes) cannot own a + * client subtree, so they are skipped — their sessions stay unbound rather + * than guessed. + */ +export function listBinderPaneSnapshots(): BinderPaneSnapshot[] { + const snapshots: BinderPaneSnapshot[] = [] + for (const pty of listRegisteredPtys()) { + if (!pty.paneKey || pty.pid === null) { + continue + } + const parsed = pty.worktreeId ? splitWorktreeIdForFilesystem(pty.worktreeId) : null + snapshots.push({ + paneKey: pty.paneKey, + directory: parsed?.worktreePath ?? null, + worktreeId: pty.worktreeId, + shellPid: pty.pid + }) + } + return snapshots +} + +/** Apply one round's decisions to the listener registry. */ +export function applyBinderOwnerships( + state: HookListenerState, + panes: readonly BinderPaneSnapshot[], + ownerships: readonly SessionOwnership[], + nowMs: number +): number { + const worktreeByPane = new Map() + for (const pane of panes) { + // Why overwrite: matching the round's newest-wins pane dedupe, so a + // remint's live row wins over a stale row with a different worktree. + worktreeByPane.set(pane.paneKey, pane.worktreeId) + } + let applied = 0 + for (const ownership of ownerships) { + const binding: OpenCodeSessionBinding = { + paneKey: ownership.paneKey, + boundAt: nowMs, + basis: ownership.basis + } + const worktreeId = worktreeByPane.get(ownership.paneKey) + if (worktreeId) { + binding.worktreeId = worktreeId + } + if (bindOpenCodeSession(state, ownership.sessionId, binding)) { + applied += 1 + } + } + return applied +} diff --git a/src/main/opencode/status-plugin-post-source.ts b/src/main/opencode/status-plugin-post-source.ts index 0afb78264684..0cca07e61a5c 100644 --- a/src/main/opencode/status-plugin-post-source.ts +++ b/src/main/opencode/status-plugin-post-source.ts @@ -16,6 +16,7 @@ export function getStatusPluginPostSource(hookPathname: string): string[] { ' worktreeId: process.env.ORCA_WORKTREE_ID || "",', ' env: coords.env,', ' version: coords.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 058ee2cdd076..f84b403a709c 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;', diff --git a/src/main/opencode2/status-plugin-setup-source.ts b/src/main/opencode2/status-plugin-setup-source.ts index 93d6840220db..ab7c1a11f86b 100644 --- a/src/main/opencode2/status-plugin-setup-source.ts +++ b/src/main/opencode2/status-plugin-setup-source.ts @@ -81,6 +81,7 @@ async function tuiReportsPaneLifecycle() { } async function setupOpenCode2Status(ctx) { + reportingOpenCodeMajor = 2; const noop = async () => {}; if (isOpenCode2TuiContext(ctx)) return setupOpenCode2Tui(ctx); let hooks; diff --git a/src/shared/agent-hook-listener-opencode-reattribution.test.ts b/src/shared/agent-hook-listener-opencode-reattribution.test.ts new file mode 100644 index 000000000000..a9ce55c51e84 --- /dev/null +++ b/src/shared/agent-hook-listener-opencode-reattribution.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from 'vitest' +import { normalizeHookPayload } from './agent-hook-listener' +import { createHookListenerState } from './agent-hook-listener/listener-state' +import { bindOpenCodeSession } from './agent-hook-listener/opencode-session-registry' +import { makePaneKey } from './stable-pane-id' + +import type { HookListenerState } from './agent-hook-listener/listener-state' + +const LEAF_A = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' +const LEAF_B = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' +const PANE_A = makePaneKey('tab-a', LEAF_A) +const PANE_B = makePaneKey('tab-b', LEAF_B) + +function opencodeBusy( + state: HookListenerState, + paneKey: string, + sessionId: string, + launchToken = '' +): ReturnType { + return normalizeHookPayload( + state, + 'opencode', + { paneKey, launchToken, payload: { hook_event_name: 'SessionBusy', sessionID: sessionId } }, + 'production' + ) +} + +describe('OpenCode 1 shared-server reattribution (#21359)', () => { + it('reattributes a bound session to its real pane', () => { + const state = createHookListenerState() + bindOpenCodeSession(state, 'ses_1', { + paneKey: PANE_B, + boundAt: 1, + basis: 'creation-correlation' + }) + const result = opencodeBusy(state, PANE_A, 'ses_1') + expect(result?.paneKey).toBe(PANE_B) + expect(result?.tabId).toBe('tab-b') + expect(result?.payload.state).toBe('working') + }) + + it('keeps the stamped pane for unbound sessions', () => { + const state = createHookListenerState() + const result = opencodeBusy(state, PANE_A, 'ses_unknown') + expect(result?.paneKey).toBe(PANE_A) + }) + + it('substitutes the bound pane live token so its fence passes', () => { + const state = createHookListenerState() + // A tokened post teaches the listener pane B's live token. + normalizeHookPayload( + state, + 'claude', + { paneKey: PANE_B, launchToken: 'token-b-live', payload: { hook_event_name: 'Stop' } }, + 'production' + ) + bindOpenCodeSession(state, 'ses_1', { + paneKey: PANE_B, + boundAt: 1, + basis: 'argv' + }) + const result = opencodeBusy(state, PANE_A, 'ses_1') + expect(result?.paneKey).toBe(PANE_B) + 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', { + paneKey: PANE_B, + boundAt: 1, + basis: 'argv' + }) + const result = normalizeHookPayload( + state, + 'claude', + { paneKey: PANE_A, payload: { hook_event_name: 'Stop', session_id: 'ses_1' } }, + 'production' + ) + expect(result?.paneKey).toBe(PANE_A) + }) + + it('never lets a stale same-pane stamp overwrite the live token', () => { + const state = createHookListenerState() + // A tokened post teaches the listener pane B's live token. + normalizeHookPayload( + state, + 'claude', + { paneKey: PANE_B, launchToken: 'token-b-live', payload: { hook_event_name: 'Stop' } }, + 'production' + ) + bindOpenCodeSession(state, 'ses_1', { + paneKey: PANE_B, + boundAt: 1, + basis: 'argv' + }) + // The shared server's frozen stamp carries a stale token for the same pane. + const result = opencodeBusy(state, PANE_B, 'ses_1', 'token-b-stale') + expect(result?.paneKey).toBe(PANE_B) + expect(result?.launchToken).toBe('token-b-live') + // And the stale stamp must not have poisoned the cache: a later lookup + // still returns the live token. + const again = opencodeBusy(state, PANE_B, 'ses_1', 'token-b-stale') + expect(again?.launchToken).toBe('token-b-live') + }) + + it('drops the stamped worktree when the binding has none', () => { + const state = createHookListenerState() + bindOpenCodeSession(state, 'ses_1', { + paneKey: PANE_B, + boundAt: 1, + basis: 'argv' + }) + const result = normalizeHookPayload( + state, + 'opencode', + { + paneKey: PANE_A, + worktreeId: 'repo::/stamped-worktree', + payload: { hook_event_name: 'SessionBusy', sessionID: 'ses_1' } + }, + 'production' + ) + expect(result?.paneKey).toBe(PANE_B) + expect(result?.worktreeId).toBeUndefined() + }) +}) diff --git a/src/shared/agent-hook-listener-opencode-session-correlation.test.ts b/src/shared/agent-hook-listener-opencode-session-correlation.test.ts new file mode 100644 index 000000000000..c0bc1d0ef37a --- /dev/null +++ b/src/shared/agent-hook-listener-opencode-session-correlation.test.ts @@ -0,0 +1,228 @@ +import { describe, expect, it } from 'vitest' +import { + correlateOpenCodeSessionOwners, + sessionIdFromArgv +} from './agent-hook-listener/opencode-session-correlation' + +const NOW = 1_000_000_000 +const DIR = '/Users/jin/work/mocitec' + +function session(id: string, createdAtMs: number, parentId: string | null = null) { + return { id, directory: DIR, createdAtMs, parentId } +} + +function client( + paneKey: string, + startedAtMs: number, + lastSeenAliveMs = NOW, + argv: readonly string[] = ['opencode'] +) { + return { paneKey, startedAtMs, lastSeenAliveMs, argv } +} + +describe('sessionIdFromArgv', () => { + it('reads --session forms', () => { + expect(sessionIdFromArgv(['opencode', '--session', 'ses_1'])).toBe('ses_1') + expect(sessionIdFromArgv(['opencode', '-s', 'ses_2'])).toBe('ses_2') + expect(sessionIdFromArgv(['opencode', '--session=ses_3'])).toBe('ses_3') + }) + + it('ignores bare launches and flag-like values', () => { + expect(sessionIdFromArgv(['opencode'])).toBeNull() + expect(sessionIdFromArgv(['opencode', '--session'])).toBeNull() + expect(sessionIdFromArgv(['opencode', '--session', '--port'])).toBeNull() + }) +}) + +describe('correlateOpenCodeSessionOwners', () => { + it('binds a lone pane in the directory', () => { + const results = correlateOpenCodeSessionOwners({ + sessions: [session('ses_1', NOW - 60_000)], + panes: [{ paneKey: 'pane-a', directory: DIR }], + clients: [client('pane-a', NOW - 120_000)], + knownOwners: new Map() + }) + expect(results).toEqual([ + { sessionId: 'ses_1', paneKey: 'pane-a', basis: 'single-pane-directory' } + ]) + }) + + it('contains sessions beneath the worktree root', () => { + const sub = correlateOpenCodeSessionOwners({ + sessions: [ + { + id: 'ses_sub', + directory: `${DIR}/packages/app`, + createdAtMs: NOW - 60_000, + parentId: null + } + ], + panes: [{ paneKey: 'pane-a', directory: DIR }], + clients: [client('pane-a', NOW - 120_000)], + knownOwners: new Map() + }) + expect(sub).toEqual([ + { sessionId: 'ses_sub', paneKey: 'pane-a', basis: 'single-pane-directory' } + ]) + }) + + it('strips the macOS /tmp alias so both spellings meet', () => { + const results = correlateOpenCodeSessionOwners({ + sessions: [ + { id: 'ses_1', directory: '/tmp/binder-e2e', createdAtMs: NOW - 60_000, parentId: null } + ], + panes: [{ paneKey: 'pane-a', directory: '/private/tmp/binder-e2e' }], + clients: [client('pane-a', NOW - 120_000)], + knownOwners: new Map() + }) + expect(results).toEqual([ + { sessionId: 'ses_1', paneKey: 'pane-a', basis: 'single-pane-directory' } + ]) + }) + + it('keeps genuinely distinct /private roots apart', () => { + const results = correlateOpenCodeSessionOwners({ + sessions: [ + { id: 'ses_1', directory: '/private/repo', createdAtMs: NOW - 60_000, parentId: null } + ], + panes: [{ paneKey: 'pane-a', directory: '/repo' }], + clients: [client('pane-a', NOW - 120_000)], + knownOwners: new Map() + }) + expect(results).toEqual([]) + }) + + it('folds Windows case differences', () => { + const results = correlateOpenCodeSessionOwners({ + sessions: [ + { id: 'ses_1', directory: 'c:\\users\\repo', createdAtMs: NOW - 60_000, parentId: null } + ], + panes: [{ paneKey: 'pane-a', directory: 'C:\\Users\\Repo' }], + clients: [client('pane-a', NOW - 120_000)], + knownOwners: new Map() + }) + expect(results).toEqual([ + { sessionId: 'ses_1', paneKey: 'pane-a', basis: 'single-pane-directory' } + ]) + }) + + it('keeps POSIX backslashes literal', () => { + const results = correlateOpenCodeSessionOwners({ + sessions: [ + { id: 'ses_1', directory: '/repo/a\\b', createdAtMs: NOW - 60_000, parentId: null } + ], + panes: [{ paneKey: 'pane-a', directory: '/repo/a/b' }], + clients: [client('pane-a', NOW - 120_000)], + knownOwners: new Map() + }) + expect(results).toEqual([]) + }) + + it('resolves dot segments before containment', () => { + const results = correlateOpenCodeSessionOwners({ + sessions: [ + { id: 'ses_1', directory: '/repo/../other', createdAtMs: NOW - 60_000, parentId: null } + ], + panes: [{ paneKey: 'pane-a', directory: '/repo' }], + clients: [client('pane-a', NOW - 120_000)], + knownOwners: new Map() + }) + expect(results).toEqual([]) + }) + + it('leaves a session unbound when no client brackets it', () => { + const results = correlateOpenCodeSessionOwners({ + sessions: [session('ses_1', NOW - 60_000)], + panes: [{ paneKey: 'pane-a', directory: DIR }], + clients: [], + knownOwners: new Map() + }) + expect(results).toEqual([]) + }) + + it('breaks a same-directory tie by client evidence', () => { + const results = correlateOpenCodeSessionOwners({ + sessions: [session('ses_1', NOW - 60_000)], + panes: [ + { paneKey: 'pane-a', directory: DIR }, + { paneKey: 'pane-b', directory: DIR } + ], + clients: [client('pane-b', NOW - 120_000)], + knownOwners: new Map() + }) + expect(results).toEqual([ + { sessionId: 'ses_1', paneKey: 'pane-b', basis: 'creation-correlation' } + ]) + }) + + it('stays unbound when both same-directory panes evidence a client', () => { + const results = correlateOpenCodeSessionOwners({ + sessions: [session('ses_1', NOW - 60_000)], + panes: [ + { paneKey: 'pane-a', directory: DIR }, + { paneKey: 'pane-b', directory: DIR } + ], + clients: [client('pane-a', NOW - 120_000), client('pane-b', NOW - 90_000)], + knownOwners: new Map() + }) + expect(results).toEqual([]) + }) + + it('rejects a client that started after creation beyond skew', () => { + const results = correlateOpenCodeSessionOwners({ + sessions: [session('ses_1', NOW - 3_600_000)], + panes: [{ paneKey: 'pane-a', directory: DIR }], + clients: [client('pane-a', NOW - 60_000, NOW)], + knownOwners: new Map() + }) + expect(results).toEqual([]) + }) + + it('accepts a long-lived client that brackets creation', () => { + const results = correlateOpenCodeSessionOwners({ + sessions: [session('ses_1', NOW - 60_000)], + panes: [{ paneKey: 'pane-a', directory: DIR }], + clients: [client('pane-a', NOW - 86_400_000)], + knownOwners: new Map() + }) + expect(results).toHaveLength(1) + }) + + it('binds by argv even in a crowded directory', () => { + const results = correlateOpenCodeSessionOwners({ + sessions: [session('ses_9', NOW - 60_000)], + panes: [ + { paneKey: 'pane-a', directory: DIR }, + { paneKey: 'pane-b', directory: DIR } + ], + clients: [ + client('pane-a', NOW - 120_000), + client('pane-b', NOW - 110_000, NOW, ['opencode', '--session', 'ses_9']) + ], + knownOwners: new Map() + }) + expect(results).toEqual([{ sessionId: 'ses_9', paneKey: 'pane-b', basis: 'argv' }]) + }) + + it('a child session inherits its bound root', () => { + const results = correlateOpenCodeSessionOwners({ + sessions: [session('ses_child', NOW - 30_000, 'ses_root')], + panes: [{ paneKey: 'pane-a', directory: DIR }], + clients: [client('pane-a', NOW - 120_000)], + knownOwners: new Map([['ses_root', 'pane-a']]) + }) + expect(results).toEqual([ + { sessionId: 'ses_child', paneKey: 'pane-a', basis: 'creation-correlation' } + ]) + }) + + it('skips already-known sessions', () => { + const results = correlateOpenCodeSessionOwners({ + sessions: [session('ses_1', NOW - 60_000)], + panes: [{ paneKey: 'pane-a', directory: DIR }], + clients: [client('pane-a', NOW - 120_000)], + knownOwners: new Map([['ses_1', 'pane-a']]) + }) + expect(results).toEqual([]) + }) +}) diff --git a/src/shared/agent-hook-listener-opencode-session-registry.test.ts b/src/shared/agent-hook-listener-opencode-session-registry.test.ts new file mode 100644 index 000000000000..c5d4874a4231 --- /dev/null +++ b/src/shared/agent-hook-listener-opencode-session-registry.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest' +import { createHookListenerState } from './agent-hook-listener/listener-state' +import { + bindOpenCodeSession, + lookupOpenCodePaneLaunchToken, + lookupOpenCodeSessionPane, + moveOpenCodeSessionBindings, + OPENCODE_SESSION_BINDINGS_MAX, + trackOpenCodePaneLaunchToken, + unbindOpenCodeSessionsOfPane +} from './agent-hook-listener/opencode-session-registry' +import { makePaneKey } from './stable-pane-id' + +const LEAF_A = '11111111-1111-4111-8111-111111111111' +const LEAF_B = '22222222-2222-4222-8222-222222222222' +const PANE_A = makePaneKey('tab-a', LEAF_A) +const PANE_B = makePaneKey('tab-b', LEAF_B) + +describe('opencode session registry', () => { + it('binds and looks up a session owner', () => { + const state = createHookListenerState() + expect( + bindOpenCodeSession(state, 'ses_1', { paneKey: PANE_A, boundAt: 1, basis: 'argv' }) + ).toBe(true) + expect(lookupOpenCodeSessionPane(state, 'ses_1')?.paneKey).toBe(PANE_A) + expect(lookupOpenCodeSessionPane(state, 'ses_unknown')).toBeUndefined() + }) + + it('refuses blank ids and malformed pane keys', () => { + const state = createHookListenerState() + expect(bindOpenCodeSession(state, ' ', { paneKey: PANE_A, boundAt: 1, basis: 'argv' })).toBe( + false + ) + expect( + bindOpenCodeSession(state, 'ses_1', { paneKey: 'not-a-pane', boundAt: 1, basis: 'argv' }) + ).toBe(false) + expect(state.opencodeSessionPaneBySessionId.size).toBe(0) + }) + + it('rebinds a session to a new owner and refreshes eviction order', () => { + const state = createHookListenerState() + bindOpenCodeSession(state, 'ses_1', { paneKey: PANE_A, boundAt: 1, basis: 'argv' }) + bindOpenCodeSession(state, 'ses_1', { + paneKey: PANE_B, + boundAt: 2, + basis: 'creation-correlation' + }) + expect(lookupOpenCodeSessionPane(state, 'ses_1')?.paneKey).toBe(PANE_B) + expect(lookupOpenCodeSessionPane(state, 'ses_1')?.basis).toBe('creation-correlation') + }) + + it('evicts oldest-bound first once capped', () => { + const state = createHookListenerState() + for (let i = 0; i < OPENCODE_SESSION_BINDINGS_MAX + 5; i += 1) { + bindOpenCodeSession(state, `ses_${i}`, { paneKey: PANE_A, boundAt: i, basis: 'argv' }) + } + expect(state.opencodeSessionPaneBySessionId.size).toBe(OPENCODE_SESSION_BINDINGS_MAX) + expect(lookupOpenCodeSessionPane(state, 'ses_0')).toBeUndefined() + expect( + lookupOpenCodeSessionPane(state, `ses_${OPENCODE_SESSION_BINDINGS_MAX + 4}`) + ).toBeDefined() + }) + + it('unbinds every session of a closed pane', () => { + const state = createHookListenerState() + bindOpenCodeSession(state, 'ses_1', { paneKey: PANE_A, boundAt: 1, basis: 'argv' }) + bindOpenCodeSession(state, 'ses_2', { paneKey: PANE_A, boundAt: 2, basis: 'argv' }) + bindOpenCodeSession(state, 'ses_3', { paneKey: PANE_B, boundAt: 3, basis: 'argv' }) + expect(unbindOpenCodeSessionsOfPane(state, PANE_A)).toBe(2) + expect(lookupOpenCodeSessionPane(state, 'ses_1')).toBeUndefined() + expect(lookupOpenCodeSessionPane(state, 'ses_3')?.paneKey).toBe(PANE_B) + }) + + it('moves bindings with a pane', () => { + const state = createHookListenerState() + bindOpenCodeSession(state, 'ses_1', { paneKey: PANE_A, boundAt: 1, basis: 'argv' }) + moveOpenCodeSessionBindings(state, PANE_A, PANE_B) + expect(lookupOpenCodeSessionPane(state, 'ses_1')?.paneKey).toBe(PANE_B) + }) + + it('tracks last-seen launch tokens per pane', () => { + const state = createHookListenerState() + trackOpenCodePaneLaunchToken(state, PANE_A, ' ') + expect(lookupOpenCodePaneLaunchToken(state, PANE_A)).toBeUndefined() + trackOpenCodePaneLaunchToken(state, PANE_A, 'token-1') + trackOpenCodePaneLaunchToken(state, PANE_A, 'token-2') + expect(lookupOpenCodePaneLaunchToken(state, PANE_A)).toBe('token-2') + }) +}) diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index 7df46e74cbc0..480b6d551398 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -18,6 +18,10 @@ import { extractPromptText } from './agent-hook-listener/prompt-fields' import { normalizeProviderEvent } from './agent-hook-listener/provider-dispatch' import { hasExplicitUserPrompt } from './agent-hook-listener/provider-event-routing' import { hasExplicitAmpPrompt } from './agent-hook-listener/providers/amp-events' +import { + resolveOpenCodeSharedServerEnvelope, + trackOpenCodePaneLaunchToken +} from './agent-hook-listener/opencode-session-registry' import { readString } from './agent-hook-listener/tool-input-preview' /** Canonical transport-agnostic normalization entry shared by main and relay listeners. */ export function normalizeHookPayload( @@ -31,9 +35,16 @@ export function normalizeHookPayload( if (!envelope) { return null } - const { record, paneKey, hookPayloadRecord, tabId, worktreeId, launchToken } = envelope + const { + record, + paneKey: stampedPaneKey, + hookPayloadRecord, + tabId: stampedTabId, + worktreeId: stampedWorktreeId, + launchToken: stampedLaunchToken + } = envelope if (source === 'claude') { - state.claudeUnconfirmedRestoredStatusPaneKeys.delete(paneKey) + state.claudeUnconfirmedRestoredStatusPaneKeys.delete(stampedPaneKey) } const eventName = readFirstString(record, ['hook_event_name', 'hookEventName', 'hook_type', 'hookType']) ?? @@ -44,6 +55,26 @@ export function normalizeHookPayload( source === 'codex' && readString(hookPayloadRecord, 'agent_id') ? null : extractAgentProviderSession(source, hookPayloadRecord) + // 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. + const { paneKey, tabId, worktreeId, launchToken } = resolveOpenCodeSharedServerEnvelope({ + state, + source, + stamped: { + paneKey: stampedPaneKey, + tabId: stampedTabId, + worktreeId: stampedWorktreeId, + launchToken: stampedLaunchToken + }, + 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 + // carries the stored token (or nothing) for bound sessions instead. + trackOpenCodePaneLaunchToken(state, paneKey, launchToken) const providerPromptId = source === 'claude' ? normalizeClaudePromptId(hookPayloadRecord.prompt_id) diff --git a/src/shared/agent-hook-listener/listener-state.ts b/src/shared/agent-hook-listener/listener-state.ts index 128502fdece5..53848ca707a8 100644 --- a/src/shared/agent-hook-listener/listener-state.ts +++ b/src/shared/agent-hook-listener/listener-state.ts @@ -15,6 +15,11 @@ import type { CodexSubagentRoster } from '../codex-subagent-roster' import type { CodexSubagentTranscriptState } from '../codex-subagent-transcript' import type { MuseSessionLogState } from '../muse-session-log' import type { AgentHookEventPayload, ToolSnapshot } from './listener-event' +import { + moveOpenCodeSessionBindings, + unbindOpenCodeSessionsOfPane, + type OpenCodeSessionBinding +} from './opencode-session-registry' /** Per-listener-instance caches needing per-PTY teardown; Orca's main process and the relay each get their own, never shared. */ export type HookListenerState = { @@ -54,6 +59,15 @@ export type HookListenerState = { grokMainAgentStatusByPaneKey: Map /** Muse child-session filter and session-log cursor per pane. */ musePaneStateByPaneKey: Map + /** + * OpenCode session id -> owning pane, observed from the client side. The + * shared v2 server stamps every post with its own frozen pane, so ingest + * reattributes bound sessions before disposition. Not a state claim itself — + * it names no row — so paneHasStateClaims ignores it. + */ + opencodeSessionPaneBySessionId: Map + /** Last launch token seen per pane; a rewritten shared-server post needs the bound pane's live token to pass its fence. */ + lastLaunchTokenByPaneKey: Map } export type MusePaneState = { @@ -103,7 +117,9 @@ export function createHookListenerState( codexLeadStateByPaneKey: new Map(), grokActiveTurnByPaneKey: new Map(), grokMainAgentStatusByPaneKey: new Map(), - musePaneStateByPaneKey: new Map() + musePaneStateByPaneKey: new Map(), + opencodeSessionPaneBySessionId: new Map(), + lastLaunchTokenByPaneKey: new Map() } legacyStatusAdapterByState.set(state, adapter) return state @@ -192,6 +208,8 @@ export function clearPaneCacheState(state: HookListenerState, paneKey: string): state.grokActiveTurnByPaneKey.delete(paneKey) state.grokMainAgentStatusByPaneKey.delete(paneKey) state.musePaneStateByPaneKey.delete(paneKey) + unbindOpenCodeSessionsOfPane(state, paneKey) + deletePaneScopedCacheEntry(state.lastLaunchTokenByPaneKey, paneKey) } /** Does this pane still hold anything that can ASSERT a state — a stored row, or a Claude latch that @@ -268,6 +286,8 @@ export function movePaneCacheState( movePaneScopedMapEntries(state.grokActiveTurnByPaneKey, fromPaneKey, toPaneKey) movePaneScopedMapEntries(state.grokMainAgentStatusByPaneKey, fromPaneKey, toPaneKey) movePaneScopedMapEntries(state.musePaneStateByPaneKey, fromPaneKey, toPaneKey) + moveOpenCodeSessionBindings(state, fromPaneKey, toPaneKey) + movePaneScopedMapEntries(state.lastLaunchTokenByPaneKey, fromPaneKey, toPaneKey) } export function clearPaneTurnCacheState(state: HookListenerState, paneKey: string): void { @@ -319,4 +339,6 @@ export function clearAllListenerCaches(state: HookListenerState): void { state.codexLeadStateByPaneKey.clear() state.grokActiveTurnByPaneKey.clear() state.grokMainAgentStatusByPaneKey.clear() + state.opencodeSessionPaneBySessionId.clear() + state.lastLaunchTokenByPaneKey.clear() } diff --git a/src/shared/agent-hook-listener/opencode-session-correlation.ts b/src/shared/agent-hook-listener/opencode-session-correlation.ts new file mode 100644 index 000000000000..069769ab07bc --- /dev/null +++ b/src/shared/agent-hook-listener/opencode-session-correlation.ts @@ -0,0 +1,252 @@ +/** + * Pure session→pane correlation for the shared OpenCode server (#21359). + * + * The server stamps every post with its own frozen pane, so the binder must + * decide ownership from client-side evidence: which panes sit in the + * session's directory, and which of those panes runs an OpenCode client that + * could have created it. No platform APIs here — the main-process binder + * supplies sessions (SQLite), panes (PTY registry + CWD) and clients + * (argv-aware process sweep); this module only decides. + */ + +/** One session row as the binder sees it. */ +export type CorrelatedSession = { + id: string + directory: string + /** ms epoch from the session store. */ + createdAtMs: number + parentId: string | null +} + +/** One pane as the binder sees it. */ +export type CorrelatedPane = { + paneKey: string + /** + * Worktree root backing the pane (from the PTY registry), or null when + * unknown. A session belongs to a pane's candidate set when its directory + * is the root or beneath it; same-worktree panes tie and the client sweep + * breaks the tie. + */ + directory: string | null +} + +/** One live client process as the binder sees it. */ +export type CorrelatedClient = { + /** Pane whose subtree holds this client. */ + paneKey: string + /** ms epoch the client process started. */ + startedAtMs: number + /** ms epoch the client was last observed alive. */ + lastSeenAliveMs: number + /** Full argv; a `--session ` hit binds deterministically. */ + argv: readonly string[] +} + +/** One decided session owner for this round. */ +export type SessionOwnership = { + sessionId: string + paneKey: string + basis: 'argv' | 'creation-correlation' | 'single-pane-directory' +} + +/** Allow for stamp skew between the process table and the session store. */ +export const OPENCODE_CREATE_SKEW_MS = 2 * 60 * 1000 + +import { normalizeRuntimePathForComparison } from '../cross-platform-path' + +/** + * macOS symlinks /tmp, /var and /etc into /private; opencode records + * whichever spelling the process saw, so fold the alias to the real root + * before comparing. Narrow on purpose: a blanket `/private` strip would merge + * genuinely distinct POSIX roots (`/private/repo` vs `/repo`). + */ +function foldMacOsPrivateAlias(directory: string): string { + for (const name of ['tmp', 'var', 'etc']) { + if (directory === `/${name}`) { + return `/private/${name}` + } + if (directory.startsWith(`/${name}/`)) { + return `/private/${directory.slice(1)}` + } + } + return directory +} + +/** Lexically resolve `.` and `..` so prefix containment cannot be fooled by dot segments. */ +function resolveDotSegments(normalized: string): string { + const isAbsolute = normalized.startsWith('/') + const parts: string[] = [] + for (const part of normalized.split('/')) { + if (part === '' || part === '.') { + continue + } + if (part === '..') { + if (parts.length > 0 && parts.at(-1) !== '..') { + parts.pop() + continue + } + if (!isAbsolute) { + parts.push(part) + } + continue + } + parts.push(part) + } + const joined = parts.join('/') + if (isAbsolute) { + return `/${joined}` + } + return joined === '' ? '.' : joined +} + +/** + * Comparison key for session/pane directories. NFC + Windows-only backslash + * folding and case folding come from the shared helper (a backslash stays a + * literal filename character on POSIX); dot segments resolve lexically. + */ +function normalizeDir(directory: string): string { + return resolveDotSegments( + normalizeRuntimePathForComparison(foldMacOsPrivateAlias(directory.trim())) + ) +} + +/** `--session `, `-s `, `--session=` or a trailing attach target. */ +export function sessionIdFromArgv(argv: readonly string[]): string | null { + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i] + if (arg === '--session' || arg === '-s') { + const next = argv[i + 1] + if (next && !next.startsWith('-')) { + return next + } + } else if (arg.startsWith('--session=')) { + const value = arg.slice('--session='.length) + if (value) { + return value + } + } + } + return null +} + +function clientCouldCreate(client: CorrelatedClient, createdAtMs: number): boolean { + // Why lifetime-overlap instead of a start window: a TUI opened days ago + // creates today's session from the same process. Started-before plus + // seen-alive-after brackets the creation; a start window alone would miss + // every long-lived client. + return ( + client.startedAtMs <= createdAtMs + OPENCODE_CREATE_SKEW_MS && + client.lastSeenAliveMs >= createdAtMs + ) +} + +/** + * Decide owners for sessions the registry has not bound yet. Child sessions + * inherit their root's owner (they roll up to the same pane); argv hits bind + * immediately; otherwise exactly one pane must have both the directory and a + * client that brackets the creation. Anything else stays unbound — today's + * behavior — rather than guessing. + */ +export function correlateOpenCodeSessionOwners(args: { + sessions: readonly CorrelatedSession[] + panes: readonly CorrelatedPane[] + clients: readonly CorrelatedClient[] + /** Owners already known (registry + earlier binds this round). */ + knownOwners: ReadonlyMap +}): SessionOwnership[] { + const { sessions, panes, clients, knownOwners } = args + const owners = new Map(knownOwners) + const results: SessionOwnership[] = [] + + /** Record one ownership decision, visible to later sessions in this round. */ + const claim = (sessionId: string, paneKey: string, basis: SessionOwnership['basis']): void => { + owners.set(sessionId, paneKey) + results.push({ sessionId, paneKey, basis }) + } + + // Why argv first: a command line names its session outright, so it outranks + // every heuristic even when the directory is crowded. + for (const client of clients) { + const named = sessionIdFromArgv(client.argv) + if (named && !owners.has(named)) { + claim(named, client.paneKey, 'argv') + } + } + + const panesByDirectory = new Map() + for (const pane of panes) { + if (!pane.directory) { + continue + } + const key = normalizeDir(pane.directory) + const list = panesByDirectory.get(key) + if (list) { + list.push(pane) + } else { + panesByDirectory.set(key, [pane]) + } + } + + /** Panes whose worktree root contains this directory (exact or beneath). */ + function containingPanes(directory: string): CorrelatedPane[] { + const target = normalizeDir(directory) + const found: CorrelatedPane[] = [] + for (const [root, list] of panesByDirectory) { + if (target === root || target.startsWith(`${root}/`)) { + found.push(...list) + } + } + return found + } + + /** Walk the parent chain for an already-known root owner. */ + const rootOwner = (session: CorrelatedSession): string | undefined => { + let current: CorrelatedSession | undefined = session + const seen = new Set() + while (current?.parentId && !seen.has(current.id)) { + seen.add(current.id) + const owner = owners.get(current.parentId) + if (owner) { + return owner + } + current = sessions.find((s) => s.id === current?.parentId) + } + return current && current !== session ? owners.get(current.id) : undefined + } + + for (const session of sessions) { + if (owners.has(session.id)) { + continue + } + const inherited = session.parentId + ? (rootOwner(session) ?? owners.get(session.parentId)) + : undefined + if (inherited) { + claim(session.id, inherited, 'creation-correlation') + continue + } + const candidates = containingPanes(session.directory) + if (candidates.length === 0) { + continue + } + const evidencing = candidates.filter((pane) => + clients.some( + (client) => + client.paneKey === pane.paneKey && clientCouldCreate(client, session.createdAtMs) + ) + ) + const [only] = evidencing + if (evidencing.length !== 1 || !only) { + continue + } + // Why the basis split: a lone containing pane needs no client evidence + // to be unambiguous; a shared worktree always resolves through it. + claim( + session.id, + only.paneKey, + candidates.length === 1 ? 'single-pane-directory' : 'creation-correlation' + ) + } + + return results +} diff --git a/src/shared/agent-hook-listener/opencode-session-registry.ts b/src/shared/agent-hook-listener/opencode-session-registry.ts new file mode 100644 index 000000000000..8fade351510d --- /dev/null +++ b/src/shared/agent-hook-listener/opencode-session-registry.ts @@ -0,0 +1,202 @@ +import { parsePaneKey } from '../stable-pane-id' +import type { AgentHookSource } from '../agent-hook-relay' +import type { HookListenerState } from './listener-state' + +/** + * Which pane owns one OpenCode session, as observed from the client side. + * + * 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 + * at ingest to reattribute posts before disposition. + */ +export type OpenCodeSessionBinding = { + paneKey: string + worktreeId?: string + /** ms epoch of the bind; oldest-bound evicts first once capped. */ + boundAt: number + /** How the binder learned this owner. */ + basis: 'argv' | 'creation-correlation' | 'single-pane-directory' +} + +/** Upper bound; sessions are cheap rows but the map must not grow forever. */ +export const OPENCODE_SESSION_BINDINGS_MAX = 1000 + +/** Per-pane launch-token cache; keyed differently from bindings but shares the same bound. */ +export const OPENCODE_PANE_LAUNCH_TOKENS_MAX = 1000 + +/** Per-listener session→pane map; the binder writes, ingest reads. */ +function bindings(state: HookListenerState): Map { + return state.opencodeSessionPaneBySessionId +} + +/** + * Record a session owner. Ignores blank ids and malformed pane keys so a + * corrupt binder observation can never poison ingest. + */ +export function bindOpenCodeSession( + state: HookListenerState, + sessionId: string, + binding: OpenCodeSessionBinding +): boolean { + const id = sessionId.trim() + if (!id || parsePaneKey(binding.paneKey) === null) { + return false + } + const map = bindings(state) + // Why delete-then-set: Map evicts in insertion order, so a refreshed bind + // must move to the back or a hot session would evict as if it were the + // oldest one. + map.delete(id) + map.set(id, binding) + while (map.size > OPENCODE_SESSION_BINDINGS_MAX) { + const oldest = map.keys().next().value + if (oldest === undefined) { + break + } + map.delete(oldest) + } + return true +} + +/** Pane that owns this session, if the binder has seen it. */ +export function lookupOpenCodeSessionPane( + state: HookListenerState, + sessionId: string +): OpenCodeSessionBinding | undefined { + return bindings(state).get(sessionId.trim()) +} + +/** + * Drop every binding owned by a pane: teardown, reuse and close must not let + * a dead pane keep claiming a live session's dots. + * @returns Number of bindings removed. + */ +export function unbindOpenCodeSessionsOfPane(state: HookListenerState, paneKey: string): number { + let removed = 0 + for (const [sessionId, binding] of bindings(state)) { + // Why exact match only: bindings store validated `tabId:uuid` pane keys, + // which cannot carry the `\0` subscopes the hierarchical pane caches use. + if (binding.paneKey === paneKey) { + bindings(state).delete(sessionId) + removed += 1 + } + } + return removed +} + +/** Rewrite bindings when a pane moves (e.g. detached into another tab). */ +export function moveOpenCodeSessionBindings( + state: HookListenerState, + fromPaneKey: string, + toPaneKey: string +): void { + if (fromPaneKey === toPaneKey || parsePaneKey(toPaneKey) === null) { + return + } + for (const binding of bindings(state).values()) { + if (binding.paneKey === fromPaneKey) { + binding.paneKey = toPaneKey + } + } +} + +/** + * Last launch token seen per pane, from any source's envelope. The shared + * server's posts carry a frozen (usually empty) token, so a rewritten post + * for a fenced pane would be suppressed without the bound pane's live token. + * Updated on every tokened post; the fence comparison hashes it the same way. + */ +export function trackOpenCodePaneLaunchToken( + state: HookListenerState, + paneKey: string, + launchToken: string | undefined +): void { + const token = launchToken?.trim() + if (!token) { + return + } + const map = state.lastLaunchTokenByPaneKey + map.delete(paneKey) + map.set(paneKey, token) + while (map.size > OPENCODE_PANE_LAUNCH_TOKENS_MAX) { + const oldest = map.keys().next().value + if (oldest === undefined) { + break + } + map.delete(oldest) + } +} + +/** Live token for a pane, if any tokened post has arrived since startup. */ +export function lookupOpenCodePaneLaunchToken( + state: HookListenerState, + paneKey: string +): string | undefined { + 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 + tabId?: string + worktreeId?: string + launchToken?: string +} + +/** + * Reattribute one shared-server post to the bound pane (#21359). Reads + * nothing but the registry: unbound sessions and other sources pass through + * untouched, so this is a no-op everywhere the binder has said nothing. A + * bound session always takes the stored pane token — which may be absent, + * in which case the post carries no token rather than the frozen stamp. + */ +export function resolveOpenCodeSharedServerEnvelope(args: { + state: HookListenerState + source: AgentHookSource + stamped: OpenCodeStampedEnvelope + sessionId: string | undefined + /** The raw post; see isOpenCodeSharedServerPost. */ + body: unknown +}): OpenCodeStampedEnvelope { + const { state, source, stamped, sessionId } = args + if (!isOpenCodeSharedServerPost(source, args.body) || !sessionId) { + return stamped + } + const binding = lookupOpenCodeSessionPane(state, sessionId) + if (!binding) { + return stamped + } + return { + paneKey: binding.paneKey, + // Why derive: the envelope rejects a tabId that disagrees with the pane + // key, so the substituted tab must come from the substituted pane. + tabId: parsePaneKey(binding.paneKey)?.tabId ?? stamped.tabId, + // Why no stamped fallback: a binding without a worktree (its pane row + // carried none) combined with the stamped pane's worktree would file the + // row under the wrong worktree. Absent is honest; wrong is not. + worktreeId: binding.worktreeId, + // Why the stored token or nothing: the frozen stamp carries the + // server-starter's (usually empty) token, which a fenced pane would + // suppress — and recording that stale token as live would poison the + // cache for the pane's real posts. The session is the authority here, + // not the posting process. + launchToken: lookupOpenCodePaneLaunchToken(state, binding.paneKey) + } +} From 38903a9434b175ae4698142c859232b63069d5ad Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:32:23 -0700 Subject: [PATCH 11/25] fix(opencode): show OpenCode 2 `opencode run` as Working, then Done, on its own pane OpenCode 2's `opencode run` loads no plugin, and the shared service that runs its session cannot tell which pane it belongs to, so a pane running it showed nothing. The runtime now reports it from the pane's own process lifetime. On the pane's OSC 133 command start (main already parses 133 for every local PTY; the start callback was never wired), after the pane tracker's 350 ms settle it reads the foreground process name through the existing foreground reader, and only when that name is OpenCode, the foreground command line from one fresh shell-foreground process-table capture. An `opencode run` posts Working through the existing terminal-status path into the hook server's store; the pane's 133;D (or the daemon's background fact) posts Done, marked interrupted on Ctrl-C. No polling. Exclusive with plugin reporting: each write carries the command's start time, and the store drops it once a hook has written the pane since then, so an OpenCode 1 `run` (in-process plugin) or a server plugin without the TUI copy owns its command alone and there is never a second Done. Local macOS and Linux panes only; SSH, WSL and Windows panes stay silent because their foreground cannot be read on this host. --- .../server-process-lifetime-status.test.ts | 89 ++++++++++ .../server/server-ingest-terminal.ts | 22 ++- .../agent-foreground-command-line.test.ts | 72 ++++++++ .../providers/agent-foreground-process.ts | 41 ++++- .../local-pty-foreground-command-line.ts | 8 + .../opencode-run-lifetime-runtime.test.ts | 103 +++++++++++ .../opencode-run-lifetime-status.test.ts | 167 ++++++++++++++++++ .../runtime/opencode-run-lifetime-status.ts | 119 +++++++++++++ .../orca-runtime-apply-tracked-pty-title.ts | 1 + ...minal-side-effect-command-code-detector.ts | 9 +- ...-runtime-emit-daemon-pty-transient-fact.ts | 1 + ...e-get-unpersisted-tracked-title-for-pty.ts | 5 + .../orca-runtime-stop-requested-pty-ids.ts | 22 +++ .../runtime/runtime-terminal-contracts.ts | 3 + src/shared/opencode-headless-command.test.ts | 26 +++ src/shared/opencode-headless-command.ts | 16 ++ .../terminal-output-side-effects.test.ts | 13 ++ src/shared/terminal-output-side-effects.ts | 13 +- 18 files changed, 712 insertions(+), 18 deletions(-) create mode 100644 src/main/agent-hooks/server-process-lifetime-status.test.ts create mode 100644 src/main/providers/agent-foreground-command-line.test.ts create mode 100644 src/main/runtime/local-pty-foreground-command-line.ts create mode 100644 src/main/runtime/opencode-run-lifetime-runtime.test.ts create mode 100644 src/main/runtime/opencode-run-lifetime-status.test.ts create mode 100644 src/main/runtime/opencode-run-lifetime-status.ts create mode 100644 src/shared/opencode-headless-command.test.ts create mode 100644 src/shared/opencode-headless-command.ts 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 000000000000..b9265840ee31 --- /dev/null +++ b/src/main/agent-hooks/server-process-lifetime-status.test.ts @@ -0,0 +1,89 @@ +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') + }) +}) diff --git a/src/main/agent-hooks/server/server-ingest-terminal.ts b/src/main/agent-hooks/server/server-ingest-terminal.ts index 9a83465d3b44..cbf0dff376f6 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) @@ -82,15 +86,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 +165,7 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges : event.payload }, undefined, - 'osc', + event.origin ?? 'osc', undefined, mutationBefore ) 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 000000000000..b7479d15461b --- /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 84b147ca3e95..4aaef54a574f 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: ReturnType +} | 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/runtime/local-pty-foreground-command-line.ts b/src/main/runtime/local-pty-foreground-command-line.ts new file mode 100644 index 000000000000..d535ed9b8c30 --- /dev/null +++ b/src/main/runtime/local-pty-foreground-command-line.ts @@ -0,0 +1,8 @@ +import { listRegisteredPtys } from '../memory/pty-registry' +import { resolveAgentForegroundCommandLine } from '../providers/agent-foreground-process' + +/** Command line of a local PTY's foreground agent, read from this host's process table. */ +export async function readLocalPtyForegroundCommandLine(ptyId: string): Promise { + const shellPid = listRegisteredPtys().find((pty) => pty.ptyId === ptyId)?.pid + return shellPid ? resolveAgentForegroundCommandLine(shellPid) : null +} 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 000000000000..bad343c7edad --- /dev/null +++ b/src/main/runtime/opencode-run-lifetime-runtime.test.ts @@ -0,0 +1,103 @@ +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 { OPENCODE_RUN_SETTLE_MS } from './opencode-run-lifetime-status' +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[] +} { + const statuses: RuntimeTerminalAgentStatusEvent[] = [] + const runtime = new OrcaRuntimeService(undefined, undefined, { + onTerminalAgentStatus: (event) => statuses.push(event) + }) + 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 } +} + +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(OPENCODE_RUN_SETTLE_MS) + 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') + expect(statuses[0]?.yieldsToHookSince).toBe(statuses[1]?.yieldsToHookSince) + }) + + it('posts Done from the daemon fact when the pane finished while hidden', async () => { + const { runtime, statuses } = createRuntime() + + runtime.onPtyData('pty-1', '\x1b]133;C\x07', 100) + await vi.advanceTimersByTimeAsync(OPENCODE_RUN_SETTLE_MS) + runtime.emitDaemonPtyTransientFact('pty-1', { kind: 'command-finished', exitCode: 130 }) + + 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(OPENCODE_RUN_SETTLE_MS) + runtime.onPtyData('pty-1', '\x1b]133;D;0\x07', 101) + + expect(statuses).toEqual([]) + expect(readCommandLineMock).not.toHaveBeenCalled() + }) +}) 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 000000000000..50901e73a6de --- /dev/null +++ b/src/main/runtime/opencode-run-lifetime-status.test.ts @@ -0,0 +1,167 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ParsedAgentStatusPayload } from '../../shared/agent-status-types' +import { OPENCODE_RUN_SETTLE_MS, 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 + } = {} +) { + 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, + 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(OPENCODE_RUN_SETTLE_MS) +} + +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('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('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('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 000000000000..025d9dc0a616 --- /dev/null +++ b/src/main/runtime/opencode-run-lifetime-status.ts @@ -0,0 +1,119 @@ +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 { isOpenCodeRunCommand } from '../../shared/opencode-headless-command' + +// Why: the pane foreground tracker's settle delay; the shell has exec'd the command by then. +export const OPENCODE_RUN_SETTLE_MS = 350 +const SIGINT_EXIT_CODE = 130 + +type OpenCodeAgent = 'opencode' | 'opencode2' + +type Dependencies = { + /** Local POSIX PTYs only: elsewhere the foreground cannot be read on this host. */ + isObservablePty(ptyId: string): boolean + readForegroundProcessName(ptyId: string): Promise + readForegroundCommandLine(ptyId: 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 { + this.forgetPty(ptyId) + if (!this.deps.isObservablePty(ptyId)) { + return + } + const state: CommandState = { + generation: ++this.nextGeneration, + startedAt: this.deps.now(), + timer: null, + armed: null + } + state.timer = setTimeout(() => { + state.timer = null + void this.inspect(ptyId, state) + }, OPENCODE_RUN_SETTLE_MS) + this.commands.set(ptyId, state) + } + + 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 isCurrent(ptyId: string, state: CommandState): boolean { + return this.commands.get(ptyId)?.generation === state.generation + } + + private async inspect(ptyId: string, state: CommandState): Promise { + try { + const name = await this.deps.readForegroundProcessName(ptyId) + const agent = recognizeAgentProcess(name)?.agent + if (!this.isCurrent(ptyId, state) || (agent !== 'opencode' && agent !== 'opencode2')) { + return + } + const tokens = tokenizeCommandLine((await this.deps.readForegroundCommandLine(ptyId)) ?? '') + 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 f4f8826e6e78..2976c4dff9b9 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 5486e9539892..38ccd1b6ce72 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 cc112a78ade7..824ab18a5dc6 100644 --- a/src/main/runtime/orca-runtime-emit-daemon-pty-transient-fact.ts +++ b/src/main/runtime/orca-runtime-emit-daemon-pty-transient-fact.ts @@ -18,6 +18,7 @@ export class OrcaRuntimeWithEmitDaemonPtyTransientFact extends OrcaRuntimeWithSc this.recordTerminalSideEffectFact(ptyId, { kind: 'bell' }) return case 'command-finished': + this.openCodeRunLifetime.onCommandFinished(ptyId, fact.exitCode) this.retirePtyAgentLaunchAuthority(ptyId) this.recordTerminalSideEffectFact(ptyId, { kind: 'command-finished', diff --git a/src/main/runtime/orca-runtime-get-unpersisted-tracked-title-for-pty.ts b/src/main/runtime/orca-runtime-get-unpersisted-tracked-title-for-pty.ts index 3ece144bc248..bb9180e692ff 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,7 +143,12 @@ export class OrcaRuntimeWithGetUnpersistedTrackedTitleForPty extends OrcaRuntime onAgentExited: () => { this.confirmPtyAgentExit(ptyId) }, + onCommandStarted: () => { + this.openCodeRunLifetime.onCommandStarted(ptyId) + }, onCommandFinished: (exitCode: number | null) => { + // Why first: the run's Done must land before retirement fences the pane. + this.openCodeRunLifetime.onCommandFinished(ptyId, exitCode) this.retirePtyAgentLaunchAuthority(ptyId) this.recordTerminalSideEffectFact(ptyId, { kind: 'command-finished', exitCode }) }, diff --git a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts index a7175383df0d..300e5dd9b286 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,8 @@ 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 { RuntimeTerminalAgentStatusQuery } from './runtime-terminal-agent-status-query' import type { OrchestrationDb } from './orchestration/db' import { OrchestrationMailboxOwner } from './orchestration/mailbox-owner' @@ -162,6 +164,26 @@ 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; Windows has no foreground process group. + return process.platform !== 'win32' && !!pty && !pty.connectionId && !pty.wslDistro + }, + readForegroundProcessName: async (ptyId) => { + const read = await this.ptyForegroundAgent.read(ptyId) + return read?.available ? read.process : null + }, + readForegroundCommandLine: (ptyId) => readLocalPtyForegroundCommandLine(ptyId), + publish: (ptyId, payload, yieldsToHookSince) => + 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 e08fffa273cf..d623a3f1fd14 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/shared/opencode-headless-command.test.ts b/src/shared/opencode-headless-command.test.ts new file mode 100644 index 000000000000..408895a1dbb9 --- /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 000000000000..5328f7931fce --- /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/terminal-output-side-effects.test.ts b/src/shared/terminal-output-side-effects.test.ts index 876e4dc12cd1..22f68baeed7f 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 20128c632349..993f3bcabb98 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 From d7a2b5bd92c7a51f0badc16c82628d954d274145 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:44:09 -0700 Subject: [PATCH 12/25] fixup! fix(opencode): show OpenCode 2 `opencode run` as Working, then Done, on its own pane Type the selected descendants as process-table rows; ReturnType of the generic collector widened them to bare identity rows. --- src/main/providers/agent-foreground-process.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/providers/agent-foreground-process.ts b/src/main/providers/agent-foreground-process.ts index 4aaef54a574f..da6929ff410c 100644 --- a/src/main/providers/agent-foreground-process.ts +++ b/src/main/providers/agent-foreground-process.ts @@ -210,7 +210,7 @@ function selectAgentForegroundFromPs( shellPid: number ): { selected: SelectedForegroundProcess - candidates: ReturnType + candidates: (ProcessTableRow & { depth: number })[] } | null { // Memoized per snapshot identity, so the caller's own index build is reused. const index = getProcessTableIndex(rows) From c8e37a879c6b8e667bfe5e201be3d3a29faa7dec Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:36:47 -0700 Subject: [PATCH 13/25] fix(opencode): keep an `opencode run` pane's Done after the command exits The run's Done was published inside the chunk that carried its OSC 133;D, before the chunk's command-finished fact. The renderer drops an exited agent's row on command-finished when the row has not changed since that fact arrived, so it took the Done as the stale row and dropped it, in the renderer and in main. Publish the run's Done after the chunk's side-effect facts are emitted (and after the daemon's background fact). The renderer then sees command-finished while the row is still Working, and the Done that follows counts as a change, so it stays, the same way a hook Done that lands after exit already does. --- .../opencode-run-lifetime-runtime.test.ts | 25 ++- ...-runtime-emit-daemon-pty-transient-fact.ts | 2 +- ...e-get-unpersisted-tracked-title-for-pty.ts | 4 +- ...rca-runtime-schedule-wait-blocked-check.ts | 22 +- .../orca-runtime-stop-requested-pty-ids.ts | 12 +- .../runtime/runtime-terminal-state-records.ts | 2 + ...nection-command-finished-late-done.test.ts | 210 ++++++++++++++++++ 7 files changed, 263 insertions(+), 14 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-command-finished-late-done.test.ts diff --git a/src/main/runtime/opencode-run-lifetime-runtime.test.ts b/src/main/runtime/opencode-run-lifetime-runtime.test.ts index bad343c7edad..9784414cd8ee 100644 --- a/src/main/runtime/opencode-run-lifetime-runtime.test.ts +++ b/src/main/runtime/opencode-run-lifetime-runtime.test.ts @@ -17,10 +17,17 @@ 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) + 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(), @@ -43,7 +50,7 @@ function createRuntime(): { { tabId: 'tab-1', worktreeId: WORKTREE_ID, leafId: LEAF_ID, paneRuntimeId: 1, ptyId: 'pty-1' } ] }) - return { runtime, statuses } + return { runtime, statuses, channelOrder } } const summary = (statuses: RuntimeTerminalAgentStatusEvent[]): string[] => @@ -76,13 +83,25 @@ describe('OpenCode run process lifetime in the runtime', () => { 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(OPENCODE_RUN_SETTLE_MS) + 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 } = createRuntime() + const { runtime, statuses, channelOrder } = createRuntime() runtime.onPtyData('pty-1', '\x1b]133;C\x07', 100) await vi.advanceTimersByTimeAsync(OPENCODE_RUN_SETTLE_MS) 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` 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 824ab18a5dc6..dbbb124d34aa 100644 --- a/src/main/runtime/orca-runtime-emit-daemon-pty-transient-fact.ts +++ b/src/main/runtime/orca-runtime-emit-daemon-pty-transient-fact.ts @@ -18,12 +18,12 @@ export class OrcaRuntimeWithEmitDaemonPtyTransientFact extends OrcaRuntimeWithSc this.recordTerminalSideEffectFact(ptyId, { kind: 'bell' }) return case 'command-finished': - this.openCodeRunLifetime.onCommandFinished(ptyId, fact.exitCode) this.retirePtyAgentLaunchAuthority(ptyId) this.recordTerminalSideEffectFact(ptyId, { kind: 'command-finished', exitCode: fact.exitCode }) + this.openCodeRunLifetime.onCommandFinished(ptyId, fact.exitCode) return case 'pr-link': this.recordTerminalSideEffectFact(ptyId, { kind: 'pr-link', link: fact.link }) diff --git a/src/main/runtime/orca-runtime-get-unpersisted-tracked-title-for-pty.ts b/src/main/runtime/orca-runtime-get-unpersisted-tracked-title-for-pty.ts index bb9180e692ff..ded7a86eed58 100644 --- a/src/main/runtime/orca-runtime-get-unpersisted-tracked-title-for-pty.ts +++ b/src/main/runtime/orca-runtime-get-unpersisted-tracked-title-for-pty.ts @@ -147,10 +147,9 @@ export class OrcaRuntimeWithGetUnpersistedTrackedTitleForPty extends OrcaRuntime this.openCodeRunLifetime.onCommandStarted(ptyId) }, onCommandFinished: (exitCode: number | null) => { - // Why first: the run's Done must land before retirement fences the pane. - this.openCodeRunLifetime.onCommandFinished(ptyId, exitCode) this.retirePtyAgentLaunchAuthority(ptyId) this.recordTerminalSideEffectFact(ptyId, { kind: 'command-finished', exitCode }) + this.openCodeRunLifetime.onCommandFinished(ptyId, exitCode) }, onBell: () => { this.recordTerminalSideEffectFact(ptyId, { kind: 'bell' }) @@ -176,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 f62192179251..5f874cbc182a 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 300e5dd9b286..009aab178f39 100644 --- a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts +++ b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts @@ -175,11 +175,15 @@ export class OrcaRuntimeWithStopRequestedPtyIds extends OrcaRuntimeWithRuntimeId return read?.available ? read.process : null }, readForegroundCommandLine: (ptyId) => readLocalPtyForegroundCommandLine(ptyId), + // 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.emitTerminalAgentStatusEvents( - ptyId, - { payloads: [payload] }, - { origin: 'process', yieldsToHookSince } + this.runAfterPendingTerminalSideEffectFacts(ptyId, () => + this.emitTerminalAgentStatusEvents( + ptyId, + { payloads: [payload] }, + { origin: 'process', yieldsToHookSince } + ) ), now: () => Date.now() }) diff --git a/src/main/runtime/runtime-terminal-state-records.ts b/src/main/runtime/runtime-terminal-state-records.ts index d030b896da99..64a32c746293 100644 --- a/src/main/runtime/runtime-terminal-state-records.ts +++ b/src/main/runtime/runtime-terminal-state-records.ts @@ -107,6 +107,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/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 000000000000..cea25d0f099f --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-command-finished-late-done.test.ts @@ -0,0 +1,210 @@ +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) + connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps({ isVisibleRef: { current: false } }) as never + ) + 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]?.state).toBe('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) + }) +}) From 45a0661b9b54c8446f110a0b562a4582a812cc36 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:38:27 -0700 Subject: [PATCH 14/25] fix(opencode): let an `opencode run` revive a pane Orca retired When an agent Orca launched exits, command completion retires the pane, and only a hook new-turn event revived it. A later `opencode run` in that pane posts no hook, so its process-lifetime Working was refused and the pane stayed silent. A process-lifetime Working is posted only after a fresh OSC 133;C and the pane's own foreground argv prove a new OpenCode run, which is at least as strong as a hook new turn. The store now revives the retired pane on it the same way: it clears the retirement, drops the launch-token fence, and rebinds the observation. OSC status and a lone process Done still cannot write a retired pane, and a closed tab stays closed. --- .../server-process-lifetime-status.test.ts | 23 +++++++++++++++++++ .../server/server-ingest-terminal.ts | 12 +++++++++- .../server/server-status-disposition.ts | 9 ++++++-- 3 files changed, 41 insertions(+), 3 deletions(-) diff --git a/src/main/agent-hooks/server-process-lifetime-status.test.ts b/src/main/agent-hooks/server-process-lifetime-status.test.ts index b9265840ee31..e0f489299af1 100644 --- a/src/main/agent-hooks/server-process-lifetime-status.test.ts +++ b/src/main/agent-hooks/server-process-lifetime-status.test.ts @@ -86,4 +86,27 @@ describe('process-lifetime status', () => { 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-terminal.ts b/src/main/agent-hooks/server/server-ingest-terminal.ts index cbf0dff376f6..63355e3592b2 100644 --- a/src/main/agent-hooks/server/server-ingest-terminal.ts +++ b/src/main/agent-hooks/server/server-ingest-terminal.ts @@ -47,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() diff --git a/src/main/agent-hooks/server/server-status-disposition.ts b/src/main/agent-hooks/server/server-status-disposition.ts index e8ac4ddd31ef..ef633d7c2c80 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() From d8eb1ea3c7a1bc19d921d5f0b0c3e77df51d4487 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:43:42 -0700 Subject: [PATCH 15/25] fix(opencode): report `opencode run` on local Windows panes too The run producer skipped every Windows pane, although Orca already resolves a Windows pane's foreground agent from the native process table. On main an OpenCode 2 `run` showed there (on the service's pane); on this branch it was silent. The argv read now has a Windows branch: one fresh native table read (windows-process-table, no interpreter spawn), the same foreground identity the Windows resolver already computes, and the command line of the process that identity names. It runs only after the name read says OpenCode. Local Windows panes whose shell prints OSC 133 C/D (PowerShell with PSReadLine, Git Bash with Orca's wrapper) now go Working, then Done; cmd.exe prints no markers and stays silent. SSH and WSL panes stay silent. --- ...dows-agent-foreground-command-line.test.ts | 60 +++++++++++++++++++ .../windows-agent-foreground-command-line.ts | 21 +++++++ .../windows-agent-foreground-process.ts | 2 +- .../local-pty-foreground-command-line.ts | 13 +++- .../opencode-run-lifetime-runtime.test.ts | 21 ++++++- .../runtime/opencode-run-lifetime-status.ts | 14 +++-- .../orca-runtime-stop-requested-pty-ids.ts | 7 ++- 7 files changed, 127 insertions(+), 11 deletions(-) create mode 100644 src/main/providers/windows-agent-foreground-command-line.test.ts create mode 100644 src/main/providers/windows-agent-foreground-command-line.ts 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 000000000000..1c2599dea444 --- /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 000000000000..a0858642d77a --- /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 8ffba73c24d9..8a439562f718 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 index d535ed9b8c30..aa836d6f0f2c 100644 --- a/src/main/runtime/local-pty-foreground-command-line.ts +++ b/src/main/runtime/local-pty-foreground-command-line.ts @@ -1,8 +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): Promise { +export async function readLocalPtyForegroundCommandLine( + ptyId: string, + foregroundProcess: string +): Promise { const shellPid = listRegisteredPtys().find((pty) => pty.ptyId === ptyId)?.pid - return shellPid ? resolveAgentForegroundCommandLine(shellPid) : null + 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 index 9784414cd8ee..24dba806a0a9 100644 --- a/src/main/runtime/opencode-run-lifetime-runtime.test.ts +++ b/src/main/runtime/opencode-run-lifetime-runtime.test.ts @@ -79,7 +79,7 @@ describe('OpenCode run process lifetime in the runtime', () => { 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') + expect(readCommandLineMock).toHaveBeenCalledWith('pty-1', 'opencode') expect(statuses[0]?.yieldsToHookSince).toBe(statuses[1]?.yieldsToHookSince) }) @@ -119,4 +119,23 @@ describe('OpenCode run process lifetime in the runtime', () => { 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(OPENCODE_RUN_SETTLE_MS) + 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.ts b/src/main/runtime/opencode-run-lifetime-status.ts index 025d9dc0a616..959e7ae7b498 100644 --- a/src/main/runtime/opencode-run-lifetime-status.ts +++ b/src/main/runtime/opencode-run-lifetime-status.ts @@ -13,10 +13,10 @@ const SIGINT_EXIT_CODE = 130 type OpenCodeAgent = 'opencode' | 'opencode2' type Dependencies = { - /** Local POSIX PTYs only: elsewhere the foreground cannot be read on this host. */ + /** Local PTYs only: SSH and WSL foregrounds cannot be read on this host. */ isObservablePty(ptyId: string): boolean readForegroundProcessName(ptyId: string): Promise - readForegroundCommandLine(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 @@ -91,10 +91,16 @@ export class OpenCodeRunLifetimeStatus { try { const name = await this.deps.readForegroundProcessName(ptyId) const agent = recognizeAgentProcess(name)?.agent - if (!this.isCurrent(ptyId, state) || (agent !== 'opencode' && agent !== 'opencode2')) { + if ( + !name || + !this.isCurrent(ptyId, state) || + (agent !== 'opencode' && agent !== 'opencode2') + ) { return } - const tokens = tokenizeCommandLine((await this.deps.readForegroundCommandLine(ptyId)) ?? '') + const tokens = tokenizeCommandLine( + (await this.deps.readForegroundCommandLine(ptyId, name)) ?? '' + ) if ( !this.isCurrent(ptyId, state) || recognizeAgentProcess(tokens[0])?.agent !== agent || 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 009aab178f39..43edd9d7a919 100644 --- a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts +++ b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts @@ -167,14 +167,15 @@ 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; Windows has no foreground process group. - return process.platform !== 'win32' && !!pty && !pty.connectionId && !pty.wslDistro + // Why: SSH and WSL foregrounds live on another host or in the guest. + return !!pty && !pty.connectionId && !pty.wslDistro && !this.wslDistroByPtyId.has(ptyId) }, readForegroundProcessName: async (ptyId) => { const read = await this.ptyForegroundAgent.read(ptyId) return read?.available ? read.process : null }, - readForegroundCommandLine: (ptyId) => readLocalPtyForegroundCommandLine(ptyId), + 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) => From 19e732e1609d4717570475775ff80289635e179d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:46:38 -0700 Subject: [PATCH 16/25] fix(opencode): re-read an `opencode run` foreground on the pane tracker's ladder The producer read the pane's foreground once, 350 ms after the command started. A wrapper, a shim or `sleep 1; opencode run` execs OpenCode later, so those runs stayed silent. The renderer's pane tracker already re-reads a command's foreground at 350, then 1200, then 6000 ms for exactly this. Move those delays to one shared module and use it from both. The producer re-reads only while the foreground is a non-shell process that is not OpenCode, and at most on those three rungs; no new polling. --- .../opencode-run-lifetime-runtime.test.ts | 12 +++--- .../opencode-run-lifetime-status.test.ts | 33 ++++++++++++++- .../runtime/opencode-run-lifetime-status.ts | 40 +++++++++++++------ .../pane-foreground-agent-tracker.ts | 8 ++-- src/shared/foreground-command-settle.ts | 5 +++ 5 files changed, 74 insertions(+), 24 deletions(-) create mode 100644 src/shared/foreground-command-settle.ts diff --git a/src/main/runtime/opencode-run-lifetime-runtime.test.ts b/src/main/runtime/opencode-run-lifetime-runtime.test.ts index 24dba806a0a9..a3e8a3834864 100644 --- a/src/main/runtime/opencode-run-lifetime-runtime.test.ts +++ b/src/main/runtime/opencode-run-lifetime-runtime.test.ts @@ -7,7 +7,7 @@ vi.mock('./local-pty-foreground-command-line', () => ({ })) import { OrcaRuntimeService } from './orca-runtime' -import { OPENCODE_RUN_SETTLE_MS } from './opencode-run-lifetime-status' +import { FOREGROUND_COMMAND_SETTLE_MS } from '../../shared/foreground-command-settle' import type { RuntimeTerminalAgentStatusEvent } from './runtime-terminal-contracts' const WORKTREE_ID = 'repo::/worktree' @@ -75,7 +75,7 @@ describe('OpenCode run process lifetime in the runtime', () => { const { runtime, statuses } = createRuntime() runtime.onPtyData('pty-1', '\x1b]133;C\x07', 100) - await vi.advanceTimersByTimeAsync(OPENCODE_RUN_SETTLE_MS) + await vi.advanceTimersByTimeAsync(FOREGROUND_COMMAND_SETTLE_MS) runtime.onPtyData('pty-1', 'done\x1b]133;D;0\x07', 101) expect(summary(statuses)).toEqual([`${PANE_KEY}:working:process`, `${PANE_KEY}:done:process`]) @@ -88,7 +88,7 @@ describe('OpenCode run process lifetime in the runtime', () => { const { runtime, channelOrder } = createRuntime() runtime.onPtyData('pty-1', '\x1b]133;C\x07', 100) - await vi.advanceTimersByTimeAsync(OPENCODE_RUN_SETTLE_MS) + await vi.advanceTimersByTimeAsync(FOREGROUND_COMMAND_SETTLE_MS) runtime.onPtyData('pty-1', 'done\x1b]133;D;0\x07', 101) expect(channelOrder).toEqual(['status:working', 'fact:command-finished', 'status:done']) @@ -98,7 +98,7 @@ describe('OpenCode run process lifetime in the runtime', () => { const { runtime, statuses, channelOrder } = createRuntime() runtime.onPtyData('pty-1', '\x1b]133;C\x07', 100) - await vi.advanceTimersByTimeAsync(OPENCODE_RUN_SETTLE_MS) + await vi.advanceTimersByTimeAsync(FOREGROUND_COMMAND_SETTLE_MS) runtime.emitDaemonPtyTransientFact('pty-1', { kind: 'command-finished', exitCode: 130 }) expect(channelOrder.slice(-2)).toEqual(['fact:command-finished', 'status:done']) @@ -113,7 +113,7 @@ describe('OpenCode run process lifetime in the runtime', () => { runtime.registerPty('pty-1', WORKTREE_ID, 'ssh-conn-1') runtime.onPtyData('pty-1', '\x1b]133;C\x07', 100) - await vi.advanceTimersByTimeAsync(OPENCODE_RUN_SETTLE_MS) + await vi.advanceTimersByTimeAsync(FOREGROUND_COMMAND_SETTLE_MS) runtime.onPtyData('pty-1', '\x1b]133;D;0\x07', 101) expect(statuses).toEqual([]) @@ -128,7 +128,7 @@ describe('OpenCode run process lifetime in the runtime', () => { readCommandLineMock.mockResolvedValue('"C:\\Tools\\opencode.exe" run fix it') runtime.onPtyData('pty-1', '\x1b]133;C\x07', 100) - await vi.advanceTimersByTimeAsync(OPENCODE_RUN_SETTLE_MS) + await vi.advanceTimersByTimeAsync(FOREGROUND_COMMAND_SETTLE_MS) runtime.onPtyData('pty-1', '\x1b]133;D;0\x07', 101) expect(summary(statuses)).toEqual([`${PANE_KEY}:working:process`, `${PANE_KEY}:done:process`]) diff --git a/src/main/runtime/opencode-run-lifetime-status.test.ts b/src/main/runtime/opencode-run-lifetime-status.test.ts index 50901e73a6de..7050c2f360e1 100644 --- a/src/main/runtime/opencode-run-lifetime-status.test.ts +++ b/src/main/runtime/opencode-run-lifetime-status.test.ts @@ -1,6 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { ParsedAgentStatusPayload } from '../../shared/agent-status-types' -import { OPENCODE_RUN_SETTLE_MS, OpenCodeRunLifetimeStatus } from './opencode-run-lifetime-status' +import { + FOREGROUND_COMMAND_RETRY_DELAYS_MS, + FOREGROUND_COMMAND_SETTLE_MS +} from '../../shared/foreground-command-settle' +import { OpenCodeRunLifetimeStatus } from './opencode-run-lifetime-status' type Published = { ptyId: string; payload: ParsedAgentStatusPayload; yieldsToHookSince: number } @@ -35,7 +39,7 @@ function setup( } async function settle(): Promise { - await vi.advanceTimersByTimeAsync(OPENCODE_RUN_SETTLE_MS) + await vi.advanceTimersByTimeAsync(FOREGROUND_COMMAND_SETTLE_MS) } describe('OpenCodeRunLifetimeStatus', () => { @@ -91,6 +95,31 @@ describe('OpenCodeRunLifetimeStatus', () => { expect(states()).toEqual([]) }) + it('re-reads a wrapper foreground on the shared ladder until OpenCode execs', async () => { + const { lifetime, states, readForegroundProcessName } = setup() + readForegroundProcessName.mockResolvedValueOnce('sleep') + lifetime.onCommandStarted('pty-1') + await settle() + expect(states()).toEqual([]) + await vi.advanceTimersByTimeAsync(FOREGROUND_COMMAND_RETRY_DELAYS_MS[0]) + expect(states()).toEqual(['working']) + expect(readForegroundProcessName).toHaveBeenCalledTimes(2) + }) + + it('stops re-reading at a shell and after the last rung', async () => { + const wrapper = setup({ name: 'node' }) + wrapper.lifetime.onCommandStarted('pty-1') + await vi.advanceTimersByTimeAsync(60_000) + expect(wrapper.readForegroundProcessName).toHaveBeenCalledTimes( + 1 + FOREGROUND_COMMAND_RETRY_DELAYS_MS.length + ) + const shell = setup({ name: 'bash' }) + shell.lifetime.onCommandStarted('pty-1') + await vi.advanceTimersByTimeAsync(60_000) + expect(shell.readForegroundProcessName).toHaveBeenCalledTimes(1) + expect([...wrapper.states(), ...shell.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 }) diff --git a/src/main/runtime/opencode-run-lifetime-status.ts b/src/main/runtime/opencode-run-lifetime-status.ts index 959e7ae7b498..af6102504ac3 100644 --- a/src/main/runtime/opencode-run-lifetime-status.ts +++ b/src/main/runtime/opencode-run-lifetime-status.ts @@ -4,10 +4,13 @@ import { normalizeAgentStatusPayload, type ParsedAgentStatusPayload } from '../../shared/agent-status-types' +import { + FOREGROUND_COMMAND_RETRY_DELAYS_MS, + FOREGROUND_COMMAND_SETTLE_MS +} from '../../shared/foreground-command-settle' import { isOpenCodeRunCommand } from '../../shared/opencode-headless-command' +import { isShellProcess } from '../../shared/shell-process-detection' -// Why: the pane foreground tracker's settle delay; the shell has exec'd the command by then. -export const OPENCODE_RUN_SETTLE_MS = 350 const SIGINT_EXIT_CODE = 130 type OpenCodeAgent = 'opencode' | 'opencode2' @@ -51,11 +54,8 @@ export class OpenCodeRunLifetimeStatus { timer: null, armed: null } - state.timer = setTimeout(() => { - state.timer = null - void this.inspect(ptyId, state) - }, OPENCODE_RUN_SETTLE_MS) this.commands.set(ptyId, state) + this.scheduleInspect(ptyId, state, FOREGROUND_COMMAND_SETTLE_MS, 0) } onCommandFinished(ptyId: string, exitCode: number | null): void { @@ -83,19 +83,35 @@ export class OpenCodeRunLifetimeStatus { 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): Promise { + 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) || - (agent !== 'opencode' && agent !== 'opencode2') - ) { + if (!name || !this.isCurrent(ptyId, state)) { + return + } + if (agent !== 'opencode' && agent !== 'opencode2') { + // Why: a wrapper, shim or `sleep 1; ...` can exec OpenCode after the first read. + const retryDelay = FOREGROUND_COMMAND_RETRY_DELAYS_MS[retryIndex] + if (retryDelay !== undefined && !isShellProcess(name)) { + this.scheduleInspect(ptyId, state, retryDelay, retryIndex + 1) + } return } const tokens = tokenizeCommandLine( 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 d831226267d0..32740317cf92 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 @@ -7,12 +7,12 @@ 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_RETRY_DELAYS_MS as WRAPPER_RESOLVE_RETRY_DELAYS_MS, + FOREGROUND_COMMAND_SETTLE_MS as COMMAND_SETTLE_MS +} 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 = { diff --git a/src/shared/foreground-command-settle.ts b/src/shared/foreground-command-settle.ts new file mode 100644 index 000000000000..029bc88cf502 --- /dev/null +++ b/src/shared/foreground-command-settle.ts @@ -0,0 +1,5 @@ +// 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_SETTLE_MS = 350 +export const FOREGROUND_COMMAND_RETRY_DELAYS_MS = [1200, 6000] as const From 57a83aebb9d7123f2ad801cd545f8ff9bbe62aa3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:47:41 -0700 Subject: [PATCH 17/25] fix(opencode): end an armed `opencode run` when the next command starts A new OSC 133;C in a pane whose `opencode run` was still armed dropped the armed state without a Done, so a run whose 133;D never arrived left the pane on Working. A new command start proves the previous command ended, so it now posts that run's Done (not marked interrupted: no exit code is known) before the new command is inspected. --- src/main/runtime/opencode-run-lifetime-status.test.ts | 10 ++++++++++ src/main/runtime/opencode-run-lifetime-status.ts | 3 ++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/main/runtime/opencode-run-lifetime-status.test.ts b/src/main/runtime/opencode-run-lifetime-status.test.ts index 7050c2f360e1..6b9dad3e5d4b 100644 --- a/src/main/runtime/opencode-run-lifetime-status.test.ts +++ b/src/main/runtime/opencode-run-lifetime-status.test.ts @@ -172,6 +172,16 @@ describe('OpenCodeRunLifetimeStatus', () => { 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') diff --git a/src/main/runtime/opencode-run-lifetime-status.ts b/src/main/runtime/opencode-run-lifetime-status.ts index af6102504ac3..2ec07386854e 100644 --- a/src/main/runtime/opencode-run-lifetime-status.ts +++ b/src/main/runtime/opencode-run-lifetime-status.ts @@ -44,7 +44,8 @@ export class OpenCodeRunLifetimeStatus { constructor(private readonly deps: Dependencies) {} onCommandStarted(ptyId: string): void { - this.forgetPty(ptyId) + // 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)) { return } From 362b2c130142c280ddf36ea1c98430378c5baa8f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:52:22 -0700 Subject: [PATCH 18/25] fix(opencode): skip the session-start row inside an OpenCode 1 `run` process OpenCode 1 `run` loads the status plugin in its own process, and the plugin posts SessionStart when the run's session is created. The host lands that as an idle session boundary, so a pane the run producer had already shown as Working blinked idle before the plugin's Busy. The plugin now skips SessionStart when its own process is a `run`, read from its argv the same way isOpenCodeRunCommand reads a pane's foreground (first positional after global options, past the compiled binary's entry path). A `run` session goes Busy at once, and its first prompt part still revives a retired pane and resets the turn caches. The TUI and `serve` still post it, and OpenCode 2's `run` loads no plugin. --- ...k-plugin-run-process-session-start.test.ts | 97 +++++++++++++++++++ .../opencode/status-plugin-factory-source.ts | 1 + .../status-plugin-runtime-state-source.ts | 18 +++- 3 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 src/main/opencode/hook-plugin-run-process-session-start.test.ts 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 000000000000..0854b8ddfa26 --- /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/status-plugin-factory-source.ts b/src/main/opencode/status-plugin-factory-source.ts index 8775a223f1de..43f063dde812 100644 --- a/src/main/opencode/status-plugin-factory-source.ts +++ b/src/main/opencode/status-plugin-factory-source.ts @@ -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 })', ' );', diff --git a/src/main/opencode/status-plugin-runtime-state-source.ts b/src/main/opencode/status-plugin-runtime-state-source.ts index f84b403a709c..071385e29cdb 100644 --- a/src/main/opencode/status-plugin-runtime-state-source.ts +++ b/src/main/opencode/status-plugin-runtime-state-source.ts @@ -54,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') +} From a868d4903c691a9d901de254133de590d5ad5408 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:52:55 -0700 Subject: [PATCH 19/25] chore(opencode): say where the plugin's opencodeMajor comes from The field is set when OpenCode's loader calls setup(), which only OpenCode 2 does; the plugin never reads OpenCode's version. Say so at the field. Comment only; the generated plugin is unchanged. --- src/main/opencode/status-plugin-post-source.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/opencode/status-plugin-post-source.ts b/src/main/opencode/status-plugin-post-source.ts index 0cca07e61a5c..7c219857bca4 100644 --- a/src/main/opencode/status-plugin-post-source.ts +++ b/src/main/opencode/status-plugin-post-source.ts @@ -16,6 +16,7 @@ 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 || {}) },', ' });', From ca40d529414efffc72607d63be566d89ae8d9a74 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:54:38 -0700 Subject: [PATCH 20/25] test(opencode): type the late-Done pane fixture without bare casts The new renderer test passed its fixtures with `as never`; use the checked fixture-tuple cast with a SAFETY note, like the sibling pty-connection tests. --- ...pty-connection-command-finished-late-done.test.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-command-finished-late-done.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-command-finished-late-done.test.ts index cea25d0f099f..2520d55a147e 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-command-finished-late-done.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-command-finished-late-done.test.ts @@ -163,11 +163,13 @@ describe('command-finished drop and a Done that lands after it', () => { }) transportFactoryQueue.push(transport) const paneKey = makePaneKey('tab-1', LEAF_1) - connectPanePty( - createPane(1) as never, - createManager(1) as never, - createDeps({ isVisibleRef: { current: false } }) as never - ) + // 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() From f69636d24bdacbb630eab123d9e4eccac3078ecd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:58:05 -0700 Subject: [PATCH 21/25] fix(opencode): keep `opencode run` silent when OpenCode status is turned off The run producer ignored the status-hooks switch, so a user who turned status off globally or for OpenCode (#23667) still got a run's Working and Done. It now checks isAgentStatusHooksEnabledForAgent for the run's agent (opencode or opencode2), the same predicate the plugin install honours, before reading argv or posting anything. --- src/main/runtime/opencode-run-lifetime-status.test.ts | 11 +++++++++++ src/main/runtime/opencode-run-lifetime-status.ts | 5 +++++ .../runtime/orca-runtime-stop-requested-pty-ids.ts | 3 +++ 3 files changed, 19 insertions(+) diff --git a/src/main/runtime/opencode-run-lifetime-status.test.ts b/src/main/runtime/opencode-run-lifetime-status.test.ts index 6b9dad3e5d4b..89811babfdd7 100644 --- a/src/main/runtime/opencode-run-lifetime-status.test.ts +++ b/src/main/runtime/opencode-run-lifetime-status.test.ts @@ -13,6 +13,7 @@ function setup( name?: string | null commandLine?: string | null observable?: boolean + statusEnabled?: boolean } = {} ) { const published: Published[] = [] @@ -25,6 +26,7 @@ function setup( let clock = 1_000 const lifetime = new OpenCodeRunLifetimeStatus({ isObservablePty: () => options.observable ?? true, + isStatusEnabled: () => options.statusEnabled ?? true, readForegroundProcessName, readForegroundCommandLine, publish: (ptyId, payload, yieldsToHookSince) => @@ -163,6 +165,15 @@ describe('OpenCodeRunLifetimeStatus', () => { expect(states()).toEqual([]) }) + it('stays silent when OpenCode status is turned off for that agent', async () => { + const { lifetime, states, readForegroundCommandLine } = setup({ statusEnabled: false }) + lifetime.onCommandStarted('pty-1') + await settle() + lifetime.onCommandFinished('pty-1', 0) + expect(readForegroundCommandLine).not.toHaveBeenCalled() + expect(states()).toEqual([]) + }) + it('reads nothing for a pane whose foreground is on another host', async () => { const { lifetime, states, readForegroundProcessName } = setup({ observable: false }) lifetime.onCommandStarted('pty-1') diff --git a/src/main/runtime/opencode-run-lifetime-status.ts b/src/main/runtime/opencode-run-lifetime-status.ts index 2ec07386854e..54bc316ce592 100644 --- a/src/main/runtime/opencode-run-lifetime-status.ts +++ b/src/main/runtime/opencode-run-lifetime-status.ts @@ -18,6 +18,8 @@ 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. */ @@ -115,6 +117,9 @@ export class OpenCodeRunLifetimeStatus { } return } + if (!this.deps.isStatusEnabled(agent)) { + return + } const tokens = tokenizeCommandLine( (await this.deps.readForegroundCommandLine(ptyId, name)) ?? '' ) 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 43edd9d7a919..1e54583435c6 100644 --- a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts +++ b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts @@ -26,6 +26,7 @@ import { RuntimeManagedWorktreeQueries } from './runtime-managed-worktree-querie 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' @@ -170,6 +171,8 @@ export class OrcaRuntimeWithStopRequestedPtyIds extends OrcaRuntimeWithRuntimeId // 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 From 51ebfbb94ff9ee8af8b3cd528f3567f133aa0200 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:59:11 -0700 Subject: [PATCH 22/25] test(opencode): read the late-Done row without an untyped property access The mock store types agentStatusByPaneKey values as unknown, so reading `.state` failed tc:web. Assert the row with toMatchObject instead. --- .../pty-connection-command-finished-late-done.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-command-finished-late-done.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-command-finished-late-done.test.ts index 2520d55a147e..2b92e607d227 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-command-finished-late-done.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-command-finished-late-done.test.ts @@ -196,7 +196,7 @@ describe('command-finished drop and a Done that lands after it', () => { await vi.advanceTimersByTimeAsync(350 + 1200 + 6000) expect(mockStoreState.dropAgentStatus).not.toHaveBeenCalled() - expect(mockStoreState.agentStatusByPaneKey[pane.paneKey]?.state).toBe('done') + expect(mockStoreState.agentStatusByPaneKey[pane.paneKey]).toMatchObject({ state: 'done' }) }) it('drops a Done that landed before the command-finished fact', async () => { From 5bb3190ea0409f9519a773e8a24f31092899a242 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 19:17:13 -0700 Subject: [PATCH 23/25] perf(opencode): read a command's foreground only while it could become `opencode run` Two costs the run producer paid on every command in every local pane: - The retry ladder re-read the foreground (a process-table capture on the local provider) for any non-shell program still running, including other agents, editors and dev servers, up to three times. It now re-reads only while the foreground is still the shell (the command has not exec'd yet) or an unrecognised launcher that may still exec OpenCode (node, bun, bunx, npx, npm, pnpm, pnpx, yarn). Any other program is read once. - With OpenCode status turned off for both opencode and opencode2, it still set the timer and read the foreground only to discard the result. It now returns before any timer or read. The per-agent check after the name read stays for the case where only one of them is off. --- .../opencode-run-lifetime-status.test.ts | 70 ++++++++++++------- .../runtime/opencode-run-lifetime-status.ts | 20 +++++- 2 files changed, 62 insertions(+), 28 deletions(-) diff --git a/src/main/runtime/opencode-run-lifetime-status.test.ts b/src/main/runtime/opencode-run-lifetime-status.test.ts index 89811babfdd7..7e8eabe6d374 100644 --- a/src/main/runtime/opencode-run-lifetime-status.test.ts +++ b/src/main/runtime/opencode-run-lifetime-status.test.ts @@ -13,7 +13,7 @@ function setup( name?: string | null commandLine?: string | null observable?: boolean - statusEnabled?: boolean + enabledAgents?: readonly string[] } = {} ) { const published: Published[] = [] @@ -26,7 +26,8 @@ function setup( let clock = 1_000 const lifetime = new OpenCodeRunLifetimeStatus({ isObservablePty: () => options.observable ?? true, - isStatusEnabled: () => options.statusEnabled ?? true, + isStatusEnabled: (agent) => + (options.enabledAgents ?? ['opencode', 'opencode2']).includes(agent), readForegroundProcessName, readForegroundCommandLine, publish: (ptyId, payload, yieldsToHookSince) => @@ -97,31 +98,43 @@ describe('OpenCodeRunLifetimeStatus', () => { expect(states()).toEqual([]) }) - it('re-reads a wrapper foreground on the shared ladder until OpenCode execs', async () => { - const { lifetime, states, readForegroundProcessName } = setup() - readForegroundProcessName.mockResolvedValueOnce('sleep') - lifetime.onCommandStarted('pty-1') - await settle() - expect(states()).toEqual([]) - await vi.advanceTimersByTimeAsync(FOREGROUND_COMMAND_RETRY_DELAYS_MS[0]) - expect(states()).toEqual(['working']) - expect(readForegroundProcessName).toHaveBeenCalledTimes(2) - }) + 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_RETRY_DELAYS_MS[0]) + expect(states()).toEqual(['working']) + expect(readForegroundProcessName).toHaveBeenCalledTimes(2) + } + ) - it('stops re-reading at a shell and after the last rung', async () => { - const wrapper = setup({ name: 'node' }) - wrapper.lifetime.onCommandStarted('pty-1') - await vi.advanceTimersByTimeAsync(60_000) - expect(wrapper.readForegroundProcessName).toHaveBeenCalledTimes( - 1 + FOREGROUND_COMMAND_RETRY_DELAYS_MS.length - ) - const shell = setup({ name: 'bash' }) - shell.lifetime.onCommandStarted('pty-1') - await vi.advanceTimersByTimeAsync(60_000) - expect(shell.readForegroundProcessName).toHaveBeenCalledTimes(1) - expect([...wrapper.states(), ...shell.states()]).toEqual([]) + 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_RETRY_DELAYS_MS.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 }) @@ -166,7 +179,7 @@ describe('OpenCodeRunLifetimeStatus', () => { }) it('stays silent when OpenCode status is turned off for that agent', async () => { - const { lifetime, states, readForegroundCommandLine } = setup({ statusEnabled: false }) + const { lifetime, states, readForegroundCommandLine } = setup({ enabledAgents: ['opencode2'] }) lifetime.onCommandStarted('pty-1') await settle() lifetime.onCommandFinished('pty-1', 0) @@ -174,6 +187,13 @@ describe('OpenCodeRunLifetimeStatus', () => { 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') diff --git a/src/main/runtime/opencode-run-lifetime-status.ts b/src/main/runtime/opencode-run-lifetime-status.ts index 54bc316ce592..80b2f54ebe4e 100644 --- a/src/main/runtime/opencode-run-lifetime-status.ts +++ b/src/main/runtime/opencode-run-lifetime-status.ts @@ -12,6 +12,18 @@ 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' @@ -48,7 +60,10 @@ export class OpenCodeRunLifetimeStatus { onCommandStarted(ptyId: string): void { // Why: a new command proves the armed one ended even though its 133;D never arrived. this.onCommandFinished(ptyId, null) - if (!this.deps.isObservablePty(ptyId)) { + if ( + !this.deps.isObservablePty(ptyId) || + (!this.deps.isStatusEnabled('opencode') && !this.deps.isStatusEnabled('opencode2')) + ) { return } const state: CommandState = { @@ -110,9 +125,8 @@ export class OpenCodeRunLifetimeStatus { return } if (agent !== 'opencode' && agent !== 'opencode2') { - // Why: a wrapper, shim or `sleep 1; ...` can exec OpenCode after the first read. const retryDelay = FOREGROUND_COMMAND_RETRY_DELAYS_MS[retryIndex] - if (retryDelay !== undefined && !isShellProcess(name)) { + if (retryDelay !== undefined && mayStillBecomeOpenCode(name)) { this.scheduleInspect(ptyId, state, retryDelay, retryIndex + 1) } return From 565feb79c84ea20c60a0e1a891cdc64f560e6ce0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:16:12 -0700 Subject: [PATCH 24/25] fix(opencode): let a new hook turn end a pane's process-exit completion A confirmed agent process exit records a pane-wide completion identity that names only the agent. Hook Dones are matched against it by agent alone, and only a working title cleared it, so in a pane whose agent paints no working title (OpenCode) every later hook-reported Done was treated as already notified: no notification and no unread mark. An unreported exit (a status-off `opencode run`, or quitting an idle client) was enough to set it. A fresh hook Working now clears a process-exit identity, since a new turn cannot be a duplicate of an earlier exit. Hook identities stay, so same-turn and replay dedupe is unchanged. --- ...dinator-process-exit-turn-boundary.test.ts | 105 ++++++++++++++++++ .../agent-completion-coordinator.ts | 1 + .../agent-completion-hook-observer.ts | 5 + .../agent-completion-identity-store.ts | 6 + 4 files changed, 117 insertions(+) create mode 100644 src/renderer/src/components/terminal-pane/agent-completion-coordinator-process-exit-turn-boundary.test.ts 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 000000000000..aeedd4f133bf --- /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 9b60b37ff1d3..f23a61492efc 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 e6e64ade5a84..698747a612eb 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 70a1f57a32bd..14d2f7361269 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) }, From 7b2d32624cc0b28b469d4dcb8c78a0aa87a378f0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:58:30 -0700 Subject: [PATCH 25/25] refactor(opencode): share the foreground read schedule as one value The pane tracker's import of the two shared foreground-read delays took four lines where its old local constants took three, which put the file one line over max-lines once merged with main. Export the settle delay and retry ladder as one object so each consumer imports a single name. No behaviour change: same 350 ms settle and 1200/6000 ms retries. --- .../runtime/opencode-run-lifetime-runtime.test.ts | 12 ++++++------ .../runtime/opencode-run-lifetime-status.test.ts | 11 ++++------- src/main/runtime/opencode-run-lifetime-status.ts | 9 +++------ .../terminal-pane/pane-foreground-agent-tracker.ts | 11 ++++------- src/shared/foreground-command-settle.ts | 3 +-- 5 files changed, 18 insertions(+), 28 deletions(-) diff --git a/src/main/runtime/opencode-run-lifetime-runtime.test.ts b/src/main/runtime/opencode-run-lifetime-runtime.test.ts index a3e8a3834864..4475abdb49fc 100644 --- a/src/main/runtime/opencode-run-lifetime-runtime.test.ts +++ b/src/main/runtime/opencode-run-lifetime-runtime.test.ts @@ -7,7 +7,7 @@ vi.mock('./local-pty-foreground-command-line', () => ({ })) import { OrcaRuntimeService } from './orca-runtime' -import { FOREGROUND_COMMAND_SETTLE_MS } from '../../shared/foreground-command-settle' +import { FOREGROUND_COMMAND_READS } from '../../shared/foreground-command-settle' import type { RuntimeTerminalAgentStatusEvent } from './runtime-terminal-contracts' const WORKTREE_ID = 'repo::/worktree' @@ -75,7 +75,7 @@ describe('OpenCode run process lifetime in the runtime', () => { const { runtime, statuses } = createRuntime() runtime.onPtyData('pty-1', '\x1b]133;C\x07', 100) - await vi.advanceTimersByTimeAsync(FOREGROUND_COMMAND_SETTLE_MS) + 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`]) @@ -88,7 +88,7 @@ describe('OpenCode run process lifetime in the runtime', () => { const { runtime, channelOrder } = createRuntime() runtime.onPtyData('pty-1', '\x1b]133;C\x07', 100) - await vi.advanceTimersByTimeAsync(FOREGROUND_COMMAND_SETTLE_MS) + 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']) @@ -98,7 +98,7 @@ describe('OpenCode run process lifetime in the runtime', () => { const { runtime, statuses, channelOrder } = createRuntime() runtime.onPtyData('pty-1', '\x1b]133;C\x07', 100) - await vi.advanceTimersByTimeAsync(FOREGROUND_COMMAND_SETTLE_MS) + 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']) @@ -113,7 +113,7 @@ describe('OpenCode run process lifetime in the runtime', () => { runtime.registerPty('pty-1', WORKTREE_ID, 'ssh-conn-1') runtime.onPtyData('pty-1', '\x1b]133;C\x07', 100) - await vi.advanceTimersByTimeAsync(FOREGROUND_COMMAND_SETTLE_MS) + await vi.advanceTimersByTimeAsync(FOREGROUND_COMMAND_READS.settleMs) runtime.onPtyData('pty-1', '\x1b]133;D;0\x07', 101) expect(statuses).toEqual([]) @@ -128,7 +128,7 @@ describe('OpenCode run process lifetime in the runtime', () => { readCommandLineMock.mockResolvedValue('"C:\\Tools\\opencode.exe" run fix it') runtime.onPtyData('pty-1', '\x1b]133;C\x07', 100) - await vi.advanceTimersByTimeAsync(FOREGROUND_COMMAND_SETTLE_MS) + 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`]) diff --git a/src/main/runtime/opencode-run-lifetime-status.test.ts b/src/main/runtime/opencode-run-lifetime-status.test.ts index 7e8eabe6d374..efaf0d8f0c44 100644 --- a/src/main/runtime/opencode-run-lifetime-status.test.ts +++ b/src/main/runtime/opencode-run-lifetime-status.test.ts @@ -1,9 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { ParsedAgentStatusPayload } from '../../shared/agent-status-types' -import { - FOREGROUND_COMMAND_RETRY_DELAYS_MS, - FOREGROUND_COMMAND_SETTLE_MS -} from '../../shared/foreground-command-settle' +import { FOREGROUND_COMMAND_READS } from '../../shared/foreground-command-settle' import { OpenCodeRunLifetimeStatus } from './opencode-run-lifetime-status' type Published = { ptyId: string; payload: ParsedAgentStatusPayload; yieldsToHookSince: number } @@ -42,7 +39,7 @@ function setup( } async function settle(): Promise { - await vi.advanceTimersByTimeAsync(FOREGROUND_COMMAND_SETTLE_MS) + await vi.advanceTimersByTimeAsync(FOREGROUND_COMMAND_READS.settleMs) } describe('OpenCodeRunLifetimeStatus', () => { @@ -106,7 +103,7 @@ describe('OpenCodeRunLifetimeStatus', () => { lifetime.onCommandStarted('pty-1') await settle() expect(states()).toEqual([]) - await vi.advanceTimersByTimeAsync(FOREGROUND_COMMAND_RETRY_DELAYS_MS[0]) + await vi.advanceTimersByTimeAsync(FOREGROUND_COMMAND_READS.retryDelaysMs[0]) expect(states()).toEqual(['working']) expect(readForegroundProcessName).toHaveBeenCalledTimes(2) } @@ -118,7 +115,7 @@ describe('OpenCodeRunLifetimeStatus', () => { lifetime.onCommandStarted('pty-1') await vi.advanceTimersByTimeAsync(60_000) expect(readForegroundProcessName).toHaveBeenCalledTimes( - 1 + FOREGROUND_COMMAND_RETRY_DELAYS_MS.length + 1 + FOREGROUND_COMMAND_READS.retryDelaysMs.length ) } }) diff --git a/src/main/runtime/opencode-run-lifetime-status.ts b/src/main/runtime/opencode-run-lifetime-status.ts index 80b2f54ebe4e..304e020f31ef 100644 --- a/src/main/runtime/opencode-run-lifetime-status.ts +++ b/src/main/runtime/opencode-run-lifetime-status.ts @@ -4,10 +4,7 @@ import { normalizeAgentStatusPayload, type ParsedAgentStatusPayload } from '../../shared/agent-status-types' -import { - FOREGROUND_COMMAND_RETRY_DELAYS_MS, - FOREGROUND_COMMAND_SETTLE_MS -} from '../../shared/foreground-command-settle' +import { FOREGROUND_COMMAND_READS } from '../../shared/foreground-command-settle' import { isOpenCodeRunCommand } from '../../shared/opencode-headless-command' import { isShellProcess } from '../../shared/shell-process-detection' @@ -73,7 +70,7 @@ export class OpenCodeRunLifetimeStatus { armed: null } this.commands.set(ptyId, state) - this.scheduleInspect(ptyId, state, FOREGROUND_COMMAND_SETTLE_MS, 0) + this.scheduleInspect(ptyId, state, FOREGROUND_COMMAND_READS.settleMs, 0) } onCommandFinished(ptyId: string, exitCode: number | null): void { @@ -125,7 +122,7 @@ export class OpenCodeRunLifetimeStatus { return } if (agent !== 'opencode' && agent !== 'opencode2') { - const retryDelay = FOREGROUND_COMMAND_RETRY_DELAYS_MS[retryIndex] + const retryDelay = FOREGROUND_COMMAND_READS.retryDelaysMs[retryIndex] if (retryDelay !== undefined && mayStillBecomeOpenCode(name)) { this.scheduleInspect(ptyId, state, retryDelay, retryIndex + 1) } 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 cef78edf3168..308ca14919a2 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,10 +9,7 @@ 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_RETRY_DELAYS_MS as WRAPPER_RESOLVE_RETRY_DELAYS_MS, - FOREGROUND_COMMAND_SETTLE_MS as COMMAND_SETTLE_MS -} from '../../../../shared/foreground-command-settle' +import { FOREGROUND_COMMAND_READS } from '../../../../shared/foreground-command-settle' const VISIBLE_PTY_SETTLE_MS = 350 type ForegroundReadReason = 'command' | 'visible-pty' | 'command-finished' @@ -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/shared/foreground-command-settle.ts b/src/shared/foreground-command-settle.ts index 029bc88cf502..b2a20041dd70 100644 --- a/src/shared/foreground-command-settle.ts +++ b/src/shared/foreground-command-settle.ts @@ -1,5 +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_SETTLE_MS = 350 -export const FOREGROUND_COMMAND_RETRY_DELAYS_MS = [1200, 6000] as const +export const FOREGROUND_COMMAND_READS = { settleMs: 350, retryDelaysMs: [1200, 6000] } as const