prodboard 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # prodboard
2
2
 
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#6](https://github.com/G4brym/prodboard/pull/6) [`030c913`](https://github.com/G4brym/prodboard/commit/030c9136dc344e93616e0ef6014bab25d45b50d5) Thanks [@G4brym](https://github.com/G4brym)! - Add OpenCode support, prodboard-managed worktrees, tmux session wrapping, configurable base path, and web UI
8
+
9
+ ## 0.1.3
10
+
11
+ ### Patch Changes
12
+
13
+ - 79dd032: Auto-initialize prodboard when the MCP server starts, removing the need to run `prodboard init` separately
14
+
3
15
  ## 0.1.2
4
16
 
5
17
  ### Patch Changes
package/README.md CHANGED
@@ -1,22 +1,27 @@
1
1
  # prodboard
2
2
 
3
- Give Claude Code a persistent task board and a cron scheduler so it can manage work across sessions.
3
+ Give AI coding agents a persistent task board and a cron scheduler so they can manage work across sessions.
4
4
 
5
- **The problem:** Claude Code loses context between sessions. It can't remember what tasks exist, what's in progress, or what to work on next. There's no way to schedule it to run recurring jobs like daily triage or nightly CI.
5
+ **The problem:** AI coding agents lose context between sessions. They can't remember what tasks exist, what's in progress, or what to work on next. There's no way to schedule them to run recurring jobs like daily triage or nightly CI.
6
6
 
7
- **The solution:** prodboard is a local issue tracker backed by SQLite that Claude Code can read and write through MCP tools. It also includes a cron daemon that spawns Claude Code on a schedule to work through tasks autonomously.
7
+ **The solution:** prodboard is a local issue tracker backed by SQLite that agents can read and write through MCP tools. It includes a cron daemon that spawns agents on a schedule, with optional tmux session wrapping and git worktree isolation.
8
8
 
9
9
  ```
10
10
  You (CLI) ──┐
11
- ├──▶ SQLite DB ◀── MCP Server ◀── Claude Code
12
- Cron Daemon ──┘
11
+ ├──▶ SQLite DB ◀── MCP Server ◀── Claude Code / OpenCode
12
+ Cron Daemon ──┘
13
+ Web UI ─────────────────┘
13
14
  ```
14
15
 
15
16
  ## What You Get
16
17
 
17
- - **An issue board Claude Code can use** — Claude reads, creates, updates, and completes issues via MCP tools during any session
18
- - **Scheduled Claude Code runs** — Define cron jobs that spawn Claude Code to triage issues, run maintenance, or work through the backlog
19
- - **A CLI you can use too** — Same board, human-friendly commands. Add issues, check status, review what Claude did
18
+ - **An issue board your agent can use** — The agent reads, creates, updates, and completes issues via MCP tools during any session
19
+ - **Scheduled agent runs** — Define cron jobs that spawn your agent to triage issues, run maintenance, or work through the backlog
20
+ - **Multiple agent support** — Works with Claude Code (default) and OpenCode
21
+ - **tmux sessions** — Running agents are wrapped in tmux sessions you can attach to and watch live
22
+ - **Git worktree isolation** — Each scheduled run gets its own worktree so concurrent runs don't conflict
23
+ - **Web UI** — Optional browser-based kanban board for managing issues, schedules, and runs
24
+ - **A CLI you can use too** — Same board, human-friendly commands. Add issues, check status, review what the agent did
20
25
  - **Everything local** — Single SQLite file at `~/.prodboard/db.sqlite`. No servers, no accounts, no cloud
21
26
 
22
27
  ## Quick Start
@@ -25,10 +30,7 @@ Cron Daemon ──┘
25
30
  # Install
26
31
  bun install -g prodboard
27
32
 
28
- # Initialize
29
- prodboard init
30
-
31
- # Connect Claude Code to the board
33
+ # Connect Claude Code to the board (auto-initializes on first use)
32
34
  claude mcp add prodboard -- bunx prodboard mcp
33
35
  ```
34
36
 
@@ -198,6 +200,30 @@ These are the tools Claude Code sees when connected to the board:
198
200
 
199
201
  MCP resources: `prodboard://issues` (board summary) and `prodboard://schedules` (active schedules).
200
202
 
