@dfsramos/foreman 0.0.0-stage → 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.
Files changed (59) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/LICENSE +21 -0
  3. package/README.md +385 -2
  4. package/dist/agent/adapters/agy.js +96 -0
  5. package/dist/agent/adapters/claude.js +152 -0
  6. package/dist/agent/adapters/codex.js +107 -0
  7. package/dist/agent/adapters/opencode.js +91 -0
  8. package/dist/agent/config.js +169 -0
  9. package/dist/agent/process.js +230 -0
  10. package/dist/agent/prompt.js +184 -0
  11. package/dist/agent/registry.js +57 -0
  12. package/dist/agent/types.js +12 -0
  13. package/dist/cli-args.js +70 -0
  14. package/dist/cli.js +184 -0
  15. package/dist/config.js +102 -0
  16. package/dist/daemon.js +109 -0
  17. package/dist/db.js +467 -0
  18. package/dist/git/taskstore.js +224 -0
  19. package/dist/git/worktree.js +248 -0
  20. package/dist/imports/connections.js +57 -0
  21. package/dist/imports/destination.js +2 -0
  22. package/dist/imports/linear.js +40 -0
  23. package/dist/imports/registry.js +4 -0
  24. package/dist/imports/types.js +1 -0
  25. package/dist/kaneo/client.js +47 -0
  26. package/dist/kaneo/issues.js +138 -0
  27. package/dist/kaneo/workspace.js +24 -0
  28. package/dist/linear/browse.js +55 -0
  29. package/dist/linear/client.js +48 -0
  30. package/dist/linear/issues.js +99 -0
  31. package/dist/linear/workspace.js +30 -0
  32. package/dist/paths.js +12 -0
  33. package/dist/pidfile.js +35 -0
  34. package/dist/projects.js +211 -0
  35. package/dist/review/decide.js +186 -0
  36. package/dist/review/loop.js +114 -0
  37. package/dist/runner.js +1281 -0
  38. package/dist/server/app.js +344 -0
  39. package/dist/server/imports.js +182 -0
  40. package/dist/server/tasks.js +77 -0
  41. package/dist/sources/branch.js +17 -0
  42. package/dist/sources/git-import.js +60 -0
  43. package/dist/sources/git.js +430 -0
  44. package/dist/sources/kaneo.js +156 -0
  45. package/dist/sources/lease.js +38 -0
  46. package/dist/sources/linear.js +186 -0
  47. package/dist/sources/options.js +26 -0
  48. package/dist/sources/registry.js +6 -0
  49. package/dist/sources/taskfile.js +190 -0
  50. package/dist/sources/types.js +1 -0
  51. package/package.json +63 -4
  52. package/public/agents.js +435 -0
  53. package/public/app.js +1200 -0
  54. package/public/imports.js +481 -0
  55. package/public/index.html +19 -0
  56. package/public/projects.js +481 -0
  57. package/public/style.css +1614 -0
  58. package/public/tasks.js +253 -0
  59. package/public/ui.js +165 -0
