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