From ae147e85dbaff6a83b8f5dd4e8e756ea2ae61b2b Mon Sep 17 00:00:00 2001 From: Junmo Kim Date: Wed, 28 Oct 2026 13:14:18 +0900 Subject: [PATCH] feat(opencode): import local session history (#1677) * feat(cli): list local OpenCode sessions over machine RPC * feat(hub): import OpenCode sessions into HAPI * feat(web): import OpenCode history from new-session picker * fix(cli): derive OpenCode imported message ids from part ids Random ids made every scan differ, so delta classification flagged unchanged transcripts as diverged on re-import. * fix(cli): surface unreadable OpenCode db as rpc error A locked or unopenable opencode.db now fails the listing instead of reporting an empty success; summaries normalize timestamps and the web import toast counts only created or actually-appended sessions. * fix(hub): fail closed on opencode re-import past live HAPI continuation Imported entries must remain a strict prefix of the native transcript. Insertions ahead of the imported boundary or new native messages after the session continued in HAPI now fail closed instead of appending duplicated or reordered history. * fix(web,hub): apply selected launch config when importing OpenCode history Pass the model, reasoning effort, and permission mode chosen in the new-session picker through the import request and persist them on the imported HAPI session via applySessionConfig, so reopen launches with the visible selection instead of stale or default values. * fix(hub,cli): preserve explicit launch resets and skip streaming parts on import - Launch config keys are applied by presence, so explicit null/default selections from the picker must reach applySessionConfig to clear stale model, effort, or permission values on re-import. Requests that omit the fields entirely still leave persisted config untouched. - Assistant text parts are mutable while a response streams. Mirror the live scanner gate and import only finished (time.end set), synthetic, or user text so an in-progress row can never be stored and later finalized as transcript_diverged. * test(hub): fix launch-config reset assertions to satisfy strict types * fix(cli): fail opencode transcript reads on query errors instead of partial snapshots Swallowed SQLite errors could return a truncated message list that the hub later rejects as transcript_diverged with no repair path. Full transcript reads now propagate query failures; summary lastUserMessage extraction stays resilient per message. * test(cli): cover unreadable-part-table transcript read stays non-partial * fix(hub): scope launch-config failures to the per-session import result A thrown applySessionConfig call used to escape the bulk handler as an unstructured 500 even though transcripts were already persisted. Catch it per session and report it as a config_failed result so remaining sessions in a bulk request still import. Also cover the mid-transcript part-query failure with an injected opener that rejects instead of returning a holey transcript. * fix(cli): read opencode transcripts inside a deferred snapshot transaction Messages and their parts were read in separate statements, so a concurrent OpenCode write could leave a hole in the imported sequence that positional re-import checks then reject forever. Wrap each full transcript read in BEGIN DEFERRED/COMMIT with rollback on failure. * fix(cli): follow machine path policy for OpenCode imports * fix(web): use unified permission mode for OpenCode imports * fix(opencode): harden session import consistency * fix(hub): preserve metadata on rejected OpenCode imports * fix(hub): refresh OpenCode import state under lock * fix(hub): resolve OpenCode imports after locking * fix(opencode): bind imported resume to source machine --- cli/src/api/apiMachine.ts | 35 ++ .../modules/common/opencodeSessions.test.ts | 284 +++++++++ cli/src/modules/common/opencodeSessions.ts | 311 ++++++++++ hub/src/store/index.ts | 4 + hub/src/sync/rpcGateway.ts | 8 + hub/src/sync/sessionCache.ts | 20 +- hub/src/sync/sessionModel.test.ts | 79 +++ hub/src/sync/syncEngine.ts | 7 +- hub/src/web/routes/opencodeSessions.test.ts | 587 ++++++++++++++++++ hub/src/web/routes/opencodeSessions.ts | 376 +++++++++++ hub/src/web/server.ts | 5 + shared/src/apiTypes.ts | 40 ++ shared/src/rpcMethods.ts | 1 + web/src/api/client.ts | 17 + .../NewSession/OpencodeImportActions.test.tsx | 26 + .../NewSession/OpencodeImportActions.tsx | 41 ++ web/src/components/NewSession/index.tsx | 189 +++++- .../OpencodeSessionImportDialog.tsx | 126 ++++ web/src/lib/locales/en.ts | 24 + web/src/lib/locales/zh-CN.ts | 24 + web/src/types/api.ts | 36 ++ 21 files changed, 2229 insertions(+), 11 deletions(-) create mode 100644 cli/src/modules/common/opencodeSessions.test.ts create mode 100644 cli/src/modules/common/opencodeSessions.ts create mode 100644 hub/src/web/routes/opencodeSessions.test.ts create mode 100644 hub/src/web/routes/opencodeSessions.ts create mode 100644 web/src/components/NewSession/OpencodeImportActions.test.tsx create mode 100644 web/src/components/NewSession/OpencodeImportActions.tsx create mode 100644 web/src/components/OpencodeSessionImportDialog.tsx diff --git a/cli/src/api/apiMachine.ts b/cli/src/api/apiMachine.ts index 1acc85a8..bcd64e48 100644 --- a/cli/src/api/apiMachine.ts +++ b/cli/src/api/apiMachine.ts @@ -11,10 +11,12 @@ import type { ClientToServerEvents, ServerToClientEvents, Update, UpdateMachineB import { ArchiveCodexSessionRpcRequestSchema, ListCodexSessionsRpcRequestSchema, + ListOpencodeSessionsRpcRequestSchema, ListPiSessionsRpcRequestSchema, type ArchiveCodexSessionRpcResponse, type AgentAvailabilityResponse, type ListCodexSessionsRpcResponse, + type ListOpencodeSessionsRpcResponse, type ListPiSessionsRpcResponse, type MachineDirectoryEntry, type MachineListDirectoryResponse, @@ -36,6 +38,10 @@ import { type ListOpencodeModelsForCwdRequest, type ListOpencodeModelsForCwdResponse } from '../modules/common/opencodeModels' +import { + listLocalOpencodeSessionSummaries, + listLocalOpencodeSessionsWithMessagesByIds +} from '../modules/common/opencodeSessions' import { listOpencodeModelVariants, type ListOpencodeModelVariantsResponse @@ -410,6 +416,35 @@ export class ApiMachineClient { return { success: true, sessions } } ) + + this.rpcHandlerManager.registerHandler( + RPC_METHODS.ListOpencodeSessions, + async (params) => { + const parsed = ListOpencodeSessionsRpcRequestSchema.safeParse(params) + if (!parsed.success) return { success: false, error: 'Invalid Opencode sessions request' } + const rawCwd = typeof parsed.data.cwd === 'string' ? parsed.data.cwd.trim() : '' + if (rawCwd) { + const resolvedCwd = await this.pathPolicy.resolveForCheck(rawCwd) + if (!this.pathPolicy.isWithinSpawnRoots(resolvedCwd)) { + return { success: false, error: 'Path is outside workspace roots' } + } + } + const requestedIds = parsed.data.sessionIds ? new Set(parsed.data.sessionIds) : null + let allSessions + try { + allSessions = requestedIds + ? await listLocalOpencodeSessionsWithMessagesByIds(requestedIds) + : await listLocalOpencodeSessionSummaries() + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : 'Failed to read local OpenCode sessions' } + } + const sessions = [] + for (const session of allSessions) { + if (await this.isLocalSessionWithinWorkspaceRoots(session)) sessions.push(session) + } + return { success: true, sessions } + } + ) } private async isLocalSessionWithinWorkspaceRoots(session: { cwd?: string | null }): Promise { diff --git a/cli/src/modules/common/opencodeSessions.test.ts b/cli/src/modules/common/opencodeSessions.test.ts new file mode 100644 index 00000000..d2490a11 --- /dev/null +++ b/cli/src/modules/common/opencodeSessions.test.ts @@ -0,0 +1,284 @@ +import { describe, expect, it } from 'vitest' +import { spawnSync } from 'node:child_process' +import { join } from 'node:path' + +// bun:sqlite is only resolvable under the Bun runtime, while the unit suite +// runs on node (vitest). Mirror the agy modules' approach: the production +// module lazy-imports bun:sqlite, and these tests drive the real module + +// a real sqlite db inside a `bun` subprocess, asserting on its JSON output. + +const RUNNER = /* ts */ ` +import { mkdtempSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { Database } from 'bun:sqlite' +import { + getOpencodeDbPath, + listLocalOpencodeSessionSummaries, + listLocalOpencodeSessionsWithMessagesByIds +} from './opencodeSessions' + +function createDb(root) { + const db = new Database(join(root, 'opencode.db')) + db.exec('CREATE TABLE session (id TEXT PRIMARY KEY, title TEXT, directory TEXT, time_updated REAL, time_archived REAL); CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT, data TEXT, time_created REAL); CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT, data TEXT, time_created REAL);') + return db +} + +function insertMessages(dbPath, messages, parts) { + const db = new Database(dbPath) + try { + for (const m of messages) { + db.query('INSERT INTO message (id, session_id, data, time_created) VALUES (?, ?, ?, ?)') + .run(m.id, m.sessionId, JSON.stringify({ role: m.role }), m.timeCreated) + } + let i = 0 + for (const p of parts) { + i += 1 + const data = Object.assign( + { type: p.type }, + p.text === undefined ? {} : { text: p.text }, + p.extra ?? {} + ) + db.query('INSERT INTO part (id, message_id, data, time_created) VALUES (?, ?, ?, ?)') + .run('part-' + i, p.messageId, JSON.stringify(data), i) + } + } finally { + db.close() + } +} + +const scenario = process.env.SCENARIO + +if (scenario === 'sessions') { + const root = mkdtempSync(join(tmpdir(), 'opencode-sessions-')) + process.env.OPENCODE_HOME = root + const dbPath = join(root, 'opencode.db') + createDb(root) + const db = new Database(dbPath) + db.query('INSERT INTO session (id, title, directory, time_updated) VALUES (?, ?, ?, ?)').run('session-1', 'Normal session', '/tmp/project', 2000) + db.query('INSERT INTO session (id, title, directory, time_updated, time_archived) VALUES (?, ?, ?, ?, ?)').run('session-2', 'Archived session', '/tmp/project', 3000, 4000) + db.query('INSERT INTO session (id, title, directory, time_updated) VALUES (?, ?, ?, ?)').run('session-3', 'Malformed', '/tmp/project', 1000) + db.close() + insertMessages(dbPath, [ + { id: 'm1-u', sessionId: 'session-1', role: 'user', timeCreated: 1100 }, + { id: 'm1-a', sessionId: 'session-1', role: 'assistant', timeCreated: 1200 }, + { id: 'm2-u', sessionId: 'session-2', role: 'user', timeCreated: 1400 } + ], [ + { messageId: 'm1-a', type: 'tool' }, + { messageId: 'm1-u', type: 'text', text: 'hello world' }, + { messageId: 'm1-a', type: 'text', text: 'hi there', extra: { time: { end: 1250 } } }, + { messageId: 'm2-u', type: 'text', text: 'internal' } + ]) + { + const db = new Database(dbPath) + try { + // malformed message-data JSON row must be skipped silently + db.query('INSERT INTO message (id, session_id, data, time_created) VALUES (?, ?, ?, ?)') + .run('m3-u', 'session-3', '{invalid json', 1300) + db.query('INSERT INTO part (id, message_id, data, time_created) VALUES (?, ?, ?, ?)') + .run('part-m3', 'm3-u', JSON.stringify({ type: 'text', text: 'unreachable' }), 1) + } finally { + db.close() + } + } + const summaries = await listLocalOpencodeSessionSummaries() + const sessions = await listLocalOpencodeSessionsWithMessagesByIds(new Set(['session-1', 'session-2'])) + console.log(JSON.stringify({ summaries, sessions })) +} else if (scenario === 'truncate') { + const root = mkdtempSync(join(tmpdir(), 'opencode-truncate-')) + process.env.OPENCODE_HOME = root + const dbPath = join(root, 'opencode.db') + createDb(root) + const db = new Database(dbPath) + db.query('INSERT INTO session (id, title, directory, time_updated) VALUES (?, ?, ?, ?)').run('session-long', 'Long', '/tmp/project', 5000) + db.close() + insertMessages(dbPath, [ + { id: 'ml-u', sessionId: 'session-long', role: 'user', timeCreated: 5100 } + ], [ + { messageId: 'ml-u', type: 'text', text: 'x'.repeat(200) } + ]) + const summaries = await listLocalOpencodeSessionSummaries() + console.log(JSON.stringify({ summaries })) +} else if (scenario === 'streaming') { + const root = mkdtempSync(join(tmpdir(), 'opencode-streaming-')) + process.env.OPENCODE_HOME = root + const dbPath = join(root, 'opencode.db') + createDb(root) + const db = new Database(dbPath) + db.query('INSERT INTO session (id, title, directory, time_updated) VALUES (?, ?, ?, ?)').run('session-stream', 'Streaming', '/tmp/project', 6000) + db.close() + insertMessages(dbPath, [ + { id: 'ms-u', sessionId: 'session-stream', role: 'user', timeCreated: 6100 }, + { id: 'ms-a', sessionId: 'session-stream', role: 'assistant', timeCreated: 6200 } + ], [ + { messageId: 'ms-u', type: 'text', text: 'question' }, + { messageId: 'ms-a', type: 'text', text: 'finished answer', extra: { time: { end: 6300 } } }, + { messageId: 'ms-a', type: 'text', text: 'partial answer being generated' } + ]) + const sessions = await listLocalOpencodeSessionsWithMessagesByIds(new Set(['session-stream'])) + console.log(JSON.stringify({ sessions })) +} else if (scenario === 'queryfail') { + const root = mkdtempSync(join(tmpdir(), 'opencode-queryfail-')) + process.env.OPENCODE_HOME = root + const dbPath = join(root, 'opencode.db') + createDb(root) + const db = new Database(dbPath) + db.query('INSERT INTO session (id, title, directory, time_updated) VALUES (?, ?, ?, ?)').run('session-qf', 'QueryFail', '/tmp/project', 7000) + insertMessages(dbPath, [ + { id: 'mq-u', sessionId: 'session-qf', role: 'user', timeCreated: 7100 }, + { id: 'mq-a', sessionId: 'session-qf', role: 'assistant', timeCreated: 7200 } + ], [ + { messageId: 'mq-u', type: 'text', text: 'question' }, + { messageId: 'mq-a', type: 'text', text: 'answer', extra: { time: { end: 7300 } } } + ]) + // Simulate a transient SQLite failure reading parts of later messages. + db.exec('DROP TABLE part') + db.close() + const out = await listLocalOpencodeSessionsWithMessagesByIds(new Set(['session-qf'])) + console.log(JSON.stringify({ sessions: out })) +} else if (scenario === 'failclosed') { + const missingRoot = mkdtempSync(join(tmpdir(), 'opencode-missing-')) + process.env.OPENCODE_HOME = missingRoot + const noDb = { + path: getOpencodeDbPath(), + summaries: await listLocalOpencodeSessionSummaries(), + byIds: await listLocalOpencodeSessionsWithMessagesByIds(new Set(['any'])) + } + + const emptyRoot = mkdtempSync(join(tmpdir(), 'opencode-empty-')) + process.env.OPENCODE_HOME = emptyRoot + writeFileSync(join(emptyRoot, 'opencode.db'), '') + const emptyFile = { + summaries: await listLocalOpencodeSessionSummaries(), + byIds: await listLocalOpencodeSessionsWithMessagesByIds(new Set(['any'])) + } + + const partialRoot = mkdtempSync(join(tmpdir(), 'opencode-partial-')) + process.env.OPENCODE_HOME = partialRoot + const partial = new Database(join(partialRoot, 'opencode.db')) + partial.exec('CREATE TABLE session (id TEXT PRIMARY KEY);') + partial.close() + const missingTables = { + summaries: await listLocalOpencodeSessionSummaries(), + byIds: await listLocalOpencodeSessionsWithMessagesByIds(new Set(['any'])) + } + console.log(JSON.stringify({ noDb, emptyFile, missingTables })) +} +` + +function runScenario(name: string): Record { + const result = spawnSync('bun', ['-e', RUNNER], { + cwd: import.meta.dirname, + env: { ...process.env, SCENARIO: name }, + encoding: 'utf-8' + }) + if (result.status !== 0) throw new Error(`bun scenario '${name}' failed:\n${result.stderr}`) + const line = result.stdout.trim().split('\n').filter(Boolean).pop() + return JSON.parse(line ?? '{}') +} + +describe('local opencode sessions', () => { + it('lists summaries with lastUserMessage rules and converts message envelopes', () => { + const output = runScenario('sessions') as any + expect(output.summaries.map((summary: any) => summary.id)).toEqual(['session-1', 'session-3']) + expect(output.summaries[0]).toMatchObject({ + id: 'session-1', + title: 'Normal session', + cwd: '/tmp/project', + modifiedAt: 2_000_000, + lastUserMessage: 'hello world' + }) + // tool part skipped; malformed-JSON user row yields null lastUserMessage + expect(output.summaries[1]).toMatchObject({ id: 'session-3', lastUserMessage: null }) + + expect(output.sessions.map((session: any) => session.id)).toEqual(['session-2', 'session-1']) + const archived = output.sessions.find((session: any) => session.id === 'session-2') + // archived sessions are loadable by ids; their '<'-prefixed user text + // is excluded from lastUserMessage but still converts to an envelope + expect(archived?.messages).toHaveLength(1) + expect(archived?.messages[0]).toMatchObject({ content: { role: 'user' } }) + expect(archived?.lastUserMessage ?? null).toBe(null) + const normal = output.sessions.find((session: any) => session.id === 'session-1') + expect(normal?.messages).toHaveLength(2) + expect(normal.messages[0]).toMatchObject({ + content: { role: 'user', content: { type: 'text', text: 'hello world' }, meta: { sentFrom: 'cli' } } + }) + expect(normal.messages[1]).toMatchObject({ + content: { + role: 'agent', + content: { type: 'codex', data: { type: 'message', message: 'hi there' } }, + meta: { sentFrom: 'cli' } + } + }) + expect(typeof normal.messages[0].localId).toBe('string') + }) + + it('truncates long last user messages to 140 chars', () => { + const output = runScenario('truncate') as any + const lastUserMessage = output.summaries[0]?.lastUserMessage as string | undefined + expect(lastUserMessage).toHaveLength(140) + expect(lastUserMessage?.endsWith('…')).toBe(true) + }) + + it('skips unfinished assistant text parts that are still streaming', () => { + const output = runScenario('streaming') as any + const messages = output.sessions[0]?.messages ?? [] + expect(messages).toHaveLength(2) + expect(messages[0]).toMatchObject({ content: { role: 'user', content: { type: 'text', text: 'question' } } }) + expect(messages[1]).toMatchObject({ + content: { + role: 'agent', + content: { type: 'codex', data: { type: 'message', message: 'finished answer' } }, + meta: { sentFrom: 'cli' } + } + }) + }) + + it('never surfaces a holey partial transcript when parts are unreadable', () => { + const output = runScenario('queryfail') as any + // Missing part table short-circuits the read entirely — the caller + // sees an empty result, never a transcript with a hole in the middle. + expect(output.sessions).toEqual([]) + }) + + it('fails closed when the db or required tables are missing', () => { + const output = runScenario('failclosed') as any + expect(output.noDb.path.endsWith(join('opencode.db'))).toBe(true) + expect(output.noDb.summaries).toEqual([]) + expect(output.noDb.byIds).toEqual([]) + expect(output.emptyFile.summaries).toEqual([]) + expect(output.emptyFile.byIds).toEqual([]) + expect(output.missingTables.summaries).toEqual([]) + expect(output.missingTables.byIds).toEqual([]) + }) + + it('rejects instead of returning a partial transcript when a middle part query fails', async () => { + const { listLocalOpencodeSessionsWithMessagesByIds } = await import('./opencodeSessions') + let partReads = 0 + const stmt = (rows: unknown[]) => ({ all: () => rows, get: () => rows[0] }) + const fakeDb = { + query(sql: string) { + if (/FROM part WHERE/.test(sql)) { + partReads += 1 + if (partReads === 2) { + return { all: () => { throw new Error('injected part read failure') }, get: () => undefined } + } + } + if (/sqlite_master/.test(sql)) return stmt([{ name: 'x' }]) + if (/FROM session WHERE id/.test(sql)) { + return stmt([{ id: 's1', title: 'T', directory: '/tmp', time_updated: 7000 }]) + } + if (/FROM message WHERE session_id/.test(sql)) { + return stmt([ + { id: 'm1', data: JSON.stringify({ role: 'user' }), time_created: 7100 }, + { id: 'm2', data: JSON.stringify({ role: 'assistant' }), time_created: 7200 } + ]) + } + return stmt([]) + }, + close: () => {} + } + await expect(listLocalOpencodeSessionsWithMessagesByIds(new Set(['s1']), async () => fakeDb)) + .rejects.toThrow('injected part read failure') + }) +}) diff --git a/cli/src/modules/common/opencodeSessions.ts b/cli/src/modules/common/opencodeSessions.ts new file mode 100644 index 00000000..ba659632 --- /dev/null +++ b/cli/src/modules/common/opencodeSessions.ts @@ -0,0 +1,311 @@ +import { existsSync } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' +import { AGENT_MESSAGE_PAYLOAD_TYPE } from '@hapi/protocol' +import type { + OpencodeImportedMessage, + OpencodeImportedMessageContent, + OpencodeLocalSessionSummary, + OpencodeLocalSessionWithMessages +} from '@hapi/protocol/apiTypes' + +const DEFAULT_OPENCODE_SESSION_SCAN_LIMIT = 200 +const LAST_USER_MESSAGE_MAX_LENGTH = 140 + +type JsonRecord = Record + +type SqlParams = unknown[] + +type StatementLike = { + get: (...params: SqlParams) => unknown + all: (...params: SqlParams) => unknown[] +} + +type DatabaseLike = { + query: (sql: string) => StatementLike + close: () => void + exec?: (sql: string) => void +} + +export type OpencodeDatabaseOpener = (dbPath: string) => Promise + +type SessionRow = { + id: string + title: string | null + directory: string | null + time_updated: number | string +} + +let cachedOpener: OpencodeDatabaseOpener | null = null + +async function getDefaultOpener(): Promise { + if (!cachedOpener) { + cachedOpener = async (dbPath: string) => { + const { Database } = await import('bun:sqlite') + if (!existsSync(dbPath)) return null + const db = new Database(dbPath, { readonly: true }) as unknown as DatabaseLike & { exec?: (sql: string) => void } + try { db.exec?.('PRAGMA busy_timeout = 5000') } catch {} + return db + } + } + return cachedOpener +} + +function asRecord(value: unknown): JsonRecord | null { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? value as JsonRecord + : null +} + +export function getOpencodeSessionsRoot(overrideRoot?: string): string { + const direct = overrideRoot ?? process.env.OPENCODE_HOME?.trim() + if (direct) return direct + const dataHome = process.env.XDG_DATA_HOME?.trim() || join(homedir(), '.local', 'share') + return join(dataHome, 'opencode') +} + +export function getOpencodeDbPath(overrideRoot?: string): string { + return join(getOpencodeSessionsRoot(overrideRoot), 'opencode.db') +} + +function truncateText(value: string, maxLength: number): string { + return value.length > maxLength ? `${value.slice(0, maxLength - 1)}…` : value +} + +async function openDb(dbPath: string, opener?: OpencodeDatabaseOpener): Promise { + const resolveOpener = opener ?? await getDefaultOpener() + return await resolveOpener(dbPath) +} + +function hasRequiredTables(db: DatabaseLike): boolean { + for (const table of ['session', 'message', 'part']) { + try { + const row = db.query( + `SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?` + ).get(table) as { name: string } | null | undefined + if (!row?.name) return false + } catch { + return false + } + } + return true +} + +function parseJson(value: unknown): JsonRecord | null { + if (typeof value !== 'string' || !value.trim()) return null + try { + return asRecord(JSON.parse(value)) + } catch { + return null + } +} + +function collectTextParts(db: DatabaseLike, messageId: string, role: string): Array<{ id: string; text: string }> { + // Query failures must propagate: a swallowed error here would yield a + // partial transcript that the hub later rejects as transcript_diverged. + const partRows = db.query( + `SELECT id, data FROM part WHERE message_id = ? ORDER BY time_created ASC` + ).all(messageId) as Array<{ id: string; data: unknown }> + const texts: Array<{ id: string; text: string }> = [] + for (const partRow of partRows) { + const parsed = parseJson(partRow.data) + if (parsed?.type !== 'text' || typeof parsed.text !== 'string') continue + if (!parsed.text.trim()) continue + // Mirror the live scanner gate: assistant text parts are mutable while + // the response is still streaming; only finished (or synthetic/user) + // parts are stable enough to import. + if (role !== 'user' && parsed.synthetic !== true) { + const time = asRecord(parsed.time) + if (typeof time?.end !== 'number') continue + } + texts.push({ id: partRow.id, text: parsed.text }) + } + return texts +} + +function extractLastUserMessage(db: DatabaseLike, sessionId: string): string | null { + let messageRows: Array<{ id: string; data: unknown }> = [] + try { + messageRows = db.query( + `SELECT id, data FROM message WHERE session_id = ? ORDER BY time_created DESC` + ).all(sessionId) as Array<{ id: string; data: unknown }> + } catch { + return null + } + for (const messageRow of messageRows) { + const parsed = parseJson(messageRow.data) + if (parsed?.role !== 'user') continue + let text: string + try { + text = collectTextParts(db, messageRow.id, 'user') + .filter((part) => !part.text.startsWith('<')) + .map((part) => part.text) + .join('') + .trim() + } catch { + continue + } + if (!text) continue + return truncateText(text, LAST_USER_MESSAGE_MAX_LENGTH) + } + return null +} + +function importedUser(text: string): OpencodeImportedMessageContent { + return { + role: 'user', + content: { type: 'text', text }, + meta: { sentFrom: 'cli' } + } +} + +function importedAgent(text: string, id: string): OpencodeImportedMessageContent { + return { + role: 'agent', + content: { type: AGENT_MESSAGE_PAYLOAD_TYPE, data: { type: 'message', message: text, id } }, + meta: { sentFrom: 'cli' } + } +} + +function normalizeTimestamp(value: number | string | null | undefined, fallback: number): number { + const timestamp = Number(value) + if (!Number.isFinite(timestamp)) return fallback + return timestamp < 1_000_000_000_000 ? timestamp * 1000 : timestamp +} + +async function buildSessionMessages( + db: DatabaseLike, + summary: OpencodeLocalSessionSummary +): Promise { + const messages: OpencodeImportedMessage[] = [] + let createdAt = summary.modifiedAt + try { + const messageRows = db.query( + `SELECT id, data, time_created FROM message WHERE session_id = ? ORDER BY time_created ASC` + ).all(summary.id) as Array<{ id: string; data: unknown; time_created: number | string }> + for (const messageRow of messageRows) { + const parsed = parseJson(messageRow.data) + const role = typeof parsed?.role === 'string' ? parsed.role : null + if (!role) continue + createdAt = normalizeTimestamp(messageRow.time_created, createdAt) + // No silent catch here either: a mid-transcript query failure must + // fail the whole read instead of returning a partial snapshot that + // the hub would permanently reject as transcript_diverged later. + for (const part of collectTextParts(db, messageRow.id, role)) { + messages.push({ + localId: `opencode:${summary.id}:${messageRow.id}:${part.id}`, + createdAt, + content: role === 'user' ? importedUser(part.text) : importedAgent(part.text, part.id) + }) + } + } + } catch (error) { + if (messages.length === 0 && error instanceof Error && /no such table: message/i.test(error.message)) { + // Fresh installations without the message table yet: empty history. + // Anything else (including a missing part table mid-transcript) + // must propagate so callers never persist a partial snapshot. + return { ...summary, messages } + } + throw error + } + return { ...summary, messages } +} + +export async function listLocalOpencodeSessionSummaries( + limit = DEFAULT_OPENCODE_SESSION_SCAN_LIMIT, + opener?: OpencodeDatabaseOpener +): Promise { + if (limit <= 0) return [] + const dbPath = getOpencodeDbPath() + const db = await openDb(dbPath, opener) + if (!db) return [] + try { + if (!hasRequiredTables(db)) return [] + const exec = db.exec?.bind(db) + const inTransaction = typeof exec === 'function' + if (inTransaction) exec('BEGIN DEFERRED') + let rows: SessionRow[] = [] + try { + rows = db.query( + `SELECT id, title, directory, time_updated FROM session WHERE time_archived IS NULL ORDER BY time_updated DESC LIMIT ?` + ).all(limit) as SessionRow[] + } catch { + if (inTransaction) { + try { exec('ROLLBACK') } catch {} + } + return [] + } + try { + const summaries = rows.map((row) => ({ + id: row.id, + title: row.title ?? '', + lastUserMessage: extractLastUserMessage(db, row.id), + cwd: row.directory ?? null, + file: dbPath, + modifiedAt: normalizeTimestamp(row.time_updated, Date.now()) + })) + if (inTransaction) exec('COMMIT') + return summaries + } catch (error) { + if (inTransaction) { + try { exec('ROLLBACK') } catch {} + } + throw error + } + } finally { + db.close() + } +} + +export async function listLocalOpencodeSessionsWithMessagesByIds( + ids: Set, + opener?: OpencodeDatabaseOpener +): Promise { + if (ids.size === 0) return [] + const dbPath = getOpencodeDbPath() + const db = await openDb(dbPath, opener) + if (!db) return [] + try { + if (!hasRequiredTables(db)) return [] + const sessions: OpencodeLocalSessionWithMessages[] = [] + for (const id of ids) { + let rows: SessionRow[] = [] + try { + rows = db.query( + `SELECT id, title, directory, time_updated FROM session WHERE id = ?` + ).all(id) as SessionRow[] + } catch { + continue + } + const row = rows[0] + if (!row) continue + // Read messages and their parts inside one deferred transaction: + // OpenCode may write while we scan, and a snapshot hole here would + // permanently fail re-imports as transcript_diverged. + const exec = db.exec?.bind(db) + const inTransaction = typeof exec === 'function' + if (inTransaction) exec('BEGIN DEFERRED') + try { + const summary: OpencodeLocalSessionSummary = { + id: row.id, + title: row.title ?? '', + lastUserMessage: extractLastUserMessage(db, row.id), + cwd: row.directory ?? null, + file: dbPath, + modifiedAt: normalizeTimestamp(row.time_updated, Date.now()) + } + sessions.push(await buildSessionMessages(db, summary)) + if (inTransaction) exec('COMMIT') + } catch (error) { + if (inTransaction) { + try { exec('ROLLBACK') } catch {} + } + throw error + } + } + // same ordering semantics as summaries: modifiedAt desc + return sessions.sort((a, b) => b.modifiedAt - a.modifiedAt) + } finally { + db.close() + } +} diff --git a/hub/src/store/index.ts b/hub/src/store/index.ts index 4c3a2911..5efe5dd9 100644 --- a/hub/src/store/index.ts +++ b/hub/src/store/index.ts @@ -128,6 +128,10 @@ export class Store { this.workGraph = new WorkGraphStore(this.db) } + runInTransaction(work: () => T): T { + return this.db.transaction(work)() + } + /** * Atomically records a CLI prompt-consumption acknowledgement and returns * the persisted session activity timestamp. A duplicate or sibling-stamped diff --git a/hub/src/sync/rpcGateway.ts b/hub/src/sync/rpcGateway.ts index a00cf6c7..03172bb6 100644 --- a/hub/src/sync/rpcGateway.ts +++ b/hub/src/sync/rpcGateway.ts @@ -5,6 +5,7 @@ import { AgentAvailabilityResponseSchema, CursorChatStoreStatusSchema, ListCodexSessionsRpcResponseSchema, + ListOpencodeSessionsRpcResponseSchema, ListPiSessionsRpcResponseSchema } from '@hapi/protocol/apiTypes' import type { @@ -30,6 +31,7 @@ import type { ListDirectoryResponse, ListCodexSessionsRpcResponse, ListPiSessionsRpcResponse, + ListOpencodeSessionsRpcResponse, ArchiveCodexSessionRpcResponse, OpencodeModelsResponse, OpencodeModelSummary, @@ -116,6 +118,7 @@ export type RpcCodexModel = CodexModelSummary export type RpcListCodexModelsResponse = CodexModelsResponse export type RpcListCodexSessionsResponse = ListCodexSessionsRpcResponse export type RpcListPiSessionsResponse = ListPiSessionsRpcResponse +export type RpcListOpencodeSessionsResponse = ListOpencodeSessionsRpcResponse export type RpcArchiveCodexSessionResponse = ArchiveCodexSessionRpcResponse export type RpcCursorModel = CursorModelSummary export type RpcListCursorModelsResponse = CursorModelsResponse @@ -506,6 +509,11 @@ export class RpcGateway { return ListPiSessionsRpcResponseSchema.parse(result) } + async listOpencodeSessionsForMachine(machineId: string, cwd?: string | null, sessionIds?: string[]): Promise { + const result = await this.machineRpc(machineId, RPC_METHODS.ListOpencodeSessions, { cwd: cwd ?? null, sessionIds }, MODEL_LIST_RPC_TIMEOUT_MS) + return ListOpencodeSessionsRpcResponseSchema.parse(result) + } + async archiveCodexSessionForMachine(machineId: string, sessionId: string): Promise { const result = await this.machineRpc(machineId, RPC_METHODS.ArchiveCodexSession, { sessionId }, MODEL_LIST_RPC_TIMEOUT_MS) return ArchiveCodexSessionRpcResponseSchema.parse(result) diff --git a/hub/src/sync/sessionCache.ts b/hub/src/sync/sessionCache.ts index fb52016b..b5a11922 100644 --- a/hub/src/sync/sessionCache.ts +++ b/hub/src/sync/sessionCache.ts @@ -1665,12 +1665,15 @@ export class SessionCache { private extractAgentSessionId( metadata: NonNullable ): { field: 'codexSessionId' | 'claudeSessionId' | 'geminiSessionId' | 'opencodeSessionId' | 'grokSessionId' | 'cursorSessionId' | 'piSessionId' | 'agySessionId' | 'copilotSessionId'; value: string; dedupeKey: string; machineId?: string } | null { - const scoped = (field: 'codexSessionId' | 'claudeSessionId' | 'geminiSessionId' | 'opencodeSessionId' | 'grokSessionId' | 'cursorSessionId' | 'piSessionId' | 'agySessionId' | 'copilotSessionId', value: string) => ({ - field, - value, - dedupeKey: field === 'piSessionId' ? `${field}:${metadata.machineId ?? 'unscoped'}:${value}` : `${field}:${value}`, - ...(field === 'piSessionId' && metadata.machineId ? { machineId: metadata.machineId } : {}) - }) + const scoped = (field: 'codexSessionId' | 'claudeSessionId' | 'geminiSessionId' | 'opencodeSessionId' | 'grokSessionId' | 'cursorSessionId' | 'piSessionId' | 'agySessionId' | 'copilotSessionId', value: string) => { + const machineScoped = field === 'piSessionId' || field === 'opencodeSessionId' + return { + field, + value, + dedupeKey: machineScoped ? `${field}:${metadata.machineId ?? 'unscoped'}:${value}` : `${field}:${value}`, + ...(machineScoped && metadata.machineId ? { machineId: metadata.machineId } : {}) + } + } if (metadata.codexSessionId) return scoped('codexSessionId', metadata.codexSessionId) if (metadata.claudeSessionId) return scoped('claudeSessionId', metadata.claudeSessionId) if (metadata.geminiSessionId) return scoped('geminiSessionId', metadata.geminiSessionId) @@ -1707,8 +1710,9 @@ export class SessionCache { const currentSession = this.sessions.get(sessionId) const candidates: { id: string; session: Session }[] = [] + const machineScoped = agentId.field === 'piSessionId' || agentId.field === 'opencodeSessionId' if (currentSession?.metadata && currentSession.metadata[agentId.field] === agentId.value) { - if (agentId.field !== 'piSessionId' || currentSession.metadata.machineId === agentId.machineId) { + if (!machineScoped || currentSession.metadata.machineId === agentId.machineId) { candidates.push({ id: sessionId, session: currentSession }) } } @@ -1717,7 +1721,7 @@ export class SessionCache { if (existing.namespace !== session.namespace) continue if (!existing.metadata) continue if (existing.metadata[agentId.field] !== agentId.value) continue - if (agentId.field === 'piSessionId' && existing.metadata.machineId !== agentId.machineId) continue + if (machineScoped && existing.metadata.machineId !== agentId.machineId) continue candidates.push({ id: existingId, session: existing }) } diff --git a/hub/src/sync/sessionModel.test.ts b/hub/src/sync/sessionModel.test.ts index c2ac0ad8..29c0fa79 100644 --- a/hub/src/sync/sessionModel.test.ts +++ b/hub/src/sync/sessionModel.test.ts @@ -3704,6 +3704,61 @@ describe('session model', () => { } }) + it('does not resume a native OpenCode session on a same-host machine when its recorded machine is offline', async () => { + const store = new Store(':memory:') + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + + try { + const session = engine.getOrCreateSession( + 'opencode-offline-recorded-machine-resume', + { + path: '/remote/project', + host: 'shared-host-label', + machineId: 'recorded-machine-offline', + flavor: 'opencode', + opencodeSessionId: 'opencode-native-offline', + lifecycleState: 'archived' + }, + null, + 'default' + ) + engine.getOrCreateMachine( + 'recorded-machine-offline', + { host: 'shared-host-label', platform: 'linux', happyCliVersion: '0.1.0' }, + null, + 'default' + ) + engine.getOrCreateMachine( + 'wrong-same-host-machine', + { host: 'shared-host-label', platform: 'linux', happyCliVersion: '0.1.0' }, + { status: 'running' }, + 'default' + ) + engine.handleMachineAlive({ machineId: 'wrong-same-host-machine', time: Date.now() }) + + let spawnCalled = false + ;(engine as any).rpcGateway.spawnSession = async () => { + spawnCalled = true + return { type: 'success', sessionId: session.id } + } + + expect(await engine.reopenSession(session.id, 'default')).toEqual({ + type: 'error', + message: 'No machine online', + code: 'no_machine_online' + }) + expect(spawnCalled).toBe(false) + expect(engine.getSession(session.id)?.metadata?.lifecycleState).toBe('archived') + } finally { + engine.stop() + } + }) + it('resumeSession fresh-spawns when inactive cursor session has no agent id and no user messages', async () => { const store = new Store(':memory:') const engine = new SyncEngine( @@ -4022,6 +4077,30 @@ describe('session model', () => { store.close() }) + it('does not merge the same OpenCode session id across different machines', async () => { + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const cache = new SessionCache(store, createPublisher(events)) + const s1 = cache.getOrCreateSession( + 'opencode-tag-1', + { path: '/tmp/project', host: 'one', machineId: 'machine-1', flavor: 'opencode', opencodeSessionId: 'native-opencode-id' }, + null, + 'default' + ) + const s2 = cache.getOrCreateSession( + 'opencode-tag-2', + { path: '/tmp/project', host: 'two', machineId: 'machine-2', flavor: 'opencode', opencodeSessionId: 'native-opencode-id' }, + null, + 'default' + ) + + await cache.deduplicateByAgentSessionId(s2.id) + + expect(cache.getSession(s1.id)).toBeDefined() + expect(cache.getSession(s2.id)).toBeDefined() + store.close() + }) + it('does not merge across namespaces', async () => { const store = new Store(':memory:') const events: SyncEvent[] = [] diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index bfbc13fc..32f41540 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -48,6 +48,7 @@ import { type RpcListClaudeModelsResponse, type RpcListCodexModelsResponse, type RpcListPiSessionsResponse, + type RpcListOpencodeSessionsResponse, type RpcArchiveCodexSessionResponse, type RpcListCursorModelsResponse, type RpcListOpencodeModelsResponse, @@ -3306,7 +3307,7 @@ export class SyncEngine { const targetMachine = this.resolveOnlineMachineForSession( session, namespace, - { strictMachineId: flavor === 'cursor' || (flavor === 'pi' && resumeToken !== undefined) } + { strictMachineId: flavor === 'cursor' || ((flavor === 'pi' || flavor === 'opencode') && resumeToken !== undefined) } ) if (!targetMachine) { return { type: 'error', message: 'No machine online', code: 'no_machine_online' } @@ -4491,6 +4492,10 @@ export class SyncEngine { return await this.rpcGateway.listPiSessionsForMachine(machineId, cwd, sessionIds) } + async listOpencodeSessionsForMachine(machineId: string, cwd?: string | null, sessionIds?: string[]): Promise { + return await this.rpcGateway.listOpencodeSessionsForMachine(machineId, cwd, sessionIds) + } + async archiveCodexSessionForMachine(machineId: string, sessionId: string): Promise { return await this.rpcGateway.archiveCodexSessionForMachine(machineId, sessionId) } diff --git a/hub/src/web/routes/opencodeSessions.test.ts b/hub/src/web/routes/opencodeSessions.test.ts new file mode 100644 index 00000000..1024b050 --- /dev/null +++ b/hub/src/web/routes/opencodeSessions.test.ts @@ -0,0 +1,587 @@ +import { afterEach, describe, expect, it } from 'bun:test' +import { Hono } from 'hono' +import type { OpencodeLocalSessionSummary, OpencodeLocalSessionWithMessages } from '@hapi/protocol/apiTypes' +import { Store } from '../../store' +import type { Machine, SyncEngine } from '../../sync/syncEngine' +import type { WebAppEnv } from '../middleware/auth' +import { createOpencodeSessionRoutes, importOpencodeSession } from './opencodeSessions' + +function machine(id: string): Machine { + return { + id, + namespace: 'default', + seq: 1, + createdAt: 1, + updatedAt: 1, + active: true, + activeAt: 1, + metadata: { + host: `${id}.local`, + platform: 'darwin', + happyCliVersion: 'test' + }, + metadataVersion: 1, + runnerState: null, + runnerStateVersion: 1, + health: null + } +} + +function userMessage(sessionId: string, localId: string, text: string, createdAt: number) { + return { + localId: `opencode:${sessionId}:${localId}`, + createdAt, + content: { + role: 'user' as const, + content: { type: 'text' as const, text }, + meta: { sentFrom: 'cli' as const } + } + } +} + +function transcript( + sessionId: string, + messages: Array> +): OpencodeLocalSessionWithMessages { + const lastUser = [...messages].reverse().find((message) => message.content.role === 'user') + return { + id: sessionId, + title: `Session ${sessionId}`, + lastUserMessage: lastUser?.content.role === 'user' ? lastUser.content.content.text : null, + cwd: '/tmp/project', + file: `/tmp/${sessionId}.json`, + modifiedAt: messages.at(-1)?.createdAt ?? 1, + messages + } +} + +describe('OpenCode session import', () => { + const stores: Store[] = [] + + afterEach(() => { + for (const store of stores.splice(0)) store.close() + }) + + function setup() { + const store = new Store(':memory:') + stores.push(store) + const events: unknown[] = [] + const engine = { + recordSessionActivity: (sessionId: string, updatedAt: number) => { + store.sessions.touchSessionUpdatedAt(sessionId, updatedAt, 'default') + }, + handleRealtimeEvent: (event: unknown) => events.push(event) + } as unknown as SyncEngine + return { store, engine, events } + } + + function appWith(store: Store, engine: SyncEngine) { + const app = new Hono() + app.use('*', async (c, next) => { + c.set('namespace', 'default') + await next() + }) + app.route('/api', createOpencodeSessionRoutes({ store, getSyncEngine: () => engine })) + return app + } + + it('returns 503 when no online machine is available', async () => { + const { store } = setup() + const engine = { + getOnlineMachinesByNamespace: () => [] + } as unknown as SyncEngine + const app = appWith(store, engine) + + const response = await app.request('/api/opencode/sessions') + + expect(response.status).toBe(503) + expect(await response.json()).toMatchObject({ success: false, sessions: [] }) + }) + + it('annotates summaries with hapiSessionId of already-imported sessions', async () => { + const { store } = setup() + const selectedMachine = machine('machine-1') + store.sessions.getOrCreateSession('imported-native-1', { + flavor: 'opencode', + machineId: selectedMachine.id, + opencodeSessionId: 'native-1' + }, {}, 'default') + const summaries: OpencodeLocalSessionSummary[] = [ + transcript('native-1', []), + transcript('native-2', []) + ] + const engine = { + getOnlineMachinesByNamespace: () => [selectedMachine], + listOpencodeSessionsForMachine: async () => ({ success: true, sessions: summaries }) + } as unknown as SyncEngine + const app = appWith(store, engine) + + const response = await app.request('/api/opencode/sessions?machineId=machine-1') + + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ + success: true, + machineId: 'machine-1', + sessions: [ + { id: 'native-1', hapiSessionId: expect.any(String) }, + { id: 'native-2' } + ] + }) + }) + + it('creates a HAPI session with flavor/opencodeSessionId metadata and appended messages', async () => { + const { store } = setup() + const selectedMachine = machine('machine-1') + const transcripts = [ + transcript('native-create', [ + userMessage('native-create', 'msg-1', 'one', 1_000) + ]) + ] + const engine = { + getOnlineMachinesByNamespace: () => [selectedMachine], + listOpencodeSessionsForMachine: async (_machineId: string, _cwd: string | null, sessionIds?: string[]) => { + expect(sessionIds).toEqual(['native-create']) + return { success: true, sessions: transcripts } + }, + recordSessionActivity: (sessionId: string, updatedAt: number) => { + store.sessions.touchSessionUpdatedAt(sessionId, updatedAt, 'default') + }, + handleRealtimeEvent: () => {} + } as unknown as SyncEngine + const app = appWith(store, engine) + + const response = await app.request('/api/opencode/import-sessions', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ machineId: selectedMachine.id, sessionIds: ['native-create'] }) + }) + + expect(response.status).toBe(200) + const payload = await response.json() as { results: Array<{ hapiSessionId?: string; action?: string; appended?: number }> } + expect(payload).toMatchObject({ + success: true, + machineId: 'machine-1', + results: [{ action: 'created', appended: 1 }] + }) + const hapiSessionId = payload.results[0]!.hapiSessionId! + const stored = store.sessions.getSession(hapiSessionId)! + expect(stored.metadata).toMatchObject({ + flavor: 'opencode', + machineId: 'machine-1', + opencodeSessionId: 'native-create' + }) + expect(store.messages.getAllMessages(hapiSessionId)).toHaveLength(1) + expect(store.messages.getAllMessages(hapiSessionId)[0]).toMatchObject({ + localId: 'opencode:native-create:msg-1', + invokedAt: 1_000 + }) + }) + + it('appends only the delta when re-importing an extended transcript', async () => { + const { store, engine } = setup() + const first = transcript('native-delta', [ + userMessage('native-delta', 'msg-1', 'one', 1_000) + ]) + const initial = importOpencodeSession({ store, engine, namespace: 'default', machine: machine('machine-1'), transcript: first }) + expect(initial).toMatchObject({ action: 'created', appended: 1 }) + + const unchanged = importOpencodeSession({ store, engine, namespace: 'default', machine: machine('machine-1'), transcript: first }) + expect(unchanged).toMatchObject({ action: 'unchanged', appended: 0 }) + + const extended = transcript('native-delta', [ + userMessage('native-delta', 'msg-1', 'one', 1_000), + userMessage('native-delta', 'msg-2', 'two', 2_000) + ]) + const updated = importOpencodeSession({ store, engine, namespace: 'default', machine: machine('machine-1'), transcript: extended }) + expect(updated).toMatchObject({ hapiSessionId: initial.hapiSessionId, action: 'updated', appended: 1 }) + expect(store.messages.getAllMessages(initial.hapiSessionId!)).toHaveLength(2) + }) + + it('rolls back the full import when a message insert fails', () => { + const { store, engine } = setup() + const original = store.messages.addImportedMessage.bind(store.messages) + let calls = 0 + store.messages.addImportedMessage = (...args) => { + calls += 1 + if (calls === 2) throw new Error('simulated insert failure') + return original(...args) + } + + const result = importOpencodeSession({ + store, + engine, + namespace: 'default', + machine: machine('machine-1'), + transcript: transcript('native-rollback', [ + userMessage('native-rollback', 'msg-1', 'one', 1_000), + userMessage('native-rollback', 'msg-2', 'two', 2_000) + ]) + }) + + expect(result.error?.code).toBe('import_failed') + expect(store.sessions.getSessionsByNamespace('default')).toHaveLength(0) + }) + + it('fails with transcript_diverged and keeps stored history intact on content change', () => { + const { store, engine } = setup() + const source = transcript('native-diverged', [ + userMessage('native-diverged', 'msg-1', 'one', 1_000) + ]) + const first = importOpencodeSession({ store, engine, namespace: 'default', machine: machine('machine-1'), transcript: source }) + const before = store.sessions.getSession(first.hapiSessionId!)! + + const rewritten = transcript('native-diverged', [ + userMessage('native-diverged', 'msg-1', 'changed', 1_000) + ]) + rewritten.cwd = '/tmp/rejected-diverged' + rewritten.title = 'Rejected divergent title' + const result = importOpencodeSession({ store, engine, namespace: 'default', machine: machine('machine-1'), transcript: rewritten }) + expect(result.error?.code).toBe('transcript_diverged') + expect(result.hapiSessionId).toBe(first.hapiSessionId) + expect(store.messages.getAllMessages(first.hapiSessionId!)).toHaveLength(1) + expect(store.messages.getAllMessages(first.hapiSessionId!)[0]).toMatchObject({ + localId: 'opencode:native-diverged:msg-1' + }) + const after = store.sessions.getSession(first.hapiSessionId!)! + expect(after.metadata).toEqual(before.metadata) + expect(after.metadataVersion).toBe(before.metadataVersion) + }) + + it('fails when new native entries are inserted ahead of the imported boundary', () => { + const { store, engine } = setup() + const first = importOpencodeSession({ + store, + engine, + namespace: 'default', + machine: machine('machine-1'), + transcript: transcript('native-insert', [ + userMessage('native-insert', 'msg-1', 'one', 1_000), + userMessage('native-insert', 'msg-2', 'two', 2_000) + ]) + }) + expect(first).toMatchObject({ action: 'created' }) + + const reordered = importOpencodeSession({ + store, + engine, + namespace: 'default', + machine: machine('machine-1'), + transcript: transcript('native-insert', [ + userMessage('native-insert', 'msg-new', 'new', 500), + userMessage('native-insert', 'msg-1', 'one', 1_000), + userMessage('native-insert', 'msg-2', 'two', 2_000) + ]) + }) + expect(reordered.error?.code).toBe('transcript_diverged') + }) + + it('fails closed when HAPI continuation happened after an import and native history advanced', () => { + const { store, engine } = setup() + const first = importOpencodeSession({ + store, + engine, + namespace: 'default', + machine: machine('machine-1'), + transcript: transcript('native-tail', [userMessage('native-tail', 'msg-1', 'one', 1_000)]) + }) + expect(first.hapiSessionId).toBeTruthy() + const continuation = store.messages.addImportedMessage(first.hapiSessionId!, { + role: 'user', + content: { type: 'text', text: 'live follow-up' }, + meta: { sentFrom: 'web' as const } + }, 'hapi-live-msg-1', 5_000) + expect(continuation.inserted).toBe(true) + + const extended = importOpencodeSession({ + store, + engine, + namespace: 'default', + machine: machine('machine-1'), + transcript: transcript('native-tail', [ + userMessage('native-tail', 'msg-1', 'one', 1_000), + userMessage('native-tail', 'msg-2', 'two', 2_000) + ]) + }) + expect(extended.error?.code).toBe('transcript_diverged') + expect(store.messages.getAllMessages(first.hapiSessionId!)).toHaveLength(2) + }) + + it('applies requested launch config to the imported session before reopen', async () => { + const { store } = setup() + const selectedMachine = machine('machine-1') + const applied: Array> = [] + const engine = { + getOnlineMachinesByNamespace: () => [selectedMachine], + listOpencodeSessionsForMachine: async () => ({ + success: true, + sessions: [transcript('native-launch', [userMessage('native-launch', 'msg-1', 'one', 1_000)])] + }), + applySessionConfig: async (sessionId: string, config: Record) => { + applied.push({ sessionId, config }) + }, + recordSessionActivity: (sessionId: string, updatedAt: number) => { + store.sessions.touchSessionUpdatedAt(sessionId, updatedAt, 'default') + }, + handleRealtimeEvent: () => {} + } as unknown as SyncEngine + const app = appWith(store, engine) + + const response = await app.request('/api/opencode/import-sessions', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + sessionIds: ['native-launch'], + model: 'opengpt/5.2-max', + modelReasoningEffort: 'high', + permissionMode: 'yolo' + }) + }) + expect(response.status).toBe(200) + const body = await response.json() as { results: Array<{ hapiSessionId?: string }> } + const importedSessionId = body.results[0]?.hapiSessionId + expect(importedSessionId).toBeTruthy() + expect(applied).toEqual([{ sessionId: importedSessionId, config: { model: 'opengpt/5.2-max', modelReasoningEffort: 'high', permissionMode: 'yolo' } }]) + }) + + it('serializes concurrent import configuration for the same native session', async () => { + const { store } = setup() + const selectedMachine = machine('machine-1') + const applied: string[] = [] + let signalFirstStarted!: () => void + let releaseFirst!: () => void + const firstStarted = new Promise((resolve) => { signalFirstStarted = resolve }) + const firstRelease = new Promise((resolve) => { releaseFirst = resolve }) + const engine = { + getOnlineMachinesByNamespace: () => [selectedMachine], + listOpencodeSessionsForMachine: async () => ({ + success: true, + sessions: [transcript('native-concurrent', [userMessage('native-concurrent', 'msg-1', 'one', 1_000)])] + }), + applySessionConfig: async (_sessionId: string, config: Record) => { + applied.push(String(config.permissionMode)) + if (config.permissionMode === 'yolo') { + signalFirstStarted() + await firstRelease + } + }, + recordSessionActivity: (sessionId: string, updatedAt: number) => { + store.sessions.touchSessionUpdatedAt(sessionId, updatedAt, 'default') + }, + handleRealtimeEvent: () => {} + } as unknown as SyncEngine + const app = appWith(store, engine) + const request = (permissionMode: string) => app.request('/api/opencode/import-sessions', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ sessionIds: ['native-concurrent'], permissionMode }) + }) + + const first = request('yolo') + await firstStarted + const second = request('default') + await Promise.resolve() + expect(applied).toEqual(['yolo']) + releaseFirst() + const responses = await Promise.all([first, second]) + + expect(responses.map((response) => response.status)).toEqual([200, 200]) + const bodies = await Promise.all(responses.map(async (response) => await response.json())) as Array<{ results: Array<{ action?: string }> }> + expect(bodies.map((body) => body.results[0]?.action)).toEqual(['created', 'unchanged']) + expect(applied).toEqual(['yolo', 'default']) + }) + + it('reports a per-session config_failed result instead of a 500 when applySessionConfig throws', async () => { + const { store } = setup() + const selectedMachine = machine('machine-1') + const engine = { + getOnlineMachinesByNamespace: () => [selectedMachine], + listOpencodeSessionsForMachine: async () => ({ + success: true, + sessions: [transcript('native-cfgfail', [userMessage('native-cfgfail', 'msg-1', 'one', 1_000)])] + }), + applySessionConfig: async () => { + throw new Error('rpc timeout') + }, + recordSessionActivity: (sessionId: string, updatedAt: number) => { + store.sessions.touchSessionUpdatedAt(sessionId, updatedAt, 'default') + }, + handleRealtimeEvent: () => {} + } as unknown as SyncEngine + const app = appWith(store, engine) + + const response = await app.request('/api/opencode/import-sessions', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ sessionIds: ['native-cfgfail'], permissionMode: 'yolo' }) + }) + expect(response.status).toBe(200) + const body = await response.json() as { success: boolean; results: Array<{ hapiSessionId?: string; error?: { code: string; message: string } }> } + expect(body.success).toBe(false) + expect(body.results[0]?.error).toMatchObject({ code: 'config_failed', message: 'rpc timeout' }) + expect(body.results[0]?.hapiSessionId).toBeTruthy() + }) + + it('resets persisted launch config when the picker sends explicit defaults', async () => { + const { store } = setup() + const selectedMachine = machine('machine-1') + const applied: Array<{ sessionId: string; config: Record }> = [] + const engine = { + getOnlineMachinesByNamespace: () => [selectedMachine], + listOpencodeSessionsForMachine: async () => ({ + success: true, + sessions: [transcript('native-reset', [userMessage('native-reset', 'msg-1', 'one', 1_000)])] + }), + applySessionConfig: async (sessionId: string, config: Record) => { + applied.push({ sessionId, config }) + }, + recordSessionActivity: (sessionId: string, updatedAt: number) => { + store.sessions.touchSessionUpdatedAt(sessionId, updatedAt, 'default') + }, + handleRealtimeEvent: () => {} + } as unknown as SyncEngine + const app = appWith(store, engine) + + const response = await app.request('/api/opencode/import-sessions', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + sessionIds: ['native-reset'], + model: null, + modelReasoningEffort: null, + permissionMode: null + }) + }) + expect(response.status).toBe(200) + const body = await response.json() as { results: Array<{ hapiSessionId?: string }> } + const importedSessionId: string | undefined = body.results[0]?.hapiSessionId + if (!importedSessionId) throw new Error('expected hapiSessionId in import result') + const appliedCall = applied[0] + if (!appliedCall) throw new Error('expected applySessionConfig to be called') + expect(appliedCall.sessionId).toBe(importedSessionId) + expect(Object.keys(appliedCall!.config)).toEqual(['model', 'modelReasoningEffort', 'permissionMode']) + expect(appliedCall!.config.model).toBeNull() + expect(appliedCall!.config.modelReasoningEffort).toBeNull() + expect(appliedCall!.config.permissionMode).toBe('default') + }) + + it('rejects more sessions than the machine RPC scan limit', async () => { + const { store } = setup() + let rpcCalls = 0 + const engine = { + getOnlineMachinesByNamespace: () => [machine('machine-1')], + listOpencodeSessionsForMachine: async () => { + rpcCalls += 1 + return { success: true, sessions: [] } + } + } as unknown as SyncEngine + const app = appWith(store, engine) + + const response = await app.request('/api/opencode/import-sessions', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ sessionIds: Array.from({ length: 201 }, (_, index) => `native-${index}`) }) + }) + + expect(response.status).toBe(400) + expect(rpcCalls).toBe(0) + }) + + it('does not touch launch config when the request omits the fields', async () => { + const { store } = setup() + const selectedMachine = machine('machine-1') + let applyCalls = 0 + const engine = { + getOnlineMachinesByNamespace: () => [selectedMachine], + listOpencodeSessionsForMachine: async () => ({ + success: true, + sessions: [transcript('native-omit', [userMessage('native-omit', 'msg-1', 'one', 1_000)])] + }), + applySessionConfig: async () => { applyCalls += 1 }, + recordSessionActivity: (sessionId: string, updatedAt: number) => { + store.sessions.touchSessionUpdatedAt(sessionId, updatedAt, 'default') + }, + handleRealtimeEvent: () => {} + } as unknown as SyncEngine + const app = appWith(store, engine) + + const response = await app.request('/api/opencode/import-sessions', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ sessionIds: ['native-omit'] }) + }) + expect(response.status).toBe(200) + expect(applyCalls).toBe(0) + }) + + it('rejects a permission mode that is not allowed for the opencode flavor', async () => { + const { store } = setup() + const selectedMachine = machine('machine-1') + const engine = { + getOnlineMachinesByNamespace: () => [selectedMachine], + listOpencodeSessionsForMachine: async () => ({ success: true, sessions: [] }) + } as unknown as SyncEngine + const app = appWith(store, engine) + + const response = await app.request('/api/opencode/import-sessions', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ sessionIds: ['native-x'], permissionMode: 'acceptEdits' }) + }) + expect(response.status).toBe(400) + }) + + it('fails with session_active when the target session is active and new messages exist', () => { + const { store, engine } = setup() + const first = importOpencodeSession({ + store, + engine, + namespace: 'default', + machine: machine('machine-1'), + transcript: transcript('native-active', [userMessage('native-active', 'msg-1', 'one', 1_000)]) + }) + const staleInactive = store.sessions.getSession(first.hapiSessionId!)! + store.sessions.setSessionActive(first.hapiSessionId!, true, 2_000, 'default') + const before = store.sessions.getSession(first.hapiSessionId!)! + const extended = transcript('native-active', [ + userMessage('native-active', 'msg-1', 'one', 1_000), + userMessage('native-active', 'msg-2', 'two', 2_000) + ]) + extended.cwd = '/tmp/rejected-active' + extended.title = 'Rejected active title' + + const result = importOpencodeSession({ + store, + engine, + namespace: 'default', + machine: machine('machine-1'), + transcript: extended, + existingSession: staleInactive + }) + expect(result).toMatchObject({ error: { code: 'session_active' }, appended: 0 }) + expect(store.messages.getAllMessages(first.hapiSessionId!)).toHaveLength(1) + const after = store.sessions.getSession(first.hapiSessionId!)! + expect(after.metadata).toEqual(before.metadata) + expect(after.metadataVersion).toBe(before.metadataVersion) + }) + + it('fails closed when a captured existing session disappears before import', async () => { + const { store, engine } = setup() + const source = transcript('native-disappeared', [userMessage('native-disappeared', 'msg-1', 'one', 1_000)]) + const first = importOpencodeSession({ store, engine, namespace: 'default', machine: machine('machine-1'), transcript: source }) + const stale = store.sessions.getSession(first.hapiSessionId!)! + await store.sessions.deleteSession(stale.id, 'default') + + const result = importOpencodeSession({ + store, + engine, + namespace: 'default', + machine: machine('machine-1'), + transcript: source, + existingSession: stale + }) + + expect(result).toMatchObject({ error: { code: 'import_failed', message: 'Imported HAPI session disappeared' } }) + expect(result.hapiSessionId).toBeUndefined() + expect(store.sessions.getSessionsByNamespace('default')).toHaveLength(0) + }) +}) diff --git a/hub/src/web/routes/opencodeSessions.ts b/hub/src/web/routes/opencodeSessions.ts new file mode 100644 index 00000000..26381c4f --- /dev/null +++ b/hub/src/web/routes/opencodeSessions.ts @@ -0,0 +1,376 @@ +import { dirname } from 'node:path' +import { isDeepStrictEqual } from 'node:util' +import { Hono } from 'hono' +import { MAX_OPENCODE_SESSION_IMPORTS } from '@hapi/protocol/apiTypes' +import type { OpencodeLocalSessionSummary, OpencodeLocalSessionWithMessages } from '@hapi/protocol/apiTypes' +import { isPermissionModeAllowedForFlavor } from '@hapi/protocol/modes' +import type { PermissionMode } from '@hapi/protocol/modes' +import type { Metadata } from '@hapi/protocol/types' +import type { Store, StoredMessage, StoredSession } from '../../store' +import { ImportedMessageConflictError } from '../../store/messages' +import { truncateOversizedMessageContent } from '../../store/contentCodec' +import type { Machine, SyncEngine } from '../../sync/syncEngine' +import type { WebAppEnv } from '../middleware/auth' + +const importLocks = new Map>() + +export type OpencodeSessionListItem = OpencodeLocalSessionSummary & { + hapiSessionId?: string +} + +export type OpencodeImportResult = { + opencodeSessionId: string + hapiSessionId?: string + action?: 'created' | 'updated' | 'unchanged' + appended?: number + error?: { code: string; message: string } +} + +function asRecord(value: unknown): Record | null { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : null +} + +function storedMetadata(session: StoredSession): Record { + return asRecord(session.metadata) ?? {} +} + +function resolveOpencodeMachine( + engine: SyncEngine | null, + namespace: string, + requestedMachineId?: string | null +): Machine | null { + if (!engine) return null + const online = engine.getOnlineMachinesByNamespace(namespace) + if (requestedMachineId) return online.find((machine) => machine.id === requestedMachineId) ?? null + return online[0] ?? null +} + +function importedOpencodeSessionsById( + store: Store, + namespace: string, + machineId: string +): Map { + const importedByOpencodeId = new Map() + for (const session of store.sessions.getSessionsByNamespace(namespace)) { + const metadata = storedMetadata(session) + const opencodeSessionId = metadata.opencodeSessionId + if (metadata.flavor !== 'opencode' + || metadata.machineId !== machineId + || typeof opencodeSessionId !== 'string' + || importedByOpencodeId.has(opencodeSessionId)) continue + importedByOpencodeId.set(opencodeSessionId, session) + } + return importedByOpencodeId +} + +function findImportedOpencodeSession( + store: Store, + namespace: string, + machineId: string, + opencodeSessionId: string +): StoredSession | null { + return importedOpencodeSessionsById(store, namespace, machineId).get(opencodeSessionId) ?? null +} + +function buildOpencodeMetadata( + transcript: OpencodeLocalSessionWithMessages, + machine: Machine, + existing: Record +): Metadata { + const summaryText = transcript.lastUserMessage ?? transcript.title + return { + ...existing, + path: transcript.cwd ?? (typeof existing.path === 'string' ? existing.path : dirname(transcript.file)), + host: typeof existing.host === 'string' ? existing.host : (machine.metadata?.host ?? machine.id), + os: typeof existing.os === 'string' ? existing.os : (machine.metadata?.platform ?? process.platform), + name: typeof existing.name === 'string' ? existing.name : transcript.title, + summary: summaryText ? { text: summaryText, updatedAt: Date.now() } : undefined, + machineId: machine.id, + flavor: 'opencode', + opencodeSessionId: transcript.id, + lifecycleState: typeof existing.lifecycleState === 'string' ? existing.lifecycleState : 'archived', + lifecycleStateSince: typeof existing.lifecycleStateSince === 'number' ? existing.lifecycleStateSince : Date.now(), + archivedBy: typeof existing.archivedBy === 'string' ? existing.archivedBy : 'opencode-import', + archiveReason: typeof existing.archiveReason === 'string' ? existing.archiveReason : 'Imported from local OpenCode history' + } +} + +function updateMetadataWithRetry( + store: Store, + sessionId: string, + namespace: string, + transform: (metadata: Record) => Metadata +): Metadata { + for (let attempt = 0; attempt < 5; attempt += 1) { + const current = store.sessions.getSessionByNamespace(sessionId, namespace) + if (!current) throw new Error('Imported HAPI session disappeared') + const next = transform(storedMetadata(current)) + const result = store.sessions.updateSessionMetadata( + sessionId, + next, + current.metadataVersion, + namespace, + { touchUpdatedAt: false } + ) + if (result.result === 'success') return next + if (result.result === 'error') throw new Error('Failed to persist OpenCode import metadata') + } + throw new Error('OpenCode import metadata changed concurrently') +} + +function emitImportedMessages(engine: SyncEngine, sessionId: string, messages: StoredMessage[]): void { + for (const message of messages) { + engine.handleRealtimeEvent({ + type: 'message-received', + sessionId, + message: { + id: message.id, + seq: message.seq, + localId: message.localId, + content: message.content, + createdAt: message.createdAt, + invokedAt: message.invokedAt + } + }) + } +} + +function classifyImportDelta( + existing: StoredMessage[], + transcript: OpencodeLocalSessionWithMessages +): { messages: OpencodeLocalSessionWithMessages['messages']; error?: string } { + const sourceByLocalId = new Map(transcript.messages.map((message) => [ + message.localId, + truncateOversizedMessageContent(message.content) + ])) + const sourceIds = transcript.messages.map((message) => message.localId) + const storedImported = existing + .filter((message) => message.localId?.startsWith(importedPrefix(transcript.id))) + for (let index = 0; index < storedImported.length; index += 1) { + if (sourceIds[index] !== storedImported[index]!.localId) { + return { messages: [], error: `Local OpenCode transcript changed or dropped imported entry ${storedImported[index]!.localId}` } + } + } + for (const message of storedImported) { + const source = sourceByLocalId.get(message.localId!) + if (!source || !isDeepStrictEqual(source, message.content)) { + return { messages: [], error: `Local OpenCode transcript changed or dropped imported entry ${message.localId}` } + } + } + const imported = new Set(storedImported.map((message) => message.localId!)) + const delta = transcript.messages.filter((message) => !imported.has(message.localId)) + if (existing.length > storedImported.length && delta.length > 0) { + return { messages: [], error: 'The HAPI session continued past the imported history; re-importing would duplicate or reorder messages' } + } + return { messages: delta } +} + +function importedPrefix(sessionId: string): string { + return `opencode:${sessionId}:` +} + +export function importOpencodeSession(options: { + store: Store + engine: SyncEngine + namespace: string + machine: Machine + transcript: OpencodeLocalSessionWithMessages + existingSession?: StoredSession | null +}): OpencodeImportResult { + const { store, engine, namespace, machine, transcript, existingSession } = options + let stored: StoredSession | null = existingSession === undefined + ? findImportedOpencodeSession(store, namespace, machine.id, transcript.id) + : existingSession + try { + const committed = store.runInTransaction(() => { + const created = !stored + if (stored) { + const current = store.sessions.getSessionByNamespace(stored.id, namespace) + if (!current) throw new Error('Imported HAPI session disappeared') + stored = current + } else { + stored = store.sessions.getOrCreateSession( + `opencode-import:${machine.id}:${transcript.id}`, + buildOpencodeMetadata(transcript, machine, {}), + {}, + namespace + ) + } + + const delta = classifyImportDelta(store.messages.getAllMessages(stored.id), transcript) + if (delta.error) { + return { + result: { + opencodeSessionId: transcript.id, + hapiSessionId: stored.id, + error: { code: 'transcript_diverged', message: delta.error } + } satisfies OpencodeImportResult, + appended: [] as StoredMessage[] + } + } + if (stored.active && delta.messages.length > 0) { + return { + result: { + opencodeSessionId: transcript.id, + hapiSessionId: stored.id, + appended: 0, + error: { code: 'session_active', message: 'The HAPI OpenCode session is active; stop it before importing native history changes' } + } satisfies OpencodeImportResult, + appended: [] as StoredMessage[] + } + } + + const appended: StoredMessage[] = [] + for (const source of delta.messages) { + const result = store.messages.addImportedMessage(stored.id, source.content, source.localId, source.createdAt) + if (result.inserted) appended.push(result.message) + } + updateMetadataWithRetry(store, stored.id, namespace, (metadata) => buildOpencodeMetadata(transcript, machine, metadata)) + return { + result: { + opencodeSessionId: transcript.id, + hapiSessionId: stored.id, + action: created ? 'created' : appended.length > 0 ? 'updated' : 'unchanged', + appended: appended.length + } satisfies OpencodeImportResult, + appended + } + }) + if (committed.result.error) return committed.result + const activityAt = committed.appended.at(-1)?.createdAt ?? transcript.modifiedAt + engine.recordSessionActivity(committed.result.hapiSessionId!, activityAt) + emitImportedMessages(engine, committed.result.hapiSessionId!, committed.appended) + engine.handleRealtimeEvent({ type: 'session-updated', sessionId: committed.result.hapiSessionId! }) + return committed.result + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to persist imported OpenCode history' + const code = error instanceof ImportedMessageConflictError ? 'transcript_diverged' : 'import_failed' + const surviving = stored ? store.sessions.getSessionByNamespace(stored.id, namespace) : null + return { + opencodeSessionId: transcript.id, + ...(surviving ? { hapiSessionId: surviving.id } : {}), + error: { code, message } + } + } +} + +async function importWithLock(key: string, work: () => Promise | OpencodeImportResult): Promise { + const prior = importLocks.get(key) ?? Promise.resolve() + let release!: () => void + const gate = new Promise((resolve) => { release = resolve }) + const current = prior.catch(() => {}).then(() => gate) + importLocks.set(key, current) + await prior.catch(() => {}) + try { + return await work() + } finally { + release() + if (importLocks.get(key) === current) importLocks.delete(key) + } +} + +export function createOpencodeSessionRoutes(options: { + store: Store + getSyncEngine: () => SyncEngine | null +}): Hono { + const app = new Hono() + + app.get('/opencode/sessions', async (c) => { + const namespace = c.get('namespace') + const machine = resolveOpencodeMachine(options.getSyncEngine(), namespace, c.req.query('machineId')?.trim() || null) + if (!machine) return c.json({ success: false, error: 'No online machine available for OpenCode history import', sessions: [] }, 503) + const result = await options.getSyncEngine()!.listOpencodeSessionsForMachine(machine.id, c.req.query('cwd')?.trim() || null) + if (!result.success) return c.json({ success: false, error: result.error, sessions: [], machineId: machine.id }, 503) + const importedByOpencodeId = importedOpencodeSessionsById(options.store, namespace, machine.id) + const sessions: OpencodeSessionListItem[] = result.sessions.map((summary) => { + const imported = importedByOpencodeId.get(summary.id) + return { + ...summary, + ...(imported ? { hapiSessionId: imported.id } : {}) + } + }) + return c.json({ success: true, sessions, machineId: machine.id }) + }) + + app.post('/opencode/import-sessions', async (c) => { + const body = asRecord(await c.req.json().catch(() => null)) + const rawSessionIds = Array.isArray(body?.sessionIds) ? body.sessionIds : [] + if (rawSessionIds.length > MAX_OPENCODE_SESSION_IMPORTS) { + return c.json({ success: false, error: `At most ${MAX_OPENCODE_SESSION_IMPORTS} OpenCode sessions can be imported at once`, results: [] }, 400) + } + const sessionIds = rawSessionIds + .filter((id): id is string => typeof id === 'string' && id.trim().length > 0) + .map((id) => id.trim()) + if (sessionIds.length === 0) return c.json({ success: false, error: 'No OpenCode sessions selected', results: [] }, 400) + const uniqueSessionIds = [...new Set(sessionIds)] + const namespace = c.get('namespace') + const engine = options.getSyncEngine() + const machine = resolveOpencodeMachine(engine, namespace, typeof body?.machineId === 'string' ? body.machineId.trim() : null) + if (!engine || !machine) return c.json({ success: false, error: 'No online machine available for OpenCode history import', results: [] }, 503) + const remote = await engine.listOpencodeSessionsForMachine( + machine.id, + typeof body?.cwd === 'string' ? body.cwd.trim() : null, + uniqueSessionIds + ) + if (!remote.success) return c.json({ success: false, error: remote.error, results: [], machineId: machine.id }, 503) + const byId = new Map(remote.sessions + .filter((session): session is OpencodeLocalSessionWithMessages => 'messages' in session) + .map((session) => [session.id, session])) + const requestedModel = typeof body?.model === 'string' && body.model.trim().length > 0 ? body.model.trim() : null + const requestedModelReasoningEffort = typeof body?.modelReasoningEffort === 'string' && body.modelReasoningEffort.trim().length > 0 + ? body.modelReasoningEffort.trim() + : null + const requestedPermissionMode = typeof body?.permissionMode === 'string' && body.permissionMode.trim().length > 0 + ? body.permissionMode.trim() + : null + if (requestedPermissionMode && !isPermissionModeAllowedForFlavor(requestedPermissionMode as PermissionMode, 'opencode')) { + return c.json({ success: false, error: 'Invalid permission mode for OpenCode sessions', results: [] }, 400) + } + type LaunchConfig = Parameters[1] + const launchConfig: LaunchConfig = {} + const hasLaunchKey = (key: string): boolean => Object.prototype.hasOwnProperty.call(body ?? {}, key) + // Property presence matters: applySessionConfig only resets a persisted + // value when the key is present, so explicit nulls / 'default' must be + // preserved to clear stale selections on re-import. + if (hasLaunchKey('model')) launchConfig.model = requestedModel + if (hasLaunchKey('modelReasoningEffort')) launchConfig.modelReasoningEffort = requestedModelReasoningEffort + if (hasLaunchKey('permissionMode')) launchConfig.permissionMode = (requestedPermissionMode ?? 'default') as PermissionMode + const results: OpencodeImportResult[] = [] + for (const sessionId of uniqueSessionIds) { + const transcript = byId.get(sessionId) + if (!transcript) { + results.push({ opencodeSessionId: sessionId, error: { code: 'not_found', message: 'OpenCode session transcript not found' } }) + continue + } + const result = await importWithLock(`${namespace}:${machine.id}:${sessionId}`, async () => { + const imported = importOpencodeSession({ + store: options.store, + engine, + namespace, + machine, + transcript + }) + if (!imported.error && imported.hapiSessionId && Object.keys(launchConfig).length > 0) { + // The transcript is already persisted here; a config failure + // must stay scoped to this session's result instead of turning + // into an unstructured 500 that aborts the rest of a bulk run. + try { + await engine.applySessionConfig(imported.hapiSessionId, launchConfig) + } catch (error) { + imported.error = { + code: 'config_failed', + message: error instanceof Error ? error.message : 'Failed to apply OpenCode launch config' + } + } + } + return imported + }) + results.push(result) + } + return c.json({ success: results.every((result) => !result.error), results, machineId: machine.id }) + }) + + return app +} diff --git a/hub/src/web/server.ts b/hub/src/web/server.ts index 3444b88c..e179508a 100644 --- a/hub/src/web/server.ts +++ b/hub/src/web/server.ts @@ -27,6 +27,7 @@ import { createGitRoutes } from './routes/git' import { createCliRoutes } from './routes/cli' import { createCodexDesktopRoutes } from './routes/codexDesktop' import { createPiSessionRoutes } from './routes/piSessions' +import { createOpencodeSessionRoutes } from './routes/opencodeSessions' import { createPushRoutes } from './routes/push' import { createDevicesRoutes } from './routes/devices' import { createVoiceRoutes } from './routes/voice' @@ -303,6 +304,10 @@ function createWebApp(options: { store: options.store, getSyncEngine: options.getSyncEngine })) + app.route('/api', createOpencodeSessionRoutes({ + store: options.store, + getSyncEngine: options.getSyncEngine + })) app.route('/api', createPushRoutes(options.store, options.vapidPublicKey)) app.route('/api', createDevicesRoutes(options.store)) app.route('/api', createVoiceRoutes({ dataDir: configuration.dataDir })) diff --git a/shared/src/apiTypes.ts b/shared/src/apiTypes.ts index 132200d7..a9ada3a2 100644 --- a/shared/src/apiTypes.ts +++ b/shared/src/apiTypes.ts @@ -287,6 +287,46 @@ export type PiLocalSessionWithMessages = z.infer export type ListPiSessionsRpcResponse = z.infer +export const OpencodeImportedMessageContentSchema = CodexImportedMessageSchema + +export const OpencodeImportedMessageSchema = z.object({ + localId: z.string().min(1), + createdAt: z.number(), + content: OpencodeImportedMessageContentSchema +}) + +export const OpencodeLocalSessionSummarySchema = z.object({ + id: z.string().min(1), + title: z.string(), + lastUserMessage: z.string().nullable().optional(), + cwd: z.string().nullable().optional(), + file: z.string().min(1), + modifiedAt: z.number() +}) + +export const OpencodeLocalSessionWithMessagesSchema = OpencodeLocalSessionSummarySchema.extend({ + messages: z.array(OpencodeImportedMessageSchema) +}) + +export const MAX_OPENCODE_SESSION_IMPORTS = 200 + +export const ListOpencodeSessionsRpcRequestSchema = z.object({ + cwd: z.string().nullable().optional(), + sessionIds: z.array(z.string().min(1)).max(MAX_OPENCODE_SESSION_IMPORTS).optional() +}) + +export const ListOpencodeSessionsRpcResponseSchema = z.union([ + z.object({ success: z.literal(true), sessions: z.array(z.union([OpencodeLocalSessionWithMessagesSchema, OpencodeLocalSessionSummarySchema])) }), + z.object({ success: z.literal(false), error: z.string() }) +]) + +export type OpencodeImportedMessageContent = z.infer +export type OpencodeImportedMessage = z.infer +export type OpencodeLocalSessionSummary = z.infer +export type OpencodeLocalSessionWithMessages = z.infer +export type ListOpencodeSessionsRpcRequest = z.infer +export type ListOpencodeSessionsRpcResponse = z.infer + export const SessionCollaborationModeRequestSchema = z.object({ mode: CodexCollaborationModeSchema }) diff --git a/shared/src/rpcMethods.ts b/shared/src/rpcMethods.ts index 3a2d6548..3e7c1239 100644 --- a/shared/src/rpcMethods.ts +++ b/shared/src/rpcMethods.ts @@ -44,6 +44,7 @@ export const RPC_METHODS = { ListPiSessions: 'listPiSessions', ListOpencodeModels: 'listOpencodeModels', ListOpencodeModelVariants: 'listOpencodeModelVariants', + ListOpencodeSessions: 'listOpencodeSessions', ListOpencodeModelsForCwd: 'listOpencodeModelsForCwd', ListGrokModelsForCwd: 'listGrokModelsForCwd', ListGrokModels: 'listGrokModels', diff --git a/web/src/api/client.ts b/web/src/api/client.ts index e7cc5ef0..387f0614 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -14,6 +14,8 @@ import type { FileSearchResponse, MachinesResponse, MessagesResponse, + OpencodeImportSessionsResponse, + OpencodeLocalSessionsResponse, PermissionMode, PiImportSessionsResponse, PiLocalSessionsResponse, @@ -316,6 +318,21 @@ export class ApiClient { }) } + async getOpencodeSessions(cwd?: string | null, machineId?: string | null): Promise { + const params = new URLSearchParams() + if (cwd?.trim()) params.set('cwd', cwd.trim()) + if (machineId?.trim()) params.set('machineId', machineId.trim()) + const query = params.size ? `?${params.toString()}` : '' + return await this.request(`/api/opencode/sessions${query}`) + } + + async importOpencodeSessions(payload: { sessionIds: string[]; cwd?: string | null; machineId?: string | null; model?: string | null; modelReasoningEffort?: string | null; permissionMode?: PermissionMode }): Promise { + return await this.request('/api/opencode/import-sessions', { + method: 'POST', + body: JSON.stringify(payload) + }) + } + async archiveCodexSession(sessionId: string, machineId?: string | null): Promise { return await this.request('/api/codex/archive-session', { method: 'POST', diff --git a/web/src/components/NewSession/OpencodeImportActions.test.tsx b/web/src/components/NewSession/OpencodeImportActions.test.tsx new file mode 100644 index 00000000..67e6a266 --- /dev/null +++ b/web/src/components/NewSession/OpencodeImportActions.test.tsx @@ -0,0 +1,26 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { OpencodeImportActions } from './OpencodeImportActions' + +vi.mock('@/lib/use-translation', () => ({ + useTranslation: () => ({ t: (key: string) => key }) +})) + +describe('OpencodeImportActions', () => { + it('opens the local OpenCode history picker', () => { + const onChooseHistory = vi.fn() + render( + + ) + + fireEvent.click(screen.getByRole('button', { name: 'opencodeSync.inline.choose' })) + expect(onChooseHistory).toHaveBeenCalledOnce() + }) +}) diff --git a/web/src/components/NewSession/OpencodeImportActions.tsx b/web/src/components/NewSession/OpencodeImportActions.tsx new file mode 100644 index 00000000..6d26d1a3 --- /dev/null +++ b/web/src/components/NewSession/OpencodeImportActions.tsx @@ -0,0 +1,41 @@ +import type { OpencodeLocalSessionSummary } from '@/types/api' +import { useTranslation } from '@/lib/use-translation' + +export function OpencodeImportActions(props: { + selectedSession: OpencodeLocalSessionSummary | null + isLoading: boolean + isDisabled: boolean + error: string | null + onChooseHistory: () => void + onClear: () => void +}) { + const { t } = useTranslation() + return ( +
+
+
+
{t('opencodeSync.inline.title')}
+
+ {props.selectedSession ? props.selectedSession.title : t('opencodeSync.inline.description')} +
+
+
+ {props.selectedSession ? ( + + ) : null} + +
+
+ {props.error ?
{props.error}
: null} +
+ ) +} diff --git a/web/src/components/NewSession/index.tsx b/web/src/components/NewSession/index.tsx index 26bb79ee..3fda8f21 100644 --- a/web/src/components/NewSession/index.tsx +++ b/web/src/components/NewSession/index.tsx @@ -1,6 +1,6 @@ import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react' import type { ApiClient } from '@/api/client' -import type { CodexDuplicateSessionGroup, CodexLocalSessionSummary, Machine, PiLocalSessionSummary } from '@/types/api' +import type { CodexDuplicateSessionGroup, CodexLocalSessionSummary, Machine, OpencodeLocalSessionSummary, PiLocalSessionSummary } from '@/types/api' import type { CodexCollaborationMode, GrokPermissionMode, PermissionMode, CopilotAgentMode } from '@hapi/protocol' import { getClaudeEffortLevelsForModel, resolveClaudeEffortForModel } from '@hapi/protocol' import { codexModelAdvertisesFastTier } from '@/components/AssistantChat/codexFastMode' @@ -52,6 +52,7 @@ import { AgentSelector } from './AgentSelector' import { CollaborationModeSelector } from './CollaborationModeSelector' import { CodexImportActions } from './CodexImportActions' import { PiImportActions } from './PiImportActions' +import { OpencodeImportActions } from './OpencodeImportActions' import { clearBatchImportedCodexSelection, resolveCodexImportRedirectSessionId } from './codexImportMerge' import { AgyModelSelector } from './AgyModelSelector' import { DirectorySection } from './DirectorySection' @@ -80,6 +81,7 @@ import { PermissionField } from './PermissionField' import { usesNativePermissionSelect, usesSharedPermissionModeState } from '@/lib/codexFamilyPermissionAgents' import { CodexSessionSyncDialog } from '@/components/CodexSessionSyncDialog' import { PiSessionImportDialog } from '@/components/PiSessionImportDialog' +import { OpencodeSessionImportDialog } from '@/components/OpencodeSessionImportDialog' import { ConfirmDialog } from '@/components/ui/ConfirmDialog' import { formatRunnerSpawnError } from '../../utils/formatRunnerSpawnError' import { markCodexSessionsImported } from '@/lib/codexImportedSessions' @@ -150,6 +152,15 @@ export function NewSession(props: { const [isBulkImportingPiSessions, setIsBulkImportingPiSessions] = useState(false) const [isPiImportDialogOpen, setIsPiImportDialogOpen] = useState(false) const piLoadGenerationRef = useRef(0) + const [opencodeImportSessions, setOpencodeImportSessions] = useState([]) + const [selectedOpencodeImportSessionId, setSelectedOpencodeImportSessionId] = useState(null) + const [opencodeImportMachineId, setOpencodeImportMachineId] = useState(null) + const [isLoadingOpencodeImportSessions, setIsLoadingOpencodeImportSessions] = useState(false) + const [opencodeImportError, setOpencodeImportError] = useState(null) + const [isImportingOpencodeSession, setIsImportingOpencodeSession] = useState(false) + const [isBulkImportingOpencodeSessions, setIsBulkImportingOpencodeSessions] = useState(false) + const [isOpencodeImportDialogOpen, setIsOpencodeImportDialogOpen] = useState(false) + const opencodeLoadGenerationRef = useRef(0) const [isCreating, setIsCreating] = useState(false) const createInFlightRef = useRef(false) const [isBulkImportingCodexSessions, setIsBulkImportingCodexSessions] = useState(false) @@ -167,6 +178,8 @@ export function NewSession(props: { || isBulkImportingCodexSessions || isImportingPiSession || isBulkImportingPiSessions + || isImportingOpencodeSession + || isBulkImportingOpencodeSessions ) const worktreeInputRef = useRef(null) const preserveRestoredDraftRef = useRef(false) @@ -1165,6 +1178,44 @@ export function NewSession(props: { return message?.trim() || t('piImport.failed.body') }, [t]) + useEffect(() => { + opencodeLoadGenerationRef.current += 1 + setIsLoadingOpencodeImportSessions(false) + }, [agent, machineId, trimmedDirectory]) + + useEffect(() => () => { + opencodeLoadGenerationRef.current += 1 + }, []) + + const loadOpencodeImportSessions = useCallback(async () => { + if (agent !== 'opencode' || !machineId) return + const generation = ++opencodeLoadGenerationRef.current + setIsLoadingOpencodeImportSessions(true) + setOpencodeImportError(null) + try { + const result = await props.api.getOpencodeSessions(trimmedDirectory || null, machineId) + if (generation !== opencodeLoadGenerationRef.current) return + if (!result.success) throw new Error(result.error) + setOpencodeImportSessions(result.sessions) + setOpencodeImportMachineId(result.machineId ?? machineId) + setSelectedOpencodeImportSessionId((current) => current && result.sessions.some((session) => session.id === current) ? current : null) + } catch (loadError) { + if (generation !== opencodeLoadGenerationRef.current) return + setOpencodeImportSessions([]) + setOpencodeImportMachineId(null) + setSelectedOpencodeImportSessionId(null) + setOpencodeImportError(loadError instanceof Error ? loadError.message : t('opencodeSync.failed.body')) + } finally { + if (generation === opencodeLoadGenerationRef.current) setIsLoadingOpencodeImportSessions(false) + } + }, [agent, machineId, props.api, trimmedDirectory, t]) + + const formatOpencodeImportError = useCallback((code?: string, message?: string): string => { + if (code === 'transcript_diverged') return t('opencodeSync.error.diverged') + if (code === 'session_active') return t('opencodeSync.error.active') + return message?.trim() || t('opencodeSync.failed.body') + }, [t]) + const normalizeCodexScriptError = useCallback((message: string | null | undefined, fallback: string): string => { const raw = (message ?? '').trim() if (!raw) return fallback @@ -1419,6 +1470,66 @@ export function NewSession(props: { trimmedDirectory ]) + const handleBulkImportOpencodeSessions = useCallback(async (sessionIds: string[]) => { + if (isBulkImportingOpencodeSessions || isLoadingOpencodeImportSessions) return + setIsBulkImportingOpencodeSessions(true) + try { + const result = await props.api.importOpencodeSessions({ + sessionIds, + cwd: trimmedDirectory || null, + machineId: opencodeImportMachineId ?? machineId + }) + const importedCount = result.results.filter((item) => item.hapiSessionId && !item.error && (item.action === 'created' || (item.appended ?? 0) > 0)).length + const failed = result.results.filter((item) => item.error) + if (importedCount > 0) { + addToast({ + title: t('opencodeSync.success.title'), + body: t('opencodeSync.success.body', { n: importedCount }), + sessionId: '', + url: '' + }) + await refetchSessions() + await loadOpencodeImportSessions() + } + if (failed.length > 0) { + const first = failed[0]!.error! + addToast({ + title: t('opencodeSync.failed.title'), + body: t('opencodeSync.failed.partial', { + failed: failed.length, + reason: formatOpencodeImportError(first.code, first.message) + }), + sessionId: '', + url: '' + }) + return + } + setIsOpencodeImportDialogOpen(false) + setSelectedOpencodeImportSessionId(null) + } catch (importError) { + addToast({ + title: t('opencodeSync.failed.title'), + body: importError instanceof Error ? importError.message : t('opencodeSync.failed.body'), + sessionId: '', + url: '' + }) + } finally { + setIsBulkImportingOpencodeSessions(false) + } + }, [ + addToast, + formatOpencodeImportError, + isBulkImportingOpencodeSessions, + isLoadingOpencodeImportSessions, + loadOpencodeImportSessions, + machineId, + opencodeImportMachineId, + props.api, + refetchSessions, + t, + trimmedDirectory + ]) + const selectedCodexImportSession = useMemo( () => codexImportSessions.find((session) => session.id === selectedCodexImportSessionId) ?? null, [codexImportSessions, selectedCodexImportSessionId] @@ -1427,6 +1538,10 @@ export function NewSession(props: { () => piImportSessions.find((session) => session.id === selectedPiImportSessionId) ?? null, [piImportSessions, selectedPiImportSessionId] ) + const selectedOpencodeImportSession = useMemo( + () => opencodeImportSessions.find((session) => session.id === selectedOpencodeImportSessionId) ?? null, + [opencodeImportSessions, selectedOpencodeImportSessionId] + ) // Pi history import reopens the native session as-is; the launch-only // model/effort controls would silently not apply, so hide them. const showPiLaunchConfig = agent !== 'pi' || !selectedPiImportSession @@ -1447,6 +1562,9 @@ export function NewSession(props: { setSelectedPiImportSessionId(null) setPiImportSessions([]) setPiImportMachineId(null) + setSelectedOpencodeImportSessionId(null) + setOpencodeImportSessions([]) + setOpencodeImportMachineId(null) const paths = getRecentPaths(newMachineId) if (paths[0]) { setDirectory(paths[0]) @@ -1546,6 +1664,11 @@ export function NewSession(props: { if (session.cwd?.trim()) setDirectory(session.cwd.trim()) }, []) + const handleSelectOpencodeImportSession = useCallback((session: OpencodeLocalSessionSummary) => { + setSelectedOpencodeImportSessionId(session.id) + if (session.cwd?.trim()) setDirectory(session.cwd.trim()) + }, []) + const handlePathClick = useCallback((path: string) => { setDirectory(path) }, []) @@ -1741,6 +1864,34 @@ export function NewSession(props: { return } + if (agent === 'opencode' && selectedOpencodeImportSession) { + setIsImportingOpencodeSession(true) + const result = await props.api.importOpencodeSessions({ + sessionIds: [selectedOpencodeImportSession.id], + cwd: selectedOpencodeImportSession.cwd ?? trimmedDirectory, + machineId: opencodeImportMachineId ?? machineId, + model: resolvedModel ?? null, + modelReasoningEffort: resolvedModelReasoningEffort ?? null, + permissionMode: nativePermissionMode + }) + const imported = result.results.find((item) => item.opencodeSessionId === selectedOpencodeImportSession.id) + if (imported?.error) { + setIsImportingOpencodeSession(false) + haptic.notification('error') + setError(formatOpencodeImportError(imported.error.code, imported.error.message)) + return + } + if (!imported?.hapiSessionId) throw new Error(result.error || t('opencodeSync.failed.body')) + const reopened = await props.api.reopenSession(imported.hapiSessionId) + haptic.notification('success') + savePreferredLaunchSettings(machineId, agent, preferredLaunchSettings) + clearNewSessionFormDraft() + setLastUsedMachineId(machineId) + addRecentPath(machineId, trimmedDirectory) + props.onSuccess(reopened.sessionId) + return + } + const result = await spawnSession({ machineId, directory: trimmedDirectory, @@ -1777,6 +1928,7 @@ export function NewSession(props: { } catch (e) { setIsImportingCodexSession(false) setIsImportingPiSession(false) + setIsImportingOpencodeSession(false) haptic.notification('error') setError(e instanceof Error ? e.message : 'Failed to create session') } finally { @@ -1935,6 +2087,19 @@ export function NewSession(props: { onClear={() => setSelectedPiImportSessionId(null)} /> ) : null} + {agent === 'opencode' ? ( + { + setIsOpencodeImportDialogOpen(true) + void loadOpencodeImportSessions() + }} + onClear={() => setSelectedOpencodeImportSessionId(null)} + /> + ) : null} {agent === 'dsh' ? ( + setIsOpencodeImportDialogOpen(false)} + sessions={opencodeImportSessions} + currentSessionId={selectedOpencodeImportSessionId} + currentWorkDirectory={trimmedDirectory} + onConfirm={async (sessionIds) => { + if (sessionIds.length === 1) { + const session = opencodeImportSessions.find((candidate) => candidate.id === sessionIds[0]) + if (session) { + handleSelectOpencodeImportSession(session) + setIsOpencodeImportDialogOpen(false) + } + return + } + await handleBulkImportOpencodeSessions(sessionIds) + }} + isPending={isBulkImportingOpencodeSessions} + isLoading={isLoadingOpencodeImportSessions} + /> 0} onClose={closeDuplicateMergeDialog} diff --git a/web/src/components/OpencodeSessionImportDialog.tsx b/web/src/components/OpencodeSessionImportDialog.tsx new file mode 100644 index 00000000..15740c24 --- /dev/null +++ b/web/src/components/OpencodeSessionImportDialog.tsx @@ -0,0 +1,126 @@ +import { useEffect, useMemo, useState } from 'react' +import type { OpencodeLocalSessionSummary } from '@/types/api' +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { SelectControl } from '@/components/ui/select-control' +import { useTranslation } from '@/lib/use-translation' + +const ALL_DIRECTORIES = '__all__' + +export function OpencodeSessionImportDialog(props: { + isOpen: boolean + onClose: () => void + sessions: OpencodeLocalSessionSummary[] + currentSessionId: string | null + currentWorkDirectory?: string | null + onConfirm: (sessionIds: string[]) => Promise + isPending: boolean + isLoading: boolean +}) { + const { t } = useTranslation() + const [selectedIds, setSelectedIds] = useState([]) + const [directory, setDirectory] = useState(ALL_DIRECTORIES) + const [query, setQuery] = useState('') + + const directories = useMemo(() => Array.from(new Set(props.sessions + .map((session) => session.cwd?.trim()) + .filter((value): value is string => Boolean(value)))).sort(), [props.sessions]) + const filtered = useMemo(() => { + const normalized = query.trim().toLowerCase() + return props.sessions.filter((session) => { + if (directory !== ALL_DIRECTORIES && session.cwd !== directory) return false + if (!normalized) return true + return [session.title, session.lastUserMessage, session.cwd, session.id] + .filter((value): value is string => typeof value === 'string') + .some((value) => value.toLowerCase().includes(normalized)) + }) + }, [directory, props.sessions, query]) + + useEffect(() => { + if (!props.isOpen) return + setSelectedIds(props.currentSessionId ? [props.currentSessionId] : []) + const cwd = props.currentWorkDirectory?.trim() + setDirectory(cwd && directories.includes(cwd) ? cwd : ALL_DIRECTORIES) + setQuery('') + }, [directories, props.currentSessionId, props.currentWorkDirectory, props.isOpen]) + + const toggle = (id: string) => { + if (props.isPending || props.isLoading) return + setSelectedIds((current) => current.includes(id) ? current.filter((value) => value !== id) : [...current, id]) + } + + return ( + !open && props.onClose()}> + + + {t('opencodeSync.dialog.title')} + {t('opencodeSync.dialog.description')} + +
+
+ {t('opencodeSync.dialog.concurrentWarning')} +
+
+ {t('opencodeSync.selectedCount', { n: selectedIds.length })} +
+ + +
+
+ {props.sessions.length > 0 ? ( +
+ setDirectory(event.target.value)}> + + {directories.map((value) => )} + + setQuery(event.target.value)} + /> +
+ ) : null} +
+ {props.isLoading ? ( +
{t('opencodeSync.loading')}
+ ) : filtered.length === 0 ? ( +
{t('opencodeSync.empty')}
+ ) : ( +
+ {filtered.map((session) => { + const checked = selectedIds.includes(session.id) + return ( + + ) + })} +
+ )} +
+
+ + +
+
+
+
+ ) +} diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index a74045ca..4de14f8b 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -134,6 +134,30 @@ export default { 'piImport.manual.success.body': 'Synced {n} new Pi history items.', 'piImport.manual.success.noNewMessages': 'Pi history is already up to date.', 'piImport.manual.failed.title': 'Pi history sync failed', + 'opencodeSync.inline.title': 'Import OpenCode history', + 'opencodeSync.inline.description': 'Optional: choose an existing local OpenCode session to import and continue.', + 'opencodeSync.inline.clear': 'Clear', + 'opencodeSync.inline.choose': 'Choose history', + 'opencodeSync.dialog.title': 'Import OpenCode sessions', + 'opencodeSync.dialog.description': 'Select local OpenCode histories. One selection is continued now; multiple selections are imported in the background.', + 'opencodeSync.dialog.concurrentWarning': 'Multiple processes can open the same OpenCode session, but avoid writing from stale copies. Before continuing in HAPI, reload or close any other OpenCode process to avoid an unintended branch.', + 'opencodeSync.loading': 'Scanning OpenCode sessions…', + 'opencodeSync.empty': 'No importable OpenCode sessions found.', + 'opencodeSync.selectedCount': '{n} selected', + 'opencodeSync.clearAll': 'Clear all', + 'opencodeSync.selectAll': 'Select all', + 'opencodeSync.allDirectories': 'All directories', + 'opencodeSync.search': 'Search title, prompt, path, or ID', + 'opencodeSync.imported': 'Imported', + 'opencodeSync.importing': 'Importing…', + 'opencodeSync.confirm': 'Import', + 'opencodeSync.success.title': 'OpenCode sessions imported', + 'opencodeSync.success.body': 'Imported {n} OpenCode sessions.', + 'opencodeSync.failed.title': 'OpenCode import failed', + 'opencodeSync.failed.body': 'Failed to import OpenCode history.', + 'opencodeSync.failed.partial': '{failed} sessions failed: {reason}', + 'opencodeSync.error.diverged': 'The local OpenCode branch changed after the previous import. Review the session before continuing.', + 'opencodeSync.error.active': 'Stop the active Hapi OpenCode session before importing newer native history.', // 中文注释:以下文案支撑导入弹窗的多选列表、加载态,以及右上角重启 Codex 客户端按钮提示。 'codexSync.confirm.selectAll': 'Select all', 'codexSync.confirm.clearAll': 'Clear all', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index bc87a509..145e7d76 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -134,6 +134,30 @@ export default { 'piImport.manual.success.body': '已同步 {n} 条新的 Pi 历史记录。', 'piImport.manual.success.noNewMessages': 'Pi 历史已经是最新状态。', 'piImport.manual.failed.title': 'Pi 历史同步失败', + 'opencodeSync.inline.title': '导入 OpenCode 历史', + 'opencodeSync.inline.description': '可选:选择本机已有的 OpenCode session,导入历史后继续对话。', + 'opencodeSync.inline.clear': '清除', + 'opencodeSync.inline.choose': '选择历史', + 'opencodeSync.dialog.title': '导入 OpenCode sessions', + 'opencodeSync.dialog.description': '选择本机 OpenCode 历史。单选会立即继续,多选会批量导入到会话列表。', + 'opencodeSync.dialog.concurrentWarning': '多个进程可以同时打开同一个 OpenCode session,但不要从未刷新的副本交替写入。继续在 HAPI 中操作前,请重新加载或关闭其他 OpenCode 进程,避免产生意外分支。', + 'opencodeSync.loading': '正在扫描 OpenCode sessions…', + 'opencodeSync.empty': '没有找到可导入的 OpenCode session。', + 'opencodeSync.selectedCount': '已选择 {n} 个', + 'opencodeSync.clearAll': '全部清除', + 'opencodeSync.selectAll': '全部选择', + 'opencodeSync.allDirectories': '全部目录', + 'opencodeSync.search': '搜索标题、消息、路径或 ID', + 'opencodeSync.imported': '已导入', + 'opencodeSync.importing': '正在导入…', + 'opencodeSync.confirm': '导入', + 'opencodeSync.success.title': 'OpenCode sessions 已导入', + 'opencodeSync.success.body': '已导入 {n} 个 OpenCode sessions。', + 'opencodeSync.failed.title': 'OpenCode 导入失败', + 'opencodeSync.failed.body': '导入 OpenCode 历史失败。', + 'opencodeSync.failed.partial': '{failed} 个 session 导入失败:{reason}', + 'opencodeSync.error.diverged': '本机 OpenCode 分支在上次导入后发生变化,请先检查会话再继续。', + 'opencodeSync.error.active': '请先停止正在运行的 Hapi OpenCode session,再导入新增的原生历史。', // 中文注释:以下文案支撑导入弹窗的多选列表、加载态,以及右上角重启 Codex 客户端按钮提示。 'codexSync.confirm.selectAll': '全选', 'codexSync.confirm.clearAll': '全取消', diff --git a/web/src/types/api.ts b/web/src/types/api.ts index 0563ed43..ca4345dd 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -290,6 +290,42 @@ export type PiImportSessionsResponse = { error?: string } +export type OpencodeLocalSessionSummary = { + id: string + title: string + lastUserMessage?: string | null + cwd?: string | null + file: string + modifiedAt: number + hapiSessionId?: string +} + +export type OpencodeLocalSessionsResponse = { + success: true + sessions: OpencodeLocalSessionSummary[] + machineId: string +} | { + success: false + error: string + sessions: [] + machineId?: string +} + +export type OpencodeImportResult = { + opencodeSessionId: string + hapiSessionId?: string + action?: 'created' | 'updated' | 'unchanged' + appended?: number + error?: { code: string; message: string } +} + +export type OpencodeImportSessionsResponse = { + success: boolean + results: OpencodeImportResult[] + machineId?: string + error?: string +} + export type CodexArchiveSessionResponse = { success: true