diff --git a/README.md b/README.md index 6d0a1c0..348a95f 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ This launches an interactive setup wizard that configures your project for AI-as | [GitHub Copilot](https://code.visualstudio.com/) | ✅ Supported | ❌ Not Ready | | [Gemini CLI](https://github.com/google-gemini/gemini-cli) | ✅ Supported | ✅ Ready | | [Cursor](https://cursor.sh/) | ✅ Supported | ❌ Not Ready | -| [opencode](https://opencode.ai/) | ✅ Supported | ❌ Not Ready | +| [opencode](https://opencode.ai/) | ✅ Supported | 🚧 Testing | | [Antigravity](https://antigravity.google/) | ✅ Supported | ❌ Not Ready | | [Codex CLI](https://github.com/openai/codex) | ✅ Supported | ✅ Ready | | [Windsurf](https://windsurf.com/) | 🚧 Testing | ❌ Not Ready | diff --git a/package-lock.json b/package-lock.json index 577d853..a16a81b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ai-devkit", - "version": "0.28.0", + "version": "0.29.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ai-devkit", - "version": "0.28.0", + "version": "0.29.0", "license": "MIT", "workspaces": [ "apps/*", @@ -13158,7 +13158,11 @@ "name": "@ai-devkit/agent-manager", "version": "0.12.0", "license": "MIT", + "dependencies": { + "better-sqlite3": "^12.6.2" + }, "devDependencies": { + "@types/better-sqlite3": "^7.6.11", "@types/jest": "^30.0.0", "@types/node": "^20.11.5", "@typescript-eslint/eslint-plugin": "^6.19.1", diff --git a/packages/agent-manager/package.json b/packages/agent-manager/package.json index 1c3eefc..8d892e1 100644 --- a/packages/agent-manager/package.json +++ b/packages/agent-manager/package.json @@ -26,7 +26,11 @@ ], "author": "", "license": "MIT", + "dependencies": { + "better-sqlite3": "^12.6.2" + }, "devDependencies": { + "@types/better-sqlite3": "^7.6.11", "@types/jest": "^30.0.0", "@types/node": "^20.11.5", "@typescript-eslint/eslint-plugin": "^6.19.1", diff --git a/packages/agent-manager/src/__tests__/adapters/OpenCodeAdapter.test.ts b/packages/agent-manager/src/__tests__/adapters/OpenCodeAdapter.test.ts new file mode 100644 index 0000000..8b0d66c --- /dev/null +++ b/packages/agent-manager/src/__tests__/adapters/OpenCodeAdapter.test.ts @@ -0,0 +1,475 @@ +/** + * Tests for OpenCodeAdapter + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { beforeEach, afterEach, describe, expect, it, jest } from '@jest/globals'; +import { OpenCodeAdapter } from '../../adapters/OpenCodeAdapter'; +import type { ProcessInfo } from '../../adapters/AgentAdapter'; +import { AgentStatus } from '../../adapters/AgentAdapter'; +import { listAgentProcesses, enrichProcesses } from '../../utils/process'; +import { generateAgentName } from '../../utils/matching'; + +jest.mock('../../utils/process', () => ({ + listAgentProcesses: jest.fn(), + enrichProcesses: jest.fn(), +})); + +jest.mock('../../utils/matching', () => ({ + generateAgentName: jest.fn(), + matchProcessesToSessions: jest.fn(), +})); + +const mockedListAgentProcesses = listAgentProcesses as jest.MockedFunction; +const mockedEnrichProcesses = enrichProcesses as jest.MockedFunction; +const mockedGenerateAgentName = generateAgentName as jest.MockedFunction; + +function makeDb(queries: { + session?: Array<{ id: string; directory: string; time_created: number }>; + lastMessage?: { role: string; timeUpdated: number } | null; + lastAssistant?: { completed: number | null; errored: number | null } | null; + firstUserText?: { text: string } | null; + parts?: Array<{ role: string; partData: string; timeCreated: number }>; +}) { + const prepareImpl = (sql: string) => { + const normalized = sql.replace(/\s+/g, ' ').trim().toLowerCase(); + + if (normalized.includes('from session')) { + return { + all: () => (queries.session ?? []).map((r) => ({ + id: r.id, directory: r.directory, timeCreated: r.time_created, + })), + get: (dir: string) => { + const match = (queries.session ?? []).find((r) => r.directory === dir); + if (!match) return undefined; + return { id: match.id, directory: match.directory, time_created: match.time_created }; + }, + }; + } + + if (normalized.includes('max(time_updated)')) { + return { + get: () => ({ maxUpdated: queries.lastMessage?.timeUpdated ?? 0 }), + }; + } + + if (normalized.includes('from message') && !normalized.includes("'$.time.completed'") && !normalized.includes('order by p.time_created')) { + return { + get: () => queries.lastMessage ?? undefined, + }; + } + + if (normalized.includes("'$.time.completed'")) { + return { + get: () => queries.lastAssistant === undefined + ? undefined + : queries.lastAssistant ?? undefined, + }; + } + + if (normalized.includes("json_extract(m.data, '$.role') = 'user'")) { + return { + get: () => queries.firstUserText === undefined + ? undefined + : queries.firstUserText ?? undefined, + }; + } + + if (normalized.includes('order by p.time_created asc')) { + return { + all: () => queries.parts ?? [], + }; + } + + return { all: () => [], get: () => undefined }; + }; + + return { prepare: prepareImpl, close: jest.fn() }; +} + +function makeDbConstructor(db: ReturnType) { + return jest.fn().mockReturnValue(db); +} + +describe('OpenCodeAdapter', () => { + let adapter: OpenCodeAdapter; + let tmpDir: string; + let dbPath: string; + + beforeEach(() => { + adapter = new OpenCodeAdapter(); + + mockedListAgentProcesses.mockReset(); + mockedEnrichProcesses.mockReset(); + mockedGenerateAgentName.mockReset(); + + mockedEnrichProcesses.mockImplementation((procs) => procs); + mockedGenerateAgentName.mockImplementation((cwd, pid) => { + const folder = path.basename(cwd) || 'unknown'; + return `${folder}-${pid}`; + }); + + tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'opencode-test-')); + dbPath = path.join(tmpDir, 'opencode.db'); + (adapter as any).dbPath = dbPath; + (adapter as any).db = null; + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + jest.restoreAllMocks(); + }); + + describe('type', () => { + it('exposes opencode type', () => { + expect(adapter.type).toBe('opencode'); + }); + }); + + describe('canHandle', () => { + it('returns true for opencode command', () => { + expect(adapter.canHandle({ pid: 1, command: 'opencode', cwd: '/repo', tty: 'ttys001' })).toBe(true); + }); + + it('returns true for full path opencode', () => { + expect(adapter.canHandle({ pid: 2, command: '/usr/local/bin/opencode serve', cwd: '/repo', tty: 'ttys002' })).toBe(true); + }); + + it('returns true for opencode.exe with unix-style path', () => { + expect(adapter.canHandle({ pid: 3, command: '/usr/bin/opencode.exe', cwd: '/repo', tty: 'ttys003' })).toBe(true); + }); + + it('returns false for non-opencode processes', () => { + expect(adapter.canHandle({ pid: 4, command: 'node server.js', cwd: '/repo', tty: 'ttys004' })).toBe(false); + }); + + it('returns false when opencode appears only in path args', () => { + expect(adapter.canHandle({ + pid: 5, + command: 'node /projects/opencode-plugin/index.js', + cwd: '/repo', + tty: 'ttys005', + })).toBe(false); + }); + }); + + describe('detectAgents', () => { + it('returns empty list when no opencode processes running', async () => { + mockedListAgentProcesses.mockReturnValue([]); + + const agents = await adapter.detectAgents(); + + expect(agents).toEqual([]); + expect(mockedListAgentProcesses).toHaveBeenCalledWith('opencode'); + }); + + it('returns process-only agent when DB does not exist', async () => { + const procs: ProcessInfo[] = [ + { pid: 100, command: 'opencode', cwd: '/repo', tty: 'ttys001' }, + ]; + mockedListAgentProcesses.mockReturnValue(procs); + mockedEnrichProcesses.mockReturnValue(procs); + + const agents = await adapter.detectAgents(); + + expect(agents).toHaveLength(1); + expect(agents[0]).toMatchObject({ + type: 'opencode', + status: AgentStatus.RUNNING, + pid: 100, + projectPath: '/repo', + sessionId: 'pid-100', + summary: 'OpenCode process running', + }); + }); + + it('returns process-only agent when no session matches CWD', async () => { + const procs: ProcessInfo[] = [ + { pid: 100, command: 'opencode', cwd: '/repo', tty: 'ttys001' }, + ]; + mockedListAgentProcesses.mockReturnValue(procs); + mockedEnrichProcesses.mockReturnValue(procs); + + fs.writeFileSync(dbPath, ''); // file exists but empty → sqlite throws + const db = makeDb({ session: [] }); // no matching session + jest.mock('better-sqlite3', () => makeDbConstructor(db)); + (adapter as any).db = db; + + const agents = await adapter.detectAgents(); + + expect(agents).toHaveLength(1); + expect(agents[0]).toMatchObject({ + type: 'opencode', + status: AgentStatus.RUNNING, + sessionId: 'pid-100', + }); + }); + + it('returns waiting when assistant turn has time.completed set', async () => { + const now = Date.now(); + const procs: ProcessInfo[] = [ + { pid: 200, command: 'opencode', cwd: '/my-project', tty: 'ttys002' }, + ]; + mockedListAgentProcesses.mockReturnValue(procs); + mockedEnrichProcesses.mockReturnValue(procs); + + const db = makeDb({ + session: [{ id: 'sess-001', directory: '/my-project', time_created: now - 60000 }], + lastMessage: { role: 'assistant', timeUpdated: now - 60_000 }, + lastAssistant: { completed: now - 30_000, errored: null }, + firstUserText: { text: 'Refactor the auth module' }, + }); + (adapter as any).db = db; + + const agents = await adapter.detectAgents(); + + expect(agents).toHaveLength(1); + expect(agents[0]).toMatchObject({ + type: 'opencode', + status: AgentStatus.WAITING, + sessionId: 'sess-001', + summary: 'Refactor the auth module', + }); + }); + + it('returns running when assistant turn has no time.completed (in-progress, any age)', async () => { + const now = Date.now(); + const procs: ProcessInfo[] = [ + { pid: 250, command: 'opencode', cwd: '/proj', tty: 'ttys004' }, + ]; + mockedListAgentProcesses.mockReturnValue(procs); + mockedEnrichProcesses.mockReturnValue(procs); + + const db = makeDb({ + session: [{ id: 'sess-tool', directory: '/proj', time_created: now - 180_000 }], + lastMessage: { role: 'assistant', timeUpdated: now - 120_000 }, + lastAssistant: { completed: null, errored: null }, + firstUserText: { text: 'Run the build' }, + }); + (adapter as any).db = db; + + const agents = await adapter.detectAgents(); + + expect(agents[0]).toMatchObject({ + status: AgentStatus.RUNNING, + sessionId: 'sess-tool', + }); + }); + + it('returns running between steps (the bug fix) — no time.completed even during quiet moment', async () => { + const now = Date.now(); + const procs: ProcessInfo[] = [ + { pid: 260, command: 'opencode', cwd: '/proj-b', tty: 'ttys005' }, + ]; + mockedListAgentProcesses.mockReturnValue(procs); + mockedEnrichProcesses.mockReturnValue(procs); + + const db = makeDb({ + session: [{ id: 'sess-mid', directory: '/proj-b', time_created: now - 60_000 }], + lastMessage: { role: 'assistant', timeUpdated: now - 45_000 }, + lastAssistant: { completed: null, errored: null }, + firstUserText: null, + }); + (adapter as any).db = db; + + const agents = await adapter.detectAgents(); + expect(agents[0].status).toBe(AgentStatus.RUNNING); + }); + + it('returns waiting even when latest user message was metadata-updated after assistant completion', async () => { + // Regression: OpenCode updates user.message.time_updated when appending + // summary diffs after a turn finishes. Ordering by time_created (not + // time_updated) keeps the assistant message correctly identified as latest. + const now = Date.now(); + const procs: ProcessInfo[] = [ + { pid: 270, command: 'opencode', cwd: '/proj-c', tty: 'ttys006' }, + ]; + mockedListAgentProcesses.mockReturnValue(procs); + mockedEnrichProcesses.mockReturnValue(procs); + + // makeDb returns lastMessage from the time_created-ordered query — supply the assistant. + const db = makeDb({ + session: [{ id: 'sess-meta', directory: '/proj-c', time_created: now - 120_000 }], + lastMessage: { role: 'assistant', timeUpdated: now - 30_000 }, + lastAssistant: { completed: now - 30_000, errored: null }, + firstUserText: null, + }); + (adapter as any).db = db; + + const agents = await adapter.detectAgents(); + expect(agents[0].status).toBe(AgentStatus.WAITING); + }); + + it('returns running agent when last role is user (no assistant message yet)', async () => { + const now = Date.now(); + const procs: ProcessInfo[] = [ + { pid: 300, command: 'opencode', cwd: '/work', tty: 'ttys003' }, + ]; + mockedListAgentProcesses.mockReturnValue(procs); + mockedEnrichProcesses.mockReturnValue(procs); + + const db = makeDb({ + session: [{ id: 'sess-002', directory: '/work', time_created: now - 30000 }], + lastMessage: { role: 'user', timeUpdated: now - 30_000 }, + lastAssistant: null, + firstUserText: { text: 'Add unit tests' }, + }); + (adapter as any).db = db; + + const agents = await adapter.detectAgents(); + + expect(agents[0]).toMatchObject({ + status: AgentStatus.RUNNING, + sessionId: 'sess-002', + }); + }); + + it('returns idle agent when last activity exceeds threshold', async () => { + const now = Date.now(); + const staleTime = now - 10 * 60 * 1000; // 10 minutes ago + const procs: ProcessInfo[] = [ + { pid: 400, command: 'opencode', cwd: '/old-work', tty: 'ttys004' }, + ]; + mockedListAgentProcesses.mockReturnValue(procs); + mockedEnrichProcesses.mockReturnValue(procs); + + const db = makeDb({ + session: [{ id: 'sess-003', directory: '/old-work', time_created: staleTime }], + lastMessage: { role: 'assistant', timeUpdated: staleTime }, + lastAssistant: { completed: staleTime, errored: null }, + firstUserText: null, + }); + (adapter as any).db = db; + + const agents = await adapter.detectAgents(); + + expect(agents[0]).toMatchObject({ + status: AgentStatus.IDLE, + sessionId: 'sess-003', + }); + }); + }); + + describe('getConversation', () => { + it('returns empty array for invalid session ref', () => { + const messages = adapter.getConversation('/no-separator-here'); + expect(messages).toEqual([]); + }); + + it('returns text messages from session parts', () => { + const db = makeDb({ + parts: [ + { role: 'user', partData: JSON.stringify({ type: 'text', text: 'Hello agent' }), timeCreated: 1000 }, + { role: 'assistant', partData: JSON.stringify({ type: 'text', text: 'Hi, how can I help?' }), timeCreated: 2000 }, + ], + }); + (adapter as any).db = db; + + const ref = `${dbPath}::sess-abc`; + const messages = adapter.getConversation(ref); + + expect(messages).toHaveLength(2); + expect(messages[0]).toEqual({ role: 'user', content: 'Hello agent' }); + expect(messages[1]).toEqual({ role: 'assistant', content: 'Hi, how can I help?' }); + }); + + it('skips reasoning parts when verbose is false', () => { + const db = makeDb({ + parts: [ + { role: 'assistant', partData: JSON.stringify({ type: 'reasoning', reasoning: 'internal thought' }), timeCreated: 1000 }, + { role: 'assistant', partData: JSON.stringify({ type: 'text', text: 'Answer' }), timeCreated: 2000 }, + ], + }); + (adapter as any).db = db; + + const messages = adapter.getConversation(`${dbPath}::sess-x`); + expect(messages).toHaveLength(1); + expect(messages[0].content).toBe('Answer'); + }); + + it('includes reasoning and tool parts when verbose is true', () => { + const db = makeDb({ + parts: [ + { role: 'assistant', partData: JSON.stringify({ type: 'reasoning', reasoning: 'my thinking' }), timeCreated: 1000 }, + { role: 'assistant', partData: JSON.stringify({ type: 'tool', tool: 'read_file' }), timeCreated: 2000 }, + ], + }); + (adapter as any).db = db; + + const messages = adapter.getConversation(`${dbPath}::sess-y`, { verbose: true }); + expect(messages).toHaveLength(2); + expect(messages[0].content).toContain('my thinking'); + expect(messages[1].content).toContain('read_file'); + }); + + it('returns empty array when DB cannot be opened', () => { + (adapter as any).db = null; + const messages = adapter.getConversation(`${dbPath}::sess-z`); + expect(messages).toEqual([]); + }); + }); + + describe('listSessions', () => { + it('returns empty array when DB does not exist', async () => { + const sessions = await adapter.listSessions(); + expect(sessions).toEqual([]); + }); + + it('returns all sessions from DB', async () => { + const now = Date.now(); + const db = makeDb({ + session: [ + { id: 'sess-a', directory: '/proj-a', time_created: now - 3000 }, + { id: 'sess-b', directory: '/proj-b', time_created: now - 6000 }, + ], + lastMessage: null, + lastAssistant: null, + firstUserText: { text: 'Build the feature' }, + }); + (adapter as any).db = db; + + const sessions = await adapter.listSessions(); + + expect(sessions).toHaveLength(2); + expect(sessions[0]).toMatchObject({ type: 'opencode', sessionId: 'sess-a', cwd: '/proj-a' }); + expect(sessions[1]).toMatchObject({ type: 'opencode', sessionId: 'sess-b', cwd: '/proj-b' }); + expect(sessions[0].sessionFilePath).toContain('sess-a'); + }); + + it('filters sessions by cwd when opts.cwd is set', async () => { + const now = Date.now(); + const db = makeDb({ + session: [ + { id: 'sess-a', directory: '/proj-a', time_created: now - 3000 }, + { id: 'sess-b', directory: '/proj-b', time_created: now - 6000 }, + ], + lastMessage: null, + lastAssistant: null, + firstUserText: null, + }); + (adapter as any).db = db; + + const sessions = await adapter.listSessions({ cwd: '/proj-a' }); + + expect(sessions).toHaveLength(1); + expect(sessions[0].sessionId).toBe('sess-a'); + }); + + it('uses session time_created as startedAt and lastActive when no parts exist', async () => { + const timeCreated = Date.now() - 120000; + const db = makeDb({ + session: [{ id: 'sess-c', directory: '/repo', time_created: timeCreated }], + lastMessage: null, + lastAssistant: null, + firstUserText: null, + }); + (adapter as any).db = db; + + const [session] = await adapter.listSessions(); + + expect(session.startedAt.getTime()).toBeCloseTo(timeCreated, -2); + expect(session.lastActive.getTime()).toBeCloseTo(timeCreated, -2); + }); + }); +}); diff --git a/packages/agent-manager/src/adapters/AgentAdapter.ts b/packages/agent-manager/src/adapters/AgentAdapter.ts index f13a5ff..01633f2 100644 --- a/packages/agent-manager/src/adapters/AgentAdapter.ts +++ b/packages/agent-manager/src/adapters/AgentAdapter.ts @@ -8,7 +8,7 @@ /** * Type of AI agent */ -export type AgentType = 'claude' | 'gemini_cli' | 'codex' | 'other'; +export type AgentType = 'claude' | 'gemini_cli' | 'codex' | 'opencode' | 'other'; /** * Current status of an agent diff --git a/packages/agent-manager/src/adapters/OpenCodeAdapter.ts b/packages/agent-manager/src/adapters/OpenCodeAdapter.ts new file mode 100644 index 0000000..17321c0 --- /dev/null +++ b/packages/agent-manager/src/adapters/OpenCodeAdapter.ts @@ -0,0 +1,340 @@ +/** + * OpenCode Adapter + * + * Detects running OpenCode agents by: + * 1. Finding running opencode processes via shared listAgentProcesses() + * 2. Enriching with CWD and start times via shared enrichProcesses() + * 3. Querying OpenCode's SQLite DB (~/.local/share/opencode/opencode.db) to + * find the session matching each process's CWD and read status from message.time.completed + * + * sessionFilePath encodes "::" so getConversation() can open the right + * DB row without extending the AgentAdapter interface. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import Database from 'better-sqlite3'; +import type { + AgentAdapter, + AgentInfo, + ProcessInfo, + ConversationMessage, + SessionSummary, + ListSessionsOptions, +} from './AgentAdapter'; +import { AgentStatus } from './AgentAdapter'; +import { listAgentProcesses, enrichProcesses } from '../utils/process'; +import { generateAgentName } from '../utils/matching'; + +const SESSION_REF_SEP = '::'; + +function encodeSessionRef(dbPath: string, sessionId: string): string { + return `${dbPath}${SESSION_REF_SEP}${sessionId}`; +} + +function decodeSessionRef(ref: string): { dbPath: string; sessionId: string } | null { + const idx = ref.lastIndexOf(SESSION_REF_SEP); + if (idx === -1) return null; + return { dbPath: ref.slice(0, idx), sessionId: ref.slice(idx + SESSION_REF_SEP.length) }; +} + +interface OpenCodeSession { + sessionId: string; + directory: string; + timeCreated: number; +} + +interface OpenCodeSessionStats { + lastRole: string | null; + lastTimeUpdated: number; + /** OpenCode writes `time.completed` on the assistant message only when the turn finishes. */ + lastAssistantCompleted: boolean; + lastAssistantErrored: boolean; + summary: string; +} + +export class OpenCodeAdapter implements AgentAdapter { + readonly type = 'opencode' as const; + + private static readonly IDLE_THRESHOLD_MINUTES = 5; + + private readonly dbPath: string; + private db: Database.Database | null = null; + + constructor() { + this.dbPath = OpenCodeAdapter.resolveDbPath(); + const cleanup = (): void => this.close(); + process.once('exit', cleanup); + process.once('SIGINT', cleanup); + process.once('SIGTERM', cleanup); + } + + close(): void { + if (this.db) { + try { this.db.close(); } catch { /* ignore */ } + this.db = null; + } + } + + private static resolveDbPath(): string { + const xdg = process.env.XDG_DATA_HOME; + const home = process.env.HOME || process.env.USERPROFILE || ''; + const base = xdg || path.join(home, '.local', 'share'); + return path.join(base, 'opencode', 'opencode.db'); + } + + canHandle(processInfo: ProcessInfo): boolean { + const exe = (processInfo.command.trim().split(/\s+/)[0] || '').toLowerCase(); + const base = path.basename(exe); + return base === 'opencode' || base === 'opencode.exe'; + } + + async detectAgents(): Promise { + const processes = enrichProcesses(listAgentProcesses('opencode')); + if (processes.length === 0) return []; + + const db = this.openDb(); + if (!db) return processes.map((p) => this.mapProcessOnlyAgent(p)); + + const agents: AgentInfo[] = []; + for (const proc of processes) { + if (!proc.cwd) { + agents.push(this.mapProcessOnlyAgent(proc)); + continue; + } + + const session = this.findSessionForDirectory(db, proc.cwd); + if (!session) { + agents.push(this.mapProcessOnlyAgent(proc)); + continue; + } + + const stats = this.getSessionStats(db, session.sessionId); + agents.push(this.mapSessionToAgent(session, stats, proc)); + } + + return agents; + } + + getConversation(sessionFilePath: string, options?: { verbose?: boolean }): ConversationMessage[] { + const verbose = options?.verbose ?? false; + const ref = decodeSessionRef(sessionFilePath); + if (!ref) return []; + + const db = this.openDb(); + if (!db) return []; + + try { + const rows = db.prepare<[string], { role: string; partData: string; timeCreated: number }>(` + SELECT json_extract(m.data, '$.role') AS role, + p.data AS partData, + p.time_created AS timeCreated + FROM part p + JOIN message m ON p.message_id = m.id + WHERE p.session_id = ? + ORDER BY p.time_created ASC + `).all(ref.sessionId); + + const messages: ConversationMessage[] = []; + + for (const row of rows) { + let partData: { type?: string; text?: string; reasoning?: string; tool?: string } = {}; + try { + partData = JSON.parse(row.partData); + } catch { + continue; + } + + const role = row.role === 'user' ? 'user' : 'assistant'; + + if (partData.type === 'text' && partData.text) { + messages.push({ role, content: partData.text }); + } else if (partData.type === 'reasoning' && verbose) { + const text = partData.reasoning || partData.text || ''; + if (text) messages.push({ role: 'assistant', content: `[thinking] ${text}` }); + } else if (partData.type === 'tool' && verbose) { + const toolName = partData.tool || 'tool'; + messages.push({ role: 'assistant', content: `[tool: ${toolName}]` }); + } + } + + return messages; + } catch { + this.close(); + return []; + } + } + + async listSessions(opts?: ListSessionsOptions): Promise { + const db = this.openDb(); + if (!db) return []; + + try { + const rows = db.prepare<[], { id: string; directory: string; timeCreated: number }>(` + SELECT id, directory, time_created AS timeCreated + FROM session + ORDER BY time_created DESC + `).all(); + + const summaries: SessionSummary[] = []; + + for (const row of rows) { + if (opts?.cwd !== undefined && row.directory !== opts.cwd) continue; + + const stats = this.getSessionStats(db, row.id); + const lastActive = stats.lastTimeUpdated > 0 + ? new Date(stats.lastTimeUpdated) + : new Date(row.timeCreated); + const startedAt = new Date(row.timeCreated); + + summaries.push({ + type: 'opencode', + sessionId: row.id, + cwd: row.directory, + firstUserMessage: stats.summary, + lastActive, + startedAt, + sessionFilePath: encodeSessionRef(this.dbPath, row.id), + }); + } + + return summaries; + } catch { + this.close(); + return []; + } + } + + private findSessionForDirectory(db: Database.Database, directory: string): OpenCodeSession | null { + try { + const row = db.prepare<[string], { id: string; directory: string; time_created: number }>(` + SELECT id, directory, time_created + FROM session + WHERE directory = ? + ORDER BY time_created DESC + LIMIT 1 + `).get(directory); + + if (!row) return null; + return { sessionId: row.id, directory: row.directory, timeCreated: row.time_created }; + } catch { + return null; + } + } + + private getSessionStats(db: Database.Database, sessionId: string): OpenCodeSessionStats { + const empty: OpenCodeSessionStats = { + lastRole: null, + lastTimeUpdated: 0, + lastAssistantCompleted: false, + lastAssistantErrored: false, + summary: '', + }; + + try { + // Order by time_created — time_updated can lag when OpenCode appends + // metadata (e.g. summary diffs) to user messages after a turn finishes. + const last = db.prepare<[string], { role: string; timeUpdated: number }>(` + SELECT json_extract(data, '$.role') AS role, + time_updated AS timeUpdated + FROM message + WHERE session_id = ? + ORDER BY time_created DESC + LIMIT 1 + `).get(sessionId); + + const heartbeat = db.prepare<[string], { maxUpdated: number }>(` + SELECT MAX(time_updated) AS maxUpdated FROM message WHERE session_id = ? + `).get(sessionId); + + const lastAssistant = db.prepare<[string], { + completed: number | null; + errored: number | null; + }>(` + SELECT json_extract(data, '$.time.completed') AS completed, + json_extract(data, '$.time.error') AS errored + FROM message + WHERE session_id = ? AND json_extract(data, '$.role') = 'assistant' + ORDER BY time_created DESC + LIMIT 1 + `).get(sessionId); + + const first = db.prepare<[string], { text: string }>(` + SELECT json_extract(p.data, '$.text') AS text + FROM part p + JOIN message m ON p.message_id = m.id + WHERE p.session_id = ? + AND json_extract(m.data, '$.role') = 'user' + AND json_extract(p.data, '$.type') = 'text' + AND json_extract(p.data, '$.text') IS NOT NULL + ORDER BY p.time_created ASC + LIMIT 1 + `).get(sessionId); + + return { + lastRole: last?.role ?? null, + lastTimeUpdated: heartbeat?.maxUpdated ?? last?.timeUpdated ?? 0, + lastAssistantCompleted: lastAssistant?.completed != null, + lastAssistantErrored: lastAssistant?.errored != null, + summary: first?.text?.trim() ?? '', + }; + } catch { + return empty; + } + } + + private mapSessionToAgent( + session: OpenCodeSession, + stats: OpenCodeSessionStats, + proc: ProcessInfo, + ): AgentInfo { + const lastActive = stats.lastTimeUpdated > 0 + ? new Date(stats.lastTimeUpdated) + : new Date(session.timeCreated); + + return { + name: generateAgentName(session.directory || proc.cwd || '', proc.pid), + type: this.type, + status: this.determineStatus(stats, lastActive), + summary: stats.summary || 'OpenCode session active', + pid: proc.pid, + projectPath: session.directory || proc.cwd || '', + sessionId: session.sessionId, + lastActive, + sessionFilePath: encodeSessionRef(this.dbPath, session.sessionId), + }; + } + + private mapProcessOnlyAgent(proc: ProcessInfo): AgentInfo { + return { + name: generateAgentName(proc.cwd || '', proc.pid), + type: this.type, + status: AgentStatus.RUNNING, + summary: 'OpenCode process running', + pid: proc.pid, + projectPath: proc.cwd || '', + sessionId: `pid-${proc.pid}`, + lastActive: new Date(), + }; + } + + private determineStatus(stats: OpenCodeSessionStats, lastActive: Date): AgentStatus { + const ageMin = (Date.now() - lastActive.getTime()) / 60000; + if (ageMin > OpenCodeAdapter.IDLE_THRESHOLD_MINUTES) return AgentStatus.IDLE; + + if (stats.lastRole === 'assistant' && !stats.lastAssistantCompleted) return AgentStatus.RUNNING; + if (stats.lastRole === 'assistant') return AgentStatus.WAITING; + return AgentStatus.RUNNING; + } + + private openDb(): Database.Database | null { + if (this.db) return this.db; + if (!fs.existsSync(this.dbPath)) return null; + try { + this.db = new Database(this.dbPath, { readonly: true }); + return this.db; + } catch { + return null; + } + } +} diff --git a/packages/agent-manager/src/adapters/index.ts b/packages/agent-manager/src/adapters/index.ts index 62e458b..4c6053a 100644 --- a/packages/agent-manager/src/adapters/index.ts +++ b/packages/agent-manager/src/adapters/index.ts @@ -1,5 +1,6 @@ export { ClaudeCodeAdapter } from './ClaudeCodeAdapter'; export { CodexAdapter } from './CodexAdapter'; export { GeminiCliAdapter } from './GeminiCliAdapter'; +export { OpenCodeAdapter } from './OpenCodeAdapter'; export { AgentStatus } from './AgentAdapter'; export type { AgentAdapter, AgentType, AgentInfo, ProcessInfo } from './AgentAdapter'; diff --git a/packages/agent-manager/src/index.ts b/packages/agent-manager/src/index.ts index 0817a01..09c1770 100644 --- a/packages/agent-manager/src/index.ts +++ b/packages/agent-manager/src/index.ts @@ -3,6 +3,7 @@ export { AgentManager } from './AgentManager'; export { ClaudeCodeAdapter } from './adapters/ClaudeCodeAdapter'; export { CodexAdapter } from './adapters/CodexAdapter'; export { GeminiCliAdapter } from './adapters/GeminiCliAdapter'; +export { OpenCodeAdapter } from './adapters/OpenCodeAdapter'; export { AgentStatus } from './adapters/AgentAdapter'; export type { AgentAdapter, diff --git a/packages/cli/src/__tests__/commands/agent.test.ts b/packages/cli/src/__tests__/commands/agent.test.ts index 8197c55..250b37d 100644 --- a/packages/cli/src/__tests__/commands/agent.test.ts +++ b/packages/cli/src/__tests__/commands/agent.test.ts @@ -31,6 +31,7 @@ jest.mock('@ai-devkit/agent-manager', () => ({ ClaudeCodeAdapter: jest.fn(), CodexAdapter: jest.fn(), GeminiCliAdapter: jest.fn(), + OpenCodeAdapter: jest.fn(), TerminalFocusManager: jest.fn(() => mockFocusManager), TtyWriter: { send: (location: any, message: string) => mockTtyWriterSend(location, message) }, AgentStatus: { diff --git a/packages/cli/src/commands/agent.ts b/packages/cli/src/commands/agent.ts index 1744e66..50a3495 100644 --- a/packages/cli/src/commands/agent.ts +++ b/packages/cli/src/commands/agent.ts @@ -7,6 +7,7 @@ import { ClaudeCodeAdapter, CodexAdapter, GeminiCliAdapter, + OpenCodeAdapter, AgentStatus, TerminalFocusManager, TtyWriter, @@ -52,6 +53,7 @@ const TYPE_LABELS: Record = { claude: 'Claude Code', codex: 'Codex', gemini_cli: 'Gemini CLI', + opencode: 'OpenCode', other: 'Other', }; @@ -78,6 +80,7 @@ function createAgentManager(): AgentManager { manager.registerAdapter(new ClaudeCodeAdapter()); manager.registerAdapter(new CodexAdapter()); manager.registerAdapter(new GeminiCliAdapter()); + manager.registerAdapter(new OpenCodeAdapter()); return manager; }