203
+ ## Supported Agents
204
+
205
+ prodboard works with multiple AI coding agents. Set `daemon.agent` in your config:
206
+
207
+ | Agent | Config value | Notes |
208
+ |-------|-------------|-------|
209
+ | **Claude Code** | `"claude"` (default) | Uses `claude` CLI with `--dangerously-skip-permissions` |
210
+ | **OpenCode** | `"opencode"` | Uses `opencode run` with JSON output. Prodboard auto-starts `opencode serve` if needed |
211
+
212
+ OpenCode-specific settings:
213
+
214
+ ```jsonc
215
+ {
216
+ "daemon": {
217
+ "agent": "opencode",
218
+ "opencode": {
219
+ "serverUrl": null, // auto-detect or override (e.g., "http://localhost:4096")
220
+ "model": null, // e.g., "anthropic/claude-sonnet-4-20250514"
221
+ "agent": null // opencode agent name
222
+ }
223
+ }
224
+ }
225
+ ```
226
+
201
227
  ## Configuration
202
228
 
203
229
  Config file: `~/.prodboard/config.jsonc`
@@ -210,17 +236,94 @@ Config file: `~/.prodboard/config.jsonc`
210
236
  "idPrefix": ""
211
237
  },
212
238
  "daemon": {
239
+ "agent": "claude", // "claude" or "opencode"
240
+ "basePath": null, // base path for worktrees and runs (null = use schedule workdir)
241
+ "useTmux": true, // wrap agent runs in tmux sessions
213
242
  "maxConcurrentRuns": 2,
214
243
  "maxTurns": 50,
215
244
  "hardMaxTurns": 200,
216
245
  "runTimeoutSeconds": 1800,
217
246
  "runRetentionDays": 30,
218
247
  "logLevel": "info",
248
+ "logMaxSizeMb": 10, // max size per log file in MB
249
+ "logMaxFiles": 5, // max number of rotated log files
250
+ "defaultAllowedTools": [...], // tools allowed for git-repo runs
251
+ "nonGitDefaultAllowedTools": [...], // tools allowed for non-git runs
252
+ "useWorktrees": "auto" // "auto", "always", or "never"
253
+ },
254
+ "webui": {
255
+ "enabled": false, // enable the web UI
256
+ "port": 3838,
257
+ "hostname": "127.0.0.1",
258
+ "password": null // set a password to require login
259
+ }
260
+ }
261
+ ```
262
+
263
+ ## Web UI
264
+
265
+ prodboard includes an optional browser-based interface for managing issues, schedules, and runs.
266
+
267
+ To enable it, set `webui.enabled` in your config:
268
+
269
+ ```jsonc
270
+ {
271
+ "webui": {
272
+ "enabled": true,
273
+ "port": 3838,
274
+ "password": "your-secret" // optional — null for no auth
275
+ }
276
+ }
277
+ ```
278
+
279
+ Start the daemon and open `http://127.0.0.1:3838`. The web UI provides:
280
+
281
+ - Kanban board with drag-and-drop issue management
282
+ - Schedule creation and editing
283
+ - Run monitoring with status, cost, and token usage
284
+ - Password protection when `password` is set
285
+
286
+ ## tmux Sessions
287
+
288
+ When `daemon.useTmux` is `true` (the default) and tmux is installed, each agent run is wrapped in a detached tmux session. This lets you attach and watch the agent work in real time:
289
+
290
+ ```bash
291
+ # List active prodboard sessions
292
+ tmux list-sessions | grep prodboard
293
+
294
+ # Attach to a running agent
295
+ tmux attach -t prodboard-<run-id-prefix>
296
+ ```
297
+
298
+ The session name is `prodboard-` followed by the first 8 characters of the run ID (visible in `prodboard schedule logs`).
299
+
300
+ If tmux is not installed, runs fall back to direct process spawning with piped stdout. A warning is logged at daemon startup.
301
+
302
+ ## Git Worktrees
303
+
304
+ prodboard creates isolated git worktrees for each scheduled run, so concurrent runs in the same repository don't conflict.
305
+
306
+ **Requirement:** You must set `daemon.basePath` in your config for worktrees to work. This tells prodboard where to create the `.worktrees/` directory. If `basePath` is `null` (the default), worktrees are disabled regardless of the `useWorktrees` setting.
307
+
308
+ ```jsonc
309
+ {
310
+ "daemon": {
311
+ "basePath": "/home/you/my-project", // required for worktrees
219
312
  "useWorktrees": "auto"
220
313
  }
221
314
  }
222
315
  ```
