prodboard 0.2.3 → 0.4.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 +22 -0
- package/README.md +1 -0
- package/package.json +1 -1
- package/src/agents/claude.ts +12 -6
- package/src/agents/opencode.ts +3 -2
- package/src/agents/types.ts +6 -4
- package/src/commands/schedules.ts +6 -3
- package/src/config.ts +1 -0
- package/src/db.ts +4 -0
- package/src/mcp.ts +63 -1
- package/src/queries/runs.ts +9 -2
- package/src/queries/schedules.ts +5 -2
- package/src/scheduler.ts +9 -2
- package/src/types.ts +2 -0
- package/src/webui/components/board.tsx +1 -1
- package/src/webui/routes/issues.tsx +79 -1
- package/src/webui/routes/runs.tsx +6 -0
- package/templates/config.jsonc +6 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# prodboard
|
|
2
2
|
|
|
3
|
+
## 0.4.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#21](https://github.com/G4brym/prodboard/pull/21) [`761e8fc`](https://github.com/G4brym/prodboard/commit/761e8fce4cd370424903753952835f7a399730bb) Thanks [@G4brym](https://github.com/G4brym)! - Add per-schedule and global model selection for Claude and OpenCode agents
|
|
8
|
+
|
|
9
|
+
## 0.3.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- [#17](https://github.com/G4brym/prodboard/pull/17) [`20f3860`](https://github.com/G4brym/prodboard/commit/20f38608b1a5a9d986bcf1d04d4dfb579fa7ec18) Thanks [@G4brym](https://github.com/G4brym)! - Add `trigger_schedule` MCP tool to manually trigger a schedule run
|
|
14
|
+
|
|
15
|
+
Adds a new MCP tool that allows agents and users to trigger a schedule to run immediately without waiting for the cron interval. The run is started asynchronously and returns the run ID so callers can check status via `list_runs`. Disabled schedules are rejected, and the concurrent run limit is enforced.
|
|
16
|
+
|
|
17
|
+
### Patch Changes
|
|
18
|
+
|
|
19
|
+
- [#20](https://github.com/G4brym/prodboard/pull/20) [`53c5d4d`](https://github.com/G4brym/prodboard/commit/53c5d4d1ee7331e50f4b4cc5fb1aa8e37836967e) Thanks [@G4brym](https://github.com/G4brym)! - Fix schedules with identical cron patterns — all matching schedules now fire
|
|
20
|
+
|
|
21
|
+
Snapshot the running-run count once before the tick loop instead of re-querying
|
|
22
|
+
inside the loop. This prevents a run created for schedule A from counting against
|
|
23
|
+
schedule B's concurrency check when both share the same cron expression.
|
|
24
|
+
|
|
3
25
|
## 0.2.3
|
|
4
26
|
|
|
5
27
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -197,6 +197,7 @@ These are the tools Claude Code sees when connected to the board:
|
|
|
197
197
|
| `create_schedule` | Set up a new cron job |
|
|
198
198
|
| `update_schedule` | Modify a schedule |
|
|
199
199
|
| `delete_schedule` | Remove a schedule |
|
|
200
|
+
| `trigger_schedule` | Trigger a schedule to run immediately |
|
|
200
201
|
| `list_runs` | Check run history and results |
|
|
201
202
|
|
|
202
203
|
MCP resources: `prodboard://issues` (board summary) and `prodboard://schedules` (active schedules).
|
package/package.json
CHANGED
package/src/agents/claude.ts
CHANGED
|
@@ -53,6 +53,11 @@ export class ClaudeDriver implements AgentDriver {
|
|
|
53
53
|
args.push("--agents", schedule.agents_json);
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
const model = schedule.model ?? config.daemon.model;
|
|
57
|
+
if (model) {
|
|
58
|
+
args.push("--model", model);
|
|
59
|
+
}
|
|
60
|
+
|
|
56
61
|
return args;
|
|
57
62
|
}
|
|
58
63
|
|
|
@@ -86,13 +91,14 @@ export class ClaudeDriver implements AgentDriver {
|
|
|
86
91
|
}
|
|
87
92
|
}
|
|
88
93
|
if (event.type === "result") {
|
|
89
|
-
if (event.
|
|
90
|
-
|
|
91
|
-
|
|
94
|
+
if (event.usage) {
|
|
95
|
+
tokens_in = (event.usage.input_tokens ?? 0)
|
|
96
|
+
+ (event.usage.cache_read_input_tokens ?? 0)
|
|
97
|
+
+ (event.usage.cache_creation_input_tokens ?? 0);
|
|
98
|
+
tokens_out = event.usage.output_tokens ?? 0;
|
|
99
|
+
}
|
|
100
|
+
if (event.total_cost_usd) cost_usd = event.total_cost_usd;
|
|
92
101
|
}
|
|
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
102
|
}
|
|
97
103
|
|
|
98
104
|
return {
|
package/src/agents/opencode.ts
CHANGED
|
@@ -17,8 +17,9 @@ export class OpenCodeDriver implements AgentDriver {
|
|
|
17
17
|
args.push("--attach", opencode.serverUrl);
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
const model = schedule.model ?? config.daemon.opencode.model ?? config.daemon.model;
|
|
21
|
+
if (model) {
|
|
22
|
+
args.push("--model", model);
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
if (opencode.agent) {
|
package/src/agents/types.ts
CHANGED
|
@@ -25,10 +25,12 @@ export interface StreamEvent {
|
|
|
25
25
|
session_id?: string;
|
|
26
26
|
tool?: string;
|
|
27
27
|
tool_input?: any;
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
28
|
+
total_cost_usd?: number;
|
|
29
|
+
usage?: {
|
|
30
|
+
input_tokens?: number;
|
|
31
|
+
output_tokens?: number;
|
|
32
|
+
cache_read_input_tokens?: number;
|
|
33
|
+
cache_creation_input_tokens?: number;
|
|
32
34
|
};
|
|
33
35
|
[key: string]: any;
|
|
34
36
|
}
|
|
@@ -77,6 +77,7 @@ export async function scheduleAdd(args: string[], dbOverride?: Database): Promis
|
|
|
77
77
|
use_worktree: !flags["no-worktree"],
|
|
78
78
|
inject_context: !flags["no-context"],
|
|
79
79
|
persist_session: !!flags["persist-session"],
|
|
80
|
+
model: flags.model as string | undefined,
|
|
80
81
|
});
|
|
81
82
|
|
|
82
83
|
console.log(`Created schedule ${schedule.id}: ${schedule.name} [${schedule.cron}]`);
|
|
@@ -101,16 +102,16 @@ export async function scheduleLs(args: string[], dbOverride?: Database): Promise
|
|
|
101
102
|
}
|
|
102
103
|
|
|
103
104
|
const table = renderTable(
|
|
104
|
-
["ID", "Name", "Cron", "Enabled", "Next Fire"],
|
|
105
|
+
["ID", "Name", "Cron", "Model", "Enabled", "Next Fire"],
|
|
105
106
|
schedules.map((s) => {
|
|
106
107
|
let nextFire = "";
|
|
107
108
|
try {
|
|
108
109
|
const next = getNextFire(s.cron, new Date());
|
|
109
110
|
nextFire = formatDate(next.toISOString());
|
|
110
111
|
} catch {}
|
|
111
|
-
return [s.id, s.name, s.cron, s.enabled ? "yes" : "no", nextFire];
|
|
112
|
+
return [s.id, s.name, s.cron, s.model ?? "-", s.enabled ? "yes" : "no", nextFire];
|
|
112
113
|
}),
|
|
113
|
-
{ maxWidths: [10, 30, 20, 8, 18] }
|
|
114
|
+
{ maxWidths: [10, 30, 20, 20, 8, 18] }
|
|
114
115
|
);
|
|
115
116
|
console.log(table);
|
|
116
117
|
console.log(`${schedules.length} schedule${schedules.length === 1 ? "" : "s"}`);
|
|
@@ -140,6 +141,7 @@ export async function scheduleEdit(args: string[], dbOverride?: Database): Promi
|
|
|
140
141
|
}
|
|
141
142
|
if (flags.prompt || flags.p) fields.prompt = flags.prompt ?? flags.p;
|
|
142
143
|
if (flags["max-turns"]) fields.max_turns = parseInt(flags["max-turns"] as string, 10);
|
|
144
|
+
if (flags.model !== undefined) fields.model = flags.model === "" ? null : flags.model;
|
|
143
145
|
|
|
144
146
|
const updated = updateSchedule(db, schedule.id, fields);
|
|
145
147
|
console.log(`Updated schedule ${updated.id}: ${updated.name}`);
|
|
@@ -205,6 +207,7 @@ export async function scheduleLogs(args: string[], dbOverride?: Database): Promi
|
|
|
205
207
|
schedule_id: (flags.schedule ?? flags.s) as string | undefined,
|
|
206
208
|
status: flags.status as string | undefined,
|
|
207
209
|
limit: safeParseInt(flags.limit ?? flags.n),
|
|
210
|
+
include_output: isJson,
|
|
208
211
|
});
|
|
209
212
|
|
|
210
213
|
if (isJson) {
|
package/src/config.ts
CHANGED
package/src/db.ts
CHANGED
package/src/mcp.ts
CHANGED
|
@@ -168,6 +168,7 @@ const TOOLS = [
|
|
|
168
168
|
prompt: { type: "string" as const, description: "Prompt to send to Claude" },
|
|
169
169
|
workdir: { type: "string" as const, description: "Working directory" },
|
|
170
170
|
max_turns: { type: "number" as const, description: "Max turns per run" },
|
|
171
|
+
model: { type: "string" as const, description: "Model to use for this schedule (e.g. claude-sonnet-4-6)" },
|
|
171
172
|
},
|
|
172
173
|
required: ["name", "cron", "prompt"],
|
|
173
174
|
},
|
|
@@ -183,6 +184,7 @@ const TOOLS = [
|
|
|
183
184
|
cron: { type: "string" as const },
|
|
184
185
|
prompt: { type: "string" as const },
|
|
185
186
|
enabled: { type: "boolean" as const },
|
|
187
|
+
model: { type: "string" as const, description: "Model override (empty string to clear)" },
|
|
186
188
|
},
|
|
187
189
|
required: ["id"],
|
|
188
190
|
},
|
|
@@ -200,16 +202,28 @@ const TOOLS = [
|
|
|
200
202
|
},
|
|
201
203
|
{
|
|
202
204
|
name: "list_runs",
|
|
203
|
-
description: "List run history for schedules.",
|
|
205
|
+
description: "List run history for schedules. By default excludes large fields (stdout_tail, prompt_used) for compact output.",
|
|
204
206
|
inputSchema: {
|
|
205
207
|
type: "object" as const,
|
|
206
208
|
properties: {
|
|
207
209
|
schedule_id: { type: "string" as const, description: "Filter by schedule ID" },
|
|
208
210
|
status: { type: "string" as const, description: "Filter by run status" },
|
|
209
211
|
limit: { type: "number" as const, description: "Max runs to return" },
|
|
212
|
+
include_output: { type: "boolean" as const, description: "Include stdout_tail and prompt_used fields (default: false)" },
|
|
210
213
|
},
|
|
211
214
|
},
|
|
212
215
|
},
|
|
216
|
+
{
|
|
217
|
+
name: "trigger_schedule",
|
|
218
|
+
description: "Manually trigger a schedule to run immediately. The run is started asynchronously — returns the run ID so you can check status later via list_runs.",
|
|
219
|
+
inputSchema: {
|
|
220
|
+
type: "object" as const,
|
|
221
|
+
properties: {
|
|
222
|
+
id: { type: "string" as const, description: "Schedule ID or unique prefix" },
|
|
223
|
+
},
|
|
224
|
+
required: ["id"],
|
|
225
|
+
},
|
|
226
|
+
},
|
|
213
227
|
];
|
|
214
228
|
|
|
215
229
|
const RESOURCES = [
|
|
@@ -389,6 +403,7 @@ export async function handleCreateSchedule(db: Database, params: any) {
|
|
|
389
403
|
prompt: params.prompt,
|
|
390
404
|
workdir: params.workdir,
|
|
391
405
|
max_turns: params.max_turns,
|
|
406
|
+
model: params.model,
|
|
392
407
|
source: "mcp",
|
|
393
408
|
});
|
|
394
409
|
}
|
|
@@ -408,6 +423,7 @@ export async function handleUpdateSchedule(db: Database, params: any) {
|
|
|
408
423
|
}
|
|
409
424
|
if (params.prompt !== undefined) fields.prompt = params.prompt;
|
|
410
425
|
if (params.enabled !== undefined) fields.enabled = params.enabled ? 1 : 0;
|
|
426
|
+
if (params.model !== undefined) fields.model = params.model === "" ? null : params.model;
|
|
411
427
|
|
|
412
428
|
return sq.updateSchedule(db, schedule.id, fields);
|
|
413
429
|
}
|
|
@@ -429,7 +445,50 @@ export async function handleListRuns(db: Database, params: any) {
|
|
|
429
445
|
schedule_id: params?.schedule_id,
|
|
430
446
|
status: params?.status,
|
|
431
447
|
limit: params?.limit,
|
|
448
|
+
include_output: params?.include_output,
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
export async function handleTriggerSchedule(db: Database, config: Config, params: any) {
|
|
453
|
+
const sq = await getScheduleQueries();
|
|
454
|
+
if (!sq) throw new Error("Schedule module not available");
|
|
455
|
+
const rq = await getRunQueries();
|
|
456
|
+
if (!rq) throw new Error("Run module not available");
|
|
457
|
+
|
|
458
|
+
const schedule = sq.getScheduleByPrefix(db, params.id);
|
|
459
|
+
|
|
460
|
+
if (!schedule.enabled) {
|
|
461
|
+
throw new Error(`Schedule "${schedule.name}" is disabled. Enable it first via update_schedule.`);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Respect concurrent run limit
|
|
465
|
+
const runningRuns = rq.getRunningRuns(db);
|
|
466
|
+
if (runningRuns.length >= config.daemon.maxConcurrentRuns) {
|
|
467
|
+
throw new Error(
|
|
468
|
+
`Concurrent run limit reached (${runningRuns.length}/${config.daemon.maxConcurrentRuns}). Wait for a running schedule to finish or increase maxConcurrentRuns.`
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const run = rq.createRun(db, {
|
|
473
|
+
schedule_id: schedule.id,
|
|
474
|
+
prompt_used: schedule.prompt,
|
|
475
|
+
agent: config.daemon.agent,
|
|
432
476
|
});
|
|
477
|
+
|
|
478
|
+
// Fire-and-forget: start execution asynchronously
|
|
479
|
+
// Note: ExecutionManager created without tmux/worktree managers — MCP-triggered runs
|
|
480
|
+
// use direct execution only. Daemon-triggered runs get full tmux/worktree support.
|
|
481
|
+
const { ExecutionManager } = await import("./scheduler.ts");
|
|
482
|
+
const em = new ExecutionManager(db, config);
|
|
483
|
+
em.executeRun(schedule, run).catch(() => {});
|
|
484
|
+
|
|
485
|
+
return {
|
|
486
|
+
run_id: run.id,
|
|
487
|
+
schedule_id: schedule.id,
|
|
488
|
+
schedule_name: schedule.name,
|
|
489
|
+
status: "started",
|
|
490
|
+
message: "Run started asynchronously. Use list_runs to check status.",
|
|
491
|
+
};
|
|
433
492
|
}
|
|
434
493
|
|
|
435
494
|
export async function startMcpServer(): Promise<void> {
|
|
@@ -497,6 +556,9 @@ export async function startMcpServer(): Promise<void> {
|
|
|
497
556
|
case "list_runs":
|
|
498
557
|
result = await handleListRuns(db, params ?? {});
|
|
499
558
|
break;
|
|
559
|
+
case "trigger_schedule":
|
|
560
|
+
result = await handleTriggerSchedule(db, config, params ?? {});
|
|
561
|
+
break;
|
|
500
562
|
default:
|
|
501
563
|
return {
|
|
502
564
|
content: [{ type: "text", text: `Unknown tool: ${name}` }],
|
package/src/queries/runs.ts
CHANGED
|
@@ -50,7 +50,7 @@ export function updateRun(
|
|
|
50
50
|
|
|
51
51
|
export function listRuns(
|
|
52
52
|
db: Database,
|
|
53
|
-
opts?: { schedule_id?: string; status?: string; limit?: number }
|
|
53
|
+
opts?: { schedule_id?: string; status?: string; limit?: number; include_output?: boolean }
|
|
54
54
|
): Run[] {
|
|
55
55
|
const conditions: string[] = [];
|
|
56
56
|
const params: any[] = [];
|
|
@@ -67,8 +67,15 @@ export function listRuns(
|
|
|
67
67
|
const where = conditions.length > 0 ? "WHERE " + conditions.join(" AND ") : "";
|
|
68
68
|
const limit = opts?.limit ?? 50;
|
|
69
69
|
|
|
70
|
+
const columns = opts?.include_output
|
|
71
|
+
? "r.*"
|
|
72
|
+
: `r.id, r.schedule_id, r.status, r.pid, r.started_at, r.finished_at,
|
|
73
|
+
r.exit_code, r.stderr_tail, r.session_id, r.worktree_path,
|
|
74
|
+
r.tokens_in, r.tokens_out, r.cost_usd, r.tools_used,
|
|
75
|
+
r.issues_touched, r.tmux_session, r.agent`;
|
|
76
|
+
|
|
70
77
|
return db.query(`
|
|
71
|
-
SELECT
|
|
78
|
+
SELECT ${columns}, s.name as schedule_name
|
|
72
79
|
FROM runs r
|
|
73
80
|
LEFT JOIN schedules s ON r.schedule_id = s.id
|
|
74
81
|
${where}
|
package/src/queries/schedules.ts
CHANGED
|
@@ -16,6 +16,7 @@ export function createSchedule(
|
|
|
16
16
|
persist_session?: boolean;
|
|
17
17
|
agents_json?: string;
|
|
18
18
|
source?: string;
|
|
19
|
+
model?: string;
|
|
19
20
|
}
|
|
20
21
|
): Schedule {
|
|
21
22
|
const id = generateId();
|
|
@@ -23,8 +24,8 @@ export function createSchedule(
|
|
|
23
24
|
|
|
24
25
|
db.query(`
|
|
25
26
|
INSERT INTO schedules (id, name, cron, prompt, workdir, max_turns, allowed_tools,
|
|
26
|
-
use_worktree, inject_context, persist_session, agents_json, source, created_at, updated_at)
|
|
27
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
27
|
+
use_worktree, inject_context, persist_session, agents_json, source, model, created_at, updated_at)
|
|
28
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
28
29
|
`).run(
|
|
29
30
|
id, opts.name, opts.cron, opts.prompt,
|
|
30
31
|
opts.workdir ?? ".",
|
|
@@ -35,6 +36,7 @@ export function createSchedule(
|
|
|
35
36
|
opts.persist_session ? 1 : 0,
|
|
36
37
|
opts.agents_json ?? null,
|
|
37
38
|
opts.source ?? "cli",
|
|
39
|
+
opts.model ?? null,
|
|
38
40
|
now, now
|
|
39
41
|
);
|
|
40
42
|
|
|
@@ -83,6 +85,7 @@ export function updateSchedule(
|
|
|
83
85
|
enabled: "enabled", max_turns: "max_turns", allowed_tools: "allowed_tools",
|
|
84
86
|
use_worktree: "use_worktree", inject_context: "inject_context",
|
|
85
87
|
persist_session: "persist_session", agents_json: "agents_json",
|
|
88
|
+
model: "model",
|
|
86
89
|
};
|
|
87
90
|
|
|
88
91
|
let hasRealFields = false;
|
package/src/scheduler.ts
CHANGED
|
@@ -282,6 +282,13 @@ export class CronLoop {
|
|
|
282
282
|
|
|
283
283
|
const schedules = listSchedules(this.db);
|
|
284
284
|
|
|
285
|
+
// Snapshot running count once before creating any new runs this tick.
|
|
286
|
+
// Without this, createRun() for schedule A inserts a 'running' row that
|
|
287
|
+
// getRunningRuns() would count when evaluating schedule B, causing
|
|
288
|
+
// schedules with identical cron patterns to block each other.
|
|
289
|
+
const runningCount = getRunningRuns(this.db).length;
|
|
290
|
+
let newRunsThisTick = 0;
|
|
291
|
+
|
|
285
292
|
for (const schedule of schedules) {
|
|
286
293
|
try {
|
|
287
294
|
if (!shouldFire(schedule.cron, now)) continue;
|
|
@@ -289,8 +296,7 @@ export class CronLoop {
|
|
|
289
296
|
const lastFiredMinute = this.lastFired.get(schedule.id);
|
|
290
297
|
if (lastFiredMinute === minuteTs) continue;
|
|
291
298
|
|
|
292
|
-
|
|
293
|
-
if (runningRuns.length >= this.config.daemon.maxConcurrentRuns) continue;
|
|
299
|
+
if (runningCount + newRunsThisTick >= this.config.daemon.maxConcurrentRuns) break;
|
|
294
300
|
|
|
295
301
|
const run = createRun(this.db, {
|
|
296
302
|
schedule_id: schedule.id,
|
|
@@ -299,6 +305,7 @@ export class CronLoop {
|
|
|
299
305
|
});
|
|
300
306
|
|
|
301
307
|
this.lastFired.set(schedule.id, minuteTs);
|
|
308
|
+
newRunsThisTick++;
|
|
302
309
|
|
|
303
310
|
this.executionManager.executeRun(schedule, run).catch(() => {});
|
|
304
311
|
} catch (err) {
|
package/src/types.ts
CHANGED
|
@@ -6,6 +6,7 @@ export interface Config {
|
|
|
6
6
|
};
|
|
7
7
|
daemon: {
|
|
8
8
|
agent: "claude" | "opencode";
|
|
9
|
+
model: string | null;
|
|
9
10
|
basePath: string | null;
|
|
10
11
|
useTmux: boolean;
|
|
11
12
|
opencode: {
|
|
@@ -62,6 +63,7 @@ export interface Schedule {
|
|
|
62
63
|
use_worktree: number;
|
|
63
64
|
inject_context: number;
|
|
64
65
|
persist_session: number;
|
|
66
|
+
model: string | null;
|
|
65
67
|
agents_json: string | null;
|
|
66
68
|
source: string;
|
|
67
69
|
created_at: string;
|
|
@@ -14,7 +14,7 @@ export const Board: FC<{ issues: Issue[]; statuses: string[] }> = ({ issues, sta
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
return (
|
|
17
|
-
<div class="flex gap-4 overflow-x-auto pb-4">
|
|
17
|
+
<div id="board" data-statuses={JSON.stringify(statuses.filter((s) => s !== "archived"))} class="flex gap-4 overflow-x-auto pb-4">
|
|
18
18
|
{statuses.filter((s) => s !== "archived").map((status) => (
|
|
19
19
|
<div class="flex-1 min-w-[220px]" key={status}>
|
|
20
20
|
<div class="flex items-center gap-2 mb-3 px-1">
|
|
@@ -21,7 +21,7 @@ export function issueRoutes(db: Database, config: Config) {
|
|
|
21
21
|
<div class="flex items-center justify-between mb-6">
|
|
22
22
|
<div>
|
|
23
23
|
<h1 class="text-xl font-semibold">Issues</h1>
|
|
24
|
-
<p class="text-sm text-muted-foreground mt-0.5">{issues.length} total</p>
|
|
24
|
+
<p id="board-total" class="text-sm text-muted-foreground mt-0.5">{issues.length} total</p>
|
|
25
25
|
</div>
|
|
26
26
|
<button
|
|
27
27
|
onclick="document.getElementById('new-issue-form').classList.toggle('hidden')"
|
|
@@ -76,6 +76,84 @@ export function issueRoutes(db: Database, config: Config) {
|
|
|
76
76
|
</div>
|
|
77
77
|
|
|
78
78
|
<Board issues={issues} statuses={config.general.statuses} />
|
|
79
|
+
|
|
80
|
+
<p id="board-updated" class="text-xs text-muted-foreground/50 mt-2 text-right"></p>
|
|
81
|
+
|
|
82
|
+
<script dangerouslySetInnerHTML={{ __html: `
|
|
83
|
+
(function() {
|
|
84
|
+
var INTERVAL = 30000;
|
|
85
|
+
var board = document.getElementById('board');
|
|
86
|
+
var updatedEl = document.getElementById('board-updated');
|
|
87
|
+
var statuses = JSON.parse(board.dataset.statuses);
|
|
88
|
+
var lastUpdate = Date.now();
|
|
89
|
+
|
|
90
|
+
var STATUS_STYLES = {
|
|
91
|
+
'todo': 'bg-zinc-700/50 text-zinc-300 border-zinc-600',
|
|
92
|
+
'in-progress': 'bg-blue-500/15 text-blue-400 border-blue-500/30',
|
|
93
|
+
'review': 'bg-amber-500/15 text-amber-400 border-amber-500/30',
|
|
94
|
+
'done': 'bg-emerald-500/15 text-emerald-400 border-emerald-500/30',
|
|
95
|
+
'human-approval': 'bg-amber-500/15 text-amber-400 border-amber-500/30',
|
|
96
|
+
'running': 'bg-blue-500/15 text-blue-400 border-blue-500/30'
|
|
97
|
+
};
|
|
98
|
+
var DEFAULT_STYLE = 'bg-zinc-700/50 text-zinc-300 border-zinc-600';
|
|
99
|
+
|
|
100
|
+
function escapeHtml(s) {
|
|
101
|
+
var d = document.createElement('div');
|
|
102
|
+
d.textContent = s;
|
|
103
|
+
return d.innerHTML;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function renderBoard(issues) {
|
|
107
|
+
var grouped = {};
|
|
108
|
+
statuses.forEach(function(s) { grouped[s] = []; });
|
|
109
|
+
issues.forEach(function(issue) {
|
|
110
|
+
if (grouped[issue.status]) grouped[issue.status].push(issue);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
var html = '';
|
|
114
|
+
statuses.forEach(function(status) {
|
|
115
|
+
var items = grouped[status] || [];
|
|
116
|
+
html += '<div class="flex-1 min-w-[220px]">';
|
|
117
|
+
html += '<div class="flex items-center gap-2 mb-3 px-1">';
|
|
118
|
+
html += '<h3 class="text-xs font-semibold uppercase tracking-wider text-muted-foreground">' + escapeHtml(status) + '</h3>';
|
|
119
|
+
html += '<span class="text-xs text-muted-foreground/60">' + items.length + '</span>';
|
|
120
|
+
html += '</div>';
|
|
121
|
+
html += '<div class="space-y-2">';
|
|
122
|
+
items.forEach(function(issue) {
|
|
123
|
+
html += '<a href="/issues/' + escapeHtml(issue.id) + '" class="block rounded-lg border border-border bg-card p-3 hover:bg-accent transition-colors group">';
|
|
124
|
+
html += '<div class="text-sm font-medium text-card-foreground group-hover:text-foreground">' + escapeHtml(issue.title) + '</div>';
|
|
125
|
+
html += '<div class="text-xs text-muted-foreground mt-1 font-mono">' + escapeHtml(issue.id.slice(0, 8)) + '</div>';
|
|
126
|
+
html += '</a>';
|
|
127
|
+
});
|
|
128
|
+
html += '</div></div>';
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
board.innerHTML = html;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function updateTimestamp() {
|
|
135
|
+
var secs = Math.round((Date.now() - lastUpdate) / 1000);
|
|
136
|
+
updatedEl.textContent = 'Updated ' + secs + 's ago';
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function poll() {
|
|
140
|
+
fetch('/api/issues')
|
|
141
|
+
.then(function(r) { return r.ok ? r.json() : null; })
|
|
142
|
+
.then(function(issues) {
|
|
143
|
+
if (!issues) return;
|
|
144
|
+
renderBoard(issues);
|
|
145
|
+
var countEl = document.getElementById('board-total');
|
|
146
|
+
if (countEl) countEl.textContent = issues.length + ' total';
|
|
147
|
+
lastUpdate = Date.now();
|
|
148
|
+
updateTimestamp();
|
|
149
|
+
})
|
|
150
|
+
.catch(function() {});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
setInterval(poll, INTERVAL);
|
|
154
|
+
setInterval(updateTimestamp, 5000);
|
|
155
|
+
})();
|
|
156
|
+
` }} />
|
|
79
157
|
</Layout>
|
|
80
158
|
);
|
|
81
159
|
});
|
|
@@ -5,6 +5,7 @@ import type { Config } from "../../types.ts";
|
|
|
5
5
|
import { Layout } from "../components/layout.tsx";
|
|
6
6
|
import { StatusBadge } from "../components/status-badge.tsx";
|
|
7
7
|
import { listRuns, getRunningRuns } from "../../queries/runs.ts";
|
|
8
|
+
import { listIssues } from "../../queries/issues.ts";
|
|
8
9
|
import type { Run } from "../../types.ts";
|
|
9
10
|
|
|
10
11
|
export function runRoutes(db: Database, _config: Config) {
|
|
@@ -137,5 +138,10 @@ export function apiRoutes(db: Database, _config: Config) {
|
|
|
137
138
|
});
|
|
138
139
|
});
|
|
139
140
|
|
|
141
|
+
app.get("/issues", (c) => {
|
|
142
|
+
const { issues } = listIssues(db, { includeArchived: true, limit: 500 });
|
|
143
|
+
return c.json(issues);
|
|
144
|
+
});
|
|
145
|
+
|
|
140
146
|
return app;
|
|
141
147
|
}
|
package/templates/config.jsonc
CHANGED
|
@@ -17,6 +17,12 @@
|
|
|
17
17
|
// Agent to use: "claude" or "opencode"
|
|
18
18
|
// "agent": "claude",
|
|
19
19
|
|
|
20
|
+
// Default model for agent runs (null = agent's default)
|
|
21
|
+
// For Claude: "claude-sonnet-4-6", "claude-opus-4-6", "claude-haiku-4-5-20251001"
|
|
22
|
+
// For OpenCode: "anthropic/claude-sonnet-4-20250514", etc.
|
|
23
|
+
// Can be overridden per-schedule with --model
|
|
24
|
+
// "model": null,
|
|
25
|
+
|
|
20
26
|
// Base path for worktrees and agent runs (null = use schedule workdir)
|
|
21
27
|
// "basePath": null,
|
|
22
28
|
|