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/src/mcp.ts CHANGED
@@ -7,8 +7,9 @@ import {
7
7
  ReadResourceRequestSchema,
8
8
  } from "@modelcontextprotocol/sdk/types.js";
9
9
  import { Database } from "bun:sqlite";
10
+ import { existsSync } from "fs";
10
11
  import { ensureDb } from "./db.ts";
11
- import { loadConfig } from "./config.ts";
12
+ import { loadConfig, PRODBOARD_DIR } from "./config.ts";
12
13
  import {
13
14
  createIssue, getIssueByPrefix, listIssues, updateIssue,
14
15
  deleteIssue, getIssueCounts, validateStatus, resolveIssueId,
@@ -432,6 +433,10 @@ export async function handleListRuns(db: Database, params: any) {
432
433
  }
433
434
 
434
435
  export async function startMcpServer(): Promise<void> {
436
+ if (!existsSync(PRODBOARD_DIR)) {
437
+ const { init } = await import("./commands/init.ts");
438
+ await init([]);
439
+ }
435
440
  const db = ensureDb();
436
441
  const config = loadConfig();
437
442
  const pkg = await import("../package.json");
@@ -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
 
package/src/scheduler.ts CHANGED
@@ -4,83 +4,18 @@ import * as path from "path";
4
4
  import type { Config, Schedule, Run } from "./types.ts";
5
5
  import { PRODBOARD_DIR } from "./config.ts";
6
6
  import { shouldFire } from "./cron.ts";
7
- import { detectEnvironment, buildInvocation } from "./invocation.ts";
7
+ import { detectEnvironment } from "./invocation.ts";
8
8
  import { listSchedules } from "./queries/schedules.ts";
9
9
  import { createRun, updateRun, getRunningRuns, pruneOldRuns } from "./queries/runs.ts";
10
10
  import { resolveTemplate, buildTemplateContext } from "./templates.ts";
11
+ import { createAgentDriver } from "./agents/index.ts";
12
+ import type { AgentDriver, StreamEvent } from "./agents/types.ts";
13
+ import { TmuxManager } from "./tmux.ts";
14
+ import { WorktreeManager } from "./worktree.ts";
15
+ import { OpenCodeServerManager } from "./opencode-server.ts";
11
16
 
12
- // Stream JSON event types
13
- export interface StreamEvent {
14
- type: string;
15
- session_id?: string;
16
- tool?: string;
17
- tool_input?: any;
18
- result?: {
19
- tokens_in?: number;
20
- tokens_out?: number;
21
- cost_usd?: number;
22
- };
23
- [key: string]: any;
24
- }
25
-
26
- export function parseStreamJson(line: string): StreamEvent | null {
27
- try {
28
- const parsed = JSON.parse(line.trim());
29
- return parsed;
30
- } catch {
31
- return null;
32
- }
33
- }
34
-
35
- export function extractCostData(events: StreamEvent[]): {
36
- tokens_in: number;
37
- tokens_out: number;
38
- cost_usd: number;
39
- session_id: string | null;
40
- tools_used: string[];
41
- issues_touched: string[];
42
- } {
43
- let tokens_in = 0;
44
- let tokens_out = 0;
45
- let cost_usd = 0;
46
- let session_id: string | null = null;
47
- const tools_used = new Set<string>();
48
- const issues_touched = new Set<string>();
49
-
50
- for (const event of events) {
51
- if (event.type === "init" && event.session_id) {
52
- session_id = event.session_id;
53
- }
54
- if (event.type === "tool_use" && event.tool) {
55
- tools_used.add(event.tool);
56
- // Track prodboard issue IDs from tool inputs
57
- if (event.tool.startsWith("mcp__prodboard__") && event.tool_input?.id) {
58
- issues_touched.add(event.tool_input.id);
59
- }
60
- if (event.tool.startsWith("mcp__prodboard__") && event.tool_input?.issue_id) {
61
- issues_touched.add(event.tool_input.issue_id);
62
- }
63
- }
64
- if (event.type === "result") {
65
- if (event.result?.tokens_in) tokens_in = event.result.tokens_in;
66
- if (event.result?.tokens_out) tokens_out = event.result.tokens_out;
67
- if (event.result?.cost_usd) cost_usd = event.result.cost_usd;
68
- }
69
- // Also handle top-level fields some stream formats use
70
- if (event.tokens_in) tokens_in = event.tokens_in;
71
- if (event.tokens_out) tokens_out = event.tokens_out;
72
- if (event.cost_usd) cost_usd = event.cost_usd;
73
- }
74
-
75
- return {
76
- tokens_in,
77
- tokens_out,
78
- cost_usd,
79
- session_id,
80
- tools_used: [...tools_used],
81
- issues_touched: [...issues_touched],
82
- };
83
- }
17
+ // Re-export for backward compatibility
18
+ export type { StreamEvent };
84
19
 
85
20
  class RingBuffer {
86
21
  private buffer: string[] = [];
@@ -103,10 +38,17 @@ class RingBuffer {
103
38
  }
104
39
 
105
40
  export class ExecutionManager {
106
- constructor(private db: Database, private config: Config) {}
41
+ constructor(
42
+ private db: Database,
43
+ private config: Config,
44
+ private driver: AgentDriver = createAgentDriver(config),
45
+ private tmuxManager?: TmuxManager,
46
+ private worktreeManager?: WorktreeManager,
47
+ ) {}
107
48
 
108
49
  async executeRun(schedule: Schedule, run: Run): Promise<void> {
109
- const env = detectEnvironment(schedule.workdir, this.config);
50
+ const baseWorkdir = this.config.daemon.basePath ?? schedule.workdir;
51
+ const env = detectEnvironment(baseWorkdir, this.config);
110
52
 
111
53
  // Resolve prompt templates
112
54
  let resolvedPrompt = schedule.prompt;
@@ -119,94 +61,171 @@ export class ExecutionManager {
119
61
  }
120
62
  } catch {}
121
63
 
122
- const args = buildInvocation(schedule, run, this.config, env, resolvedPrompt, this.db);
64
+ // Create worktree if applicable
65
+ let worktreePath: string | null = null;
66
+ let effectiveWorkdir = baseWorkdir;
67
+
68
+ if (
69
+ this.worktreeManager &&
70
+ this.config.daemon.useWorktrees !== "never" &&
71
+ schedule.use_worktree !== 0 &&
72
+ this.worktreeManager.isGitRepo(baseWorkdir)
73
+ ) {
74
+ try {
75
+ worktreePath = await this.worktreeManager.create(run.id, baseWorkdir);
76
+ effectiveWorkdir = worktreePath;
77
+ updateRun(this.db, run.id, { worktree_path: worktreePath });
78
+ } catch (err: any) {
79
+ console.error(`[prodboard] Warning: Failed to create worktree: ${err.message}`);
80
+ }
81
+ }
82
+
83
+ const args = this.driver.buildCommand({
84
+ schedule,
85
+ run,
86
+ config: this.config,
87
+ env,
88
+ resolvedPrompt,
89
+ workdir: effectiveWorkdir,
90
+ db: this.db,
91
+ });
123
92
 
124
- // Update run with PID (will be set after spawn)
125
93
  const stdoutBuffer = new RingBuffer(500);
126
94
  const stderrBuffer = new RingBuffer(100);
127
95
  const events: StreamEvent[] = [];
128
96
 
129
- let proc: any;
97
+ const useTmux = this.config.daemon.useTmux && this.tmuxManager?.isAvailable();
98
+ let tmuxSessionName: string | null = null;
99
+ let jsonlPath: string | null = null;
130
100
  let timeoutId: Timer | undefined;
101
+ let timedOut = false;
102
+
131
103
  try {
132
- proc = Bun.spawn(args, {
133
- cwd: schedule.workdir,
134
- stdout: "pipe",
135
- stderr: "pipe",
136
- env: process.env,
137
- });
104
+ if (useTmux && this.tmuxManager) {
105
+ // tmux path: spawn detached session, wait for completion, read events from file
106
+ tmuxSessionName = this.tmuxManager.sessionName(run.id);
107
+ jsonlPath = `/tmp/prodboard-${run.id}.jsonl`;
108
+ const wrappedArgs = this.tmuxManager.wrapCommand(tmuxSessionName, args, jsonlPath);
109
+
110
+ Bun.spawnSync(wrappedArgs, { cwd: effectiveWorkdir, env: process.env });
111
+ updateRun(this.db, run.id, { tmux_session: tmuxSessionName });
112
+
113
+ // Set up timeout
114
+ const timeoutMs = this.config.daemon.runTimeoutSeconds * 1000;
115
+ timeoutId = setTimeout(() => {
116
+ timedOut = true;
117
+ if (tmuxSessionName && this.tmuxManager) {
118
+ this.tmuxManager.killSession(tmuxSessionName);
119
+ }
120
+ }, timeoutMs);
138
121
 
139
- updateRun(this.db, run.id, { pid: proc.pid });
122
+ const exitCode = await this.tmuxManager.waitForCompletion(tmuxSessionName, jsonlPath);
140
123
 
141
- // Set up timeout
142
- const timeoutMs = this.config.daemon.runTimeoutSeconds * 1000;
143
- timeoutId = setTimeout(() => {
124
+ // Read JSONL file for events
144
125
  try {
145
- proc.kill("SIGTERM");
146
- setTimeout(() => {
147
- try { proc.kill("SIGKILL"); } catch {}
148
- }, 10000);
126
+ const content = fs.readFileSync(jsonlPath, "utf-8");
127
+ for (const line of content.split("\n")) {
128
+ if (line.trim()) {
129
+ stdoutBuffer.push(line);
130
+ const event = this.driver.parseEvent(line);
131
+ if (event) events.push(event);
132
+ }
133
+ }
149
134
  } catch {}
150
- }, timeoutMs);
151
135
 
152
- // Read stdout line by line
153
- if (proc.stdout) {
154
- const reader = proc.stdout.getReader();
155
- let buffer = "";
156
- try {
157
- while (true) {
158
- const { value, done } = await reader.read();
159
- if (done) break;
160
- buffer += new TextDecoder().decode(value);
161
- const lines = buffer.split("\n");
162
- buffer = lines.pop() ?? "";
163
- for (const line of lines) {
164
- if (line.trim()) {
165
- stdoutBuffer.push(line);
166
- const event = parseStreamJson(line);
167
- if (event) events.push(event);
136
+ const result = this.driver.extractResult(events);
137
+ const now = new Date().toISOString().replace("T", " ").slice(0, 19);
138
+
139
+ updateRun(this.db, run.id, {
140
+ status: timedOut ? "timeout" : exitCode === 0 ? "success" : "failed",
141
+ finished_at: now,
142
+ exit_code: exitCode,
143
+ stdout_tail: stdoutBuffer.toString(),
144
+ session_id: result.session_id,
145
+ tokens_in: result.tokens_in,
146
+ tokens_out: result.tokens_out,
147
+ cost_usd: result.cost_usd,
148
+ tools_used: result.tools_used.length > 0 ? JSON.stringify(result.tools_used) : null,
149
+ issues_touched: result.issues_touched.length > 0 ? JSON.stringify(result.issues_touched) : null,
150
+ });
151
+ } else {
152
+ // Direct spawn path (no tmux)
153
+ const proc = Bun.spawn(args, {
154
+ cwd: effectiveWorkdir,
155
+ stdout: "pipe",
156
+ stderr: "pipe",
157
+ env: process.env,
158
+ });
159
+
160
+ updateRun(this.db, run.id, { pid: proc.pid });
161
+
162
+ const timeoutMs = this.config.daemon.runTimeoutSeconds * 1000;
163
+ timeoutId = setTimeout(() => {
164
+ try {
165
+ proc.kill("SIGTERM");
166
+ setTimeout(() => {
167
+ try { proc.kill("SIGKILL"); } catch {}
168
+ }, 10000);
169
+ } catch {}
170
+ }, timeoutMs);
171
+
172
+ if (proc.stdout) {
173
+ const reader = proc.stdout.getReader();
174
+ const decoder = new TextDecoder();
175
+ let buffer = "";
176
+ try {
177
+ while (true) {
178
+ const { value, done } = await reader.read();
179
+ if (done) break;
180
+ buffer += decoder.decode(value, { stream: true });
181
+ const lines = buffer.split("\n");
182
+ buffer = lines.pop() ?? "";
183
+ for (const line of lines) {
184
+ if (line.trim()) {
185
+ stdoutBuffer.push(line);
186
+ const event = this.driver.parseEvent(line);
187
+ if (event) events.push(event);
188
+ }
168
189
  }
169
190
  }
170
- }
171
- } catch {}
172
- reader.releaseLock();
173
- }
191
+ } catch {}
192
+ reader.releaseLock();
193
+ }
174
194
 
175
- // Read stderr
176
- if (proc.stderr) {
177
- const stderrText = await new Response(proc.stderr).text();
178
- for (const line of stderrText.split("\n")) {
179
- if (line.trim()) stderrBuffer.push(line);
195
+ if (proc.stderr) {
196
+ const stderrText = await new Response(proc.stderr).text();
197
+ for (const line of stderrText.split("\n")) {
198
+ if (line.trim()) stderrBuffer.push(line);
199
+ }
180
200
  }
181
- }
182
201
 
183
- const exitCode = await proc.exited;
202
+ const exitCode = await proc.exited;
203
+ const result = this.driver.extractResult(events);
204
+ const now = new Date().toISOString().replace("T", " ").slice(0, 19);
184
205
 
185
- const costData = extractCostData(events);
186
- const now = new Date().toISOString().replace("T", " ").slice(0, 19);
206
+ let status: string;
207
+ if (exitCode === 0) {
208
+ status = "success";
209
+ } else if (exitCode === null) {
210
+ status = "timeout";
211
+ } else {
212
+ status = "failed";
213
+ }
187
214
 
188
- let status: string;
189
- if (exitCode === 0) {
190
- status = "success";
191
- } else if (exitCode === null) {
192
- status = "timeout";
193
- } else {
194
- status = "failed";
215
+ updateRun(this.db, run.id, {
216
+ status,
217
+ finished_at: now,
218
+ exit_code: exitCode,
219
+ stdout_tail: stdoutBuffer.toString(),
220
+ stderr_tail: stderrBuffer.toString(),
221
+ session_id: result.session_id,
222
+ tokens_in: result.tokens_in,
223
+ tokens_out: result.tokens_out,
224
+ cost_usd: result.cost_usd,
225
+ tools_used: result.tools_used.length > 0 ? JSON.stringify(result.tools_used) : null,
226
+ issues_touched: result.issues_touched.length > 0 ? JSON.stringify(result.issues_touched) : null,
227
+ });
195
228
  }
196
-
197
- updateRun(this.db, run.id, {
198
- status,
199
- finished_at: now,
200
- exit_code: exitCode,
201
- stdout_tail: stdoutBuffer.toString(),
202
- stderr_tail: stderrBuffer.toString(),
203
- session_id: costData.session_id,
204
- tokens_in: costData.tokens_in,
205
- tokens_out: costData.tokens_out,
206
- cost_usd: costData.cost_usd,
207
- tools_used: costData.tools_used.length > 0 ? JSON.stringify(costData.tools_used) : null,
208
- issues_touched: costData.issues_touched.length > 0 ? JSON.stringify(costData.issues_touched) : null,
209
- });
210
229
  } catch (err: any) {
211
230
  const now = new Date().toISOString().replace("T", " ").slice(0, 19);
212
231
  updateRun(this.db, run.id, {
@@ -216,6 +235,17 @@ export class ExecutionManager {
216
235
  });
217
236
  } finally {
218
237
  if (timeoutId !== undefined) clearTimeout(timeoutId);
238
+
239
+ // Clean up worktree
240
+ if (worktreePath && this.worktreeManager) {
241
+ try { await this.worktreeManager.remove(run.id); } catch {}
242
+ }
243
+
244
+ // Clean up tmux JSONL temp file
245
+ if (jsonlPath) {
246
+ try { fs.unlinkSync(jsonlPath); } catch {}
247
+ try { fs.unlinkSync(`${jsonlPath}.exit`); } catch {}
248
+ }
219
249
  }
220
250
  }
221
251
  }
@@ -233,7 +263,6 @@ export class CronLoop {
233
263
 
234
264
  start(): void {
235
265
  this.interval = setInterval(() => this.tick(), 30_000);
236
- // Also tick immediately
237
266
  this.tick();
238
267
  }
239
268
 
@@ -257,23 +286,20 @@ export class CronLoop {
257
286
  try {
258
287
  if (!shouldFire(schedule.cron, now)) continue;
259
288
 
260
- // Prevent double-fire within same minute
261
289
  const lastFiredMinute = this.lastFired.get(schedule.id);
262
290
  if (lastFiredMinute === minuteTs) continue;
263
291
 
264
- // Check concurrent limit
265
292
  const runningRuns = getRunningRuns(this.db);
266
293
  if (runningRuns.length >= this.config.daemon.maxConcurrentRuns) continue;
267
294
 
268
295
  const run = createRun(this.db, {
269
296
  schedule_id: schedule.id,
270
297
  prompt_used: schedule.prompt,
298
+ agent: this.config.daemon.agent,
271
299
  });
272
300
 
273
- // Mark fired only after successful run creation
274
301
  this.lastFired.set(schedule.id, minuteTs);
275
302
 
276
- // Execute async - don't block the loop
277
303
  this.executionManager.executeRun(schedule, run).catch(() => {});
278
304
  } catch (err) {
279
305
  console.error(`[prodboard] Error evaluating schedule ${schedule.id}:`, err instanceof Error ? err.message : String(err));
@@ -293,7 +319,7 @@ export class CleanupWorker {
293
319
  constructor(private db: Database, private config: Config) {}
294
320
 
295
321
  start(): void {
296
- this.interval = setInterval(() => this.cleanup(), 3600_000); // 1 hour
322
+ this.interval = setInterval(() => this.cleanup(), 3600_000);
297
323
  }
298
324
 
299
325
  stop(): void {
@@ -317,23 +343,63 @@ export class Daemon {
317
343
  private cronLoop: CronLoop;
318
344
  private cleanupWorker: CleanupWorker;
319
345
  private executionManager: ExecutionManager;
346
+ private tmuxManager: TmuxManager;
347
+ private worktreeManager?: WorktreeManager;
348
+ private openCodeServer?: OpenCodeServerManager;
349
+ private webServer?: any;
320
350
 
321
351
  constructor(private db: Database, private config: Config) {
322
- this.executionManager = new ExecutionManager(db, config);
352
+ const driver = createAgentDriver(config);
353
+ this.tmuxManager = new TmuxManager();
354
+ if (config.daemon.basePath) {
355
+ this.worktreeManager = new WorktreeManager(config.daemon.basePath);
356
+ }
357
+ this.executionManager = new ExecutionManager(db, config, driver, this.tmuxManager, this.worktreeManager);
323
358
  this.cronLoop = new CronLoop(db, config, this.executionManager);
324
359
  this.cleanupWorker = new CleanupWorker(db, config);
325
360
  }
326
361
 
327
362
  async start(): Promise<void> {
328
363
  this.recoverCrashedRuns();
364
+
365
+ // Check tmux availability
366
+ if (this.config.daemon.useTmux) {
367
+ if (this.tmuxManager.isAvailable()) {
368
+ console.error("[prodboard] tmux available — sessions will be attachable");
369
+ } else {
370
+ console.error("[prodboard] Warning: useTmux is true but tmux is not installed");
371
+ }
372
+ }
373
+
374
+ // Start OpenCode server if needed
375
+ if (this.config.daemon.agent === "opencode") {
376
+ this.openCodeServer = new OpenCodeServerManager(this.config);
377
+ try {
378
+ const url = await this.openCodeServer.ensureRunning();
379
+ console.error(`[prodboard] OpenCode server running at ${url}`);
380
+ } catch (err: any) {
381
+ console.error(`[prodboard] Warning: Could not start OpenCode server: ${err.message}`);
382
+ }
383
+ }
384
+
329
385
  this.writePidFile();
330
386
  this.cronLoop.start();
331
387
  this.cleanupWorker.start();
332
388
 
389
+ // Start web UI if enabled
390
+ if (this.config.webui.enabled) {
391
+ try {
392
+ const { startWebUI } = await import("./webui/index.ts");
393
+ this.webServer = await startWebUI(this.db, this.config);
394
+ } catch (err: any) {
395
+ console.error(`[prodboard] Warning: Could not start web UI: ${err.message}`);
396
+ }
397
+ }
398
+
333
399
  process.on("SIGTERM", () => this.stop());
334
400
  process.on("SIGINT", () => this.stop());
335
401
 
336
- console.error(`[prodboard] Daemon started (PID ${process.pid})`);
402
+ console.error(`[prodboard] Daemon started (PID ${process.pid}, agent: ${this.config.daemon.agent})`);
337
403
  }
338
404
 
339
405
  async stop(): Promise<void> {
@@ -341,11 +407,16 @@ export class Daemon {
341
407
  this.cronLoop.stop();
342
408
  this.cleanupWorker.stop();
343
409
 
344
- // Wait for running processes
410
+ // Kill running tmux sessions
345
411
  const running = getRunningRuns(this.db);
412
+ for (const run of running) {
413
+ if (run.tmux_session) {
414
+ this.tmuxManager.killSession(run.tmux_session);
415
+ }
416
+ }
417
+
346
418
  if (running.length > 0) {
347
419
  console.error(`[prodboard] Waiting for ${running.length} running process(es)...`);
348
- // Give them 30 seconds
349
420
  const deadline = Date.now() + 30_000;
350
421
  while (Date.now() < deadline) {
351
422
  const still = getRunningRuns(this.db);
@@ -353,14 +424,12 @@ export class Daemon {
353
424
  await new Promise((resolve) => setTimeout(resolve, 1000));
354
425
  }
355
426
 
356
- // Kill remaining processes first, then mark cancelled
357
427
  const stillRunning = getRunningRuns(this.db);
358
428
  for (const run of stillRunning) {
359
429
  if (run.pid) {
360
430
  try { process.kill(run.pid, "SIGTERM"); } catch {}
361
431
  }
362
432
  }
363
- // Brief wait for processes to exit after SIGTERM
364
433
  await new Promise((resolve) => setTimeout(resolve, 2000));
365
434
  for (const run of stillRunning) {
366
435
  if (run.pid) {
@@ -371,6 +440,16 @@ export class Daemon {
371
440
  }
372
441
  }
373
442
 
443
+ // Stop web server
444
+ if (this.webServer) {
445
+ try { this.webServer.stop(); } catch {}
446
+ }
447
+
448
+ // Stop OpenCode server
449
+ if (this.openCodeServer) {
450
+ await this.openCodeServer.stop();
451
+ }
452
+
374
453
  this.removePidFile();
375
454
  process.exit(0);
376
455
  }
@@ -396,6 +475,12 @@ export class Daemon {
396
475
  process.kill(run.pid, 0);
397
476
  alive = true;
398
477
  } catch {}
478
+ } else if (run.tmux_session) {
479
+ const result = Bun.spawnSync(["tmux", "has-session", "-t", run.tmux_session], {
480
+ stdout: "pipe",
481
+ stderr: "pipe",
482
+ });
483
+ alive = result.exitCode === 0;
399
484
  }
400
485
 
401
486
  if (!alive) {