@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
package/server/jobs.js ADDED
@@ -0,0 +1,2044 @@
1
+ // Job board — persistence, dispatcher loop, PR watching.
2
+ //
3
+ // The pure logic (schema, dispatch selection, PR parsing, status derivation)
4
+ // lives in lib/jobs.js. This module owns the timers, the git/gh calls, and the
5
+ // job list inside config.json.
6
+ //
7
+ // Dependency direction matches the rest of the server: `broadcast` and
8
+ // `createSession` are passed in rather than imported, so this module never
9
+ // forms a cycle with ws.js or server.js.
10
+
11
+ import { execFile } from 'child_process';
12
+ import { copyFileSync, existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'fs';
13
+ import { basename, dirname, join, resolve, sep } from 'path';
14
+ import { config, sessions, orphans, adoptingOrphans, codenamePool, CONFIG_DIR } from './state.js';
15
+ import { saveConfig, syncOrphansToConfig } from './config.js';
16
+ import { gitExec, removeWorktree } from './git.js';
17
+ import { transcriptsFor, codexSessionIdFor } from './agent-transcripts.js';
18
+ import { safeFilename, expandHome } from '../lib/helpers.js';
19
+ import { sendNotice } from './messages.js';
20
+ import { liveBillion } from './billion.js';
21
+ import {
22
+ createJob, selectDispatchableJobs, buildJobCommand, deriveJobStatus,
23
+ parsePrList, parseMergedPr, openPrListArgs, mergedPrListArgs, closedPrViewArgs, parseClosedPr,
24
+ branchSlugFromTitle, isValidPermissionMode, resolveJobPermissionMode, dispatchPermissionMode,
25
+ JOB_STATES,
26
+ DISPATCH_INTERVAL_MS, MAX_AGENTS_PER_REPO, DEFAULT_PERMISSION_MODE,
27
+ MAX_TITLE_LEN, MAX_DETAIL_LEN, isScheduled, jobType, resolveJobType, jobRequiresPr,
28
+ scheduleHold, supersededRuns, createRunJob, runsToPrune, defaultRequiresPr, isJobDue, STATE_LABELS,
29
+ jobAgent, jobAgentFromCommand, resolveJobAgent, resumeCommand, isValidJobAgent, recordedPermissionFlags,
30
+ BILLION_NAME, envPermissionMode,
31
+ } from '../lib/jobs.js';
32
+ import { nextCronIso } from '../lib/cron.js';
33
+
34
+ // --- Board settings ---
35
+
36
+ function defaultSettings() {
37
+ return {
38
+ // Off until the user presses Start. An unattended process that types into
39
+ // agents and creates worktrees should not begin on its own the first time
40
+ // the app is opened.
41
+ running: false,
42
+ maxPerRepo: MAX_AGENTS_PER_REPO,
43
+ intervalMs: DISPATCH_INTERVAL_MS,
44
+ permissionMode: DEFAULT_PERMISSION_MODE,
45
+ // Whether a human ever picked that mode in the toolbar. Written only by
46
+ // updateSettings; see boardSettings() for why the flag has to exist.
47
+ permissionModeChosen: false,
48
+ // Whether the legacy-value migration in boardSettings() has already run.
49
+ // Separate from `permissionModeChosen` on purpose: that one means a person
50
+ // decided, and setting it here would be a lie that also stops any future
51
+ // default from ever reaching this board.
52
+ permissionModeMigrated: false,
53
+ };
54
+ }
55
+
56
+ // The board's mode for a card on this CLI, before the card's own mode: one
57
+ // picked in the board's dropdown wins; with none picked, the CLI's .env
58
+ // default (CLAUDE_PERMISSION_MODE / CODEX_PERMISSION_MODE); then the board's
59
+ // built-in default.
60
+ export function boardModeFor(agent) {
61
+ const settings = boardSettings();
62
+ if (settings.permissionModeChosen) return settings.permissionMode;
63
+ return envPermissionMode(agent) || settings.permissionMode;
64
+ }
65
+
66
+ export function boardSettings() {
67
+ if (!config.jobBoard || typeof config.jobBoard !== 'object') config.jobBoard = defaultSettings();
68
+ else config.jobBoard = { ...defaultSettings(), ...config.jobBoard };
69
+ // Migrate the legacy value ONCE. Until the toolbar got a permission-mode
70
+ // control there was no way to choose one, so every mode sitting in
71
+ // ~/.agent-007/config.json today was written by the first dispatcher start
72
+ // out of whatever DEFAULT_PERMISSION_MODE was then. The stored object is
73
+ // spread OVER the defaults, so without this a changed default reaches
74
+ // nobody. Unchosen therefore reads as unset, and the current default wins.
75
+ //
76
+ // The `permissionModeMigrated` guard is what makes "once" true. Keying the
77
+ // reset on `permissionModeChosen` alone re-ran it on EVERY call, so a mode
78
+ // hand-edited into config.json was silently overwritten on the next read,
79
+ // for ever — and hand-editing that file is what every doc said to do before
80
+ // this release, and is still the only route on a box with no browser. The
81
+ // symptom was the worst kind: the file says one thing and the board
82
+ // dispatches another, with nothing logged.
83
+ if (!config.jobBoard.permissionModeMigrated) {
84
+ if (!config.jobBoard.permissionModeChosen) config.jobBoard.permissionMode = DEFAULT_PERMISSION_MODE;
85
+ config.jobBoard.permissionModeMigrated = true;
86
+ // Written through immediately, and this is the only place boardSettings()
87
+ // is allowed to write. A marker that lives only in memory is not a marker:
88
+ // a board with no queued cards never reaches the dispatcher's persist, so
89
+ // the flag would die with the process and the migration would run again on
90
+ // the next boot -- reverting exactly the hand-edit it is meant to respect.
91
+ // saveConfig only serialises `config`, so there is no recursion back here,
92
+ // and the branch cannot be taken twice.
93
+ saveConfig();
94
+ }
95
+ // Always, whenever it arrived: an unknown mode would show up as a blank
96
+ // select, and buildJobCommand would fall back to the default anyway.
97
+ if (!isValidPermissionMode(config.jobBoard.permissionMode)) {
98
+ config.jobBoard.permissionMode = DEFAULT_PERMISSION_MODE;
99
+ }
100
+ return config.jobBoard;
101
+ }
102
+
103
+ export function allJobs() {
104
+ if (!Array.isArray(config.jobs)) config.jobs = [];
105
+ return config.jobs;
106
+ }
107
+
108
+ // --- Broadcast ---
109
+
110
+ // The wire shape adds `status` (running / needs-input / stalled / gone) and the
111
+ // live agent state, both derived fresh on every send. They are never persisted:
112
+ // they describe a PTY that exists right now, so a stored copy would go stale
113
+ // the moment the server restarts.
114
+ export function jobsPayload() {
115
+ const jobs = allJobs().map((job) => {
116
+ const session = job.agentSessionId ? sessions.get(job.agentSessionId) : null;
117
+ return {
118
+ ...job,
119
+ // Resolved rather than raw: cards written before scheduled jobs existed
120
+ // carry no type at all, and the client should not have to know that.
121
+ type: jobType(job),
122
+ status: deriveJobStatus(job, session),
123
+ agentState: session ? session.state : null,
124
+ agentAlive: !!(session && !session.exited),
125
+ };
126
+ });
127
+ // The .env defaults ride along, so the toolbar can show the mode workers
128
+ // really start in while no mode has been picked there.
129
+ const envModes = { claude: envPermissionMode('claude'), codex: envPermissionMode('codex') };
130
+ return { type: 'jobs-list', jobs, settings: { ...boardSettings(), envModes } };
131
+ }
132
+
133
+ export function broadcastJobs(broadcast) {
134
+ if (broadcast) broadcast(jobsPayload());
135
+ }
136
+
137
+ function persist(broadcast) {
138
+ saveConfig(broadcast);
139
+ broadcastJobs(broadcast);
140
+ }
141
+
142
+ // --- PR-check notes ---
143
+
144
+ // Why the board cannot see this job's pull request, written onto the card.
145
+ // Shared by both sweeps: they ask GitHub different questions but report the
146
+ // answer the same way, under the same rule — write only when the text changes,
147
+ // so a permanent failure does not rewrite config.json every scan. Each returns
148
+ // whether it actually changed anything, so a caller can decide to persist.
149
+ function notePrCheckError(job, message) {
150
+ if (job.prCheckError === message) return false;
151
+ job.prCheckError = message;
152
+ job.prCheckErrorAt = new Date().toISOString();
153
+ return true;
154
+ }
155
+
156
+ function clearPrCheckError(job) {
157
+ if (!job.prCheckError) return false;
158
+ job.prCheckError = null;
159
+ job.prCheckErrorAt = null;
160
+ return true;
161
+ }
162
+
163
+ // --- Attachments ---
164
+
165
+ // Files posted with a card live under the config dir, never in the repo, so
166
+ // the agent reads them by absolute path and nothing can end up committed. They
167
+ // arrive inline on the job message as base64, the same shape as the terminal's
168
+ // upload-file, so the id is known before anything touches the disk.
169
+ const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
170
+ const MAX_ATTACHMENTS = 20;
171
+ // Per save, not per card: one message is one WebSocket frame, and ws drops
172
+ // the socket (not the message) past its 100MiB default. Bounded well under
173
+ // that, base64 included, so the failure a user sees is a form error rather
174
+ // than a vanished card.
175
+ const MAX_ATTACHMENT_TOTAL_BYTES = 50 * 1024 * 1024;
176
+ const ATTACHMENTS_DIR = resolve(CONFIG_DIR, 'attachments');
177
+
178
+ function attachmentDir(jobId) {
179
+ return join(ATTACHMENTS_DIR, jobId);
180
+ }
181
+
182
+ // Stored paths and ids come from config.json, which a person (or an agent
183
+ // running as the same user) can edit by hand, so nothing is served or deleted
184
+ // through one unless it resolves inside the attachments dir.
185
+ function insideAttachments(path) {
186
+ return resolve(String(path || '')).startsWith(ATTACHMENTS_DIR + sep);
187
+ }
188
+
189
+ // The full list the card should have: { name } keeps an existing file, { name,
190
+ // data } writes a new one, and anything on disk that is not named is removed.
191
+ // Split in two so updateJob can refuse before it has touched the job: plan
192
+ // decodes and validates without side effects, apply writes. Returns null when
193
+ // the message did not mention attachments at all.
194
+ function planAttachments(job, list) {
195
+ if (!Array.isArray(list)) return null;
196
+ if (list.length > MAX_ATTACHMENTS) return { error: `At most ${MAX_ATTACHMENTS} files per job` };
197
+ const dir = attachmentDir(job.id);
198
+ if (!insideAttachments(dir)) return { error: 'Job id cannot hold attachments' };
199
+ const stored = job.attachments || [];
200
+ const kept = [];
201
+ const writes = [];
202
+ let total = 0;
203
+ for (const item of list) {
204
+ // No separator survives the sanitiser and a name with no letter or digit
205
+ // is refused, so join() cannot climb out of the job's dir. Every refusal
206
+ // is an error, not a silent drop: the user attached the file on purpose.
207
+ const name = safeFilename(item?.name).slice(0, 120);
208
+ if (!/[a-zA-Z0-9]/.test(name)) return { error: `Unusable file name "${String(item?.name || '')}"` };
209
+ // Case-insensitive: macOS and Windows would store Shot.png and shot.png
210
+ // as one file that two records then fight over.
211
+ if (kept.some(a => a.name.toLowerCase() === name.toLowerCase())) return { error: `Two files would be stored as "${name}"` };
212
+ const path = join(dir, name);
213
+ if (typeof item.data === 'string') {
214
+ const buf = Buffer.from(item.data, 'base64');
215
+ if (buf.length > MAX_ATTACHMENT_BYTES) return { error: `${name} is too large (max 10MB)` };
216
+ total += buf.length;
217
+ if (total > MAX_ATTACHMENT_TOTAL_BYTES) return { error: 'Attachments add up to more than 50MB' };
218
+ writes.push({ path, buf });
219
+ kept.push({ name, path });
220
+ continue;
221
+ }
222
+ // A { name } keeps what the card already has, record and all: a file
223
+ // that has gone missing from disk stays listed (its link 404s, which
224
+ // says so) rather than being dropped from the card by an unrelated edit.
225
+ const old = stored.find(a => a.name === name);
226
+ if (old) kept.push(old);
227
+ }
228
+ return { dir, kept, writes };
229
+ }
230
+
231
+ function applyAttachments(job, plan) {
232
+ // Written beside the final name and renamed into place only once every
233
+ // write has succeeded, so a write that fails (disk full) leaves the files
234
+ // the card already had as they were. The suffix holds a character the
235
+ // sanitiser strips, so it can never be a real attachment's name.
236
+ // ponytail: the rename loop itself is not atomic as a set; a rename that
237
+ // fails mid-loop (Windows EPERM on an open file) leaves earlier ones done.
238
+ const parts = plan.writes.map(w => ({ ...w, part: `${w.path}~part` }));
239
+ try {
240
+ if (parts.length) mkdirSync(plan.dir, { recursive: true });
241
+ for (const w of parts) writeFileSync(w.part, w.buf);
242
+ for (const w of parts) renameSync(w.part, w.path);
243
+ } catch (err) {
244
+ for (const w of parts) rmSync(w.part, { force: true });
245
+ return { error: `Could not save attachments: ${err.message}` };
246
+ }
247
+ for (const old of job.attachments || []) {
248
+ if (!plan.kept.some(a => a.name === old.name) && insideAttachments(old.path)) rmSync(old.path, { force: true });
249
+ }
250
+ return { attachments: plan.kept };
251
+ }
252
+
253
+ // For the download route: the stored path, or null if the card has no such file.
254
+ export function attachmentPath(jobId, name) {
255
+ const job = allJobs().find(j => j.id === jobId);
256
+ const hit = job && (job.attachments || []).find(a => a.name === name);
257
+ return hit && insideAttachments(hit.path) ? hit.path : null;
258
+ }
259
+
260
+ // A card is a few lines of JSON and stays forever; its files can be megabytes
261
+ // and were inputs to a run that is now over. Done is terminal, so the moment a
262
+ // card gets there — by hand or by the merge sweep — the disk comes back. The
263
+ // records go with the files: an archive card full of 404 links says less than
264
+ // none. On a failure (or a path the containment check refuses) the list is
265
+ // kept, so the files stay owned by the card and still go when it is deleted.
266
+ // Returns whether anything changed, so callers know to persist.
267
+ function clearAttachments(job) {
268
+ if (!(job.attachments || []).length) return false;
269
+ const dir = attachmentDir(job.id);
270
+ if (!insideAttachments(dir)) return false;
271
+ try {
272
+ rmSync(dir, { recursive: true, force: true });
273
+ job.attachments = [];
274
+ return true;
275
+ } catch (err) {
276
+ console.error(`Failed to remove attachments for finished job "${job.title}":`, err.message);
277
+ return false;
278
+ }
279
+ }
280
+
281
+ // True while a live agent may still be reading this card's files: its prompt
282
+ // named their absolute paths at dispatch — the same hazard updateJob's To-do
283
+ // gate exists for — and a Review agent is kept until its card is done, so a
284
+ // kill that failed at Done leaves it holding the files until it exits.
285
+ function attachmentsInUse(job) {
286
+ const session = job.agentSessionId ? sessions.get(job.agentSessionId) : null;
287
+ return !!session && !session.exited;
288
+ }
289
+
290
+ // Done cards whose files are still on disk: a clear the OS refused (a file
291
+ // open in a browser tab on Windows), or one deferred while the card's agent
292
+ // was still alive. Done is terminal, so without this per-sweep retry a single
293
+ // failure would leak the directory for as long as the archive keeps the card.
294
+ function clearFinishedAttachments() {
295
+ let cleared = false;
296
+ for (const job of allJobs()) {
297
+ if (job.state === 'done' && (job.attachments || []).length && !attachmentsInUse(job)) {
298
+ if (clearAttachments(job)) cleared = true;
299
+ }
300
+ }
301
+ return cleared;
302
+ }
303
+
304
+ // --- CRUD ---
305
+
306
+ export function addJob({ title, detail, repoPath, type, schedule, permissionMode, agent, requiresPr, postedBy, postedByName, postedByAgent, postedByBillion, attachments }, broadcast) {
307
+ const result = createJob({ title, detail, repoPath, type, schedule, permissionMode, agent, requiresPr, postedBy, postedByName, postedByAgent, postedByBillion });
308
+ if (result.error) return result;
309
+ const plan = planAttachments(result.job, attachments);
310
+ if (plan?.error) return plan;
311
+ const written = plan ? applyAttachments(result.job, plan) : null;
312
+ if (written?.error) return written;
313
+ if (written) result.job.attachments = written.attachments;
314
+ allJobs().push(result.job);
315
+ persist(broadcast);
316
+ return { job: result.job };
317
+ }
318
+
319
+ // --- Repo references ---
320
+
321
+ // The board's own form picks a repo from a dropdown of exact paths. An agent
322
+ // calling the MCP tool types whatever it knows — "~/code/app", a relative path,
323
+ // or just the folder name. Resolve all of those against the configured repos,
324
+ // and refuse anything that doesn't match rather than queueing a job into a repo
325
+ // the dispatcher will silently skip forever.
326
+ export function resolveRepoRef(ref) {
327
+ const repos = Array.isArray(config.repos) ? config.repos : [];
328
+ const known = repos.map(r => r.path);
329
+ if (repos.length === 0) return { error: 'No repositories are configured in Agent 007 — add one in the explorer first' };
330
+ const raw = String(ref || '').trim();
331
+ if (!raw) return { error: `Which repository? Pass repo with one of: ${known.map(p => basename(p)).join(', ')}` };
332
+ const expanded = expandHome(raw);
333
+ const abs = resolve(expanded);
334
+ const byPath = known.find(p => p === raw || p === abs);
335
+ if (byPath) return { path: byPath };
336
+ // Folder name, case-insensitively. Ambiguity is possible (two repos sharing a
337
+ // basename), so say so instead of guessing.
338
+ const lower = raw.toLowerCase();
339
+ const byName = known.filter(p => basename(p).toLowerCase() === lower);
340
+ if (byName.length === 1) return { path: byName[0] };
341
+ if (byName.length > 1) {
342
+ // Enough to tell them apart (the parent folder), not the absolute path:
343
+ // every other branch here reports basenames only, and this reply goes out
344
+ // to whatever called the tool.
345
+ const hints = byName.map(p => join(basename(dirname(p)), basename(p)));
346
+ return { error: `"${raw}" matches more than one repository (${hints.join(', ')}) — pass the full path` };
347
+ }
348
+ return { error: `Unknown repository "${raw}" — known repositories: ${known.map(p => basename(p)).join(', ')}` };
349
+ }
350
+
351
+ // --- Posting on an agent's behalf ---
352
+ //
353
+ // The one place a card gets created for an agent, shared by the MCP tool and by
354
+ // POST /api/jobs. Both doors must resolve the repo, attribute the card and
355
+ // report dispatcher state identically; a second copy of this would drift.
356
+ //
357
+ // Not ownership-gated, matching the WebSocket `job-create` handler: the board is
358
+ // shared workspace state, and postedBy/postedByAgent are attribution rather than
359
+ // access control.
360
+ // Said the same way at both doors: an agent that passed a JSON number or an
361
+ // object meant to schedule something, and deserves the same answer either way.
362
+ function scheduleTypeError(schedule) {
363
+ return schedule != null && typeof schedule !== 'string'
364
+ ? 'schedule must be a string — a five-field cron expression or an @shorthand'
365
+ : null;
366
+ }
367
+
368
+ export function postJobForAgent({ title, detail, repo, schedule, type, agent, requiresPr, session, user }, broadcast) {
369
+ // The repo the calling agent is working in is the overwhelmingly likely
370
+ // answer, so an agent only names one when it means a different repo.
371
+ const resolved = resolveRepoRef(repo || (session && session.repoPath) || '');
372
+ if (resolved.error) return { error: resolved.error };
373
+
374
+ // A non-string schedule (a JSON number, an object) must come back as an
375
+ // error, not a silent one-time card: the plain HTTP door has no schema in
376
+ // front of it, the caller asked for a scheduled job, and every other bad
377
+ // input here is answered with a message the caller can act on.
378
+ const badSchedule = scheduleTypeError(schedule);
379
+ if (badSchedule) return { error: badSchedule };
380
+ if (type != null && typeof type !== 'string') {
381
+ return { error: 'type must be a string — "one-time" or "scheduled"' };
382
+ }
383
+ if (agent != null && typeof agent !== 'string') {
384
+ return { error: 'agent must be a string — "claude" or "codex"' };
385
+ }
386
+ if (requiresPr != null && typeof requiresPr !== 'boolean') {
387
+ return { error: 'requires_pr must be true or false' };
388
+ }
389
+
390
+ const result = addJob({
391
+ // Typed explicitly: this comes off the wire, and a non-string would be
392
+ // stringified into the card ("[object Object]") rather than rejected.
393
+ // createJob still owns the length and emptiness rules.
394
+ title: typeof title === 'string' ? title : '',
395
+ detail: typeof detail === 'string' ? detail : '',
396
+ repoPath: resolved.path,
397
+ // A schedule alone makes it a scheduled job — createJob owns that rule, and
398
+ // the cron validation with it, so a bad expression comes back as a message
399
+ // the calling agent can act on rather than a card that never fires.
400
+ type: typeof type === 'string' ? type : undefined,
401
+ schedule: typeof schedule === 'string' ? schedule : '',
402
+ // Unnamed, the card runs on the same CLI as the agent posting it; a person
403
+ // at the HTTP door with no session gets the board default.
404
+ agent: agent || (session ? jobAgentFromCommand(session.command) : undefined),
405
+ requiresPr,
406
+ // No permissionMode: an agent posting a card must not be able to pick the
407
+ // mode the board will spawn with, which would be a way around every gate
408
+ // its own session runs under. A card an agent files inherits the board's.
409
+ postedBy: user ? user.id : null,
410
+ postedByName: user ? user.displayName : null,
411
+ postedByAgent: session ? session.name : null,
412
+ postedByBillion: !!session?.isBillion,
413
+ }, broadcast);
414
+ if (result.error) return { error: result.error };
415
+
416
+ // A card an agent posted while the user was looking at a terminal would
417
+ // otherwise land silently on a tab they cannot see. The board's own form
418
+ // needs no toast — the user is looking straight at the column it lands in.
419
+ if (session && broadcast) {
420
+ broadcast({
421
+ type: 'notification', level: 'info',
422
+ message: `${session.name} posted a job: "${result.job.title}"`,
423
+ });
424
+ }
425
+ return {
426
+ job: result.job,
427
+ repoName: basename(resolved.path),
428
+ // Whether the board will actually act on it. Reported out loud, because a
429
+ // stopped dispatcher means the card just sits in To do — and an agent
430
+ // telling its user "queued" would imply work is under way when none is.
431
+ dispatcherRunning: !!boardSettings().running,
432
+ };
433
+ }
434
+
435
+ // --- The read side of the same door ---
436
+ //
437
+ // An agent asked by its user to look at the board, rather than write to it. The
438
+ // board is one shared wall — jobsPayload sends every card to every connected
439
+ // client — so there is nothing per-agent to filter out here, and these read the
440
+ // same store the browser does.
441
+
442
+ // The row an agent gets per card: enough to say what the card is and to name it
443
+ // in a follow-up call, without the detail body, which is the long part.
444
+ function jobSummary(job) {
445
+ const session = job.agentSessionId ? sessions.get(job.agentSessionId) : null;
446
+ return {
447
+ id: job.id,
448
+ title: job.title,
449
+ state: job.state,
450
+ type: jobType(job),
451
+ agent: jobAgent(job),
452
+ requiresPr: jobRequiresPr(job),
453
+ schedule: job.schedule || null,
454
+ nextRunAt: job.nextRunAt || null,
455
+ scheduleId: job.scheduleId || null,
456
+ lastSkipReason: job.lastSkipReason || null,
457
+ repo: basename(job.repoPath || ''),
458
+ editedByAgent: job.editedByAgent || null,
459
+ // Derived fresh, never stored: it describes a PTY that exists right now.
460
+ status: deriveJobStatus(job, session),
461
+ agentName: job.agentName || null,
462
+ prUrl: job.prUrl || null,
463
+ postedByName: job.postedByName || null,
464
+ postedByAgent: job.postedByAgent || null,
465
+ postedAt: job.postedAt || null,
466
+ };
467
+ }
468
+
469
+ export function listJobsForAgent({ state, repo } = {}) {
470
+ const wanted = typeof state === 'string' && state.trim() ? state.trim() : null;
471
+ if (wanted && !JOB_STATES.includes(wanted)) {
472
+ return { error: `Unknown state "${wanted}" — expected one of: ${JOB_STATES.join(', ')}` };
473
+ }
474
+ // Narrowing arguments must narrow or refuse, never widen: a non-string repo
475
+ // used to fall through to "every repository", which is the opposite of what
476
+ // the caller asked for. postJobForAgent refuses the same shape.
477
+ if (repo != null && typeof repo !== 'string') {
478
+ return { error: 'repo must be a string — a full path or a folder name' };
479
+ }
480
+ let repoPath = null;
481
+ if (typeof repo === 'string' && repo.trim()) {
482
+ const resolved = resolveRepoRef(repo);
483
+ if (resolved.error) return { error: resolved.error };
484
+ repoPath = resolved.path;
485
+ }
486
+ // Finished cards are the archive behind the board's toolbar, not a column, so
487
+ // an unfiltered list answers with the board — asking for `done` reaches them.
488
+ const jobs = allJobs().filter(job => (wanted ? job.state === wanted : job.state !== 'done')
489
+ && (!repoPath || job.repoPath === repoPath))
490
+ // Oldest first, the order the board's own columns are sorted in, so an
491
+ // agent describes the board the user is looking at rather than a shuffle.
492
+ .sort((a, b) => String(a.postedAt || '').localeCompare(String(b.postedAt || '')));
493
+ return {
494
+ jobs: jobs.map(jobSummary),
495
+ state: wanted,
496
+ repoName: repoPath ? basename(repoPath) : null,
497
+ // Said out loud by the caller: a board that looks empty because the archive
498
+ // is hidden is different from a board with nothing on it.
499
+ archived: wanted ? 0 : allJobs().filter(job => job.state === 'done').length,
500
+ };
501
+ }
502
+
503
+ export function readJobForAgent(jobId) {
504
+ const job = allJobs().find(j => j.id === jobId);
505
+ if (!job) return { error: `No job with id "${jobId}" — list the board to see the ids.` };
506
+ return {
507
+ job: {
508
+ ...jobSummary(job),
509
+ detail: job.detail || '',
510
+ // Basename only, deliberately — resolveRepoRef reports repos the same
511
+ // way. An absolute repo or worktree path is the layout of the user's
512
+ // disk, and this reply goes to whatever agent called the tool, about
513
+ // every card on the board including other people's.
514
+ branchName: job.branchName || null,
515
+ startedAt: job.startedAt || null,
516
+ prMergedAt: job.prMergedAt || null,
517
+ prClosedAt: job.prClosedAt || null,
518
+ resultSummary: job.resultSummary || null,
519
+ editedAt: job.editedAt || null,
520
+ lastRunAt: job.lastRunAt || null,
521
+ runCount: job.runCount || 0,
522
+ // Both, and separately: the board keeps them apart because a card can be
523
+ // failing to dispatch AND failing its PR check at once.
524
+ lastError: job.lastError || null,
525
+ prCheckError: job.prCheckError || null,
526
+ attachments: (Array.isArray(job.attachments) ? job.attachments : []).map(a => a && a.name).filter(Boolean),
527
+ },
528
+ };
529
+ }
530
+
531
+ // The same edit, asked for by an agent. It refuses ahead of updateJob rather
532
+ // than after it so a no-op edit to a dispatched card answers with the rule that
533
+ // actually stopped it, not "nothing to change".
534
+ //
535
+ // Two guards here that the board's own form does not need. A To do card's
536
+ // detail IS the next agent's prompt — buildJobPrompt hands it over verbatim to
537
+ // an unattended `claude --permission-mode auto` — and every board-dispatched
538
+ // agent holds one of these tokens, so an agent working a hostile repo could
539
+ // otherwise rewrite a card queued for a different repo and have the board run
540
+ // its text. So: an agent may not touch a card another person queued, matching
541
+ // the ownership rule ws.js already applies to terminals; and an edit that does
542
+ // land says so out loud and leaves its name on the card, because the whole
543
+ // hazard is an edit nobody sees. Reading stays board-wide — every browser
544
+ // already sees every card — but writing does not.
545
+ export function editJobForAgent({ id, title, detail, repo, schedule, requiresPr, session, user }, broadcast) {
546
+ const job = allJobs().find(j => j.id === id);
547
+ if (!job) return { error: `No job with id "${id}" — list the board to see the ids.` };
548
+ const gate = editableInPlace(job);
549
+ if (gate) return gate;
550
+ // Null on a single-player board (no users file), on both sides, so this only
551
+ // ever bites once identities exist.
552
+ const asker = user ? user.id : null;
553
+ if (job.postedBy && job.postedBy !== asker) {
554
+ return {
555
+ error: `"${job.title}" was queued by ${job.postedByName || 'someone else'}, so this agent cannot edit it. `
556
+ + 'Ask them, or post a new card.',
557
+ };
558
+ }
559
+ // Billion's cards carry its trust — its worker asks Billion, not a person,
560
+ // for permission — and belong to no user, so the check above never fires on
561
+ // them. Only Billion rewrites them; a person still can, from the board.
562
+ if (job.postedByBillion && !session?.isBillion) {
563
+ return { error: `"${job.title}" is ${BILLION_NAME}'s card, so only ${BILLION_NAME} can edit it. Ask it with send_message, or post a new card.` };
564
+ }
565
+
566
+ const fields = {};
567
+ const changed = [];
568
+ if (title !== undefined) {
569
+ if (typeof title !== 'string' || !title.trim()) {
570
+ return { error: 'Title must be a non-empty string — omit it to leave the title alone.' };
571
+ }
572
+ if (title.trim() !== job.title) { fields.title = title; changed.push('title'); }
573
+ }
574
+ if (detail !== undefined) {
575
+ if (typeof detail !== 'string') return { error: 'detail must be a string' };
576
+ if (detail.trim() !== (job.detail || '')) { fields.detail = detail; changed.push('detail'); }
577
+ }
578
+ if (repo !== undefined) {
579
+ const resolved = resolveRepoRef(repo);
580
+ if (resolved.error) return { error: resolved.error };
581
+ if (resolved.path !== job.repoPath) { fields.repoPath = resolved.path; changed.push('repo'); }
582
+ }
583
+ if (schedule !== undefined) {
584
+ const bad = scheduleTypeError(schedule);
585
+ if (bad) return { error: bad };
586
+ const text = String(schedule || '').trim();
587
+ // An empty schedule is the way back to a one-time card: without naming the
588
+ // type too, resolveJobType would read "scheduled with no cron" and refuse.
589
+ // The tool exposes no `type` of its own — the schedule is the whole of what
590
+ // a card IS, and two fields that can disagree is a bug waiting to be filed.
591
+ fields.schedule = text;
592
+ fields.type = text ? 'scheduled' : 'one-time';
593
+ if (text !== (job.schedule || '')) changed.push('schedule');
594
+ }
595
+ if (requiresPr !== undefined) {
596
+ if (typeof requiresPr !== 'boolean') return { error: 'requires_pr must be true or false' };
597
+ // Always passed on, so a type change in the same call cannot swap it for
598
+ // the new type's default; only reported when it changes what the card does.
599
+ fields.requiresPr = requiresPr;
600
+ if (requiresPr !== jobRequiresPr(job)) changed.push('requires_pr');
601
+ }
602
+
603
+ if (!changed.length) {
604
+ return { error: 'Nothing to change — pass a new title, detail, repo, schedule or requires_pr.' };
605
+ }
606
+ const result = updateJob(job.id, fields, broadcast);
607
+ if (result.error) return result;
608
+ // Stamped before the repaint that carries it to every open board.
609
+ job.editedByAgent = session ? session.name : null;
610
+ job.editedAt = new Date().toISOString();
611
+ if (session && broadcast) {
612
+ broadcast({
613
+ type: 'notification', level: 'info',
614
+ message: `${session.name} edited a job: "${result.job.title}"`,
615
+ });
616
+ }
617
+ return { job: jobSummary(result.job), changed };
618
+ }
619
+
620
+ // The agent's own "I am done". A one-time card moves to Review on this call,
621
+ // with its agent and worktree kept. Only the agent the card is linked to may
622
+ // finish it, so an agent cannot close out someone else's work.
623
+ //
624
+ // A card that requires a PR must name one, and it must be the open PR on this
625
+ // card's own branch: the link lands on the card, so a made-up or unrelated URL
626
+ // would send the reviewer to the wrong place. A card that requires none must
627
+ // carry a summary, since that is the whole of what Review has to show.
628
+ export async function finishJobForAgent({ session, summary, prUrl }, broadcast, { findPr = findPrForBranch } = {}) {
629
+ const job = session ? allJobs().find(j => j.agentSessionId === session.id) : null;
630
+ if (!job) return { error: 'This agent is not working a job on the board, so there is nothing to finish.' };
631
+ if (summary != null && typeof summary !== 'string') return { error: 'summary must be a string' };
632
+ // Already in Review: the PR poll can find the PR /ship opened before the
633
+ // agent calls in, or someone moved the card by hand. The agent is right that
634
+ // it is done, so this is a success, and a summary it brings still lands.
635
+ if (job.state === 'review') {
636
+ const note = String(summary || '').trim().slice(0, MAX_DETAIL_LEN);
637
+ let changed = false;
638
+ if (note) { job.resultSummary = note; changed = true; }
639
+ // A PR link that is not the card's: the agent reworked and opened a new
640
+ // PR on the same branch. It is the card's PR of record if it is that
641
+ // branch's open PR, checked the same way as a first finish.
642
+ const norm = u => String(u).trim().replace(/\/+$/, '').toLowerCase();
643
+ if (jobRequiresPr(job) && prUrl && prUrl.trim() && (!job.prUrl || norm(prUrl) !== norm(job.prUrl))) {
644
+ const askedBranch = job.branchName;
645
+ const found = await findPr(job.repoPath, askedBranch);
646
+ if (found.pr && norm(found.pr.url) === norm(prUrl) && job.state === 'review' && job.branchName === askedBranch) {
647
+ job.prUrl = found.pr.url;
648
+ job.prNumber = found.pr.number;
649
+ job.prClosedSeenAt = null;
650
+ changed = true;
651
+ }
652
+ }
653
+ if (changed) persist(broadcast);
654
+ return { job: jobSummary(job) };
655
+ }
656
+ if (job.state !== 'in-progress') {
657
+ return { error: `"${job.title}" is in ${STATE_LABELS[job.state] || job.state} already, so there is nothing to finish.` };
658
+ }
659
+ if (prUrl != null && typeof prUrl !== 'string') return { error: 'pr_url must be a string' };
660
+ const text = String(summary || '').trim().slice(0, MAX_DETAIL_LEN);
661
+ const requiresPr = jobRequiresPr(job);
662
+ // The same skill under each CLI's spelling, as the prompt names it.
663
+ const ship = jobAgent(job) === 'codex' ? '$ship' : '/ship';
664
+ let pr = null;
665
+ if (requiresPr) {
666
+ if (!prUrl || !prUrl.trim()) {
667
+ return { error: `This job requires a pull request — run ${ship}, wait for it to open the PR, then call finish_job with its URL as pr_url.` };
668
+ }
669
+ const askedBranch = job.branchName;
670
+ const found = await findPr(job.repoPath, askedBranch);
671
+ if (found.error) return { error: `Could not check the pull request — ${found.error}` };
672
+ // Re-validate after the network call, as every other PR path does.
673
+ // The PR poll can move the card to Review during this very lookup; that is
674
+ // the same finish, so it takes the already-in-Review success above.
675
+ if (allJobs().includes(job) && job.state === 'review' && job.branchName === askedBranch) {
676
+ return finishJobForAgent({ session, summary }, broadcast, { findPr });
677
+ }
678
+ if (!allJobs().includes(job) || job.state !== 'in-progress' || job.branchName !== askedBranch) {
679
+ return { error: `"${job.title}" moved while its pull request was being checked, so it was left as it is now.` };
680
+ }
681
+ if (!found.pr) return { error: `There is no open pull request on ${askedBranch} yet — run ${ship} first.` };
682
+ const norm = u => String(u).trim().replace(/\/+$/, '').toLowerCase();
683
+ if (norm(prUrl) !== norm(found.pr.url)) {
684
+ return { error: `${prUrl} is not this job's pull request — the open one on ${askedBranch} is ${found.pr.url}.` };
685
+ }
686
+ pr = found.pr;
687
+ } else if (!text) {
688
+ return { error: 'This job needs no pull request, so the summary is its result — pass what you did or found as summary.' };
689
+ }
690
+
691
+ job.state = 'review';
692
+ job.reviewAt = new Date().toISOString();
693
+ job.resultSummary = text || null;
694
+ if (pr) {
695
+ job.prUrl = pr.url;
696
+ job.prNumber = pr.number;
697
+ }
698
+ job.lastError = null;
699
+ job.lastErrorAt = null;
700
+ clearPrCheckError(job);
701
+ persist(broadcast);
702
+ if (broadcast) {
703
+ broadcast({
704
+ type: 'notification', level: 'info',
705
+ message: `Job "${job.title}" moved to Review — ${pr ? `PR #${pr.number}` : `${session.name} finished`}`,
706
+ });
707
+ }
708
+ notifyBillion(job);
709
+ return { job: jobSummary(job) };
710
+ }
711
+
712
+ // A card Billion posted tells Billion the moment it lands in Review, so a
713
+ // finished result is picked up now rather than at its next wake-up. Billion
714
+ // only: any other agent that posted a card may be mid-conversation with a
715
+ // person, and a notice typed into that terminal would be an interruption
716
+ // nobody asked for. The summary is a worker's text, so it goes in quoted and
717
+ // trimmed; read_job has the whole of it.
718
+ const NOTICE_SUMMARY_CHARS = 1500;
719
+ export function notifyBillion(job) {
720
+ if (!job.postedByBillion) return false;
721
+ const billion = liveBillion();
722
+ if (!billion) return false;
723
+ const summary = job.resultSummary || '';
724
+ const lines = [
725
+ job.prUrl ? `Pull request: ${job.prUrl}` : null,
726
+ summary ? `Summary: ${summary.length > NOTICE_SUMMARY_CHARS ? `${summary.slice(0, NOTICE_SUMMARY_CHARS)}… (read_job for the rest)` : summary}` : null,
727
+ ].filter(Boolean);
728
+ return sendNotice(billion, `"${job.title}" (card ${job.id}, ${basename(job.repoPath || '')}) is in Review.`, lines);
729
+ }
730
+
731
+ // Billion's verdict on one of its own cards in Review (docs/BILLION.md, part 3).
732
+ // Accept files it as Done and retires its agent; send it back returns it to To
733
+ // do with the reason added to its detail, so the next worker knows what to fix.
734
+ //
735
+ // Only Billion, only its own cards, only from Review: this is the owner's
736
+ // "Done" button handed to one agent, not to every agent on the board. A card
737
+ // with a pull request is not accepted here — merging it is what files it as
738
+ // Done, and a Done card whose PR never merged would claim work shipped that
739
+ // did not.
740
+ const SENT_BACK_CHARS = 2000;
741
+ export async function closeJobForAgent({ session, id, accept, note }, broadcast, { killSession } = {}) {
742
+ if (!session?.isBillion) return { error: 'Only Billion can close cards.' };
743
+ const job = allJobs().find(j => j.id === id);
744
+ if (!job) return { error: `No card with id "${id}". list_jobs shows the ids.` };
745
+ if (!job.postedByBillion) return { error: `"${job.title}" was not posted by you, so it is not yours to close.` };
746
+ if (job.state !== 'review') return { error: `"${job.title}" is in ${STATE_LABELS[job.state] || job.state}; only a card in Review can be closed.` };
747
+ const reason = typeof note === 'string' ? note.trim() : '';
748
+ if (accept) {
749
+ if (job.prUrl) return { error: `"${job.title}" has a pull request (${job.prUrl}). Merge it, or close it to drop the work: either way the board files the card away on its own.` };
750
+ const result = await moveJob(job.id, 'done', broadcast, { killSession });
751
+ return result.error ? result : { job: jobSummary(job), accepted: true };
752
+ }
753
+ if (!reason) return { error: 'Say why it goes back (note): the next worker only knows what the card tells it.' };
754
+ // The note goes on before the move: once the card is back in To do the
755
+ // dispatcher may hand it out, and the worker must get the reason with it.
756
+ // The PR is kept for the reply, since the move clears it from the card.
757
+ const oldPrUrl = job.prUrl || null;
758
+ const oldDetail = job.detail;
759
+ // The note always fits: it is the old detail that gives way.
760
+ // Capped well short of the card's limit, so the task itself always survives.
761
+ const sentBack = `Sent back by ${BILLION_NAME}: ${reason}`.slice(0, SENT_BACK_CHARS);
762
+ const room = MAX_DETAIL_LEN - sentBack.length - 2;
763
+ job.detail = job.detail && room > 0 ? `${job.detail.slice(0, room)}\n\n${sentBack}` : sentBack;
764
+ const result = await moveJob(job.id, 'todo', broadcast, { killSession });
765
+ if (result.error) {
766
+ job.detail = oldDetail;
767
+ return result;
768
+ }
769
+ return { job: jobSummary(job), accepted: false, oldPrUrl };
770
+ }
771
+
772
+ // A card stops being editable the moment it leaves To do, whoever is asking.
773
+ //
774
+ // The reasons stack up: its agent was handed the title, detail and attachment
775
+ // paths in its prompt at dispatch, so a later edit changes nothing about the
776
+ // run and leaves the card describing work nobody was asked to do; repointing
777
+ // repoPath misattributes work already under way in a worktree; and flipping an
778
+ // in-flight card to a schedule would strand its agent on a card that is never
779
+ // dispatched and never moves.
780
+ //
781
+ // The board's own form has only ever offered Edit on a To do card, so this is
782
+ // the rule the interface always implied — now enforced for every door into the
783
+ // store rather than trusted to the button not being drawn.
784
+ function editableInPlace(job) {
785
+ if (job.state === 'todo') return null;
786
+ const label = STATE_LABELS[job.state] || job.state;
787
+ return {
788
+ error: `"${job.title}" is in ${label}, and only cards still in To do can be edited — `
789
+ + 'its agent has already been handed the card as it stands.',
790
+ };
791
+ }
792
+
793
+ export function updateJob(jobId, fields, broadcast) {
794
+ const job = allJobs().find(j => j.id === jobId);
795
+ if (!job) return { error: 'Job not found' };
796
+ const gate = editableInPlace(job);
797
+ if (gate) return gate;
798
+ // Everything else that can be refused is checked before anything on the job
799
+ // is touched, or an error reply would leave the card half-edited in memory
800
+ // for the next unrelated persist to write out.
801
+ const plan = planAttachments(job, fields.attachments);
802
+ if (plan?.error) return plan;
803
+ // Refused before anything is written, alongside the attachment plan and the
804
+ // type/schedule pair, for the same reason: an error reply must not leave a
805
+ // half-edited card in memory.
806
+ let mode = null;
807
+ if (fields.permissionMode !== undefined) {
808
+ mode = resolveJobPermissionMode(fields.permissionMode);
809
+ if (mode.error) return { error: mode.error };
810
+ }
811
+ let cli = null;
812
+ if (fields.agent !== undefined) {
813
+ cli = resolveJobAgent(fields.agent);
814
+ if (cli.error) return { error: cli.error };
815
+ }
816
+ // Type and schedule move together: "scheduled with no cron" and "one-time
817
+ // carrying a cron" are both incoherent, so they are resolved as a pair and
818
+ // rejected as a pair.
819
+ let resolved = null;
820
+ let changes = false;
821
+ if (fields.type !== undefined || fields.schedule !== undefined) {
822
+ resolved = resolveJobType({
823
+ type: fields.type !== undefined ? fields.type : jobType(job),
824
+ schedule: fields.schedule !== undefined ? fields.schedule : job.schedule,
825
+ });
826
+ if (resolved.error) return { error: resolved.error };
827
+ changes = resolved.type !== jobType(job)
828
+ || (resolved.schedule || null) !== (job.schedule || null);
829
+ }
830
+ // The disk write is the last thing that can fail, and it happens before the
831
+ // first field changes.
832
+ const written = plan ? applyAttachments(job, plan) : null;
833
+ if (written?.error) return written;
834
+ if (written) job.attachments = written.attachments;
835
+ if (typeof fields.title === 'string' && fields.title.trim()) job.title = fields.title.trim().slice(0, MAX_TITLE_LEN);
836
+ if (typeof fields.detail === 'string') job.detail = fields.detail.trim().slice(0, MAX_DETAIL_LEN);
837
+ if (fields.repoPath) job.repoPath = fields.repoPath;
838
+ if (mode) job.permissionMode = mode.permissionMode;
839
+ if (cli) job.agent = cli.agent;
840
+ const typeChanged = !!resolved && resolved.type !== jobType(job);
841
+ if (resolved) {
842
+ job.type = resolved.type;
843
+ // A run turned into a schedule is no longer that schedule's run; left
844
+ // linked, it would sit in To do and hold its parent off for good.
845
+ if (typeChanged && resolved.type === 'scheduled') delete job.scheduleId;
846
+ job.schedule = resolved.schedule;
847
+ // Recompute only on a real change: the old due time belongs to the old
848
+ // cron, but a save that merely retitled the card must not re-arm an
849
+ // overdue schedule and eat the firing that was about to happen.
850
+ if (changes) job.nextRunAt = resolved.schedule ? nextCronIso(resolved.schedule) : null;
851
+ }
852
+ // A type change without an explicit choice takes the new type's default
853
+ // (see jobRequiresPr), so a schedule turned one-time does not silently keep
854
+ // a no-PR setting that only ever meant "its runs".
855
+ if (typeof fields.requiresPr === 'boolean') job.requiresPr = fields.requiresPr;
856
+ else if (typeChanged) job.requiresPr = defaultRequiresPr(job.type);
857
+ persist(broadcast);
858
+ return { job };
859
+ }
860
+
861
+ // Pause / resume. Deliberately NOT routed through updateJob: that gate refuses
862
+ // any edit to a card past To do because the agent has already been handed the
863
+ // card's text, and pause changes no text — it only decides whether the NEXT
864
+ // firing goes out. So a schedule can be stopped while its current run is still
865
+ // going, which is exactly when someone reaches for it.
866
+ //
867
+ // Resuming re-arms from now rather than leaving a due time that went by during
868
+ // the pause: an overdue nextRunAt would dispatch on the very next scan, so a
869
+ // card resumed after a week off would fire immediately, which is the backlog
870
+ // replay the board avoids everywhere else.
871
+ export function setJobPaused(jobId, paused, broadcast) {
872
+ const job = allJobs().find(j => j.id === jobId);
873
+ if (!job) return { error: 'Job not found' };
874
+ const next = !!paused;
875
+ if (job.paused === next) return { job };
876
+ job.paused = next;
877
+ if (!next && job.schedule) job.nextRunAt = nextCronIso(job.schedule);
878
+ persist(broadcast);
879
+ return { job };
880
+ }
881
+
882
+ // Deleting a card retires its agent, for the same reason requeueing does:
883
+ // otherwise the agent keeps running with nothing pointing at it, stops counting
884
+ // toward the per-repo cap, and is never cleaned up. removeWorktree still
885
+ // protects the work — dirty or unpushed changes become an orphan.
886
+ export async function deleteJob(jobId, broadcast, { killSession } = {}) {
887
+ const jobs = allJobs();
888
+ const idx = jobs.findIndex(j => j.id === jobId);
889
+ if (idx === -1) return { error: 'Job not found' };
890
+ const [removed] = jobs.splice(idx, 1);
891
+ persist(broadcast);
892
+ // After persist, so a file the OS will not let go of (open in a browser tab
893
+ // on Windows) cannot leave a card that is gone from memory but back on the
894
+ // next restart. The id is checked the same way a path is: it too is
895
+ // config.json text.
896
+ const dir = attachmentDir(removed.id);
897
+ if (insideAttachments(dir)) {
898
+ try { rmSync(dir, { recursive: true, force: true }); } catch (err) {
899
+ console.error(`Failed to remove attachments for deleted job "${removed.title}":`, err.message);
900
+ }
901
+ }
902
+ // With the card gone, nothing else would ever retire its agent. An exited
903
+ // one too — its session entry and worktree are just as stranded, and
904
+ // killSession copes with a dead process.
905
+ const sessionId = removed.agentSessionId;
906
+ if (sessionId && killSession && sessions.get(sessionId)) {
907
+ try {
908
+ await killSession(sessionId);
909
+ } catch (err) {
910
+ console.error(`Failed to close agent for deleted job "${removed.title}":`, err.message);
911
+ }
912
+ }
913
+ return { job: removed };
914
+ }
915
+
916
+ // Manual move (the buttons on a card). Permissive everywhere the card is still
917
+ // on the board, because automatic transitions can be wrong and the user should
918
+ // have the last word — with one exception, below: done is terminal.
919
+ //
920
+ // Moving back to To do also RETIRES the job's agent. Unlinking it without
921
+ // killing it left a running agent that no job pointed at: it no longer counted
922
+ // toward the per-repo cap, so the board would dispatch a replacement alongside
923
+ // it, and repeating the move walks straight past the cap. removeWorktree still
924
+ // protects the work: uncommitted or unpushed changes become an orphan rather
925
+ // than being deleted.
926
+ // discardChanges: only for a caller that knows the worktree is scratch (a
927
+ // superseded run, whose result is its summary); unpushed commits stay protected.
928
+ export async function moveJob(jobId, state, broadcast, { killSession, findPr = findPrForBranch, discardChanges = false } = {}) {
929
+ if (!JOB_STATES.includes(state)) return { error: `Unknown state "${state}"` };
930
+ const job = allJobs().find(j => j.id === jobId);
931
+ if (!job) return { error: 'Job not found' };
932
+ // Done is terminal. A finished card is the record of work that shipped, and
933
+ // the only thing that can happen to it is deletion.
934
+ //
935
+ // It is enforced here and not just in the UI because letting a card back onto
936
+ // the board could not be made safe. The card keeps the PR that finished it,
937
+ // and reviewAt is the sweep's time floor, so a job walked back to In progress
938
+ // carried a spent PR of record into its new attempt: checkMergedPullRequests
939
+ // re-matched that same old merge on the very next scan and filed the card
940
+ // away again — killing whatever agent had been re-adopted on the branch,
941
+ // because finishing retires one. Clearing those fields would
942
+ // just trade that for a card whose history is gone. Work that follows a
943
+ // merged PR is a new job, and the archive keeps the old one to point at.
944
+ if (job.state === 'done') {
945
+ return { error: 'That job is finished — post a new job for follow-up work, or delete this card' };
946
+ }
947
+ // A schedule is never dispatched, so it has nowhere to move: its runs are
948
+ // the cards that cross the board. The UI offers it no moves; the raw
949
+ // message has to be refused too.
950
+ if (isScheduled(job)) {
951
+ return { error: 'A schedule stays in To do — its runs are the cards that move. Pause or delete it instead.' };
952
+ }
953
+ // A job with no branch has never been dispatched, so nothing can move it out
954
+ // of in-progress again: the dispatcher only looks at todo, and the PR watcher
955
+ // only at jobs with a branch. Accepting the move would strand it forever.
956
+ if (state === 'in-progress' && !job.branchName) {
957
+ return { error: 'That job has never been dispatched — leave it in To do so the board can pick it up' };
958
+ }
959
+ // Two moves retire the agent: back to To do, and to Done. Review keeps it —
960
+ // the work is finished, the agent idles at its prompt, and it is right there
961
+ // if the review turns up something to fix. The per-repo cap only counts In
962
+ // progress, so a kept Review agent does not hold a slot.
963
+ //
964
+ // To do retires it because the next dispatch spawns a fresh one. Done retires
965
+ // it, worktree and all, because a finished card is off the board and nothing
966
+ // visible would point at an agent left running.
967
+ const retiringSessionId = state === 'todo' || state === 'done' ? job.agentSessionId : null;
968
+ // Captured before To do clears them, for the orphan release at the end.
969
+ const attempt = { branchName: job.branchName, repoPath: job.repoPath, worktreePath: job.worktreePath };
970
+ const fromState = job.state;
971
+ job.state = state;
972
+ if (state === 'todo') {
973
+ job.agentSessionId = null;
974
+ job.agentName = null;
975
+ job.startedAt = null;
976
+ job.branchName = null;
977
+ job.worktreePath = null;
978
+ job.prUrl = null;
979
+ job.prNumber = null;
980
+ job.reviewAt = null;
981
+ job.resultSummary = null; // the last attempt's result, not this one's
982
+ }
983
+ // Restamped when the work comes back from In progress: a run sent back for a
984
+ // follow-up is the newest result again (see supersededRuns).
985
+ // Only on a card that needs no PR: on one that does, reviewAt is also the
986
+ // merge sweep's time floor, which must not move past a merge.
987
+ if (state === 'review' && (!job.reviewAt || (fromState === 'in-progress' && !jobRequiresPr(job)))) job.reviewAt = new Date().toISOString();
988
+ // Stamped on arrival, and never cleared, because nothing leaves done. A job
989
+ // finished by hand gets a doneAt but no prMergedAt: the board is recording
990
+ // that the USER called it finished, which is not a claim about GitHub.
991
+ if (state === 'done') {
992
+ job.doneAt = new Date().toISOString();
993
+ clearAttachments(job);
994
+ }
995
+ // The note explaining why the board could not check this job's PR describes an
996
+ // attempt the user is now overriding by hand, so it must not survive the move:
997
+ // not onto a fresh To do card, where it would report a failure against work
998
+ // that has not been tried yet, and not into the archive, where it would still
999
+ // be telling the user to move the card by hand. The Review case is handled
1000
+ // below, where a successful lookup clears it too.
1001
+ if (state !== 'review') {
1002
+ clearPrCheckError(job);
1003
+ job.prClosedSeenAt = null; // a closed reading is about this stay in Review only
1004
+ }
1005
+ // A manual move means "the PR was opened outside the board". Look it up, or
1006
+ // the card sits in Review with no link to the thing it produced — nothing
1007
+ // else backfills it, since the watcher only examines in-progress jobs.
1008
+ if (state === 'review' && jobRequiresPr(job) && !job.prNumber && job.branchName) {
1009
+ // Best-effort: a lookup that fails must not abandon the move half-applied,
1010
+ // with the state changed in memory but never persisted and the agent never
1011
+ // retired. The card just goes without its link, as it did before.
1012
+ try {
1013
+ const { pr } = await findPr(job.repoPath, job.branchName);
1014
+ // Re-check before writing: findPr is a network call, and a concurrent
1015
+ // requeue during it would otherwise leave a To do card carrying PR links
1016
+ // for an attempt that no longer exists.
1017
+ if (pr && job.state === 'review' && allJobs().includes(job)) {
1018
+ job.prUrl = pr.url;
1019
+ job.prNumber = pr.number;
1020
+ job.lastError = null;
1021
+ job.lastErrorAt = null;
1022
+ // The PR is in hand, so any earlier "cannot check" note is obsolete.
1023
+ job.prCheckError = null;
1024
+ job.prCheckErrorAt = null;
1025
+ }
1026
+ } catch (err) {
1027
+ console.error(`PR lookup failed while moving "${job.title}" to review:`, err.message);
1028
+ }
1029
+ }
1030
+ // Persist and repaint before the kill so the card moves immediately; the kill
1031
+ // then emits its own session-ended and orphan notifications.
1032
+ persist(broadcast);
1033
+ if (retiringSessionId && killSession) {
1034
+ const session = sessions.get(retiringSessionId);
1035
+ if (session && !session.exited) {
1036
+ try {
1037
+ await killSession(retiringSessionId, { discardChanges });
1038
+ // Only after it succeeded: a failed kill leaves the agent running, and
1039
+ // the card must keep pointing at it rather than become unreachable.
1040
+ if (job.agentSessionId === retiringSessionId) {
1041
+ job.agentSessionId = null;
1042
+ persist(broadcast);
1043
+ }
1044
+ } catch (err) {
1045
+ console.error(`Failed to close agent for job "${job.title}":`, err.message);
1046
+ }
1047
+ } else if (job.agentSessionId === retiringSessionId) {
1048
+ // Nothing to kill — the session already went. Drop the link anyway, or
1049
+ // the card keeps a dead id, which is how a stale link outlived a restart
1050
+ // and resolved to an unrelated agent in the next process generation.
1051
+ job.agentSessionId = null;
1052
+ persist(broadcast);
1053
+ }
1054
+ }
1055
+ if (state === 'done' || state === 'todo') await releaseOrphanedWorktree(attempt, broadcast, { discardChanges });
1056
+ return { job };
1057
+ }
1058
+
1059
+ // A restart kills every agent, and a kept agent's worktree comes back as an
1060
+ // orphan record rather than a session. A card that reaches Done (or goes back
1061
+ // to To do) before anyone re-adopts it has no agent to retire, so the worktree
1062
+ // is released through that orphan instead — the same removeWorktree killSession uses, so
1063
+ // unpushed or dirty work still stays behind as the orphan.
1064
+ async function releaseOrphanedWorktree({ branchName, repoPath, worktreePath }, broadcast, { discardChanges = false } = {}) {
1065
+ if (!branchName) return false;
1066
+ // By worktree path when the card recorded one: a schedule's runs share a
1067
+ // title, so a later run can reuse an earlier one's branch name.
1068
+ const entry = [...orphans.values()].find(o => o.repoPath === repoPath
1069
+ && (worktreePath ? o.worktreePath === worktreePath : o.branchName === branchName));
1070
+ if (!entry || adoptingOrphans.has(entry.id)) return false;
1071
+ // Held for the whole removal, which is several awaited git calls: a re-adopt
1072
+ // clicked in that window would spawn an agent in a worktree about to go.
1073
+ adoptingOrphans.add(entry.id);
1074
+ let orphaned;
1075
+ try {
1076
+ ({ orphaned } = await removeWorktree(entry, { discardChanges }));
1077
+ } finally {
1078
+ adoptingOrphans.delete(entry.id);
1079
+ }
1080
+ if (orphaned) return false;
1081
+ codenamePool.recycle(entry.name);
1082
+ if (entry.worktreePath) codenamePool.recycle(basename(entry.worktreePath)); // differs after a rename
1083
+ orphans.delete(entry.id);
1084
+ syncOrphansToConfig(broadcast);
1085
+ if (broadcast) broadcast({ type: 'orphans-list', orphans: [...orphans.values()] });
1086
+ return true;
1087
+ }
1088
+
1089
+ export function updateSettings(fields, broadcast) {
1090
+ const settings = boardSettings();
1091
+ if (typeof fields.running === 'boolean') settings.running = fields.running;
1092
+ if (Number.isFinite(fields.maxPerRepo)) settings.maxPerRepo = Math.max(1, Math.min(10, Math.floor(fields.maxPerRepo)));
1093
+ if (Number.isFinite(fields.intervalMs)) settings.intervalMs = Math.max(30_000, Math.min(60 * 60_000, Math.floor(fields.intervalMs)));
1094
+ // Allowlisted: this value is interpolated into the agent's command line.
1095
+ // A valid one is also the only thing that ever sets permissionModeChosen —
1096
+ // that flag is what tells the stored mode apart from one nobody picked.
1097
+ if (isValidPermissionMode(fields.permissionMode)) {
1098
+ settings.permissionMode = fields.permissionMode;
1099
+ settings.permissionModeChosen = true;
1100
+ }
1101
+ persist(broadcast);
1102
+ return { settings };
1103
+ }
1104
+
1105
+ // --- Dispatch ---
1106
+
1107
+ function liveSessionIds() {
1108
+ const live = new Set();
1109
+ for (const [id, s] of sessions) if (!s.exited) live.add(id);
1110
+ return live;
1111
+ }
1112
+
1113
+ // Repos we can actually spawn into right now. A repo removed from the sidebar
1114
+ // (or whose directory has gone missing) leaves its jobs queued rather than
1115
+ // failing them — the path may well come back.
1116
+ function availableRepos() {
1117
+ const set = new Set();
1118
+ for (const repo of config.repos) if (existsSync(repo.path)) set.add(repo.path);
1119
+ return set;
1120
+ }
1121
+
1122
+ // `onSessionCreated` publishes the new session to clients. It is injected
1123
+ // rather than imported: ws.js already imports this module, so reaching back for
1124
+ // sessionPayload() here would close a cycle. Without it a board agent would run
1125
+ // invisibly — a PTY with no tab, which is precisely the thing the user must be
1126
+ // able to reach to answer a question.
1127
+ export async function dispatchOnce(createSession, broadcast, { onSessionCreated, killSession } = {}) {
1128
+ const settings = boardSettings();
1129
+ const candidates = selectDispatchableJobs(allJobs(), {
1130
+ maxPerRepo: settings.maxPerRepo,
1131
+ availableRepos: availableRepos(),
1132
+ liveSessionIds: liveSessionIds(),
1133
+ });
1134
+ const dispatched = [];
1135
+ for (const job of candidates) {
1136
+ const command = buildJobCommand(job, { permissionMode: boardModeFor(jobAgent(job)) });
1137
+ // Kept so the recheck below can tell whether the card still dispatches
1138
+ // into the same repo as the session it is about to be handed.
1139
+ const spawnedRepo = job.repoPath;
1140
+ // Branch named after the job, not a cocktail, so `git branch` reads like
1141
+ // the board. Two jobs can share a title, so collisions take a -2 suffix
1142
+ // rather than failing the dispatch.
1143
+ const branch = branchSlugFromTitle(job.title);
1144
+ // spawnedBy:'board' rides along on the session so the client can open the
1145
+ // tab WITHOUT stealing focus. An unattended dispatcher yanking the user out
1146
+ // of whatever they are typing every few minutes would be unusable.
1147
+ const result = await createSession(command, null, job.repoPath, branch, job.postedBy || null, {
1148
+ spawnedBy: 'board', jobId: job.id, branchSuffixOnCollision: true,
1149
+ // Billion's cards ask Billion before they ask a person (part 4).
1150
+ approvalsToBillion: job.postedByBillion === true,
1151
+ });
1152
+ if (result.error) {
1153
+ // Surface the failure on the card and leave it in To do; the next tick
1154
+ // retries. A broken repo shows a visible reason instead of a job that
1155
+ // silently never starts.
1156
+ job.lastError = result.error;
1157
+ job.lastErrorAt = new Date().toISOString();
1158
+ if (broadcast) broadcast({ type: 'notification', level: 'error', message: `Job "${job.title}" could not start: ${result.error}` });
1159
+ continue;
1160
+ }
1161
+ const session = result.session;
1162
+
1163
+ // Re-validate before claiming the job. createSession takes seconds (addRepo
1164
+ // + `git worktree add` + a PTY spawn) and WebSocket handlers run during it,
1165
+ // so the job may have been deleted, requeued or moved while we waited.
1166
+ // Writing state onto a job that is gone would leave the agent running with
1167
+ // no card pointing at it: invisible to the board, uncounted by the cap, and
1168
+ // never cleaned up. The scan guard does not cover this — it serialises
1169
+ // scans against each other, not against the user.
1170
+ //
1171
+ // The argv is rechecked on the same terms — rebuilt from the card as it
1172
+ // is now (boardSettings() re-read, so a board retuned mid-tick counts too)
1173
+ // and compared with the one actually spawned. That covers everything the
1174
+ // argv is made of at once: permission mode, which CLI, the prompt (title,
1175
+ // detail, attachments) — so a card tightened, switched or rewritten while
1176
+ // its agent spawned cannot be claimed by a process running the old text
1177
+ // under a mode neither it nor the board still says. The repo is checked
1178
+ // the same way, since it is not in the argv but is where the worktree
1179
+ // was just made. Abandoning the spawn hands it the same remedy every
1180
+ // other change in this window gets: the card stays in To do and the next
1181
+ // tick dispatches it again, as it now reads.
1182
+ // A card turned into a schedule during the spawn builds the same argv (the
1183
+ // prompt no longer depends on the type), so the type is checked outright:
1184
+ // a schedule claimed as In progress could never move again.
1185
+ const stillQueued = allJobs().includes(job) && job.state === 'todo' && !isScheduled(job)
1186
+ && buildJobCommand(job, { permissionMode: boardModeFor(jobAgent(job)) }) === command
1187
+ && job.repoPath === spawnedRepo;
1188
+ if (!stillQueued) {
1189
+ if (killSession) {
1190
+ try { await killSession(session.id); } catch (err) {
1191
+ console.error(`Failed to clean up agent for vanished job "${job.title}":`, err.message);
1192
+ }
1193
+ }
1194
+ continue;
1195
+ }
1196
+
1197
+ if (onSessionCreated) onSessionCreated(session);
1198
+ job.state = 'in-progress';
1199
+ job.agentSessionId = session.id;
1200
+ job.agentName = session.name;
1201
+ job.startedAt = new Date().toISOString();
1202
+ job.branchName = session.branchName;
1203
+ job.worktreePath = session.worktreePath;
1204
+ job.lastError = null;
1205
+ job.lastErrorAt = null;
1206
+
1207
+ dispatched.push({ job, session });
1208
+ }
1209
+ if (dispatched.length > 0 || candidates.length > 0) persist(broadcast);
1210
+ return dispatched;
1211
+ }
1212
+
1213
+ // Reconnect a re-adopted orphan to the job it was working on.
1214
+ //
1215
+ // A server restart clears every job's agentSessionId, because those sessions
1216
+ // are gone. Re-adopting the orphan brings the agent back, but nothing tied it
1217
+ // to its card: the job stayed "agent gone" while the agent was demonstrably
1218
+ // alive and working, it never counted toward the per-repo cap again (so the
1219
+ // board would dispatch a replacement alongside it), and when its PR appeared
1220
+ // the board could not retire it or release its worktree.
1221
+ //
1222
+ // The branch is the link. It is created per job, never reused while it exists,
1223
+ // and survives the restart on both the orphan record and the job — the one
1224
+ // identifier that outlives the session.
1225
+ //
1226
+ // findJobForBranch is that link on its own: the card an agent on this branch
1227
+ // belongs to, or null. relinkSessionToJob below ties the session to it, and
1228
+ // resumeCommandForOrphan reads the card's agent through the same lookup before
1229
+ // the spawn, so the two cannot disagree about which card the orphan came from.
1230
+ export function findJobForBranch({ repoPath, branchName }) {
1231
+ if (!branchName) return null;
1232
+ // in-progress OR review: a job can reach review while its link is null (the
1233
+ // PR was found after a restart, so there was no session to retire), and the
1234
+ // agent re-adopted afterwards still belongs to that card.
1235
+ const matches = allJobs().filter(j =>
1236
+ j.branchName === branchName
1237
+ && j.repoPath === repoPath
1238
+ && (j.state === 'in-progress' || j.state === 'review')
1239
+ // Never steal a job that already has a live agent. A link to a session
1240
+ // that is gone or exited is not one: an agent closed by hand or crashed
1241
+ // leaves its id behind, and re-adopting it must still reach the card.
1242
+ && (!j.agentSessionId || !sessions.get(j.agentSessionId) || sessions.get(j.agentSessionId).exited),
1243
+ );
1244
+ // Prefer work still in flight: if an old review job and a new in-progress job
1245
+ // share a branch, the agent belongs to the one that is not finished.
1246
+ return matches.find(j => j.state === 'in-progress') || matches[0] || null;
1247
+ }
1248
+
1249
+ // What re-adopting an orphan should run. The orphan record says which CLI the
1250
+ // session ran when it was orphaned by a restart or a close. One written before
1251
+ // that was noted, or discovered from a bare worktree on disk, carries no such
1252
+ // note, so its job card, matched on the branch, is the next witness; a manually
1253
+ // spawned agent has no card either, so the transcripts the CLIs leave under
1254
+ // their own homes are the last. With none of those it is Claude Code, the
1255
+ // board's own default — the same guess as before the note existed.
1256
+ //
1257
+ // When the card is known its permission mode rides along too: the resumed
1258
+ // session should run under the sandbox it was dispatched with, not the CLI's
1259
+ // default. A hand-spawned orphan has no card; its record's `permissionFlags`
1260
+ // (what it was started with) are resolved and returned as `flags`, and go on
1261
+ // the command when no card supplies a mode. The returned `agent` is what was
1262
+ // resolved (null when nothing was).
1263
+ //
1264
+ // A Codex agent's command names its session: the newest one recorded in
1265
+ // exactly its worktree (`codex resume <id>`), or Codex's picker when there is
1266
+ // none — never `--last`, which would reach a sibling worktree's session.
1267
+ //
1268
+ // `homes` is for tests, which must not probe the developer's real ~/.codex.
1269
+ export function orphanResumePlan(orphan, homes) {
1270
+ const card = findJobForBranch(orphan);
1271
+ // The note is trusted only when it is one of ours: config.json is hand-
1272
+ // editable, and a stray value would otherwise be stamped onto the new
1273
+ // session as if it had been seen.
1274
+ const known = (isValidJobAgent(orphan.agent) ? orphan.agent : null)
1275
+ || (card ? jobAgent(card) : null);
1276
+ const probed = known ? null : transcriptsFor(orphan.worktreePath, homes);
1277
+ const agent = known || probed.agent;
1278
+ // A board agent whose card is gone (done, or deleted) still belongs to the
1279
+ // board: it resumes under the board's current mode, not the CLI's default —
1280
+ // and not the mode it was dispatched with, which the board may have
1281
+ // tightened since.
1282
+ const mode = card ? dispatchPermissionMode(card, boardModeFor(jobAgent(card)))
1283
+ : orphan.origin === 'board' ? dispatchPermissionMode(null, boardModeFor(agent))
1284
+ : null;
1285
+ // The flags it was spawned with, for a hand-spawned agent with no card.
1286
+ // They belong to the CLI on the record: a note-less orphan resolved by a
1287
+ // transcript has none to pass on, and resumes under that CLI's default.
1288
+ // Never the .env default: an agent spawned since it was set recorded the
1289
+ // flags it got, and one that recorded none may have named a mode the
1290
+ // allowlist doesn't read, which a default must not replace.
1291
+ const flags = recordedPermissionFlags(orphan);
1292
+ // A Codex agent is pinned to its own worktree's session by id — the one
1293
+ // the transcript probe already found, when that is how its CLI was known.
1294
+ const sessionId = agent !== 'codex' ? null
1295
+ : probed ? probed.codexSessionId
1296
+ : codexSessionIdFor(orphan.worktreePath, homes);
1297
+ return { agent, mode, flags, command: resumeCommand(agent, mode, flags, sessionId) };
1298
+ }
1299
+
1300
+ export function resumeCommandForOrphan(orphan, homes) {
1301
+ return orphanResumePlan(orphan, homes).command;
1302
+ }
1303
+
1304
+ export function relinkSessionToJob(session, broadcast) {
1305
+ if (!session) return null;
1306
+ const job = findJobForBranch(session);
1307
+ if (!job) return null;
1308
+ job.agentSessionId = session.id;
1309
+ job.agentName = session.name;
1310
+ job.lastError = null;
1311
+ job.lastErrorAt = null;
1312
+ job.prCheckError = null;
1313
+ job.prCheckErrorAt = null;
1314
+ session.jobId = job.id; // so the PR path can retire it like any board agent
1315
+ persist(broadcast);
1316
+ return job;
1317
+ }
1318
+
1319
+ // --- PR watching ---
1320
+
1321
+ // --- gh plumbing ---
1322
+
1323
+ // One `gh` invocation. `token` picks the account; undefined means "whatever gh
1324
+ // is signed in as". GITHUB_TOKEN is dropped when we override, since it outranks
1325
+ // GH_TOKEN and would silently win.
1326
+ function runGh(args, { cwd, token, timeout = 15_000 } = {}) {
1327
+ const env = { ...process.env };
1328
+ if (token) {
1329
+ env.GH_TOKEN = token;
1330
+ delete env.GITHUB_TOKEN;
1331
+ }
1332
+ return new Promise((resolve, reject) => {
1333
+ execFile('gh', args, { cwd, env, timeout, maxBuffer: 1024 * 1024 },
1334
+ (err, out, stderr) => (err ? reject(Object.assign(err, { stderr })) : resolve(out)));
1335
+ });
1336
+ }
1337
+
1338
+ // Every account gh is signed in to, active one first.
1339
+ async function ghAccounts() {
1340
+ try {
1341
+ const parsed = JSON.parse(await runGh(['auth', 'status', '--json', 'hosts'], { timeout: 10_000 }));
1342
+ const found = [];
1343
+ for (const entries of Object.values(parsed.hosts || {})) {
1344
+ for (const entry of entries || []) {
1345
+ if (entry && entry.state === 'success' && entry.login) {
1346
+ found.push({ login: entry.login, active: !!entry.active });
1347
+ }
1348
+ }
1349
+ }
1350
+ found.sort((a, b) => Number(b.active) - Number(a.active));
1351
+ return found.map(a => a.login);
1352
+ } catch (_) {
1353
+ // Older gh has no --json on auth status; fall back to the text form.
1354
+ try {
1355
+ const text = await runGh(['auth', 'status'], { timeout: 10_000 });
1356
+ return [...text.matchAll(/account\s+(\S+)/g)].map(m => m[1]);
1357
+ } catch (_) { return []; }
1358
+ }
1359
+ }
1360
+
1361
+ async function ghToken(login) {
1362
+ try {
1363
+ const token = (await runGh(['auth', 'token', '-u', login], { timeout: 10_000 })).trim();
1364
+ return token || null;
1365
+ } catch (_) { return null; }
1366
+ }
1367
+
1368
+ // Which account last answered for a repo, so the steady state stays one gh call
1369
+ // instead of a walk. Memory only; a stale guess costs one retry.
1370
+ const ghAccountForRepo = new Map();
1371
+
1372
+ // The account list and its tokens are the same for every job in a scan, but the
1373
+ // walk is per job — so without this a scan over N jobs pays N `gh auth status`
1374
+ // plus N-per-account `gh auth token` subprocess spawns for answers that cannot
1375
+ // have changed. Short TTL rather than a per-scan cache because both sweeps and
1376
+ // the "Run now" button walk independently; a minute is long enough to collapse
1377
+ // a scan and short enough that signing in or out is picked up promptly.
1378
+ const GH_AUTH_TTL_MS = 60_000;
1379
+ const ghAuthCache = new Map(); // key -> { at, value }
1380
+
1381
+ async function ghCached(key, produce) {
1382
+ const hit = ghAuthCache.get(key);
1383
+ if (hit && Date.now() - hit.at < GH_AUTH_TTL_MS) return hit.value;
1384
+ const value = await produce();
1385
+ ghAuthCache.set(key, { at: Date.now(), value });
1386
+ return value;
1387
+ }
1388
+
1389
+ const ghAccountsCached = () => ghCached('accounts', ghAccounts);
1390
+ const ghTokenCached = (login) => ghCached(`token:${login}`, () => ghToken(login));
1391
+
1392
+ // Names the command in a failure message, so "…failed (tried 2 accounts)" says
1393
+ // what was actually run rather than leaving the reader to guess.
1394
+ const GH_PR_LIST = 'gh pr list';
1395
+
1396
+ // First line only: gh errors are one useful line plus usage noise.
1397
+ function ghErrorDetail(err) {
1398
+ return String(err.stderr || err.message || '').trim().split('\n')[0].slice(0, 200);
1399
+ }
1400
+
1401
+ async function prListOnce(repoPath, branchName, token) {
1402
+ try {
1403
+ const stdout = await runGh(openPrListArgs(branchName), { cwd: repoPath, token });
1404
+ return { pr: parsePrList(stdout) };
1405
+ } catch (err) {
1406
+ return { error: ghErrorDetail(err) || `${GH_PR_LIST} failed` };
1407
+ }
1408
+ }
1409
+
1410
+ // The merged twin of prListOnce. `--state merged` rather than reading the state
1411
+ // off the open query, because gh's default listing is open-only: a merged PR
1412
+ // simply vanishes from it, which is indistinguishable from a branch that never
1413
+ // had one.
1414
+ async function mergedPrListOnce(repoPath, branchName, token, match) {
1415
+ try {
1416
+ const stdout = await runGh(mergedPrListArgs(branchName), { cwd: repoPath, token });
1417
+ return { pr: parseMergedPr(stdout, match) };
1418
+ } catch (err) {
1419
+ return { error: ghErrorDetail(err) || `${GH_PR_LIST} failed` };
1420
+ }
1421
+ }
1422
+
1423
+ async function closedPrListOnce(repoPath, branchName, token, number) {
1424
+ try {
1425
+ const stdout = await runGh(closedPrViewArgs(number), { cwd: repoPath, token });
1426
+ return { pr: parseClosedPr(stdout, number) };
1427
+ } catch (err) {
1428
+ return { error: ghErrorDetail(err) || 'gh pr view failed' };
1429
+ }
1430
+ }
1431
+
1432
+ // Is this even a path we can ask gh about? Cheap local checks first, so a
1433
+ // removed repo or an undispatched job never shells out at all.
1434
+ async function isQueryableRepo(repoPath, branchName) {
1435
+ if (!repoPath || !branchName || !existsSync(repoPath)) return false;
1436
+ try {
1437
+ return !!(await gitExec(['-C', repoPath, 'rev-parse', '--git-dir']));
1438
+ } catch { return false; }
1439
+ }
1440
+
1441
+ // The account walk, shared by every gh lookup.
1442
+ //
1443
+ // Tries EVERY signed-in gh account before giving up. One machine can hold
1444
+ // several, and a private repo is usually visible to exactly one of them — this
1445
+ // app routinely manages repos owned by different accounts, so the signed-in
1446
+ // account failing says nothing about whether the PR exists. Observed:
1447
+ // `Could not resolve to a Repository` from the active account while another
1448
+ // account on the same machine could see the repo perfectly well.
1449
+ //
1450
+ // `query(token)` runs one lookup and returns `{ error }` to mean "this account
1451
+ // cannot see the repo" or anything else to mean success. A success ends the
1452
+ // walk and is handed back verbatim, so each lookup keeps its own return shape.
1453
+ //
1454
+ // The collaborators are injectable for the same reason createSession and
1455
+ // killSession are: otherwise the walk — ordering, dedup, remembering, error
1456
+ // aggregation — is only reachable by talking to real GitHub accounts.
1457
+ async function walkGhAccounts(repoPath, query, { listAccounts = ghAccountsCached, tokenFor = ghTokenCached, label = GH_PR_LIST } = {}) {
1458
+ // Label for a lookup that uses no explicit token — whatever gh picks itself.
1459
+ const AMBIENT = 'signed-in account';
1460
+ const tried = [];
1461
+ const failures = [];
1462
+
1463
+ const attempt = async (label, token) => {
1464
+ if (tried.includes(label)) return null;
1465
+ tried.push(label);
1466
+ const result = await query(token);
1467
+ // Detail only — the account list in the final message names who was tried,
1468
+ // and the errors are almost always identical across accounts anyway.
1469
+ if (result.error) { failures.push(result.error); return null; }
1470
+ return result;
1471
+ };
1472
+
1473
+ // 1. The account that answered for this repo last time.
1474
+ const remembered = ghAccountForRepo.get(repoPath);
1475
+ if (remembered) {
1476
+ const token = await tokenFor(remembered);
1477
+ if (token) {
1478
+ const hit = await attempt(remembered, token);
1479
+ if (hit) return { ok: true, result: hit };
1480
+ }
1481
+ }
1482
+
1483
+ // 2. Every account gh knows about, active first, each with its own token.
1484
+ //
1485
+ // Named accounts rather than "whatever gh is signed in as" so the walk is
1486
+ // unambiguous: an earlier version tried the ambient account first and then
1487
+ // skipped accounts[0] to avoid repeating it, which quietly assumed the first
1488
+ // entry was the account ambient had used. With more than one host configured
1489
+ // that assumption can skip the only account able to see the repo.
1490
+ const accounts = await listAccounts();
1491
+ for (const login of accounts) {
1492
+ const token = await tokenFor(login);
1493
+ if (!token) continue;
1494
+ const hit = await attempt(login, token);
1495
+ if (hit) { ghAccountForRepo.set(repoPath, login); return { ok: true, result: hit }; }
1496
+ }
1497
+
1498
+ // 3. Last resort: no explicit token. Covers a token supplied through the
1499
+ // environment, an enterprise host, and older gh versions whose auth status
1500
+ // this cannot enumerate.
1501
+ const ambient = await attempt(AMBIENT, undefined);
1502
+ if (ambient) { ghAccountForRepo.delete(repoPath); return { ok: true, result: ambient }; }
1503
+
1504
+ ghAccountForRepo.delete(repoPath);
1505
+ const plural = tried.length === 1 ? '' : 's';
1506
+ return {
1507
+ ok: false,
1508
+ error: `${failures[0] || `${label} failed`} (tried ${tried.length} account${plural}: ${tried.join(', ')})`,
1509
+ };
1510
+ }
1511
+
1512
+ // `gh pr list` against the repo, filtered to the job's branch. Runs in the main
1513
+ // repo (not the worktree) so it still works after the worktree is removed.
1514
+ //
1515
+ // Returns { pr } on a successful query — pr null meaning "no PR yet" — or
1516
+ // { pr: null, error } only when EVERY account failed. An earlier version
1517
+ // collapsed both into null, so a repo the board could never see looked exactly
1518
+ // like a branch with no PR, and its jobs sat in progress forever unexplained.
1519
+ // Guard, walk, unwrap — identical for both branch lookups; only the query
1520
+ // differs. They stay two named exports rather than one function with a flag so
1521
+ // each call site says which question it is asking.
1522
+ async function branchPrLookup(repoPath, branchName, query, { listAccounts, tokenFor, label }) {
1523
+ if (!(await isQueryableRepo(repoPath, branchName))) return { pr: null };
1524
+ const walked = await walkGhAccounts(repoPath, query, { listAccounts, tokenFor, label });
1525
+ return walked.ok ? walked.result : { pr: null, error: walked.error };
1526
+ }
1527
+
1528
+ export async function findPrForBranch(repoPath, branchName, {
1529
+ listAccounts = ghAccountsCached,
1530
+ tokenFor = ghTokenCached,
1531
+ prList = prListOnce,
1532
+ } = {}) {
1533
+ return branchPrLookup(repoPath, branchName, token => prList(repoPath, branchName, token),
1534
+ { listAccounts, tokenFor, label: GH_PR_LIST });
1535
+ }
1536
+
1537
+ // The same lookup, asking instead whether the branch's PR has MERGED. Same
1538
+ // account walk, same error contract — { pr: null } means "not merged (yet)",
1539
+ // and an error means nobody could see the repo.
1540
+ //
1541
+ // `prNumber` and `mergedAfter` are what make the answer about THIS card rather
1542
+ // than about the branch name — see parseMergedPr for why the distinction is not
1543
+ // academic. They are passed on to the parser, not to gh: `gh pr list` has no
1544
+ // way to ask "the merge of PR #7", only "merges on this head ref".
1545
+ export async function findMergedPrForBranch(repoPath, branchName, {
1546
+ listAccounts = ghAccountsCached,
1547
+ tokenFor = ghTokenCached,
1548
+ prList = mergedPrListOnce,
1549
+ prNumber = null,
1550
+ mergedAfter = null,
1551
+ } = {}) {
1552
+ const match = { number: prNumber, mergedAfter };
1553
+ return branchPrLookup(repoPath, branchName, token => prList(repoPath, branchName, token, match),
1554
+ { listAccounts, tokenFor, label: `${GH_PR_LIST} --state merged` });
1555
+ }
1556
+
1557
+ // The card's PR of record, if it was closed without merging.
1558
+ export async function findClosedPrForBranch(repoPath, branchName, {
1559
+ listAccounts = ghAccountsCached,
1560
+ tokenFor = ghTokenCached,
1561
+ prList = closedPrListOnce,
1562
+ prNumber = null,
1563
+ } = {}) {
1564
+ return branchPrLookup(repoPath, branchName, token => prList(repoPath, branchName, token, prNumber),
1565
+ { listAccounts, tokenFor, label: 'gh pr view' });
1566
+ }
1567
+
1568
+ // Close the agent that delivered a job, resolving it by branch when the stored
1569
+ // link is gone. killSession -> removeWorktree deletes the worktree and the local
1570
+ // branch (fully pushed by then); the PR is untouched. The card keeps the whole
1571
+ // record — agent name, branch, PR link — so nothing is lost by the terminal
1572
+ // going away, and the work itself is on the remote.
1573
+ //
1574
+ // Resolved by branch because a restart nulls every agentSessionId, and an agent
1575
+ // re-adopted afterwards is found by its branch: created per job, not reused
1576
+ // while it exists, durable across restarts.
1577
+ //
1578
+ // Called when the merge sweep files a card as Done. The PR poll and finish_job
1579
+ // keep the agent — Review is finished work with its agent still on hand — and
1580
+ // manual moves retire it in moveJob, by the linked session.
1581
+ //
1582
+ // Returns whether it actually closed something. A failure is logged and
1583
+ // swallowed: the work has shipped either way, so a cleanup that did not work
1584
+ // must not strand the card.
1585
+ // byBranch: false restricts it to the linked session. A Review card's agent is
1586
+ // always linked (a re-adopt relinks it), so an unlinked session on that branch
1587
+ // is one someone opened by hand, and a merge is no reason to close it.
1588
+ async function retireAgentForJob(job, askedBranch, askedSessionId, killSession, { byBranch = true } = {}) {
1589
+ if (!killSession) return false;
1590
+ const linked = askedSessionId && askedSessionId === job.agentSessionId
1591
+ ? sessions.get(askedSessionId)
1592
+ : null;
1593
+ const session = linked || byBranch && [...sessions.values()].find(candidate =>
1594
+ candidate && !candidate.exited
1595
+ && candidate.branchName === askedBranch
1596
+ && candidate.repoPath === job.repoPath,
1597
+ );
1598
+ if (!session || session.exited) return false;
1599
+ try {
1600
+ if (!job.agentName) job.agentName = session.name;
1601
+ await killSession(session.id);
1602
+ job.agentSessionId = null; // only after the kill actually succeeded
1603
+ return true;
1604
+ } catch (err) {
1605
+ console.error(`Failed to close agent for job "${job.title}":`, err.message);
1606
+ return false;
1607
+ }
1608
+ }
1609
+
1610
+ // `findPr` is injected for the same reason createSession and killSession are:
1611
+ // an internal call to findPrForBranch is bound directly by the module system
1612
+ // and cannot be substituted from outside, so the PR-to-review transition would
1613
+ // otherwise only be testable by talking to GitHub.
1614
+ //
1615
+ // The fallback for an agent that opened its PR but never called finish_job:
1616
+ // the same move, found by polling. Cards that require no PR are skipped — their
1617
+ // agent reports its own finish, and a PR on one proves nothing about that.
1618
+ // The agent is kept, as finish_job keeps it; Done is what retires it.
1619
+ export async function checkPullRequests(broadcast, { findPr = findPrForBranch } = {}) {
1620
+ const inProgress = allJobs().filter(j => j.state === 'in-progress' && j.branchName && jobRequiresPr(j));
1621
+ if (inProgress.length === 0) return [];
1622
+ const moved = [];
1623
+ let noted = false; // a PR-check failure was recorded on some card
1624
+ for (const job of inProgress) {
1625
+ // Capture what we are asking about: findPr is a network call, and a
1626
+ // requeue or delete during it would otherwise let us apply a PR result to
1627
+ // a job that has since moved on — silently undoing the user's action, or
1628
+ // killing an agent that belongs to a different attempt.
1629
+ const askedBranch = job.branchName;
1630
+ const { pr, error } = await findPr(job.repoPath, askedBranch);
1631
+
1632
+ // Re-validate before touching the job at all. findPr is a network call and
1633
+ // WebSocket handlers run during it, so the job may have been deleted,
1634
+ // requeued or moved while we waited — writing either a result OR an error
1635
+ // onto it then lands on a job that has moved on, or on a detached object.
1636
+ if (!allJobs().includes(job) || job.state !== 'in-progress' || job.branchName !== askedBranch) continue;
1637
+
1638
+ if (error) {
1639
+ // Say so on the card. Without this the job looks like an agent that went
1640
+ // quiet, and the user has no way to learn the board simply cannot see
1641
+ // this repo's pull requests. Only written when the message changes, so a
1642
+ // persistent failure does not rewrite config.json every five minutes.
1643
+ // Its own field, not lastError. A job can already be carrying a restart
1644
+ // note naming where its work is, and the two are both true at once: the
1645
+ // agent is gone AND the board cannot see this repo's pull requests.
1646
+ // Sharing one field meant either clobbering that note or suppressing this
1647
+ // one. Written only when the text changes, so a permanent failure does
1648
+ // not rewrite config.json every five minutes.
1649
+ const message = `Cannot check for a pull request here — ${error}. This job stays put until you move it by hand.`;
1650
+ if (notePrCheckError(job, message)) noted = true;
1651
+ continue;
1652
+ }
1653
+
1654
+ // The query worked, so a previous "cannot check" note is wrong — clear it
1655
+ // even when there is still no PR, or a repo that regained access would keep
1656
+ // claiming it was unreachable until a PR happened to appear.
1657
+ if (clearPrCheckError(job)) noted = true;
1658
+
1659
+ if (!pr) continue;
1660
+ job.state = 'review';
1661
+ job.prUrl = pr.url;
1662
+ job.prNumber = pr.number;
1663
+ job.prClosedSeenAt = null;
1664
+ job.reviewAt = new Date().toISOString();
1665
+ // The restart note says the board is still watching for this PR. It just
1666
+ // found it, so the note is now false on its own card.
1667
+ job.lastError = null;
1668
+ job.lastErrorAt = null;
1669
+ moved.push(job);
1670
+ if (broadcast) {
1671
+ broadcast({ type: 'notification', level: 'info', message: `Job "${job.title}" moved to Review — PR #${pr.number}` });
1672
+ }
1673
+ notifyBillion(job);
1674
+ }
1675
+ if (moved.length > 0 || noted) persist(broadcast);
1676
+ return moved;
1677
+ }
1678
+
1679
+ // -> Done, once the PR has merged.
1680
+ //
1681
+ // A merged PR is finished work. Leaving its card in Review means the column
1682
+ // slowly fills with things nobody has to look at again, and stops meaning
1683
+ // "needs your review" — so the card leaves the board while the job itself is
1684
+ // kept, reachable through Finished jobs.
1685
+ //
1686
+ // Covers In progress as well as Review, because a PR can open and merge inside
1687
+ // one scan interval and `gh pr list --state open` cannot see it afterwards: the
1688
+ // open query returns nothing, so checkPullRequests leaves the job in progress,
1689
+ // and a Review-only sweep would never look at it. That card would sit in In
1690
+ // progress forever reading "agent gone", holding a slot on the Jobs tab badge,
1691
+ // for work that had actually shipped — the exact case this whole change exists
1692
+ // to file away.
1693
+ //
1694
+ // Deliberately narrower than checkPullRequests in three ways:
1695
+ //
1696
+ // - Only THIS card's PR counts (see parseMergedPr). Merged files the card as
1697
+ // merged. Closed without merging files it too, marked prClosedAt: closing a
1698
+ // PR is already someone deciding, and a card left in Review would hold a
1699
+ // live agent and worktree for work nobody is going to land (and, on a
1700
+ // schedule's run, hold the schedule off). Unpushed work stays an orphan.
1701
+ // - Only cards that require a PR. One that does not has no PR of its own to
1702
+ // watch, and a branch name matching some merge proves nothing about it.
1703
+ //
1704
+ // Reaching Done retires the agent and releases its worktree from either
1705
+ // column, the same as a manual move to Done: merged means finished.
1706
+ // How long a closed reading must hold before the card is filed (see the
1707
+ // closed path in checkMergedPullRequests).
1708
+ export const CLOSED_CONFIRM_MS = 60_000;
1709
+
1710
+ export async function checkMergedPullRequests(broadcast, { killSession, findMerged = findMergedPrForBranch, findClosed = findClosedPrForBranch, findPr = findPrForBranch } = {}) {
1711
+ // prMergedAt is only ever written alongside state 'done', and done is
1712
+ // terminal, so the state filter already excludes every stamped job. Kept as a
1713
+ // cheap assertion of that invariant rather than a live condition.
1714
+ // A schedule has no branch, so it never matches. A run whose PR merges goes
1715
+ // to Done here like any card, which is what lets its schedule fire again.
1716
+ // Before the early return, so a card with nothing left to merge still gets
1717
+ // its straggler files freed.
1718
+ const recleared = clearFinishedAttachments();
1719
+ const candidates = allJobs().filter(j =>
1720
+ (j.state === 'review' || j.state === 'in-progress') && j.branchName && !j.prMergedAt && jobRequiresPr(j));
1721
+ if (candidates.length === 0) {
1722
+ if (recleared) persist(broadcast);
1723
+ return [];
1724
+ }
1725
+ const finished = [];
1726
+ let noted = false; // a PR-check failure was recorded on some card
1727
+ for (const job of candidates) {
1728
+ // Captured for the same reason checkPullRequests captures them: findMerged
1729
+ // is a network call, and a move or delete during it would otherwise let us
1730
+ // apply the answer to a job that has since moved on.
1731
+ const askedBranch = job.branchName;
1732
+ const askedState = job.state;
1733
+ // What makes the answer about this card and not about the branch name: its
1734
+ // PR of record when it has one, otherwise the earliest merge that could be
1735
+ // its work. Board branch names outlive their branches and get reused.
1736
+ const { pr, error } = await findMerged(job.repoPath, askedBranch, {
1737
+ prNumber: job.prNumber ?? null,
1738
+ mergedAfter: job.reviewAt || job.startedAt || null,
1739
+ });
1740
+
1741
+ if (!allJobs().includes(job) || job.state !== askedState || job.branchName !== askedBranch) continue;
1742
+
1743
+ if (error) {
1744
+ // Same field as the in-progress check, and for the same reason: the user
1745
+ // needs to know the board cannot see this repo's pull requests rather
1746
+ // than assuming nothing has merged.
1747
+ // Only reported for a Review card. An in-progress job is already covered
1748
+ // by checkPullRequests' own note about the same repo, and writing a
1749
+ // second one over it would just churn the field between the two sweeps.
1750
+ if (askedState === 'review') {
1751
+ const message = `Cannot check whether this pull request merged — ${error}. This job stays in Review until you move it by hand.`;
1752
+ if (notePrCheckError(job, message)) noted = true;
1753
+ }
1754
+ continue;
1755
+ }
1756
+
1757
+ // A Review card with a PR still has the closed-PR lookups ahead; its note
1758
+ // is cleared only once those succeed too, or a lasting failure there would
1759
+ // clear and rewrite it on every scan.
1760
+ const closedLookupAhead = !pr && askedState === 'review' && job.prNumber != null;
1761
+ if (askedState === 'review' && !closedLookupAhead && clearPrCheckError(job)) noted = true;
1762
+
1763
+ if (!pr) {
1764
+ if (askedState === 'review' && job.prNumber != null) {
1765
+ const prNumber = job.prNumber;
1766
+ const still = () => allJobs().includes(job) && job.state === 'review'
1767
+ && job.prNumber === prNumber && job.branchName === askedBranch;
1768
+ const { pr: closedPr, error: closedError } = await findClosed(job.repoPath, askedBranch, { prNumber });
1769
+ if (!still()) continue;
1770
+ if (closedError) {
1771
+ if (notePrCheckError(job, `Cannot check whether this pull request was closed — ${closedError}. This job stays in Review until you move it by hand.`)) noted = true;
1772
+ continue;
1773
+ }
1774
+ if (!closedPr) {
1775
+ if (clearPrCheckError(job)) noted = true;
1776
+ if (job.prClosedSeenAt) { job.prClosedSeenAt = null; noted = true; }
1777
+ continue;
1778
+ }
1779
+ // Closed and replaced: an agent asked to rework often closes its PR
1780
+ // and opens another on the same branch. That new PR is the card's now.
1781
+ // Any open PR means "not closed": the same number reopened in between.
1782
+ // A failed lookup decides nothing — the replacement may be there.
1783
+ const { pr: openPr, error: openError } = await findPr(job.repoPath, askedBranch);
1784
+ if (!still()) continue;
1785
+ if (openError) {
1786
+ if (notePrCheckError(job, `Cannot check for a replacement pull request — ${openError}. This job stays in Review until you move it by hand.`)) noted = true;
1787
+ continue;
1788
+ }
1789
+ if (clearPrCheckError(job)) noted = true;
1790
+ if (openPr) {
1791
+ if (openPr.number !== prNumber) {
1792
+ job.prNumber = openPr.number;
1793
+ job.prUrl = openPr.url;
1794
+ }
1795
+ if (job.prClosedSeenAt || openPr.number !== prNumber) { job.prClosedSeenAt = null; noted = true; }
1796
+ continue;
1797
+ }
1798
+ // Two readings at least CLOSED_CONFIRM_MS apart before acting: closing
1799
+ // and reopening a PR (to re-run CI, to retarget it) must not file the
1800
+ // card away, and "Run now" right after a tick must not count as two.
1801
+ if (!job.prClosedSeenAt) { job.prClosedSeenAt = new Date().toISOString(); noted = true; continue; }
1802
+ if (Date.now() - Date.parse(job.prClosedSeenAt) < CLOSED_CONFIRM_MS) continue;
1803
+ // Someone may be talking to its agent about the rework; wait until it
1804
+ // is quiet, as superseding does.
1805
+ const live = job.agentSessionId ? sessions.get(job.agentSessionId) : null;
1806
+ if (live && !live.exited && live.state !== 'WAITING') continue;
1807
+ job.state = 'done';
1808
+ job.prClosedAt = new Date().toISOString();
1809
+ job.doneAt = job.prClosedAt;
1810
+ finished.push(job);
1811
+ // Not discarded: a closed PR's work may still be wanted, so anything
1812
+ // unpushed or dirty stays behind as an orphan.
1813
+ const closedAgent = await retireAgentForJob(job, askedBranch, job.agentSessionId, killSession, { byBranch: false });
1814
+ if (!closedAgent) await releaseOrphanedWorktree(job, broadcast);
1815
+ if (!attachmentsInUse(job)) clearAttachments(job);
1816
+ if (broadcast) {
1817
+ broadcast({
1818
+ type: 'notification', level: 'info',
1819
+ message: `Job "${job.title}" is done — PR #${prNumber} was closed without merging. It moved to Finished jobs.`
1820
+ + (job.scheduleId ? ' Its schedule can run again.' : ''),
1821
+ });
1822
+ }
1823
+ }
1824
+ continue;
1825
+ }
1826
+ job.state = 'done';
1827
+ job.prMergedAt = pr.mergedAt || new Date().toISOString();
1828
+ job.doneAt = new Date().toISOString();
1829
+ // The merged PR is the authority on where the work ended up: a card that
1830
+ // never reached Review has no URL yet, and this is the moment one exists.
1831
+ if (pr.url) job.prUrl = pr.url;
1832
+ if (pr.number != null) job.prNumber = pr.number;
1833
+ if (!job.reviewAt) job.reviewAt = job.doneAt;
1834
+ finished.push(job);
1835
+
1836
+ // The link as it is now, not as it was asked about: an agent re-adopted
1837
+ // during the lookup is the one to retire, or it outlives its card.
1838
+ const closed = await retireAgentForJob(job, askedBranch, job.agentSessionId, killSession,
1839
+ { byBranch: askedState === 'in-progress' });
1840
+ const released = !closed && await releaseOrphanedWorktree(job, broadcast);
1841
+ // After the retire, so a just-killed agent no longer counts as in use. A
1842
+ // kill that failed leaves the files held; the retry pass at the top of
1843
+ // this sweep frees them once that session exits.
1844
+ if (!attachmentsInUse(job)) clearAttachments(job);
1845
+ if (broadcast) {
1846
+ broadcast({
1847
+ type: 'notification', level: 'info',
1848
+ message: `Job "${job.title}" is done — PR #${job.prNumber} merged. It moved to Finished jobs.`
1849
+ + (closed ? ` · ${job.agentName} closed, worktree released` : '')
1850
+ + (released ? ' · its orphaned worktree was released' : ''),
1851
+ });
1852
+ }
1853
+ }
1854
+ if (finished.length > 0 || noted || recleared) persist(broadcast);
1855
+ return finished;
1856
+ }
1857
+
1858
+ // --- Schedules ---
1859
+
1860
+ // Post a run for every schedule that has come due, or note why it held off
1861
+ // (see scheduleHold). Either way the schedule moves on to its next time, so a
1862
+ // firing it skipped is not replayed later — the same rule a pause follows.
1863
+ //
1864
+ // Runs FIRST in the dispatch half of the scan, so a run it posts goes out on
1865
+ // this same scan rather than waiting a whole interval in To do.
1866
+ export function fireSchedules(broadcast, { now = Date.now() } = {}) {
1867
+ const fired = [];
1868
+ let changed = false;
1869
+ for (const schedule of allJobs().filter(j => isScheduled(j) && j.state === 'todo')) {
1870
+ if (!isJobDue(schedule, now)) continue;
1871
+ changed = true;
1872
+ schedule.nextRunAt = schedule.schedule ? nextCronIso(schedule.schedule, now) : null;
1873
+ const hold = scheduleHold(schedule, allJobs());
1874
+ if (hold) {
1875
+ schedule.lastSkipAt = new Date(now).toISOString();
1876
+ schedule.lastSkipReason = hold;
1877
+ continue;
1878
+ }
1879
+ const result = createRunJob(schedule);
1880
+ if (result.error) {
1881
+ schedule.lastError = `Could not post this run: ${result.error}`;
1882
+ schedule.lastErrorAt = new Date(now).toISOString();
1883
+ continue;
1884
+ }
1885
+ // The run gets its own copy of the schedule's files, so neither card's
1886
+ // cleanup — a run filed to Done, the schedule deleted or edited — can take
1887
+ // a file out from under the other.
1888
+ const copied = copyRunAttachments(result.job, schedule);
1889
+ if (copied.error) {
1890
+ schedule.lastError = `Could not post this run: ${copied.error}`;
1891
+ schedule.lastErrorAt = new Date(now).toISOString();
1892
+ continue;
1893
+ }
1894
+ allJobs().push(result.job);
1895
+ schedule.runCount = (Number(schedule.runCount) || 0) + 1;
1896
+ schedule.lastRunAt = new Date(now).toISOString();
1897
+ schedule.lastRunJobId = result.job.id;
1898
+ schedule.lastSkipAt = null;
1899
+ schedule.lastSkipReason = null;
1900
+ schedule.lastError = null;
1901
+ schedule.lastErrorAt = null;
1902
+ fired.push(result.job);
1903
+ }
1904
+ if (changed) persist(broadcast);
1905
+ return fired;
1906
+ }
1907
+
1908
+ function copyRunAttachments(run, schedule) {
1909
+ const files = (schedule.attachments || []).filter(a => a && a.path && insideAttachments(a.path) && existsSync(a.path));
1910
+ if (!files.length) { run.attachments = []; return {}; }
1911
+ const dir = attachmentDir(run.id);
1912
+ try {
1913
+ mkdirSync(dir, { recursive: true });
1914
+ run.attachments = files.map(a => {
1915
+ const path = join(dir, safeFilename(a.name));
1916
+ copyFileSync(a.path, path);
1917
+ return { name: basename(path), path };
1918
+ });
1919
+ return {};
1920
+ } catch (err) {
1921
+ try { rmSync(dir, { recursive: true, force: true }); } catch { /* the error below is what matters */ }
1922
+ return { error: `copying its files failed: ${err.message}` };
1923
+ }
1924
+ }
1925
+
1926
+ // Keep each schedule's finished runs to the newest MAX_FINISHED_RUNS (see
1927
+ // runsToPrune), attachment folders included. One pass and one save, however
1928
+ // many go: deleteJob per run would rewrite config.json and rebroadcast the
1929
+ // board once for each. A run whose agent is still alive (a kill at Done that
1930
+ // failed) is left for a later scan, so no agent loses the card pointing at it.
1931
+ export function pruneFinishedRuns(broadcast) {
1932
+ const pruned = runsToPrune(allJobs()).filter(run => !attachmentsInUse(run));
1933
+ if (!pruned.length) return [];
1934
+ const gone = new Set(pruned.map(run => run.id));
1935
+ config.jobs = allJobs().filter(job => !gone.has(job.id));
1936
+ persist(broadcast);
1937
+ for (const run of pruned) {
1938
+ const dir = attachmentDir(run.id);
1939
+ if (!insideAttachments(dir)) continue;
1940
+ try { rmSync(dir, { recursive: true, force: true }); } catch (err) {
1941
+ console.error(`Failed to remove attachments for pruned run "${run.title}":`, err.message);
1942
+ }
1943
+ }
1944
+ return pruned;
1945
+ }
1946
+
1947
+ // File away every no-PR run that a newer run of the same schedule has
1948
+ // replaced in Review, and retire its agent — through moveJob, so it is the
1949
+ // same Done as a person pressing the button. The newest run records how many
1950
+ // it replaced, so a board nobody read for a day says so.
1951
+ export async function supersedeRuns(broadcast, { killSession } = {}) {
1952
+ const replaced = [];
1953
+ for (const { old, by } of supersededRuns(allJobs())) {
1954
+ // Rechecked per run: each move awaits a kill and git, and a person can
1955
+ // requeue or delete either card in the meantime.
1956
+ if (!allJobs().includes(old) || !allJobs().includes(by) || old.state !== 'review' || by.state !== 'review') continue;
1957
+ // An agent working a follow-up or asking something is someone's live
1958
+ // conversation; it is superseded on a later scan, once it is quiet.
1959
+ const session = old.agentSessionId ? sessions.get(old.agentSessionId) : null;
1960
+ if (session && !session.exited && session.state !== 'WAITING') continue;
1961
+ // Its result is the summary it reported, so what it left in the worktree is
1962
+ // scratch; an hourly schedule would otherwise orphan a worktree a run.
1963
+ const result = await moveJob(old.id, 'done', broadcast, { killSession, discardChanges: true });
1964
+ if (result.error) continue;
1965
+ old.supersededBy = by.id;
1966
+ by.supersededRuns = (Number(by.supersededRuns) || 0) + 1 + (Number(old.supersededRuns) || 0);
1967
+ replaced.push(old);
1968
+ }
1969
+ if (replaced.length) {
1970
+ persist(broadcast);
1971
+ if (broadcast) {
1972
+ broadcast({
1973
+ type: 'notification', level: 'info',
1974
+ message: `Filed ${replaced.length} earlier run${replaced.length === 1 ? '' : 's'} to Finished — a newer run is in Review`,
1975
+ });
1976
+ }
1977
+ }
1978
+ return replaced;
1979
+ }
1980
+
1981
+ // --- Scan ---
1982
+
1983
+ // One scan = find PRs, file away superseded and merged runs, fire due
1984
+ // schedules, then dispatch.
1985
+ // Both entry points (the interval timer and the "Run now" button) go through
1986
+ // here, behind a single in-flight flag.
1987
+ //
1988
+ // The guard is not decorative. dispatchOnce reads job.state to pick candidates,
1989
+ // then awaits createSession, and only sets state = 'in-progress' after that
1990
+ // await returns. Two overlapping scans therefore both see the same job as
1991
+ // 'todo' and both dispatch it: two agents, two worktrees, two branches for one
1992
+ // job, with job.agentSessionId keeping only the last — the other agent is
1993
+ // orphaned, invisible to the board, and never cleaned up. It also lets the
1994
+ // per-repo cap be exceeded, since both scans read the same in-flight count.
1995
+ // The window is wide (createSession does addRepo + `git worktree add` + a PTY
1996
+ // spawn) and trivially hit by clicking Run now while a tick is in flight.
1997
+ let scanInFlight = false;
1998
+
1999
+ // findPr/findMerged are forwarded rather than left to their defaults so the
2000
+ // order of the scan — PRs found, then merges swept, then dispatch — is
2001
+ // reachable from a test without talking to GitHub.
2002
+ export async function runScan(createSession, broadcast, { onSessionCreated, killSession, findPr, findMerged, findClosed } = {}) {
2003
+ if (scanInFlight) return { skipped: true };
2004
+ scanInFlight = true;
2005
+ try {
2006
+ await checkPullRequests(broadcast, { findPr });
2007
+ await supersedeRuns(broadcast, { killSession });
2008
+ await checkMergedPullRequests(broadcast, { killSession, findMerged, findClosed, findPr });
2009
+ pruneFinishedRuns(broadcast);
2010
+ fireSchedules(broadcast);
2011
+ await dispatchOnce(createSession, broadcast, { onSessionCreated, killSession });
2012
+ return { skipped: false };
2013
+ } finally {
2014
+ scanInFlight = false;
2015
+ }
2016
+ }
2017
+
2018
+ // --- Loop ---
2019
+
2020
+ let dispatchTimer = null;
2021
+
2022
+ // Self-rescheduling rather than setInterval so a slow git/gh pass can never
2023
+ // overlap the next tick (same reasoning as startTreeScanLoop in git.js).
2024
+ export function startDispatcher(createSession, broadcast, { onSessionCreated, killSession } = {}) {
2025
+ stopDispatcher();
2026
+ const tick = async () => {
2027
+ try {
2028
+ if (boardSettings().running) {
2029
+ await runScan(createSession, broadcast, { onSessionCreated, killSession });
2030
+ }
2031
+ } catch (err) {
2032
+ console.error('Job dispatcher tick failed:', err.message);
2033
+ }
2034
+ dispatchTimer = setTimeout(tick, boardSettings().intervalMs);
2035
+ };
2036
+ // First tick soon after start so pressing Start feels responsive, rather than
2037
+ // appearing to do nothing until the first full interval elapses.
2038
+ dispatchTimer = setTimeout(tick, 2000);
2039
+ }
2040
+
2041
+ export function stopDispatcher() {
2042
+ clearTimeout(dispatchTimer);
2043
+ dispatchTimer = null;
2044
+ }