223
316
 
317
+ | `useWorktrees` | Behavior |
318
+ |---------------|----------|
319
+ | `"auto"` (default) | Create worktrees when `basePath` is set, the directory is a git repo, and the schedule allows it |
320
+ | `"always"` | Always create worktrees (fails if directory is not a git repo) |
321
+ | `"never"` | Never create worktrees |
322
+
323
+ Worktrees are created under `<basePath>/.worktrees/<run-id>` on a branch named `prodboard/<run-id>`, and automatically cleaned up (directory + branch deleted) after the run completes.
324
+
325
+ Per-schedule control: set `use_worktree` to `false` on a schedule to skip worktree creation for that specific job.
326
+
224
327
  ## Scheduler Details
225
328
 
226
329
  ### Cron Syntax
@@ -285,10 +388,14 @@ prodboard install --force
285
388
 
286
389
  **MCP not connecting** — Verify `claude mcp add prodboard -- bunx prodboard mcp` was run, or check `~/.prodboard/mcp.json`.
287
390
 
288
- **Daemon not starting** — Check `~/.prodboard/logs/daemon.log`. Make sure `claude` CLI is installed and `ANTHROPIC_API_KEY` is set.
391
+ **Daemon not starting** — Check `~/.prodboard/logs/daemon.log`. Make sure your agent CLI is installed (`claude` or `opencode`) and `ANTHROPIC_API_KEY` is set.
289
392
 
290
393
  **Stale PID file** — The daemon crashed. Run `prodboard daemon` to restart (auto-cleans stale PIDs).
291
394
 
395
+ **tmux not working** — Install tmux (`apt install tmux` / `brew install tmux`). The daemon falls back to direct spawning without it.
396
+
397
+ **Worktree errors** — Ensure the working directory is a git repo with at least one commit. Set `useWorktrees: "never"` to disable.
398
+
292
399
  ## Development
293
400
 
294
401
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prodboard",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "Self-hosted, CLI-first issue tracker and cron scheduler for AI coding agents",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -33,7 +33,8 @@
33
33
  "release": "bun run test && bun run typecheck && npm publish"
34
34
  },
35
35
  "dependencies": {
36
- "@modelcontextprotocol/sdk": "^1.12.1"
36
+ "@modelcontextprotocol/sdk": "^1.12.1",
37
+ "hono": "^4.12.5"
37
38
  },
38
39
  "engines": {
39
40
  "bun": ">=1.0.0"
@@ -46,6 +47,7 @@
46
47
  "CHANGELOG.md"
47
48
  ],
48
49
  "devDependencies": {
50
+ "@changesets/changelog-github": "^0.5.2",
49
51
  "@changesets/cli": "^2.29.8",
50
52
  "@types/bun": "^1.3.9"
51
53
  }
