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 +12 -0
- package/README.md +120 -13
- package/package.json +4 -2
- package/src/agents/claude.ts +107 -0
- package/src/agents/index.ts +18 -0
- package/src/agents/opencode.ts +108 -0
- package/src/agents/types.ts +41 -0
- package/src/commands/install.ts +3 -2
- package/src/commands/schedules.ts +1 -0
- package/src/config.ts +14 -0
- package/src/db.ts +7 -0
- package/src/index.ts +0 -1
- package/src/invocation.ts +13 -66
- package/src/mcp.ts +6 -1
- package/src/opencode-server.ts +54 -0
- package/src/queries/runs.ts +5 -4
- package/src/scheduler.ts +240 -155
- package/src/tmux.ts +68 -0
- package/src/types.ts +17 -0
- package/src/webui/components/board.tsx +32 -0
- package/src/webui/components/layout.tsx +28 -0
- package/src/webui/components/status-badge.tsx +23 -0
- package/src/webui/index.ts +85 -0
- package/src/webui/routes/auth.tsx +57 -0
- package/src/webui/routes/issues.tsx +162 -0
- package/src/webui/routes/runs.tsx +103 -0
- package/src/webui/routes/schedules.tsx +144 -0
- package/src/webui/static/style.ts +47 -0
- package/src/worktree.ts +75 -0
- package/templates/CLAUDE.md +3 -0
- package/templates/config.jsonc +43 -1
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
|
|
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:**
|
|
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
|
|
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
|
|
18
|
-
- **Scheduled
|
|
19
|
-
- **
|
|
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
|
-
#
|
|
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
|
|
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.
|
|
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
|
+
}
|
package/src/commands/install.ts
CHANGED
|
@@ -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("
|
|
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
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 {
|
|
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
|
|
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
|
}
|