diff --git a/src/main/ai-vault/session-scanner-opencode-sources.test.ts b/src/main/ai-vault/session-scanner-opencode-sources.test.ts index b3a7983e607..17af8d83ee0 100644 --- a/src/main/ai-vault/session-scanner-opencode-sources.test.ts +++ b/src/main/ai-vault/session-scanner-opencode-sources.test.ts @@ -1,10 +1,16 @@ import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' -import { opencodeDiscoveries } from './session-scanner-opencode-sources' +import { opencode2Discoveries, opencodeDiscoveries } from './session-scanner-opencode-sources' -const { discoverOpenCodeSessionsMock, listOpenCodeDatabasesMock } = vi.hoisted(() => ({ - discoverOpenCodeSessionsMock: vi.fn(), - listOpenCodeDatabasesMock: vi.fn() +const { discoverOpenCodeSessionsMock, listOpenCodeDatabasesMock, listOpenCode2SessionsMock } = + vi.hoisted(() => ({ + discoverOpenCodeSessionsMock: vi.fn(), + listOpenCodeDatabasesMock: vi.fn(), + listOpenCode2SessionsMock: vi.fn() + })) + +vi.mock('./session-scanner-opencode-sqlite-worker-spawn', () => ({ + listOpenCode2SqliteSessionsViaWorker: listOpenCode2SessionsMock })) vi.mock('./session-scanner-opencode-sqlite-discovery', () => ({ @@ -21,6 +27,19 @@ describe('opencodeDiscoveries', () => { vi.clearAllMocks() }) + it('checks the shared database for v2 sessions as well as the beta databases', async () => { + const dbPaths = [join('/data', 'opencode.db'), join('/data', 'opencode-next.db')] + listOpenCode2SessionsMock.mockResolvedValue([]) + await Promise.all(opencode2Discoveries({ opencodeDbPaths: dbPaths }, [], 25, [])) + expect(listOpenCode2SessionsMock).toHaveBeenCalledWith({ dbPaths, limit: 25, issues: [] }) + await Promise.all(opencodeDiscoveries({ opencodeDbPaths: dbPaths }, [], 25, [])) + expect(discoverOpenCodeSessionsMock).toHaveBeenCalledWith( + expect.objectContaining({ + dbPaths: [dbPaths[0]] + }) + ) + }) + it('discovers local storage from the OpenCode XDG data directory', async () => { vi.stubEnv('XDG_DATA_HOME', '/xdg/data') vi.stubEnv('OPENCODE_CONFIG_DIR', '/opencode/config') diff --git a/src/main/ai-vault/session-scanner-opencode-sources.ts b/src/main/ai-vault/session-scanner-opencode-sources.ts index 07fb53b7e4e..bcb0b8b9c63 100644 --- a/src/main/ai-vault/session-scanner-opencode-sources.ts +++ b/src/main/ai-vault/session-scanner-opencode-sources.ts @@ -10,27 +10,6 @@ import { listOpenCode2SqliteSessionsViaWorker } from './session-scanner-opencode import { isOpenCodeV2DatabaseName } from '../../shared/opencode-database-name' import type { AiVaultScanOptions, SessionFileDiscovery } from './session-scanner-types' -// Why: opencode2 (beta) stores sessions in channel-scoped DBs -// (opencode-next.db / opencode-local.db) alongside the v1 opencode.db. Both -// match the `opencode*.db` glob, so paths are split by basename here: the v1 -// discovery never sees v2 DBs (different, beta-unstable schema) and vice versa. - -function splitDatabasePaths(dbPaths: readonly string[]): { - v1Paths: string[] - v2Paths: string[] -} { - const v1Paths: string[] = [] - const v2Paths: string[] = [] - for (const dbPath of dbPaths) { - if (isOpenCodeV2DatabaseName(basename(dbPath))) { - v2Paths.push(dbPath) - } else { - v1Paths.push(dbPath) - } - } - return { v1Paths, v2Paths } -} - export function opencodeDiscoveries( options: AiVaultScanOptions, wslHomeDirs: readonly string[], @@ -39,9 +18,8 @@ export function opencodeDiscoveries( ): Promise[] { const storageDirs = opencodeStorageDirs(options, wslHomeDirs) return storageDirs.map(async (storageDir, index) => { - const { v1Paths } = splitDatabasePaths( - await opencodeDbPathsForSource(options, wslHomeDirs, storageDir, index, issues) - ) + const dbPaths = await opencodeDbPathsForSource(options, wslHomeDirs, storageDir, index, issues) + const v1Paths = dbPaths.filter((path) => !isOpenCodeV2DatabaseName(basename(path))) return discoverOpenCodeSessions({ storageDir, dbPaths: v1Paths, limitPerAgent: limit, issues }) }) } @@ -53,9 +31,8 @@ export function opencode2Discoveries( issues: AiVaultScanIssue[] ): Promise[] { return opencodeStorageDirs(options, wslHomeDirs).map(async (storageDir, index) => { - const { v2Paths } = splitDatabasePaths( - await opencodeDbPathsForSource(options, wslHomeDirs, storageDir, index, issues) - ) + // Current releases share opencode.db with v1; the worker checks for v2 tables. + const v2Paths = await opencodeDbPathsForSource(options, wslHomeDirs, storageDir, index, issues) return v2Paths.length > 0 ? discoverOpenCode2Sessions(storageDir, v2Paths, limit, issues) : emptyOpenCode2Discovery(storageDir) @@ -80,8 +57,7 @@ async function opencodeDbPathsForSource( issues: AiVaultScanIssue[] ): Promise { if (options.opencodeDbPaths) { - const split = splitDatabasePaths(sourceIndex === 0 ? options.opencodeDbPaths : []) - return [...split.v1Paths, ...split.v2Paths] + return sourceIndex === 0 ? options.opencodeDbPaths : [] } // Why: custom OpenCode storage roots still keep SQLite DBs in the parent data dir. if (sourceIndex === 0 && options.opencodeStorageDir) { diff --git a/src/main/ai-vault/session-scanner-opencode2-sqlite.test.ts b/src/main/ai-vault/session-scanner-opencode2-sqlite.test.ts index 0283a3a8180..feeaa3a7b3d 100644 --- a/src/main/ai-vault/session-scanner-opencode2-sqlite.test.ts +++ b/src/main/ai-vault/session-scanner-opencode2-sqlite.test.ts @@ -6,6 +6,7 @@ import Database from '../sqlite/sync-database' import { buildOpenCodeSqliteCandidatePath } from './session-scanner-opencode-sqlite-paths' import { listOpenCode2SqliteSessions } from './session-scanner-opencode2-sqlite-list' import { parseOpenCode2SqliteSession } from './session-scanner-opencode2-sqlite' +import { withFullFirstUserPromptCapture } from './session-scanner-first-user-prompt-capture' import type { AiVaultScanIssue } from '../../shared/ai-vault-types' // Why: the opencode2 (beta) channel-scoped DB schema differs from v1 — @@ -209,7 +210,6 @@ describe('parseOpenCode2SqliteSession', () => { timeCreated: 1_777_634_000_500, data: JSON.stringify({ id: 'msg_1', - type: 'user', text: 'Add login flow', time: { created: 1_777_634_000_500 } }) @@ -229,17 +229,20 @@ describe('parseOpenCode2SqliteSession', () => { }) db.close() - const session = await parseOpenCode2SqliteSession({ - dbPath: path, - sessionId: 'session_1', - platform: 'darwin' - }) + const session = await withFullFirstUserPromptCapture(() => + parseOpenCode2SqliteSession({ + dbPath: path, + sessionId: 'session_1', + platform: 'darwin' + }) + ) expect(session).not.toBeNull() expect(session!.agent).toBe('opencode2') expect(session!.sessionId).toBe('session_1') expect(session!.filePath).toBe(path) expect(session!.title).toBe('Fix login') + expect(session?.firstUserPrompt).toBe('Add login flow') expect(session!.cwd).toBe('/repo') expect(session!.model).toBe('glm-5.2') expect(session!.totalTokens).toBe(35) @@ -256,7 +259,9 @@ describe('parseOpenCode2SqliteSession', () => { timestamp: new Date(1_777_634_000_900).toISOString() } ]) - expect(session!.resumeCommand).toBe("cd '/repo' && opencode2 --session 'session_1'") + expect(session!.resumeCommand).toBe( + "cd '/repo' && opencode2 --standalone --session 'session_1'" + ) }) it('extracts assistant text from content arrays and falls back to raw model ids', async () => { diff --git a/src/main/ai-vault/session-scanner-opencode2-sqlite.ts b/src/main/ai-vault/session-scanner-opencode2-sqlite.ts index 50707d6b6e2..d16203fca6b 100644 --- a/src/main/ai-vault/session-scanner-opencode2-sqlite.ts +++ b/src/main/ai-vault/session-scanner-opencode2-sqlite.ts @@ -205,7 +205,6 @@ function readFirstUserPromptFromDb(db: SyncDatabase, sessionId: string): string `SELECT data FROM ${OPENCODE2_MESSAGE_TABLE} WHERE session_id = ? AND type = 'user' - AND data LIKE '%"type":"user"%' ORDER BY time_created ASC, seq ASC LIMIT 1` ) diff --git a/src/main/ai-vault/session-scanner.test.ts b/src/main/ai-vault/session-scanner.test.ts index d8723c5209e..c64a4987b85 100644 --- a/src/main/ai-vault/session-scanner.test.ts +++ b/src/main/ai-vault/session-scanner.test.ts @@ -421,7 +421,7 @@ describe('scanAiVaultSessions', () => { "cd '/tmp/opencode' && opencode --session 'opencode-session'" ) expect(commandByAgent.get('opencode2')).toBe( - "cd '/tmp/opencode2' && opencode2 --session 'opencode2-session'" + "cd '/tmp/opencode2' && opencode2 --standalone --session 'opencode2-session'" ) expect(commandByAgent.get('grok')).toBe("cd '/tmp/grok' && grok --resume 'grok-session'") expect(commandByAgent.get('hermes')).toBe( diff --git a/src/main/opencode/hook-plugin-module-contract.test.ts b/src/main/opencode/hook-plugin-module-contract.test.ts index c06e2346d21..bc90061b82b 100644 --- a/src/main/opencode/hook-plugin-module-contract.test.ts +++ b/src/main/opencode/hook-plugin-module-contract.test.ts @@ -30,7 +30,11 @@ describe('OpenCode status plugin module contract', () => { dispose?: () => Promise } type PluginModule = { - default?: { id?: unknown; server?: (ctx: unknown) => Promise } + default?: { + id?: unknown + server?: (ctx: unknown) => Promise + setup?: (ctx: unknown) => Promise<() => Promise> + } OrcaOpenCodeStatusPlugin?: (ctx: unknown) => Promise } @@ -158,6 +162,54 @@ describe('OpenCode status plugin module contract', () => { }) }) + it('subscribes through the OpenCode 2 setup API and disposes its registrations', async () => { + process.env.ORCA_PANE_KEY = 'tab-1:leaf-1' + const posts: unknown[] = [] + globalThis.fetch = vi.fn(async (_input, init) => { + posts.push(JSON.parse(String(init?.body))) + return new Response('{}', { status: 200 }) + }) + const dispose = vi.fn() + let subscriptionSignal: AbortSignal | undefined + const module = await loadPluginModule(_internals.getOpenCode2PluginSource()) + expect(module.default?.setup).toBeTypeOf('function') + const cleanup = await module.default?.setup?.({ + session: { + get: async ({ sessionID }: { sessionID: string }) => ({ data: { id: sessionID } }), + hook: async () => ({ dispose }) + }, + event: { + subscribe: async function* ({ signal }: { signal: AbortSignal }) { + subscriptionSignal = signal + yield { type: 'session.created', data: { sessionID: 'ses_root' } } + yield { + type: 'session.execution.started', + data: { sessionID: 'ses_root' } + } + yield { + type: 'session.execution.succeeded', + data: { sessionID: 'ses_root' } + } + } + } + }) + await vi.waitFor(() => { + expect(posts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + payload: expect.objectContaining({ hook_event_name: 'SessionBusy' }) + }), + expect.objectContaining({ + payload: expect.objectContaining({ hook_event_name: 'SessionIdle' }) + }) + ]) + ) + }) + await cleanup?.() + expect(dispose).toHaveBeenCalledOnce() + expect(subscriptionSignal?.aborted).toBe(true) + }) + it('turns OpenCode 2 step lifecycle events into working and done hooks', async () => { process.env.ORCA_PANE_KEY = 'tab-1:leaf-1' const posts: { body: Record }[] = [] diff --git a/src/main/opencode/status-plugin-factory-source.ts b/src/main/opencode/status-plugin-factory-source.ts index b227ddf3b8f..9438be0d74a 100644 --- a/src/main/opencode/status-plugin-factory-source.ts +++ b/src/main/opencode/status-plugin-factory-source.ts @@ -1,30 +1,14 @@ +import { + getOpenCode2SetupSource, + getOpenCode2EventNormalizationSource +} from '../opencode2/status-plugin-setup-source' + export function getStatusPluginFactorySource(options: { emitSessionStart: boolean emitNextEvents?: boolean }): string[] { return [ - ...(options.emitNextEvents - ? [ - '', - 'function normalizeNextLifecycleEvent(event) {', - ' if (!event || typeof event.type !== "string") return event;', - ' const properties = event.properties || {};', - ' if (event.type === "permission.v2.asked") return { ...event, type: "permission.asked", properties: { ...properties, id: properties.id, permission: properties.action, patterns: properties.resources } };', - ' if (event.type === "permission.v2.replied") return { ...event, type: "permission.replied", properties: { ...properties } };', - ' if (event.type === "question.v2.asked") return { ...event, type: "question.asked", properties: { ...properties } };', - ' if (event.type === "question.v2.replied") return { ...event, type: "question.replied", properties: { ...properties } };', - ' if (event.type === "question.v2.rejected") return { ...event, type: "question.rejected", properties: { ...properties } };', - ' if (event.type === "session.next.step.started" || event.type === "session.next.tool.called" || event.type === "session.next.tool.progress" || event.type === "session.next.retried") {', - ' return { ...event, type: "session.status", properties: { ...properties, status: { type: "busy" } } };', - ' }', - ' if (event.type === "session.next.step.ended" || event.type === "session.next.step.failed") {', - ' return { ...event, type: "session.status", properties: { ...properties, status: { type: "idle" } } };', - ' }', - ' return event;', - '}', - '' - ] - : []), + ...(options.emitNextEvents ? getOpenCode2EventNormalizationSource() : []), '// Why: accept the factory argument as an optional opaque parameter instead', '// of destructuring (`async ({ client }) => …`). OpenCode can invoke the', '// plugin factory with undefined during startup, which makes the', @@ -290,10 +274,17 @@ export function getStatusPluginFactorySource(options: { ' },', ' };', '};', - '// Why: OpenCode also resolves plugins through the module default export, which must expose server(); keep the named factory too.', + ...(options.emitNextEvents ? getOpenCode2SetupSource() : []), + '', + '// Why: OpenCode also resolves plugins through the module default export, and that', + '// loader rejects the module unless the default exposes `server()` ("must default', + '// export an object with server()"). `setup()` does not satisfy it. Keep the named', + '// export so the factory-based loader still finds the same instance.', 'export default {', ' id: "orca-opencode-status",', ' server: OrcaOpenCodeStatusPlugin,', - '};' + ...(options.emitNextEvents ? [' setup: setupOpenCode2Status,'] : []), + '};', + '' ] } diff --git a/src/main/opencode2/hook-service.ts b/src/main/opencode2/hook-service.ts index c1099ecb6e3..ae8cd3401f3 100644 --- a/src/main/opencode2/hook-service.ts +++ b/src/main/opencode2/hook-service.ts @@ -1,4 +1,2 @@ -// OpenCode 2 uses the same supported server-plugin loader as OpenCode. The -// variant is kept under the opencode module so config overlays and plugin -// filenames stay isolated from the legacy agent. +// Share overlay management while keeping the v2 plugin and config isolated. export { openCode2HookService as openCode2ConfigHookService } from '../opencode/hook-service' diff --git a/src/main/opencode2/status-plugin-setup-source.ts b/src/main/opencode2/status-plugin-setup-source.ts new file mode 100644 index 00000000000..78991418b29 --- /dev/null +++ b/src/main/opencode2/status-plugin-setup-source.ts @@ -0,0 +1,80 @@ +export function getOpenCode2SetupSource(): string[] { + return String.raw` +async function setupOpenCode2Status(ctx) { + const controller = new AbortController(); + const client = { session: { get: (input, options) => ctx.session.get(input, options) } }; + const hooks = await OrcaOpenCodeStatusPlugin({ client }); + const promptRegistration = await ctx.session.hook("prompt", async (properties) => { + await hooks.event({ event: { type: "session.next.prompt.admitted", properties } }); + }); + 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") { + type = "question.asked"; + const form = properties.form; + properties = { + ...form, + questions: form.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") { + 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 consuming = consume().catch((error) => { + if (!controller.signal.aborted) console.warn("[orca-hook] event subscription failed:", error.message); + }); + return async () => { + controller.abort(); + await promptRegistration.dispose(); + await consuming; + await hooks.dispose(); + }; +} +`.split('\n') +} + +export function getOpenCode2EventNormalizationSource(): string[] { + return [ + '', + 'function normalizeNextLifecycleEvent(event) {', + ' if (!event || typeof event.type !== "string") return event;', + ' const properties = event.properties || {};', + ' if (event.type === "permission.v2.asked") return { ...event, type: "permission.asked", properties: { ...properties, id: properties.id, permission: properties.action, patterns: properties.resources } };', + ' if (event.type === "permission.v2.replied") return { ...event, type: "permission.replied", properties: { ...properties } };', + ' if (event.type === "question.v2.asked") return { ...event, type: "question.asked", properties: { ...properties } };', + ' if (event.type === "question.v2.replied") return { ...event, type: "question.replied", properties: { ...properties } };', + ' if (event.type === "question.v2.rejected") return { ...event, type: "question.rejected", properties: { ...properties } };', + ' if (event.type === "session.next.step.started" || event.type === "session.next.tool.called" || event.type === "session.next.tool.progress" || event.type === "session.next.retried") {', + ' return { ...event, type: "session.status", properties: { ...properties, status: { type: "busy" } } };', + ' }', + ' if (event.type === "session.next.step.ended" || event.type === "session.next.step.failed") {', + ' return { ...event, type: "session.status", properties: { ...properties, status: { type: "idle" } } };', + ' }', + ' return event;', + '}', + '' + ] +} diff --git a/src/shared/agent-session-resume.ts b/src/shared/agent-session-resume.ts index 3567bc6ba6b..1a1be8200eb 100644 --- a/src/shared/agent-session-resume.ts +++ b/src/shared/agent-session-resume.ts @@ -263,7 +263,9 @@ export function getAgentResumeArgv( case 'opencode': return providerSession.key === 'session_id' ? ['opencode', '--session', id] : null case 'opencode2': - return providerSession.key === 'session_id' ? ['opencode2', '--session', id] : null + return providerSession.key === 'session_id' + ? ['opencode2', '--standalone', '--session', id] + : null case 'pi': return providerSession.key === 'session_id' && providerSession.transcriptPath ? ['pi', '--session', providerSession.transcriptPath] diff --git a/src/shared/ai-vault-resume-command.ts b/src/shared/ai-vault-resume-command.ts index a106d3ae0b8..da4cd29e038 100644 --- a/src/shared/ai-vault-resume-command.ts +++ b/src/shared/ai-vault-resume-command.ts @@ -200,8 +200,9 @@ function buildAgentResumeInvocation( return `${baseCommand} resume ${sessionArg}` case 'rovo': return `${baseCommand} rovodev run --restore ${sessionArg}` - case 'opencode': case 'opencode2': + return `${baseCommand} --standalone --session ${sessionArg}` + case 'opencode': case 'pi': // Why: Kimi Code resumes with `kimi --session ` (alias `-S`). Sessions // are work-dir-scoped, so the cwd prefix from buildAiVaultResumeCommand is diff --git a/src/shared/opencode-database-name.ts b/src/shared/opencode-database-name.ts index 665a4a34635..79bc4f49200 100644 --- a/src/shared/opencode-database-name.ts +++ b/src/shared/opencode-database-name.ts @@ -1,9 +1,4 @@ -// Why: the opencode2 beta stores sessions in a channel-scoped SQLite DB -// (opencode-next.db on the default channel, opencode-local.db on the dev -// channel) while opencode v1 uses opencode.db plus stale sibling copies. Both -// match the `opencode*.db` glob used by Orca's v1 scanners, so callers must -// classify before parsing: the v2 schema (session_v2/session_message) is -// explicitly unstable in beta and must never reach the v1 parsers. +// Beta-only database names; current v2 releases share opencode.db with v1. export const OPENCODE_V2_DATABASE_NAME_RE = /^opencode-(?:next|local)\.db$/i export function isOpenCodeV2DatabaseName(name: string): boolean { diff --git a/src/shared/tui-agent-config.ts b/src/shared/tui-agent-config.ts index 00aecd9d106..46d382e7075 100644 --- a/src/shared/tui-agent-config.ts +++ b/src/shared/tui-agent-config.ts @@ -132,7 +132,8 @@ const TUI_AGENT_CONFIG_SOURCE: Record = { // Its @opentui composer keeps the same cursor-gated paste signal. opencode2: { detectCmd: 'opencode2', - launchCmd: 'opencode2', + // The private server inherits this pane's hook endpoint and identity. + launchCmd: 'opencode2 --standalone', expectedProcess: 'opencode2', promptInjectionMode: 'flag-prompt', draftPasteReadySignal: 'render-cursor-after-bracketed-paste'