@@ -0,0 +1,107 @@
1
+ import * as path from "path";
2
+ import { PRODBOARD_DIR } from "../config.ts";
3
+ import { getLastSessionId } from "../queries/runs.ts";
4
+ import type { AgentDriver, AgentRunContext, AgentResult, StreamEvent } from "./types.ts";
5
+
6
+ export class ClaudeDriver implements AgentDriver {
7
+ readonly name = "claude";
8
+
9
+ buildCommand(ctx: AgentRunContext): string[] {
10
+ const { schedule, config, env, resolvedPrompt, db } = ctx;
11
+ const args: string[] = ["claude"];
12
+
13
+ args.push("-p", resolvedPrompt);
14
+ args.push("--dangerously-skip-permissions");
15
+ args.push("--verbose", "--output-format", "stream-json");
16
+
17
+ const mcpConfigPath = path.join(PRODBOARD_DIR, "mcp.json");
18
+ args.push("--mcp-config", mcpConfigPath);
19
+
20
+ const systemPromptFile = env.hasGit
21
+ ? path.join(PRODBOARD_DIR, "system-prompt.md")
22
+ : path.join(PRODBOARD_DIR, "system-prompt-nogit.md");
23
+ args.push("--append-system-prompt-file", systemPromptFile);
24
+
25
+ const scheduleTurns = schedule.max_turns ?? config.daemon.maxTurns;
26
+ const maxTurns = Math.min(scheduleTurns, config.daemon.hardMaxTurns);
27
+ args.push("--max-turns", String(maxTurns));
28
+
29
+ let tools: string[];
30
+ if (schedule.allowed_tools) {
31
+ try {
32
+ tools = JSON.parse(schedule.allowed_tools);
33
+ } catch {
34
+ tools = env.hasGit ? config.daemon.defaultAllowedTools : config.daemon.nonGitDefaultAllowedTools;
35
+ }
36
+ } else if (!env.hasGit) {
37
+ tools = config.daemon.nonGitDefaultAllowedTools;
38
+ } else {
39
+ tools = config.daemon.defaultAllowedTools;
40
+ }
41
+ for (const tool of tools) {
42
+ args.push("--allowedTools", tool);
43
+ }
44
+
45
+ if (schedule.persist_session && db) {
46
+ const lastSessionId = getLastSessionId(db, schedule.id);
47
+ if (lastSessionId) {
48
+ args.push("--resume", lastSessionId);
49
+ }
50
+ }
51
+
52
+ if (schedule.agents_json) {
53
+ args.push("--agents", schedule.agents_json);
54
+ }
55
+
56
+ return args;
57
+ }
58
+
59
+ parseEvent(line: string): StreamEvent | null {
60
+ try {
61
+ return JSON.parse(line.trim());
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+
67
+ extractResult(events: StreamEvent[]): AgentResult {
68
+ let tokens_in = 0;
69
+ let tokens_out = 0;
70
+ let cost_usd = 0;
71
+ let session_id: string | null = null;
72
+ const tools_used = new Set<string>();
73
+ const issues_touched = new Set<string>();
74
+
75
+ for (const event of events) {
76
+ if (event.type === "init" && event.session_id) {
77
+ session_id = event.session_id;
78
+ }
79
+ if (event.type === "tool_use" && event.tool) {
80
+ tools_used.add(event.tool);
81
+ if (event.tool.startsWith("mcp__prodboard__") && event.tool_input?.id) {
82
+ issues_touched.add(event.tool_input.id);
83
+ }
84
+ if (event.tool.startsWith("mcp__prodboard__") && event.tool_input?.issue_id) {
85
+ issues_touched.add(event.tool_input.issue_id);
86
+ }
87
+ }
88
+ if (event.type === "result") {
89
+ if (event.result?.tokens_in) tokens_in = event.result.tokens_in;
90
+ if (event.result?.tokens_out) tokens_out = event.result.tokens_out;
91
+ if (event.result?.cost_usd) cost_usd = event.result.cost_usd;
92
+ }
93
+ if (event.tokens_in) tokens_in = event.tokens_in;
94
+ if (event.tokens_out) tokens_out = event.tokens_out;
95
+ if (event.cost_usd) cost_usd = event.cost_usd;
96
+ }
97
+
98
+ return {
99
+ tokens_in,
100
+ tokens_out,
101
+ cost_usd,
102
+ session_id,
103
+ tools_used: [...tools_used],
104
+ issues_touched: [...issues_touched],
105
+ };
106
+ }
107
+ }
@@ -0,0 +1,18 @@
1
+ import type { Config } from "../types.ts";
2
+ import type { AgentDriver } from "./types.ts";
3
+ import { ClaudeDriver } from "./claude.ts";
4
+ import { OpenCodeDriver } from "./opencode.ts";
5
+
6
+ export function createAgentDriver(config: Config): AgentDriver {
7
+ switch (config.daemon.agent) {
8
+ case "opencode":
9
+ return new OpenCodeDriver();
10
+ case "claude":
11
+ default:
12
+ return new ClaudeDriver();
13
+ }
14
+ }
15
+
16
+ export type { AgentDriver, AgentRunContext, AgentResult, StreamEvent } from "./types.ts";
17
+ export { ClaudeDriver } from "./claude.ts";
18
+ export { OpenCodeDriver } from "./opencode.ts";
@@ -0,0 +1,108 @@
1
+ import type { AgentDriver, AgentRunContext, AgentResult, StreamEvent } from "./types.ts";
2
+ import { getLastSessionId } from "../queries/runs.ts";
3
+
4
+ export class OpenCodeDriver implements AgentDriver {
5
+ readonly name = "opencode";
6
+
7
+ buildCommand(ctx: AgentRunContext): string[] {
8
+ const { schedule, config, resolvedPrompt, workdir, db } = ctx;
9
+ const args: string[] = ["opencode", "run", resolvedPrompt];
10
+
11
+ args.push("--format", "json");
12
+ args.push("--dir", workdir);
13
+
14
+ const opencode = config.daemon.opencode;
15
+
16
+ if (opencode.serverUrl) {
17
+ args.push("--attach", opencode.serverUrl);
18
+ }
19
+
20
+ if (opencode.model) {
21
+ args.push("--model", opencode.model);
22
+ }
23
+
24
+ if (opencode.agent) {
25
+ args.push("--agent", opencode.agent);
26
+ }
27
+
28
+ if (schedule.persist_session && db) {
29
+ const lastSessionId = getLastSessionId(db, schedule.id);
30
+ if (lastSessionId) {
31
+ args.push("--session", lastSessionId, "--continue");
32
+ }
33
+ }
34
+
35
+ return args;
36
+ }
37
+
38
+ parseEvent(line: string): StreamEvent | null {
39
+ let text = line.trim();
40
+ if (!text) return null;
41
+
42
+ // Strip SSE data: prefix
43
+ if (text.startsWith("data: ")) {
44
+ text = text.slice(6);
45
+ }
46
+
47
+ try {
48
+ return JSON.parse(text);
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
53
+
54
+ extractResult(events: StreamEvent[]): AgentResult {
55
+ let tokens_in = 0;
56
+ let tokens_out = 0;
57
+ let cost_usd = 0;
58
+ let session_id: string | null = null;
59
+ const tools_used = new Set<string>();
60
+ const issues_touched = new Set<string>();
61
+
62
+ for (const event of events) {
63
+ // OpenCode session.updated events
64
+ if (event.type === "session.updated" && event.session?.id) {
65
+ session_id = event.session.id;
66
+ }
67
+ if (event.type === "session.updated" && event.session?.usage) {
68
+ const usage = event.session.usage;
69
+ if (usage.input_tokens) tokens_in = usage.input_tokens;
70
+ if (usage.output_tokens) tokens_out = usage.output_tokens;
71
+ if (usage.cost_usd) cost_usd = usage.cost_usd;
72
+ }
73
+
74
+ // OpenCode tool events
75
+ if (event.type === "tool_use" && event.tool) {
76
+ tools_used.add(event.tool);
77
+ if (event.tool.startsWith("mcp__prodboard__") && event.tool_input?.id) {
78
+ issues_touched.add(event.tool_input.id);
79
+ }
80
+ if (event.tool.startsWith("mcp__prodboard__") && event.tool_input?.issue_id) {
81
+ issues_touched.add(event.tool_input.issue_id);
82
+ }
83
+ }
84
+
85
+ // Also handle message.part.updated events with tool info
86
+ if (event.type === "message.part.updated" && event.part?.type === "tool-invocation") {
87
+ if (event.part.toolName) {
88
+ tools_used.add(event.part.toolName);
89
+ }
90
+ }
91
+
92
+ // Fallback top-level fields
93
+ if (event.session_id) session_id = event.session_id;
94
+ if (event.tokens_in) tokens_in = event.tokens_in;
95
+ if (event.tokens_out) tokens_out = event.tokens_out;
96
+ if (event.cost_usd) cost_usd = event.cost_usd;
97
+ }
98
+
99
+ return {
100
+ tokens_in,
101
+ tokens_out,
102
+ cost_usd,
103
+ session_id,
104
+ tools_used: [...tools_used],
105
+ issues_touched: [...issues_touched],
106
+ };
107
+ }
108
+ }
@@ -0,0 +1,41 @@
1
+ import type { Database } from "bun:sqlite";
2
+ import type { Config, Schedule, Run, EnvironmentInfo } from "../types.ts";
3
+
4
+ export interface AgentResult {
5
+ tokens_in: number;
6
+ tokens_out: number;
7
+ cost_usd: number;
8
+ session_id: string | null;
9
+ tools_used: string[];
10
+ issues_touched: string[];
11
+ }
12
+
13
+ export interface AgentRunContext {
14
+ schedule: Schedule;
15
+ run: Run;
16
+ config: Config;
17
+ env: EnvironmentInfo;
18
+ resolvedPrompt: string;
19
+ workdir: string;
20
+ db: Database;
21
+ }
22
+
23
+ export interface StreamEvent {
24
+ type: string;
25
+ session_id?: string;
26
+ tool?: string;
27
+ tool_input?: any;
28
+ result?: {
29
+ tokens_in?: number;
30
+ tokens_out?: number;
31
+ cost_usd?: number;
32
+ };
33
+ [key: string]: any;
34
+ }
35
+
36
+ export interface AgentDriver {
37
+ readonly name: string;
38
+ buildCommand(ctx: AgentRunContext): string[];
39
+ parseEvent(line: string): StreamEvent | null;
40
+ extractResult(events: StreamEvent[]): AgentResult;
41
+ }
@@ -71,7 +71,8 @@ export async function install(args: string[]): Promise<void> {
71
71
  const alreadyInstalled = fs.existsSync(SERVICE_PATH);
72
72
 
73
73
  if (alreadyInstalled && !flags.force) {
74
- console.log("prodboard is already installed as a systemd service.");
74
+ console.log("prodboard is already installed as a systemd service. Restarting...");
75
+ await runSystemctl("restart", SERVICE_NAME);
75
76
  const { stdout } = await runSystemctl("status", SERVICE_NAME);
76
77
  console.log(stdout);
77
78
  return;
@@ -99,7 +100,7 @@ export async function install(args: string[]): Promise<void> {
99
100
  process.exit(1);
100
101
  }
101
102
 
102
- const start = await runSystemctl("start", SERVICE_NAME);
103
+ const start = await runSystemctl("restart", SERVICE_NAME);
103
104
  if (start.exitCode !== 0) {
104
105
  console.error("Failed to start service:", start.stderr);
105
106
  process.exit(1);
@@ -251,6 +251,7 @@ export async function scheduleRun(args: string[], dbOverride?: Database): Promis
251
251
  prompt_used: schedule.prompt,
252
252
  });
253
253
 
254
+ // CLI schedule run is a lightweight path without tmux/worktree support
254
255
  const em = new ExecutionManager(db, config);
255
256
  await em.executeRun(schedule, run);
256
257
 
package/src/config.ts CHANGED
@@ -72,6 +72,14 @@ export function getDefaults(): Config {
72
72
  idPrefix: "",
73
73
  },
74
74
  daemon: {
75
+ agent: "claude",
76
+ basePath: null,
77
+ useTmux: true,
78
+ opencode: {
79
+ serverUrl: null,
80
+ model: null,
81
+ agent: null,
82
+ },
75
83
  maxConcurrentRuns: 2,
76
84
  maxTurns: 50,
77
85
  hardMaxTurns: 200,
@@ -104,6 +112,12 @@ export function getDefaults(): Config {
104
112
  ],
105
113
  useWorktrees: "auto",
106
114
  },
115
+ webui: {
116
+ enabled: false,
117
+ port: 3838,
118
+ hostname: "127.0.0.1",
119
+ password: null,
120
+ },
107
121
  };
108
122
  }
109
123
 
package/src/db.ts CHANGED
@@ -101,6 +101,13 @@ const MIGRATIONS: Migration[] = [
101
101
  CREATE INDEX IF NOT EXISTS idx_runs_started ON runs(started_at);
102
102
  `,
103
103
  },
104
+ {
105
+ version: 2,
106
+ sql: `
107
+ ALTER TABLE runs ADD COLUMN tmux_session TEXT;
108
+ ALTER TABLE runs ADD COLUMN agent TEXT NOT NULL DEFAULT 'claude';
109
+ `,
110
+ },
104
111
  ];
105
112
 
106
113
  export { MIGRATIONS };
package/src/index.ts CHANGED
@@ -161,7 +161,6 @@ export async function main(): Promise<void> {
161
161
  break;
162
162
  }
163
163
  case "mcp": {
164
- ensureInitialized();
165
164
  const { startMcpServer } = await import("./mcp.ts");
166
165
  await startMcpServer();
167
166
  break;
package/src/invocation.ts CHANGED
@@ -1,7 +1,5 @@
1
- import * as path from "path";
2
1
  import type { Config, Schedule, Run, EnvironmentInfo } from "./types.ts";
3
- import { PRODBOARD_DIR } from "./config.ts";
4
- import { getLastSessionId } from "./queries/runs.ts";
2
+ import { ClaudeDriver } from "./agents/claude.ts";
5
3
  import { Database } from "bun:sqlite";
6
4
 
7
5
  export function detectEnvironment(workdir: string, config: Config): EnvironmentInfo {
@@ -24,9 +22,18 @@ export function detectEnvironment(workdir: string, config: Config): EnvironmentI
24
22
  hasClaude = result.exitCode === 0;
25
23
  } catch {}
26
24
 
25
+ let hasOpencode = false;
26
+ try {
27
+ const result = Bun.spawnSync(["opencode", "--version"], {
28
+ stdout: "pipe",
29
+ stderr: "pipe",
30
+ });
31
+ hasOpencode = result.exitCode === 0;
32
+ } catch {}
33
+
27
34
  const worktreeSupported = hasGit && config.daemon.useWorktrees !== "never";
28
35
 
29
- return { hasGit, hasClaude, worktreeSupported };
36
+ return { hasGit, hasClaude, hasOpencode, worktreeSupported };
30
37
  }
31
38
 
32
39
  export function buildInvocation(
@@ -37,66 +44,6 @@ export function buildInvocation(
37
44
  resolvedPrompt: string,
38
45
  db?: Database
39
46
  ): string[] {
40
- const args: string[] = ["claude"];
41
-
42
- // Prompt
43
- args.push("-p", resolvedPrompt);
44
-
45
- // Permissions
46
- args.push("--dangerously-skip-permissions");
47
-
48
- // Output format
49
- args.push("--verbose", "--output-format", "stream-json");
50
-
51
- // MCP config
52
- const mcpConfigPath = path.join(PRODBOARD_DIR, "mcp.json");
53
- args.push("--mcp-config", mcpConfigPath);
54
-
55
- // System prompt
56
- const systemPromptFile = env.hasGit
57
- ? path.join(PRODBOARD_DIR, "system-prompt.md")
58
- : path.join(PRODBOARD_DIR, "system-prompt-nogit.md");
59
- args.push("--append-system-prompt-file", systemPromptFile);
60
-
61
- // Max turns: min of schedule override, config default, and hard max
62
- const scheduleTurns = schedule.max_turns ?? config.daemon.maxTurns;
63
- const maxTurns = Math.min(scheduleTurns, config.daemon.hardMaxTurns);
64
- args.push("--max-turns", String(maxTurns));
65
-
66
- // Allowed tools
67
- let tools: string[];
68
- if (schedule.allowed_tools) {
69
- try {
70
- tools = JSON.parse(schedule.allowed_tools);
71
- } catch {
72
- tools = env.hasGit ? config.daemon.defaultAllowedTools : config.daemon.nonGitDefaultAllowedTools;
73
- }
74
- } else if (!env.hasGit) {
75
- tools = config.daemon.nonGitDefaultAllowedTools;
76
- } else {
77
- tools = config.daemon.defaultAllowedTools;
78
- }
79
- for (const tool of tools) {
80
- args.push("--allowedTools", tool);
81
- }
82
-
83
- // Worktree
84
- if (env.worktreeSupported && schedule.use_worktree !== 0) {
85
- args.push("--worktree");
86
- }
87
-
88
- // Session resume
89
- if (schedule.persist_session && db) {
90
- const lastSessionId = getLastSessionId(db, schedule.id);
91
- if (lastSessionId) {
92
- args.push("--resume", lastSessionId);
93
- }
94
- }
95
-
96
- // Agents JSON
97
- if (schedule.agents_json) {
98
- args.push("--agents", schedule.agents_json);
99
- }
100
-
101
- return args;
47
+ const driver = new ClaudeDriver();
48
+ return driver.buildCommand({ schedule, run, config, env, resolvedPrompt, workdir: schedule.workdir, db: db! });
102
49
  }