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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prodboard",
3
- "version": "0.2.3",
3
+ "version": "0.4.0",
4
4
  "description": "Self-hosted, CLI-first issue tracker and cron scheduler for AI coding agents",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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.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;
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 {
@@ -17,8 +17,9 @@ export class OpenCodeDriver implements AgentDriver {
17
17
  args.push("--attach", opencode.serverUrl);
18
18
  }
19
19
 
20
- if (opencode.model) {
21
- args.push("--model", opencode.model);
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) {
@@ -25,10 +25,12 @@ export interface StreamEvent {
25
25
  session_id?: string;
26
26
  tool?: string;
27
27
  tool_input?: any;
28
- result?: {
29
- tokens_in?: number;
30
- tokens_out?: number;
31
- cost_usd?: number;
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
@@ -73,6 +73,7 @@ export function getDefaults(): Config {
73
73
  },
74
74
  daemon: {
75
75
  agent: "claude",
76
+ model: null,
76
77
  basePath: null,
77
78
  useTmux: true,
78
79
  opencode: {
package/src/db.ts CHANGED
@@ -108,6 +108,10 @@ const MIGRATIONS: Migration[] = [
108
108
  ALTER TABLE runs ADD COLUMN agent TEXT NOT NULL DEFAULT 'claude';
109
109
  `,
110
110
  },
111
+ {
112
+ version: 3,
113
+ sql: `ALTER TABLE schedules ADD COLUMN model TEXT;`,
114
+ },
111
115
  ];
112
116
 
113
117
  export { MIGRATIONS };
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}` }],
@@ -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 r.*, s.name as schedule_name
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}
@@ -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
- const runningRuns = getRunningRuns(this.db);
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
  }
@@ -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