package/public/app.js ADDED
@@ -0,0 +1,1200 @@
1
+ // Foreman console. Plain ES module, no dependencies and no build step.
2
+ // All text that originates from the tracker or the agent is inserted as text nodes, never as HTML.
3
+
4
+ import { agentsTab, resetAgentsDraft, updateAgentsUsage } from './agents.js';
5
+ import { importsTab, resetImports } from './imports.js';
6
+ import { isEditingProject, projectsTab, resetProjectForm } from './projects.js';
7
+ import { resetTasks, tasksTab } from './tasks.js';
8
+ import {
9
+ $,
10
+ api,
11
+ banner,
12
+ button,
13
+ currentWindows,
14
+ dayLabel,
15
+ externalLink,
16
+ fill,
17
+ fmtClock,
18
+ fmtCountdown,
19
+ fmtDateTime,
20
+ fmtDuration,
21
+ fmtPercent,
22
+ fmtReset,
23
+ fmtTime,
24
+ h,
25
+ icon,
26
+ prLabel,
27
+ runDurationMs,
28
+ windowLabel,
29
+ } from './ui.js';
30
+
31
+ const PAGE_SIZE = 50;
32
+ const MAX_LOG_LINES = 2000;
33
+ const STEPS = [
34
+ ['claim', 'Claim'],
35
+ ['worktree', 'Worktree'],
36
+ ['agent', 'Agent running'],
37
+ ['pull_request', 'Pull request'],
38
+ ['report', 'Report'],
39
+ ];
40
+ const OUTCOMES = {
41
+ running: { label: 'Running', tone: 'run', icon: 'spinner' },
42
+ succeeded: { label: 'Succeeded', tone: 'ok', icon: 'check' },
43
+ failed: { label: 'Failed', tone: 'fail', icon: 'cross' },
44
+ aborted: { label: 'Aborted', tone: 'muted', icon: 'slash' },
45
+ blocked: { label: 'Needs input', tone: 'warn', icon: 'warning' },
46
+ };
47
+ const LOG_KINDS = {
48
+ foreman: 'system',
49
+ assistant: 'agent',
50
+ tool_use: 'tool',
51
+ tool_result: 'result',
52
+ result: 'result',
53
+ };
54
+ const state = {
55
+ status: null,
56
+ projects: null,
57
+ agents: null,
58
+ viewRunId: null,
59
+ connected: true,
60
+ loadError: null,
61
+ recent: [],
62
+ counts: { running: 0, succeeded: 0, failed: 0, aborted: 0, blocked: 0 },
63
+ history: { filter: 'all', projectId: null, runs: [], total: 0 },
64
+ tab: 'run',
65
+ selectedId: null,
66
+ detail: null,
67
+ live: { runId: null, events: [], follow: true, unseen: 0 },
68
+ actionError: null,
69
+ };
70
+
71
+ // References to elements that are patched in place between full renders.
72
+ let ui = {};
73
+ let mainKey = '';
74
+
75
+ /* ---------- API ---------- */
76
+
77
+ async function loadAll() {
78
+ try {
79
+ const [status, projects, agents] = await Promise.all([
80
+ api('GET', '/api/status'),
81
+ api('GET', '/api/projects'),
82
+ api('GET', '/api/agents'),
83
+ ]);
84
+ if (!status.ok || !projects.ok || !agents.ok) {
85
+ throw new Error(status.data?.error ?? projects.data?.error ?? agents.data?.error ?? 'request failed');
86
+ }
87
+ state.status = status.data;
88
+ state.projects = projects.data;
89
+ state.agents = agents.data;
90
+ state.loadError = null;
91
+ await loadRuns();
92
+ } catch (error) {
93
+ state.loadError = error instanceof Error ? error.message : String(error);
94
+ }
95
+ render();
96
+ }
97
+
98
+ async function loadRuns() {
99
+ const recent = await api('GET', `/api/runs?limit=${PAGE_SIZE}`);
100
+ if (recent.ok) {
101
+ state.recent = recent.data.runs;
102
+ state.counts = recent.data.counts;
103
+ }
104
+ await loadHistory(false);
105
+ }
106
+
107
+ async function loadHistory(more) {
108
+ const { filter, runs } = state.history;
109
+ const offset = more ? runs.length : 0;
110
+ const limit = more ? PAGE_SIZE : Math.max(PAGE_SIZE, runs.length);
111
+ const project = state.history.projectId === null ? '' : `&project=${state.history.projectId}`;
112
+ const query = `limit=${limit}&offset=${offset}${filter === 'all' ? '' : `&status=${filter}`}${project}`;
113
+ const response = await api('GET', `/api/runs?${query}`);
114
+ if (!response.ok) return;
115
+ state.history.runs = more ? [...runs, ...response.data.runs] : response.data.runs;
116
+ state.history.total = response.data.total;
117
+ state.counts = response.data.counts;
118
+ }
119
+
120
+ async function loadDetail(id) {
121
+ const [run, events] = await Promise.all([api('GET', `/api/runs/${id}`), api('GET', `/api/runs/${id}/events`)]);
122
+ if (state.selectedId !== id) return;
123
+ state.detail = run.ok ? { run: run.data, events: events.data?.events ?? [] } : { missing: true };
124
+ renderMain(true);
125
+ }
126
+
127
+ async function runnerAction(path) {
128
+ state.actionError = null;
129
+ const response = await api('POST', path);
130
+ if (response.ok) {
131
+ // The response is a snapshot from before the action took effect (an abort finishes moments later),
132
+ // and the event stream may already have delivered something newer, so ask again rather than apply it.
133
+ const status = await api('GET', '/api/status');
134
+ if (status.ok) applyStatus(status.data);
135
+ } else {
136
+ state.actionError = response.data?.error ?? `Request failed (${response.status})`;
137
+ renderHeader();
138
+ }
139
+ }
140
+
141
+ /* ---------- state updates ---------- */
142
+
143
+ const projectOf = (id) => state.projects?.projects.find((project) => project.id === id) ?? null;
144
+ const projectName = (run) => run.projectName ?? '—';
145
+ const projectStatus = (id) => state.status?.projects.find((project) => project.id === id) ?? null;
146
+
147
+ const agentName = (id) => state.agents?.agents.find((agent) => agent.id === id)?.name ?? id;
148
+
149
+ /** The active run shown on the Run tab: the one the user picked, or the oldest still running. */
150
+ function viewedRun() {
151
+ const runs = state.status?.activeRuns ?? [];
152
+ const picked = runs.find((run) => run.id === state.viewRunId) ?? runs[0] ?? null;
153
+ state.viewRunId = picked?.id ?? null;
154
+ return picked;
155
+ }
156
+
157
+ function applyStatus(status) {
158
+ state.status = status;
159
+ for (const run of status.activeRuns) upsertRun(run);
160
+ const viewed = viewedRun();
161
+ if (viewed && viewed.id !== state.live.runId) startLive(viewed.id);
162
+ renderHeader();
163
+ renderBanner();
164
+ renderTabs();
165
+ renderMain();
166
+ }
167
+
168
+ function upsertRun(run) {
169
+ for (const list of [state.recent, state.history.runs]) {
170
+ const index = list.findIndex((item) => item.id === run.id);
171
+ if (index >= 0) list[index] = run;
172
+ else if (
173
+ list === state.recent ||
174
+ ((state.history.filter === 'all' || state.history.filter === run.status) &&
175
+ (state.history.projectId === null || state.history.projectId === run.projectId))
176
+ ) {
177
+ list.unshift(run);
178
+ }
179
+ }
180
+ const active = state.status?.activeRuns ?? [];
181
+ const position = active.findIndex((item) => item.id === run.id);
182
+ if (position >= 0) active[position] = run;
183
+ if (state.detail?.run?.id === run.id) state.detail.run = run;
184
+ }
185
+
186
+ function applyRun(run) {
187
+ const known = state.recent.find((item) => item.id === run.id);
188
+ const finished = run.status !== 'running' && known?.status !== run.status;
189
+ upsertRun(run);
190
+ if (finished || !known) {
191
+ // Counts and filtered lists change when a run starts or ends.
192
+ void loadRuns().then(() => {
193
+ renderTabs();
194
+ if (!isFormTab()) renderMain(true);
195
+ });
196
+ }
197
+ if (state.tab === 'run' && ui.runHead && state.viewRunId === run.id) {
198
+ fill(ui.runHead, runHeadContent(run));
199
+ } else if (state.tab === 'run' && ui.strip && (state.status?.activeRuns ?? []).some((item) => item.id === run.id)) {
200
+ fill(ui.strip, stripContent());
201
+ } else if (!isFormTab()) {
202
+ renderMain(true);
203
+ }
204
+ }
205
+
206
+ // The Agents, Import and Tasks tabs, and the Projects tab while a project is being edited, hold unsaved input, so live updates never rebuild them.
207
+ const isFormTab = () => state.tab === 'agents' || state.tab === 'import' || state.tab === 'tasks' || (state.tab === 'projects' && isEditingProject());
208
+
209
+ function startLive(runId) {
210
+ state.live = { runId, events: [], follow: true, unseen: 0 };
211
+ void api('GET', `/api/runs/${runId}/events`).then((response) => {
212
+ if (!response.ok || state.live.runId !== runId) return;
213
+ const seen = new Set(state.live.events.map((event) => event.id));
214
+ state.live.events = [...response.data.events.filter((event) => !seen.has(event.id)), ...state.live.events].sort(
215
+ (a, b) => a.id - b.id,
216
+ );
217
+ if (state.tab === 'run') renderMain(true);
218
+ });
219
+ }
220
+
221
+ function applyLog(event) {
222
+ if (event.runId === state.live.runId && !state.live.events.some((known) => known.id === event.id)) {
223
+ state.live.events.push(event);
224
+ if (ui.liveLog) {
225
+ appendLogLine(ui.liveLog, event);
226
+ if (state.live.follow) ui.liveLog.scrollTop = ui.liveLog.scrollHeight;
227
+ else state.live.unseen += 1;
228
+ updateLogToolbar();
229
+ }
230
+ }
231
+ if (state.detail?.run?.id === event.runId && !state.detail.events.some((known) => known.id === event.id)) {
232
+ state.detail.events.push(event);
233
+ if (ui.detailLog) appendLogLine(ui.detailLog, event);
234
+ }
235
+ }
236
+
237
+ /* ---------- routing ---------- */
238
+
239
+ function readHash() {
240
+ const [tab, id] = location.hash.replace(/^#/, '').split('/');
241
+ state.tab = ['run', 'history', 'projects', 'tasks', 'import', 'agents'].includes(tab) ? tab : 'run';
242
+ const selected = state.tab === 'history' && /^\d+$/.test(id ?? '') ? Number(id) : null;
243
+ if (selected !== state.selectedId) {
244
+ state.selectedId = selected;
245
+ state.detail = null;
246
+ if (selected !== null) void loadDetail(selected);
247
+ }
248
+ }
249
+
250
+ function go(hash) {
251
+ if (location.hash === hash) return;
252
+ location.hash = hash;
253
+ }
254
+
255
+ /* ---------- shared components ---------- */
256
+
257
+ function outcomeBadge(status, large = false) {
258
+ const outcome = OUTCOMES[status];
259
+ return h('span', { class: `badge tone-${outcome.tone} ${large ? 'large' : ''}` }, icon(outcome.icon, 12), outcome.label);
260
+ }
261
+
262
+ const outcomeIcon = (status) =>
263
+ h('span', { class: `text-${OUTCOMES[status].tone}` }, icon(OUTCOMES[status].icon, 14, OUTCOMES[status].label));
264
+
265
+ function stateBadge(state, detail, large = false) {
266
+ const cls = `badge ${large ? 'large' : ''}`;
267
+ const extra = detail ? h('span', { class: 'detail' }, `· ${detail}`) : null;
268
+ if (state === 'idle') return h('span', { class: `${cls} tone-ok` }, h('span', { class: 'pulse-dot' }), 'Idle', extra);
269
+ if (state === 'working') return h('span', { class: `${cls} tone-run` }, icon('spinner', 12), 'Working', extra);
270
+ if (state === 'stopping') return h('span', { class: `${cls} tone-warn` }, icon('pause', 12), 'Stopping', extra);
271
+ return h('span', { class: `${cls} tone-muted` }, icon('square', 12), 'Stopped', extra);
272
+ }
273
+
274
+ function runnerBadge(status) {
275
+ const runs = status.activeRuns.length;
276
+ const runsText = runs === 1 ? status.activeRuns[0].identifier : runs > 1 ? `${runs} runs` : null;
277
+ if (status.state === 'idle') return stateBadge('idle', 'polling', true);
278
+ if (status.state === 'working') return stateBadge('working', runsText, true);
279
+ if (status.state === 'stopping') return stateBadge('stopping', runsText ? `halts after ${runsText}` : null, true);
280
+ return stateBadge('stopped', null, true);
281
+ }
282
+
283
+ function stepper(run, bare = false) {
284
+ const current = STEPS.findIndex(([key]) => key === run.step);
285
+ return h(
286
+ 'div',
287
+ { class: `stepper ${bare ? 'bare' : ''}` },
288
+ STEPS.map(([, label], index) => {
289
+ let kind = 'pending';
290
+ if (run.status === 'succeeded' || index < current) kind = 'done';
291
+ else if (index === current) kind = { running: 'active', failed: 'fail', aborted: 'abort', blocked: 'abort' }[run.status];
292
+ const mark = { done: 'tick', active: 'spinner', fail: 'x', abort: 'line' }[kind];
293
+ return h(
294
+ 'div',
295
+ { class: `step ${kind}` },
296
+ h('div', { class: 'track' }, h('span', { class: 'dot' }, mark ? icon(mark, 11) : String(index + 1)), h('span', { class: 'line' })),
297
+ h('div', { class: 'label', title: label }, label),
298
+ );
299
+ }),
300
+ );
301
+ }
302
+
303
+ function logLine(event) {
304
+ const kind = LOG_KINDS[event.kind] ?? 'system';
305
+ let text = event.text;
306
+ let lead = null;
307
+ if (event.kind === 'tool_use') {
308
+ const space = text.indexOf(' ');
309
+ lead = h('b', null, space < 0 ? text : text.slice(0, space));
310
+ text = space < 0 ? '' : text.slice(space);
311
+ }
312
+ return h(
313
+ 'div',
314
+ { class: `log-line kind-${kind}` },
315
+ h('span', { class: 'time' }, fmtTime(event.ts)),
316
+ h('span', { class: 'kind' }, kind),
317
+ h('span', { class: 'text' }, lead, text),
318
+ );
319
+ }
320
+
321
+ function appendLogLine(body, event) {
322
+ body.querySelector('.log-empty')?.remove();
323
+ body.append(logLine(event));
324
+ while (body.childElementCount > MAX_LOG_LINES) body.firstElementChild.remove();
325
+ }
326
+
327
+ function logBody(events, extraClass = '') {
328
+ const shown = events.slice(-MAX_LOG_LINES);
329
+ return h(
330
+ 'div',
331
+ { class: `log-body ${extraClass}`, tabindex: '0' },
332
+ shown.length > 0 ? shown.map(logLine) : h('div', { class: 'log-empty' }, 'No events yet.'),
333
+ );
334
+ }
335
+
336
+ /** Marks a run that addressed review feedback or reviewed the pull request, as opposed to one that implemented the issue. */
337
+ const reviewTag = (run) => {
338
+ if (run.kind === 'agent_review') {
339
+ return h('span', { class: 'badge tone-muted review-tag', title: `Reviewing the pull request, round ${run.round}` }, `agent review ${run.round}`);
340
+ }
341
+ return run.kind === 'review' ? h('span', { class: 'badge tone-muted review-tag', title: `Addressing review feedback, round ${run.round}` }, `review ${run.round}`) : null;
342
+ };
343
+
344
+ function runRow(run) {
345
+ let result = null;
346
+ if (run.status === 'succeeded' && run.prUrl) {
347
+ result = h('a', { href: run.prUrl, target: '_blank', rel: 'noopener noreferrer', onclick: (e) => e.stopPropagation() }, prLabel(run.prUrl), ' ↗');
348
+ } else if (run.status === 'failed') {
349
+ result = h('span', { class: 'text-fail' }, run.failureReason ?? 'Failed');
350
+ } else if (run.status === 'aborted') {
351
+ result = h('span', { class: 'muted' }, run.failureReason ?? 'Aborted');
352
+ } else if (run.status === 'blocked') {
353
+ result = h('span', { class: 'text-warn' }, run.failureReason ?? 'Needs input');
354
+ } else if (run.status === 'running') {
355
+ result = h('span', { class: 'muted' }, STEPS.find(([key]) => key === run.step)?.[1] ?? '');
356
+ }
357
+ return h(
358
+ 'button',
359
+ { class: `run-row ${run.status === 'failed' ? 'failed' : ''}`, type: 'button', onclick: () => go(`#history/${run.id}`) },
360
+ h('span', null, outcomeBadge(run.status)),
361
+ h('span', { class: 'issue' }, h('span', { class: 'id' }, run.identifier), reviewTag(run), h('span', { class: 'ellipsis' }, run.title)),
362
+ h('span', { class: 'num ellipsis', title: projectName(run) }, projectName(run)),
363
+ h('span', { class: 'num ellipsis' }, agentName(run.agent)),
364
+ h('span', { class: 'num' }, fmtClock(run.startedAt)),
365
+ h('span', { class: 'num' }, fmtDuration(runDurationMs(run))),
366
+ h('span', { class: 'result ellipsis', title: run.failureReason ?? '' }, result),
367
+ );
368
+ }
369
+
370
+ function compactRow(run) {
371
+ const outcome = OUTCOMES[run.status];
372
+ const parts = [outcome.label, projectName(run), agentName(run.agent), fmtClock(run.startedAt), fmtDuration(runDurationMs(run))];
373
+ if (run.failureReason) parts.push(run.failureReason);
374
+ const selected = state.selectedId === run.id;
375
+ return h(
376
+ 'button',
377
+ {
378
+ class: `compact-row ${run.status === 'failed' ? 'failed' : ''} ${selected ? 'selected' : ''}`,
379
+ type: 'button',
380
+ 'aria-current': selected ? 'true' : null,
381
+ onclick: () => go(`#history/${run.id}`),
382
+ },
383
+ h('span', { class: 'line1' }, outcomeIcon(run.status), h('span', { class: 'id' }, run.identifier), h('span', { class: 'ellipsis' }, run.title)),
384
+ h('span', { class: 'line2 ellipsis' }, parts.join(' · ')),
385
+ );
386
+ }
387
+
388
+ function copyButton(text, label) {
389
+ const control = h(
390
+ 'button',
391
+ {
392
+ class: 'icon-btn tiny',
393
+ type: 'button',
394
+ title: label,
395
+ 'aria-label': label,
396
+ onclick: async () => {
397
+ try {
398
+ await navigator.clipboard.writeText(text);
399
+ control.title = 'Copied';
400
+ } catch {
401
+ control.title = 'Copy failed';
402
+ }
403
+ },
404
+ },
405
+ icon('copy', 12),
406
+ );
407
+ return control;
408
+ }
409
+
410
+ /* ---------- header, banner, tabs ---------- */
411
+
412
+ /** Countdown to a project's next poll, or what stands in for it. */
413
+ function nextPollText(project) {
414
+ if (!project || project.state === 'stopped') return '—';
415
+ if (project.state === 'stopping') return 'none · stopping';
416
+ if (!project.nextPollAt) return 'now';
417
+ return fmtCountdown(new Date(project.nextPollAt) - Date.now());
418
+ }
419
+
420
+ /** Plan usage of each enabled agent that reports it, for the header. */
421
+ function planUsage() {
422
+ return (state.agents?.agents ?? [])
423
+ .filter((agent) => agent.enabled && currentWindows(agent.usage).length > 0)
424
+ .map((agent) => {
425
+ const windows = currentWindows(agent.usage);
426
+ const title = [
427
+ ...windows.map(([key, window]) => `${windowLabel(key, true)}: ${fmtPercent(window.utilization)}, resets ${fmtReset(window.resetsAt)}`),
428
+ agent.paused ? `Paused: ${agent.paused}` : null,
429
+ ]
430
+ .filter(Boolean)
431
+ .join('\n');
432
+ return h(
433
+ 'span',
434
+ { class: `nowrap ${agent.paused ? 'text-fail' : ''}`, title },
435
+ `${agent.name} `,
436
+ windows.map(([key, window], index) => [
437
+ index > 0 ? ' · ' : null,
438
+ `${windowLabel(key)} `,
439
+ h('span', { class: agent.paused ? '' : 'value' }, fmtPercent(window.utilization)),
440
+ ]),
441
+ agent.paused ? ' · paused' : null,
442
+ );
443
+ });
444
+ }
445
+
446
+ function runUsage(run) {
447
+ if (!run.usageDelta) return '—';
448
+ const parts = Object.entries(run.usageDelta.windows)
449
+ .filter(([, change]) => change !== null)
450
+ .map(([key, change]) => `+${fmtPercent(change)} of ${windowLabel(key)}`);
451
+ if (parts.length === 0) return '—';
452
+ return `≈ ${parts.join(' · ')}${run.usageDelta.shared ? ' (shared with overlapping runs)' : ''}`;
453
+ }
454
+
455
+ function renderHeader() {
456
+ const status = state.status;
457
+ if (!status) {
458
+ fill($('#header'), h('span', { class: 'wordmark' }, 'foreman'));
459
+ return;
460
+ }
461
+ const started = status.projects.filter((project) => project.state === 'idle' || project.state === 'working').length;
462
+ const stopped = status.projects.filter((project) => project.state === 'stopped').length;
463
+ const controls = [];
464
+ if (state.actionError) controls.push(h('span', { class: 'action-error', role: 'alert' }, state.actionError));
465
+ if (stopped > 0) {
466
+ controls.push(
467
+ button(status.projects.length > 1 ? 'Start all' : 'Start', {
468
+ variant: started === 0 ? 'primary' : '',
469
+ iconName: 'play',
470
+ onclick: () => runnerAction('/api/runner/start'),
471
+ }),
472
+ );
473
+ }
474
+ if (started > 0) {
475
+ controls.push(
476
+ button(status.projects.length > 1 ? 'Stop all' : 'Stop', {
477
+ iconName: 'square',
478
+ title: 'Stop polling; runs in progress are allowed to finish',
479
+ onclick: () => runnerAction('/api/runner/stop'),
480
+ }),
481
+ );
482
+ }
483
+
484
+ fill(
485
+ $('#header'),
486
+ h('span', { class: 'wordmark' }, 'foreman'),
487
+ h(
488
+ 'div',
489
+ { class: 'header-mid' },
490
+ runnerBadge(status),
491
+ h(
492
+ 'div',
493
+ { class: 'poll-info' },
494
+ h(
495
+ 'span',
496
+ { class: 'nowrap' },
497
+ status.projects.length === 0
498
+ ? 'No projects'
499
+ : [h('span', { class: 'value' }, String(started)), ` of ${status.projects.length} ${status.projects.length === 1 ? 'project' : 'projects'} started`],
500
+ ),
501
+ planUsage(),
502
+ ),
503
+ ),
504
+ h('div', { class: 'controls' }, controls),
505
+ );
506
+ }
507
+
508
+ function renderBanner() {
509
+ const items = [];
510
+ if (state.loadError) {
511
+ items.push(banner('fail', 'Could not load foreman', state.loadError));
512
+ } else if (!state.connected) {
513
+ items.push(banner('warn', 'Disconnected from foreman', 'Live updates are paused. Retrying…'));
514
+ }
515
+ for (const project of state.status?.projects ?? []) {
516
+ if (!project.lastError) continue;
517
+ const split = project.lastError.indexOf(': ');
518
+ const title = split > 0 ? project.lastError.slice(0, split) : 'Error';
519
+ const detail = split > 0 ? project.lastError.slice(split + 2) : project.lastError;
520
+ const polling =
521
+ project.state === 'idle' ? `${/[.!?]$/.test(detail) ? '' : '.'} Foreman keeps polling this project; nothing is picked up from it until this clears.` : '';
522
+ const agentRelated = project.lastError.startsWith('No agent can take work');
523
+ const target = agentRelated ? 'agents' : 'projects';
524
+ items.push(
525
+ banner(
526
+ 'fail',
527
+ `${project.name}: ${title}`,
528
+ detail + polling,
529
+ state.tab !== target ? button(agentRelated ? 'Open agents' : 'Open projects', { onclick: () => go(`#${target}`) }) : null,
530
+ ),
531
+ );
532
+ }
533
+ fill($('#banner'), items);
534
+ }
535
+
536
+ function renderTabs() {
537
+ const total = Object.values(state.counts).reduce((sum, n) => sum + n, 0);
538
+ const historyExtra =
539
+ state.counts.failed > 0
540
+ ? h('span', { class: 'pill' }, `✕ ${state.counts.failed} failed`)
541
+ : h('span', { class: 'count' }, String(total));
542
+ const tab = (id, label, extra) =>
543
+ h(
544
+ 'button',
545
+ { class: 'tab', type: 'button', role: 'tab', 'aria-selected': String(state.tab === id), onclick: () => go(`#${id}`) },
546
+ label,
547
+ extra,
548
+ );
549
+ $('#tabs').setAttribute('role', 'tablist');
550
+ const installed = (state.agents?.agents ?? []).filter((agent) => agent.available).length;
551
+ const agentsExtra = state.agents ? h('span', { class: 'count' }, String(installed)) : null;
552
+ fill(
553
+ $('#tabs'),
554
+ tab('run', 'Run'),
555
+ tab('history', 'History', historyExtra),
556
+ tab('projects', 'Projects', state.projects ? h('span', { class: 'count' }, String(state.projects.projects.length)) : null),
557
+ tab('tasks', 'Tasks'),
558
+ tab('import', 'Import'),
559
+ tab('agents', 'Agents', agentsExtra),
560
+ );
561
+ }
562
+
563
+ /* ---------- run tab ---------- */
564
+
565
+ function runHeadContent(run) {
566
+ const timeoutMinutes = projectOf(run.projectId)?.agentTimeoutMinutes ?? 30;
567
+ const timeoutMs = timeoutMinutes * 60_000;
568
+ ui.elapsed = h('span', { class: 'figure' }, fmtCountdown(runDurationMs(run)));
569
+ ui.elapsedTimeoutMs = timeoutMs;
570
+ ui.elapsedBar = h('i', { style: `width:${Math.min(100, (runDurationMs(run) / timeoutMs) * 100)}%` });
571
+ ui.elapsedRun = run;
572
+ return [
573
+ h(
574
+ 'div',
575
+ { class: 'top' },
576
+ h('div', { class: 'issue' }, externalLink(run.url, run.identifier, 'issue-link'), h('h1', { class: 'title-l' }, run.title)),
577
+ h(
578
+ 'div',
579
+ { class: 'elapsed' },
580
+ ui.elapsed,
581
+ h('span', { class: 'caption' }, `elapsed · timeout ${timeoutMinutes}m`),
582
+ h('span', { class: 'bar thin' }, ui.elapsedBar),
583
+ ),
584
+ ),
585
+ h(
586
+ 'div',
587
+ { class: 'meta' },
588
+ run.branch
589
+ ? h('span', { class: 'item' }, icon('branch', 12), run.branch, copyButton(run.branch, 'Copy branch name'))
590
+ : null,
591
+ h('span', { class: 'item' }, `started ${fmtTime(run.startedAt)}`),
592
+ h('span', { class: 'item' }, `agent ${agentName(run.agent)}`),
593
+ h('span', { class: 'item' }, `project ${projectName(run)}`),
594
+ run.kind === 'review' ? h('span', { class: 'item' }, `review round ${run.round}`) : null,
595
+ run.kind === 'agent_review' ? h('span', { class: 'item' }, `agent review, round ${run.round}`) : null,
596
+ h('span', { class: 'spacer' }),
597
+ button('Abort run', { variant: 'danger small', iconName: 'cross', iconSize: 12, onclick: () => openAbortDialog(run) }),
598
+ ),
599
+ stepper(run),
600
+ ];
601
+ }
602
+
603
+ /** One button per active run, to choose which one the Run tab shows. */
604
+ function stripContent() {
605
+ return state.status.activeRuns.map((run) =>
606
+ h(
607
+ 'button',
608
+ {
609
+ class: 'strip-item',
610
+ type: 'button',
611
+ 'aria-pressed': String(run.id === state.viewRunId),
612
+ onclick: () => {
613
+ if (state.viewRunId === run.id) return;
614
+ state.viewRunId = run.id;
615
+ startLive(run.id);
616
+ renderMain(true);
617
+ },
618
+ },
619
+ icon('spinner', 12),
620
+ h('span', { class: 'mono' }, run.identifier),
621
+ h('span', { class: 'ellipsis' }, run.title),
622
+ h('span', { class: 'faint nowrap' }, `${projectName(run)} · ${agentName(run.agent)} · ${STEPS.find(([key]) => key === run.step)?.[1] ?? ''}`),
623
+ ),
624
+ );
625
+ }
626
+
627
+ function updateLogToolbar() {
628
+ if (!ui.logControls) return;
629
+ const { follow, unseen, events } = state.live;
630
+ ui.logCount.textContent = `${events.length} events`;
631
+ fill(
632
+ ui.logControls,
633
+ follow
634
+ ? button('Auto-scroll on', {
635
+ variant: 'small',
636
+ iconName: 'pause',
637
+ onclick: () => {
638
+ state.live.follow = false;
639
+ updateLogToolbar();
640
+ },
641
+ })
642
+ : [
643
+ h('span', { class: 'badge tone-warn' }, icon('pause', 10), `Paused · ${unseen} new ${unseen === 1 ? 'line' : 'lines'}`),
644
+ button('Jump to latest ↓', {
645
+ variant: 'small primary',
646
+ onclick: () => {
647
+ state.live.follow = true;
648
+ state.live.unseen = 0;
649
+ ui.liveLog.scrollTop = ui.liveLog.scrollHeight;
650
+ updateLogToolbar();
651
+ },
652
+ }),
653
+ ],
654
+ );
655
+ }
656
+
657
+ /** Which agents can take work right now and how many of each one's slots are in use. */
658
+ function agentsRow() {
659
+ const agents = state.agents?.agents ?? [];
660
+ const chip = (agent) => {
661
+ const inUse = (state.status?.activeRuns ?? []).filter((run) => run.agent === agent.id).length;
662
+ let tone = 'muted';
663
+ let mark = icon('square', 10);
664
+ let text;
665
+ let title;
666
+ if (!agent.available) {
667
+ text = 'not installed';
668
+ title = `${agent.name} was not found on this machine`;
669
+ } else if (!agent.enabled) {
670
+ text = 'off';
671
+ title = `${agent.name} is installed but not enabled`;
672
+ } else if (agent.paused) {
673
+ tone = 'fail';
674
+ mark = icon('pause', 10);
675
+ text = `paused · ${inUse}/${agent.maxConcurrent}`;
676
+ title = `Paused: ${agent.paused}`;
677
+ } else if (agent.maxConcurrent === 0) {
678
+ mark = icon('pause', 10);
679
+ text = 'no slots';
680
+ title = `${agent.name} is enabled with a concurrency of 0`;
681
+ } else {
682
+ const free = agent.maxConcurrent - inUse;
683
+ tone = inUse > 0 ? 'run' : 'ok';
684
+ mark = inUse > 0 ? icon('spinner', 11) : icon('tick', 11);
685
+ text = `${inUse}/${agent.maxConcurrent} in use`;
686
+ title = `${agent.name}: ${free} of ${agent.maxConcurrent} ${agent.maxConcurrent === 1 ? 'slot' : 'slots'} free`;
687
+ }
688
+ return h('span', { class: `badge tone-${tone} agent-chip`, title }, mark, h('b', null, agent.name), text);
689
+ };
690
+ return h(
691
+ 'div',
692
+ { class: 'card agents-row', role: 'group', 'aria-label': 'Agents' },
693
+ h('h2', { class: 'heading' }, 'Agents'),
694
+ agents.length > 0 ? agents.map(chip) : h('span', { class: 'faint' }, 'Loading…'),
695
+ h('span', { class: 'spacer' }),
696
+ h('a', { href: '#agents' }, 'Manage →'),
697
+ );
698
+ }
699
+
700
+ /** One row per project: what it is doing and a button to start or stop it. */
701
+ function projectsCard() {
702
+ const projects = state.status.projects;
703
+ ui.countdowns = [];
704
+ const priorityTone = { high: 'run', normal: 'muted', low: 'muted' };
705
+ const row = (project) => {
706
+ const countdown = h('span', { class: 'mono' }, nextPollText(project));
707
+ ui.countdowns.push({ el: countdown, id: project.id });
708
+ const started = project.state === 'idle' || project.state === 'working';
709
+ const runs = project.activeRunIds.length;
710
+ return h(
711
+ 'div',
712
+ { class: 'project-row' },
713
+ h('span', { class: 'name ellipsis', title: project.name }, project.name),
714
+ h('span', { class: `badge tone-${priorityTone[project.priority]}`, title: 'Priority when projects compete for an agent' }, project.priority),
715
+ stateBadge(project.state, runs > 0 ? `${runs} ${runs === 1 ? 'run' : 'runs'}` : null),
716
+ h(
717
+ 'span',
718
+ { class: 'ellipsis muted', title: project.lastError ?? '' },
719
+ project.lastError
720
+ ? h('span', { class: 'text-fail' }, project.lastError)
721
+ : project.state === 'stopped'
722
+ ? 'Not polling'
723
+ : [
724
+ project.waiting ? 'Work waiting for a free agent · ' : null,
725
+ 'next poll ',
726
+ countdown,
727
+ project.inReview + project.awaitingApproval > 0 ? ` · ${project.inReview} in review · ${project.awaitingApproval} pending approval` : null,
728
+ ],
729
+ ),
730
+ project.state === 'stopped'
731
+ ? button('Start', { variant: 'small', iconName: 'play', onclick: () => runnerAction(`/api/projects/${project.id}/start`) })
732
+ : started
733
+ ? button('Stop', { variant: 'small', iconName: 'square', onclick: () => runnerAction(`/api/projects/${project.id}/stop`) })
734
+ : h('span'),
735
+ );
736
+ };
737
+ return h(
738
+ 'div',
739
+ { class: 'card' },
740
+ h('div', { class: 'card-head' }, h('h2', null, 'Projects'), h('span', { class: 'spacer' }), h('a', { href: '#projects' }, 'Manage →')),
741
+ projects.map(row),
742
+ );
743
+ }
744
+
745
+ function workingView(run) {
746
+ ui.runHead = h('div', { class: 'card run-head' }, runHeadContent(run));
747
+ ui.liveLog = logBody(state.live.events);
748
+ ui.liveLog.addEventListener('scroll', () => {
749
+ const distance = ui.liveLog.scrollHeight - ui.liveLog.scrollTop - ui.liveLog.clientHeight;
750
+ const follow = distance <= 40;
751
+ if (follow !== state.live.follow) {
752
+ state.live.follow = follow;
753
+ if (follow) state.live.unseen = 0;
754
+ updateLogToolbar();
755
+ }
756
+ });
757
+ ui.logCount = h('span', { class: 'count' });
758
+ ui.logControls = h('span', { class: 'actions' });
759
+ const others = state.recent.filter((item) => item.status !== 'running').slice(0, 5);
760
+ const several = state.status.activeRuns.length > 1;
761
+ ui.strip = several ? h('div', { class: 'strip', role: 'group', 'aria-label': 'Active runs' }, stripContent()) : null;
762
+
763
+ const view = h(
764
+ 'div',
765
+ { class: 'columns' },
766
+ h(
767
+ 'div',
768
+ { class: 'col-main' },
769
+ ui.strip,
770
+ ui.runHead,
771
+ h(
772
+ 'div',
773
+ { class: 'card log-card' },
774
+ h('div', { class: 'log-toolbar' }, h('h2', { class: 'heading' }, 'Agent log'), ui.logCount, h('span', { class: 'spacer' }), ui.logControls),
775
+ ui.liveLog,
776
+ ),
777
+ ),
778
+ h(
779
+ 'div',
780
+ { class: 'col-aside' },
781
+ projectsCard(),
782
+ others.length > 0
783
+ ? h(
784
+ 'div',
785
+ { class: 'card' },
786
+ h('div', { class: 'card-head' }, h('h2', null, 'Recent runs'), h('span', { class: 'spacer' }), h('a', { href: '#history' }, 'All →')),
787
+ others.map(compactRow),
788
+ )
789
+ : null,
790
+ ),
791
+ );
792
+ queueMicrotask(() => {
793
+ updateLogToolbar();
794
+ if (state.live.follow && ui.liveLog) ui.liveLog.scrollTop = ui.liveLog.scrollHeight;
795
+ });
796
+ return [agentsRow(), view];
797
+ }
798
+
799
+ function recentRunsCard() {
800
+ const runs = state.recent.slice(0, 6);
801
+ if (runs.length === 0) {
802
+ return h(
803
+ 'div',
804
+ { class: 'empty' },
805
+ h('strong', null, 'No runs yet'),
806
+ 'Each issue foreman picks up appears here with its outcome, duration and pull request.',
807
+ );
808
+ }
809
+ return h(
810
+ 'div',
811
+ { class: 'card' },
812
+ h('div', { class: 'card-head' }, h('h2', null, 'Recent runs'), h('span', { class: 'spacer' }), h('a', { href: '#history' }, 'View history →')),
813
+ runs.map(runRow),
814
+ );
815
+ }
816
+
817
+ /** Nothing is running: either there is nothing configured yet, or the projects are idle or stopped. */
818
+ function quietView() {
819
+ if (state.status.projects.length === 0) {
820
+ return [
821
+ agentsRow(),
822
+ h(
823
+ 'div',
824
+ { class: 'narrow' },
825
+ h(
826
+ 'div',
827
+ { class: 'card stopped-card' },
828
+ h('h1', { class: 'title-l' }, 'No projects yet'),
829
+ h(
830
+ 'p',
831
+ null,
832
+ 'A project connects a tracker to a repository. Foreman polls the tracker for waiting issues and hands each one to an agent that works in that repository.',
833
+ ),
834
+ h('div', { class: 'actions' }, button('Add a project', { variant: 'primary tall', onclick: () => go('#projects') })),
835
+ ),
836
+ ),
837
+ ];
838
+ }
839
+ return [agentsRow(), projectsCard(), recentRunsCard()];
840
+ }
841
+
842
+ function runTab() {
843
+ const viewed = viewedRun();
844
+ return viewed ? workingView(viewed) : quietView();
845
+ }
846
+
847
+ /* ---------- history tab ---------- */
848
+
849
+ function setProjectFilter(projectId) {
850
+ state.history.projectId = projectId;
851
+ state.history.runs = [];
852
+ void loadHistory(false).then(() => renderMain(true));
853
+ }
854
+
855
+ function projectFilter() {
856
+ const projects = state.projects?.projects ?? [];
857
+ if (projects.length < 2 && state.history.projectId === null) return null;
858
+ const select = h(
859
+ 'select',
860
+ { class: 'input project-filter', 'aria-label': 'Filter by project' },
861
+ h('option', { value: '', selected: state.history.projectId === null }, 'All projects'),
862
+ projects.map((project) => h('option', { value: String(project.id), selected: state.history.projectId === project.id }, project.name)),
863
+ );
864
+ select.addEventListener('change', () => setProjectFilter(select.value === '' ? null : Number(select.value)));
865
+ return select;
866
+ }
867
+
868
+ function setFilter(filter) {
869
+ state.history.filter = filter;
870
+ state.history.runs = [];
871
+ void loadHistory(false).then(() => renderMain(true));
872
+ }
873
+
874
+ function filterBar() {
875
+ const total = Object.values(state.counts).reduce((sum, n) => sum + n, 0);
876
+ const segment = (id, label, count, mark) =>
877
+ h(
878
+ 'button',
879
+ { class: 'segment', type: 'button', 'aria-pressed': String(state.history.filter === id), onclick: () => setFilter(id) },
880
+ mark,
881
+ label,
882
+ h('span', { class: 'count' }, String(count)),
883
+ );
884
+ return h(
885
+ 'div',
886
+ { class: 'segmented', role: 'group', 'aria-label': 'Filter by outcome' },
887
+ segment('all', 'All', total),
888
+ segment('succeeded', 'Succeeded', state.counts.succeeded, '✓ '),
889
+ segment('failed', 'Failed', state.counts.failed, '✕ '),
890
+ segment('aborted', 'Aborted', state.counts.aborted, '⊘ '),
891
+ segment('blocked', 'Needs input', state.counts.blocked, '? '),
892
+ );
893
+ }
894
+
895
+ function loadMoreFoot() {
896
+ if (state.history.runs.length >= state.history.total) return null;
897
+ return h(
898
+ 'div',
899
+ { class: 'list-foot' },
900
+ button(`Load more (${state.history.total - state.history.runs.length} older)`, {
901
+ onclick: () => void loadHistory(true).then(() => renderMain(true)),
902
+ }),
903
+ );
904
+ }
905
+
906
+ function historyTable() {
907
+ const rows = [];
908
+ let lastDay = null;
909
+ for (const run of state.history.runs) {
910
+ const day = dayLabel(run.startedAt);
911
+ if (day !== lastDay) {
912
+ rows.push(h('div', { class: 'day-row' }, day));
913
+ lastDay = day;
914
+ }
915
+ rows.push(runRow(run));
916
+ }
917
+ return h(
918
+ 'div',
919
+ { class: 'card' },
920
+ h('div', { class: 'toolbar' }, filterBar(), h('span', { class: 'spacer' }), projectFilter()),
921
+ h('div', { class: 'runs-head' }, ...['Outcome', 'Issue', 'Project', 'Agent', 'Started', 'Duration', 'Result'].map((label) => h('span', null, label))),
922
+ rows.length > 0
923
+ ? rows
924
+ : h('div', { class: 'list-empty' }, state.history.filter === 'all' ? 'No runs yet.' : `No ${state.history.filter} runs.`),
925
+ loadMoreFoot(),
926
+ );
927
+ }
928
+
929
+ function detailCard() {
930
+ const close = h('button', { class: 'icon-btn', type: 'button', 'aria-label': 'Close details', onclick: () => go('#history') }, icon('x', 14));
931
+ if (!state.detail) return h('div', { class: 'card detail-head' }, h('span', { class: 'muted' }, 'Loading…'));
932
+ if (state.detail.missing) {
933
+ return h('div', { class: 'card detail-head' }, h('div', { class: 'top' }, h('span', { class: 'muted' }, 'Run not found.'), h('span', { class: 'spacer' }), close));
934
+ }
935
+ const { run, events } = state.detail;
936
+
937
+ let outcomeBox = null;
938
+ if (run.status === 'blocked') {
939
+ outcomeBox = h(
940
+ 'div',
941
+ { class: 'failure-box blocked' },
942
+ h('div', { class: 'title' }, run.failureReason ?? OUTCOMES.blocked.label),
943
+ h('div', { class: 'text' }, 'The agent stopped to ask for this. The question is on the issue, which stays claimed; add the answer to its description and move it back to retry.'),
944
+ );
945
+ } else if (run.status === 'failed' || run.status === 'aborted') {
946
+ const actions = [];
947
+ if (run.worktreePath) {
948
+ actions.push(
949
+ button('Copy cd to worktree', {
950
+ iconName: 'copy',
951
+ iconSize: 12,
952
+ onclick: (event) => {
953
+ const label = event.currentTarget.lastChild;
954
+ void navigator.clipboard.writeText(`cd ${run.worktreePath}`).then(
955
+ () => (label.textContent = 'Copied'),
956
+ () => (label.textContent = 'Copy failed'),
957
+ );
958
+ },
959
+ }),
960
+ );
961
+ }
962
+ outcomeBox = h(
963
+ 'div',
964
+ { class: `failure-box ${run.status === 'aborted' ? 'aborted' : ''}` },
965
+ h('div', { class: 'title' }, run.failureReason ?? OUTCOMES[run.status].label),
966
+ h(
967
+ 'div',
968
+ { class: 'text' },
969
+ 'The issue was left claimed in its tracker, with a comment.',
970
+ run.worktreePath ? ' The worktree is kept for inspection; remove it and its branch before retrying.' : '',
971
+ ),
972
+ actions.length > 0 ? h('div', { class: 'actions' }, actions) : null,
973
+ );
974
+ }
975
+
976
+ const facts = [
977
+ ['Kind', run.kind === 'agent_review' ? `Agent review, round ${run.round}` : run.kind === 'review' ? `Review feedback, round ${run.round}` : 'Implementation'],
978
+ ['Project', projectName(run)],
979
+ ['Agent', agentName(run.agent)],
980
+ ['Branch', run.branch ?? '—'],
981
+ ['Worktree', run.worktreePath ? `${run.worktreePath}${run.status === 'succeeded' ? '' : ' (kept)'}` : '—'],
982
+ ['Started', fmtDateTime(run.startedAt)],
983
+ ['Ended', run.finishedAt ? `${fmtDateTime(run.finishedAt)} · ${fmtDuration(runDurationMs(run))}` : 'still running'],
984
+ ['Pull request', run.prUrl ? externalLink(run.prUrl, prLabel(run.prUrl)) : 'None'],
985
+ ['Turns', run.numTurns ?? '—'],
986
+ ['Plan usage (estimate)', runUsage(run)],
987
+ ['Cost', run.costUsd === null ? '—' : `$${run.costUsd.toFixed(2)}`],
988
+ ];
989
+
990
+ ui.detailLog = logBody(events, 'fixed');
991
+ queueMicrotask(() => {
992
+ if (ui.detailLog) ui.detailLog.scrollTop = ui.detailLog.scrollHeight;
993
+ });
994
+ return h(
995
+ 'div',
996
+ { class: 'card' },
997
+ h(
998
+ 'div',
999
+ { class: 'detail-head' },
1000
+ h('div', { class: 'top' }, outcomeBadge(run.status), externalLink(run.url, `${run.identifier} · open in the tracker`, 'issue-link'), h('span', { class: 'spacer' }), close),
1001
+ h('h2', null, run.title),
1002
+ ),
1003
+ outcomeBox,
1004
+ h('div', { class: 'section' }, stepper(run, true)),
1005
+ h('div', { class: 'section kv-grid' }, facts.map(([key, value]) => h('div', null, h('div', { class: 'k' }, key), h('div', { class: 'v' }, value)))),
1006
+ h(
1007
+ 'div',
1008
+ { class: 'section' },
1009
+ h('div', { class: 'log-toolbar' }, h('h3', { class: 'heading' }, 'Full log'), h('span', { class: 'count' }, `${events.length} events`)),
1010
+ ui.detailLog,
1011
+ ),
1012
+ );
1013
+ }
1014
+
1015
+ function historyTab() {
1016
+ if (state.selectedId === null) return historyTable();
1017
+ return h(
1018
+ 'div',
1019
+ { class: 'split' },
1020
+ h('div', { class: 'card split-list' }, h('div', { class: 'toolbar' }, filterBar()), state.history.runs.map(compactRow), loadMoreFoot()),
1021
+ h('div', { class: 'split-detail' }, detailCard()),
1022
+ );
1023
+ }
1024
+
1025
+ /* ---------- abort dialog ---------- */
1026
+
1027
+ function openAbortDialog(run) {
1028
+ const dialog = $('#dialog');
1029
+ const close = () => dialog.close();
1030
+ dialog.setAttribute('aria-labelledby', 'dialog-title');
1031
+ fill(
1032
+ dialog,
1033
+ h(
1034
+ 'div',
1035
+ { class: 'body' },
1036
+ h('span', { class: 'mark' }, icon('warning', 15)),
1037
+ h(
1038
+ 'div',
1039
+ null,
1040
+ h('h2', { id: 'dialog-title' }, `Abort run for ${run.identifier}?`),
1041
+ h(
1042
+ 'p',
1043
+ null,
1044
+ 'The agent is killed immediately. The issue stays claimed in its tracker, with a comment',
1045
+ run.worktreePath ? [', and the worktree is kept at ', h('span', { class: 'mono' }, run.worktreePath)] : null,
1046
+ '.',
1047
+ ),
1048
+ h('p', null, 'To let it finish instead, stop its project: runs in progress are allowed to complete.'),
1049
+ ),
1050
+ ),
1051
+ h(
1052
+ 'div',
1053
+ { class: 'foot' },
1054
+ button('Cancel', { variant: 'tall', onclick: close, autofocus: true }),
1055
+ button('Abort run', {
1056
+ variant: 'tall danger-solid',
1057
+ onclick: () => {
1058
+ close();
1059
+ void runnerAction(`/api/runs/${run.id}/abort`);
1060
+ },
1061
+ }),
1062
+ ),
1063
+ );
1064
+ dialog.showModal();
1065
+ }
1066
+
1067
+ /* ---------- rendering ---------- */
1068
+
1069
+ function renderMain(force = false) {
1070
+ const main = $('#main');
1071
+ if (!state.status || !state.projects) {
1072
+ fill(main, state.loadError ? null : h('span', { class: 'muted' }, 'Loading…'));
1073
+ return;
1074
+ }
1075
+ const { status } = state;
1076
+ const projectsSignature = status.projects.map((p) => `${p.id}${p.state}${p.waiting}${p.lastError ?? ''}${p.activeRunIds.length}`).join('|');
1077
+ // The run tab is only rebuilt when its shape changes; timers, steps and log lines are patched in place.
1078
+ const key =
1079
+ state.tab === 'run'
1080
+ ? `run:${state.viewRunId ?? 'quiet'}:${status.activeRuns.length}:${projectsSignature}`
1081
+ : state.tab === 'projects' && !isEditingProject()
1082
+ ? // The project list shows live state; the form, once open, is left alone.
1083
+ `projects:${projectsSignature}`
1084
+ : `${state.tab}:${state.selectedId}`;
1085
+ if (!force && key === mainKey) return;
1086
+ mainKey = key;
1087
+ ui = {};
1088
+ const views = { run: runTab, history: historyTab, projects: projectsView, tasks: tasksView, import: importView, agents: agentsView };
1089
+ fill(main, views[state.tab]());
1090
+ }
1091
+
1092
+ function agentsView() {
1093
+ return agentsTab({
1094
+ data: state.agents,
1095
+ onChange: (data) => {
1096
+ state.agents = data;
1097
+ renderTabs();
1098
+ renderMain(true);
1099
+ },
1100
+ rerender: () => renderMain(true),
1101
+ });
1102
+ }
1103
+
1104
+ function importView() {
1105
+ return importsTab({ projects: state.projects.projects, rerender: () => renderMain(true) });
1106
+ }
1107
+
1108
+ function tasksView() {
1109
+ return tasksTab({ projects: state.projects.projects, rerender: () => renderMain(true) });
1110
+ }
1111
+
1112
+ function projectsView() {
1113
+ return projectsTab({
1114
+ data: state.projects,
1115
+ status: state.status,
1116
+ agents: state.agents?.agents ?? [],
1117
+ stateBadge,
1118
+ nextPollText,
1119
+ countdowns: (ui.countdowns = []),
1120
+ onChange: (data) => {
1121
+ state.projects = data;
1122
+ // Creating or deleting a project changes the status list too.
1123
+ void api('GET', '/api/status').then((status) => {
1124
+ if (status.ok) applyStatus(status.data);
1125
+ renderTabs();
1126
+ renderMain(true);
1127
+ });
1128
+ },
1129
+ action: runnerAction,
1130
+ rerender: () => renderMain(true),
1131
+ });
1132
+ }
1133
+
1134
+ function render() {
1135
+ renderHeader();
1136
+ renderBanner();
1137
+ renderTabs();
1138
+ renderMain(true);
1139
+ }
1140
+
1141
+ function tick() {
1142
+ const status = state.status;
1143
+ if (!status) return;
1144
+ for (const { el, id } of ui.countdowns ?? []) {
1145
+ el.textContent = nextPollText(projectStatus(id));
1146
+ }
1147
+ if (ui.elapsed && ui.elapsedRun) {
1148
+ const elapsed = runDurationMs(ui.elapsedRun);
1149
+ const timeoutMs = ui.elapsedTimeoutMs ?? 30 * 60_000;
1150
+ ui.elapsed.textContent = fmtCountdown(elapsed);
1151
+ ui.elapsedBar.style.width = `${Math.min(100, (elapsed / timeoutMs) * 100)}%`;
1152
+ }
1153
+ }
1154
+
1155
+ /* ---------- live updates ---------- */
1156
+
1157
+ function connect() {
1158
+ const source = new EventSource('/api/stream');
1159
+ source.addEventListener('open', () => {
1160
+ if (!state.connected) {
1161
+ state.connected = true;
1162
+ void loadAll();
1163
+ }
1164
+ });
1165
+ source.addEventListener('error', () => {
1166
+ if (state.connected) {
1167
+ state.connected = false;
1168
+ renderBanner();
1169
+ }
1170
+ });
1171
+ source.addEventListener('status', (event) => applyStatus(JSON.parse(event.data)));
1172
+ source.addEventListener('run', (event) => applyRun(JSON.parse(event.data)));
1173
+ source.addEventListener('log', (event) => applyLog(JSON.parse(event.data)));
1174
+ source.addEventListener('agents', (event) => {
1175
+ state.agents = JSON.parse(event.data);
1176
+ renderHeader();
1177
+ renderBanner();
1178
+ // The Agents tab holds unsaved input, so only its usage lines are refreshed.
1179
+ if (state.tab === 'agents') updateAgentsUsage(state.agents);
1180
+ });
1181
+ }
1182
+
1183
+ window.addEventListener('hashchange', () => {
1184
+ const previous = state.tab;
1185
+ readHash();
1186
+ if (previous === 'agents' && state.tab !== 'agents') resetAgentsDraft();
1187
+ if (previous === 'projects' && state.tab !== 'projects') resetProjectForm();
1188
+ if (previous === 'import' && state.tab !== 'import') resetImports();
1189
+ if (previous === 'tasks' && state.tab !== 'tasks') resetTasks();
1190
+ renderBanner();
1191
+ renderTabs();
1192
+ renderMain();
1193
+ });
1194
+
1195
+ readHash();
1196
+ render();
1197
+ await loadAll();
1198
+ if (viewedRun()) startLive(state.viewRunId);
1199
+ connect();
1200
+ setInterval(tick, 1000);