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/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
|
|
|
@@ -126,13 +140,13 @@ export function deepMerge(defaults: any, user: any): any {
|
|
|
126
140
|
return result;
|
|
127
141
|
}
|
|
128
142
|
|
|
129
|
-
export function
|
|
143
|
+
export function loadConfigRaw(configDir?: string): { config: Config; rawParsed: any } {
|
|
130
144
|
const dir = configDir ?? PRODBOARD_DIR;
|
|
131
145
|
const configPath = path.join(dir, "config.jsonc");
|
|
132
146
|
const defaults = getDefaults();
|
|
133
147
|
|
|
134
148
|
if (!fs.existsSync(configPath)) {
|
|
135
|
-
return defaults;
|
|
149
|
+
return { config: defaults, rawParsed: {} };
|
|
136
150
|
}
|
|
137
151
|
|
|
138
152
|
let text: string;
|
|
@@ -151,5 +165,90 @@ export function loadConfig(configDir?: string): Config {
|
|
|
151
165
|
throw new Error(`Invalid JSON in config file ${configPath}: ${err.message}`);
|
|
152
166
|
}
|
|
153
167
|
|
|
154
|
-
return deepMerge(defaults, parsed);
|
|
168
|
+
return { config: deepMerge(defaults, parsed), rawParsed: parsed };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function loadConfig(configDir?: string): Config {
|
|
172
|
+
return loadConfigRaw(configDir).config;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function validateConfig(rawParsed: any): { errors: string[]; warnings: string[] } {
|
|
176
|
+
const errors: string[] = [];
|
|
177
|
+
const warnings: string[] = [];
|
|
178
|
+
|
|
179
|
+
if (typeof rawParsed !== "object" || rawParsed === null) {
|
|
180
|
+
errors.push("Config must be a JSON object.");
|
|
181
|
+
return { errors, warnings };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const knownTopLevel = ["general", "daemon", "webui"];
|
|
185
|
+
for (const key of Object.keys(rawParsed)) {
|
|
186
|
+
if (!knownTopLevel.includes(key)) {
|
|
187
|
+
warnings.push(`Unknown top-level key "${key}". Known keys: ${knownTopLevel.join(", ")}`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const g = rawParsed.general;
|
|
192
|
+
if (g !== undefined) {
|
|
193
|
+
if (g.statuses !== undefined && (!Array.isArray(g.statuses) || !g.statuses.every((s: any) => typeof s === "string"))) {
|
|
194
|
+
warnings.push("general.statuses must be an array of strings.");
|
|
195
|
+
}
|
|
196
|
+
if (g.defaultStatus !== undefined && typeof g.defaultStatus !== "string") {
|
|
197
|
+
warnings.push(`general.defaultStatus must be a string, got ${typeof g.defaultStatus}.`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const d = rawParsed.daemon;
|
|
202
|
+
if (d !== undefined) {
|
|
203
|
+
if (d.agent !== undefined && d.agent !== "claude" && d.agent !== "opencode") {
|
|
204
|
+
warnings.push(`daemon.agent must be "claude" or "opencode", got "${d.agent}".`);
|
|
205
|
+
}
|
|
206
|
+
if (d.useWorktrees !== undefined && !["auto", "always", "never"].includes(d.useWorktrees)) {
|
|
207
|
+
warnings.push(`daemon.useWorktrees must be "auto", "always", or "never", got "${d.useWorktrees}".`);
|
|
208
|
+
}
|
|
209
|
+
if (d.useTmux !== undefined && typeof d.useTmux !== "boolean") {
|
|
210
|
+
warnings.push(`daemon.useTmux must be a boolean, got ${typeof d.useTmux}.`);
|
|
211
|
+
}
|
|
212
|
+
for (const numField of ["maxConcurrentRuns", "maxTurns", "hardMaxTurns", "runTimeoutSeconds", "runRetentionDays"]) {
|
|
213
|
+
if (d[numField] !== undefined && typeof d[numField] !== "number") {
|
|
214
|
+
warnings.push(`daemon.${numField} must be a number, got ${typeof d[numField]}.`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const w = rawParsed.webui;
|
|
220
|
+
if (w !== undefined) {
|
|
221
|
+
if (w.enabled !== undefined && typeof w.enabled !== "boolean") {
|
|
222
|
+
warnings.push(`webui.enabled must be a boolean, got ${typeof w.enabled}.`);
|
|
223
|
+
}
|
|
224
|
+
if (w.port !== undefined && (typeof w.port !== "number" || w.port < 1 || w.port > 65535)) {
|
|
225
|
+
warnings.push(`webui.port must be a number between 1 and 65535, got ${JSON.stringify(w.port)}.`);
|
|
226
|
+
}
|
|
227
|
+
if (w.hostname !== undefined && typeof w.hostname !== "string") {
|
|
228
|
+
warnings.push(`webui.hostname must be a string, got ${typeof w.hostname}.`);
|
|
229
|
+
}
|
|
230
|
+
if (w.password !== undefined && w.password !== null && typeof w.password !== "string") {
|
|
231
|
+
warnings.push(`webui.password must be a string or null, got ${typeof w.password}.`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return { errors, warnings };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export async function checkWebuiDependencies(): Promise<string[]> {
|
|
239
|
+
const warnings: string[] = [];
|
|
240
|
+
try {
|
|
241
|
+
await import("hono");
|
|
242
|
+
} catch {
|
|
243
|
+
warnings.push("webui is enabled but 'hono' is not installed. Run: bun install");
|
|
244
|
+
}
|
|
245
|
+
try {
|
|
246
|
+
await import("hono/jsx/jsx-runtime");
|
|
247
|
+
} catch {
|
|
248
|
+
warnings.push(
|
|
249
|
+
"webui is enabled but the Hono JSX runtime could not be loaded. " +
|
|
250
|
+
"If prodboard is installed globally, you may need to install hono in the global package directory."
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
return warnings;
|
|
155
254
|
}
|
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
|
@@ -137,6 +137,8 @@ export async function main(): Promise<void> {
|
|
|
137
137
|
const daemonMod = await import("./commands/daemon.ts");
|
|
138
138
|
if (sub === "status") {
|
|
139
139
|
await daemonMod.daemonStatus(args.slice(2));
|
|
140
|
+
} else if (sub === "restart") {
|
|
141
|
+
await daemonMod.daemonRestart(args.slice(2));
|
|
140
142
|
} else {
|
|
141
143
|
await daemonMod.daemonStart(args.slice(1));
|
|
142
144
|
}
|
|
@@ -201,6 +203,8 @@ Commands:
|
|
|
201
203
|
comments <id> List comments for an issue
|
|
202
204
|
schedule <sub> Manage scheduled tasks
|
|
203
205
|
daemon Start the scheduler daemon
|
|
206
|
+
daemon restart Restart the daemon (systemd)
|
|
207
|
+
daemon status Show daemon status
|
|
204
208
|
install Install systemd service
|
|
205
209
|
uninstall Remove systemd service
|
|
206
210
|
config Show configuration
|
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
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { Config } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
export class OpenCodeServerManager {
|
|
4
|
+
private serverProcess: any = null;
|
|
5
|
+
private _url: string;
|
|
6
|
+
|
|
7
|
+
constructor(config: Config) {
|
|
8
|
+
this._url = config.daemon.opencode.serverUrl ?? "http://localhost:4096";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async isRunning(): Promise<boolean> {
|
|
12
|
+
try {
|
|
13
|
+
const controller = new AbortController();
|
|
14
|
+
const timeout = setTimeout(() => controller.abort(), 2000);
|
|
15
|
+
const res = await fetch(`${this._url}/global/health`, { signal: controller.signal });
|
|
16
|
+
clearTimeout(timeout);
|
|
17
|
+
return res.ok;
|
|
18
|
+
} catch {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async ensureRunning(): Promise<string> {
|
|
24
|
+
if (await this.isRunning()) return this._url;
|
|
25
|
+
|
|
26
|
+
this.serverProcess = Bun.spawn(["opencode", "serve"], {
|
|
27
|
+
stdout: "ignore",
|
|
28
|
+
stderr: "ignore",
|
|
29
|
+
env: process.env,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// Poll health endpoint for up to 30s
|
|
33
|
+
const deadline = Date.now() + 30_000;
|
|
34
|
+
while (Date.now() < deadline) {
|
|
35
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
36
|
+
if (await this.isRunning()) return this._url;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
throw new Error(`OpenCode server failed to start at ${this._url} within 30 seconds`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async stop(): Promise<void> {
|
|
43
|
+
if (this.serverProcess) {
|
|
44
|
+
try {
|
|
45
|
+
this.serverProcess.kill("SIGTERM");
|
|
46
|
+
} catch {}
|
|
47
|
+
this.serverProcess = null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
get url(): string {
|
|
52
|
+
return this._url;
|
|
53
|
+
}
|
|
54
|
+
}
|
package/src/queries/runs.ts
CHANGED
|
@@ -4,15 +4,15 @@ import { generateId } from "../ids.ts";
|
|
|
4
4
|
|
|
5
5
|
export function createRun(
|
|
6
6
|
db: Database,
|
|
7
|
-
opts: { schedule_id: string; prompt_used: string; pid?: number }
|
|
7
|
+
opts: { schedule_id: string; prompt_used: string; pid?: number; agent?: string }
|
|
8
8
|
): Run {
|
|
9
9
|
const id = generateId();
|
|
10
10
|
const now = new Date().toISOString().replace("T", " ").slice(0, 19);
|
|
11
11
|
|
|
12
12
|
db.query(`
|
|
13
|
-
INSERT INTO runs (id, schedule_id, status, prompt_used, pid, started_at)
|
|
14
|
-
VALUES (?, ?, 'running', ?, ?, ?)
|
|
15
|
-
`).run(id, opts.schedule_id, opts.prompt_used, opts.pid ?? null, now);
|
|
13
|
+
INSERT INTO runs (id, schedule_id, status, prompt_used, pid, agent, started_at)
|
|
14
|
+
VALUES (?, ?, 'running', ?, ?, ?, ?)
|
|
15
|
+
`).run(id, opts.schedule_id, opts.prompt_used, opts.pid ?? null, opts.agent ?? "claude", now);
|
|
16
16
|
|
|
17
17
|
return db.query("SELECT * FROM runs WHERE id = ?").get(id) as Run;
|
|
18
18
|
}
|
|
@@ -31,6 +31,7 @@ export function updateRun(
|
|
|
31
31
|
session_id: "session_id", worktree_path: "worktree_path",
|
|
32
32
|
tokens_in: "tokens_in", tokens_out: "tokens_out", cost_usd: "cost_usd",
|
|
33
33
|
tools_used: "tools_used", issues_touched: "issues_touched",
|
|
34
|
+
tmux_session: "tmux_session", agent: "agent",
|
|
34
35
|
prompt_used: "prompt_used", pid: "pid",
|
|
35
36
|
};
|
|
36
37
|
|