@bill10/agent-007 0.6.2

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 (66) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +222 -0
  3. package/VERSION +1 -0
  4. package/bin/adduser.js +69 -0
  5. package/bin/agent-007.js +88 -0
  6. package/lib/cron.js +189 -0
  7. package/lib/helpers.js +541 -0
  8. package/lib/jobs.js +965 -0
  9. package/package.json +63 -0
  10. package/public/app.js +650 -0
  11. package/public/assets/characters/LICENSE +21 -0
  12. package/public/assets/characters/char_0.png +0 -0
  13. package/public/assets/characters/char_1.png +0 -0
  14. package/public/assets/characters/char_2.png +0 -0
  15. package/public/assets/characters/char_3.png +0 -0
  16. package/public/assets/characters/char_4.png +0 -0
  17. package/public/assets/characters/char_5.png +0 -0
  18. package/public/assets/furniture/bookshelf.png +0 -0
  19. package/public/assets/furniture/cactus.png +0 -0
  20. package/public/assets/furniture/chair_back.png +0 -0
  21. package/public/assets/furniture/chair_front.png +0 -0
  22. package/public/assets/furniture/chair_side.png +0 -0
  23. package/public/assets/furniture/coffee.png +0 -0
  24. package/public/assets/furniture/coffee_table.png +0 -0
  25. package/public/assets/furniture/desk.png +0 -0
  26. package/public/assets/furniture/desk2.png +0 -0
  27. package/public/assets/furniture/plant_2.png +0 -0
  28. package/public/assets/furniture/sofa_front.png +0 -0
  29. package/public/assets/furniture/sofa_side.png +0 -0
  30. package/public/assets/furniture/table_front.png +0 -0
  31. package/public/index.html +245 -0
  32. package/public/modules/auth.js +83 -0
  33. package/public/modules/explorer.js +760 -0
  34. package/public/modules/jobs.js +971 -0
  35. package/public/modules/office.js +2154 -0
  36. package/public/modules/paths.js +20 -0
  37. package/public/modules/shortcuts.js +54 -0
  38. package/public/modules/state.js +75 -0
  39. package/public/modules/terminal.js +651 -0
  40. package/public/modules/voice.js +393 -0
  41. package/public/modules/ws.js +56 -0
  42. package/public/style.css +1843 -0
  43. package/server/agent-mcp-bridge.js +45 -0
  44. package/server/agent-mcp.js +184 -0
  45. package/server/agent-transcripts.js +195 -0
  46. package/server/approvals.js +155 -0
  47. package/server/auth.js +162 -0
  48. package/server/billion.js +176 -0
  49. package/server/claude-trust.js +66 -0
  50. package/server/command-path.js +102 -0
  51. package/server/config.js +184 -0
  52. package/server/direct-run.js +33 -0
  53. package/server/git.js +630 -0
  54. package/server/http.js +276 -0
  55. package/server/jobs.js +2044 -0
  56. package/server/mcp.js +596 -0
  57. package/server/messages.js +319 -0
  58. package/server/permission-hook.js +47 -0
  59. package/server/pty.js +360 -0
  60. package/server/state.js +104 -0
  61. package/server/ws.js +583 -0
  62. package/server.js +306 -0
  63. package/templates/billion/COMPANY.md +14 -0
  64. package/templates/billion/STATE.md +17 -0
  65. package/templates/billion/charter.md +232 -0
  66. package/templates/billion/owner.md +11 -0
