From f8a7648774772bc6c4e1ab7d547364b7f51b76a4 Mon Sep 17 00:00:00 2001 From: Gabriel Aguiar Date: Tue, 12 May 2026 17:51:50 -0300 Subject: [PATCH 1/3] feat(agent-manager): add OpenCode adapter for agent control MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detects running opencode processes, reads sessions from OpenCode's SQLite DB (~/.local/share/opencode/opencode.db, XDG-aware), and exposes them through the existing AgentAdapter interface so all agent commands work unchanged. Status logic distinguishes running tools from finished turns to avoid mislabeling mid-turn work as WAITING. Adapted from maquinista-labs/maquinista's source_opencode.go (Go, Postgres-backed) — credits to @otaviocarvalho in the PR body. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 2 +- package-lock.json | 8 +- packages/agent-manager/package.json | 4 + .../adapters/OpenCodeAdapter.test.ts | 474 ++++++++++++++++++ .../src/adapters/AgentAdapter.ts | 2 +- .../src/adapters/OpenCodeAdapter.ts | 352 +++++++++++++ packages/agent-manager/src/adapters/index.ts | 1 + packages/agent-manager/src/index.ts | 1 + .../cli/src/__tests__/commands/agent.test.ts | 1 + packages/cli/src/commands/agent.ts | 3 + 10 files changed, 844 insertions(+), 4 deletions(-) create mode 100644 packages/agent-manager/src/__tests__/adapters/OpenCodeAdapter.test.ts create mode 100644 packages/agent-manager/src/adapters/OpenCodeAdapter.ts 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..3c83df8 --- /dev/null +++ b/packages/agent-manager/src/__tests__/adapters/OpenCodeAdapter.test.ts @@ -0,0 +1,474 @@ +/** + * 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; + +// --------------------------------------------------------------------------- +// SQLite mock helpers +// --------------------------------------------------------------------------- + +function makeDb(queries: { + session?: Array<{ id: string; directory: string; time_created: number }>; + lastPart?: { + role: string; + timeUpdated: number; + partType?: string | null; + toolStatus?: string | 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('order by p.time_updated desc')) { + return { + get: () => queries.lastPart === undefined + ? undefined + : queries.lastPart + ? { + role: queries.lastPart.role, + timeUpdated: queries.lastPart.timeUpdated, + partType: queries.lastPart.partType ?? null, + toolStatus: queries.lastPart.toolStatus ?? null, + } + : 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); +} + +// --------------------------------------------------------------------------- +// Test setup +// --------------------------------------------------------------------------- + +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}`; + }); + + // Point dbPath to a temp location by default (not existing) + 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); + // dbPath does not exist → openDb returns null + + 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)); + // Inject db directly to bypass fs.existsSync + require + (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 agent when assistant turn finished and quiet past freshness window', 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 }], + // 30s ago, beyond MID_TURN_FRESHNESS_SEC (5s) → assistant turn is done + lastPart: { role: 'assistant', timeUpdated: now - 30_000, partType: 'text' }, + 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 tool is actively executing', async () => { + const now = Date.now(); + const procs: ProcessInfo[] = [ + { pid: 250, command: 'opencode', cwd: '/proj', tty: 'ttys004' }, + ]; + mockedListAgentProcesses.mockReturnValue(procs); + mockedEnrichProcesses.mockReturnValue(procs); + + // Tool has been running for 2 minutes; assistant role; tool state 'running' + const db = makeDb({ + session: [{ id: 'sess-tool', directory: '/proj', time_created: now - 180_000 }], + lastPart: { + role: 'assistant', + timeUpdated: now - 120_000, + partType: 'tool', + toolStatus: 'running', + }, + firstUserText: { text: 'Run the build' }, + }); + (adapter as any).db = db; + + const agents = await adapter.detectAgents(); + + // Must be RUNNING — would previously have been WAITING (the bug) + expect(agents[0]).toMatchObject({ + status: AgentStatus.RUNNING, + sessionId: 'sess-tool', + }); + }); + + it('returns running when assistant part was updated within freshness window', async () => { + const now = Date.now(); + const procs: ProcessInfo[] = [ + { pid: 260, command: 'opencode', cwd: '/proj-b', tty: 'ttys005' }, + ]; + mockedListAgentProcesses.mockReturnValue(procs); + mockedEnrichProcesses.mockReturnValue(procs); + + // 2 seconds ago, within MID_TURN_FRESHNESS_SEC → still mid-turn + const db = makeDb({ + session: [{ id: 'sess-mid', directory: '/proj-b', time_created: now - 60_000 }], + lastPart: { role: 'assistant', timeUpdated: now - 2000, partType: 'text' }, + firstUserText: null, + }); + (adapter as any).db = db; + + const agents = await adapter.detectAgents(); + expect(agents[0].status).toBe(AgentStatus.RUNNING); + }); + + it('returns running agent when last role is user', 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 }], + // User just sent a message, 30s ago → past freshness, role user → RUNNING + lastPart: { role: 'user', timeUpdated: now - 30_000, partType: 'text' }, + 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 }], + lastPart: { role: 'assistant', timeUpdated: staleTime }, + 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', () => { + // db is null, dbPath does not exist + (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 }, + ], + lastPart: 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 }, + ], + lastPart: 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 }], + lastPart: 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..5fbcaaa --- /dev/null +++ b/packages/agent-manager/src/adapters/OpenCodeAdapter.ts @@ -0,0 +1,352 @@ +/** + * 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 + * 4. Reading session metadata (lastActive, summary, status) from the DB + * + * Session reference encoding: sessionFilePath uses "::" + * so getConversation() can open the right DB row without extra parameters. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +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'; + +// Imported dynamically so tests can mock it before the module is required. +// require('better-sqlite3') returns the Database constructor directly. +type Database = import('better-sqlite3').Database; + +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; + lastPartType: string | null; + lastToolStatus: string | null; + summary: string; +} + +export class OpenCodeAdapter implements AgentAdapter { + readonly type = 'opencode' as const; + + private static readonly IDLE_THRESHOLD_MINUTES = 5; + /** If the last part was updated this recently, the agent is mid-turn emitting parts. */ + private static readonly MID_TURN_FRESHNESS_SEC = 5; + + private readonly dbPath: string; + private db: Database | null = null; + + constructor() { + this.dbPath = OpenCodeAdapter.resolveDbPath(); + } + + 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 []; + + let db: Database | null; + if (ref.dbPath === this.dbPath) { + db = this.openDb(); + } else { + db = this.openDbAt(ref.dbPath); + } + 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.resetDb(); + 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.resetDb(); + return []; + } + } + + private findSessionForDirectory(db: 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, sessionId: string): OpenCodeSessionStats { + const empty: OpenCodeSessionStats = { + lastRole: null, lastTimeUpdated: 0, lastPartType: null, lastToolStatus: null, summary: '', + }; + + try { + // Last part: gives us the most recent role, time, type, and (if tool) state.status + const last = db.prepare<[string], { + role: string; + timeUpdated: number; + partType: string | null; + toolStatus: string | null; + }>(` + SELECT json_extract(m.data, '$.role') AS role, + p.time_updated AS timeUpdated, + json_extract(p.data, '$.type') AS partType, + json_extract(p.data, '$.state.status') AS toolStatus + FROM part p + JOIN message m ON p.message_id = m.id + WHERE p.session_id = ? + ORDER BY p.time_updated DESC + LIMIT 1 + `).get(sessionId); + + // First user text part: becomes the summary + 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: last?.timeUpdated ?? 0, + lastPartType: last?.partType ?? null, + lastToolStatus: last?.toolStatus ?? 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 ageSec = (Date.now() - lastActive.getTime()) / 1000; + + if (ageSec > OpenCodeAdapter.IDLE_THRESHOLD_MINUTES * 60) return AgentStatus.IDLE; + + // A tool actively running means the agent is working, regardless of role. + if (stats.lastPartType === 'tool' && stats.lastToolStatus === 'running') { + return AgentStatus.RUNNING; + } + + // Recently updated → agent is mid-turn emitting parts (text, reasoning, tool transitions). + if (ageSec < OpenCodeAdapter.MID_TURN_FRESHNESS_SEC) return AgentStatus.RUNNING; + + // Assistant turn finished and has been quiet long enough → waiting for next user input. + if (stats.lastRole === 'assistant') return AgentStatus.WAITING; + + return AgentStatus.RUNNING; + } + + private openDb(): Database | null { + if (this.db) return this.db; + this.db = this.openDbAt(this.dbPath); + return this.db; + } + + private openDbAt(dbPath: string): Database | null { + if (!fs.existsSync(dbPath)) return null; + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports, @typescript-eslint/no-explicit-any + const Ctor = require('better-sqlite3') as any; + const db = new Ctor(dbPath, { readonly: true }) as Database; + if (dbPath === this.dbPath) this.db = db; + return db; + } catch { + return null; + } + } + + private resetDb(): void { + if (this.db) { + try { this.db.close(); } catch { /* ignore */ } + this.db = 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; } From 7251c4bdf04fed9d1ca1e3b473c0cda55e6347f0 Mon Sep 17 00:00:00 2001 From: Gabriel Aguiar Date: Tue, 12 May 2026 18:29:41 -0300 Subject: [PATCH 2/3] fix(agent-manager): use OpenCode's message.time.completed for status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the time-based freshness heuristic with OpenCode's own turn-done flag (assistant message's time.completed JSON field). Resolves two issues: - Mid-turn pauses (LLM thinking between steps, long reasoning) no longer flip status to WAITING — time.completed stays null until the turn ends. - Ordering messages by time_created (not time_updated) prevents a stale user message — touched by OpenCode appending summary diffs after the turn — from masking the latest assistant message and falling through to RUNNING when the agent had actually finished. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../adapters/OpenCodeAdapter.test.ts | 127 +++++++++--------- .../src/adapters/OpenCodeAdapter.ts | 84 ++++++------ 2 files changed, 105 insertions(+), 106 deletions(-) diff --git a/packages/agent-manager/src/__tests__/adapters/OpenCodeAdapter.test.ts b/packages/agent-manager/src/__tests__/adapters/OpenCodeAdapter.test.ts index 3c83df8..8b0d66c 100644 --- a/packages/agent-manager/src/__tests__/adapters/OpenCodeAdapter.test.ts +++ b/packages/agent-manager/src/__tests__/adapters/OpenCodeAdapter.test.ts @@ -25,18 +25,10 @@ const mockedListAgentProcesses = listAgentProcesses as jest.MockedFunction; const mockedGenerateAgentName = generateAgentName as jest.MockedFunction; -// --------------------------------------------------------------------------- -// SQLite mock helpers -// --------------------------------------------------------------------------- - function makeDb(queries: { session?: Array<{ id: string; directory: string; time_created: number }>; - lastPart?: { - role: string; - timeUpdated: number; - partType?: string | null; - toolStatus?: string | null; - } | null; + 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 }>; }) { @@ -56,18 +48,23 @@ function makeDb(queries: { }; } - if (normalized.includes('order by p.time_updated desc')) { + 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.lastPart === undefined + get: () => queries.lastAssistant === undefined ? undefined - : queries.lastPart - ? { - role: queries.lastPart.role, - timeUpdated: queries.lastPart.timeUpdated, - partType: queries.lastPart.partType ?? null, - toolStatus: queries.lastPart.toolStatus ?? null, - } - : undefined, + : queries.lastAssistant ?? undefined, }; } @@ -95,10 +92,6 @@ function makeDbConstructor(db: ReturnType) { return jest.fn().mockReturnValue(db); } -// --------------------------------------------------------------------------- -// Test setup -// --------------------------------------------------------------------------- - describe('OpenCodeAdapter', () => { let adapter: OpenCodeAdapter; let tmpDir: string; @@ -117,7 +110,6 @@ describe('OpenCodeAdapter', () => { return `${folder}-${pid}`; }); - // Point dbPath to a temp location by default (not existing) tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'opencode-test-')); dbPath = path.join(tmpDir, 'opencode.db'); (adapter as any).dbPath = dbPath; @@ -129,16 +121,12 @@ describe('OpenCodeAdapter', () => { 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); @@ -166,8 +154,6 @@ describe('OpenCodeAdapter', () => { }); }); - // ------------------------------------------------------------------------- - describe('detectAgents', () => { it('returns empty list when no opencode processes running', async () => { mockedListAgentProcesses.mockReturnValue([]); @@ -184,7 +170,6 @@ describe('OpenCodeAdapter', () => { ]; mockedListAgentProcesses.mockReturnValue(procs); mockedEnrichProcesses.mockReturnValue(procs); - // dbPath does not exist → openDb returns null const agents = await adapter.detectAgents(); @@ -209,7 +194,6 @@ describe('OpenCodeAdapter', () => { fs.writeFileSync(dbPath, ''); // file exists but empty → sqlite throws const db = makeDb({ session: [] }); // no matching session jest.mock('better-sqlite3', () => makeDbConstructor(db)); - // Inject db directly to bypass fs.existsSync + require (adapter as any).db = db; const agents = await adapter.detectAgents(); @@ -222,7 +206,7 @@ describe('OpenCodeAdapter', () => { }); }); - it('returns waiting agent when assistant turn finished and quiet past freshness window', async () => { + 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' }, @@ -232,8 +216,8 @@ describe('OpenCodeAdapter', () => { const db = makeDb({ session: [{ id: 'sess-001', directory: '/my-project', time_created: now - 60000 }], - // 30s ago, beyond MID_TURN_FRESHNESS_SEC (5s) → assistant turn is done - lastPart: { role: 'assistant', timeUpdated: now - 30_000, partType: 'text' }, + 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; @@ -249,7 +233,7 @@ describe('OpenCodeAdapter', () => { }); }); - it('returns running when assistant tool is actively executing', async () => { + 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' }, @@ -257,29 +241,23 @@ describe('OpenCodeAdapter', () => { mockedListAgentProcesses.mockReturnValue(procs); mockedEnrichProcesses.mockReturnValue(procs); - // Tool has been running for 2 minutes; assistant role; tool state 'running' - const db = makeDb({ + const db = makeDb({ session: [{ id: 'sess-tool', directory: '/proj', time_created: now - 180_000 }], - lastPart: { - role: 'assistant', - timeUpdated: now - 120_000, - partType: 'tool', - toolStatus: 'running', - }, + 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(); - // Must be RUNNING — would previously have been WAITING (the bug) expect(agents[0]).toMatchObject({ status: AgentStatus.RUNNING, sessionId: 'sess-tool', }); }); - it('returns running when assistant part was updated within freshness window', async () => { + 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' }, @@ -287,10 +265,10 @@ describe('OpenCodeAdapter', () => { mockedListAgentProcesses.mockReturnValue(procs); mockedEnrichProcesses.mockReturnValue(procs); - // 2 seconds ago, within MID_TURN_FRESHNESS_SEC → still mid-turn - const db = makeDb({ + const db = makeDb({ session: [{ id: 'sess-mid', directory: '/proj-b', time_created: now - 60_000 }], - lastPart: { role: 'assistant', timeUpdated: now - 2000, partType: 'text' }, + lastMessage: { role: 'assistant', timeUpdated: now - 45_000 }, + lastAssistant: { completed: null, errored: null }, firstUserText: null, }); (adapter as any).db = db; @@ -299,7 +277,31 @@ describe('OpenCodeAdapter', () => { expect(agents[0].status).toBe(AgentStatus.RUNNING); }); - it('returns running agent when last role is user', async () => { + 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' }, @@ -309,8 +311,8 @@ describe('OpenCodeAdapter', () => { const db = makeDb({ session: [{ id: 'sess-002', directory: '/work', time_created: now - 30000 }], - // User just sent a message, 30s ago → past freshness, role user → RUNNING - lastPart: { role: 'user', timeUpdated: now - 30_000, partType: 'text' }, + lastMessage: { role: 'user', timeUpdated: now - 30_000 }, + lastAssistant: null, firstUserText: { text: 'Add unit tests' }, }); (adapter as any).db = db; @@ -334,7 +336,8 @@ describe('OpenCodeAdapter', () => { const db = makeDb({ session: [{ id: 'sess-003', directory: '/old-work', time_created: staleTime }], - lastPart: { role: 'assistant', timeUpdated: staleTime }, + lastMessage: { role: 'assistant', timeUpdated: staleTime }, + lastAssistant: { completed: staleTime, errored: null }, firstUserText: null, }); (adapter as any).db = db; @@ -348,8 +351,6 @@ describe('OpenCodeAdapter', () => { }); }); - // ------------------------------------------------------------------------- - describe('getConversation', () => { it('returns empty array for invalid session ref', () => { const messages = adapter.getConversation('/no-separator-here'); @@ -403,15 +404,12 @@ describe('OpenCodeAdapter', () => { }); it('returns empty array when DB cannot be opened', () => { - // db is null, dbPath does not exist - (adapter as any).db = null; + (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(); @@ -425,7 +423,8 @@ describe('OpenCodeAdapter', () => { { id: 'sess-a', directory: '/proj-a', time_created: now - 3000 }, { id: 'sess-b', directory: '/proj-b', time_created: now - 6000 }, ], - lastPart: null, + lastMessage: null, + lastAssistant: null, firstUserText: { text: 'Build the feature' }, }); (adapter as any).db = db; @@ -445,7 +444,8 @@ describe('OpenCodeAdapter', () => { { id: 'sess-a', directory: '/proj-a', time_created: now - 3000 }, { id: 'sess-b', directory: '/proj-b', time_created: now - 6000 }, ], - lastPart: null, + lastMessage: null, + lastAssistant: null, firstUserText: null, }); (adapter as any).db = db; @@ -460,7 +460,8 @@ describe('OpenCodeAdapter', () => { const timeCreated = Date.now() - 120000; const db = makeDb({ session: [{ id: 'sess-c', directory: '/repo', time_created: timeCreated }], - lastPart: null, + lastMessage: null, + lastAssistant: null, firstUserText: null, }); (adapter as any).db = db; diff --git a/packages/agent-manager/src/adapters/OpenCodeAdapter.ts b/packages/agent-manager/src/adapters/OpenCodeAdapter.ts index 5fbcaaa..c7ace42 100644 --- a/packages/agent-manager/src/adapters/OpenCodeAdapter.ts +++ b/packages/agent-manager/src/adapters/OpenCodeAdapter.ts @@ -4,12 +4,11 @@ * 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 - * 4. Reading session metadata (lastActive, summary, status) from the DB + * 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 * - * Session reference encoding: sessionFilePath uses "::" - * so getConversation() can open the right DB row without extra parameters. + * sessionFilePath encodes "::" so getConversation() can open the right + * DB row without extending the AgentAdapter interface. */ import * as fs from 'fs'; @@ -26,8 +25,6 @@ import { AgentStatus } from './AgentAdapter'; import { listAgentProcesses, enrichProcesses } from '../utils/process'; import { generateAgentName } from '../utils/matching'; -// Imported dynamically so tests can mock it before the module is required. -// require('better-sqlite3') returns the Database constructor directly. type Database = import('better-sqlite3').Database; const SESSION_REF_SEP = '::'; @@ -51,8 +48,9 @@ interface OpenCodeSession { interface OpenCodeSessionStats { lastRole: string | null; lastTimeUpdated: number; - lastPartType: string | null; - lastToolStatus: string | null; + /** OpenCode writes `time.completed` on the assistant message only when the turn finishes. */ + lastAssistantCompleted: boolean; + lastAssistantErrored: boolean; summary: string; } @@ -60,8 +58,6 @@ export class OpenCodeAdapter implements AgentAdapter { readonly type = 'opencode' as const; private static readonly IDLE_THRESHOLD_MINUTES = 5; - /** If the last part was updated this recently, the agent is mid-turn emitting parts. */ - private static readonly MID_TURN_FRESHNESS_SEC = 5; private readonly dbPath: string; private db: Database | null = null; @@ -223,29 +219,41 @@ export class OpenCodeAdapter implements AgentAdapter { private getSessionStats(db: Database, sessionId: string): OpenCodeSessionStats { const empty: OpenCodeSessionStats = { - lastRole: null, lastTimeUpdated: 0, lastPartType: null, lastToolStatus: null, summary: '', + lastRole: null, + lastTimeUpdated: 0, + lastAssistantCompleted: false, + lastAssistantErrored: false, + summary: '', }; try { - // Last part: gives us the most recent role, time, type, and (if tool) state.status - const last = db.prepare<[string], { - role: string; - timeUpdated: number; - partType: string | null; - toolStatus: string | null; + // 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(m.data, '$.role') AS role, - p.time_updated AS timeUpdated, - json_extract(p.data, '$.type') AS partType, - json_extract(p.data, '$.state.status') AS toolStatus - FROM part p - JOIN message m ON p.message_id = m.id - WHERE p.session_id = ? - ORDER BY p.time_updated DESC + 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); - // First user text part: becomes the summary const first = db.prepare<[string], { text: string }>(` SELECT json_extract(p.data, '$.text') AS text FROM part p @@ -260,9 +268,9 @@ export class OpenCodeAdapter implements AgentAdapter { return { lastRole: last?.role ?? null, - lastTimeUpdated: last?.timeUpdated ?? 0, - lastPartType: last?.partType ?? null, - lastToolStatus: last?.toolStatus ?? null, + lastTimeUpdated: heartbeat?.maxUpdated ?? last?.timeUpdated ?? 0, + lastAssistantCompleted: lastAssistant?.completed != null, + lastAssistantErrored: lastAssistant?.errored != null, summary: first?.text?.trim() ?? '', }; } catch { @@ -306,21 +314,11 @@ export class OpenCodeAdapter implements AgentAdapter { } private determineStatus(stats: OpenCodeSessionStats, lastActive: Date): AgentStatus { - const ageSec = (Date.now() - lastActive.getTime()) / 1000; - - if (ageSec > OpenCodeAdapter.IDLE_THRESHOLD_MINUTES * 60) return AgentStatus.IDLE; + const ageMin = (Date.now() - lastActive.getTime()) / 60000; + if (ageMin > OpenCodeAdapter.IDLE_THRESHOLD_MINUTES) return AgentStatus.IDLE; - // A tool actively running means the agent is working, regardless of role. - if (stats.lastPartType === 'tool' && stats.lastToolStatus === 'running') { - return AgentStatus.RUNNING; - } - - // Recently updated → agent is mid-turn emitting parts (text, reasoning, tool transitions). - if (ageSec < OpenCodeAdapter.MID_TURN_FRESHNESS_SEC) return AgentStatus.RUNNING; - - // Assistant turn finished and has been quiet long enough → waiting for next user input. + if (stats.lastRole === 'assistant' && !stats.lastAssistantCompleted) return AgentStatus.RUNNING; if (stats.lastRole === 'assistant') return AgentStatus.WAITING; - return AgentStatus.RUNNING; } From 838597ab26e58e4c6e94cfefef3f7a3453cc1136 Mon Sep 17 00:00:00 2001 From: Gabriel Aguiar Date: Wed, 13 May 2026 10:11:00 -0300 Subject: [PATCH 3/3] refactor(agent-manager): apply OpenCodeAdapter PR review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use static `import Database from 'better-sqlite3'` instead of dynamic require — jest.mock works the same with an ES import. - Drop the `dbPath === this.dbPath` branch in getConversation; the else path is unreachable since sessionFilePath is always our own encoding. - Replace private resetDb with public close(), and register process exit / SIGINT / SIGTERM hooks so the SQLite handle is released on normal command completion and signal-driven shutdown, not only on query exceptions. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/adapters/OpenCodeAdapter.ts | 54 ++++++++----------- 1 file changed, 22 insertions(+), 32 deletions(-) diff --git a/packages/agent-manager/src/adapters/OpenCodeAdapter.ts b/packages/agent-manager/src/adapters/OpenCodeAdapter.ts index c7ace42..17321c0 100644 --- a/packages/agent-manager/src/adapters/OpenCodeAdapter.ts +++ b/packages/agent-manager/src/adapters/OpenCodeAdapter.ts @@ -13,6 +13,7 @@ import * as fs from 'fs'; import * as path from 'path'; +import Database from 'better-sqlite3'; import type { AgentAdapter, AgentInfo, @@ -25,8 +26,6 @@ import { AgentStatus } from './AgentAdapter'; import { listAgentProcesses, enrichProcesses } from '../utils/process'; import { generateAgentName } from '../utils/matching'; -type Database = import('better-sqlite3').Database; - const SESSION_REF_SEP = '::'; function encodeSessionRef(dbPath: string, sessionId: string): string { @@ -60,10 +59,21 @@ export class OpenCodeAdapter implements AgentAdapter { private static readonly IDLE_THRESHOLD_MINUTES = 5; private readonly dbPath: string; - private db: Database | null = null; + 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 { @@ -111,12 +121,7 @@ export class OpenCodeAdapter implements AgentAdapter { const ref = decodeSessionRef(sessionFilePath); if (!ref) return []; - let db: Database | null; - if (ref.dbPath === this.dbPath) { - db = this.openDb(); - } else { - db = this.openDbAt(ref.dbPath); - } + const db = this.openDb(); if (!db) return []; try { @@ -155,7 +160,7 @@ export class OpenCodeAdapter implements AgentAdapter { return messages; } catch { - this.resetDb(); + this.close(); return []; } } @@ -195,12 +200,12 @@ export class OpenCodeAdapter implements AgentAdapter { return summaries; } catch { - this.resetDb(); + this.close(); return []; } } - private findSessionForDirectory(db: Database, directory: string): OpenCodeSession | null { + 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 @@ -217,7 +222,7 @@ export class OpenCodeAdapter implements AgentAdapter { } } - private getSessionStats(db: Database, sessionId: string): OpenCodeSessionStats { + private getSessionStats(db: Database.Database, sessionId: string): OpenCodeSessionStats { const empty: OpenCodeSessionStats = { lastRole: null, lastTimeUpdated: 0, @@ -322,29 +327,14 @@ export class OpenCodeAdapter implements AgentAdapter { return AgentStatus.RUNNING; } - private openDb(): Database | null { + private openDb(): Database.Database | null { if (this.db) return this.db; - this.db = this.openDbAt(this.dbPath); - return this.db; - } - - private openDbAt(dbPath: string): Database | null { - if (!fs.existsSync(dbPath)) return null; + if (!fs.existsSync(this.dbPath)) return null; try { - // eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports, @typescript-eslint/no-explicit-any - const Ctor = require('better-sqlite3') as any; - const db = new Ctor(dbPath, { readonly: true }) as Database; - if (dbPath === this.dbPath) this.db = db; - return db; + this.db = new Database(this.dbPath, { readonly: true }); + return this.db; } catch { return null; } } - - private resetDb(): void { - if (this.db) { - try { this.db.close(); } catch { /* ignore */ } - this.db = null; - } - } }