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/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 loadConfig(configDir?: string): Config {
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 { 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
  }
@@ -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
+ }
@@ -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