prodboard 0.1.3 → 0.2.1
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 +119 -9
- 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/daemon.ts +57 -1
- package/src/commands/install.ts +21 -2
- package/src/commands/schedules.ts +1 -0
- package/src/config.ts +102 -3
- package/src/db.ts +7 -0
- package/src/index.ts +4 -0
- package/src/invocation.ts +13 -66
- 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.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- [#9](https://github.com/G4brym/prodboard/pull/9) [`d418fc4`](https://github.com/G4brym/prodboard/commit/d418fc41b52e26789dd3b5be7f2dcdf9429ef287) Thanks [@G4brym](https://github.com/G4brym)! - Add `daemon restart` command with config validation and webui dependency checks. The `install` command now also validates config before proceeding. Invalid config values produce clear warnings with fix tips.
|
|
8
|
+
|
|
9
|
+
## 0.2.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- [#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
|
|
14
|
+
|
|
3
15
|
## 0.1.3
|
|
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
|
|
@@ -195,6 +200,30 @@ These are the tools Claude Code sees when connected to the board:
|
|
|
195
200
|
|
|
196
201
|
MCP resources: `prodboard://issues` (board summary) and `prodboard://schedules` (active schedules).
|
|
197
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
|
+
|
|
198
227
|
## Configuration
|
|
199
228
|
|
|
200
229
|
Config file: `~/.prodboard/config.jsonc`
|
|
@@ -207,17 +236,94 @@ Config file: `~/.prodboard/config.jsonc`
|
|
|
207
236
|
"idPrefix": ""
|
|
208
237
|
},
|
|
209
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
|
|
210
242
|
"maxConcurrentRuns": 2,
|
|
211
243
|
"maxTurns": 50,
|
|
212
244
|
"hardMaxTurns": 200,
|
|
213
245
|
"runTimeoutSeconds": 1800,
|
|
214
246
|
"runRetentionDays": 30,
|
|
215
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
|
|
216
312
|
"useWorktrees": "auto"
|
|
217
313
|
}
|
|
218
314
|
}
|
|
219
315
|
```
|
|
220
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
|
+
|
|
221
327
|
## Scheduler Details
|
|
222
328
|
|
|
223
329
|
### Cron Syntax
|
|
@@ -282,10 +388,14 @@ prodboard install --force
|
|
|
282
388
|
|
|
283
389
|
**MCP not connecting** — Verify `claude mcp add prodboard -- bunx prodboard mcp` was run, or check `~/.prodboard/mcp.json`.
|
|
284
390
|
|
|
285
|
-
**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.
|
|
286
392
|
|
|
287
393
|
**Stale PID file** — The daemon crashed. Run `prodboard daemon` to restart (auto-cleans stale PIDs).
|
|
288
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
|
+
|
|
289
399
|
## Development
|
|
290
400
|
|
|
291
401
|
```bash
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "prodboard",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
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/daemon.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import * as fs from "fs";
|
|
2
2
|
import * as path from "path";
|
|
3
|
+
import * as os from "os";
|
|
3
4
|
import { ensureDb } from "../db.ts";
|
|
4
|
-
import { loadConfig, PRODBOARD_DIR } from "../config.ts";
|
|
5
|
+
import { loadConfig, loadConfigRaw, validateConfig, checkWebuiDependencies, PRODBOARD_DIR } from "../config.ts";
|
|
5
6
|
import { listSchedules } from "../queries/schedules.ts";
|
|
6
7
|
import { getNextFire } from "../cron.ts";
|
|
7
8
|
import { formatDate } from "../format.ts";
|
|
8
9
|
import { Daemon } from "../scheduler.ts";
|
|
10
|
+
import { systemctlAvailable, runSystemctl } from "./install.ts";
|
|
9
11
|
|
|
10
12
|
function parseArgs(args: string[]): { flags: Record<string, string | boolean>; positional: string[] } {
|
|
11
13
|
const flags: Record<string, string | boolean> = {};
|
|
@@ -110,3 +112,57 @@ export async function daemonStatus(args: string[]): Promise<void> {
|
|
|
110
112
|
try { fs.unlinkSync(pidFile); } catch {}
|
|
111
113
|
}
|
|
112
114
|
}
|
|
115
|
+
|
|
116
|
+
export async function daemonRestart(_args: string[]): Promise<void> {
|
|
117
|
+
// Validate config
|
|
118
|
+
let config;
|
|
119
|
+
try {
|
|
120
|
+
const { config: cfg, rawParsed } = loadConfigRaw();
|
|
121
|
+
config = cfg;
|
|
122
|
+
const { errors, warnings } = validateConfig(rawParsed);
|
|
123
|
+
for (const e of errors) {
|
|
124
|
+
console.error(`✗ Config: ${e}`);
|
|
125
|
+
}
|
|
126
|
+
if (errors.length > 0) {
|
|
127
|
+
process.exit(1);
|
|
128
|
+
}
|
|
129
|
+
for (const w of warnings) {
|
|
130
|
+
console.warn(`⚠ Config: ${w}`);
|
|
131
|
+
}
|
|
132
|
+
} catch (err: any) {
|
|
133
|
+
console.error(`Config error: ${err.message}`);
|
|
134
|
+
process.exit(1);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Check webui dependencies
|
|
138
|
+
if (config.webui.enabled) {
|
|
139
|
+
const depWarnings = await checkWebuiDependencies();
|
|
140
|
+
for (const w of depWarnings) {
|
|
141
|
+
console.warn(`⚠ ${w}`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Check systemd availability
|
|
146
|
+
if (!(await systemctlAvailable())) {
|
|
147
|
+
console.error("systemd is not available. daemon restart requires systemd.");
|
|
148
|
+
process.exit(1);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Check service file exists
|
|
152
|
+
const servicePath = path.join(os.homedir(), ".config", "systemd", "user", "prodboard.service");
|
|
153
|
+
if (!fs.existsSync(servicePath)) {
|
|
154
|
+
console.error("prodboard is not installed as a systemd service. Run: prodboard install");
|
|
155
|
+
process.exit(1);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Restart and show status
|
|
159
|
+
const restart = await runSystemctl("restart", "prodboard");
|
|
160
|
+
if (restart.exitCode !== 0) {
|
|
161
|
+
console.error("Failed to restart prodboard:", restart.stderr);
|
|
162
|
+
process.exit(1);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
console.log("prodboard daemon restarted.");
|
|
166
|
+
const { stdout } = await runSystemctl("status", "prodboard");
|
|
167
|
+
console.log(stdout);
|
|
168
|
+
}
|
package/src/commands/install.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as fs from "fs";
|
|
2
2
|
import * as path from "path";
|
|
3
3
|
import * as os from "os";
|
|
4
|
+
import { loadConfigRaw, validateConfig, checkWebuiDependencies } from "../config.ts";
|
|
4
5
|
|
|
5
6
|
const SERVICE_NAME = "prodboard";
|
|
6
7
|
const SERVICE_DIR = path.join(os.homedir(), ".config", "systemd", "user");
|
|
@@ -16,7 +17,7 @@ function parseArgs(args: string[]): { flags: Record<string, boolean> } {
|
|
|
16
17
|
return { flags };
|
|
17
18
|
}
|
|
18
19
|
|
|
19
|
-
async function systemctlAvailable(): Promise<boolean> {
|
|
20
|
+
export async function systemctlAvailable(): Promise<boolean> {
|
|
20
21
|
try {
|
|
21
22
|
const proc = Bun.spawn(["systemctl", "--version"], {
|
|
22
23
|
stdout: "ignore",
|
|
@@ -29,7 +30,7 @@ async function systemctlAvailable(): Promise<boolean> {
|
|
|
29
30
|
}
|
|
30
31
|
}
|
|
31
32
|
|
|
32
|
-
async function runSystemctl(...args: string[]): Promise<{ exitCode: number; stdout: string; stderr: string }> {
|
|
33
|
+
export async function runSystemctl(...args: string[]): Promise<{ exitCode: number; stdout: string; stderr: string }> {
|
|
33
34
|
const proc = Bun.spawn(["systemctl", "--user", ...args], {
|
|
34
35
|
stdout: "pipe",
|
|
35
36
|
stderr: "pipe",
|
|
@@ -62,6 +63,24 @@ WantedBy=default.target
|
|
|
62
63
|
export async function install(args: string[]): Promise<void> {
|
|
63
64
|
const { flags } = parseArgs(args);
|
|
64
65
|
|
|
66
|
+
// Validate config before proceeding
|
|
67
|
+
try {
|
|
68
|
+
const { config, rawParsed } = loadConfigRaw();
|
|
69
|
+
const { warnings } = validateConfig(rawParsed);
|
|
70
|
+
for (const w of warnings) {
|
|
71
|
+
console.warn(`⚠ Config: ${w}`);
|
|
72
|
+
}
|
|
73
|
+
if (config.webui.enabled) {
|
|
74
|
+
const depWarnings = await checkWebuiDependencies();
|
|
75
|
+
for (const w of depWarnings) {
|
|
76
|
+
console.warn(`⚠ ${w}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
} catch (err: any) {
|
|
80
|
+
console.error(`Config error: ${err.message}`);
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
|
|
65
84
|
if (!(await systemctlAvailable())) {
|
|
66
85
|
console.error("systemd is not available on this system.");
|
|
67
86
|
console.error("The install command requires systemd (Linux).");
|
|
@@ -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
|
|