diff --git a/.ai-devkit.json b/.ai-devkit.json index c418ec9..42f7f4a 100644 --- a/.ai-devkit.json +++ b/.ai-devkit.json @@ -9,7 +9,7 @@ "antigravity" ], "createdAt": "2025-12-28T13:35:45.251Z", - "updatedAt": "2026-05-09T16:24:22.226Z", + "updatedAt": "2026-05-13T14:32:19.150Z", "phases": [ "requirements", "design", @@ -49,6 +49,10 @@ { "registry": "codeaholicguy/ai-devkit", "name": "document-code" + }, + { + "registry": "codeaholicguy/ai-devkit", + "name": "security-review" } ] } diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index e1dba2d..7c35c91 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "ai-devkit", - "version": "0.28.0", + "version": "0.29.0", "description": "Structured AI-assisted development with phase workflows, persistent memory, and reusable skills", "author": { "name": "Hoang Nguyen", diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 3d8fff0..4477a1b 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "ai-devkit", - "version": "0.28.0", + "version": "0.29.0", "description": "AI-assisted development toolkit for Codex with structured workflows, reusable commands, persistent memory, and skills", "author": { "name": "Hoang Nguyen", diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 12dbf10..2fd017d 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "ai-devkit", - "version": "0.28.0", + "version": "0.29.0", "description": "AI-assisted development toolkit with structured SDLC workflows, persistent memory, and reusable skills", "author": { "name": "Hoang Nguyen", diff --git a/BACKLOG.md b/BACKLOG.md new file mode 100644 index 0000000..7f04b25 --- /dev/null +++ b/BACKLOG.md @@ -0,0 +1,8 @@ +# Backlog + +Ideas and items to follow up on. + +- [ ] Stop hardcoding `BUILTIN_SKILL_NAMES` in the CLI. Have `init` fetch the canonical list from a manifest in the registry repo (e.g., `skills/builtin.json`). +- [ ] Add daemon mode for `ai-devkit channel start` command. +- [ ] Add multiple Telegram channels support for `ai-devkit channel` — allow running multiple Telegram bots at the same time. +- [ ] Add agent session detail command. diff --git a/CHANGELOG.md b/CHANGELOG.md index 407d0d5..589dfaa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **OpenCode Agent Adapter** - Added `OpenCodeAdapter` to `agent-manager` so `ai-devkit agent` commands can discover, inspect, and control running OpenCode sessions (#82). +- **HTML Artifact in `document-code` Skill** - The `document-code` skill now offers an HTML artifact alongside its markdown output (ff9d3bb). + +### Fixed + +- **Claude Code PID-File Live Status** - `agent-manager` now prefers the PID-file live status when resolving Claude Code agent state, improving accuracy of `agent list` output (f4e189d). +- **Claude Code Lossy Project Dir Encoding** - `agent-manager` now matches Claude Code's lossy project directory encoding when resolving session paths, fixing session discovery for paths that Claude Code re-encodes (a98e7ac). + +## [0.29.0] - 2026-05-11 + +### Added + - **Non-interactive `init`** - `ai-devkit init` now accepts `-y, --yes` to run without prompts (required for agent/CI contexts where stdin is not a TTY). Without a template, `--yes` requires `-e ` and one of `-a`/`-p`, otherwise it exits non-zero with a clear message instead of hanging on a checkbox prompt. - **`init --overwrite` flag** - When combined with `--yes`, `--overwrite` overwrites existing environments and phase files; the default under `--yes` is to skip them, matching the `install --overwrite` convention. - **Telegram Markdown Rendering** - `channel-connector`'s Telegram adapter now renders Markdown to Telegram-flavored HTML with an automatic plain-text fallback when Telegram rejects the formatted payload. diff --git a/README-zh.md b/README-zh.md new file mode 100644 index 0000000..6a9e6ba --- /dev/null +++ b/README-zh.md @@ -0,0 +1,60 @@ +# AI DevKit + +**AI 辅助软件开发工具包** + +AI DevKit 帮助 AI 编程智能体更高效地与你的代码库协作。它提供结构化的工作流、持久化记忆和可复用技能——让智能体像高级开发者一样遵循相同的工程标准。 + +[![npm version](https://img.shields.io/npm/v/ai-devkit.svg)](https://www.npmjs.com/package/ai-devkit) +[![npm downloads](https://img.shields.io/npm/dt/ai-devkit.svg)](https://www.npmjs.com/package/ai-devkit) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) + +> 🌐 [English](./README.md) | **中文** + +## 快速开始 + +```bash +npx ai-devkit@latest init +``` + +这将启动一个交互式设置向导,在不到一分钟内完成项目配置,使其支持 AI 辅助开发。 + +## 支持的智能体 + +| 智能体 | 智能体配置支持 | 智能体控制支持 | +|--------|---------------|---------------| +| [Claude Code](https://www.anthropic.com/claude-code) | ✅ 已支持 | ✅ 已就绪 | +| [GitHub Copilot](https://code.visualstudio.com/) | ✅ 已支持 | ❌ 未就绪 | +| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | ✅ 已支持 | ✅ 已就绪 | +| [Cursor](https://cursor.sh/) | ✅ 已支持 | ❌ 未就绪 | +| [opencode](https://opencode.ai/) | ✅ 已支持 | ❌ 未就绪 | +| [Antigravity](https://antigravity.google/) | ✅ 已支持 | ❌ 未就绪 | +| [Codex CLI](https://github.com/openai/codex) | ✅ 已支持 | ✅ 已就绪 | +| [Windsurf](https://windsurf.com/) | 🚧 测试中 | ❌ 未就绪 | +| [Kilo Code](https://github.com/Kilo-Org/kilocode) | 🚧 测试中 | ❌ 未就绪 | +| [Roo Code](https://roocode.com/) | 🚧 测试中 | ❌ 未就绪 | +| [Amp](https://ampcode.com/) | ✅ 已支持 | ❌ 未就绪 | + +## 文档 + +📖 **访问 [ai-devkit.com](https://ai-devkit.com/docs/) 查看完整文档**,包括: + +- 入门指南 +- 基于阶段的开发工作流 +- 记忆系统设置 +- 技能管理 +- 智能体配置 + +## 参与贡献 + +我们欢迎贡献!详情请参阅[贡献指南](./CONTRIBUTING.md)。 + +```bash +git clone https://github.com/Codeaholicguy/ai-devkit.git +cd ai-devkit +npm install +npm run build +``` + +## 许可证 + +MIT diff --git a/README.md b/README.md index 6d0a1c0..f567b0b 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # AI DevKit +> 🌐 **English** | [中文](./README-zh.md) + **The toolkit for AI-assisted software development.** AI DevKit helps AI coding agents work more effectively with your codebase. It provides structured workflows, persistent memory, and reusable skills — so agents follow the same engineering standards as senior developers. @@ -24,7 +26,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..f26a13e 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/*", @@ -13156,9 +13156,13 @@ }, "packages/agent-manager": { "name": "@ai-devkit/agent-manager", - "version": "0.12.0", + "version": "0.13.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", @@ -13208,10 +13212,10 @@ }, "packages/cli": { "name": "ai-devkit", - "version": "0.29.0", + "version": "0.30.0", "license": "MIT", "dependencies": { - "@ai-devkit/agent-manager": "0.12.0", + "@ai-devkit/agent-manager": "0.13.0", "@ai-devkit/channel-connector": "0.5.0", "@ai-devkit/memory": "0.10.0", "chalk": "^4.1.2", diff --git a/package.json b/package.json index 84aa3f2..51e1018 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ai-devkit", - "version": "0.28.0", + "version": "0.29.0", "private": true, "description": "A CLI toolkit for AI-assisted software development with phase templates and environment setup", "scripts": { diff --git a/packages/agent-manager/package.json b/packages/agent-manager/package.json index 1c3eefc..9de416f 100644 --- a/packages/agent-manager/package.json +++ b/packages/agent-manager/package.json @@ -1,6 +1,6 @@ { "name": "@ai-devkit/agent-manager", - "version": "0.12.0", + "version": "0.13.0", "description": "Standalone agent detection and management utilities for AI DevKit", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -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/ClaudeCodeAdapter.test.ts b/packages/agent-manager/src/__tests__/adapters/ClaudeCodeAdapter.test.ts index 9884cca..5363b17 100644 --- a/packages/agent-manager/src/__tests__/adapters/ClaudeCodeAdapter.test.ts +++ b/packages/agent-manager/src/__tests__/adapters/ClaudeCodeAdapter.test.ts @@ -408,6 +408,194 @@ describe('ClaudeCodeAdapter', () => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); + it('should prefer PID-file live status over JSONL-derived status and surface waitingFor in summary', async () => { + const startTime = new Date(); + const processes: ProcessInfo[] = [ + { pid: 55050, command: 'claude', cwd: '/project/wait', tty: 'ttys001', startTime }, + ]; + mockedListAgentProcesses.mockReturnValue(processes); + mockedEnrichProcesses.mockReturnValue(processes); + + const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-pid-wait-')); + const sessionsDir = path.join(tmpDir, 'sessions'); + const projectsDir = path.join(tmpDir, 'projects'); + const projDir = path.join(projectsDir, '-project-wait'); + fs.mkdirSync(sessionsDir, { recursive: true }); + fs.mkdirSync(projDir, { recursive: true }); + + const sessionId = 'wait-session'; + const jsonlPath = path.join(projDir, `${sessionId}.jsonl`); + // JSONL trails with permission-mode → parser would resolve to UNKNOWN. + // PID file's live status must win. + fs.writeFileSync(jsonlPath, [ + JSON.stringify({ type: 'user', timestamp: new Date().toISOString(), cwd: '/project/wait', message: { content: '/reddit-commenter' } }), + JSON.stringify({ type: 'permission-mode', timestamp: new Date().toISOString(), permissionMode: 'default' }), + ].join('\n')); + + fs.writeFileSync( + path.join(sessionsDir, '55050.json'), + JSON.stringify({ + pid: 55050, sessionId, cwd: '/project/wait', + startedAt: startTime.getTime(), + kind: 'interactive', entrypoint: 'cli', + status: 'waiting', waitingFor: 'approve Read', + }), + ); + + (adapter as any).sessionsDir = sessionsDir; + (adapter as any).projectsDir = projectsDir; + + const agents = await adapter.detectAgents(); + + expect(agents).toHaveLength(1); + expect(agents[0].status).toBe(AgentStatus.WAITING); + expect(agents[0].summary).toContain('/reddit-commenter'); + expect(agents[0].summary).toContain('waiting for approve Read'); + + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('should resolve PID-file status: "idle" to AgentStatus.IDLE even when JSONL would say UNKNOWN', async () => { + const startTime = new Date(); + const processes: ProcessInfo[] = [ + { pid: 55051, command: 'claude', cwd: '/project/idle', tty: 'ttys001', startTime }, + ]; + mockedListAgentProcesses.mockReturnValue(processes); + mockedEnrichProcesses.mockReturnValue(processes); + + const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-pid-idle-')); + const sessionsDir = path.join(tmpDir, 'sessions'); + const projectsDir = path.join(tmpDir, 'projects'); + const projDir = path.join(projectsDir, '-project-idle'); + fs.mkdirSync(sessionsDir, { recursive: true }); + fs.mkdirSync(projDir, { recursive: true }); + + const sessionId = 'idle-session'; + const jsonlPath = path.join(projDir, `${sessionId}.jsonl`); + fs.writeFileSync(jsonlPath, [ + JSON.stringify({ type: 'user', timestamp: new Date().toISOString(), cwd: '/project/idle', message: { content: 'hello' } }), + JSON.stringify({ type: 'permission-mode', timestamp: new Date().toISOString(), permissionMode: 'default' }), + ].join('\n')); + + fs.writeFileSync( + path.join(sessionsDir, '55051.json'), + JSON.stringify({ + pid: 55051, sessionId, cwd: '/project/idle', + startedAt: startTime.getTime(), + kind: 'interactive', entrypoint: 'cli', + status: 'idle', + }), + ); + + (adapter as any).sessionsDir = sessionsDir; + (adapter as any).projectsDir = projectsDir; + + const agents = await adapter.detectAgents(); + + expect(agents).toHaveLength(1); + expect(agents[0].status).toBe(AgentStatus.IDLE); + // No waitingFor in PID file → summary is just the last user message + expect(agents[0].summary).toBe('hello'); + + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('should fall back to JSONL-derived status when PID-file status is missing or unrecognized', async () => { + const startTime = new Date(); + const processes: ProcessInfo[] = [ + { pid: 55052, command: 'claude', cwd: '/project/legacy', tty: 'ttys001', startTime }, + ]; + mockedListAgentProcesses.mockReturnValue(processes); + mockedEnrichProcesses.mockReturnValue(processes); + + const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-pid-nostat-')); + const sessionsDir = path.join(tmpDir, 'sessions'); + const projectsDir = path.join(tmpDir, 'projects'); + const projDir = path.join(projectsDir, '-project-legacy'); + fs.mkdirSync(sessionsDir, { recursive: true }); + fs.mkdirSync(projDir, { recursive: true }); + + const sessionId = 'legacy-session'; + const jsonlPath = path.join(projDir, `${sessionId}.jsonl`); + // Last entry is assistant → JSONL parser yields WAITING. + fs.writeFileSync(jsonlPath, [ + JSON.stringify({ type: 'user', timestamp: new Date().toISOString(), cwd: '/project/legacy', message: { content: 'do the thing' } }), + JSON.stringify({ type: 'assistant', timestamp: new Date().toISOString() }), + ].join('\n')); + + // PID file with unrecognized status string — adapter must ignore it and use parser + fs.writeFileSync( + path.join(sessionsDir, '55052.json'), + JSON.stringify({ + pid: 55052, sessionId, cwd: '/project/legacy', + startedAt: startTime.getTime(), + kind: 'interactive', entrypoint: 'cli', + status: 'fantastical-future-state', + }), + ); + + (adapter as any).sessionsDir = sessionsDir; + (adapter as any).projectsDir = projectsDir; + + const agents = await adapter.detectAgents(); + + expect(agents).toHaveLength(1); + expect(agents[0].status).toBe(AgentStatus.WAITING); + + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('should pick up live status from PID file even when matched via --resume', async () => { + const sessionId = 'aaaaaaaa-1111-2222-3333-444444444444'; + const startTime = new Date(); + const processes: ProcessInfo[] = [ + { + pid: 55053, + command: `claude --resume ${sessionId}`, + cwd: '/project/resume-wait', + tty: 'ttys001', + startTime, + }, + ]; + mockedListAgentProcesses.mockReturnValue(processes); + mockedEnrichProcesses.mockReturnValue(processes); + + const tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-resume-wait-')); + const sessionsDir = path.join(tmpDir, 'sessions'); + const projectsDir = path.join(tmpDir, 'projects'); + const projDir = path.join(projectsDir, '-project-resume-wait'); + fs.mkdirSync(sessionsDir, { recursive: true }); + fs.mkdirSync(projDir, { recursive: true }); + + const jsonlPath = path.join(projDir, `${sessionId}.jsonl`); + fs.writeFileSync(jsonlPath, [ + JSON.stringify({ type: 'user', timestamp: new Date().toISOString(), cwd: '/project/resume-wait', message: { content: 'resumed work' } }), + JSON.stringify({ type: 'permission-mode', timestamp: new Date().toISOString(), permissionMode: 'default' }), + ].join('\n')); + + fs.writeFileSync( + path.join(sessionsDir, '55053.json'), + JSON.stringify({ + pid: 55053, sessionId, cwd: '/project/resume-wait', + startedAt: startTime.getTime(), + kind: 'interactive', entrypoint: 'cli', + status: 'waiting', waitingFor: 'approve Bash', + }), + ); + + (adapter as any).sessionsDir = sessionsDir; + (adapter as any).projectsDir = projectsDir; + + const agents = await adapter.detectAgents(); + + expect(agents).toHaveLength(1); + expect(agents[0].status).toBe(AgentStatus.WAITING); + expect(agents[0].summary).toContain('resumed work'); + expect(agents[0].summary).toContain('waiting for approve Bash'); + + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + it('should fall back to process-only when direct-matched JSONL becomes unreadable', async () => { const startTime = new Date(); const processes: ProcessInfo[] = [ @@ -914,7 +1102,7 @@ describe('ClaudeCodeAdapter', () => { }; const writeJsonl = (cwd: string, sessionId: string) => { - const encoded = cwd.replace(/\//g, '-'); + const encoded = cwd.replace(/[^a-zA-Z0-9]/g, '-'); const projDir = path.join(projectsDir, encoded); fs.mkdirSync(projDir, { recursive: true }); const filePath = path.join(projDir, `${sessionId}.jsonl`); @@ -1049,6 +1237,44 @@ describe('ClaudeCodeAdapter', () => { }); }); + describe('getProjectDir', () => { + const encode = (cwd: string) => (adapter as any).getProjectDir(cwd) as string; + + it('should replace path separators with hyphens', () => { + const expected = path.join((adapter as any).projectsDir, '-Users-foo-bar'); + expect(encode('/Users/foo/bar')).toBe(expected); + }); + + it('should encode underscores as hyphens (matches Claude Code CLI)', () => { + const expected = path.join((adapter as any).projectsDir, '-Users-foo-my-project'); + expect(encode('/Users/foo/my_project')).toBe(expected); + }); + + it('should encode dots as hyphens', () => { + const expected = path.join((adapter as any).projectsDir, '-Users-foo--worktrees-x'); + expect(encode('/Users/foo/.worktrees/x')).toBe(expected); + }); + + it('should collide paths that differ only in non-alphanumeric chars', () => { + // The encoding is intentionally lossy — callers must + // disambiguate via session JSONL contents, not dir name. + expect(encode('/a/b_c')).toBe(encode('/a/b-c')); + expect(encode('/a/b_c')).toBe(encode('/a/b.c')); + }); + + it('should resolve to a real session dir when cwd contains underscores', () => { + const cwd = '/Users/foo/my_project'; + const projectsDir = (adapter as any).projectsDir as string; + const expectedDir = path.join(projectsDir, '-Users-foo-my-project'); + fs.mkdirSync(expectedDir, { recursive: true }); + const sessionFile = path.join(expectedDir, 'session-underscore.jsonl'); + fs.writeFileSync(sessionFile, ''); + + expect(encode(cwd)).toBe(expectedDir); + expect(fs.existsSync(path.join(encode(cwd), 'session-underscore.jsonl'))).toBe(true); + }); + }); + describe('readSession', () => { it('should parse session file with timestamps, cwd, and entry type', () => { const readSession = (adapter as any).parser.readSession.bind((adapter as any).parser); 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/ClaudeCodeAdapter.ts b/packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts index a7a05b9..acbda8e 100644 --- a/packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts +++ b/packages/agent-manager/src/adapters/ClaudeCodeAdapter.ts @@ -28,14 +28,29 @@ interface PidFileEntry { startedAt: number; kind: string; entrypoint: string; + /** + * Authoritative live status published by the Claude Code process + * (e.g., 'running', 'waiting', 'idle'). Preferred over JSONL-derived + * status because trailing entries like 'permission-mode' / 'ai-title' + * can mask the real conversational state. + */ + status?: string; + /** Short description of what the agent is waiting on (e.g., "approve Read"). */ + waitingFor?: string; } /** * A process directly matched to a session via PID file (authoritative path). + * + * When the matching PID file also exposes live status/waitingFor metadata, + * those values are carried here so `mapSessionToAgent` can prefer them + * over the JSONL-derived heuristic. */ interface DirectMatch { process: ProcessInfo; sessionFile: SessionFile; + pidStatus?: AgentStatus; + waitingFor?: string; } /** Maximum allowed delta (ms) between process start time and PID file startedAt. */ @@ -106,10 +121,14 @@ export class ClaudeCodeAdapter implements AgentAdapter { const agents: AgentInfo[] = []; // Build agents from direct (resume + PID-file) matches - for (const { process: proc, sessionFile } of direct) { + for (const match of direct) { + const { process: proc, sessionFile } = match; const sessionData = this.parser.readSession(sessionFile.filePath, sessionFile.resolvedCwd); if (sessionData) { - agents.push(this.mapSessionToAgent(sessionData, proc, sessionFile)); + agents.push(this.mapSessionToAgent(sessionData, proc, sessionFile, { + pidStatus: match.pidStatus, + waitingFor: match.waitingFor, + })); } else { matchedPids.delete(proc.pid); } @@ -203,6 +222,12 @@ export class ClaudeCodeAdapter implements AgentAdapter { continue; } + // Best-effort: the PID file (if present for this proc) is the + // authoritative source of live status. We still match the session + // via --resume, but we read the PID file alongside to capture + // status/waitingFor. + const pidEntry = this.readMatchingPidFile(proc.pid, proc.startTime); + direct.push({ process: proc, sessionFile: { @@ -212,6 +237,8 @@ export class ClaudeCodeAdapter implements AgentAdapter { birthtimeMs: stat.birthtimeMs, resolvedCwd: proc.cwd, }, + pidStatus: this.mapPidStatus(pidEntry?.status), + waitingFor: pidEntry?.waitingFor, }); } @@ -223,6 +250,54 @@ export class ClaudeCodeAdapter implements AgentAdapter { return match?.[1] ?? null; } + /** + * Read and parse ~/.claude/sessions/.json, returning null on any + * I/O / parse failure or when the file is stale relative to the live + * process. + * + * "Stale" means the PID file's startedAt diverges from the process's + * start time by more than {@link PID_FILE_STALENESS_MS} — typically + * a previous Claude Code process recycled the same PID without cleanup. + */ + private readMatchingPidFile(pid: number, procStartTime?: Date): PidFileEntry | null { + const pidFilePath = path.join(this.sessionsDir, `${pid}.json`); + try { + const entry = JSON.parse( + fs.readFileSync(pidFilePath, 'utf-8'), + ) as PidFileEntry; + + if (procStartTime) { + const deltaMs = Math.abs(procStartTime.getTime() - entry.startedAt); + if (deltaMs > PID_FILE_STALENESS_MS) { + return null; + } + } + + return entry; + } catch { + return null; + } + } + + /** + * Map the PID file's live status string to {@link AgentStatus}. + * + * Returns undefined for missing / unrecognized values so the caller + * can fall back to JSONL-derived heuristics. + */ + private mapPidStatus(status: string | undefined): AgentStatus | undefined { + switch (status) { + case 'running': + return AgentStatus.RUNNING; + case 'waiting': + return AgentStatus.WAITING; + case 'idle': + return AgentStatus.IDLE; + default: + return undefined; + } + } + /** * Attempt to match each process to its session via ~/.claude/sessions/.json. * @@ -241,43 +316,32 @@ export class ClaudeCodeAdapter implements AgentAdapter { const fallback: ProcessInfo[] = []; for (const proc of processes) { - const pidFilePath = path.join(this.sessionsDir, `${proc.pid}.json`); - try { - const entry = JSON.parse( - fs.readFileSync(pidFilePath, 'utf-8'), - ) as PidFileEntry; - - // Stale-file guard: reject PID files from a previous process with the same PID - if (proc.startTime) { - const deltaMs = Math.abs(proc.startTime.getTime() - entry.startedAt); - if (deltaMs > PID_FILE_STALENESS_MS) { - fallback.push(proc); - continue; - } - } - - const projectDir = this.getProjectDir(entry.cwd); - const jsonlPath = path.join(projectDir, `${entry.sessionId}.jsonl`); + const entry = this.readMatchingPidFile(proc.pid, proc.startTime); + if (!entry) { + fallback.push(proc); + continue; + } - if (!fs.existsSync(jsonlPath)) { - fallback.push(proc); - continue; - } + const projectDir = this.getProjectDir(entry.cwd); + const jsonlPath = path.join(projectDir, `${entry.sessionId}.jsonl`); - direct.push({ - process: proc, - sessionFile: { - sessionId: entry.sessionId, - filePath: jsonlPath, - projectDir, - birthtimeMs: entry.startedAt, - resolvedCwd: entry.cwd, - }, - }); - } catch { - // PID file absent, unreadable, or malformed — fall back per-process + if (!fs.existsSync(jsonlPath)) { fallback.push(proc); + continue; } + + direct.push({ + process: proc, + sessionFile: { + sessionId: entry.sessionId, + filePath: jsonlPath, + projectDir, + birthtimeMs: entry.startedAt, + resolvedCwd: entry.cwd, + }, + pidStatus: this.mapPidStatus(entry.status), + waitingFor: entry.waitingFor, + }); } return { direct, fallback }; @@ -286,11 +350,18 @@ export class ClaudeCodeAdapter implements AgentAdapter { /** * Derive the Claude Code project directory for a given CWD. * - * Claude Code encodes paths by replacing '/' with '-': - * /Users/foo/bar → ~/.claude/projects/-Users-foo-bar/ + * Claude Code encodes paths by replacing every non-alphanumeric + * character with '-', so '/', '_', '.', spaces, etc. all collapse: + * /Users/foo/bar → -Users-foo-bar + * /Users/foo/my_project → -Users-foo-my-project + * /Users/foo/.worktrees/x → -Users-foo--worktrees-x + * + * The encoding is lossy — multiple real paths can collide on the + * same encoded dir. Callers that need to disambiguate must read the + * `cwd` field inside each session JSONL. */ private getProjectDir(cwd: string): string { - const encoded = cwd.replace(/\//g, '-'); + const encoded = cwd.replace(/[^a-zA-Z0-9]/g, '-'); return path.join(this.projectsDir, encoded); } @@ -298,12 +369,22 @@ export class ClaudeCodeAdapter implements AgentAdapter { session: ClaudeSession, processInfo: ProcessInfo, sessionFile: SessionFile, + liveInfo?: { pidStatus?: AgentStatus; waitingFor?: string }, ): AgentInfo { + // Live PID-file status is authoritative when present — JSONL-derived + // status mis-classifies sessions whose latest entry is a UI-state + // event like `permission-mode` or `ai-title`. + const status = liveInfo?.pidStatus ?? this.parser.determineStatus(session); + const baseSummary = session.lastUserMessage || 'Session started'; + const summary = status === AgentStatus.WAITING && liveInfo?.waitingFor + ? `${baseSummary} — waiting for ${liveInfo.waitingFor}` + : baseSummary; + return { name: generateAgentName(processInfo.cwd, processInfo.pid), type: this.type, - status: this.parser.determineStatus(session), - summary: session.lastUserMessage || 'Session started', + status, + summary, pid: processInfo.pid, projectPath: sessionFile.resolvedCwd || processInfo.cwd || '', sessionId: sessionFile.sessionId, 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/package.json b/packages/cli/package.json index 0664b1a..4e00987 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "ai-devkit", - "version": "0.29.0", + "version": "0.30.0", "description": "A CLI toolkit for AI-assisted software development with phase templates and environment setup", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -27,7 +27,7 @@ "author": "", "license": "MIT", "dependencies": { - "@ai-devkit/agent-manager": "0.12.0", + "@ai-devkit/agent-manager": "0.13.0", "@ai-devkit/channel-connector": "0.5.0", "@ai-devkit/memory": "0.10.0", "chalk": "^4.1.2", 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; } diff --git a/skills/document-code/SKILL.md b/skills/document-code/SKILL.md index a3a780b..0666136 100644 --- a/skills/document-code/SKILL.md +++ b/skills/document-code/SKILL.md @@ -35,9 +35,33 @@ Build structured understanding of code entry points with an analysis-first workf 5. Create Documentation - Normalize name to kebab-case (`calculateTotalPrice` → `calculate-total-price`). -- Create `docs/ai/implementation/knowledge-{name}.md` using the Output Template. +- Create `docs/ai/implementation/knowledge-{name}.md` using the Output Template — this is the source of truth. - Include mermaid diagrams when they clarify flows or relationships. +6. Offer HTML Artifact +- After the markdown is written, ask the user once: "Also generate an HTML artifact for easier scanning? (y/N)". +- If yes, generate sibling `docs/ai/implementation/knowledge-{name}.html` per the HTML Artifact spec. Regenerate from the markdown on subsequent runs; never hand-edit. +- If no or no response, stop here — markdown alone is a complete result. + +## HTML Artifact + +Generated only when the user opts in at step 6. A self-contained HTML file optimized for scanning, not reference reading. Complements the markdown — does not replace it. + +Constraints: +- Single file. Inline CSS. No build step. Only external asset allowed is mermaid via CDN (`https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.min.js`). +- Card-based grid layout, not a long scroll. The reader should capture structure at a glance. +- Responsive down to laptop width. Print-friendly. +- No interactivity beyond collapsible deep-dives and mermaid pan/zoom. + +Section mapping (from the Output Template): +- Overview → hero card: title, one-line purpose, language/type badges. +- Implementation Details → grid of sectioned cards with short bullets, not prose. +- Dependencies → graph card (mermaid) plus a categorized list (imports, calls, services, external). +- Visual Diagrams → full-width rendered mermaid blocks. +- Additional Insights → callout boxes, color-coded by kind (info, warning, risk). +- Next Steps → checklist card. +- Metadata → compact footer (date, depth, files touched). + ## Red Flags and Rationalizations | Rationalization | Why It's Wrong | Do Instead | @@ -48,8 +72,9 @@ Build structured understanding of code entry points with an analysis-first workf ## Validation - Documentation covers all Output Template sections. +- If an HTML artifact was generated, it opens standalone in a browser, renders mermaid, and reflects the markdown content (no drift). - Summarize key insights, open questions, and related areas for deeper dives. -- Confirm file path and remind to commit. +- Confirm file path(s) and remind to commit. ## Output Template - Overview