From ee354a35d7206e3b8fe0ce06ae3ac9e72dc5fafd Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 19 Sep 2026 17:49:03 -0700 Subject: [PATCH] feat(agents): add OpenCode 2 beta support (#21418) * feat(agents): add OpenCode 2 beta support Co-authored-by: Xiro The Dev * fix(opencode2): support current plugin lifecycle and session storage * fix(opencode2): preserve lifecycle ordering and full session capture * test(opencode2): cover setup event bridge * test(opencode2): cover setup event bridge * test(browser): satisfy anti-slop naming check * test(opencode2): cover live form lifecycle * fix(relay): preserve OMP config directory selection * test(opencode2): avoid assertions in bridge fixture * fix(rebase): retain OMP resume and fresh launch behavior * test: align upstream OMP resume expectations * test(opencode2): verify rejected form closes waiting state --------- Co-authored-by: Xiro The Dev --- .../server-opencode-normalization.test.ts | 38 +- .../server-retired-pane-new-turn.test.ts | 1 + .../agent-hooks/wsl-guest-plugin-install.ts | 17 +- src/main/agent-hooks/wsl-hook-relay-deps.ts | 7 +- .../wsl-hook-relay-guest-install.ts | 2 + .../wsl-hook-relay-manager.test.ts | 15 +- .../agent-hooks/wsl-hook-relay-manager.ts | 17 +- .../session-search-opencode-index.test.ts | 3 + .../opencode-transcript-capture-limits.ts | 3 + .../ai-vault/session-scanner-agent-parser.ts | 27 ++ .../session-scanner-every-agent-fixture.ts | 3 +- ...scanner-opencode-sources-wsl-stall.test.ts | 8 +- .../session-scanner-opencode-sources.test.ts | 25 +- .../session-scanner-opencode-sources.ts | 42 +- ...session-scanner-opencode-sqlite-capture.ts | 35 +- ...canner-opencode-sqlite-coexistence.test.ts | 2 + ...n-scanner-opencode-sqlite-worker-client.ts | 25 +- ...on-scanner-opencode-sqlite-worker-entry.ts | 48 ++- ...scanner-opencode-sqlite-worker-protocol.ts | 6 + ...on-scanner-opencode-sqlite-worker-spawn.ts | 28 ++ .../session-scanner-opencode2-message.test.ts | 35 ++ .../session-scanner-opencode2-message.ts | 59 +++ .../session-scanner-opencode2-sqlite-list.ts | 127 ++++++ .../session-scanner-opencode2-sqlite.test.ts | 380 ++++++++++++++++++ .../session-scanner-opencode2-sqlite.ts | 340 ++++++++++++++++ .../ai-vault/session-scanner-parse-cache.ts | 1 + .../ai-vault/session-scanner-test-fixtures.ts | 50 +++ src/main/ai-vault/session-scanner.test.ts | 27 ++ ...ion-transcript-every-agent-capture.test.ts | 16 +- src/main/ipc/pty/host-env/assembly.ts | 15 +- .../scanner-windows-data-directory.test.ts | 7 +- .../hook-plugin-module-contract.test.ts | 216 +++++++++- .../hook-plugin-opencode2-setup.test.ts | 285 +++++++++++++ src/main/opencode/hook-service.test.ts | 17 +- src/main/opencode/hook-service.ts | 63 ++- .../opencode/status-plugin-factory-source.ts | 72 +++- .../opencode2/status-plugin-setup-source.ts | 77 ++++ .../ssh-relay-session-managed-hooks.test.ts | 1 + src/main/ssh/ssh-relay-session.ts | 1 + src/relay/agent-hook-integration.test.ts | 14 +- src/relay/plugin-overlay.test.ts | 15 + src/relay/plugin-overlay.ts | 72 +++- src/relay/relay-agent-hook-runtime.ts | 15 +- src/relay/wsl-install-plugins-handler.test.ts | 49 ++- src/relay/wsl-install-plugins-handler.ts | 42 +- src/renderer/src/i18n/locales/en.json | 3 +- src/renderer/src/lib/agent-catalog.tsx | 9 + src/renderer/src/lib/agent-status.ts | 1 + ...t-resume-host-authority-capability.test.ts | 8 + .../agent-resume-host-authority-capability.ts | 2 + .../agent-hook-listener-transport.test.ts | 1 + .../agent-hook-listener/provider-dispatch.ts | 3 +- .../provider-event-routing.ts | 7 +- .../providers/opencode-family-events.ts | 9 +- .../agent-hook-listener/source-routing.ts | 1 + src/shared/agent-hook-relay.ts | 1 + src/shared/agent-kind.ts | 1 + src/shared/agent-name-token-match.ts | 1 + src/shared/agent-session-resume.ts | 6 + src/shared/agent-type-label.ts | 1 + src/shared/ai-vault-resume-command.ts | 2 + src/shared/ai-vault-types.ts | 2 + src/shared/commit-message-agent-spec.test.ts | 45 +++ .../commit-message-agent-specs-primary.ts | 23 ++ src/shared/opencode-database-name.ts | 6 + src/shared/opencode-launch-command.test.ts | 15 + src/shared/opencode-launch-command.ts | 8 + .../pane-agent-identity-inventory.test.ts | 2 +- src/shared/protocol-version.ts | 3 + src/shared/skills-cli-agent-keys.ts | 1 + .../source-control-ai-action-recipes.test.ts | 2 +- src/shared/telemetry-property-schemas.ts | 1 + src/shared/terminal-title-agent-type.ts | 4 + src/shared/tui-agent-config.ts | 10 + src/shared/tui-agent-display-names.ts | 1 + src/shared/tui-agent-selection.ts | 1 + src/shared/tui-agent.ts | 1 + 77 files changed, 2349 insertions(+), 180 deletions(-) create mode 100644 src/main/ai-vault/opencode-transcript-capture-limits.ts create mode 100644 src/main/ai-vault/session-scanner-opencode2-message.test.ts create mode 100644 src/main/ai-vault/session-scanner-opencode2-message.ts create mode 100644 src/main/ai-vault/session-scanner-opencode2-sqlite-list.ts create mode 100644 src/main/ai-vault/session-scanner-opencode2-sqlite.test.ts create mode 100644 src/main/ai-vault/session-scanner-opencode2-sqlite.ts create mode 100644 src/main/opencode/hook-plugin-opencode2-setup.test.ts create mode 100644 src/main/opencode2/status-plugin-setup-source.ts create mode 100644 src/shared/opencode-database-name.ts create mode 100644 src/shared/opencode-launch-command.test.ts create mode 100644 src/shared/opencode-launch-command.ts diff --git a/src/main/agent-hooks/server-opencode-normalization.test.ts b/src/main/agent-hooks/server-opencode-normalization.test.ts index adec0a8048f..56bb0b063b8 100644 --- a/src/main/agent-hooks/server-opencode-normalization.test.ts +++ b/src/main/agent-hooks/server-opencode-normalization.test.ts @@ -26,26 +26,26 @@ afterEach(() => { vi.restoreAllMocks() }) -describe('OpenCode hook normalization', () => { +describe.each(['opencode', 'opencode2'] as const)('%s hook normalization', (source) => { it('SessionBusy maps to working', () => { const result = _internals.normalizeHookPayload( - 'opencode', + source, buildBody({ hook_event_name: 'SessionBusy' }), 'production' ) expect(result?.payload.state).toBe('working') - expect(result?.payload.agentType).toBe('opencode') + expect(result?.payload.agentType).toBe(source) }) it('SessionBusy does NOT clear the cached user prompt', () => { // Why: OpenCode caches the user's MessagePart before SessionBusy fires, so the cached prompt is this turn's; clearing it would clobber the dashboard. _internals.normalizeHookPayload( - 'opencode', + source, buildBody({ hook_event_name: 'MessagePart', role: 'user', text: 'new prompt' }), 'production' ) const result = _internals.normalizeHookPayload( - 'opencode', + source, buildBody({ hook_event_name: 'SessionBusy' }), 'production' ) @@ -55,17 +55,17 @@ describe('OpenCode hook normalization', () => { it('SessionIdle maps to done', () => { const result = _internals.normalizeHookPayload( - 'opencode', + source, buildBody({ hook_event_name: 'SessionIdle' }), 'production' ) expect(result?.payload.state).toBe('done') - expect(result?.payload.agentType).toBe('opencode') + expect(result?.payload.agentType).toBe(source) }) it('PermissionRequest maps to waiting', () => { const result = _internals.normalizeHookPayload( - 'opencode', + source, buildBody({ hook_event_name: 'PermissionRequest' }), 'production' ) @@ -75,17 +75,17 @@ describe('OpenCode hook normalization', () => { it('AskUserQuestion maps to waiting', () => { // Why: AskUserQuestion leaves the agent idle-but-waiting on a human, so it must map to `waiting` (red dot) like permission.asked, not stay `working`. const result = _internals.normalizeHookPayload( - 'opencode', + source, buildBody({ hook_event_name: 'AskUserQuestion' }), 'production' ) expect(result?.payload.state).toBe('waiting') - expect(result?.payload.agentType).toBe('opencode') + expect(result?.payload.agentType).toBe(source) }) it('unknown event name returns null', () => { const result = _internals.normalizeHookPayload( - 'opencode', + source, buildBody({ hook_event_name: 'SomeOtherEvent' }), 'production' ) @@ -94,7 +94,7 @@ describe('OpenCode hook normalization', () => { it('MessagePart with role=user surfaces text as the prompt and stays working', () => { const result = _internals.normalizeHookPayload( - 'opencode', + source, buildBody({ hook_event_name: 'MessagePart', role: 'user', @@ -106,12 +106,12 @@ describe('OpenCode hook normalization', () => { expect(result?.payload.state).toBe('working') expect(result?.payload.prompt).toBe('hi there') expect(result?.hasExplicitPrompt).toBe(true) - expect(result?.promptInteractionKey).toBe('opencode-message-msg-1') + expect(result?.promptInteractionKey).toBe(`${source}-message-msg-1`) }) it('MessagePart with role=assistant populates lastAssistantMessage', () => { const result = _internals.normalizeHookPayload( - 'opencode', + source, buildBody({ hook_event_name: 'MessagePart', role: 'assistant', @@ -126,7 +126,7 @@ describe('OpenCode hook normalization', () => { it('caps oversized MessagePart text from stale (pre-throttle) plugin builds', () => { // Why: stale plugin builds re-post the full reply on every part update, so the listener must cap the text to keep per-event work O(cap). const assistant = _internals.normalizeHookPayload( - 'opencode', + source, buildBody({ hook_event_name: 'MessagePart', role: 'assistant', @@ -138,7 +138,7 @@ describe('OpenCode hook normalization', () => { // Why: prompt is capped at 200 by normalizeAgentStatusObject; assert oversized input still stays within that bound. const user = _internals.normalizeHookPayload( - 'opencode', + source, buildBody({ hook_event_name: 'MessagePart', role: 'user', @@ -152,17 +152,17 @@ describe('OpenCode hook normalization', () => { it('subsequent SessionIdle preserves cached prompt + assistant message', () => { _internals.normalizeHookPayload( - 'opencode', + source, buildBody({ hook_event_name: 'MessagePart', role: 'user', text: 'hi' }), 'production' ) _internals.normalizeHookPayload( - 'opencode', + source, buildBody({ hook_event_name: 'MessagePart', role: 'assistant', text: 'hello back' }), 'production' ) const done = _internals.normalizeHookPayload( - 'opencode', + source, buildBody({ hook_event_name: 'SessionIdle' }), 'production' ) diff --git a/src/main/agent-hooks/server-retired-pane-new-turn.test.ts b/src/main/agent-hooks/server-retired-pane-new-turn.test.ts index 117fa50970b..3466cb52af9 100644 --- a/src/main/agent-hooks/server-retired-pane-new-turn.test.ts +++ b/src/main/agent-hooks/server-retired-pane-new-turn.test.ts @@ -38,6 +38,7 @@ const NEW_TURN_EVENT: Record = { hermes: 'pre_llm_call', devin: 'UserPromptSubmit', opencode: 'SessionStart', + opencode2: 'SessionStart', 'mimo-code': null, 'command-code': null } diff --git a/src/main/agent-hooks/wsl-guest-plugin-install.ts b/src/main/agent-hooks/wsl-guest-plugin-install.ts index 5d0d189e73f..a8a84b50c17 100644 --- a/src/main/agent-hooks/wsl-guest-plugin-install.ts +++ b/src/main/agent-hooks/wsl-guest-plugin-install.ts @@ -16,7 +16,7 @@ type GuestPluginInstallDeps = { * with `unavailable` (no handler / teardown): only `none` means the previously * recorded dir is now unusable and must stop being advertised to PTYs. */ export type GuestOverlayResult = - | { kind: 'dir'; dir: string } + | { kind: 'dir'; dir?: string; dir2?: string } | { kind: 'none' } | { kind: 'unavailable' } @@ -26,13 +26,24 @@ export async function requestGuestOpenCodeOverlayDir( distro: string ): Promise { try { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime validation or the local test fixture establishes the asserted shape. const res = (await mux.request(AGENT_HOOK_INSTALL_PLUGINS_METHOD, deps.pluginSources())) as { - overlayDirs?: { opencode?: unknown } + overlayDirs?: { opencode?: unknown; opencode2?: unknown } } const dir = res?.overlayDirs?.opencode - return typeof dir === 'string' && dir.length > 0 ? { kind: 'dir', dir } : { kind: 'none' } + const dir2 = res?.overlayDirs?.opencode2 + const opencodeDir = typeof dir === 'string' && dir.length > 0 ? dir : undefined + const opencode2Dir = typeof dir2 === 'string' && dir2.length > 0 ? dir2 : undefined + return opencodeDir || opencode2Dir + ? { + kind: 'dir', + ...(opencodeDir ? { dir: opencodeDir } : {}), + ...(opencode2Dir ? { dir2: opencode2Dir } : {}) + } + : { kind: 'none' } } catch (err) { // Why: -32601 = older guest bundle without the handler; CONNECTION_LOST/DISPOSED = routine mid-flight teardown — swallow both. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime validation or the local test fixture establishes the asserted shape. const code = (err as { code?: unknown })?.code if (code === -32601 || code === 'CONNECTION_LOST' || code === 'DISPOSED' || mux.isDisposed()) { return { kind: 'unavailable' } diff --git a/src/main/agent-hooks/wsl-hook-relay-deps.ts b/src/main/agent-hooks/wsl-hook-relay-deps.ts index cd9c1929853..30805b02cf1 100644 --- a/src/main/agent-hooks/wsl-hook-relay-deps.ts +++ b/src/main/agent-hooks/wsl-hook-relay-deps.ts @@ -9,7 +9,7 @@ import { AGENT_STATUS_LEGACY_UNADVERTISED_PEER_CAPABILITIES } from '../../shared import { agentHookServer } from './server' import type { ManagedHookDetectionSettings } from './managed-hook-detection-commands' import { installRemoteManagedAgentHooks } from './remote-managed-hook-installers' -import { getOpenCodePluginSource } from '../opencode/hook-service' +import { getOpenCode2PluginSource, getOpenCodePluginSource } from '../opencode/hook-service' import { codexHookService } from '../codex/hook-service' import type { AgentHookInstallStatus } from '../../shared/agent-hook-types' import type { PluginSources } from '../../relay/plugin-overlay' @@ -119,7 +119,10 @@ export const defaultWslHookRelayDeps: WslHookRelayManagerDeps = { }), managedHookSettings: () => null, // Why: only OpenCode is in scope for WSL now; the payload shape stays identical to SSH so Pi/OMP are additive later. - pluginSources: () => ({ opencodePluginSource: getOpenCodePluginSource() }), + pluginSources: () => ({ + opencodePluginSource: getOpenCodePluginSource(), + opencode2PluginSource: getOpenCode2PluginSource() + }), warn: (message) => console.warn(message), transientRetryDelayMs: WSL_RELAY_TRANSIENT_RETRY_DELAY_MS } diff --git a/src/main/agent-hooks/wsl-hook-relay-guest-install.ts b/src/main/agent-hooks/wsl-hook-relay-guest-install.ts index 84cf4bfa57a..e985664e403 100644 --- a/src/main/agent-hooks/wsl-hook-relay-guest-install.ts +++ b/src/main/agent-hooks/wsl-hook-relay-guest-install.ts @@ -26,6 +26,7 @@ type GuestInstallState = { guestHome?: string codexHomePath?: string opencodeOverlayDir?: string + opencode2OverlayDir?: string lastInstallAt?: number } @@ -53,6 +54,7 @@ export async function runWslRelayGuestInstall( // Clearing on 'none' matters: a rebuild that failed after wiping leaves the dir // present but plugin-less, and advertising it would hide the user's own config. state.opencodeOverlayDir = overlay.kind === 'dir' ? overlay.dir : undefined + state.opencode2OverlayDir = overlay.kind === 'dir' ? overlay.dir2 : undefined } } diff --git a/src/main/agent-hooks/wsl-hook-relay-manager.test.ts b/src/main/agent-hooks/wsl-hook-relay-manager.test.ts index 4f4607f423d..9b27e4c2207 100644 --- a/src/main/agent-hooks/wsl-hook-relay-manager.test.ts +++ b/src/main/agent-hooks/wsl-hook-relay-manager.test.ts @@ -150,6 +150,7 @@ describe('WslHookRelayManager', () => { const codexHome = '\\\\wsl.localhost\\Ubuntu\\home\\wsl-test-user\\.local\\share\\orca\\codex-runtime-home\\home' const opencodeOverlayDir = `${home}/.orca-relay/opencode-overlays/deadbeefcafe` + const opencode2OverlayDir = `${home}/.orca-relay/opencode2-overlays/deadbeefcafe` let harnesses: GuestHarness[] beforeEach(() => { @@ -200,8 +201,8 @@ describe('WslHookRelayManager', () => { // A guest bundle predating the plugin overlay omits this handler (-32601). if (registerInstallPlugins) { harness.guestDispatcher.onRequest(AGENT_HOOK_INSTALL_PLUGINS_METHOD, async () => ({ - installed: { opencode: true, pi: false, omp: false }, - overlayDirs: { opencode: opencodeOverlayDir } + installed: { opencode: true, opencode2: true, pi: false, omp: false }, + overlayDirs: { opencode: opencodeOverlayDir, opencode2: opencode2OverlayDir } })) } return harness.transport @@ -323,6 +324,16 @@ describe('WslHookRelayManager', () => { manager.disposeAll() }) + it('keeps the OpenCode 2 guest overlay separate', async () => { + const { manager } = createManager({}) + manager.ensureForDistro('Ubuntu', codexHome) + await vi.waitFor(() => + expect(manager.getOpenCodeOverlayDir('Ubuntu', 'opencode2')).toBe(opencode2OverlayDir) + ) + expect(manager.getOpenCodeOverlayDir('Ubuntu', 'opencode')).toBe(opencodeOverlayDir) + manager.disposeAll() + }) + it('leaves the overlay dir null when the guest bundle lacks the installPlugins handler', async () => { const waitForSentinel = vi.fn(async () => guestTransport({ registerInstallPlugins: false })) const { manager, deps } = createManager({ waitForSentinel }) diff --git a/src/main/agent-hooks/wsl-hook-relay-manager.ts b/src/main/agent-hooks/wsl-hook-relay-manager.ts index 1a414df5898..696800b6ca5 100644 --- a/src/main/agent-hooks/wsl-hook-relay-manager.ts +++ b/src/main/agent-hooks/wsl-hook-relay-manager.ts @@ -1,6 +1,3 @@ -// Host-side lifecycle manager for the guest-resident WSL agent-hook relay -// (STA-1515): one relay per distro per instance, ensured from every WSL PTY -// spawn, forwarding envelopes into ingestRemote and installing guest hooks. import type { ChildProcessWithoutNullStreams } from 'node:child_process' import { @@ -43,7 +40,7 @@ type DistroState = { guestHome?: string codexHomePath?: string guestEndpointFilePath?: string - opencodeOverlayDir?: string + opencodeOverlayDir?: string; opencode2OverlayDir?: string failures: number cooldownUntil: number connectedAt?: number @@ -56,7 +53,6 @@ export class WslHookRelayManager { private deps: WslHookRelayManagerDeps private recovery: WslRelayRecovery private states = new Map() - /** Distros a hooks-off teardown stopped, so re-enabling can put them back. */ private stoppedByHooksOff = new Map() private defaultDistro: string | null = null private disposed = false @@ -101,18 +97,11 @@ export class WslHookRelayManager { return this.states.get(wslHookRelayStateKey(distro ?? this.defaultDistro ?? '')) } - /** Guest endpoint file path once known; null before first connect - * (callers keep the /p-translated Windows endpoint path until then). */ getGuestEndpointFilePath(distro: string | null): string | null { return this.stateFor(distro)?.guestEndpointFilePath ?? null } - /** Guest OpenCode config-overlay dir once the guest relay materializes it; - * null before then (older bundle / relay not yet connected). Callers drop - * OPENCODE_CONFIG_DIR while null so no Windows overlay path crosses into WSL. */ - getOpenCodeOverlayDir(distro: string | null): string | null { - return this.stateFor(distro)?.opencodeOverlayDir ?? null - } + getOpenCodeOverlayDir(distro: string | null, agent: 'opencode' | 'opencode2' = 'opencode'): string | null { const state = this.stateFor(distro); return agent === 'opencode2' ? (state?.opencode2OverlayDir ?? null) : (state?.opencodeOverlayDir ?? null) } /** Kills every live relay. Non-permanent (hooks switched off mid-session) leaves the * manager reusable, so re-enabling hooks can start relays again without an app restart. */ @@ -201,7 +190,7 @@ export class WslHookRelayManager { failures: existing?.failures ?? 0, // Why: instance-keyed and on the distro's persistent fs, so it outlives a relay // crash — dropping it would blank status on panes spawned mid-relaunch. - opencodeOverlayDir: existing?.opencodeOverlayDir, + opencodeOverlayDir: existing?.opencodeOverlayDir, opencode2OverlayDir: existing?.opencode2OverlayDir, codexHomePath: requestedCodexHomePath ?? existing?.codexHomePath, cooldownUntil: 0 } diff --git a/src/main/ai-vault-search/session-search-opencode-index.test.ts b/src/main/ai-vault-search/session-search-opencode-index.test.ts index f825168c16a..52da511765d 100644 --- a/src/main/ai-vault-search/session-search-opencode-index.test.ts +++ b/src/main/ai-vault-search/session-search-opencode-index.test.ts @@ -9,7 +9,10 @@ vi.mock('../ai-vault/session-scanner-opencode-sqlite-worker-spawn', async () => const parse = await import('../ai-vault/session-scanner-opencode-sqlite') const capture = await import('../ai-vault/session-scanner-opencode-sqlite-capture') const own = await import('./session-search-opencode-index.test') + const { listOpenCode2SqliteSessions } = + await import('../ai-vault/session-scanner-opencode2-sqlite-list') return { + listOpenCode2SqliteSessionsViaWorker: listOpenCode2SqliteSessions, resolveOpenCodeSqliteWorkerEntryPath: () => null, listOpenCodeSqliteSessionsViaWorker: ( args: Parameters[0] diff --git a/src/main/ai-vault/opencode-transcript-capture-limits.ts b/src/main/ai-vault/opencode-transcript-capture-limits.ts new file mode 100644 index 00000000000..2781fecb6f8 --- /dev/null +++ b/src/main/ai-vault/opencode-transcript-capture-limits.ts @@ -0,0 +1,3 @@ +// Captures cross a worker boundary; exceeding either bound must fail, never return a complete-looking prefix. +export const OPENCODE_CAPTURE_RECORD_LIMIT = 20_000 +export const OPENCODE_CAPTURE_TEXT_LIMIT = 64 * 1024 * 1024 diff --git a/src/main/ai-vault/session-scanner-agent-parser.ts b/src/main/ai-vault/session-scanner-agent-parser.ts index 0e91c4ceb80..604d1c5231a 100644 --- a/src/main/ai-vault/session-scanner-agent-parser.ts +++ b/src/main/ai-vault/session-scanner-agent-parser.ts @@ -9,6 +9,8 @@ import { parseKimiSessionFile } from './session-scanner-kimi-parser' import { splitOpenCodeSqliteCandidate } from './session-scanner-opencode-sqlite-paths' import { captureOpenCodeSqliteSessionViaWorker, + captureOpenCode2SqliteSessionViaWorker, + parseOpenCode2SqliteSessionViaWorker, parseOpenCodeSqliteSessionViaWorker } from './session-scanner-opencode-sqlite-worker-spawn' import { parseClaudeSessionFile } from './session-scanner-primary-parsers' @@ -89,6 +91,31 @@ export async function parseAgentSessionFile( } return parseOpenCodeSessionFile(candidate.file, platform, messages) } + case 'opencode2': { + // Why: opencode2 (beta) sessions are read from the channel-scoped SQLite + // DB (session_v2 schema) via the same synthetic # + // candidate path; there is no legacy file store. + const sqliteCandidate = splitOpenCodeSqliteCandidate(candidate.file.path) + if (sqliteCandidate) { + if (messages?.active) { + const capture = await captureOpenCode2SqliteSessionViaWorker({ + dbPath: sqliteCandidate.dbPath, + sessionId: sqliteCandidate.sessionId, + platform + }) + for (const message of capture.messages) { + messages.push(message) + } + return capture.session + } + return parseOpenCode2SqliteSessionViaWorker({ + dbPath: sqliteCandidate.dbPath, + sessionId: sqliteCandidate.sessionId, + platform + }) + } + return null + } case 'grok': return parseGrokSessionFile(candidate.file, platform, messages) case 'hermes': diff --git a/src/main/ai-vault/session-scanner-every-agent-fixture.ts b/src/main/ai-vault/session-scanner-every-agent-fixture.ts index 5025bce5021..84f69b12699 100644 --- a/src/main/ai-vault/session-scanner-every-agent-fixture.ts +++ b/src/main/ai-vault/session-scanner-every-agent-fixture.ts @@ -1,4 +1,4 @@ -import { isolatedScanRoots } from './session-scanner-test-fixtures' +import { isolatedScanRoots, writeOpenCode2SqliteFixture } from './session-scanner-test-fixtures' import { writeDocumentAgentFixtures } from './session-scanner-document-agent-fixtures' import { writeLogAgentFixtures } from './session-scanner-log-agent-fixtures' @@ -28,5 +28,6 @@ export async function writeEveryAgentVault(root: string): Promise ({ // The SQLite leg spawns a real worker thread, which fake timers cannot drive. vi.mock('./session-scanner-opencode-sqlite-worker-spawn', () => ({ - listOpenCodeSqliteSessionsViaWorker: async () => [] + listOpenCodeSqliteSessionsViaWorker: async () => [], + listOpenCode2SqliteSessionsViaWorker: async () => [] })) import { opencodeDiscoveries } from './session-scanner-opencode-sources' @@ -77,7 +78,8 @@ describe('OpenCode source discovery with a stalled WSL data directory', () => { // Zero databases for that home is the degraded answer; without the issue it // would be indistinguishable from "OpenCode was never installed there". const resolved = await discoveries - expect(resolved).toHaveLength(2) + expect(resolved).toHaveLength(4) + expect(mocks.readdir.mock.calls.filter(([path]) => path === WSL_DATA_DIR)).toHaveLength(1) expect(resolved.every((discovery) => discovery.files.length === 0)).toBe(true) expect(issues.some((issue) => issue.path === WSL_DATA_DIR)).toBe(true) expect(issues.every((issue) => issue.agent === 'opencode')).toBe(true) @@ -96,7 +98,7 @@ describe('OpenCode source discovery with a stalled WSL data directory', () => { // The primary source is the one per-root containment cannot reach, so a // silent [] here reads as "no OpenCode sessions" on a clean scan. - await expect(discoveries).resolves.toHaveLength(1) + await expect(discoveries).resolves.toHaveLength(2) expect( issues.some((issue) => issue.agent === 'opencode' && issue.path === `${WSL_HOME}/opencode`) ).toBe(true) 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..753805405b7 100644 --- a/src/main/ai-vault/session-scanner-opencode-sources.test.ts +++ b/src/main/ai-vault/session-scanner-opencode-sources.test.ts @@ -2,9 +2,15 @@ import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { 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().mockResolvedValue([]) + })) + +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(opencodeDiscoveries({ 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 4dc1303487c..ea93787d642 100644 --- a/src/main/ai-vault/session-scanner-opencode-sources.ts +++ b/src/main/ai-vault/session-scanner-opencode-sources.ts @@ -1,4 +1,4 @@ -import { dirname, join } from 'node:path' +import { basename, dirname, join } from 'node:path' import type { AiVaultScanIssue } from '../../shared/ai-vault-types' import { wslGatedReaddir } from '../native-chat/wsl-transcript-fs-access' import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate' @@ -6,6 +6,8 @@ import { resolveOpenCodeStorageDirectory } from '../opencode/opencode-data-direc import { listOpenCodeDatabases } from '../opencode-usage/opencode-database-discovery' import { recordSessionScanIssue } from './session-scan-issues' import { discoverOpenCodeSessions } from './session-scanner-opencode-sqlite-discovery' +import { listOpenCode2SqliteSessionsViaWorker } from './session-scanner-opencode-sqlite-worker-spawn' +import { isOpenCodeV2DatabaseName } from '../../shared/opencode-database-name' import type { AiVaultScanOptions, SessionFileDiscovery } from './session-scanner-types' export function opencodeDiscoveries( @@ -14,15 +16,21 @@ export function opencodeDiscoveries( limit: number, issues: AiVaultScanIssue[] ): Promise[] { - const storageDirs = opencodeStorageDirs(options, wslHomeDirs) - return storageDirs.map(async (storageDir, index) => - discoverOpenCodeSessions({ - storageDir, - dbPaths: await opencodeDbPathsForSource(options, wslHomeDirs, storageDir, index, issues), - limitPerAgent: limit, - issues - }) - ) + return opencodeStorageDirs(options, wslHomeDirs).flatMap((storageDir, index) => { + const paths = opencodeDbPathsForSource(options, wslHomeDirs, storageDir, index, issues) + return [ + paths.then((dbPaths) => + discoverOpenCodeSessions({ + storageDir, + dbPaths: dbPaths.filter((path) => !isOpenCodeV2DatabaseName(basename(path))), + limitPerAgent: limit, + issues + }) + ), + // Current releases share opencode.db with v1; the worker checks for v2 tables. + paths.then((dbPaths) => discoverOpenCode2Sessions(storageDir, dbPaths, limit, issues)) + ] + }) } function opencodeStorageDirs( @@ -83,3 +91,17 @@ async function listOpenCodeDatabasesInDirectory( return [] } } + +async function discoverOpenCode2Sessions( + storageDir: string, + dbPaths: readonly string[], + limit: number, + issues: AiVaultScanIssue[] +): Promise { + const files = await listOpenCode2SqliteSessionsViaWorker({ dbPaths, limit, issues }) + return { + agent: 'opencode2' as const, + rootDir: storageDir, + files: files.map((candidate) => candidate.file) + } +} diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-capture.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-capture.ts index 9be9b734e2f..f4622118956 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-capture.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-capture.ts @@ -1,3 +1,7 @@ +import { + OPENCODE_CAPTURE_RECORD_LIMIT, + OPENCODE_CAPTURE_TEXT_LIMIT +} from './opencode-transcript-capture-limits' import type { AiVaultSession } from '../../shared/ai-vault-types' import { timestampIso } from './session-scanner-accumulator' import { asRecord } from './session-scanner-record-value' @@ -16,31 +20,6 @@ import type SyncDatabase from '../sqlite/sync-database' /** The part types that carry something a person would search for. */ const OPENCODE_CAPTURE_PART_TYPES = "('text','reasoning','tool')" -/** - * How many parts one session may hold before this read gives up. - * - * A safety valve on memory, not a policy: the rows are materialized and then - * posted across the worker boundary, so an unbounded session would be held - * twice. Exceeding it throws rather than returning a prefix, because a prefix - * committed under a complete-read cursor would leave the tail unsearchable with - * nothing on the row to say so. A failed read is retried and surfaces; a silent - * truncation does neither. Measured against a real 21 GB database: the busiest - * session there holds 1,427 of these parts. - */ -const OPENCODE_CAPTURE_PART_LIMIT = 20_000 - -/** - * How much decoded text one session may carry, for the same reason. - * - * Not a truncation policy and not a second cap on tool rows -- the index writer - * owns that, at 3 KB a row. This is the bound a non-streaming source needs and - * a streaming one does not: a JSONL provider publishes each message as it reads - * it, while this one holds the whole session before posting it. Measured on the - * same database, the largest session's parts total 9.5 MB, so this is ~7x the - * worst real one. - */ -const OPENCODE_CAPTURE_TEXT_LIMIT = 64 * 1024 * 1024 - type CaptureRow = { messageId: string role: string | null @@ -180,10 +159,10 @@ export function readOpenCodeSessionMessages( `OpenCode session ${sessionId} uses an unreadable message-part schema; its transcript was not read.` ) } - const rows = db.prepare(buildCaptureQuery()).all(sessionId, OPENCODE_CAPTURE_PART_LIMIT + 1) - if (rows.length > OPENCODE_CAPTURE_PART_LIMIT) { + const rows = db.prepare(buildCaptureQuery()).all(sessionId, OPENCODE_CAPTURE_RECORD_LIMIT + 1) + if (rows.length > OPENCODE_CAPTURE_RECORD_LIMIT) { throw new Error( - `OpenCode session ${sessionId} holds more than ${OPENCODE_CAPTURE_PART_LIMIT} text parts; its transcript was not read.` + `OpenCode session ${sessionId} holds more than ${OPENCODE_CAPTURE_RECORD_LIMIT} text parts; its transcript was not read.` ) } diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts index a5981a016d3..6a376f7bd85 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts @@ -13,7 +13,9 @@ vi.mock('./session-scanner-opencode-sqlite-worker-spawn', async () => { import('./session-scanner-opencode-sqlite-list'), import('./session-scanner-opencode-sqlite') ]) + const { listOpenCode2SqliteSessions } = await import('./session-scanner-opencode2-sqlite-list') return { + listOpenCode2SqliteSessionsViaWorker: listOpenCode2SqliteSessions, listOpenCodeSqliteSessionsViaWorker: listOpenCodeSqliteSessions, parseOpenCodeSqliteSessionViaWorker: parseOpenCodeSqliteSession } diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.ts index 1a0e04d9934..1103a8379e5 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.ts @@ -81,6 +81,8 @@ export class OpenCodeSqliteWorkerClient { * @param args.dbPaths - Absolute paths to opencode.db files to scan. * @param args.limit - Maximum number of sessions to return per database. * @param args.issues - Collected scan issues (worker issues are merged in). + * @param args.agent - 'opencode2' reads the v2 channel-scoped schema; omitted + * (or 'opencode') reads the v1 schema. * @returns Synthetic candidates sorted by effective recency; empty (with a * scan issue) when the worker is unavailable, times out, or crashes. */ @@ -88,6 +90,7 @@ export class OpenCodeSqliteWorkerClient { dbPaths: readonly string[] limit: number issues: AiVaultScanIssue[] + agent?: 'opencode2' }): Promise { if (args.dbPaths.length === 0) { return [] @@ -95,7 +98,13 @@ export class OpenCodeSqliteWorkerClient { try { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the worker's list leg returns exactly this, built by the repo's own reader on the other side of a structured clone. const value = (await this.dispatch( - (id) => ({ id, kind: 'list', dbPaths: args.dbPaths, limit: args.limit }), + (id) => ({ + id, + kind: 'list', + dbPaths: args.dbPaths, + limit: args.limit, + ...(args.agent ? { agent: args.agent } : {}) + }), LIST_TIMEOUT_MS )) as OpenCodeSqliteListValue args.issues.push(...value.issues) @@ -104,7 +113,7 @@ export class OpenCodeSqliteWorkerClient { if (err instanceof OpenCodeSqliteWorkerUnavailableError) { // Kinded: a whole source failed, not a transcript. args.issues.push({ - agent: 'opencode', + agent: args.agent ?? 'opencode', kind: 'scope', path: args.dbPaths[0] ?? 'opencode.db', message: @@ -115,7 +124,7 @@ export class OpenCodeSqliteWorkerClient { // Timeout/crash: this storage dir's SQLite DBs contribute no sessions this // scan, surfaced as one scan issue rather than an unbounded stall. args.issues.push({ - agent: 'opencode', + agent: args.agent ?? 'opencode', kind: 'scope', path: args.dbPaths[0] ?? 'opencode.db', message: `OpenCode history scan did not complete: ${errorMessage(err)}` @@ -129,6 +138,8 @@ export class OpenCodeSqliteWorkerClient { * @param args.dbPath - Absolute path to the opencode.db file. * @param args.sessionId - Primary key in the `session` table. * @param args.platform - Platform used for resume-command generation. + * @param args.agent - 'opencode2' reads the v2 channel-scoped schema; omitted + * (or 'opencode') reads the v1 schema. * @returns The parsed session, or `null` when it does not exist; rejects on * worker timeout/crash so the scanner records a per-session scan issue. */ @@ -136,6 +147,7 @@ export class OpenCodeSqliteWorkerClient { dbPath: string sessionId: string platform: NodeJS.Platform + agent?: 'opencode2' }): Promise { try { const value = await this.dispatch( @@ -144,7 +156,8 @@ export class OpenCodeSqliteWorkerClient { kind: 'parse', dbPath: args.dbPath, sessionId: args.sessionId, - platform: args.platform + platform: args.platform, + ...(args.agent ? { agent: args.agent } : {}) }), PARSE_TIMEOUT_MS ) @@ -171,6 +184,7 @@ export class OpenCodeSqliteWorkerClient { dbPath: string sessionId: string platform: NodeJS.Platform + agent?: 'opencode2' }): Promise { try { const value = await this.dispatch( @@ -179,7 +193,8 @@ export class OpenCodeSqliteWorkerClient { kind: 'capture', dbPath: args.dbPath, sessionId: args.sessionId, - platform: args.platform + platform: args.platform, + ...(args.agent ? { agent: args.agent } : {}) }), CAPTURE_TIMEOUT_MS ) diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-entry.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-entry.ts index 434f9179d2e..7441bb1564a 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-entry.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-entry.ts @@ -3,6 +3,11 @@ import type { AiVaultScanIssue } from '../../shared/ai-vault-types' import { captureOpenCodeSqliteSession } from './session-scanner-opencode-sqlite-capture' import { listOpenCodeSqliteSessions } from './session-scanner-opencode-sqlite-list' import { parseOpenCodeSqliteSession } from './session-scanner-opencode-sqlite' +import { listOpenCode2SqliteSessions } from './session-scanner-opencode2-sqlite-list' +import { + captureOpenCode2SqliteSession, + parseOpenCode2SqliteSession +} from './session-scanner-opencode2-sqlite' import type { OpenCodeSqliteWorkerRequest, OpenCodeSqliteWorkerResponse @@ -24,26 +29,39 @@ async function handleRequest( try { if (request.kind === 'list') { const issues: AiVaultScanIssue[] = [] - const candidates = await listOpenCodeSqliteSessions({ - dbPaths: request.dbPaths, - limit: request.limit, - issues - }) + const candidates = + request.agent === 'opencode2' + ? await listOpenCode2SqliteSessions({ + dbPaths: request.dbPaths, + limit: request.limit, + issues + }) + : await listOpenCodeSqliteSessions({ + dbPaths: request.dbPaths, + limit: request.limit, + issues + }) return { id: request.id, ok: true, value: { candidates, issues } } } if (request.kind === 'capture') { - const capture = await captureOpenCodeSqliteSession({ - dbPath: request.dbPath, - sessionId: request.sessionId, - platform: request.platform - }) + const capture = + request.agent === 'opencode2' + ? await captureOpenCode2SqliteSession(request) + : await captureOpenCodeSqliteSession(request) return { id: request.id, ok: true, value: capture } } - const session = await parseOpenCodeSqliteSession({ - dbPath: request.dbPath, - sessionId: request.sessionId, - platform: request.platform - }) + const session = + request.agent === 'opencode2' + ? await parseOpenCode2SqliteSession({ + dbPath: request.dbPath, + sessionId: request.sessionId, + platform: request.platform + }) + : await parseOpenCodeSqliteSession({ + dbPath: request.dbPath, + sessionId: request.sessionId, + platform: request.platform + }) return { id: request.id, ok: true, value: session } } catch (err) { return { id: request.id, ok: false, error: err instanceof Error ? err.message : String(err) } diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-protocol.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-protocol.ts index ea72f9b083e..e18cad32c2c 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-protocol.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-protocol.ts @@ -11,6 +11,8 @@ export type OpenCodeSqliteListRequest = { kind: 'list' dbPaths: readonly string[] limit: number + /** When 'opencode2', lists from the v2 channel-scoped DB schema (session_v2). */ + agent?: 'opencode2' } export type OpenCodeSqliteParseRequest = { @@ -19,6 +21,8 @@ export type OpenCodeSqliteParseRequest = { dbPath: string sessionId: string platform: NodeJS.Platform + /** When 'opencode2', parses from the v2 channel-scoped DB schema (session_v2). */ + agent?: 'opencode2' } // Same arguments as `parse`, different answer: the session plus every message @@ -30,6 +34,8 @@ export type OpenCodeSqliteCaptureRequest = { dbPath: string sessionId: string platform: NodeJS.Platform + /** When 'opencode2', captures from the v2 channel-scoped schema. */ + agent?: 'opencode2' } export type OpenCodeSqliteWorkerRequest = diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-spawn.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-spawn.ts index 76e46000b8a..c1e89e9fc79 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-spawn.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-spawn.ts @@ -57,6 +57,18 @@ export function listOpenCodeSqliteSessionsViaWorker(args: { return getSharedClient().list(args) } +/** + * List opencode2 session candidates (v2 channel-scoped DB schema) through the + * shared worker client. + */ +export function listOpenCode2SqliteSessionsViaWorker(args: { + dbPaths: readonly string[] + limit: number + issues: AiVaultScanIssue[] +}): Promise { + return getSharedClient().list({ ...args, agent: 'opencode2' }) +} + /** * Parse one OpenCode SQLite session through the shared worker client. * @param args.dbPath - Absolute path to the opencode.db file. @@ -72,6 +84,14 @@ export function parseOpenCodeSqliteSessionViaWorker(args: { return getSharedClient().parse(args) } +export function parseOpenCode2SqliteSessionViaWorker(args: { + dbPath: string + sessionId: string + platform: NodeJS.Platform +}): Promise { + return getSharedClient().parse({ ...args, agent: 'opencode2' }) +} + /** * Read one OpenCode SQLite session and its whole transcript through the shared * worker client. @@ -87,3 +107,11 @@ export function captureOpenCodeSqliteSessionViaWorker(args: { }): Promise { return getSharedClient().capture(args) } + +export function captureOpenCode2SqliteSessionViaWorker(args: { + dbPath: string + sessionId: string + platform: NodeJS.Platform +}): Promise { + return getSharedClient().capture({ ...args, agent: 'opencode2' }) +} diff --git a/src/main/ai-vault/session-scanner-opencode2-message.test.ts b/src/main/ai-vault/session-scanner-opencode2-message.test.ts new file mode 100644 index 00000000000..beaf6c9ca1b --- /dev/null +++ b/src/main/ai-vault/session-scanner-opencode2-message.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' +import { + decodeOpenCode2Message, + extractOpenCode2MessageText +} from './session-scanner-opencode2-message' + +describe('OpenCode 2 message decoding', () => { + it('captures reasoning, assistant text, tool calls and results without indexing provider state', () => { + const data = JSON.stringify({ + content: [ + { type: 'reasoning', text: 'Checking the result', state: { secret: 'must not index' } }, + { type: 'text', text: 'Finished' }, + { + type: 'tool', + name: 'bash', + state: { + status: 'completed', + input: { command: 'echo proof' }, + content: [{ type: 'text', text: 'proof' }] + } + } + ] + }) + const messages = decodeOpenCode2Message(data, 'assistant', null) + expect(messages).toEqual( + expect.arrayContaining([ + { role: 'assistant', text: 'Checking the result\nFinished', timestamp: null }, + { role: 'tool', text: 'bash: echo proof', timestamp: null }, + { role: 'tool', text: 'proof', timestamp: null } + ]) + ) + expect(JSON.stringify(messages)).not.toContain('must not index') + expect(extractOpenCode2MessageText(data)).toBe('Finished') + }) +}) diff --git a/src/main/ai-vault/session-scanner-opencode2-message.ts b/src/main/ai-vault/session-scanner-opencode2-message.ts new file mode 100644 index 00000000000..17aa0cf632f --- /dev/null +++ b/src/main/ai-vault/session-scanner-opencode2-message.ts @@ -0,0 +1,59 @@ +import type { AiVaultSessionPreviewMessage } from '../../shared/ai-vault-types' +import { asRecord } from './session-scanner-record-value' +import { parseJsonObject } from './session-scanner-values' +import { transcriptMessagesFromContent } from './session-transcript-message-content' + +export function extractOpenCode2MessageText(data: string): string | null { + const record = parseJsonObject(data) + if (typeof record?.text === 'string') { + return record.text + } + if (Array.isArray(record?.text)) { + return record.text.filter((part): part is string => typeof part === 'string').join('\n') || null + } + if (!Array.isArray(record?.content)) { + return null + } + return ( + record.content + .flatMap((value) => { + const item = asRecord(value) + return item?.type === 'text' && typeof item.text === 'string' ? [item.text] : [] + }) + .join('\n') || null + ) +} + +export function decodeOpenCode2Message( + data: string, + role: AiVaultSessionPreviewMessage['role'], + timestamp: string | null +) { + const record = parseJsonObject(data) + const content = Array.isArray(record?.content) + ? record.content.flatMap((value) => { + const item = asRecord(value) + if (item?.type !== 'tool') { + return [value] + } + const state = asRecord(item.state) + return [ + { type: 'tool_use', name: item.name, input: state?.input }, + { type: 'tool_result', content: state?.content } + ] + }) + : record?.text + return transcriptMessagesFromContent(role, content, timestamp) +} + +export function parseOpenCode2MessageRow(value: unknown) { + const row = asRecord(value) + if ( + typeof row?.data !== 'string' || + typeof row.type !== 'string' || + typeof row.time_created !== 'number' + ) { + throw new Error('OpenCode 2 transcript contains an invalid message') + } + return { data: row.data, type: row.type, time_created: row.time_created } +} diff --git a/src/main/ai-vault/session-scanner-opencode2-sqlite-list.ts b/src/main/ai-vault/session-scanner-opencode2-sqlite-list.ts new file mode 100644 index 00000000000..2107d66525a --- /dev/null +++ b/src/main/ai-vault/session-scanner-opencode2-sqlite-list.ts @@ -0,0 +1,127 @@ +import type { AiVaultAgent, AiVaultScanIssue } from '../../shared/ai-vault-types' +import { + buildOpenCodeSqliteCandidatePath, + splitOpenCodeSqliteCandidate +} from './session-scanner-opencode-sqlite-paths' +import type { SessionFileCandidate } from './session-scanner-types' +import { errorMessage } from './session-scanner-values' +import SyncDatabase from '../sqlite/sync-database' +import { columnExists, tableExists } from '../opencode-usage/schema-helpers' + +// Why: the opencode2 beta stores sessions in a channel-scoped SQLite DB +// (opencode-next.db / opencode-local.db) with its own schema — `session_v2` +// rows plus `session_message` parts, no `part` table. Its schema is explicitly +// unstable during beta, so every read is column-guarded and any drift fails +// soft to "no sessions". Electron-free so the worker entry can import it. + +const OPENCODE2_SESSION_TABLE = 'session_v2' + +type SessionRow = { + id: string + time_created: number + time_updated: number +} + +function openReadonlyDatabase(dbPath: string): SyncDatabase { + const db = new SyncDatabase(dbPath, { readonly: true, fileMustExist: true }) + db.pragma('query_only = ON') + return db +} + +function canReadOpenCode2Sessions(db: SyncDatabase): boolean { + return ( + tableExists(db, OPENCODE2_SESSION_TABLE) && + columnExists(db, OPENCODE2_SESSION_TABLE, 'time_created') && + columnExists(db, OPENCODE2_SESSION_TABLE, 'time_updated') + ) +} + +function buildSessionListQuery(db: SyncDatabase, limited: boolean): string { + // Why: v2 tracks parent/fork relations and archived sessions; only list + // top-level, non-archived rows, mirroring the v1 list predicate. + const parentIdPredicate = columnExists(db, OPENCODE2_SESSION_TABLE, 'parent_id') + ? 'AND parent_id IS NULL' + : '' + const archivedPredicate = columnExists(db, OPENCODE2_SESSION_TABLE, 'time_archived') + ? 'AND time_archived IS NULL' + : '' + + return `SELECT id, time_created, time_updated + FROM ${OPENCODE2_SESSION_TABLE} + WHERE 1=1 ${parentIdPredicate} ${archivedPredicate} + ORDER BY CASE WHEN time_updated > 0 THEN time_updated ELSE time_created END DESC + ${limited ? 'LIMIT ?' : ''}` +} + +function rowToCandidate(row: SessionRow, dbPath: string): SessionFileCandidate { + const mtimeMs = + typeof row.time_updated === 'number' && row.time_updated > 0 + ? row.time_updated + : row.time_created + return { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime validation or the local test fixture establishes the asserted shape. + agent: 'opencode2' as AiVaultAgent, + file: { + path: buildOpenCodeSqliteCandidatePath(dbPath, row.id), + mtimeMs, + modifiedAt: new Date(mtimeMs).toISOString() + }, + codexHome: null + } +} + +function dedupeAndSortCandidates(candidates: SessionFileCandidate[]): SessionFileCandidate[] { + const candidatesBySessionId = new Map() + for (const candidate of candidates) { + const parsed = splitOpenCodeSqliteCandidate(candidate.file.path) + if (!parsed) { + continue + } + const previous = candidatesBySessionId.get(parsed.sessionId) + if (!previous || candidate.file.mtimeMs > previous.file.mtimeMs) { + candidatesBySessionId.set(parsed.sessionId, candidate) + } + } + return [...candidatesBySessionId.values()].sort((left, right) => { + return right.file.mtimeMs - left.file.mtimeMs + }) +} + +/** + * List opencode2 sessions from one or more channel-scoped SQLite databases as + * synthetic `SessionFileCandidate` entries, mirroring the v1 SQLite list leg. + * Databases that lack the `session_v2` table are silently skipped; errors are + * recorded as scan issues. + */ +export async function listOpenCode2SqliteSessions(args: { + dbPaths: readonly string[] + limit: number + issues: AiVaultScanIssue[] +}): Promise { + const candidates: SessionFileCandidate[] = [] + for (const dbPath of args.dbPaths) { + let db: SyncDatabase | null = null + try { + db = openReadonlyDatabase(dbPath) + if (!canReadOpenCode2Sessions(db)) { + continue + } + const limited = Number.isFinite(args.limit) + const statement = db.prepare(buildSessionListQuery(db, limited)) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime validation or the local test fixture establishes the asserted shape. + const rows = (limited ? statement.all(args.limit) : statement.all()) as SessionRow[] + for (const row of rows) { + candidates.push(rowToCandidate(row, dbPath)) + } + } catch (err) { + args.issues.push({ + agent: 'opencode2', + path: dbPath, + message: errorMessage(err) + }) + } finally { + db?.close() + } + } + return dedupeAndSortCandidates(candidates) +} diff --git a/src/main/ai-vault/session-scanner-opencode2-sqlite.test.ts b/src/main/ai-vault/session-scanner-opencode2-sqlite.test.ts new file mode 100644 index 00000000000..3365436a238 --- /dev/null +++ b/src/main/ai-vault/session-scanner-opencode2-sqlite.test.ts @@ -0,0 +1,380 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import Database from '../sqlite/sync-database' +import { buildOpenCodeSqliteCandidatePath } from './session-scanner-opencode-sqlite-paths' +import { listOpenCode2SqliteSessions } from './session-scanner-opencode2-sqlite-list' +import { + parseOpenCode2SqliteSession, + captureOpenCode2SqliteSession +} 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 — +// `session_v2` rows plus `session_message` rows whose `data` holds tagged +// message JSON. These tests pin the defensive reads against that schema. + +let tempDirs: string[] = [] + +afterEach(() => { + for (const dir of tempDirs) { + rmSync(dir, { recursive: true, force: true }) + } + tempDirs = [] +}) + +function createTempDb(name = 'opencode-next.db'): { db: Database.Database; path: string } { + const dir = mkdtempSync(join(tmpdir(), 'orca-opencode2-sqlite-')) + tempDirs.push(dir) + const path = join(dir, name) + return { db: new Database(path), path } +} + +function applyOpenCode2Schema(db: Database.Database): void { + db.exec(` + CREATE TABLE session_v2 ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + workspace_id TEXT, + parent_id TEXT, + fork_session_id TEXT, + fork_boundary TEXT, + slug TEXT NOT NULL, + directory TEXT NOT NULL, + path TEXT, + title TEXT, + version TEXT NOT NULL, + share_url TEXT, + cost REAL DEFAULT 0 NOT NULL, + tokens_input INTEGER DEFAULT 0 NOT NULL, + tokens_output INTEGER DEFAULT 0 NOT NULL, + tokens_reasoning INTEGER DEFAULT 0 NOT NULL, + tokens_cache_read INTEGER DEFAULT 0 NOT NULL, + tokens_cache_write INTEGER DEFAULT 0 NOT NULL, + agent TEXT, + model TEXT, + time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL, + time_compacting INTEGER, + time_archived INTEGER + ); + CREATE TABLE session_message ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + type TEXT NOT NULL, + seq INTEGER NOT NULL, + time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL, + data TEXT NOT NULL + ); + `) +} + +function insertSession( + db: Database.Database, + session: { + id: string + directory: string + title?: string | null + timeCreated: number + timeUpdated: number + parentId?: string | null + timeArchived?: number | null + } +): void { + db.prepare( + `INSERT INTO session_v2 + (id, project_id, parent_id, slug, directory, title, version, model, agent, time_created, time_updated, time_archived) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + session.id, + 'project_1', + session.parentId ?? null, + 'slug', + session.directory, + session.title ?? null, + '0.0.0-next-1', + null, + null, + session.timeCreated, + session.timeUpdated, + session.timeArchived ?? null + ) +} + +function insertMessage( + db: Database.Database, + message: { + id: string + sessionId: string + type: string + seq: number + timeCreated: number + data: string + } +): void { + db.prepare( + `INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ).run( + message.id, + message.sessionId, + message.type, + message.seq, + message.timeCreated, + message.timeCreated, + message.data + ) +} + +const issues: AiVaultScanIssue[] = [] + +it('captures the full transcript while keeping list previews bounded', async () => { + const { db, path } = createTempDb('opencode.db') + applyOpenCode2Schema(db) + insertSession(db, { id: 'long', directory: '/repo', timeCreated: 1000, timeUpdated: 9000 }) + for (let index = 0; index < 8; index++) { + insertMessage(db, { + id: `message_${index}`, + sessionId: 'long', + type: 'user', + seq: index, + timeCreated: 1000 + index, + data: JSON.stringify({ text: `Turn ${index}` }) + }) + } + db.close() + const args = { dbPath: path, sessionId: 'long', platform: 'darwin' as const } + const preview = await parseOpenCode2SqliteSession(args) + const capture = await captureOpenCode2SqliteSession(args) + expect(preview?.previewMessages).toHaveLength(5) + expect(capture.messages.map((message) => message.text)).toEqual( + Array.from({ length: 8 }, (_, index) => `Turn ${index}`) + ) +}) + +it('keeps session metadata readable but refuses a complete capture when ordering columns are missing', async () => { + const { db, path } = createTempDb() + applyOpenCode2Schema(db) + insertSession(db, { id: 'partial', directory: '/repo', timeCreated: 1000, timeUpdated: 1000 }) + db.exec('ALTER TABLE session_message DROP COLUMN seq') + db.close() + const args = { dbPath: path, sessionId: 'partial', platform: 'darwin' as const } + expect((await parseOpenCode2SqliteSession(args))?.sessionId).toBe('partial') + await expect(captureOpenCode2SqliteSession(args)).rejects.toThrow('schema is unreadable') +}) + +describe('listOpenCode2SqliteSessions', () => { + it('lists top-level, non-archived sessions newest-first', async () => { + const { db, path } = createTempDb() + applyOpenCode2Schema(db) + insertSession(db, { + id: 'session_old', + directory: '/repo', + timeCreated: 1000, + timeUpdated: 1000 + }) + insertSession(db, { + id: 'session_new', + directory: '/repo', + timeCreated: 2000, + timeUpdated: 2000 + }) + insertSession(db, { + id: 'session_child', + directory: '/repo', + timeCreated: 3000, + timeUpdated: 3000, + parentId: 'session_new' + }) + insertSession(db, { + id: 'session_archived', + directory: '/repo', + timeCreated: 4000, + timeUpdated: 4000, + timeArchived: 5000 + }) + db.close() + + const candidates = await listOpenCode2SqliteSessions({ + dbPaths: [path], + limit: 25, + issues + }) + + expect(candidates.map((c) => c.file.path)).toEqual([ + buildOpenCodeSqliteCandidatePath(path, 'session_new'), + buildOpenCodeSqliteCandidatePath(path, 'session_old') + ]) + expect(candidates[0]!.agent).toBe('opencode2') + }) + + it('skips databases without the session_v2 table', async () => { + const { db, path } = createTempDb() + db.exec(`CREATE TABLE unrelated (id TEXT PRIMARY KEY)`) + db.close() + + const candidates = await listOpenCode2SqliteSessions({ + dbPaths: [path], + limit: 25, + issues + }) + + expect(candidates).toEqual([]) + }) +}) + +describe('parseOpenCode2SqliteSession', () => { + it('parses session metadata and preview messages', async () => { + const { db, path } = createTempDb() + applyOpenCode2Schema(db) + insertSession(db, { + id: 'session_1', + directory: '/repo', + title: 'Fix login', + timeCreated: 1_777_634_000_000, + timeUpdated: 1_777_634_001_000 + }) + db.prepare( + `UPDATE session_v2 SET model = ?, tokens_input = 10, tokens_output = 20, tokens_reasoning = 5, cost = 0.5 WHERE id = 'session_1'` + ).run('{"id":"glm-5.2","providerID":"zai"}') + insertMessage(db, { + id: 'msg_1', + sessionId: 'session_1', + type: 'user', + seq: 1, + timeCreated: 1_777_634_000_500, + data: JSON.stringify({ + id: 'msg_1', + text: 'Add login flow', + time: { created: 1_777_634_000_500 } + }) + }) + insertMessage(db, { + id: 'msg_2', + sessionId: 'session_1', + type: 'assistant', + seq: 2, + timeCreated: 1_777_634_000_900, + data: JSON.stringify({ + id: 'msg_2', + type: 'assistant', + content: [{ type: 'text', text: 'Done.' }], + time: { created: 1_777_634_000_900 } + }) + }) + db.close() + + 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) + expect(session!.messageCount).toBe(2) + expect(session!.previewMessages).toEqual([ + { + role: 'user', + text: 'Add login flow', + timestamp: new Date(1_777_634_000_500).toISOString() + }, + { + role: 'assistant', + text: 'Done.', + timestamp: new Date(1_777_634_000_900).toISOString() + } + ]) + 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 () => { + const { db, path } = createTempDb() + applyOpenCode2Schema(db) + insertSession(db, { + id: 'session_2', + directory: '/repo', + title: null, + timeCreated: 1_777_634_000_000, + timeUpdated: 1_777_634_000_000 + }) + db.prepare(`UPDATE session_v2 SET model = ? WHERE id = 'session_2'`).run( + 'anthropic/claude-sonnet' + ) + insertMessage(db, { + id: 'msg_1', + sessionId: 'session_2', + type: 'assistant', + seq: 1, + timeCreated: 1_777_634_000_500, + data: JSON.stringify({ + id: 'msg_1', + type: 'assistant', + content: [ + { type: 'reasoning', text: 'hidden' }, + { type: 'text', text: 'Visible answer' } + ], + time: { created: 1_777_634_000_500 } + }) + }) + db.close() + + const session = await parseOpenCode2SqliteSession({ + dbPath: path, + sessionId: 'session_2', + platform: 'darwin' + }) + + expect(session).not.toBeNull() + expect(session!.model).toBe('anthropic/claude-sonnet') + expect(session!.previewMessages).toEqual([ + { + role: 'assistant', + text: 'Visible answer', + timestamp: new Date(1_777_634_000_500).toISOString() + } + ]) + }) + + it('fails soft when the session_v2 table is missing', async () => { + const { db, path } = createTempDb() + db.exec(`CREATE TABLE session_v1 (id TEXT PRIMARY KEY)`) + db.close() + + const session = await parseOpenCode2SqliteSession({ + dbPath: path, + sessionId: 'session_1', + platform: 'darwin' + }) + + expect(session).toBeNull() + }) + + it('fails soft when the session does not exist', async () => { + const { db, path } = createTempDb() + applyOpenCode2Schema(db) + db.close() + + const session = await parseOpenCode2SqliteSession({ + dbPath: path, + sessionId: 'missing', + platform: 'darwin' + }) + + expect(session).toBeNull() + }) +}) diff --git a/src/main/ai-vault/session-scanner-opencode2-sqlite.ts b/src/main/ai-vault/session-scanner-opencode2-sqlite.ts new file mode 100644 index 00000000000..86cedcc075b --- /dev/null +++ b/src/main/ai-vault/session-scanner-opencode2-sqlite.ts @@ -0,0 +1,340 @@ +import { + OPENCODE_CAPTURE_RECORD_LIMIT, + OPENCODE_CAPTURE_TEXT_LIMIT +} from './opencode-transcript-capture-limits' +import type { AiVaultSession, AiVaultSessionPreviewMessage } from '../../shared/ai-vault-types' +import { + addPreviewMessage, + createAccumulator, + finalizeSession, + updateTimeline, + timestampIso +} from './session-scanner-accumulator' +import { + normalizeFullFirstUserPromptText, + shouldCaptureFullFirstUserPrompt +} from './session-scanner-first-user-prompt' +import { normalizeTitleText } from './session-scanner-values' +import { + extractOpenCode2MessageText, + decodeOpenCode2Message, + parseOpenCode2MessageRow +} from './session-scanner-opencode2-message' +import SyncDatabase from '../sqlite/sync-database' +import { columnExists, tableExists } from '../opencode-usage/schema-helpers' +import type { TranscriptMessage, TranscriptMessageSink } from './session-transcript-consumers' + +// Why: opencode2 (beta) stores sessions in a channel-scoped SQLite DB with its +// own schema — `session_v2` rows plus `session_message` rows whose `data` +// column holds tagged message JSON (user/assistant/system/tool/…), with no +// `part` table like opencode v1. The v2 schema is explicitly unstable during +// beta, so every read is column-guarded and any drift fails soft to a null +// session. Electron-free so the worker entry can import it. + +const OPENCODE2_SESSION_TABLE = 'session_v2' +const OPENCODE2_MESSAGE_TABLE = 'session_message' + +const OPENCODE2_PREVIEW_LIMIT = 5 +// Why: v2 stores the full message JSON inline in one column, so the preview +// window can stay small; a heavy session's assistant content can still hold +// large tool blobs. +const OPENCODE2_PREVIEW_MESSAGE_WINDOW = 100 + +type SessionRow = { + id: string + title: string | null + directory: string | null + agent: string | null + model: string | null + tokens_input: number + tokens_output: number + tokens_reasoning: number + tokens_cache_read: number + cost: number + message_count: number + time_created: number + time_updated: number +} + +type PreviewRow = { + type: string | null + data: string + time_created: number +} + +function openReadonlyDatabase(dbPath: string): SyncDatabase { + const db = new SyncDatabase(dbPath, { readonly: true, fileMustExist: true }) + db.pragma('query_only = ON') + return db +} + +function canReadOpenCode2Sessions(db: SyncDatabase): boolean { + return ( + tableExists(db, OPENCODE2_SESSION_TABLE) && + columnExists(db, OPENCODE2_SESSION_TABLE, 'time_created') && + columnExists(db, OPENCODE2_SESSION_TABLE, 'time_updated') + ) +} + +function sessionColumnSelect(db: SyncDatabase, columnName: string): string { + return columnExists(db, OPENCODE2_SESSION_TABLE, columnName) ? `s.${columnName}` : 'NULL' +} + +function sessionNumberColumnSelect(db: SyncDatabase, columnName: string): string { + return columnExists(db, OPENCODE2_SESSION_TABLE, columnName) ? `s.${columnName}` : '0' +} + +function canCountOpenCode2Messages(db: SyncDatabase): boolean { + return ( + tableExists(db, OPENCODE2_MESSAGE_TABLE) && + columnExists(db, OPENCODE2_MESSAGE_TABLE, 'session_id') && + columnExists(db, OPENCODE2_MESSAGE_TABLE, 'type') + ) +} + +function buildSessionQuery(db: SyncDatabase): string { + const messageCountSubquery = canCountOpenCode2Messages(db) + ? `(SELECT COUNT(*) FROM ${OPENCODE2_MESSAGE_TABLE} m + WHERE m.session_id = s.id + AND m.type IN ('user','assistant'))` + : '0' + return `SELECT s.id, + ${sessionColumnSelect(db, 'title')} AS title, + ${sessionColumnSelect(db, 'directory')} AS directory, + ${sessionColumnSelect(db, 'agent')} AS agent, + ${sessionColumnSelect(db, 'model')} AS model, + ${sessionNumberColumnSelect(db, 'tokens_input')} AS tokens_input, + ${sessionNumberColumnSelect(db, 'tokens_output')} AS tokens_output, + ${sessionNumberColumnSelect(db, 'tokens_reasoning')} AS tokens_reasoning, + ${sessionNumberColumnSelect(db, 'tokens_cache_read')} AS tokens_cache_read, + ${sessionNumberColumnSelect(db, 'cost')} AS cost, + ${messageCountSubquery} AS message_count, + s.time_created, + s.time_updated + FROM ${OPENCODE2_SESSION_TABLE} s + WHERE s.id = ? + LIMIT 1` +} + +// Why: v2 stores the model ref as a text column. Newer builds serialize a +// Model.Ref JSON ({id, providerID}); older shapes may be a bare id string. +function extractModelId(modelText: string | null): string | null { + if (!modelText) { + return null + } + const trimmed = modelText.trim() + if (!trimmed) { + return null + } + if (!trimmed.startsWith('{')) { + return trimmed + } + try { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime validation or the local test fixture establishes the asserted shape. + const parsed = JSON.parse(trimmed) as unknown + const record = + parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime validation or the local test fixture establishes the asserted shape. + (parsed as Record) + : null + if (typeof record?.id === 'string' && record.id.trim()) { + return record.id + } + if (typeof record?.modelID === 'string' && record.modelID.trim()) { + return record.modelID + } + } catch { + return null + } + return null +} + +function mapPreviewRole(type: string | null): AiVaultSessionPreviewMessage['role'] { + if (type === 'user' || type === 'assistant' || type === 'system' || type === 'tool') { + return type + } + return 'unknown' +} + +function readFirstUserPromptFromDb(db: SyncDatabase, sessionId: string): string | null { + if (!canReadOpenCode2Messages(db)) { + return null + } + try { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The query projects the declared data column from the guarded OpenCode 2 table. + const rows = db + .prepare( + `SELECT data FROM ${OPENCODE2_MESSAGE_TABLE} + WHERE session_id = ? + AND type = 'user' + ORDER BY time_created ASC, seq ASC + LIMIT 1` + ) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the SELECT projects one string data column and better-sqlite3 returns rows synchronously. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime validation or the local test fixture establishes the asserted shape. + .all(sessionId) as { data: string }[] + const text = rows[0] ? extractOpenCode2MessageText(rows[0].data) : null + return text ? normalizeFullFirstUserPromptText(text) : null + } catch { + return null + } +} + +function buildPreviewQuery(db: SyncDatabase): string | null { + if (!canReadOpenCode2Messages(db)) { + return null + } + return `SELECT type, data, time_created + FROM (SELECT type, data, time_created, seq FROM ${OPENCODE2_MESSAGE_TABLE} + WHERE session_id = ? + AND type IN ('user','assistant') + ORDER BY time_created DESC, seq DESC + LIMIT ${OPENCODE2_PREVIEW_MESSAGE_WINDOW}) + ORDER BY time_created DESC, seq DESC + LIMIT ?` +} + +function canReadOpenCode2Messages(db: SyncDatabase): boolean { + return ( + canCountOpenCode2Messages(db) && + ['data', 'time_created', 'seq'].every((column) => + columnExists(db, OPENCODE2_MESSAGE_TABLE, column) + ) + ) +} + +function* readCaptureRows(db: SyncDatabase, sessionId: string): Generator { + if (!canReadOpenCode2Messages(db)) { + throw new Error('OpenCode 2 transcript schema is unreadable') + } + const rows = db + .prepare(`SELECT type, data, time_created FROM session_message + WHERE session_id = ? AND type IN ('user','assistant','tool') ORDER BY time_created, seq`) + .iterate(sessionId) + let count = 0 + let bytes = 0 + for (const value of rows) { + const row = parseOpenCode2MessageRow(value) + bytes += Buffer.byteLength(row.data) + if (++count > OPENCODE_CAPTURE_RECORD_LIMIT || bytes > OPENCODE_CAPTURE_TEXT_LIMIT) { + throw new Error('OpenCode 2 transcript exceeds capture limits; no partial read was published') + } + yield { type: row.type, data: row.data, time_created: row.time_created } + } +} + +/** + * Parse a single opencode2 session from the channel-scoped SQLite database + * into an `AiVaultSession`. Reads session metadata (title, cwd, model, tokens, + * cost) and up to 5 preview messages from the `session_message` table. The + * database is opened read-only with `PRAGMA query_only = ON`; any schema drift + * from the beta builds fails soft to `null`. + * @param args.dbPath - Absolute path to the opencode2 (opencode-next.db) file. + * @param args.sessionId - Primary key in the `session_v2` table. + * @param args.platform - The platform to use for resume command generation. + * @returns The parsed `AiVaultSession`, or `null` if the session does not exist + * or the database lacks the required schema. + */ +export async function parseOpenCode2SqliteSession(args: { + dbPath: string + sessionId: string + platform: NodeJS.Platform + messages?: TranscriptMessageSink +}): Promise { + const { dbPath, sessionId, platform } = args + let db: SyncDatabase | null = null + try { + db = openReadonlyDatabase(dbPath) + if (!canReadOpenCode2Sessions(db)) { + return null + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the query selects the SessionRow columns and the schema guard ran immediately above. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime validation or the local test fixture establishes the asserted shape. + const row = db.prepare(buildSessionQuery(db)).get(sessionId) as SessionRow | undefined + if (!row || row.id !== sessionId) { + return null + } + + const mtimeMs = + typeof row.time_updated === 'number' && row.time_updated > 0 + ? row.time_updated + : row.time_created + const accumulator = createAccumulator({ + agent: 'opencode2', + file: { + path: dbPath, + mtimeMs, + modifiedAt: new Date(mtimeMs).toISOString() + }, + sessionId, + messages: args.messages + }) + accumulator.title = normalizeTitleText(row.title ?? '') + accumulator.cwd = row.directory + accumulator.model = extractModelId(row.model) + accumulator.totalTokens = + (row.tokens_input ?? 0) + (row.tokens_output ?? 0) + (row.tokens_reasoning ?? 0) + accumulator.messageCount = row.message_count ?? 0 + updateTimeline(accumulator, row.time_created) + updateTimeline(accumulator, row.time_updated) + + const previewSql = buildPreviewQuery(db) + if (args.messages?.active || previewSql) { + const probedRows = + previewSql && !args.messages?.active + ? db + .prepare(previewSql) + .all(sessionId, OPENCODE2_PREVIEW_LIMIT + 1) + .map(parseOpenCode2MessageRow) + : [] + if (probedRows.length > OPENCODE2_PREVIEW_LIMIT) { + accumulator.previewMessagesTruncated = true + } + const previewRows = args.messages?.active + ? readCaptureRows(db, sessionId) + : probedRows.slice(0, OPENCODE2_PREVIEW_LIMIT).toReversed() + for (const previewRow of previewRows) { + const role = mapPreviewRole(previewRow.type) + if (args.messages?.active) { + for (const message of decodeOpenCode2Message( + previewRow.data, + role, + timestampIso(previewRow.time_created) + )) { + args.messages.push(message) + } + } + const text = extractOpenCode2MessageText(previewRow.data) + if (!text) { + continue + } + addPreviewMessage(accumulator, { + role, + publishMessage: false, + text, + timestamp: previewRow.time_created, + seedFirstUserPrompt: false + }) + } + } + + if (shouldCaptureFullFirstUserPrompt()) { + accumulator.firstUserPrompt = readFirstUserPromptFromDb(db, sessionId) + } + + return finalizeSession(accumulator, platform) + } finally { + db?.close() + } +} + +export async function captureOpenCode2SqliteSession(args: { + dbPath: string + sessionId: string + platform: NodeJS.Platform +}): Promise<{ session: AiVaultSession | null; messages: TranscriptMessage[] }> { + const messages: TranscriptMessage[] = [] + const sink: TranscriptMessageSink = { active: true, push: (message) => messages.push(message) } + const session = await parseOpenCode2SqliteSession({ ...args, messages: sink }) + return { session, messages } +} diff --git a/src/main/ai-vault/session-scanner-parse-cache.ts b/src/main/ai-vault/session-scanner-parse-cache.ts index 95b50b139ce..9a4ffc9b7ea 100644 --- a/src/main/ai-vault/session-scanner-parse-cache.ts +++ b/src/main/ai-vault/session-scanner-parse-cache.ts @@ -79,6 +79,7 @@ function resumableStateFactoryFor( case 'cline': case 'kimi': case 'opencode': + case 'opencode2': case 'rovo': return null } diff --git a/src/main/ai-vault/session-scanner-test-fixtures.ts b/src/main/ai-vault/session-scanner-test-fixtures.ts index cadcdbe0639..ddd81effcb0 100644 --- a/src/main/ai-vault/session-scanner-test-fixtures.ts +++ b/src/main/ai-vault/session-scanner-test-fixtures.ts @@ -1,5 +1,55 @@ import { mkdir, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' +import Database from '../sqlite/sync-database' + +export async function writeOpenCode2SqliteFixture(root: string): Promise { + // Why: opencode2 (beta) sessions come from the channel-scoped SQLite DB + // (session_v2/session_message schema) alongside the v1 store. + const opencode2DbPath = join(root, 'opencode-next.db') + const db = new Database(opencode2DbPath) + db.exec(` + CREATE TABLE session_v2 ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + parent_id TEXT, + slug TEXT NOT NULL, + directory TEXT NOT NULL, + title TEXT, + version TEXT NOT NULL, + time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL, + time_archived INTEGER + ); + CREATE TABLE session_message ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + type TEXT NOT NULL, + seq INTEGER NOT NULL, + time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL, + data TEXT NOT NULL + ); + `) + db.prepare( + `INSERT INTO session_v2 + (id, project_id, parent_id, slug, directory, title, version, time_created, time_updated) + VALUES (?, 'proj-1', NULL, 'slug', '/tmp/opencode2', 'OpenCode 2 title', '0.0.0-next-1', 1777634000000, 1777634001000)` + ).run('opencode2-session') + db.prepare( + `INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data) + VALUES (?, 'opencode2-session', 'user', 1, 1777634000500, 1777634000500, ?)` + ).run( + 'msg_opencode2_1', + JSON.stringify({ + id: 'msg_opencode2_1', + type: 'user', + text: 'OpenCode 2 title', + time: { created: 1777634000500 } + }) + ) + db.close() + return opencode2DbPath +} export function isolatedScanRoots(root: string) { return { diff --git a/src/main/ai-vault/session-scanner.test.ts b/src/main/ai-vault/session-scanner.test.ts index 00db94496b7..c64a4987b85 100644 --- a/src/main/ai-vault/session-scanner.test.ts +++ b/src/main/ai-vault/session-scanner.test.ts @@ -7,6 +7,30 @@ import { scanAiVaultSessions } from './session-scanner' import { isolatedScanRoots, jsonLines } from './session-scanner-test-fixtures' import { writeEveryAgentVault } from './session-scanner-every-agent-fixture' +// Why: the SQLite worker bundle does not exist in the test runtime; route the +// v1/v2 worker calls to their synchronous implementations so scanning stays +// end-to-end without spawning a real worker thread. +vi.mock('./session-scanner-opencode-sqlite-worker-spawn', async () => { + const v1List = await import('./session-scanner-opencode-sqlite-list') + const v1Parse = await import('./session-scanner-opencode-sqlite') + const v2List = await import('./session-scanner-opencode2-sqlite-list') + const v2Parse = await import('./session-scanner-opencode2-sqlite') + return { + listOpenCodeSqliteSessionsViaWorker: ( + args: Parameters[0] + ) => v1List.listOpenCodeSqliteSessions(args), + parseOpenCodeSqliteSessionViaWorker: ( + args: Parameters[0] + ) => v1Parse.parseOpenCodeSqliteSession(args), + listOpenCode2SqliteSessionsViaWorker: ( + args: Parameters[0] + ) => v2List.listOpenCode2SqliteSessions(args), + parseOpenCode2SqliteSessionViaWorker: ( + args: Parameters[0] + ) => v2Parse.parseOpenCode2SqliteSession(args) + } +}) + let tempRoots: string[] = [] afterEach(async () => { @@ -396,6 +420,9 @@ describe('scanAiVaultSessions', () => { expect(commandByAgent.get('opencode')).toBe( "cd '/tmp/opencode' && opencode --session 'opencode-session'" ) + expect(commandByAgent.get('opencode2')).toBe( + "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( "cd '/tmp/hermes' && hermes --resume 'hermes-session'" diff --git a/src/main/ai-vault/session-transcript-every-agent-capture.test.ts b/src/main/ai-vault/session-transcript-every-agent-capture.test.ts index 400f29d7746..0d135cdb7e8 100644 --- a/src/main/ai-vault/session-transcript-every-agent-capture.test.ts +++ b/src/main/ai-vault/session-transcript-every-agent-capture.test.ts @@ -7,19 +7,31 @@ import { afterEach, expect, it, vi } from 'vitest' // in-process readers, which the worker entry calls on the other side. vi.mock('./session-scanner-opencode-sqlite-worker-spawn', async () => { const list = await import('./session-scanner-opencode-sqlite-list') + const list2 = await import('./session-scanner-opencode2-sqlite-list') const parse = await import('./session-scanner-opencode-sqlite') + const parse2 = await import('./session-scanner-opencode2-sqlite') const capture = await import('./session-scanner-opencode-sqlite-capture') + const capture2 = await import('./session-scanner-opencode2-sqlite') return { resolveOpenCodeSqliteWorkerEntryPath: () => null, listOpenCodeSqliteSessionsViaWorker: ( args: Parameters[0] ) => list.listOpenCodeSqliteSessions(args), + listOpenCode2SqliteSessionsViaWorker: ( + args: Parameters[0] + ) => list2.listOpenCode2SqliteSessions(args), parseOpenCodeSqliteSessionViaWorker: ( args: Parameters[0] ) => parse.parseOpenCodeSqliteSession(args), + parseOpenCode2SqliteSessionViaWorker: ( + args: Parameters[0] + ) => parse2.parseOpenCode2SqliteSession(args), captureOpenCodeSqliteSessionViaWorker: ( args: Parameters[0] - ) => capture.captureOpenCodeSqliteSession(args) + ) => capture.captureOpenCodeSqliteSession(args), + captureOpenCode2SqliteSessionViaWorker: ( + args: Parameters[0] + ) => capture2.captureOpenCode2SqliteSession(args) } }) import { AI_VAULT_AGENTS, type AiVaultAgent } from '../../shared/ai-vault-types' @@ -100,7 +112,7 @@ async function readEveryAgentVault(): Promise { }) const result = await scanAiVaultSessions({ ...roots, - opencodeDbPaths: [dbPath], + opencodeDbPaths: [...(roots.opencodeDbPaths ?? []), dbPath], platform: 'darwin', limit: 40 }) diff --git a/src/main/ipc/pty/host-env/assembly.ts b/src/main/ipc/pty/host-env/assembly.ts index 3a93389cde8..ef8585a93a2 100644 --- a/src/main/ipc/pty/host-env/assembly.ts +++ b/src/main/ipc/pty/host-env/assembly.ts @@ -1,10 +1,11 @@ import { resolveSetupAgentSequenceLaunchCommand } from '../../../../shared/setup-agent-sequencing' +import { isOpenCode2LaunchCommand } from '../../../../shared/opencode-launch-command' import { detectExplicitPiAgentKindFromCommand, isPiCompatibleAgentType } from '../../../../shared/pi-agent-kind' import { applyTerminalGitCredentialPromptGuard } from '../../terminal-git-credential-guard' -import { openCodeHookService } from '../../../opencode/hook-service' +import { openCode2HookService, openCodeHookService } from '../../../opencode/hook-service' import { mimoCodeHookService } from '../../../mimo/hook-service' import { agentHookServer } from '../../../agent-hooks/server' import { wslHookRelayManager } from '../../../agent-hooks/wsl-hook-relay-manager' @@ -46,6 +47,12 @@ export function buildPtyHostEnv( // Why: local path's baseEnv includes process.env but the daemon path doesn't (fork inheritance, not IPC); check both sources so guards stay in lock-step across spawn paths. const preexistingOpenCodeConfigDir = resolveOpenCodeSourceConfigDir(baseEnv) const launchCommandHint = resolveSetupAgentSequenceLaunchCommand(baseEnv, opts.launchCommand) + // Typed launches do not carry the picker identity; infer the beta binary so + // it receives the OpenCode 2 hook endpoint and isolated plugin overlay. + const openCodeAgent = + opts.launchAgent === 'opencode2' || isOpenCode2LaunchCommand(launchCommandHint) + ? 'opencode2' + : 'opencode' const explicitPiAgentKind = isPiCompatibleAgentType(opts.launchAgent) ? opts.launchAgent : opts.launchAgent === undefined @@ -82,7 +89,9 @@ export function buildPtyHostEnv( if (opts.agentStatusHooksEnabled) { // Why: OPENCODE_CONFIG_DIR is a single path, not a colon-list; mirror the user's value into an overlay so their plugins and Orca's status plugin coexist. See docs/opencode-config-dir-collision.md. - Object.assign(baseEnv, openCodeHookService.buildPtyEnv(id, preexistingOpenCodeConfigDir)) + const openCodeStatusService = + openCodeAgent === 'opencode2' ? openCode2HookService : openCodeHookService + Object.assign(baseEnv, openCodeStatusService.buildPtyEnv(id, preexistingOpenCodeConfigDir)) if (baseEnv.OPENCODE_CONFIG_DIR) { // Why: ~/.zshrc can re-export the user's default after spawn; shell-ready wrappers restore this PTY-scoped value. baseEnv.ORCA_OPENCODE_CONFIG_DIR = baseEnv.OPENCODE_CONFIG_DIR @@ -133,7 +142,7 @@ export function buildPtyHostEnv( baseEnv.ORCA_AGENT_HOOK_ENDPOINT = guestEndpoint } // Why: OpenCode loads its status plugin from a guest config overlay, so point OPENCODE_CONFIG_DIR at the guest dir the relay materialized. - const opencodeOverlayDir = wslHookRelayManager.getOpenCodeOverlayDir(distro) + const opencodeOverlayDir = wslHookRelayManager.getOpenCodeOverlayDir(distro, openCodeAgent) if (opencodeOverlayDir) { baseEnv.OPENCODE_CONFIG_DIR = opencodeOverlayDir baseEnv.ORCA_OPENCODE_CONFIG_DIR = opencodeOverlayDir diff --git a/src/main/opencode-usage/scanner-windows-data-directory.test.ts b/src/main/opencode-usage/scanner-windows-data-directory.test.ts index 317e7b57954..59470f77ef3 100644 --- a/src/main/opencode-usage/scanner-windows-data-directory.test.ts +++ b/src/main/opencode-usage/scanner-windows-data-directory.test.ts @@ -9,7 +9,12 @@ import { scanOpenCodeUsageDatabases } from './scanner' vi.mock('../ai-vault/session-scanner-opencode-sqlite-worker-spawn', async () => { const { listOpenCodeSqliteSessions } = await import('../ai-vault/session-scanner-opencode-sqlite-list') - return { listOpenCodeSqliteSessionsViaWorker: listOpenCodeSqliteSessions } + const { listOpenCode2SqliteSessions } = + await import('../ai-vault/session-scanner-opencode2-sqlite-list') + return { + listOpenCodeSqliteSessionsViaWorker: listOpenCodeSqliteSessions, + listOpenCode2SqliteSessionsViaWorker: listOpenCode2SqliteSessions + } }) describe('OpenCode usage discovery on Windows', () => { diff --git a/src/main/opencode/hook-plugin-module-contract.test.ts b/src/main/opencode/hook-plugin-module-contract.test.ts index d296d5c53c1..6b3ca68ae5c 100644 --- a/src/main/opencode/hook-plugin-module-contract.test.ts +++ b/src/main/opencode/hook-plugin-module-contract.test.ts @@ -16,21 +16,18 @@ vi.mock('electron', () => ({ import { _internals } from './hook-service' -/** - * OpenCode loads a plugin file either through a named factory export or through the - * module default export. The default-export loader rejects the module outright unless - * the default is an object exposing `server()` — verified against opencode 1.18.18, - * which logs `failed to load plugin … must default export an object with server()` for - * a default of `{ id, setup }` and accepts `{ id, server }`. These tests execute the - * generated module so the shipped file is checked against both loaders, not a substring. - */ +// Execute the generated module against legacy and current plugin contracts. describe('OpenCode status plugin module contract', () => { type PluginHooks = { event: (input: { event: unknown }) => Promise 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 } @@ -72,13 +69,16 @@ describe('OpenCode status plugin module contract', () => { rmSync(tempDir, { recursive: true, force: true }) }) - async function loadPluginModule(): Promise { + async function loadPluginModule( + source = _internals.getOpenCodePluginSource() + ): Promise { // Why: a unique basename per load defeats the ESM module cache between cases. const pluginPath = join( tempDir, `orca-opencode-status-${Math.random().toString(36).slice(2)}.mjs` ) - writeFileSync(pluginPath, _internals.getOpenCodePluginSource()) + writeFileSync(pluginPath, source) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime validation or the local test fixture establishes the asserted shape. return (await import(pathToFileURL(pluginPath).href)) as PluginModule } @@ -119,9 +119,12 @@ describe('OpenCode status plugin module contract', () => { it('reports a session lifecycle event through the hook endpoint when driven via the default export', async () => { process.env.ORCA_PANE_KEY = 'tab-1:leaf-1' const posts: { url: string; body: unknown }[] = [] + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The mocked fetch is assigned to the standard Fetch API shape. globalThis.fetch = vi.fn(async (input: unknown, init?: { body?: unknown }) => { posts.push({ url: String(input), body: JSON.parse(String(init?.body ?? '{}')) }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime validation or the local test fixture establishes the asserted shape. return { ok: true } as Response + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime validation or the local test fixture establishes the asserted shape. }) as unknown as typeof globalThis.fetch const module = await loadPluginModule() @@ -151,4 +154,195 @@ describe('OpenCode status plugin module contract', () => { payload: { hook_event_name: 'SessionBusy' } }) }) + + it('keeps OpenCode 2 busy across steps until the session becomes idle', async () => { + process.env.ORCA_PANE_KEY = 'tab-1:leaf-1' + const posts: { body: Record }[] = [] + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The mocked fetch is assigned to the standard Fetch API shape. + globalThis.fetch = vi.fn(async (_input: unknown, init?: { body?: unknown }) => { + posts.push({ body: JSON.parse(String(init?.body ?? '{}')) }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The mocked fetch returns the Response shape the plugin checks. + return { ok: true } as Response + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The mocked fetch is assigned to the standard Fetch API shape. + }) as unknown as typeof globalThis.fetch + + const module = await loadPluginModule(_internals.getOpenCode2PluginSource()) + const hooks = await module.default?.server?.({ + client: { session: { get: async () => ({ data: { id: 'ses_root' } }) } } + }) + await hooks?.event({ + event: { + type: 'session.next.step.started', + properties: { sessionID: 'ses_root', assistantMessageID: 'msg-1' } + } + }) + await hooks?.event({ + event: { + type: 'session.next.step.ended', + properties: { sessionID: 'ses_root', assistantMessageID: 'msg-1' } + } + }) + expect(posts).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + payload: expect.objectContaining({ hook_event_name: 'SessionIdle' }) + }) + ]) + ) + await hooks?.event({ event: { type: 'session.idle', properties: { sessionID: 'ses_root' } } }) + await new Promise((resolve) => setTimeout(resolve, 50)) + const hookEvents = posts.map((post) => { + const payload = post.body.payload + return typeof payload === 'object' && payload !== null + ? // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The generated plugin payload is an object with a hook_event_name field. + (payload as { hook_event_name?: unknown }).hook_event_name + : undefined + }) + expect(hookEvents).toContain('SessionBusy') + expect(hookEvents).toContain('SessionIdle') + }) + + it('maps OpenCode 2 permission.v2 events to the existing permission card contract', async () => { + process.env.ORCA_PANE_KEY = 'tab-1:leaf-1' + const posts: { body: Record }[] = [] + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The mocked fetch is assigned to the standard Fetch API shape. + globalThis.fetch = vi.fn(async (_input: unknown, init?: { body?: unknown }) => { + posts.push({ body: JSON.parse(String(init?.body ?? '{}')) }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime validation or the local test fixture establishes the asserted shape. + return { ok: true } as Response + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime validation or the local test fixture establishes the asserted shape. + }) as unknown as typeof globalThis.fetch + + const module = await loadPluginModule(_internals.getOpenCode2PluginSource()) + const hooks = await module.default?.server?.({ + client: { session: { get: async () => ({ data: { id: 'ses_root' } }) } } + }) + await hooks?.event({ + event: { + type: 'permission.v2.asked', + properties: { + id: 'perm-1', + sessionID: 'ses_root', + action: 'bash', + resources: ['git status'] + } + } + }) + await new Promise((resolve) => setTimeout(resolve, 50)) + + const hookEvents = posts.map((post) => + typeof post.body.payload === 'object' && post.body.payload !== null + ? // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime validation or the local test fixture establishes the asserted shape. + (post.body.payload as { hook_event_name?: unknown }).hook_event_name + : undefined + ) + expect(hookEvents).toContain('PermissionRequest') + expect( + posts.find((post) => { + const payload = post.body.payload + return ( + typeof payload === 'object' && + payload !== null && + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime validation or the local test fixture establishes the asserted shape. + (payload as { hook_event_name?: unknown }).hook_event_name === 'PermissionRequest' + ) + })?.body + ).toMatchObject({ + payload: { + permission: 'bash', + patterns: ['git status'] + } + }) + }) + + it('forwards admitted prompts and completed streamed text once', async () => { + process.env.ORCA_PANE_KEY = 'tab-1:leaf-1' + const posts: { body: Record }[] = [] + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The mocked fetch is assigned to the standard Fetch API shape. + globalThis.fetch = vi.fn(async (_input: unknown, init?: { body?: unknown }) => { + posts.push({ body: JSON.parse(String(init?.body ?? '{}')) }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime validation or the local test fixture establishes the asserted shape. + return { ok: true } as Response + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime validation or the local test fixture establishes the asserted shape. + }) as unknown as typeof globalThis.fetch + + const module = await loadPluginModule(_internals.getOpenCode2PluginSource()) + const hooks = await module.default?.server?.({ + client: { session: { get: async () => ({ data: { id: 'ses_root' } }) } } + }) + await hooks?.event({ + event: { + type: 'session.next.prompt.admitted', + properties: { + sessionID: 'ses_root', + messageID: 'msg-user', + prompt: { text: 'Inspect the repository' } + } + } + }) + await hooks?.event({ + event: { + type: 'session.next.text.ended', + properties: { + sessionID: 'ses_root', + assistantMessageID: 'msg-assistant', + textID: 'text-1', + text: 'The repository is ready.' + } + } + }) + await new Promise((resolve) => setTimeout(resolve, 80)) + + const messageBodies = posts + .map((post) => post.body.payload) + .filter( + (payload): payload is Record => + typeof payload === 'object' && payload !== null && 'role' in payload + ) + expect(messageBodies).toEqual([ + expect.objectContaining({ role: 'user', text: 'Inspect the repository' }), + expect.objectContaining({ role: 'assistant', text: 'The repository is ready.' }) + ]) + }) + + it('maps question.v2 blockers and replies through the waiting lifecycle', async () => { + process.env.ORCA_PANE_KEY = 'tab-1:leaf-1' + const posts: { body: Record }[] = [] + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The mocked fetch is assigned to the standard Fetch API shape. + globalThis.fetch = vi.fn(async (_input: unknown, init?: { body?: unknown }) => { + posts.push({ body: JSON.parse(String(init?.body ?? '{}')) }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime validation or the local test fixture establishes the asserted shape. + return { ok: true } as Response + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime validation or the local test fixture establishes the asserted shape. + }) as unknown as typeof globalThis.fetch + + const module = await loadPluginModule(_internals.getOpenCode2PluginSource()) + const hooks = await module.default?.server?.({ + client: { session: { get: async () => ({ data: { id: 'ses_root' } }) } } + }) + await hooks?.event({ + event: { + type: 'question.v2.asked', + properties: { + id: 'que-1', + sessionID: 'ses_root', + questions: [{ question: 'Which branch?', header: 'Branch', options: [] }] + } + } + }) + await new Promise((resolve) => setTimeout(resolve, 50)) + expect(posts.map((post) => post.body.payload)).toContainEqual( + expect.objectContaining({ hook_event_name: 'AskUserQuestion' }) + ) + + await hooks?.event({ + event: { + type: 'question.v2.rejected', + properties: { requestID: 'que-1', sessionID: 'ses_root' } + } + }) + await new Promise((resolve) => setTimeout(resolve, 50)) + const lastPayload = posts.at(-1)?.body.payload + expect(lastPayload).toEqual(expect.objectContaining({ hook_event_name: 'SessionIdle' })) + }) }) diff --git a/src/main/opencode/hook-plugin-opencode2-setup.test.ts b/src/main/opencode/hook-plugin-opencode2-setup.test.ts new file mode 100644 index 00000000000..b4d2cda8a59 --- /dev/null +++ b/src/main/opencode/hook-plugin-opencode2-setup.test.ts @@ -0,0 +1,285 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { 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' + +// Execute the generated module against legacy and current plugin contracts. +describe('OpenCode 2 setup and prompt ordering', () => { + type PostBody = { payload?: unknown } + + function record(value: unknown): Record | undefined { + return typeof value === 'object' && value !== null + ? Object.fromEntries(Object.entries(value)) + : undefined + } + + function payload(body: PostBody): Record { + return record(body.payload) ?? {} + } + + type PluginHooks = { + event: (input: { event: unknown }) => Promise + dispose?: () => Promise + } + type PluginModule = { + default?: { + id?: unknown + server?: (ctx: unknown) => Promise + setup?: (ctx: unknown) => Promise<() => Promise> + } + OrcaOpenCodeStatusPlugin?: (ctx: unknown) => Promise + } + + // Why: the plugin resolves hook coords from the endpoint file first and only then from + // env. Pin every input here so the run does not depend on the developer's Orca session + // (an inherited ORCA_AGENT_HOOK_ENDPOINT would otherwise redirect the post to a live app). + const ENV_KEYS = [ + 'ORCA_PANE_KEY', + 'ORCA_AGENT_HOOK_ENDPOINT', + 'ORCA_AGENT_HOOK_PORT', + 'ORCA_AGENT_HOOK_TOKEN' + ] as const + + let tempDir: string + let savedFetch: typeof globalThis.fetch + let savedEnv: Record + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'orca-opencode-plugin-contract-')) + savedFetch = globalThis.fetch + savedEnv = {} + for (const key of ENV_KEYS) { + savedEnv[key] = process.env[key] + } + delete process.env.ORCA_AGENT_HOOK_ENDPOINT + process.env.ORCA_AGENT_HOOK_PORT = '59999' + process.env.ORCA_AGENT_HOOK_TOKEN = 'test-token' + }) + + afterEach(() => { + 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 loadPluginModule( + source = _internals.getOpenCodePluginSource() + ): Promise { + // Why: a unique basename per load defeats the ESM module cache between cases. + const pluginPath = join( + tempDir, + `orca-opencode-status-${Math.random().toString(36).slice(2)}.mjs` + ) + writeFileSync(pluginPath, source) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime validation or the local test fixture establishes the asserted shape. + return (await import(pathToFileURL(pluginPath).href)) as PluginModule + } + + 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('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 }[] = [] + globalThis.fetch = vi.fn(async (_input, init) => { + posts.push({ body: record(JSON.parse(String(init?.body))) ?? {} }) + return new Response('{}', { status: 200 }) + }) + const module = await loadPluginModule(_internals.getOpenCode2PluginSource()) + const cleanup = await module.default?.setup?.({ + session: { + get: async ({ sessionID }: { sessionID: string }) => ({ data: { id: sessionID } }), + hook: async () => ({ dispose: vi.fn() }) + }, + event: { + subscribe: async function* () { + yield { + type: 'permission.asked', + data: { id: 'perm-1', sessionID: 'ses_root', action: 'bash', resources: ['pwd'] } + } + yield { + type: 'permission.replied', + data: { id: 'perm-1', requestID: 'perm-1', sessionID: 'ses_root' } + } + yield { + type: 'form.created', + data: { + form: { + id: 'form-1', + sessionID: 'ses_root', + title: 'Pick', + fields: [ + { + title: 'Color', + description: 'Choose', + type: 'string', + options: [{ label: 'Red', value: 'red' }] + } + ] + } + } + } + yield { + type: 'session.text.started', + data: { sessionID: 'ses_root', assistantMessageID: 'msg-1' } + } + yield { + type: 'session.text.delta', + data: { sessionID: 'ses_root', assistantMessageID: 'msg-1', delta: 'hello' } + } + yield { + type: 'session.text.ended', + data: { sessionID: 'ses_root', assistantMessageID: 'msg-1', text: 'hello' } + } + yield { type: 'form.cancelled', data: { id: 'form-1', sessionID: 'ses_root' } } + } + } + }) + await vi.waitFor(() => { + const names = posts.map(({ body }) => payload(body).hook_event_name) + expect(names).toEqual(expect.arrayContaining(['PermissionRequest', 'MessagePart'])) + }) + await vi.waitFor(() => { + const names = posts.map(({ body }) => payload(body).hook_event_name) + expect(names).toContain('AskUserQuestion') + expect(posts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + body: expect.objectContaining({ + payload: expect.objectContaining({ + hook_event_name: 'AskUserQuestion', + sessionID: 'ses_root' + }) + }) + }) + ]) + ) + }) + await vi.waitFor(() => { + const names = posts.map(({ body }) => payload(body).hook_event_name) + expect(names.at(-1)).toBe('SessionIdle') + }) + await cleanup?.() + }) + + it.each(['waiting', 'idle', 'disposed'])( + 'drops an admitted prompt overtaken by %s', + async (transition) => { + 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 }) + }) + let releaseLookup: (value: { data: { id: string } }) => void = () => {} + const lookup = new Promise<{ data: { id: string } }>((resolve) => { + releaseLookup = resolve + }) + const module = await loadPluginModule(_internals.getOpenCode2PluginSource()) + const hooks = await module.default?.server?.({ client: { session: { get: () => lookup } } }) + expect(hooks).toBeDefined() + const prompt = hooks?.event({ + event: { + type: 'session.next.prompt.admitted', + properties: { + sessionID: 'ses_root', + messageID: 'msg_user', + prompt: { text: 'stale prompt' } + } + } + }) + if (transition === 'disposed') { + await hooks?.dispose?.() + } else { + // Seed ancestry while the earlier prompt lookup remains suspended. + await hooks?.event({ + event: { type: 'session.created', properties: { info: { id: 'ses_root' } } } + }) + await hooks?.event({ + event: + transition === 'waiting' + ? { + type: 'permission.asked', + properties: { + id: 'perm_1', + sessionID: 'ses_root', + permission: 'bash', + patterns: ['sleep 25'] + } + } + : { type: 'session.idle', properties: { sessionID: 'ses_root' } } + }) + } + releaseLookup({ data: { id: 'ses_root' } }) + await prompt + expect(posts).not.toContainEqual( + expect.objectContaining({ payload: expect.objectContaining({ role: 'user' }) }) + ) + await hooks?.dispose?.() + } + ) +}) diff --git a/src/main/opencode/hook-service.test.ts b/src/main/opencode/hook-service.test.ts index f0cd94610cf..1f6ecccbbe2 100644 --- a/src/main/opencode/hook-service.test.ts +++ b/src/main/opencode/hook-service.test.ts @@ -23,7 +23,8 @@ import { OpenCodeHookService, _internals, getOpenCodeFamilyPluginSource, - getOpenCodePluginSource + getOpenCodePluginSource, + getOpenCode2PluginSource } from './hook-service' beforeEach(() => { @@ -47,11 +48,14 @@ describe('OpenCode hook plugin source', () => { expect(Object.keys(module).sort()).toEqual([ 'OpenCodeHookService', '_internals', + 'getOpenCode2PluginSource', 'getOpenCodeFamilyPluginSource', 'getOpenCodePluginSource', + 'openCode2HookService', 'openCodeHookService' ]) expect(Object.keys(module._internals).sort()).toEqual([ + 'getOpenCode2PluginSource', 'getOpenCodePluginSource', 'isUsableId', 'toSafeDirName' @@ -71,6 +75,17 @@ describe('OpenCode hook plugin source', () => { expect(familySource).toContain('export const OrcaOpenCodeStatusPlugin') }) + it('generates the OpenCode 2 plugin with its dedicated hook and event family', () => { + const source = getOpenCode2PluginSource() + expect(source).toContain('/hook/opencode2') + expect(source).toContain('session.next.text.delta') + expect(source).toContain('permission.v2.asked') + expect(source).toContain('event.type === "session.next.prompt.admitted"') + expect(source).not.toContain( + 'event.type === "session.next.prompted" || event.type === "session.next.prompt.admitted"' + ) + }) + it('keeps generated plugin bytes stable across the module split', () => { const digest = (source: string): string => createHash('sha256').update(source).digest('hex') diff --git a/src/main/opencode/hook-service.ts b/src/main/opencode/hook-service.ts index ee26acb954e..7f5a8e29904 100644 --- a/src/main/opencode/hook-service.ts +++ b/src/main/opencode/hook-service.ts @@ -33,6 +33,13 @@ type OpenCodeOverlayManifest = { pluginEntries: string[] } +type OpenCodeHookVariant = { + pluginFileName: string + legacyHooksDir: string + overlayDir: string + pluginSource: () => string +} + // Why: session IDs may contain path separators and are hashed downstream; cap pathological input. function isUsableId(id: string): boolean { return typeof id === 'string' && id.length > 0 && id.length <= 1024 @@ -47,9 +54,16 @@ export function getOpenCodePluginSource(): string { return getOpenCodeFamilyPluginSource('/hook/opencode', { emitSessionStart: true }) } +export function getOpenCode2PluginSource(): string { + return getOpenCodeFamilyPluginSource('/hook/opencode2', { + emitSessionStart: true, + emitNextEvents: true + }) +} + export function getOpenCodeFamilyPluginSource( hookPathname: string, - options: { emitSessionStart: boolean } + options: { emitSessionStart: boolean; emitNextEvents?: boolean } ): string { // Why: the plugin posts PTY environment data from OpenCode to the shared hooks server. return [ @@ -67,6 +81,32 @@ export function getOpenCodeFamilyPluginSource( // Why: installs the plugin into OPENCODE_CONFIG_DIR so it POSTs to the shared agent-hooks server, unifying OpenCode status with Claude/Codex/Gemini (the old loopback-IPC path never reached agentStatusByPaneKey). export class OpenCodeHookService { + private readonly pluginSource: () => string + private readonly pluginFileName: string + private readonly legacyHooksDir: string + private readonly overlayDir: string + + constructor(variant?: OpenCodeHookVariant | (() => string)) { + const config: OpenCodeHookVariant = + typeof variant === 'function' + ? { + pluginFileName: ORCA_OPENCODE_PLUGIN_FILE, + legacyHooksDir: OPENCODE_LEGACY_HOOKS_DIR, + overlayDir: OPENCODE_OVERLAY_DIR, + pluginSource: variant + } + : (variant ?? { + pluginFileName: ORCA_OPENCODE_PLUGIN_FILE, + legacyHooksDir: OPENCODE_LEGACY_HOOKS_DIR, + overlayDir: OPENCODE_OVERLAY_DIR, + pluginSource: getOpenCodePluginSource + }) + this.pluginSource = config.pluginSource + this.pluginFileName = config.pluginFileName + this.legacyHooksDir = config.legacyHooksDir + this.overlayDir = config.overlayDir + } + clearPty(_ptyId: string): void { // Why: no-op — config dirs are app/source-scoped now, and recursive delete on the main-process hot path could freeze on Windows. } @@ -106,7 +146,7 @@ export class OpenCodeHookService { } private getOverlayRoot(): string { - return join(getAppEnvironment().getPath('userData'), OPENCODE_OVERLAY_DIR) + return join(getAppEnvironment().getPath('userData'), this.overlayDir) } private getSourceOverlayDir(sourceConfigDir: string): string { @@ -116,7 +156,7 @@ export class OpenCodeHookService { private getSharedConfigDir(): string { return join( getAppEnvironment().getPath('userData'), - OPENCODE_LEGACY_HOOKS_DIR, + this.legacyHooksDir, OPENCODE_SHARED_CONFIG_DIR ) } @@ -149,7 +189,7 @@ export class OpenCodeHookService { const overlayPluginsDir = join(overlayDir, 'plugins') for (const entryName of manifest.pluginEntries) { - if (entryName === ORCA_OPENCODE_PLUGIN_FILE) { + if (entryName === this.pluginFileName) { continue } safeRemoveTree(join(overlayPluginsDir, entryName)) @@ -187,7 +227,7 @@ 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 === ORCA_OPENCODE_PLUGIN_FILE) { + if (pluginEntry.name === this.pluginFileName) { continue } mirrorEntry( @@ -211,13 +251,13 @@ export class OpenCodeHookService { private writePluginIntoOverlay(overlayDir: string): void { const pluginsDir = join(overlayDir, 'plugins') mkdirSync(pluginsDir, { recursive: true }) - const pluginPath = join(pluginsDir, ORCA_OPENCODE_PLUGIN_FILE) + const pluginPath = join(pluginsDir, this.pluginFileName) try { unlinkSync(pluginPath) } catch { // File may not exist on a fresh overlay; a real failure surfaces on writeFileSync below. } - writeFileSync(pluginPath, getOpenCodePluginSource()) + writeFileSync(pluginPath, this.pluginSource()) } private writeSharedPluginConfig(): string | null { @@ -225,7 +265,7 @@ export class OpenCodeHookService { const pluginsDir = join(configDir, 'plugins') try { mkdirSync(pluginsDir, { recursive: true }) - writeFileSync(join(pluginsDir, ORCA_OPENCODE_PLUGIN_FILE), getOpenCodePluginSource()) + writeFileSync(join(pluginsDir, this.pluginFileName), this.pluginSource()) } catch { // Why: userData can be locked on Windows (EPERM/EBUSY); plugin is non-critical, so spawn without it. return null @@ -235,8 +275,15 @@ export class OpenCodeHookService { } export const openCodeHookService = new OpenCodeHookService() +export const openCode2HookService = new OpenCodeHookService({ + pluginFileName: 'orca-opencode2-status.js', + legacyHooksDir: 'opencode2-hooks', + overlayDir: 'opencode2-config-overlays', + pluginSource: getOpenCode2PluginSource +}) export const _internals = { getOpenCodePluginSource, + getOpenCode2PluginSource, isUsableId, toSafeDirName } diff --git a/src/main/opencode/status-plugin-factory-source.ts b/src/main/opencode/status-plugin-factory-source.ts index 9a82bcf8764..48074c5669c 100644 --- a/src/main/opencode/status-plugin-factory-source.ts +++ b/src/main/opencode/status-plugin-factory-source.ts @@ -1,5 +1,14 @@ -export function getStatusPluginFactorySource(options: { emitSessionStart: boolean }): string[] { +import { + getOpenCode2SetupSource, + getOpenCode2EventNormalizationSource +} from '../opencode2/status-plugin-setup-source' + +export function getStatusPluginFactorySource(options: { + emitSessionStart: boolean + emitNextEvents?: boolean +}): string[] { return [ + ...(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', @@ -10,6 +19,7 @@ export function getStatusPluginFactorySource(options: { emitSessionStart: boolea ' const factoryID = ++nextFactoryID;', ' activeFactoryIDs.add(factoryID);', ' let disposed = false;', + ...(options.emitNextEvents ? [' const nextTextByMessageID = new Map();'] : []), ' return {', ' event: async ({ event }) => {', ' if (disposed || !event?.type) return;', @@ -30,6 +40,55 @@ export function getStatusPluginFactorySource(options: { emitSessionStart: boolea '', ' const sessionID = event.properties?.sessionID;', ' const updatedPart = event.properties?.part;', + ...(options.emitNextEvents + ? [ + '', + ' // OpenCode 2 publishes the next-generation event family through the', + ' // same plugin event hook. Convert those events into the existing', + ' // bounded Orca lifecycle and preview posts.', + ' if (event.type === "session.next.prompt.admitted") {', + ' if (!sessionID) return;', + ' if ((await isChildSession(client, sessionID)) !== false) return;', + ' if (disposed || authorityRevision !== stateArrivalRevision || desiredStatus === "waiting") return;', + ' const prompt = event.properties?.prompt?.text;', + ' if (typeof prompt !== "string" || !prompt) return;', + ' await postMessagePart({', + ' role: "user",', + ' text: capMessagePartText(prompt),', + ' messageID: event.properties?.messageID,', + ' sessionID,', + ' }, factoryID);', + ' return;', + ' }', + ' if (event.type === "session.next.text.started") {', + ' if (event.properties?.assistantMessageID) {', + ' if (nextTextByMessageID.size >= 128) nextTextByMessageID.delete(nextTextByMessageID.keys().next().value);', + ' nextTextByMessageID.set(event.properties.assistantMessageID, "");', + ' }', + ' return;', + ' }', + ' if (event.type === "session.next.text.delta") {', + ' const messageID = event.properties?.assistantMessageID;', + ' const delta = event.properties?.delta;', + ' if (typeof messageID !== "string" || typeof delta !== "string") return;', + ' nextTextByMessageID.set(messageID, capMessagePartText((nextTextByMessageID.get(messageID) || "") + delta));', + ' if (nextTextByMessageID.size > 128) nextTextByMessageID.delete(nextTextByMessageID.keys().next().value);', + ' return;', + ' }', + ' if (event.type === "session.next.text.ended") {', + ' if (!sessionID) return;', + ' if ((await isChildSession(client, sessionID)) !== false) return;', + ' const messageID = event.properties?.assistantMessageID;', + ' const text = typeof event.properties?.text === "string"', + ' ? event.properties.text', + ' : (typeof messageID === "string" ? nextTextByMessageID.get(messageID) : "");', + ' if (typeof messageID !== "string" || !text) return;', + ' nextTextByMessageID.delete(messageID);', + ' queueAssistantPart({ role: "assistant", text, messageID, sessionID, authorityRevision, factoryID });', + ' return;', + ' }' + ] + : []), ...(options.emitSessionStart ? [ ' if (event.type === "session.created") {', @@ -69,10 +128,14 @@ export function getStatusPluginFactorySource(options: { emitSessionStart: boolea ' event.type === "question.asked" ||', ' event.type === "permission.replied" ||', ' event.type === "question.replied" ||', - ' event.type === "question.rejected"', + ` event.type === "question.rejected"${ + options.emitNextEvents + ? ' || event.type === "permission.v2.asked" || event.type === "permission.v2.replied" || event.type === "question.v2.asked" || event.type === "question.v2.replied" || event.type === "question.v2.rejected" || event.type === "session.next.step.started" || event.type === "session.next.tool.called" || event.type === "session.next.tool.progress" || event.type === "session.next.retried"' + : '' + }`, ' ) {', ' await enqueueLifecycle(() =>', - ' disposed ? undefined : handleLifecycleEvent(client, event, factoryID)', + ` disposed ? undefined : handleLifecycleEvent(client, ${options.emitNextEvents ? 'normalizeNextLifecycleEvent(event)' : 'event'}, factoryID)`, ' );', ' return;', ' }', @@ -148,6 +211,7 @@ export function getStatusPluginFactorySource(options: { emitSessionStart: boolea ' dispose: async () => {', ' if (disposed) return;', ' disposed = true;', + ...(options.emitNextEvents ? [' nextTextByMessageID.clear();'] : []), ' disposingFactoryIDs.add(factoryID);', ' await enqueueLifecycle(async () => {', ' // An older MessagePart must settle before disposal publishes the', @@ -211,6 +275,7 @@ export function getStatusPluginFactorySource(options: { emitSessionStart: boolea ' },', ' };', '};', + ...(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', @@ -219,6 +284,7 @@ export function getStatusPluginFactorySource(options: { emitSessionStart: boolea 'export default {', ' id: "orca-opencode-status",', ' server: OrcaOpenCodeStatusPlugin,', + ...(options.emitNextEvents ? [' setup: setupOpenCode2Status,'] : []), '};', '' ] 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..c33385ef8d6 --- /dev/null +++ b/src/main/opencode2/status-plugin-setup-source.ts @@ -0,0 +1,77 @@ +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" } } };', + ' }', + ' return event;', + '}', + '' + ] +} diff --git a/src/main/ssh/ssh-relay-session-managed-hooks.test.ts b/src/main/ssh/ssh-relay-session-managed-hooks.test.ts index 36d5adbf021..67351e08102 100644 --- a/src/main/ssh/ssh-relay-session-managed-hooks.test.ts +++ b/src/main/ssh/ssh-relay-session-managed-hooks.test.ts @@ -116,6 +116,7 @@ describe('SshRelaySession managed hooks', () => { ([method]) => method === AGENT_HOOK_INSTALL_PLUGINS_METHOD ) expect(muxRequestMock.mock.calls[pluginsIndex]?.[1]).toMatchObject({ + opencode2PluginSource: expect.stringContaining('/hook/opencode2'), piExtensionSource: expect.stringContaining('/hook/pi'), ompExtensionSource: expect.stringContaining('/hook/omp'), primeAgentExtensionSource: expect.stringContaining('/hook/prime-agent') diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index 3bc02dd734a..19e102b08ed 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -1530,6 +1530,7 @@ export class SshRelaySession { try { await mux.request(AGENT_HOOK_INSTALL_PLUGINS_METHOD, { opencodePluginSource: openCodeInternals.getOpenCodePluginSource(), + opencode2PluginSource: openCodeInternals.getOpenCode2PluginSource(), piExtensionSource: getPiAgentStatusExtensionSource('pi'), ompExtensionSource: getPiAgentStatusExtensionSource('omp'), primeAgentExtensionSource: getPiAgentStatusExtensionSource('prime-agent') diff --git a/src/relay/agent-hook-integration.test.ts b/src/relay/agent-hook-integration.test.ts index f7781d3cdc7..b5a0f1066e4 100644 --- a/src/relay/agent-hook-integration.test.ts +++ b/src/relay/agent-hook-integration.test.ts @@ -118,7 +118,13 @@ describe('Integration: relay hook server → mux → AgentHookServer.ingestRemot rmSync(tmpDir, { recursive: true, force: true }) }) - it('forwards a Claude UserPromptSubmit POST through to ingestRemote', async () => { + it.each([ + { agent: 'claude', input: { hook_event_name: 'UserPromptSubmit', prompt: 'roundtrip' } }, + { + agent: 'opencode2', + input: { hook_event_name: 'MessagePart', role: 'user', text: 'roundtrip' } + } + ])('forwards a $agent prompt through the relay to ingestRemote', async ({ agent, input }) => { const events: { paneKey: string; payload: unknown; connectionId: string | null }[] = [] orcaServer.setListener((event) => { events.push({ @@ -129,7 +135,7 @@ describe('Integration: relay hook server → mux → AgentHookServer.ingestRemot }) const { port, token } = hookServer.getCoordinates() - const res = await fetch(`http://127.0.0.1:${port}/hook/claude`, { + const res = await fetch(`http://127.0.0.1:${port}/hook/${agent}`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -141,7 +147,7 @@ describe('Integration: relay hook server → mux → AgentHookServer.ingestRemot worktreeId: 'wt-7', env: 'remote', version: '1', - payload: { hook_event_name: 'UserPromptSubmit', prompt: 'roundtrip' } + payload: input }) }) expect(res.status).toBe(204) @@ -159,7 +165,7 @@ describe('Integration: relay hook server → mux → AgentHookServer.ingestRemot const payload = events[0].payload as { state: string; prompt: string; agentType: string } expect(payload.state).toBe('working') expect(payload.prompt).toBe('roundtrip') - expect(payload.agentType).toBe('claude') + expect(payload.agentType).toBe(agent) }) it('sheds an oversized assistant message through the production publication path', async () => { diff --git a/src/relay/plugin-overlay.test.ts b/src/relay/plugin-overlay.test.ts index 772c6d9731c..97973f4ab13 100644 --- a/src/relay/plugin-overlay.test.ts +++ b/src/relay/plugin-overlay.test.ts @@ -43,6 +43,17 @@ describe('PluginOverlayManager', () => { expect(readFileSync(expected, 'utf8')).toBe('export const X = 1') }) + it('keeps the OpenCode 2 plugin in a separate overlay and filename', () => { + manager.setSources({ opencode2PluginSource: 'export const V2 = 1' }) + expect(manager.hasOpenCodeSource('opencode2')).toBe(true) + const dir = manager.materializeOpenCode('tab-2:0', undefined, 'opencode2') + expect(dir).not.toBeNull() + expect(readFileSync(join(dir!, 'plugins', 'orca-opencode2-status.js'), 'utf8')).toBe( + 'export const V2 = 1' + ) + expect(existsSync(join(dir!, 'plugins', 'orca-opencode-status.js'))).toBe(false) + }) + it('mirrors a preexisting remote OpenCode config dir before adding Orca plugin', () => { const userConfigDir = join(homeDir, 'company-opencode') mkdirSync(join(userConfigDir, 'plugins'), { recursive: true }) @@ -327,20 +338,24 @@ describe('PluginOverlayManager', () => { it('clearOverlay removes OpenCode overlays without deleting real Pi/OMP homes', () => { manager.setSources({ opencodePluginSource: 'opencode', + opencode2PluginSource: 'opencode2', piExtensionSource: 'pi', ompExtensionSource: 'omp' }) const opencodeDir = manager.materializeOpenCode('tab-3:0')! + const opencode2Dir = manager.materializeOpenCode('tab-3:0', undefined, 'opencode2')! const piDir = manager.materializePi('tab-3:0', undefined, 'pi')!.sourceAgentDir! const ompDir = manager.materializePi('tab-3:0', undefined, 'omp')!.sourceAgentDir! expect(piDir).not.toBe(ompDir) expect(existsSync(opencodeDir)).toBe(true) + expect(existsSync(opencode2Dir)).toBe(true) expect(existsSync(piDir)).toBe(true) expect(existsSync(ompDir)).toBe(true) manager.clearOverlay('tab-3:0') expect(existsSync(opencodeDir)).toBe(false) + expect(existsSync(opencode2Dir)).toBe(false) expect(existsSync(piDir)).toBe(true) expect(existsSync(ompDir)).toBe(true) }) diff --git a/src/relay/plugin-overlay.ts b/src/relay/plugin-overlay.ts index 2e777898b7b..992b8e46240 100644 --- a/src/relay/plugin-overlay.ts +++ b/src/relay/plugin-overlay.ts @@ -37,11 +37,13 @@ type LegacyOverlayAgentKind = Exclude const RELAY_HOOKS_DIR = '.orca-relay' const OPENCODE_OVERLAY_SUBDIR = 'opencode-overlays' +const OPENCODE2_OVERLAY_SUBDIR = 'opencode2-overlays' const PI_OVERLAY_SUBDIR_BY_KIND: Record = { pi: 'pi-overlays', omp: 'omp-overlays' } const OPENCODE_PLUGIN_FILE = 'orca-opencode-status.js' +const OPENCODE2_PLUGIN_FILE = 'orca-opencode2-status.js' const PI_EXTENSION_FILE = 'orca-agent-status.ts' const PI_AGENT_SUBDIR = 'agent' // Why: bare-shell OMP still needs ORCA_OMP_STATUS_EXTENSION without mkdir ~/.omp. @@ -80,6 +82,8 @@ function isUsableId(id: string): boolean { export type PluginSources = { /** Source body of `orca-opencode-status.js` to drop into /plugins/. */ opencodePluginSource?: string + /** Source body of OpenCode 2's status plugin. */ + opencode2PluginSource?: string /** Source body of Pi's `orca-agent-status.ts` to drop into /extensions/. */ piExtensionSource?: string /** Source body of OMP's `orca-agent-status.ts` to drop into /extensions/. */ @@ -98,12 +102,20 @@ export type MaterializePiResult = { /** Presence of this file is what makes an overlay usable — a rebuild that failed * after the wipe leaves the dir itself present but the plugin missing. */ -export function getRelayOpenCodePluginPath(overlayDir: string): string { - return join(overlayDir, 'plugins', OPENCODE_PLUGIN_FILE) +export function getRelayOpenCodePluginPath( + overlayDir: string, + agent: 'opencode' | 'opencode2' = 'opencode' +): string { + return join( + overlayDir, + 'plugins', + agent === 'opencode2' ? OPENCODE2_PLUGIN_FILE : OPENCODE_PLUGIN_FILE + ) } export class PluginOverlayManager { private opencodePluginSource: string | null = null + private opencode2PluginSource: string | null = null private piExtensionSources: Record = { pi: null, omp: null, @@ -111,12 +123,14 @@ export class PluginOverlayManager { } private homeDir: string private opencodeRoot: string + private opencode2Root: string private piRoots: Record constructor(opts?: { homeDir?: string }) { const home = opts?.homeDir ?? homedir() this.homeDir = home this.opencodeRoot = join(home, RELAY_HOOKS_DIR, OPENCODE_OVERLAY_SUBDIR) + this.opencode2Root = join(home, RELAY_HOOKS_DIR, OPENCODE2_OVERLAY_SUBDIR) this.piRoots = { pi: join(home, RELAY_HOOKS_DIR, PI_OVERLAY_SUBDIR_BY_KIND.pi), omp: join(home, RELAY_HOOKS_DIR, PI_OVERLAY_SUBDIR_BY_KIND.omp) @@ -134,6 +148,9 @@ export class PluginOverlayManager { if (typeof sources.opencodePluginSource === 'string') { this.opencodePluginSource = sources.opencodePluginSource } + if (typeof sources.opencode2PluginSource === 'string') { + this.opencode2PluginSource = sources.opencode2PluginSource + } if (typeof sources.piExtensionSource === 'string') { this.piExtensionSources.pi = withOrcaManagedPiExtensionMarker(sources.piExtensionSource) } @@ -147,8 +164,8 @@ export class PluginOverlayManager { } } - hasOpenCodeSource(): boolean { - return this.opencodePluginSource !== null + hasOpenCodeSource(agent: 'opencode' | 'opencode2' = 'opencode'): boolean { + return (agent === 'opencode2' ? this.opencode2PluginSource : this.opencodePluginSource) !== null } hasPiSource(kind?: PiAgentKind): boolean { @@ -163,7 +180,11 @@ export class PluginOverlayManager { return source ?? (kind === 'omp' ? this.piExtensionSources.pi : null) } - private mirrorOpenCodeConfig(sourceDir: string, overlayDir: string): void { + private mirrorOpenCodeConfig( + sourceDir: string, + overlayDir: string, + pluginFileName: string + ): void { for (const entry of readdirSync(sourceDir, { withFileTypes: true })) { const sourcePath = join(sourceDir, entry.name) @@ -183,7 +204,11 @@ export class PluginOverlayManager { const overlayPluginsDir = join(overlayDir, 'plugins') mkdirSync(overlayPluginsDir, { recursive: true }) for (const pluginEntry of readdirSync(resolvedSource, { withFileTypes: true })) { - if (pluginEntry.name === OPENCODE_PLUGIN_FILE) { + if ( + pluginEntry.name === pluginFileName || + pluginEntry.name === OPENCODE_PLUGIN_FILE || + pluginEntry.name === OPENCODE2_PLUGIN_FILE + ) { continue } mirrorEntry( @@ -199,16 +224,16 @@ export class PluginOverlayManager { } } - private writeOpenCodePlugin(overlayDir: string): void { + private writeOpenCodePlugin(overlayDir: string, pluginFileName: string, source: string): void { const pluginsDir = join(overlayDir, 'plugins') mkdirSync(pluginsDir, { recursive: true }) - const pluginPath = join(pluginsDir, OPENCODE_PLUGIN_FILE) + const pluginPath = join(pluginsDir, pluginFileName) try { unlinkSync(pluginPath) } catch { // Fresh overlay or no same-named stale symlink. } - writeFileSync(pluginPath, this.opencodePluginSource!) + writeFileSync(pluginPath, source) } /** Materialize the OpenCode plugin overlay for `id` (typically the @@ -217,13 +242,20 @@ export class PluginOverlayManager { * the overlay write fails — caller falls back to no plugin (the agent * CLI runs without status reporting), which is the existing fail-open * behavior on the local side. */ - materializeOpenCode(id: string, existingConfigDir?: string): string | null { - if (!this.opencodePluginSource || !isUsableId(id)) { + materializeOpenCode( + id: string, + existingConfigDir?: string, + agent: 'opencode' | 'opencode2' = 'opencode' + ): string | null { + const source = agent === 'opencode2' ? this.opencode2PluginSource : this.opencodePluginSource + if (!source || !isUsableId(id)) { return null } - const dir = join(this.opencodeRoot, safeDirName(id)) + const pluginFileName = agent === 'opencode2' ? OPENCODE2_PLUGIN_FILE : OPENCODE_PLUGIN_FILE + const root = agent === 'opencode2' ? this.opencode2Root : this.opencodeRoot + const dir = join(root, safeDirName(id)) try { - safeRemoveOverlay(dir, this.opencodeRoot) + safeRemoveOverlay(dir, root) mkdirSync(dir, { recursive: true }) if (existingConfigDir) { if (!existsSync(existingConfigDir)) { @@ -232,9 +264,9 @@ 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) + this.mirrorOpenCodeConfig(existingConfigDir, dir, pluginFileName) } - this.writeOpenCodePlugin(dir) + this.writeOpenCodePlugin(dir, pluginFileName, source) return dir } catch (err) { process.stderr.write( @@ -244,6 +276,14 @@ export class PluginOverlayManager { } } + hasOpenCode2Source(): boolean { + return this.hasOpenCodeSource('opencode2') + } + + materializeOpenCode2(id: string, existingConfigDir?: string): string | null { + return this.materializeOpenCode(id, existingConfigDir, 'opencode2') + } + private getDefaultPiAgentDir(kind: PiAgentKind, configDirName?: string): string { const root = kind === 'omp' ? configDirName || PI_AGENT_HOME_DIR_NAME.omp : PI_AGENT_HOME_DIR_NAME[kind] @@ -347,7 +387,7 @@ export class PluginOverlayManager { // Why: sweep all overlay roots (OpenCode + each Pi-kind) because PTY exit // doesn't know which kind materialized this id. Per-root scoping inside // safeRemoveOverlay keeps each call bounded to its own tree. - for (const root of [this.opencodeRoot, ...Object.values(this.piRoots)]) { + for (const root of [this.opencodeRoot, this.opencode2Root, ...Object.values(this.piRoots)]) { try { safeRemoveOverlay(join(root, safe), root) } catch (err) { diff --git a/src/relay/relay-agent-hook-runtime.ts b/src/relay/relay-agent-hook-runtime.ts index 1f503ec8a82..df60efe3bce 100644 --- a/src/relay/relay-agent-hook-runtime.ts +++ b/src/relay/relay-agent-hook-runtime.ts @@ -19,6 +19,7 @@ import { isPiCompatibleAgentType } from '../shared/pi-agent-kind' import { resolveSetupAgentSequenceLaunchCommand } from '../shared/setup-agent-sequencing' +import { isOpenCode2LaunchCommand } from '../shared/opencode-launch-command' import { relayLogLine } from './relay-diagnostic-log' import { registerManagedHookInstaller } from './managed-hook-installer' @@ -83,9 +84,14 @@ export class RelayAgentHookRuntime { ): Promise> { const env: Record = {} const overlayId = context.paneKey ?? context.id - if (this.pluginOverlay.hasOpenCodeSource()) { + const launchCommandHint = resolveSetupAgentSequenceLaunchCommand(context.env, context.command) + const opencodeAgent = + context.launchAgent === 'opencode2' || isOpenCode2LaunchCommand(launchCommandHint) + ? 'opencode2' + : 'opencode' + if (this.pluginOverlay.hasOpenCodeSource(opencodeAgent)) { const sourceDir = resolveOpenCodeSourceConfigDir(context.env, context.shell) - const dir = this.pluginOverlay.materializeOpenCode(overlayId, sourceDir) + const dir = this.pluginOverlay.materializeOpenCode(overlayId, sourceDir, opencodeAgent) if (dir) { env.OPENCODE_CONFIG_DIR = dir env.ORCA_OPENCODE_CONFIG_DIR = dir @@ -94,7 +100,6 @@ export class RelayAgentHookRuntime { } } } - const launchCommandHint = resolveSetupAgentSequenceLaunchCommand(context.env, context.command) const explicitKind = isPiCompatibleAgentType(context.launchAgent) ? context.launchAgent : context.launchAgent === undefined @@ -157,15 +162,18 @@ export class RelayAgentHookRuntime { registerManagedHookInstaller(this.dispatcher) this.dispatcher.onRequest(AGENT_HOOK_INSTALL_PLUGINS_METHOD, async (params) => { const opencode = params.opencodePluginSource + const opencode2 = params.opencode2PluginSource const pi = params.piExtensionSource const omp = params.ompExtensionSource const primeAgent = params.primeAgentExtensionSource assertPluginSourceUnderByteCap('opencodePluginSource', opencode) + assertPluginSourceUnderByteCap('opencode2PluginSource', opencode2) assertPluginSourceUnderByteCap('piExtensionSource', pi) assertPluginSourceUnderByteCap('ompExtensionSource', omp) assertPluginSourceUnderByteCap('primeAgentExtensionSource', primeAgent) this.pluginOverlay.setSources({ opencodePluginSource: typeof opencode === 'string' ? opencode : undefined, + opencode2PluginSource: typeof opencode2 === 'string' ? opencode2 : undefined, piExtensionSource: typeof pi === 'string' ? pi : undefined, ompExtensionSource: typeof omp === 'string' ? omp : undefined, primeAgentExtensionSource: typeof primeAgent === 'string' ? primeAgent : undefined @@ -173,6 +181,7 @@ export class RelayAgentHookRuntime { return { installed: { opencode: this.pluginOverlay.hasOpenCodeSource(), + opencode2: this.pluginOverlay.hasOpenCode2Source(), pi: this.pluginOverlay.hasPiSource('pi'), omp: this.pluginOverlay.hasPiSource('omp'), primeAgent: this.pluginOverlay.hasPiSource('prime-agent') diff --git a/src/relay/wsl-install-plugins-handler.test.ts b/src/relay/wsl-install-plugins-handler.test.ts index be3340009bd..9a230b768d3 100644 --- a/src/relay/wsl-install-plugins-handler.test.ts +++ b/src/relay/wsl-install-plugins-handler.test.ts @@ -29,26 +29,47 @@ describe.skipIf(process.platform === 'win32')('createInstallPluginsHandler (gues const install = createInstallPluginsHandler(new PluginOverlayManager({ homeDir: home }), { HOME: home, ORCA_WSL_HOOK_INSTANCE: 'inst1' - } as NodeJS.ProcessEnv) + }) const source = '// orca opencode status plugin\nexport const Plugin = () => ({})\n' const res = install({ opencodePluginSource: source }) expect(res.installed.opencode).toBe(true) const dir = res.overlayDirs.opencode expect(typeof dir).toBe('string') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime validation or the local test fixture establishes the asserted shape. const pluginPath = join(dir as string, 'plugins', 'orca-opencode-status.js') expect(existsSync(pluginPath)).toBe(true) expect(readFileSync(pluginPath, 'utf8')).toBe(source) }) }) + it('writes the OpenCode 2 plugin to its separate overlay', () => { + withHome((home) => { + const install = createInstallPluginsHandler(new PluginOverlayManager({ homeDir: home }), { + HOME: home, + ORCA_WSL_HOOK_INSTANCE: 'inst-v2' + }) + const source = '// opencode2\n' + const res = install({ opencode2PluginSource: source }) + const dir = res.overlayDirs.opencode2 + expect(res.installed.opencode2).toBe(true) + expect(typeof dir).toBe('string') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime validation or the local test fixture establishes the asserted shape. + expect(readFileSync(join(dir as string, 'plugins', 'orca-opencode2-status.js'), 'utf8')).toBe( + source + ) + expect(res.overlayDirs.opencode).toBeUndefined() + }) + }) + it('reuses the overlay on repeat installs instead of rebuilding it', () => { withHome((home) => { const install = createInstallPluginsHandler(new PluginOverlayManager({ homeDir: home }), { HOME: home, ORCA_WSL_HOOK_INSTANCE: 'inst1' - } as NodeJS.ProcessEnv) + }) const source = '// v1\n' + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime validation or the local test fixture establishes the asserted shape. const dir = install({ opencodePluginSource: source }).overlayDirs.opencode as string // Why: a wipe-and-rebuild would delete this alongside the rest of the tree, @@ -69,7 +90,7 @@ describe.skipIf(process.platform === 'win32')('createInstallPluginsHandler (gues // this branch today; it exists so a plugin-only overlay can't outlive a source // dir becoming resolvable. Simulated by mutating the env the factory captured. const userConfig = join(home, 'my-opencode') - const env = { HOME: home, ORCA_WSL_HOOK_INSTANCE: 'inst1' } as NodeJS.ProcessEnv + const env: NodeJS.ProcessEnv = { HOME: home, ORCA_WSL_HOOK_INSTANCE: 'inst1' } const install = createInstallPluginsHandler(new PluginOverlayManager({ homeDir: home }), env) const source = '// v1\n' install({ opencodePluginSource: source }) @@ -78,6 +99,7 @@ describe.skipIf(process.platform === 'win32')('createInstallPluginsHandler (gues writeFileSync(join(userConfig, 'opencode.json'), '{"model":"late"}') env.ORCA_OPENCODE_SOURCE_CONFIG_DIR = userConfig + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime validation or the local test fixture establishes the asserted shape. const dir = install({ opencodePluginSource: source }).overlayDirs.opencode as string expect(readFileSync(join(dir, 'opencode.json'), 'utf8')).toBe('{"model":"late"}') }) @@ -88,8 +110,9 @@ describe.skipIf(process.platform === 'win32')('createInstallPluginsHandler (gues const install = createInstallPluginsHandler(new PluginOverlayManager({ homeDir: home }), { HOME: home, ORCA_WSL_HOOK_INSTANCE: 'inst1' - } as NodeJS.ProcessEnv) + }) const source = '// v1\n' + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime validation or the local test fixture establishes the asserted shape. const dir = install({ opencodePluginSource: source }).overlayDirs.opencode as string // Why: a rebuild that failed after the wipe leaves the dir but not the plugin; // an existsSync on the dir alone would call that a cache hit forever. @@ -105,9 +128,10 @@ describe.skipIf(process.platform === 'win32')('createInstallPluginsHandler (gues const install = createInstallPluginsHandler(new PluginOverlayManager({ homeDir: home }), { HOME: home, ORCA_WSL_HOOK_INSTANCE: 'inst1' - } as NodeJS.ProcessEnv) + }) install({ opencodePluginSource: '// v1\n' }) // Why: a mid-session Orca upgrade ships new plugin source; future spawns must see it. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime validation or the local test fixture establishes the asserted shape. const dir = install({ opencodePluginSource: '// v2\n' }).overlayDirs.opencode as string expect(readFileSync(join(dir, 'plugins', 'orca-opencode-status.js'), 'utf8')).toBe('// v2\n') }) @@ -118,8 +142,9 @@ describe.skipIf(process.platform === 'win32')('createInstallPluginsHandler (gues const install = createInstallPluginsHandler(new PluginOverlayManager({ homeDir: home }), { HOME: home, ORCA_WSL_HOOK_INSTANCE: 'inst1' - } as NodeJS.ProcessEnv) + }) const source = '// v1\n' + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime validation or the local test fixture establishes the asserted shape. const dir = install({ opencodePluginSource: source }).overlayDirs.opencode as string rmSync(dir, { recursive: true, force: true }) @@ -135,12 +160,12 @@ describe.skipIf(process.platform === 'win32')('createInstallPluginsHandler (gues const userConfig = join(home, 'my-opencode') mkdirSync(userConfig, { recursive: true }) writeFileSync(join(userConfig, 'opencode.json'), '{"model":"user-set"}') - const install = createInstallPluginsHandler(new PluginOverlayManager({ homeDir: home }), { HOME: home, ORCA_OPENCODE_SOURCE_CONFIG_DIR: userConfig, ORCA_WSL_HOOK_INSTANCE: 'inst1' - } as NodeJS.ProcessEnv) + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime validation or the local test fixture establishes the asserted shape. const dir = install({ opencodePluginSource: '// v1\n' }).overlayDirs.opencode as string expect(readFileSync(join(dir, 'opencode.json'), 'utf8')).toBe('{"model":"user-set"}') @@ -156,11 +181,11 @@ describe.skipIf(process.platform === 'win32')('createInstallPluginsHandler (gues const defaultConfig = join(home, '.config', 'opencode') mkdirSync(defaultConfig, { recursive: true }) writeFileSync(join(defaultConfig, 'opencode.json'), '{"model":"default"}') - const install = createInstallPluginsHandler(new PluginOverlayManager({ homeDir: home }), { HOME: home, ORCA_WSL_HOOK_INSTANCE: 'inst1' - } as NodeJS.ProcessEnv) + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime validation or the local test fixture establishes the asserted shape. const dir = install({ opencodePluginSource: '// v1\n' }).overlayDirs.opencode as string expect(existsSync(join(dir, 'opencode.json'))).toBe(false) @@ -173,7 +198,7 @@ describe.skipIf(process.platform === 'win32')('createInstallPluginsHandler (gues const overlay = new PluginOverlayManager({ homeDir: home }) const install = createInstallPluginsHandler(overlay, { HOME: home - } as NodeJS.ProcessEnv) + }) const tooBig = 'a'.repeat(PLUGIN_SOURCE_MAX_BYTES + 1) expect(() => install({ opencodePluginSource: tooBig })).toThrow(/byte cap/) expect(overlay.hasOpenCodeSource()).toBe(false) @@ -184,7 +209,7 @@ describe.skipIf(process.platform === 'win32')('createInstallPluginsHandler (gues withHome((home) => { const install = createInstallPluginsHandler(new PluginOverlayManager({ homeDir: home }), { HOME: home - } as NodeJS.ProcessEnv) + }) const res = install({}) expect(res.installed.opencode).toBe(false) expect(res.overlayDirs.opencode).toBeUndefined() diff --git a/src/relay/wsl-install-plugins-handler.ts b/src/relay/wsl-install-plugins-handler.ts index 4b00d4c29f1..21eaf16db87 100644 --- a/src/relay/wsl-install-plugins-handler.ts +++ b/src/relay/wsl-install-plugins-handler.ts @@ -14,8 +14,14 @@ import { } from '../shared/wsl-hook-relay-contract' export type InstallPluginsResult = { - installed: { opencode: boolean; pi: boolean; omp: boolean; primeAgent: boolean } - overlayDirs: { opencode?: string } + installed: { + opencode: boolean + opencode2?: boolean + pi: boolean + omp: boolean + primeAgent: boolean + } + overlayDirs: { opencode?: string; opencode2?: string } } export type InstallPluginsHandler = (params: Record) => InstallPluginsResult @@ -36,19 +42,23 @@ export function createInstallPluginsHandler( // under running agents and race panes spawning against the path the host just // handed them. Rebuild only when the shipped source actually changed. let materialized: { source: string; sourceDir: string | undefined; dir: string } | null = null + let materialized2: { source: string; sourceDir: string | undefined; dir: string } | null = null return (params) => { const opencode = params.opencodePluginSource + const opencode2 = params.opencode2PluginSource const pi = params.piExtensionSource const omp = params.ompExtensionSource const primeAgent = params.primeAgentExtensionSource // Why: bound per-source bytes so a buggy/hostile host can't OOM the guest relay. assertPluginSourceUnderByteCap('opencodePluginSource', opencode) + assertPluginSourceUnderByteCap('opencode2PluginSource', opencode2) assertPluginSourceUnderByteCap('piExtensionSource', pi) assertPluginSourceUnderByteCap('ompExtensionSource', omp) assertPluginSourceUnderByteCap('primeAgentExtensionSource', primeAgent) pluginOverlay.setSources({ opencodePluginSource: typeof opencode === 'string' ? opencode : undefined, + opencode2PluginSource: typeof opencode2 === 'string' ? opencode2 : undefined, piExtensionSource: typeof pi === 'string' ? pi : undefined, ompExtensionSource: typeof omp === 'string' ? omp : undefined, primeAgentExtensionSource: typeof primeAgent === 'string' ? primeAgent : undefined @@ -80,14 +90,40 @@ export function createInstallPluginsHandler( : null } } + let opencode2Dir: string | undefined + if (pluginOverlay.hasOpenCode2Source()) { + const incoming = typeof opencode2 === 'string' ? opencode2 : null + const sourceDir = resolveOpenCodeSourceConfigDir(env as Record, env.SHELL) + const cached = materialized2 + if ( + cached && + (incoming === null || incoming === cached.source) && + sourceDir === cached.sourceDir && + existsSync(getRelayOpenCodePluginPath(cached.dir, 'opencode2')) + ) { + opencode2Dir = cached.dir + } else { + const overlayId = + sanitizeWslHookInstanceKey(env[WSL_HOOK_RELAY_INSTANCE_ENV]) ?? 'wsl-opencode2' + opencode2Dir = pluginOverlay.materializeOpenCode2(overlayId, sourceDir) ?? undefined + materialized2 = + opencode2Dir && incoming !== null + ? { source: incoming, sourceDir, dir: opencode2Dir } + : null + } + } return { installed: { opencode: pluginOverlay.hasOpenCodeSource(), + opencode2: pluginOverlay.hasOpenCode2Source(), pi: pluginOverlay.hasPiSource('pi'), omp: pluginOverlay.hasPiSource('omp'), primeAgent: pluginOverlay.hasPiSource('prime-agent') }, - overlayDirs: opencodeDir ? { opencode: opencodeDir } : {} + overlayDirs: { + ...(opencodeDir ? { opencode: opencodeDir } : {}), + ...(opencode2Dir ? { opencode2: opencode2Dir } : {}) + } } } } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 435091e780c..f63302164ae 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -607,7 +607,8 @@ "da41abbdd4": "Ante", "060d152fb5": "Trae", "d443a47995": "Prime Agent", - "mimo_code_label": "MiMo Code" + "mimo_code_label": "MiMo Code", + "opencode2_label": "OpenCode 2" }, "skill": { "cli": { diff --git a/src/renderer/src/lib/agent-catalog.tsx b/src/renderer/src/lib/agent-catalog.tsx index cf46fd9aae7..8217517ad8b 100644 --- a/src/renderer/src/lib/agent-catalog.tsx +++ b/src/renderer/src/lib/agent-catalog.tsx @@ -85,6 +85,12 @@ export const getAgentCatalog = createLocalizedCatalog((): AgentCatalogEntry[] => cmd: 'copilot', homepageUrl: 'https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli' }, + { + id: 'opencode2', + label: translate('auto.lib.agent.catalog.opencode2_label', 'OpenCode 2'), + cmd: 'opencode2', + homepageUrl: 'https://opencode.ai/v2/docs/' + }, { id: 'opencode', label: translate('auto.lib.agent.catalog.e7a4ca5103', 'OpenCode'), @@ -359,6 +365,9 @@ export function AgentIcon({ if (agent === 'opencode') { return } + if (agent === 'opencode2') { + return + } const catalogEntry = getAgentCatalog().find((a) => a.id === agent) // Why: prefer the favicon bundled at build time so the icon renders without a // live network request — Google's favicon service is unreachable in some diff --git a/src/renderer/src/lib/agent-status.ts b/src/renderer/src/lib/agent-status.ts index 94933a05bff..3af7b38def9 100644 --- a/src/renderer/src/lib/agent-status.ts +++ b/src/renderer/src/lib/agent-status.ts @@ -104,6 +104,7 @@ const ICONABLE_AGENT_TYPES: Record = { codex: true, autohand: true, opencode: true, + opencode2: true, 'mimo-code': true, pi: true, omp: true, diff --git a/src/renderer/src/runtime/agent-resume-host-authority-capability.test.ts b/src/renderer/src/runtime/agent-resume-host-authority-capability.test.ts index 83b6a9493d9..1885d059b89 100644 --- a/src/renderer/src/runtime/agent-resume-host-authority-capability.test.ts +++ b/src/renderer/src/runtime/agent-resume-host-authority-capability.test.ts @@ -2,12 +2,19 @@ import { describe, expect, it } from 'vitest' import { RESUMABLE_TUI_AGENTS } from '../../../shared/agent-session-resume' import { AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY, + AGENT_SESSION_OPENCODE2_RESUME_RUNTIME_CAPABILITY, AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY, RUNTIME_CAPABILITIES } from '../../../shared/protocol-version' import { agentResumeHostAuthorityCapability } from './agent-resume-host-authority-capability' describe('agentResumeHostAuthorityCapability', () => { + it('gates OpenCode 2 resume behind its own advertised capability', () => { + expect(agentResumeHostAuthorityCapability('opencode2')).toBe( + AGENT_SESSION_OPENCODE2_RESUME_RUNTIME_CAPABILITY + ) + expect(RUNTIME_CAPABILITIES).toContain(AGENT_SESSION_OPENCODE2_RESUME_RUNTIME_CAPABILITY) + }) it('gates Kimi resume behind its own capability', () => { expect(agentResumeHostAuthorityCapability('kimi')).toBe( AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY @@ -43,6 +50,7 @@ describe('agentResumeHostAuthorityCapability', () => { gemini: undefined, antigravity: undefined, opencode: undefined, + opencode2: AGENT_SESSION_OPENCODE2_RESUME_RUNTIME_CAPABILITY, pi: undefined, 'mimo-code': undefined, droid: undefined, diff --git a/src/renderer/src/runtime/agent-resume-host-authority-capability.ts b/src/renderer/src/runtime/agent-resume-host-authority-capability.ts index b1f83f1d5dc..cd2198e9db2 100644 --- a/src/renderer/src/runtime/agent-resume-host-authority-capability.ts +++ b/src/renderer/src/runtime/agent-resume-host-authority-capability.ts @@ -2,6 +2,7 @@ import type { ResumableTuiAgent } from '../../../shared/agent-session-resume' import type { TuiAgent } from '../../../shared/tui-agent' import { AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY, + AGENT_SESSION_OPENCODE2_RESUME_RUNTIME_CAPABILITY, AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY, type RuntimeCapability } from '../../../shared/protocol-version' @@ -19,6 +20,7 @@ const RESUME_HOST_AUTHORITY_CAPABILITY_BY_AGENT = { gemini: undefined, antigravity: undefined, opencode: undefined, + opencode2: AGENT_SESSION_OPENCODE2_RESUME_RUNTIME_CAPABILITY, pi: undefined, 'mimo-code': undefined, droid: undefined, diff --git a/src/shared/agent-hook-listener-transport.test.ts b/src/shared/agent-hook-listener-transport.test.ts index f0a86986414..cf0a801ede1 100644 --- a/src/shared/agent-hook-listener-transport.test.ts +++ b/src/shared/agent-hook-listener-transport.test.ts @@ -216,6 +216,7 @@ describe('shared agent-hook-listener', () => { expect(resolveHookSource('/hook/omp')).toBe('omp') expect(resolveHookSource('/hook/prime-agent')).toBe('prime-agent') expect(resolveHookSource('/hook/command-code')).toBe('command-code') + expect(resolveHookSource('/hook/opencode2')).toBe('opencode2') expect(resolveHookSource('/hook/mimo-code')).toBe('mimo-code') expect(resolveHookSource('/hook/unknown')).toBeNull() expect(resolveHookSource('/')).toBeNull() diff --git a/src/shared/agent-hook-listener/provider-dispatch.ts b/src/shared/agent-hook-listener/provider-dispatch.ts index 473bb557d09..fb3647e2651 100644 --- a/src/shared/agent-hook-listener/provider-dispatch.ts +++ b/src/shared/agent-hook-listener/provider-dispatch.ts @@ -73,10 +73,11 @@ export function normalizeProviderEvent(input: { payload = normalizeAmpEvent(state, eventName, promptText, paneKey, hookPayload) break case 'opencode': + case 'opencode2': case 'mimo-code': { if (extractedPrompt.source === 'role_user_text') { const messageId = readFirstString(hookPayload, ['messageID', 'messageId', 'message_id']) - const prefix = source === 'mimo-code' ? 'mimo-code-message' : 'opencode-message' + const prefix = source === 'mimo-code' ? 'mimo-code-message' : `${source}-message` promptInteractionKey = messageId ? `${prefix}-${messageId}` : undefined } payload = normalizeOpenCodeFamilyEvent( diff --git a/src/shared/agent-hook-listener/provider-event-routing.ts b/src/shared/agent-hook-listener/provider-event-routing.ts index 25efac74b6f..39fa150f34b 100644 --- a/src/shared/agent-hook-listener/provider-event-routing.ts +++ b/src/shared/agent-hook-listener/provider-event-routing.ts @@ -41,6 +41,7 @@ export function isNewTurnEvent(source: AgentHookSource, eventName: unknown): boo case 'amp': return eventName === 'agent.start' case 'opencode': + case 'opencode2': return eventName === 'SessionStart' case 'mimo-code': return false @@ -92,7 +93,10 @@ export function hasExplicitUserPrompt( return true } if (extractedPrompt.source === 'role_user_text') { - return (source === 'opencode' || source === 'mimo-code') && eventName === 'MessagePart' + return ( + (source === 'opencode' || source === 'opencode2' || source === 'mimo-code') && + eventName === 'MessagePart' + ) } if (extractedPrompt.text.length === 0) { return false @@ -137,6 +141,7 @@ export function extractToolFields( case 'amp': return extractAmpToolFields(eventName, hookPayload) case 'opencode': + case 'opencode2': case 'mimo-code': return extractOpenCodeToolFields(eventName, hookPayload) case 'cursor': diff --git a/src/shared/agent-hook-listener/providers/opencode-family-events.ts b/src/shared/agent-hook-listener/providers/opencode-family-events.ts index a59c7c62bc9..e030276b08b 100644 --- a/src/shared/agent-hook-listener/providers/opencode-family-events.ts +++ b/src/shared/agent-hook-listener/providers/opencode-family-events.ts @@ -7,7 +7,7 @@ import { resolvePrompt, resolveToolState } from '../prompt-fields' import { extractToolFields, isNewTurnEvent } from '../provider-event-routing' export function normalizeOpenCodeFamilyEvent( - source: 'opencode' | 'mimo-code', + source: 'opencode' | 'opencode2' | 'mimo-code', state: HookListenerState, eventName: unknown, promptText: string, @@ -22,7 +22,7 @@ export function normalizeOpenCodeFamilyEvent( ? 'working' : eventName === 'SessionIdle' ? 'done' - : source === 'opencode' && eventName === 'SessionStart' + : (source === 'opencode' || source === 'opencode2') && eventName === 'SessionStart' ? 'done' : eventName === 'PermissionRequest' || eventName === 'AskUserQuestion' ? 'waiting' @@ -52,6 +52,9 @@ export function normalizeOpenCodeFamilyEvent( interactivePrompt: snapshot.interactivePrompt, lastAssistantMessage: snapshot.lastAssistantMessage, lastAssistantMessageIsToolOutput: snapshot.lastAssistantMessageIsToolOutput, - sessionBoundary: source === 'opencode' && eventName === 'SessionStart' ? true : undefined + sessionBoundary: + (source === 'opencode' || source === 'opencode2') && eventName === 'SessionStart' + ? true + : undefined }) } diff --git a/src/shared/agent-hook-listener/source-routing.ts b/src/shared/agent-hook-listener/source-routing.ts index 470c1bf2410..b90da246254 100644 --- a/src/shared/agent-hook-listener/source-routing.ts +++ b/src/shared/agent-hook-listener/source-routing.ts @@ -9,6 +9,7 @@ export const HOOK_SOURCE_BY_PATHNAME: Readonly> '/hook/antigravity': 'antigravity', '/hook/amp': 'amp', '/hook/opencode': 'opencode', + '/hook/opencode2': 'opencode2', '/hook/mimo-code': 'mimo-code', '/hook/cursor': 'cursor', '/hook/pi': 'pi', diff --git a/src/shared/agent-hook-relay.ts b/src/shared/agent-hook-relay.ts index dadfa590a4e..6abdcb56aa0 100644 --- a/src/shared/agent-hook-relay.ts +++ b/src/shared/agent-hook-relay.ts @@ -41,6 +41,7 @@ const AGENT_HOOK_SOURCES = [ 'antigravity', 'amp', 'opencode', + 'opencode2', 'mimo-code', 'cursor', 'pi', diff --git a/src/shared/agent-kind.ts b/src/shared/agent-kind.ts index 324a73f2cd8..8ca50cbcea6 100644 --- a/src/shared/agent-kind.ts +++ b/src/shared/agent-kind.ts @@ -20,6 +20,7 @@ const TUI_AGENT_KIND_BY_AGENT = { codex: 'codex', autohand: 'autohand', opencode: 'opencode', + opencode2: 'opencode2', 'mimo-code': 'mimo-code', pi: 'pi', omp: 'omp', diff --git a/src/shared/agent-name-token-match.ts b/src/shared/agent-name-token-match.ts index e2084052d72..ea024babd32 100644 --- a/src/shared/agent-name-token-match.ts +++ b/src/shared/agent-name-token-match.ts @@ -22,6 +22,7 @@ export const AGENT_NAMES = [ 'gemini', 'antigravity', 'opencode', + 'opencode2', 'mimo', 'openclaw', 'aider', diff --git a/src/shared/agent-session-resume.ts b/src/shared/agent-session-resume.ts index ea5bdf9b4e9..56aa3952e00 100644 --- a/src/shared/agent-session-resume.ts +++ b/src/shared/agent-session-resume.ts @@ -8,6 +8,7 @@ export const RESUMABLE_TUI_AGENTS = [ 'gemini', 'antigravity', 'opencode', + 'opencode2', 'pi', 'mimo-code', 'droid', @@ -204,6 +205,7 @@ export function extractAgentProviderSession( return id ? { key: 'conversation_id', id } : null } case 'opencode': + case 'opencode2': case 'mimo-code': { const id = readSessionId(payload, ['sessionID']) return id ? { key: 'session_id', id } : null @@ -260,6 +262,10 @@ export function getAgentResumeArgv( return providerSession.key === 'conversation_id' ? ['agy', '--conversation', id] : null case 'opencode': return providerSession.key === 'session_id' ? ['opencode', '--session', id] : null + case 'opencode2': + 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/agent-type-label.ts b/src/shared/agent-type-label.ts index f13dc36b7bf..51f1695f508 100644 --- a/src/shared/agent-type-label.ts +++ b/src/shared/agent-type-label.ts @@ -11,6 +11,7 @@ const WELL_KNOWN_LABELS: Record = { amp: 'Amp', copilot: 'GitHub Copilot', opencode: 'OpenCode', + opencode2: 'OpenCode 2', 'mimo-code': 'MiMo Code', cursor: 'Cursor', aider: 'Aider', diff --git a/src/shared/ai-vault-resume-command.ts b/src/shared/ai-vault-resume-command.ts index 7ec417c7ef3..da4cd29e038 100644 --- a/src/shared/ai-vault-resume-command.ts +++ b/src/shared/ai-vault-resume-command.ts @@ -200,6 +200,8 @@ function buildAgentResumeInvocation( return `${baseCommand} resume ${sessionArg}` case 'rovo': return `${baseCommand} rovodev run --restore ${sessionArg}` + case 'opencode2': + return `${baseCommand} --standalone --session ${sessionArg}` case 'opencode': case 'pi': // Why: Kimi Code resumes with `kimi --session ` (alias `-S`). Sessions diff --git a/src/shared/ai-vault-types.ts b/src/shared/ai-vault-types.ts index 6adc59c0396..5202464dfdf 100644 --- a/src/shared/ai-vault-types.ts +++ b/src/shared/ai-vault-types.ts @@ -14,6 +14,7 @@ export const AI_VAULT_AGENTS = [ 'rovo', 'copilot', 'opencode', + 'opencode2', 'grok', 'openclaw', 'devin', @@ -56,6 +57,7 @@ export const AI_VAULT_AGENT_LABELS = { rovo: 'Rovo Dev', copilot: 'GitHub Copilot', opencode: 'OpenCode', + opencode2: 'OpenCode 2', grok: 'Grok', openclaw: 'OpenClaw', devin: 'Devin', diff --git a/src/shared/commit-message-agent-spec.test.ts b/src/shared/commit-message-agent-spec.test.ts index ffaec29c605..197a053422a 100644 --- a/src/shared/commit-message-agent-spec.test.ts +++ b/src/shared/commit-message-agent-spec.test.ts @@ -39,6 +39,7 @@ describe('COMMIT_MESSAGE_AGENT_SPECS', () => { 'kimi', 'omp', 'opencode', + 'opencode2', 'pi' ]) }) @@ -571,6 +572,50 @@ describe('buildArgs (OpenCode)', () => { }) }) +describe('buildArgs (OpenCode 2)', () => { + const spec = getCommitMessageAgentSpec('opencode2')! + + it('runs `opencode2 run` with stdin delivery', () => { + const prompt = `PROMPT ${'x'.repeat(1024)}` + const args = spec.buildArgs({ + prompt, + model: 'opencode/deepseek-v4-flash-free' + }) + + expect(args).toEqual([ + 'run', + '--model', + 'opencode/deepseek-v4-flash-free', + '--agent', + 'build', + '--format', + 'default' + ]) + expect(args).not.toContain(prompt) + expect(args).not.toContain('') + expect(spec.promptDelivery).toBe('stdin') + }) + + it('inlines the thinking variant as model#variant (v1 --variant is removed in v2)', () => { + const args = spec.buildArgs({ + prompt: 'PROMPT', + model: 'opencode/gpt-5.4-mini', + thinkingLevel: 'high' + }) + + expect(args).toEqual([ + 'run', + '--model', + 'opencode/gpt-5.4-mini#high', + '--agent', + 'build', + '--format', + 'default' + ]) + expect(args).not.toContain('--variant') + }) +}) + describe('buildArgs (Antigravity)', () => { const spec = getCommitMessageAgentSpec('antigravity')! diff --git a/src/shared/commit-message-agent-specs-primary.ts b/src/shared/commit-message-agent-specs-primary.ts index 7327b3216dc..0889ebdcef6 100644 --- a/src/shared/commit-message-agent-specs-primary.ts +++ b/src/shared/commit-message-agent-specs-primary.ts @@ -188,6 +188,29 @@ export function buildPrimaryCommitMessageAgentSpecs({ ], defaultModelId: 'opencode/deepseek-v4-flash-free' }, + opencode2: { + id: 'opencode2', + label: 'OpenCode 2', + binary: 'opencode2', + promptDelivery: 'stdin', + buildArgs: ({ model, thinkingLevel }) => [ + 'run', + '--model', + thinkingLevel ? `${model}#${thinkingLevel}` : model, + '--agent', + 'build', + '--format', + 'default' + ], + singletonOptions: [['--model', '-m'], ['--agent'], ['--format']], + modelSource: 'dynamic', + modelDiscovery: { binary: 'opencode2', args: ['models'], parse: parseLineModels }, + models: [ + { id: 'opencode/deepseek-v4-flash-free', label: 'OpenCode DeepSeek V4 Flash Free' }, + { id: 'opencode/gpt-5.4-mini', label: 'OpenCode GPT 5.4 Mini', ...withOpenAiThinking('gpt-5.4-mini') } + ], + defaultModelId: 'opencode/deepseek-v4-flash-free' + }, pi: { id: 'pi', label: 'Pi', diff --git a/src/shared/opencode-database-name.ts b/src/shared/opencode-database-name.ts new file mode 100644 index 00000000000..79bc4f49200 --- /dev/null +++ b/src/shared/opencode-database-name.ts @@ -0,0 +1,6 @@ +// 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 { + return OPENCODE_V2_DATABASE_NAME_RE.test(name) +} diff --git a/src/shared/opencode-launch-command.test.ts b/src/shared/opencode-launch-command.test.ts new file mode 100644 index 00000000000..a422633f4bf --- /dev/null +++ b/src/shared/opencode-launch-command.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest' +import { isOpenCode2LaunchCommand } from './opencode-launch-command' + +describe('isOpenCode2LaunchCommand', () => { + it.each(['opencode2', '/usr/local/bin/opencode2', 'opencode2.exe', 'opencode2.cmd'])( + 'recognizes %s', + (command) => { + expect(isOpenCode2LaunchCommand(command)).toBe(true) + } + ) + + it.each(['opencode', 'echo opencode2', 'opencode2-helper'])('rejects %s', (command) => { + expect(isOpenCode2LaunchCommand(command)).toBe(false) + }) +}) diff --git a/src/shared/opencode-launch-command.ts b/src/shared/opencode-launch-command.ts new file mode 100644 index 00000000000..44dd3038e6b --- /dev/null +++ b/src/shared/opencode-launch-command.ts @@ -0,0 +1,8 @@ +import { getCommandTokenPathBasename, getFirstCommandToken } from './command-token-scanner' + +export function isOpenCode2LaunchCommand(launchCommand: string | undefined): boolean { + const binary = getCommandTokenPathBasename(getFirstCommandToken(launchCommand ?? '')) + .toLowerCase() + .replace(/\.(?:cmd|exe|sh)$/, '') + return binary === 'opencode2' +} diff --git a/src/shared/pane-agent-identity-inventory.test.ts b/src/shared/pane-agent-identity-inventory.test.ts index 23657cf0fbe..8cb5a88f702 100644 --- a/src/shared/pane-agent-identity-inventory.test.ts +++ b/src/shared/pane-agent-identity-inventory.test.ts @@ -120,7 +120,7 @@ const INVENTORY: readonly InventoryGroup[] = [ ['src/shared/agent-title-core.ts', 4], ['src/shared/agent-title-evidence.ts', 2], ['src/shared/agent-title-identity.ts', 11], - ['src/shared/terminal-title-agent-type.ts', 14] + ['src/shared/terminal-title-agent-type.ts', 15] ] }, { diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 302e25f6ed0..5c4fc6fbb12 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -213,6 +213,8 @@ export const AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY = // older host answers the unknown member with invalid_argument — a code the launch fallback does // not retry on — so clients must probe before taking the host-authority path. export const AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY = 'agent-session.kimi-resume.v1' as const +export const AGENT_SESSION_OPENCODE2_RESUME_RUNTIME_CAPABILITY = + 'agent-session.opencode2-resume.v1' as const // Why: older runtimes strip mutation owner fields, so clients must fence writes before RPC. export const FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY = 'files.mutation-ownership.v1' as const export const FILE_MUTATION_OWNERSHIP_UPDATE_REQUIRED_MESSAGE = @@ -354,6 +356,7 @@ export const RUNTIME_CAPABILITIES = [ AGENT_SESSION_TURN_ITEM_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY, + AGENT_SESSION_OPENCODE2_RESUME_RUNTIME_CAPABILITY, FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY, GITHUB_MARK_PR_READY_RUNTIME_CAPABILITY, GITLAB_READY_FOR_REVIEW_RUNTIME_CAPABILITY, diff --git a/src/shared/skills-cli-agent-keys.ts b/src/shared/skills-cli-agent-keys.ts index 8f77675e8c0..7c49e0346cd 100644 --- a/src/shared/skills-cli-agent-keys.ts +++ b/src/shared/skills-cli-agent-keys.ts @@ -19,6 +19,7 @@ export const SKILLS_CLI_AGENT_KEY_BY_TUI_AGENT = { codex: 'codex', autohand: 'autohand-code', opencode: 'opencode', + opencode2: 'opencode', 'mimo-code': null, pi: 'pi', omp: null, diff --git a/src/shared/source-control-ai-action-recipes.test.ts b/src/shared/source-control-ai-action-recipes.test.ts index 5255f75b7e0..66310f28420 100644 --- a/src/shared/source-control-ai-action-recipes.test.ts +++ b/src/shared/source-control-ai-action-recipes.test.ts @@ -427,7 +427,7 @@ describe('source-control AI action recipes', () => { ).toEqual({ ok: false, error: - 'Agent "aider" does not support Source Control AI commit messages. Supported agents: OMP, Claude, Codex, OpenCode, Pi, Amp, Cursor, Kimi, GitHub Copilot, Antigravity, or Custom command.' + 'Agent "aider" does not support Source Control AI commit messages. Supported agents: OMP, Claude, Codex, OpenCode, OpenCode 2, Pi, Amp, Cursor, Kimi, GitHub Copilot, Antigravity, or Custom command.' }) }) }) diff --git a/src/shared/telemetry-property-schemas.ts b/src/shared/telemetry-property-schemas.ts index 3346ea73320..9d373903fdb 100644 --- a/src/shared/telemetry-property-schemas.ts +++ b/src/shared/telemetry-property-schemas.ts @@ -14,6 +14,7 @@ export const AGENT_KIND_VALUES = [ 'codex', 'autohand', 'opencode', + 'opencode2', 'mimo-code', 'pi', 'omp', diff --git a/src/shared/terminal-title-agent-type.ts b/src/shared/terminal-title-agent-type.ts index 4a000f8be9b..8e8f8b12405 100644 --- a/src/shared/terminal-title-agent-type.ts +++ b/src/shared/terminal-title-agent-type.ts @@ -187,6 +187,9 @@ function computeAgentLabel(title: string): string | null { if (titleHasAgentName(title, 'antigravity') || AGY_AGENT_NAME_RE.test(title)) { return 'Antigravity' } + if (titleHasAgentName(title, 'opencode2')) { + return 'OpenCode 2' + } if (titleHasAgentName(title, 'opencode')) { return 'OpenCode' } @@ -232,6 +235,7 @@ const TITLE_LABEL_TO_AGENT: Partial> = { Devin: 'devin', Antigravity: 'antigravity', OpenCode: 'opencode', + 'OpenCode 2': 'opencode2', 'MiMo Code': 'mimo-code', Aider: 'aider', Cursor: 'cursor', diff --git a/src/shared/tui-agent-config.ts b/src/shared/tui-agent-config.ts index 0bb2c35a040..46d382e7075 100644 --- a/src/shared/tui-agent-config.ts +++ b/src/shared/tui-agent-config.ts @@ -128,6 +128,16 @@ const TUI_AGENT_CONFIG_SOURCE: Record = { // Why: opencode enables bracketed paste before its composer mounts; wait for the post-\x1b[?2004h show-cursor so paste lands. draftPasteReadySignal: 'render-cursor-after-bracketed-paste' }, + // Why: opencode2 installs as a separate binary and uses the same prompt flags. + // Its @opentui composer keeps the same cursor-gated paste signal. + opencode2: { + detectCmd: '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' + }, 'mimo-code': { detectCmd: 'mimo', promptInjectionMode: 'flag-prompt', diff --git a/src/shared/tui-agent-display-names.ts b/src/shared/tui-agent-display-names.ts index 0b2078f0479..4c2576bddc8 100644 --- a/src/shared/tui-agent-display-names.ts +++ b/src/shared/tui-agent-display-names.ts @@ -15,6 +15,7 @@ export const TUI_AGENT_DISPLAY_NAMES: Record = { trae: 'Trae', autohand: 'Autohand Code', opencode: 'OpenCode', + opencode2: 'OpenCode 2', 'mimo-code': 'MiMo Code', pi: 'Pi', omp: 'OMP', diff --git a/src/shared/tui-agent-selection.ts b/src/shared/tui-agent-selection.ts index 86216e589b8..1de6301bd2f 100644 --- a/src/shared/tui-agent-selection.ts +++ b/src/shared/tui-agent-selection.ts @@ -10,6 +10,7 @@ export const TUI_AGENT_AUTO_PICK_ORDER = [ 'codex', 'grok', 'copilot', + 'opencode2', 'opencode', 'mimo-code', 'ante', diff --git a/src/shared/tui-agent.ts b/src/shared/tui-agent.ts index a885e3dd24c..b6901aa8ef1 100644 --- a/src/shared/tui-agent.ts +++ b/src/shared/tui-agent.ts @@ -7,6 +7,7 @@ export type TuiAgent = | 'codex' // OpenAI Codex | 'autohand' // Autohand Code CLI | 'opencode' // OpenCode + | 'opencode2' // OpenCode 2 beta | 'mimo-code' | 'pi' // Pi (pi.dev) | 'omp' // OMP (omp.sh)