@@ -0,0 +1,971 @@
1
+ // Job board — a pinned tab in the terminal panel. Renders three columns
2
+ // (To do / In progress / Review), an archive of finished jobs behind a toolbar
3
+ // toggle, and drives the dispatcher settings.
4
+ //
5
+ // Two kinds of card share those columns. A one-time job crosses them once and
6
+ // stops in Review, its agent kept until the card is done. A schedule stays in
7
+ // To do and posts a one-time run card each time it comes due; the runs are
8
+ // what cross the board.
9
+ //
10
+ // Card *state* comes from the server (durable, persisted). The "needs you"
11
+ // badge is computed here from the live agent map, because it describes a PTY
12
+ // as it is right now — a value the server persisted would be stale the moment
13
+ // it was written.
14
+
15
+ import { agents, jobs, boardSettings, setBoardSettings, boardActive, setBoardActive, activeSessionId, repos } from './state.js';
16
+ import { send } from './ws.js';
17
+ import { escapeHtml, getToken } from './auth.js';
18
+ import { switchToSession } from './terminal.js';
19
+
20
+ // The columns, in board order. `done` deliberately has none: a job whose PR
21
+ // merged is finished work, and a Review column that accumulates it stops
22
+ // meaning "needs your review". Those jobs are kept and reachable through the
23
+ // Finished jobs view below.
24
+ export const COLUMNS = [
25
+ { state: 'todo', label: 'To do' },
26
+ { state: 'in-progress', label: 'In progress' },
27
+ { state: 'review', label: 'Review' },
28
+ ];
29
+
30
+ // The one mode that leaves a dispatched agent unreviewed. Named here rather
31
+ // than compared inline in three places, and marked wherever it is shown so it
32
+ // never reads as just another entry in a list of six. Kept in step with
33
+ // PERMISSION_MODES in lib/jobs.js by a test — public/ cannot import it.
34
+ const DANGEROUS_MODE = 'bypassPermissions';
35
+
36
+ function markDangerousMode(el) {
37
+ if (!el) return;
38
+ // An empty value is "whatever the board decides" (the toolbar's .env entry,
39
+ // a card's Board default): dangerous when that decision is bypass for
40
+ // either CLI — a picked board mode, or with none picked, a .env default.
41
+ const board = boardSettings.permissionModeChosen
42
+ ? [boardSettings.permissionMode]
43
+ : [boardSettings.permissionMode, ...Object.values(boardSettings.envModes || {})];
44
+ el.classList.toggle('job-mode-danger', el.value === DANGEROUS_MODE || (el.value === '' && board.includes(DANGEROUS_MODE)));
45
+ }
46
+
47
+ // Mirrors STALLED_AFTER_MS in lib/jobs.js.
48
+ const STALLED_AFTER_MS = 3 * 60 * 1000;
49
+
50
+ let editingJobId = null;
51
+ // Files waiting in the form: { name } for one the card already has, { name,
52
+ // data } (base64) for a new one, `reading` while the FileReader is still busy.
53
+ let pendingAttachments = [];
54
+ // Which of the two views the board is showing. The columns and the finished
55
+ // list share the same space rather than stacking, so the finished archive can
56
+ // never push the live work off the screen.
57
+ let showingFinished = false;
58
+
59
+ // --- Helpers ---
60
+
61
+ function relativeTime(iso) {
62
+ if (!iso) return '';
63
+ const then = new Date(iso).getTime();
64
+ if (Number.isNaN(then)) return '';
65
+ const secs = Math.max(0, Math.round((Date.now() - then) / 1000));
66
+ if (secs < 60) return `${secs}s ago`;
67
+ const mins = Math.round(secs / 60);
68
+ if (mins < 60) return `${mins}m ago`;
69
+ const hours = Math.round(mins / 60);
70
+ if (hours < 24) return `${hours}h ago`;
71
+ return `${Math.round(hours / 24)}d ago`;
72
+ }
73
+
74
+ // relativeTime only looks backwards. A scheduled card's whole point is the run
75
+ // that has not happened yet, so it needs the other direction too.
76
+ function untilTime(iso) {
77
+ if (!iso) return '';
78
+ const then = new Date(iso).getTime();
79
+ if (Number.isNaN(then)) return '';
80
+ const secs = Math.round((then - Date.now()) / 1000);
81
+ // Due, but the board only acts on a scan — saying "0s" would imply a
82
+ // precision the dispatcher does not have.
83
+ if (secs <= 0) return 'due';
84
+ if (secs < 60) return 'in <1m';
85
+ const mins = Math.round(secs / 60);
86
+ if (mins < 60) return `in ${mins}m`;
87
+ const hours = Math.round(mins / 60);
88
+ if (hours < 24) return `in ${hours}h`;
89
+ return `in ${Math.round(hours / 24)}d`;
90
+ }
91
+
92
+ // The absolute time as well, in the browser's locale, because "in 14h" alone
93
+ // does not tell you whether that lands at a sensible hour.
94
+ function clockTime(iso) {
95
+ const then = new Date(iso).getTime();
96
+ if (Number.isNaN(then)) return '';
97
+ // Past ~6 days the weekday alone no longer says which date is meant (a
98
+ // monthly cron's "next Sat" could be either of two Saturdays), so add it.
99
+ const far = then - Date.now() > 6 * 24 * 60 * 60 * 1000;
100
+ return new Date(then).toLocaleString([], {
101
+ weekday: 'short', ...(far ? { month: 'short', day: 'numeric' } : {}),
102
+ hour: '2-digit', minute: '2-digit',
103
+ });
104
+ }
105
+
106
+ function isScheduled(job) {
107
+ return job.type === 'scheduled';
108
+ }
109
+
110
+ function repoSlug(repoPath) {
111
+ const repo = repos.get(repoPath);
112
+ if (repo) return repo.slug;
113
+ return String(repoPath || '').split('/').filter(Boolean).pop() || repoPath;
114
+ }
115
+
116
+ // Live status for an in-progress card. Deliberately mirrors deriveJobStatus in
117
+ // lib/jobs.js, but reads the client's agent map so the badge reacts the instant
118
+ // a state-change arrives rather than on the next board broadcast.
119
+ function liveStatus(job) {
120
+ if (job.state !== 'in-progress') return null;
121
+ const agent = job.agentSessionId ? agents.get(job.agentSessionId) : null;
122
+ if (!agent || agent.state === 'DISCONNECTED') return 'gone';
123
+ if (agent.state === 'MESSAGE') return 'needs-input';
124
+ if (agent.state === 'WAITING' && (Date.now() - (agent.lastOutputAt || 0)) > STALLED_AFTER_MS) return 'stalled';
125
+ return 'running';
126
+ }
127
+
128
+ const STATUS_TEXT = {
129
+ 'running': 'running',
130
+ 'needs-input': 'needs you',
131
+ 'stalled': 'quiet — may need you',
132
+ 'gone': 'agent gone',
133
+ };
134
+
135
+
136
+ // --- Rendering ---
137
+
138
+ export function renderBoard() {
139
+ const container = document.getElementById('job-columns');
140
+ if (!container || !boardActive) return;
141
+
142
+ const finished = [...jobs.values()].filter(j => j.state === 'done');
143
+ const panel = document.getElementById('job-finished');
144
+ // A page served from cache after an upgrade can have the old markup and the
145
+ // new module — nothing busts the cache on app.js. Falling back to the columns
146
+ // beats hiding them to show an archive whose container does not exist.
147
+ if (!panel) showingFinished = false;
148
+ renderFinishedToggle(finished.length);
149
+ if (panel) panel.style.display = showingFinished ? 'flex' : 'none';
150
+ container.style.display = showingFinished ? 'none' : '';
151
+ if (showingFinished) return renderFinishedList(finished);
152
+
153
+ const byState = new Map(COLUMNS.map(c => [c.state, []]));
154
+ for (const job of jobs.values()) {
155
+ if (byState.has(job.state)) byState.get(job.state).push(job);
156
+ }
157
+ // Newest first within To do would bury the next thing to be dispatched, so
158
+ // To do stays oldest-first to match the dispatcher's own ordering. The other
159
+ // columns show most-recent activity first.
160
+ byState.get('todo').sort((a, b) => String(a.postedAt).localeCompare(String(b.postedAt)));
161
+ byState.get('in-progress').sort((a, b) => String(b.startedAt || '').localeCompare(String(a.startedAt || '')));
162
+ byState.get('review').sort((a, b) => String(b.reviewAt || '').localeCompare(String(a.reviewAt || '')));
163
+
164
+ container.innerHTML = '';
165
+ for (const col of COLUMNS) {
166
+ const list = byState.get(col.state);
167
+ const colEl = document.createElement('div');
168
+ colEl.className = 'job-column';
169
+ colEl.dataset.state = col.state;
170
+
171
+ const header = document.createElement('div');
172
+ header.className = 'job-column-header';
173
+ header.innerHTML = `<span class="job-column-label">${escapeHtml(col.label)}</span><span class="job-column-count">${list.length}</span>`;
174
+ colEl.appendChild(header);
175
+
176
+ const cards = document.createElement('div');
177
+ cards.className = 'job-cards';
178
+ if (list.length === 0) {
179
+ const empty = document.createElement('div');
180
+ empty.className = 'job-column-empty';
181
+ empty.textContent = col.state === 'todo' ? 'No jobs queued' : '—';
182
+ cards.appendChild(empty);
183
+ }
184
+ for (const job of list) cards.appendChild(renderCard(job));
185
+ colEl.appendChild(cards);
186
+ container.appendChild(colEl);
187
+ }
188
+ }
189
+
190
+ // Newest first: the thing that just merged is the one you are most likely to be
191
+ // looking for.
192
+ function renderFinishedList(finished) {
193
+ const cards = document.getElementById('job-finished-cards');
194
+ const count = document.getElementById('job-finished-count');
195
+ if (!cards) return;
196
+ const sorted = [...finished].sort((a, b) =>
197
+ String(b.doneAt || b.prMergedAt || '').localeCompare(String(a.doneAt || a.prMergedAt || '')));
198
+ if (count) count.textContent = String(sorted.length);
199
+ cards.innerHTML = '';
200
+ if (sorted.length === 0) {
201
+ const empty = document.createElement('div');
202
+ empty.className = 'job-column-empty';
203
+ empty.textContent = 'No finished jobs yet — a card lands here once its pull request merges or is closed, or you mark it done.';
204
+ cards.appendChild(empty);
205
+ return;
206
+ }
207
+ for (const job of sorted) cards.appendChild(renderCard(job));
208
+ }
209
+
210
+ // The title and aria-pressed move with the label: a tooltip still describing the
211
+ // view you just left is worse than no tooltip, and the accent border is the only
212
+ // other thing saying which view you are in — which reaches nobody using a screen
213
+ // reader.
214
+ function renderFinishedToggle(count) {
215
+ const btn = document.getElementById('btn-finished-jobs');
216
+ if (!btn) return;
217
+ btn.textContent = showingFinished
218
+ ? '\u2190 Back to board'
219
+ : `View finished jobs${count ? ` (${count})` : ''}`;
220
+ btn.title = showingFinished
221
+ ? 'Back to the To do / In progress / Review columns'
222
+ : 'Jobs whose pull request merged or was closed, or that were marked done. They leave the board but are kept here.';
223
+ btn.setAttribute('aria-pressed', String(showingFinished));
224
+ btn.classList.toggle('showing', showingFinished);
225
+ }
226
+
227
+ function renderCard(job) {
228
+ const card = document.createElement('div');
229
+ const status = liveStatus(job);
230
+ card.className = 'job-card' + (status ? ` status-${status}` : '');
231
+ card.dataset.jobId = job.id;
232
+
233
+ // A card whose agent is still around is a jump target — In progress, and
234
+ // Review, where the agent is kept until the card is done. The badge below
235
+ // stays clickable, but the whole card is a far bigger hit area than a
236
+ // one-line pill, and "click the job to see the agent" is what the columns
237
+ // already imply.
238
+ const liveAgentId = (job.state === 'in-progress' || job.state === 'review') && job.agentSessionId && agents.has(job.agentSessionId)
239
+ ? job.agentSessionId
240
+ : null;
241
+
242
+ const title = document.createElement('div');
243
+ title.className = 'job-card-title';
244
+ title.textContent = job.title;
245
+ if (isScheduled(job)) {
246
+ // A chip rather than a whole second column: a scheduled card is still an
247
+ // ordinary card in the same queue, and splitting the board would hide it
248
+ // from the glance that the three columns exist to give.
249
+ const chip = document.createElement('span');
250
+ chip.className = 'job-card-type';
251
+ chip.textContent = 'scheduled';
252
+ chip.title = 'Posts a run card each time it comes due; the runs are the cards that move';
253
+ title.appendChild(chip);
254
+ }
255
+ if (job.scheduleId) {
256
+ const chip = document.createElement('span');
257
+ chip.className = 'job-card-type';
258
+ chip.textContent = 'run';
259
+ chip.title = 'Posted by its schedule';
260
+ title.appendChild(chip);
261
+ }
262
+ if (job.agent === 'codex') {
263
+ const chip = document.createElement('span');
264
+ chip.className = 'job-card-type';
265
+ chip.textContent = 'codex';
266
+ chip.title = 'Runs on Codex instead of Claude Code';
267
+ title.appendChild(chip);
268
+ }
269
+ // A chip marks the setting that differs from its type's default: no PR on a
270
+ // one-time card, PR runs on a schedule.
271
+ if (isScheduled(job) && job.requiresPr === true) {
272
+ const chip = document.createElement('span');
273
+ chip.className = 'job-card-type';
274
+ chip.textContent = 'PR runs';
275
+ chip.title = 'Each run opens a pull request';
276
+ title.appendChild(chip);
277
+ }
278
+ if (!isScheduled(job) && job.requiresPr === false) {
279
+ const chip = document.createElement('span');
280
+ chip.className = 'job-card-type';
281
+ chip.textContent = 'no PR';
282
+ chip.title = 'Finishes with a summary from its agent instead of a pull request';
283
+ title.appendChild(chip);
284
+ }
285
+ if (job.permissionMode) {
286
+ // Only when the card overrides the board. The mode decides how much its
287
+ // agent may do unasked, so a card carrying its own must say so on its face
288
+ // rather than only inside the edit form.
289
+ const chip = document.createElement('span');
290
+ // bypassPermissions gets the danger colour rather than the routine gold:
291
+ // it is the one mode with nothing reviewing the agent, and a card already
292
+ // on the board is exactly where that has to be readable at a glance.
293
+ chip.className = `job-card-type${job.permissionMode === DANGEROUS_MODE ? ' job-mode-danger' : ''}`;
294
+ chip.textContent = job.permissionMode;
295
+ chip.title = job.agent === 'codex'
296
+ ? `This job runs on Codex in ${job.permissionMode}, mapped onto Codex's sandbox and approval flags, instead of the board's setting`
297
+ : `This job runs with --permission-mode ${job.permissionMode} instead of the board's setting`;
298
+ title.appendChild(chip);
299
+ }
300
+ card.appendChild(title);
301
+
302
+ const meta = document.createElement('div');
303
+ meta.className = 'job-card-meta';
304
+ // "Who posted it" is up to three facts: the person it belongs to, the agent
305
+ // that typed it (when one did — see the board's MCP tool), and when. Any of
306
+ // the first two can be absent, so filter rather than branch.
307
+ const posted = [
308
+ job.postedByName ? escapeHtml(job.postedByName) : null,
309
+ job.postedByAgent ? `<span class="job-card-via">via ${escapeHtml(job.postedByAgent)}</span>` : null,
310
+ relativeTime(job.postedAt),
311
+ // An agent can rewrite a To do card through the board's edit_job tool, and
312
+ // that text becomes the next agent's prompt. The card would otherwise still
313
+ // read as the work of whoever queued it, so the rewrite says so on the card
314
+ // and not only in the toast that has already scrolled away.
315
+ job.editedByAgent
316
+ ? `<span class="job-card-via">edited by ${escapeHtml(job.editedByAgent)} ${relativeTime(job.editedAt)}</span>`
317
+ : null,
318
+ ].filter(Boolean).join(' · ');
319
+ meta.innerHTML = `<span class="job-card-repo">${escapeHtml(repoSlug(job.repoPath))}</span><span class="job-card-posted">${posted}</span>`;
320
+ card.appendChild(meta);
321
+
322
+ if (isScheduled(job)) {
323
+ const sched = document.createElement('div');
324
+ sched.className = 'job-card-schedule';
325
+ const bits = [`<span class="job-card-cron">${escapeHtml(job.schedule || '')}</span>`];
326
+ if (job.paused) {
327
+ // The stored nextRunAt is not shown: it is the due time the pause is
328
+ // holding, and resuming re-arms from that moment instead of running it.
329
+ bits.push('<span class="job-card-next job-card-paused">paused</span>');
330
+ } else if (job.nextRunAt) {
331
+ bits.push(`<span class="job-card-next">next ${escapeHtml(clockTime(job.nextRunAt))} · ${escapeHtml(untilTime(job.nextRunAt))}</span>`);
332
+ } else {
333
+ // nextCronIso returned nothing: a valid expression that matches no date
334
+ // that will ever come round, such as 30 February.
335
+ bits.push('<span class="job-card-next">never fires again</span>');
336
+ }
337
+ // Coerced, not trusted: this lands in innerHTML, and job records come from
338
+ // config.json, which a person can edit by hand.
339
+ const runs = Number(job.runCount) || 0;
340
+ if (runs) bits.push(`<span class="job-card-runs">· ran ${runs}\u00d7${job.lastRunAt ? `, last ${escapeHtml(relativeTime(job.lastRunAt))}` : ''}</span>`);
341
+ // Where its latest run is, so the schedule points at the card to look at.
342
+ const latest = job.lastRunJobId ? jobs.get(job.lastRunJobId) : null;
343
+ const column = latest && (COLUMNS.find(c => c.state === latest.state)?.label || (latest.state === 'done' ? 'Finished' : null));
344
+ sched.innerHTML = bits.join(' ');
345
+ card.appendChild(sched);
346
+ // The way to the card to look at: its agent when it has a live one,
347
+ // otherwise the card itself on the board.
348
+ if (column) {
349
+ const link = document.createElement('button');
350
+ link.className = 'job-card-lastrun';
351
+ link.textContent = `latest run: ${column}`;
352
+ link.title = latest.agentSessionId && agents.has(latest.agentSessionId) ? 'Open its agent' : 'Show the run';
353
+ link.onclick = (e) => {
354
+ e.stopPropagation();
355
+ if (latest.agentSessionId && agents.has(latest.agentSessionId)) return switchToSession(latest.agentSessionId);
356
+ if (latest.state === 'done' && !showingFinished) { showingFinished = true; renderBoard(); }
357
+ document.querySelector(`[data-job-id="${CSS.escape(latest.id)}"]`)?.scrollIntoView({ block: 'nearest' });
358
+ };
359
+ card.appendChild(link);
360
+ }
361
+
362
+ // A firing it held off (see scheduleHold), said out loud: a schedule that
363
+ // quietly stops posting runs is the failure this is here to prevent.
364
+ if (job.lastSkipReason && !job.paused) {
365
+ const skip = document.createElement('div');
366
+ skip.className = 'job-card-held';
367
+ skip.textContent = `held off ${relativeTime(job.lastSkipAt)}: ${job.lastSkipReason}`;
368
+ card.appendChild(skip);
369
+ }
370
+ }
371
+
372
+ if (job.detail) {
373
+ const detail = document.createElement('div');
374
+ detail.className = 'job-card-detail';
375
+ detail.textContent = job.detail;
376
+ card.appendChild(detail);
377
+ }
378
+
379
+ if (Array.isArray(job.attachments) && job.attachments.length) {
380
+ const files = document.createElement('div');
381
+ files.className = 'job-card-attachments';
382
+ for (const a of job.attachments) {
383
+ const link = document.createElement('a');
384
+ link.className = 'job-card-attachment';
385
+ link.href = attachmentUrl(job.id, a.name);
386
+ link.target = '_blank';
387
+ link.rel = 'noopener noreferrer';
388
+ link.textContent = a.name;
389
+ files.appendChild(link);
390
+ }
391
+ card.appendChild(files);
392
+ }
393
+
394
+ // Who worked on it, and where the work is (requirement 3). Rendered whenever
395
+ // EITHER is known: the branch is a fact about the job, not about the agent,
396
+ // and gating it on the name left a finished card showing nothing at all once
397
+ // the name was lost.
398
+ if (job.agentName || job.branchName || job.startedAt) {
399
+ const agentEl = document.createElement('div');
400
+ agentEl.className = 'job-card-agent';
401
+ const parts = [];
402
+ if (job.agentName) parts.push(`<span class="job-card-agent-name">${escapeHtml(job.agentName)}</span>`);
403
+ if (job.branchName) parts.push(`<span class="job-card-branch">${escapeHtml(job.branchName)}</span>`);
404
+ if (job.startedAt) parts.push(`<span class="job-card-since">· started ${relativeTime(job.startedAt)}</span>`);
405
+ agentEl.innerHTML = parts.join(' ');
406
+ card.appendChild(agentEl);
407
+ }
408
+
409
+ if (status) {
410
+ const badge = document.createElement('button');
411
+ badge.className = `job-card-status job-status-${status}`;
412
+ badge.innerHTML = `<span class="job-status-dot"></span>${escapeHtml((STATUS_TEXT[status] || status))}`;
413
+ const canJump = !!liveAgentId;
414
+ badge.title = canJump
415
+ ? `Open ${job.agentName}'s terminal`
416
+ : 'This agent is no longer running';
417
+ badge.onclick = (e) => {
418
+ e.stopPropagation();
419
+ if (canJump) switchToSession(liveAgentId);
420
+ };
421
+ if (!canJump) badge.disabled = true;
422
+ card.appendChild(badge);
423
+ }
424
+
425
+ // What the agent reported through finish_job. On a card that opens no PR
426
+ // this is the result itself, so it is shown in full rather than clipped.
427
+ if (job.resultSummary) {
428
+ const result = document.createElement('div');
429
+ result.className = 'job-card-result';
430
+ result.textContent = job.resultSummary;
431
+ // It scrolls, so it must take focus for keyboard users to reach the rest.
432
+ result.tabIndex = 0;
433
+ result.setAttribute('role', 'region');
434
+ result.setAttribute('aria-label', 'Agent summary');
435
+ result.title = ''; // the card's "open terminal" tooltip does not apply here
436
+ card.appendChild(result);
437
+ }
438
+
439
+ // How many runs of the same schedule this one replaced in Review, and on an
440
+ // archived run, that it was replaced — so the archive does not read as if
441
+ // nobody ever looked at a result that was simply overtaken.
442
+ if (job.supersededRuns || job.supersededBy) {
443
+ const note = document.createElement('div');
444
+ note.className = 'job-card-retired';
445
+ note.textContent = job.supersededBy
446
+ ? 'superseded by a newer run'
447
+ : `filed ${job.supersededRuns} earlier run${job.supersededRuns === 1 ? '' : 's'} to Finished`;
448
+ card.appendChild(note);
449
+ }
450
+
451
+ // A Review card keeps its agent until it is done, so a missing one went with
452
+ // a restart or was closed by hand — say so rather than leaving a bare name.
453
+ if (job.state === 'review' && job.agentName && !agents.has(job.agentSessionId)) {
454
+ const note = document.createElement('div');
455
+ note.className = 'job-card-retired';
456
+ note.textContent = 'agent closed';
457
+ card.appendChild(note);
458
+ }
459
+
460
+ if (job.state === 'done') {
461
+ const fin = document.createElement('div');
462
+ fin.className = 'job-card-finished';
463
+ fin.textContent = job.prMergedAt
464
+ ? `merged ${relativeTime(job.prMergedAt)}`
465
+ : job.prClosedAt
466
+ ? `PR closed without merging ${relativeTime(job.prClosedAt)}`
467
+ : `finished ${relativeTime(job.doneAt)}`;
468
+ card.appendChild(fin);
469
+ }
470
+
471
+ // The URL comes from `gh pr list`, but it still ends up in an href, and a
472
+ // non-http scheme there (javascript:, data:) executes on click. Cheap guard.
473
+ const prHref = /^https?:\/\//i.test(String(job.prUrl || '')) ? job.prUrl : null;
474
+ if (prHref) {
475
+ const pr = document.createElement('a');
476
+ pr.className = 'job-card-pr';
477
+ pr.href = prHref;
478
+ pr.target = '_blank';
479
+ pr.rel = 'noopener noreferrer';
480
+ pr.textContent = job.prNumber ? `PR #${job.prNumber}` : 'View PR';
481
+ card.appendChild(pr);
482
+ }
483
+
484
+ if (job.lastError) {
485
+ const err = document.createElement('div');
486
+ err.className = 'job-card-error';
487
+ err.textContent = job.lastError;
488
+ card.appendChild(err);
489
+ }
490
+
491
+ // Shown alongside lastError, never instead of it: "the agent is gone" and
492
+ // "the board cannot see this repo's pull requests" are both worth knowing,
493
+ // and together they explain why a card is stuck.
494
+ if (job.prCheckError) {
495
+ const err = document.createElement('div');
496
+ err.className = 'job-card-error';
497
+ err.textContent = job.prCheckError;
498
+ card.appendChild(err);
499
+ }
500
+
501
+ card.appendChild(renderCardActions(job));
502
+
503
+ if (liveAgentId) {
504
+ // Pointer affordance only. The card holds buttons and a link, so giving it
505
+ // role="button" would flatten them out of the accessibility tree, and a
506
+ // second tab stop would just duplicate the status badge — which is already
507
+ // a real button that jumps to the same place.
508
+ card.classList.add('job-card-live');
509
+ card.title = `Open ${job.agentName || 'this agent'}'s terminal`;
510
+ card.onclick = (e) => {
511
+ // The buttons and the PR link own their own clicks, and a click that ends
512
+ // a selection inside this card is someone reading it, not asking to leave.
513
+ // So is a click in the agent's summary, which scrolls.
514
+ if (e.target.closest && e.target.closest('button, a, .job-card-result')) return;
515
+ const sel = window.getSelection?.();
516
+ if (sel && !sel.isCollapsed && card.contains(sel.anchorNode)) return;
517
+ switchToSession(liveAgentId);
518
+ };
519
+ }
520
+
521
+ return card;
522
+ }
523
+
524
+ function renderCardActions(job) {
525
+ const actions = document.createElement('div');
526
+ actions.className = 'job-card-actions';
527
+
528
+ const mk = (label, title, onClick, cls = '') => {
529
+ const b = document.createElement('button');
530
+ b.className = `job-card-btn ${cls}`.trim();
531
+ b.textContent = label;
532
+ b.title = title;
533
+ b.onclick = (e) => { e.stopPropagation(); onClick(); };
534
+ return b;
535
+ };
536
+
537
+ // Manual moves in both directions: the automatic transitions are best-effort
538
+ // (gh may be unavailable, an agent may finish without a PR), so the user
539
+ // always keeps the final say over where a card sits.
540
+ if (job.state === 'todo') {
541
+ actions.appendChild(mk('Edit', 'Edit this job', () => openForm(job.id)));
542
+ }
543
+ // Pause holds the schedule's next firing; a run already posted is its own
544
+ // card and is left alone.
545
+ if (isScheduled(job)) {
546
+ actions.appendChild(job.paused
547
+ ? mk('Resume', 'Resume this schedule. The next run is set from now, so a firing missed while paused is not replayed.',
548
+ () => send({ type: 'job-pause', jobId: job.id, paused: false }))
549
+ : mk('Pause', 'Hold this schedule. The card stays in To do and no further runs go out until you resume; a run already under way is left alone.',
550
+ () => send({ type: 'job-pause', jobId: job.id, paused: true })));
551
+ }
552
+ // A schedule never moves (the server refuses it too); its runs do.
553
+ if (job.state === 'in-progress' && !isScheduled(job)) {
554
+ actions.appendChild(mk('→ Review', 'Mark as ready for review (use when the agent finished without calling finish_job). The agent is kept.', () => send({ type: 'job-move', jobId: job.id, state: 'review' })));
555
+ actions.appendChild(mk('← To do', 'Requeue this job and close its agent. Uncommitted or unpushed work is kept as an orphan.', () => {
556
+ if (confirm(`Return "${job.title}" to To do?\n\n${job.agentName || 'The agent'} is closed and its worktree released, then the board dispatches a fresh agent for this job. Any uncommitted or unpushed work is kept as an orphan.`)) {
557
+ send({ type: 'job-move', jobId: job.id, state: 'todo' });
558
+ }
559
+ }));
560
+ }
561
+ if (job.state === 'review' && !isScheduled(job)) {
562
+ actions.appendChild(mk('← In progress', 'Send back to In progress', () => send({ type: 'job-move', jobId: job.id, state: 'in-progress' })));
563
+ // The board files a card away by itself once its PR merges; this is the
564
+ // same move by hand, for a PR the board cannot see or work that landed
565
+ // some other way. It is one-way — done is the end of a card's life on the
566
+ // board — so it asks first, the way Delete does.
567
+ actions.appendChild(mk('✓ Done', 'File this job away as finished. This is final: the card leaves the board for Finished jobs and cannot be brought back.', () => {
568
+ // Names the agent for the same reason Delete does: this move retires it,
569
+ // and a Review agent is one someone may still be talking to.
570
+ const agentNote = job.agentSessionId
571
+ ? `${job.agentName || 'Its agent'} is closed and its worktree released. `
572
+ : '';
573
+ if (confirm(`File "${job.title}" away as finished?\n\n${agentNote}The card leaves the board for Finished jobs and cannot be moved back — follow-up work needs a new job.`)) {
574
+ send({ type: 'job-move', jobId: job.id, state: 'done' });
575
+ }
576
+ }));
577
+ }
578
+ actions.appendChild(mk('Delete', 'Delete this job from the board', () => {
579
+ if (confirm(`Delete "${job.title}"?\n\n${job.agentSessionId ? 'Its agent is closed and the worktree released. Uncommitted or unpushed work is kept as an orphan.' : 'This removes the card.'}`)) {
580
+ send({ type: 'job-delete', jobId: job.id });
581
+ }
582
+ }, 'danger'));
583
+
584
+ return actions;
585
+ }
586
+
587
+ // --- Toolbar ---
588
+
589
+ function renderToolbar() {
590
+ const toggle = document.getElementById('btn-dispatcher-toggle');
591
+ const status = document.getElementById('job-dispatcher-status');
592
+ const cap = document.getElementById('job-max-per-repo');
593
+ const perm = document.getElementById('job-permission-mode');
594
+ if (!toggle) return;
595
+
596
+ const running = boardSettings.running;
597
+ toggle.textContent = running ? 'Stop' : 'Start';
598
+ toggle.classList.toggle('running', running);
599
+ const mins = Math.round((boardSettings.intervalMs || 300000) / 60000);
600
+ status.textContent = running
601
+ ? `scanning every ${mins}m`
602
+ : 'dispatcher stopped';
603
+ status.classList.toggle('running', running);
604
+ if (document.activeElement !== cap) cap.value = boardSettings.maxPerRepo;
605
+ // The mode a card falls back to when it does not name one of its own. With
606
+ // none picked here and a .env default in play, that default is what workers
607
+ // start in, so the dropdown says so (an entry of its own, so that picking
608
+ // any mode, the built-in one included, still counts as picking it).
609
+ if (perm && document.activeElement !== perm) {
610
+ const env = boardSettings.envModes || {};
611
+ const fromEnv = !boardSettings.permissionModeChosen && (env.claude || env.codex);
612
+ let entry = perm.querySelector('option[value=""]');
613
+ if (fromEnv) {
614
+ if (!entry) { entry = document.createElement('option'); entry.value = ''; perm.prepend(entry); }
615
+ const base = boardSettings.permissionMode || 'auto';
616
+ entry.textContent = `.env: claude ${env.claude || base}, codex ${env.codex || base}`;
617
+ } else if (entry) {
618
+ entry.remove();
619
+ }
620
+ perm.value = fromEnv ? '' : (boardSettings.permissionMode || 'auto');
621
+ }
622
+ markDangerousMode(perm);
623
+ }
624
+
625
+ // --- Attachments ---
626
+
627
+ // Mirrors the server's limits in server/jobs.js (public/ cannot import it).
628
+ const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
629
+ const MAX_ATTACHMENTS = 20;
630
+ const MAX_ATTACHMENT_TOTAL_BYTES = 50 * 1024 * 1024;
631
+ // Pasted screenshots carry a MIME type, not a name.
632
+ const IMAGE_EXT = { 'image/jpeg': 'jpg', 'image/svg+xml': 'svg' };
633
+
634
+ // A plain link cannot send a bearer header, so the token rides in the query,
635
+ // which the server accepts for exactly this case.
636
+ function attachmentUrl(jobId, name) {
637
+ const token = getToken();
638
+ return `/api/jobs/${encodeURIComponent(jobId)}/attachments/${encodeURIComponent(name)}${token ? `?token=${encodeURIComponent(token)}` : ''}`;
639
+ }
640
+
641
+ function renderAttachments() {
642
+ const box = document.getElementById('job-attachments');
643
+ if (!box) return;
644
+ box.innerHTML = '';
645
+ for (const a of pendingAttachments) {
646
+ const chip = document.createElement('span');
647
+ chip.className = 'job-attachment';
648
+ chip.textContent = a.name;
649
+ const rm = document.createElement('button');
650
+ rm.type = 'button';
651
+ rm.className = 'job-attachment-remove';
652
+ rm.textContent = '\u00d7';
653
+ rm.title = `Remove ${a.name}`;
654
+ rm.onclick = () => { pendingAttachments = pendingAttachments.filter(x => x !== a); renderAttachments(); };
655
+ chip.appendChild(rm);
656
+ box.appendChild(chip);
657
+ }
658
+ }
659
+
660
+ function addAttachments(files, fallbackName) {
661
+ for (const file of files) {
662
+ if (file.size > MAX_ATTACHMENT_BYTES) { showFormError(`${file.name} is too large (max 10MB)`); continue; }
663
+ // A pasted screenshot arrives as "image.png" every time; a timestamp keeps
664
+ // the second one from replacing the first.
665
+ const name = fallbackName && /^image\.\w+$/i.test(file.name || 'image.png')
666
+ ? `${fallbackName}-${Date.now()}.${IMAGE_EXT[file.type] || file.type.split('/')[1] || 'png'}`
667
+ : file.name;
668
+ const others = pendingAttachments.filter(a => a.name !== name);
669
+ // The same limits the server enforces, caught here while the file picker
670
+ // is still open rather than as a toast after the form has closed.
671
+ if (others.length >= MAX_ATTACHMENTS) { showFormError(`At most ${MAX_ATTACHMENTS} files per job`); continue; }
672
+ if (others.reduce((n, a) => n + (a.size || 0), 0) + file.size > MAX_ATTACHMENT_TOTAL_BYTES) { showFormError('Attachments add up to more than 50MB'); continue; }
673
+ const entry = { name, size: file.size, reading: true };
674
+ pendingAttachments = [...others, entry];
675
+ const reader = new FileReader();
676
+ reader.onload = () => { entry.data = String(reader.result).split(',')[1]; delete entry.reading; };
677
+ // A file that vanished or cannot be read must not leave a chip that
678
+ // blocks every save with "still reading".
679
+ reader.onerror = reader.onabort = () => {
680
+ pendingAttachments = pendingAttachments.filter(a => a !== entry);
681
+ showFormError(`Could not read ${name}`);
682
+ renderAttachments();
683
+ };
684
+ reader.readAsDataURL(file);
685
+ }
686
+ renderAttachments();
687
+ }
688
+
689
+ // --- Form ---
690
+
691
+ // The cron box only means anything for a scheduled job, so it is hidden rather
692
+ // than left sitting there inert next to a one-time card.
693
+ // The pull-request choice applies to both: on a schedule it is what its runs
694
+ // get. Its default follows the type (see jobRequiresPr in lib/jobs.js), so a
695
+ // new card switched to scheduled flips to Not required unless picked by hand.
696
+ function syncScheduleField() {
697
+ const scheduled = document.getElementById('job-type').value === 'scheduled';
698
+ document.getElementById('job-schedule-field').style.display = scheduled ? 'flex' : 'none';
699
+ }
700
+
701
+ let prPicked = false; // the user touched the PR select on this form
702
+ function syncPrDefault() {
703
+ if (prPicked) return;
704
+ document.getElementById('job-requires-pr').value = document.getElementById('job-type').value === 'scheduled' ? 'no' : 'yes';
705
+ }
706
+
707
+ // A Codex card is offered "board default", auto and bypassPermissions, so the
708
+ // Claude-only modes are hidden for it — except one the card already holds.
709
+ // The server maps every mode onto a Codex flag, so a stored plan or manual is
710
+ // a real, stricter setting; resetting it to board default on open would let
711
+ // an unrelated edit (a typo fixed in the title) quietly widen the card.
712
+ function syncAgentField() {
713
+ const codex = document.getElementById('job-agent')?.value === 'codex';
714
+ const permEl = document.getElementById('job-permission-mode-field');
715
+ if (!permEl) return;
716
+ // Both flags: Safari does not hide a hidden <option> in a native select,
717
+ // so it is disabled as well and refuses the pick there instead.
718
+ for (const opt of permEl.querySelectorAll('option[data-claude-only]')) {
719
+ const off = codex && !opt.selected;
720
+ opt.hidden = off;
721
+ opt.disabled = off;
722
+ }
723
+ // The two modes both CLIs share mean different things under each, and the
724
+ // visible gloss has to say which — a title tooltip never reaches keyboard
725
+ // or touch users.
726
+ for (const opt of permEl.querySelectorAll('option[data-codex-label]')) {
727
+ if (!opt.dataset.claudeLabel) opt.dataset.claudeLabel = opt.textContent;
728
+ opt.textContent = codex ? opt.dataset.codexLabel : opt.dataset.claudeLabel;
729
+ }
730
+ markDangerousMode(permEl);
731
+ }
732
+
733
+ // A shape check only — five whitespace-separated fields, or a known @shorthand.
734
+ // The real parser is lib/cron.js on the server, and it stays the authority; this
735
+ // exists so the commonest typo (too few fields) is caught while the form is
736
+ // still open and the user's text is still in it, rather than coming back as a
737
+ // toast after the form has closed. Keep the @-list in sync with CRON_MACROS in
738
+ // lib/cron.js — public/ cannot import server modules, so this is a copy.
739
+ const CRON_MACROS = ['@yearly', '@annually', '@monthly', '@weekly', '@daily', '@midnight', '@hourly'];
740
+
741
+ function looksLikeCron(text) {
742
+ const raw = text.trim();
743
+ if (raw.startsWith('@')) return CRON_MACROS.includes(raw.toLowerCase());
744
+ return raw.split(/\s+/).length === 5;
745
+ }
746
+
747
+ function openForm(jobId) {
748
+ // The agent spawn form is an overlay anchored below the 36px header strip,
749
+ // and it sizes to its own content rather than covering the viewport, so the
750
+ // board's toolbar and cards stay clickable underneath it. Every door into
751
+ // this form goes through here, so this is the one place that has to dismiss
752
+ // it — otherwise the form opens behind the overlay and focuses a title input
753
+ // the user cannot see, swallowing whatever they type next.
754
+ const spawn = document.getElementById('spawn-form');
755
+ if (spawn) spawn.style.display = 'none';
756
+ editingJobId = jobId || null;
757
+ // A new job lands in To do, which the archive is covering. Posting one from
758
+ // there would look like nothing happened, so the form takes you back to the
759
+ // columns first — the same move showJobBoard makes. Repainted immediately
760
+ // rather than waiting for the server's next broadcast, so the form never
761
+ // opens over a view its result will not appear in.
762
+ if (showingFinished) {
763
+ showingFinished = false;
764
+ renderBoard();
765
+ }
766
+ const form = document.getElementById('job-form');
767
+ const titleEl = document.getElementById('job-title');
768
+ const detailEl = document.getElementById('job-detail');
769
+ const repoEl = document.getElementById('job-repo');
770
+ const typeEl = document.getElementById('job-type');
771
+ const scheduleEl = document.getElementById('job-schedule');
772
+ const permEl = document.getElementById('job-permission-mode-field');
773
+ const agentEl = document.getElementById('job-agent');
774
+ const prEl = document.getElementById('job-requires-pr');
775
+ const saveBtn = document.getElementById('btn-job-save');
776
+
777
+ repoEl.innerHTML = '';
778
+ for (const [path, repo] of repos) {
779
+ const opt = document.createElement('option');
780
+ opt.value = path;
781
+ opt.textContent = repo.slug;
782
+ repoEl.appendChild(opt);
783
+ }
784
+ if (repos.size === 0) {
785
+ const opt = document.createElement('option');
786
+ opt.value = '';
787
+ opt.textContent = 'No repositories — add one in the explorer first';
788
+ repoEl.appendChild(opt);
789
+ }
790
+
791
+ const job = jobId ? jobs.get(jobId) : null;
792
+ titleEl.value = job ? job.title : '';
793
+ detailEl.value = job ? job.detail : '';
794
+ if (job) repoEl.value = job.repoPath;
795
+ typeEl.value = job && isScheduled(job) ? 'scheduled' : 'one-time';
796
+ scheduleEl.value = job && job.schedule ? job.schedule : '';
797
+ // '' is the "Board default" option, and it is what a card with no mode of
798
+ // its own goes back to — never pre-filled with the board's current value,
799
+ // which would silently freeze the card onto today's setting.
800
+ if (permEl) permEl.value = job && job.permissionMode ? job.permissionMode : '';
801
+ if (agentEl) agentEl.value = job && job.agent === 'codex' ? 'codex' : 'claude';
802
+ prPicked = false;
803
+ prEl.value = job ? (job.requiresPr === false || (isScheduled(job) && job.requiresPr !== true) ? 'no' : 'yes') : 'yes';
804
+ syncAgentField(); // also marks the danger colour on the permission select
805
+ pendingAttachments = job && Array.isArray(job.attachments) ? job.attachments.map(a => ({ name: a.name })) : [];
806
+ renderAttachments();
807
+ syncScheduleField();
808
+ saveBtn.textContent = job ? 'Save' : 'Post job';
809
+ clearFormError();
810
+ form.style.display = 'flex';
811
+ titleEl.focus();
812
+ }
813
+
814
+ function closeForm() {
815
+ editingJobId = null;
816
+ document.getElementById('job-form').style.display = 'none';
817
+ clearFormError();
818
+ }
819
+
820
+ function showFormError(text) {
821
+ const el = document.getElementById('job-form-error');
822
+ el.textContent = text;
823
+ el.style.display = 'block';
824
+ }
825
+ function clearFormError() {
826
+ const el = document.getElementById('job-form-error');
827
+ el.textContent = '';
828
+ el.style.display = 'none';
829
+ }
830
+
831
+ function saveForm() {
832
+ const title = document.getElementById('job-title').value.trim();
833
+ const detail = document.getElementById('job-detail').value;
834
+ const repoPath = document.getElementById('job-repo').value;
835
+ const jobType = document.getElementById('job-type').value;
836
+ const schedule = document.getElementById('job-schedule').value.trim();
837
+ const permissionMode = document.getElementById('job-permission-mode-field')?.value || '';
838
+ const agent = document.getElementById('job-agent')?.value || 'claude';
839
+ const requiresPr = document.getElementById('job-requires-pr').value !== 'no';
840
+ if (!title) return showFormError('Give the job a title.');
841
+ if (!repoPath) return showFormError('Add a repository in the explorer first — a job needs one to run in.');
842
+ if (jobType === 'scheduled' && !schedule) return showFormError('A scheduled job needs a cron schedule, for example "0 9 * * 1-5".');
843
+ if (jobType === 'scheduled' && !looksLikeCron(schedule)) {
844
+ return showFormError('That does not look like a cron schedule — five fields (minute hour day month weekday), or @daily / @hourly / @weekly.');
845
+ }
846
+ if (pendingAttachments.some(a => a.reading)) return showFormError('Still reading an attached file — try again in a moment.');
847
+ // The form always holds the complete list; an empty one on an edit means
848
+ // "none left".
849
+ const attachments = pendingAttachments.map(a => ({ name: a.name, data: a.data }));
850
+ const fields = { title, detail, repoPath, jobType, schedule, permissionMode, agent, requiresPr, attachments };
851
+ if (editingJobId) send({ type: 'job-update', jobId: editingJobId, ...fields });
852
+ else send({ type: 'job-create', ...fields });
853
+ closeForm();
854
+ }
855
+
856
+ // Opening the form from outside the board — the top bar's "+ Job" button. The
857
+ // board may not be the visible tab, so bring it up first; otherwise the form
858
+ // opens where nobody can see it. The tab strip repaints itself: showJobBoard()
859
+ // fires the board-visibility hook, which app.js wires to updateTabs(), and
860
+ // openForm() clears the spawn overlay. app.js closes this form on the way in
861
+ // the other direction.
862
+ export function openJobForm() {
863
+ showJobBoard();
864
+ openForm(null);
865
+ }
866
+
867
+ export function closeJobForm() {
868
+ closeForm();
869
+ }
870
+
871
+ // --- Show / hide ---
872
+
873
+ export function showJobBoard() {
874
+ // Always open on the columns. Opening the board is the user asking for the
875
+ // live work; the archive is somewhere you go on purpose, not somewhere the
876
+ // Jobs tab can strand you.
877
+ showingFinished = false;
878
+ const agent = activeSessionId ? agents.get(activeSessionId) : null;
879
+ if (agent) agent.termEl.style.display = 'none';
880
+ document.getElementById('terminal-empty').style.display = 'none';
881
+ document.getElementById('job-board').style.display = 'flex';
882
+ setBoardActive(true);
883
+ renderToolbar();
884
+ renderBoard();
885
+ if (window._onBoardVisibilityChanged) window._onBoardVisibilityChanged();
886
+ }
887
+
888
+ export function hideJobBoard() {
889
+ document.getElementById('job-board').style.display = 'none';
890
+ setBoardActive(false);
891
+ }
892
+
893
+ // --- Messages ---
894
+
895
+ export function handleJobsList(msg) {
896
+ jobs.clear();
897
+ for (const job of msg.jobs || []) jobs.set(job.id, job);
898
+ if (msg.settings) setBoardSettings(msg.settings);
899
+ renderToolbar();
900
+ renderBoard();
901
+ if (window._onBoardVisibilityChanged) window._onBoardVisibilityChanged();
902
+ }
903
+
904
+ // --- Setup ---
905
+
906
+ export function setupJobBoard() {
907
+ document.getElementById('btn-new-job').onclick = () => openForm(null);
908
+ document.getElementById('btn-new-job-shortcut').onclick = openJobForm;
909
+ document.getElementById('btn-job-cancel').onclick = closeForm;
910
+ document.getElementById('btn-job-save').onclick = saveForm;
911
+ document.getElementById('job-title').addEventListener('keydown', (e) => {
912
+ if (e.key === 'Enter') { e.preventDefault(); saveForm(); }
913
+ if (e.key === 'Escape') closeForm();
914
+ });
915
+ document.getElementById('job-type').onchange = () => { syncScheduleField(); syncPrDefault(); };
916
+ const prSelect = document.getElementById('job-requires-pr');
917
+ if (prSelect) prSelect.onchange = () => { prPicked = true; };
918
+ document.getElementById('job-schedule').addEventListener('keydown', (e) => {
919
+ if (e.key === 'Enter') { e.preventDefault(); saveForm(); }
920
+ if (e.key === 'Escape') closeForm();
921
+ });
922
+ document.getElementById('job-detail').addEventListener('keydown', (e) => {
923
+ // Cmd/Ctrl+Enter submits; a bare Enter has to stay available for newlines.
924
+ if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); saveForm(); }
925
+ if (e.key === 'Escape') closeForm();
926
+ });
927
+ document.getElementById('job-detail').addEventListener('paste', (e) => {
928
+ const files = [...(e.clipboardData?.files || [])];
929
+ if (!files.length) return;
930
+ e.preventDefault();
931
+ addAttachments(files, 'screenshot');
932
+ });
933
+ const attachBtn = document.getElementById('btn-job-attach');
934
+ const attachInput = document.getElementById('job-attach-input');
935
+ if (attachBtn && attachInput) {
936
+ attachBtn.onclick = () => attachInput.click();
937
+ attachInput.onchange = () => { addAttachments(attachInput.files); attachInput.value = ''; };
938
+ }
939
+
940
+ document.getElementById('btn-dispatcher-toggle').onclick = () => {
941
+ send({ type: 'job-settings', running: !boardSettings.running });
942
+ };
943
+ document.getElementById('btn-dispatch-now').onclick = () => {
944
+ send({ type: 'job-dispatch-now' });
945
+ };
946
+ const finishedBtn = document.getElementById('btn-finished-jobs');
947
+ if (finishedBtn) finishedBtn.onclick = () => { showingFinished = !showingFinished; renderBoard(); };
948
+ document.getElementById('job-max-per-repo').onchange = (e) => {
949
+ const value = parseInt(e.target.value, 10);
950
+ if (Number.isFinite(value)) send({ type: 'job-settings', maxPerRepo: value });
951
+ };
952
+ const permEl = document.getElementById('job-permission-mode');
953
+ // Sending this is what records the mode as CHOSEN on the server, which is
954
+ // how a stored value is told apart from one a default wrote (see
955
+ // boardSettings in server/jobs.js) — so it must only fire on a real change.
956
+ if (permEl) permEl.onchange = (e) => {
957
+ markDangerousMode(e.target);
958
+ send({ type: 'job-settings', permissionMode: e.target.value });
959
+ };
960
+ // The form's own select is marked as it changes too, not only when the form
961
+ // opens — the warning has to be on screen while the choice is being made.
962
+ const formPermEl = document.getElementById('job-permission-mode-field');
963
+ if (formPermEl) formPermEl.onchange = () => markDangerousMode(formPermEl);
964
+ const agentEl = document.getElementById('job-agent');
965
+ if (agentEl) agentEl.onchange = syncAgentField;
966
+
967
+ // Relative timestamps and the "quiet" threshold both drift with the clock, so
968
+ // the board re-renders on a slow tick while it is visible. Cheap: it only
969
+ // touches the DOM when the board is the active tab.
970
+ setInterval(() => { if (boardActive) renderBoard(); }, 30_000);
971
+ }