Skip to content

feat(agent-manager): add OpenCode adapter for agent control#82

Merged
codeaholicguy merged 3 commits into
codeaholicguy:mainfrom
nothingrotf:feat/opencode-adapter
May 13, 2026
Merged

feat(agent-manager): add OpenCode adapter for agent control#82
codeaholicguy merged 3 commits into
codeaholicguy:mainfrom
nothingrotf:feat/opencode-adapter

Conversation

@nothingrotf
Copy link
Copy Markdown
Contributor

feat(agent-manager): add OpenCode adapter for agent control

Summary

Adds an OpenCodeAdapter to @ai-devkit/agent-manager so OpenCode sessions can be detected, listed, opened, and controlled through the same ai-devkit agent commands that already work for Claude Code, Codex, and Gemini CLI.

OpenCode stores its sessions in a SQLite database (~/.local/share/opencode/opencode.db, XDG-aware) rather than JSONL files like the other agents. The adapter handles that difference internally — the rest of the agent-control surface (process detection, terminal focus, agent list/sessions/send/detail/open) is unchanged.

Credits / inspiration

The SQLite schema reading approach (tables session, message, part and the JSON-in-data extraction) was adapted from maquinista-labs/maquinista, specifically internal/monitor/source_opencode.go. The Go implementation is monitor-oriented and depends on a Postgres-backed session map; this PR adapts the same data-model knowledge to ai-devkit's AgentAdapter interface (TypeScript, file/process-driven, no external services).

Special thanks to @otaviocarvalho, maintainer of maquinista-labs/maquinista, for the reference implementation and for pointing me at the relevant source file.

What changed

  • New adapter: packages/agent-manager/src/adapters/OpenCodeAdapter.ts
    • Implements detectAgents, canHandle, getConversation, listSessions
    • Opens OpenCode's SQLite DB read-only via better-sqlite3
    • Resolves DB path from $XDG_DATA_HOME (fallback ~/.local/share)
    • Encodes (dbPath, sessionId) into AgentInfo.sessionFilePath as <dbPath>::<sessionId> so the existing interface stays untouched
  • Type union: AgentType gains 'opencode' (AgentAdapter.ts)
  • Status logic: tuned to OpenCode's data model — RUNNING while a tool part has state.status='running', RUNNING for parts updated in the last 5 s (mid-turn), WAITING for an assistant turn that has been quiet beyond that, IDLE past 5 min
  • Wiring: exports in adapters/index.ts + index.ts, registration in packages/cli/src/commands/agent.ts, TYPE_LABELS entry
  • Tests: 23 new tests in __tests__/adapters/OpenCodeAdapter.test.ts covering process matching, status transitions (tool-running, mid-turn, finished-turn, idle), conversation reconstruction, and session listing
  • Deps: better-sqlite3 added to packages/agent-manager/package.json (already in the monorepo via @ai-devkit/memory)
  • README: OpenCode row in the supported-agents table moved from ❌ Not Ready to 🚧 Testing

Status determination

Decided in this order (first match wins):

Condition Status
lastActive older than 5 min IDLE
Last part is a tool with state.status='running' RUNNING
Last part updated within the last 5 s RUNNING (mid-turn)
Last message role is assistant (turn finished, past freshness) WAITING
Otherwise RUNNING

The tool-running and freshness checks fix the case where a long-running build/tool would otherwise be misreported as WAITING just because its owning message has role='assistant'.

Test plan

  • npm test in packages/agent-manager passes (288 tests, 23 new)
  • npx tsc --noEmit clean in both agent-manager and cli
  • Manual: ai-devkit agent list against a project with a live OpenCode session shows the agent with correct CWD, summary (first user message), and status
  • Manual: status transitions from RUNNINGWAITING correctly when a turn finishes
  • Manual: ai-devkit agent send to a waiting OpenCode session delivers the message via the existing tmux/iTerm2/Terminal.app path
  • Manual: ai-devkit agent detail reconstructs the conversation from SQLite parts

Notes for reviewers

  • The sessionFilePath encoding <dbPath>::<sessionId> is internal to this adapter — other adapters and the CLI treat it as an opaque string. Chose :: over a custom URI to avoid touching the shared interface.
  • better-sqlite3 is required dynamically (require()) so test environments can mock it cleanly; production code always exercises the same path.
  • No changes to TerminalFocusManager or TtyWriter — OpenCode runs in a normal TTY, so agent open/agent send reuse the existing tmux/AppleScript path with no special-casing.

nothingrotf and others added 2 commits May 12, 2026 17:51
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
@codeaholicguy
Copy link
Copy Markdown
Owner

Thanks for the contribution, @nothingrotf, really clean adapter.

I just have a few minor comments. The rest LGTM.

if (ref.dbPath === this.dbPath) {
db = this.openDb();
} else {
db = this.openDbAt(ref.dbPath);
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems not likely to happen.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — sessionFilePath is always our own encoding, so the else branch is dead code. Dropped it in 838597a.

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;
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use import instead of require('better-sqlite3')

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

jest.mock('better-sqlite3', …) works the same with an ES import

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 838597a — switched to import Database from 'better-sqlite3', tests still pass.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed — kept the jest.mock setup as-is and it intercepts the ES import the same way.


private resetDb(): void {
if (this.db) {
try { this.db.close(); } catch { /* ignore */ }
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is only called when we have a query exception. How about the user terminates the process or the command finish running?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point — the handle was leaking on normal command completion. Renamed resetDb to a public close() and registered hooks on exit, SIGINT, and SIGTERM in the constructor (838597a). Now the DB is released on graceful exit, Ctrl+C, and signal-driven shutdown — not only on query exceptions.

- 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) <noreply@anthropic.com>
@nothingrotf
Copy link
Copy Markdown
Contributor Author

Thanks for the review @codeaholicguy! Addressed all four comments in 838597a:

  • getConversation: dropped the unreachable dbPath else branch.
  • Switched from dynamic require to static import Database from 'better-sqlite3'.
  • Renamed resetDb → public close() and registered exit / SIGINT / SIGTERM hooks so the handle is released on graceful exit and signal-driven shutdown, not only on query exceptions.

All 24 OpenCodeAdapter tests + the wider 288-test suite still green. Ready for another look whenever you have a sec.

@codeaholicguy
Copy link
Copy Markdown
Owner

Thanks for quickly addressing the comments. LGTM.

@codeaholicguy codeaholicguy merged commit 9106ad3 into codeaholicguy:main May 13, 2026
7 checks passed
@nothingrotf nothingrotf deleted the feat/opencode-adapter branch May 13, 2026 16:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants