@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/lib/jobs.js ADDED
@@ -0,0 +1,965 @@
1
+ // Pure job-board logic — schema, dispatch selection, PR parsing, status derivation.
2
+ // Kept free of I/O and shared state so it is testable in isolation; the stateful
3
+ // dispatcher (timers, spawning, git) lives in server/jobs.js.
4
+
5
+ import { randomBytes } from 'crypto';
6
+ import { basename, dirname } from 'path';
7
+ import { nextCronIso, parseCron } from './cron.js';
8
+ import { parseCommand } from './helpers.js';
9
+
10
+ // Every state a job can be in. A job's *state* is workflow position only —
11
+ // "the agent is stuck waiting for you" is deliberately NOT a state here (see
12
+ // deriveJobStatus): it is a live property of the agent, not a place the card
13
+ // moves to.
14
+ //
15
+ // `done` is the one state with no column. A job whose PR has merged is finished
16
+ // work, and leaving its card on the board means the Review column slowly fills
17
+ // with things nobody has to look at again — the column stops meaning "needs
18
+ // your review". The job itself is kept, not deleted: it is the record of what
19
+ // an agent did and where the PR is, reachable through the Finished jobs view.
20
+ export const JOB_STATES = ['todo', 'in-progress', 'review', 'done'];
21
+
22
+ // What each state is called when it is written out rather than drawn. The
23
+ // board's own columns carry these labels in public/modules/jobs.js, which the
24
+ // browser cannot import from here (only public/ is served), so a test keeps
25
+ // the two in step. `done` has no column — it is the Finished jobs archive.
26
+ export const STATE_LABELS = {
27
+ 'todo': 'To do',
28
+ 'in-progress': 'In progress',
29
+ 'review': 'Review',
30
+ 'done': 'Finished',
31
+ };
32
+
33
+ // What kind of work a card is.
34
+ //
35
+ // one-time dispatched once; its agent reports back with finish_job (or the
36
+ // board spots its PR), it waits in Review, and leaves at Done.
37
+ // scheduled a SCHEDULE, not a job: it stays in To do and is never
38
+ // dispatched itself. Each time it comes due it posts a one-time
39
+ // run card (scheduleId -> the schedule), and that run goes through
40
+ // the one-time lifecycle like any other card. See canFire for
41
+ // when a due schedule holds off instead.
42
+ //
43
+ // Cards written before this existed have no `type` at all, so every read goes
44
+ // through jobType() rather than touching job.type directly: a missing type is
45
+ // one-time, which is what those cards have always been.
46
+ export const JOB_TYPES = ['one-time', 'scheduled'];
47
+ export const DEFAULT_JOB_TYPE = 'one-time';
48
+
49
+ export function jobType(job) {
50
+ return job && job.type === 'scheduled' ? 'scheduled' : DEFAULT_JOB_TYPE;
51
+ }
52
+
53
+ export function jobAgent(job) {
54
+ return isValidJobAgent(job && job.agent) ? job.agent : DEFAULT_JOB_AGENT;
55
+ }
56
+
57
+ export function isScheduled(job) {
58
+ return jobType(job) === 'scheduled';
59
+ }
60
+
61
+ // Whether a one-time card's work ends in a pull request. Not every task is a
62
+ // code change — research, an investigation, an ops chore — and a card that
63
+ // demanded a PR of those pushed its agent into inventing one. Cards written
64
+ // before this existed have no field, and they were all written to produce a
65
+ // PR, so a missing field reads as true.
66
+ //
67
+ // On a schedule it is what its runs get, and there the default is false: a
68
+ // recurring job was never expected to open a PR (a report, a check), so it has
69
+ // to be asked for.
70
+ export function jobRequiresPr(job) {
71
+ return isScheduled(job) ? job?.requiresPr === true : job?.requiresPr !== false;
72
+ }
73
+
74
+ // What a card gets when nobody said: see jobRequiresPr.
75
+ export function defaultRequiresPr(type) {
76
+ return type !== 'scheduled';
77
+ }
78
+
79
+ // Defaults for the dispatcher. Exported so the UI can show them and tests can
80
+ // override without touching module state.
81
+ export const DISPATCH_INTERVAL_MS = 5 * 60 * 1000; // scan cadence
82
+ export const MAX_AGENTS_PER_REPO = 2; // concurrent board agents
83
+ export const STALLED_AFTER_MS = 3 * 60 * 1000; // quiet WAITING -> "stalled"
84
+
85
+ // Board agents run in auto mode. The classifier that mode brings is the only
86
+ // thing that reviews a dispatched agent's actions before they run, and that
87
+ // matters more here than anywhere else in the app: a board agent's prompt is a
88
+ // job card's detail text plus whatever it reads out of the repo, none of which
89
+ // is necessarily trustworthy. Under `bypassPermissions` nothing reviews
90
+ // anything — the agent runs Bash and edits files unprompted, as the user (see
91
+ // the board-credential section of DESIGN.md).
92
+ //
93
+ // Auto mode is not available everywhere, which is why this is a default rather
94
+ // than the only setting. It needs a supported model and an organisation that
95
+ // has not turned it off, so on an Amazon Bedrock or Vertex account running an
96
+ // unsupported model, or behind `permissions.disableAutoMode`, it is missing —
97
+ // and Claude Code does NOT error or exit in that case: "When the flag, a
98
+ // settings file, or the built-in default selects auto but auto mode isn't
99
+ // available to the session, Claude Code starts the session in Manual instead"
100
+ // (docs/en/permission-modes). The agent spawns fine and works until it needs a
101
+ // permission, then waits for a human who is not there. Every job.
102
+ //
103
+ // That failure is a stall, not a crash: `deriveJobStatus` reports the card as
104
+ // `needs-input`, then `stalled` once the quiet window passes, so the board is
105
+ // not blind to it. Because it is visible AND there is now a lever — a board
106
+ // permission mode in the toolbar, and a per-card override on the form — `auto`
107
+ // is the right default again. A machine without the classifier sets the board
108
+ // setting once; a card that needs more says so on itself.
109
+ //
110
+ // Two caveats, so neither this comment nor the release notes oversell it.
111
+ // Choosing `bypassPermissions` does not make dispatch hands-off: Claude Code
112
+ // shows its workspace-trust dialog the first time it runs in any directory,
113
+ // every board agent gets a brand-new worktree, and no permission mode skips it
114
+ // (verified against 2.1.250 — see the trust pre-seed entry in TODOS.md). So a
115
+ // job still costs one human click to start; what the mode changes is what
116
+ // happens after that, not before. And on a machine where nobody has used the
117
+ // mode before, the docs say the first session started in it asks the user to
118
+ // accept responsibility once and remembers thereafter — until someone answers
119
+ // that by hand, a board agent waits there instead of working.
120
+ export const DEFAULT_PERMISSION_MODE = 'auto';
121
+
122
+ // A command-line argument in double quotes, as parseCommand() in lib/helpers.js
123
+ // reads it back: backslash escapes for quotes and backslashes.
124
+ export const quote = (text) => `"${String(text).replace(/([\\"])/g, '\\$1')}"`;
125
+
126
+ // Billion (server/billion.js). Here because a card it posted tells its worker
127
+ // whom to ask, and this module is where the worker's prompt is written.
128
+ export const BILLION_NAME = 'Billion';
129
+
130
+ // The modes `claude --permission-mode` accepts. buildJobCommand interpolates
131
+ // this value into a command string that parseCommand splits into argv, so an
132
+ // unvalidated value from the wire becomes extra FLAGS on the spawned agent
133
+ // (e.g. "auto --dangerously-skip-permissions"). No shell is involved, so this
134
+ // is not shell injection — but it is argv injection, and the allowlist closes
135
+ // it. Keep in sync with `claude --permission-mode` choices.
136
+ export const PERMISSION_MODES = [
137
+ 'acceptEdits', 'auto', 'bypassPermissions', 'manual', 'dontAsk', 'plan',
138
+ ];
139
+
140
+ export function isValidPermissionMode(mode) {
141
+ return PERMISSION_MODES.includes(mode);
142
+ }
143
+
144
+ // A card's own permission mode, as it arrives from a form or an API call.
145
+ //
146
+ // `null` is a real third state and not a copy of the board's mode taken when
147
+ // the card was written: it means "whatever the board is set to at dispatch",
148
+ // so changing the board setting still moves every queued card that never asked
149
+ // for its own. An unrecognised mode is refused rather than quietly falling back
150
+ // to the default — the caller named a mode, and silently running something
151
+ // else is the wrong answer in both directions.
152
+ export function resolveJobPermissionMode(mode) {
153
+ if (mode === undefined || mode === null || mode === '') return { permissionMode: null };
154
+ if (!isValidPermissionMode(mode)) {
155
+ return { error: `Unknown permission mode "${mode}" \u2014 expected one of: ${PERMISSION_MODES.join(', ')}` };
156
+ }
157
+ return { permissionMode: mode };
158
+ }
159
+
160
+ // Which CLI a card's agent is. The value is interpolated into the spawned
161
+ // command's argv, so it is an allowlist for the same reason PERMISSION_MODES
162
+ // is.
163
+ export const JOB_AGENTS = ['claude', 'codex'];
164
+ export const DEFAULT_JOB_AGENT = 'claude';
165
+
166
+ // Codex has no --permission-mode, so each Claude mode maps onto the nearest
167
+ // thing Codex's own two flags say. The form only offers a Codex card board
168
+ // default, auto and bypassPermissions; the rest are reached through a board
169
+ // set to one of them, and a strict board must bind a Codex card too — without
170
+ // this a read-only board dispatched a Codex that wrote files, and an agent
171
+ // that cannot pick a permission mode through the board's MCP tool could widen
172
+ // its successor's simply by posting the card with agent: codex.
173
+ //
174
+ // auto and acceptEdits are Codex's default (workspace-write sandbox, approval
175
+ // asked on request), so they add nothing. That default runs a command it
176
+ // judges safe inside the sandbox without asking, where Claude's acceptEdits
177
+ // would have prompted for it — the nearest flag, not an equivalent one.
178
+ // manual is the read-only sandbox with approvals on request: every write and
179
+ // every escape from the sandbox comes back as a question, which is as close
180
+ // as Codex gets to asking before each tool. (Its `untrusted` approval policy,
181
+ // used here before, is gone from codex-cli 0.153 — the flag was rejected at
182
+ // parse time and the agent died on the spot.)
183
+ // Keep in sync with `codex --help`; test/jobs-agent.test.js runs each entry
184
+ // through the installed CLI's parser when there is one.
185
+ export const CODEX_MODE_FLAGS = {
186
+ auto: '',
187
+ acceptEdits: '',
188
+ plan: '--sandbox read-only',
189
+ manual: '--ask-for-approval on-request --sandbox read-only',
190
+ dontAsk: '--ask-for-approval never',
191
+ bypassPermissions: '--dangerously-bypass-approvals-and-sandbox',
192
+ };
193
+
194
+ // The permission mode .env gives every agent of one CLI that nothing more
195
+ // specific decides: CLAUDE_PERMISSION_MODE / CODEX_PERMISSION_MODE, one of
196
+ // PERMISSION_MODES. Anything else (unset, a typo) is null: no default.
197
+ export const ENV_PERMISSION_MODE = { claude: 'CLAUDE_PERMISSION_MODE', codex: 'CODEX_PERMISSION_MODE' };
198
+ export function envPermissionMode(agent, env = process.env) {
199
+ const key = Object.prototype.hasOwnProperty.call(ENV_PERMISSION_MODE, agent) ? ENV_PERMISSION_MODE[agent] : null;
200
+ const mode = key ? String(env[key] ?? '').trim() : '';
201
+ return isValidPermissionMode(mode) ? mode : null;
202
+ }
203
+
204
+ // The flags that mode is on that CLI's command line: Claude takes the mode by
205
+ // name; Codex its sandbox and approval flags (none for auto and acceptEdits).
206
+ export function permissionModeFlags(agent, mode) {
207
+ if (!isValidPermissionMode(mode)) return [];
208
+ if (agent === 'claude') return ['--permission-mode', mode];
209
+ if (agent === 'codex') return CODEX_MODE_FLAGS[mode].split(' ').filter(Boolean);
210
+ return [];
211
+ }
212
+
213
+ // A command someone typed, or a preset, with the .env default for its CLI
214
+ // added — unless it already says how it asks for permission, which then
215
+ // stands. Only claude and codex; any other command comes back unchanged. The
216
+ // flags go into the command itself, right after the executable, so everything
217
+ // that reads a session's mode off its command (who may message it, what it
218
+ // re-spawns with) sees the mode it really runs in.
219
+ //
220
+ // "Already says" is judged by the flag's name, not its value: a value the
221
+ // allowlist doesn't know (`--permission-mode default`, `-a untrusted`) is
222
+ // still the person's choice, and a default put in front of it would be what
223
+ // the session records — so a re-spawn would come back in the default instead.
224
+ export function withDefaultPermission(command, env = process.env) {
225
+ const text = String(command || '');
226
+ const agent = sessionAgentFromCommand(text);
227
+ if (!agent || namesPermissionFlag(agent, parseCommand(text).args)) return text;
228
+ const flags = permissionModeFlags(agent, envPermissionMode(agent, env));
229
+ if (!flags.length) return text;
230
+ const { file, args } = parseCommand(text);
231
+ // A plain first word keeps the rest exactly as typed; a quoted executable
232
+ // path is rebuilt, quoting every argument the way parseCommand reads it back.
233
+ const plain = text.match(/^\s*[^\s"'\\]+(?=\s|$)/);
234
+ return plain
235
+ ? `${plain[0]} ${flags.join(' ')}${text.slice(plain[0].length)}`
236
+ : [quote(file), ...flags, ...args.map(quote)].join(' ');
237
+ }
238
+
239
+ // Codex also takes config overrides and profiles, which can set any
240
+ // permission the allowlist cannot see. Shared with server/messages.js
241
+ // isUnguarded, which reads them as never asking.
242
+ export const isCodexConfigFlag = (arg) => /^(-c|--config|-p|--profile|--full-auto)(=|$)/.test(arg) || /^-[cp]\S/.test(arg);
243
+
244
+ // Whether `args` (before any `--`) name one of the CLI's permission flags, in
245
+ // any spelling normalizePermissionFlags reads, whatever the value (or, for
246
+ // Codex, a config override or profile).
247
+ function namesPermissionFlag(agent, args) {
248
+ const table = PERMISSION_FLAGS[agent];
249
+ const aliases = PERMISSION_FLAG_ALIASES[agent];
250
+ for (const raw of args) {
251
+ if (raw === '--') return false;
252
+ if (agent === 'codex' && isCodexConfigFlag(raw)) return true;
253
+ const name = raw.startsWith('--') ? raw.split('=')[0] : raw.slice(0, 2);
254
+ if (Object.prototype.hasOwnProperty.call(table, aliases[name] || name)) return true;
255
+ }
256
+ return false;
257
+ }
258
+
259
+ export function isValidJobAgent(agent) {
260
+ return JOB_AGENTS.includes(agent);
261
+ }
262
+
263
+ // The agent a session's command line runs — so a card an agent posts can
264
+ // default to the same CLI as its poster. Anything not recognised (gemini, a
265
+ // plain shell) is claude, the board's own default. Judged the way
266
+ // takesMcpConfig in server/agent-mcp.js judges it — the executable's basename,
267
+ // Windows extension stripped — so a session spawned as /opt/homebrew/bin/codex
268
+ // gets the board tool AND posts codex cards, rather than one without the other.
269
+ export function jobAgentFromCommand(command) {
270
+ const file = basename(parseCommand(String(command || '')).file).replace(/\.(cmd|exe|bat|ps1)$/i, '');
271
+ return file === 'codex' ? 'codex' : DEFAULT_JOB_AGENT;
272
+ }
273
+
274
+ // What a session's own record should say it ran, for the orphan it may
275
+ // become: 'claude' or 'codex' when the command is that CLI, else null. Unlike
276
+ // jobAgentFromCommand this does NOT default to claude — the note outranks
277
+ // every other witness at re-adopt time, so a shell tab, a `bash -lc codex`,
278
+ // or a gemini must leave it blank and let the job card and the transcripts
279
+ // on disk say what actually ran there.
280
+ export function sessionAgentFromCommand(command) {
281
+ const file = basename(parseCommand(String(command || '')).file).replace(/\.(cmd|exe|bat|ps1)$/i, '');
282
+ return JOB_AGENTS.includes(file) ? file : null;
283
+ }
284
+
285
+ // The permission flags each CLI takes on its command line, as a hand-spawned
286
+ // agent might carry them: an allowlist of flag → accepted values (null for a
287
+ // bare switch). Anything else on the command is not a permission and is not
288
+ // kept. Keep the Codex entries in sync with `codex --help`; the Claude ones
289
+ // with `claude --help`. test/jobs-agent.test.js runs the Codex forms through
290
+ // the installed CLI's parser when there is one.
291
+ export const PERMISSION_FLAGS = {
292
+ codex: {
293
+ '--sandbox': ['read-only', 'workspace-write', 'danger-full-access'],
294
+ '--ask-for-approval': ['on-request', 'never'],
295
+ '--dangerously-bypass-approvals-and-sandbox': null,
296
+ '--approve-for-me': null,
297
+ },
298
+ claude: {
299
+ '--permission-mode': PERMISSION_MODES,
300
+ '--dangerously-skip-permissions': null,
301
+ },
302
+ };
303
+ // Other spellings each CLI accepts for the same permission, mapped onto the
304
+ // long form above. Codex also takes a short option's value attached
305
+ // (`-sread-only`, `-a=never`); those are unpacked below.
306
+ const PERMISSION_FLAG_ALIASES = {
307
+ codex: { '-s': '--sandbox', '-a': '--ask-for-approval', '--yolo': '--dangerously-bypass-approvals-and-sandbox' },
308
+ claude: {},
309
+ };
310
+
311
+ // The permission flags among `tokens` for `agent`, normalised to their long
312
+ // form, one per flag, or [] when the agent is unknown. Anything not in the
313
+ // allowlist — an unknown flag, a value the CLI would reject, a bare switch
314
+ // given a value, a non-string — is dropped, so what comes back can be put on
315
+ // a command line verbatim. Scanning stops at `--`: what follows is the prompt,
316
+ // however flag-shaped, and must never come back as a permission. A flag
317
+ // given twice keeps its last value: Codex refuses a repeat outright, and
318
+ // Claude Code takes the last one anyway.
319
+ // Used both on a fresh command (to record what the agent ran with) and on a
320
+ // stored record (config.json is hand-editable), so the two can never disagree
321
+ // about what is a permission.
322
+ export function normalizePermissionFlags(agent, tokens) {
323
+ // Own-property lookup: `agent` can come from a hand-edited record, and a
324
+ // prototype key ('constructor') must find no table, not a function.
325
+ const table = Object.prototype.hasOwnProperty.call(PERMISSION_FLAGS, agent) ? PERMISSION_FLAGS[agent] : null;
326
+ if (!table || !Array.isArray(tokens)) return [];
327
+ const aliases = PERMISSION_FLAG_ALIASES[agent];
328
+ const seen = new Map();
329
+ for (let i = 0; i < tokens.length; i++) {
330
+ const raw = typeof tokens[i] === 'string' ? tokens[i] : '';
331
+ if (raw === '--') break;
332
+ let flag = raw;
333
+ let inline;
334
+ if (raw.startsWith('--') && raw.includes('=')) {
335
+ [flag, inline] = [raw.slice(0, raw.indexOf('=')), raw.slice(raw.indexOf('=') + 1)];
336
+ } else if (/^-[^-]/.test(raw) && raw.length > 2 && aliases[raw.slice(0, 2)]) {
337
+ // A short option with its value attached: -sread-only, -a=never.
338
+ [flag, inline] = [raw.slice(0, 2), raw.slice(2).replace(/^=/, '')];
339
+ }
340
+ flag = aliases[flag] || flag;
341
+ if (!Object.prototype.hasOwnProperty.call(table, flag)) continue;
342
+ const values = table[flag];
343
+ if (values === null) { if (inline === undefined) seen.set(flag, null); continue; }
344
+ // The next token is the value only if it is not itself an option (or the
345
+ // `--` terminator): both CLIs refuse `--sandbox --` at parse time, and a
346
+ // guard that consumed it would read what follows as flags again.
347
+ const next = tokens[i + 1];
348
+ const value = inline !== undefined ? inline : (typeof next === 'string' && !next.startsWith('-') ? tokens[++i] : undefined);
349
+ if (values.includes(value)) seen.set(flag, value);
350
+ }
351
+ const out = [];
352
+ for (const [flag, value] of seen) { out.push(flag); if (value !== null) out.push(value); }
353
+ return out;
354
+ }
355
+
356
+ // The permission flags a stored record (an active session, an orphan) says
357
+ // its session ran with, through the allowlist again on the way back — the
358
+ // record is hand-editable — and only for a record whose CLI is known, since
359
+ // the flags are that CLI's. Both readers of config.json go through here.
360
+ export function recordedPermissionFlags(record) {
361
+ return record && isValidJobAgent(record.agent) ? normalizePermissionFlags(record.agent, record.permissionFlags) : [];
362
+ }
363
+
364
+ // The permission flags a session was spawned with, read off its command —
365
+ // what its re-spawn should run with when no job card says otherwise.
366
+ export function permissionFlagsFromCommand(command) {
367
+ const agent = sessionAgentFromCommand(command);
368
+ if (!agent) return [];
369
+ return normalizePermissionFlags(agent, parseCommand(String(command || '')).args);
370
+ }
371
+
372
+ // A Codex session id as it appears in a rollout's session_meta: a UUID. The
373
+ // one gate for what may be named on a resume argv, and for which rollout
374
+ // field is the id.
375
+ const CODEX_SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
376
+ export function isCodexSessionId(value) {
377
+ return typeof value === 'string' && CODEX_SESSION_ID.test(value);
378
+ }
379
+
380
+ // The command that picks an interrupted session back up in its worktree —
381
+ // what re-adopting an orphan runs. Each CLI keeps its own transcripts, so a
382
+ // Codex agent revived with `claude --continue` finds nothing to continue and
383
+ // dies on the spot. A Codex agent resumes by `sessionId`, the newest session
384
+ // recorded in exactly its worktree: `codex resume --last` scopes by repo, not
385
+ // worktree, so sibling worktrees of one repo would resume each other's
386
+ // sessions. With no id to go on it opens Codex's picker rather than guess.
387
+ //
388
+ // `mode` is the permission mode the session was dispatched with, when its job
389
+ // card is known. Neither CLI remembers it: without the flag a Codex card sent
390
+ // out read-only comes back with Codex's default workspace-write sandbox, and
391
+ // an unattended dontAsk card comes back prompting and stalls. `flags` are the
392
+ // permission flags the session was spawned with, for an agent with no card
393
+ // (one spawned by hand): they come back as given, so a bypass agent does not
394
+ // return asking, nor a read-only one writing. The card wins when both are
395
+ // known — it is the board's current word. With neither, the CLI's own
396
+ // default applies. Both go through their allowlists here, so this is the one
397
+ // place that guarantees nothing but a permission reaches the argv.
398
+ export function resumeCommand(agent, mode, flags, sessionId) {
399
+ const validMode = isValidPermissionMode(mode) ? mode : null;
400
+ const own = validMode ? '' : normalizePermissionFlags(agent === 'codex' ? 'codex' : 'claude', flags).join(' ');
401
+ if (agent === 'codex') {
402
+ const flag = validMode ? CODEX_MODE_FLAGS[validMode] : own;
403
+ // The id reaches the argv, so only a real session id passes.
404
+ const target = isCodexSessionId(sessionId) ? ` ${sessionId}` : '';
405
+ return `codex resume${target}${flag ? ` ${flag}` : ''}`;
406
+ }
407
+ const flag = validMode ? `--permission-mode ${validMode}` : own;
408
+ return `claude --continue${flag ? ` ${flag}` : ''}`;
409
+ }
410
+
411
+ export function resolveJobAgent(agent) {
412
+ if (agent === undefined || agent === null || agent === '') return { agent: DEFAULT_JOB_AGENT };
413
+ if (!isValidJobAgent(agent)) {
414
+ // Echoed bounded and only when it is text: this reaches a ws notification
415
+ // and a 400 body, and the ws door does not type-check it first.
416
+ const shown = typeof agent === 'string' ? `"${agent.slice(0, 40)}"` : `a ${typeof agent}`;
417
+ return { error: `Unknown agent ${shown} \u2014 expected one of: ${JOB_AGENTS.join(', ')}` };
418
+ }
419
+ return { agent };
420
+ }
421
+
422
+ // Branch name derived from the job title, so a glance at `git branch` says what
423
+ // each branch is for. The `<gituser>/` prefix is added by createWorktree.
424
+ //
425
+ // Kept to [a-z0-9-] and length-bounded: that side-steps every git ref rule at
426
+ // once (no `..`, no leading `-`, no `~^:?*[`, no trailing `.lock`) rather than
427
+ // trying to enumerate them, and keeps the name readable in a branch listing.
428
+ export const MAX_BRANCH_SLUG_LEN = 40;
429
+
430
+ export function branchSlugFromTitle(title) {
431
+ const slug = String(title || '')
432
+ .toLowerCase()
433
+ .replace(/[^a-z0-9]+/g, '-')
434
+ .replace(/^-+|-+$/g, '')
435
+ .slice(0, MAX_BRANCH_SLUG_LEN)
436
+ .replace(/-+$/, ''); // a trim mid-word can leave a trailing dash
437
+ // A title of only punctuation or non-Latin script slugs to nothing; a job
438
+ // still needs a branch, so fall back rather than failing the dispatch.
439
+ return slug || 'job';
440
+ }
441
+
442
+ // Shared by createJob and updateJob: both have to answer "is this a valid
443
+ // (type, schedule) pair?", and a second copy of the rules would drift.
444
+ //
445
+ // A one-time job silently drops any schedule rather than refusing it. That is
446
+ // the edit path: switching a scheduled card back to one-time leaves the cron
447
+ // text sitting in the form field, and failing that save would be baffling.
448
+ export function resolveJobType({ type, schedule }) {
449
+ const raw = typeof type === 'string' && type ? type : (schedule ? 'scheduled' : DEFAULT_JOB_TYPE);
450
+ if (!JOB_TYPES.includes(raw)) {
451
+ return { error: `Unknown job type "${raw}" — expected one of: ${JOB_TYPES.join(', ')}` };
452
+ }
453
+ if (raw !== 'scheduled') return { type: raw, schedule: null };
454
+ // Trimmed but NOT truncated: parseCron owns the length rule, and slicing an
455
+ // over-long string first would either hide that error or, worse, silently
456
+ // store a valid-looking prefix of something the user did not write.
457
+ const text = String(schedule || '').trim();
458
+ const parsed = parseCron(text);
459
+ if (parsed.error) return { error: parsed.error };
460
+ return { type: 'scheduled', schedule: text };
461
+ }
462
+
463
+ export function newJobId() {
464
+ return `job-${Date.now()}-${randomBytes(3).toString('hex')}`;
465
+ }
466
+
467
+ // Bound the stored text so a paste of a whole log file can't bloat config.json.
468
+ export const MAX_TITLE_LEN = 200;
469
+ export const MAX_DETAIL_LEN = 20000;
470
+
471
+ export function createJob({ title, detail, repoPath, type, schedule, permissionMode, agent, requiresPr, postedBy, postedByName, postedByAgent, postedByBillion }) {
472
+ const cleanTitle = String(title || '').trim().slice(0, MAX_TITLE_LEN);
473
+ if (!cleanTitle) return { error: 'Title is required' };
474
+ if (!repoPath) return { error: 'Repository is required' };
475
+ // A caller that names no type but passes a schedule means a scheduled job:
476
+ // the MCP tool and POST /api/jobs both take just `schedule`, so the rule that
477
+ // turns one into the other lives here, once, rather than at each door.
478
+ const resolved = resolveJobType({ type, schedule });
479
+ if (resolved.error) return { error: resolved.error };
480
+ const mode = resolveJobPermissionMode(permissionMode);
481
+ if (mode.error) return { error: mode.error };
482
+ const cli = resolveJobAgent(agent);
483
+ if (cli.error) return { error: cli.error };
484
+ return {
485
+ job: {
486
+ id: newJobId(),
487
+ title: cleanTitle,
488
+ type: resolved.type,
489
+ // Cron text as typed, null on a one-time job. Validated above, so
490
+ // nextCronIso can never be handed something it will refuse.
491
+ schedule: resolved.schedule,
492
+ // When this schedule is next due. Recomputed from the moment of each
493
+ // firing (see fireSchedules), so a skipped firing is never replayed.
494
+ nextRunAt: resolved.schedule ? nextCronIso(resolved.schedule) : null,
495
+ lastRunAt: null,
496
+ runCount: 0,
497
+ // Held out of dispatch until resumed. Cards written before this existed
498
+ // have no field at all, which reads as not paused — same as `type`.
499
+ paused: false,
500
+ // What this card's agent is spawned with. null inherits the board's
501
+ // setting at dispatch — see resolveJobPermissionMode. Only read from To
502
+ // do, which is the one state editableInPlace still lets anyone change.
503
+ permissionMode: mode.permissionMode,
504
+ // Which CLI is spawned for it. Cards written before this existed have no
505
+ // field, which jobAgent reads as claude.
506
+ agent: cli.agent,
507
+ // See jobRequiresPr. Only false when the poster said so.
508
+ // See jobRequiresPr. Unset takes the type's default.
509
+ requiresPr: typeof requiresPr === 'boolean' ? requiresPr : defaultRequiresPr(resolved.type),
510
+ detail: String(detail || '').trim().slice(0, MAX_DETAIL_LEN),
511
+ // Files posted with the card ({ name, path }), written to disk by the
512
+ // server once the id exists. The prompt hands the agent their paths.
513
+ attachments: [],
514
+ repoPath,
515
+ state: 'todo',
516
+ // Who posted it and when (requirement 3).
517
+ postedBy: postedBy || null,
518
+ postedByName: postedByName || null,
519
+ // Set when an agent typed the card on a person's behalf, via the board's
520
+ // MCP tool. Kept separate from postedByName rather than folded into it:
521
+ // postedBy* is the human the work belongs to, and the board still needs
522
+ // to show that a machine, not they, put it there.
523
+ postedByAgent: postedByAgent || null,
524
+ // Billion's trust rides on this, never on the name: set only by the
525
+ // server, from the posting session (server/jobs.js postJobForAgent).
526
+ postedByBillion: postedByBillion === true,
527
+ postedAt: new Date().toISOString(),
528
+ // Who is working on it and when they started (requirement 3). Null until
529
+ // dispatched; kept after the PR lands so Review cards still show credit.
530
+ agentSessionId: null,
531
+ agentName: null,
532
+ startedAt: null,
533
+ branchName: null,
534
+ worktreePath: null,
535
+ prUrl: null,
536
+ prNumber: null,
537
+ reviewAt: null,
538
+ // What the agent reported when it called finish_job — the result of a
539
+ // card that opens no PR, and an optional note on one that does.
540
+ resultSummary: null,
541
+ // Why the board cannot check for this job's PR, when that is the case.
542
+ // Separate from lastError: both can be true at once, and one must not
543
+ // silence the other.
544
+ prCheckError: null,
545
+ prCheckErrorAt: null,
546
+ // When the PR merged, and when the card left the board. Separate because
547
+ // they answer different questions: prMergedAt is a fact about GitHub, and
548
+ // a card filed away by hand has a doneAt without one — which is how the
549
+ // archive knows to say "finished" rather than "merged". doneAt is when
550
+ // this board stopped showing it.
551
+ prMergedAt: null,
552
+ doneAt: null,
553
+ },
554
+ };
555
+ }
556
+
557
+ // --- Dispatch selection ---
558
+
559
+ // How many jobs a repo currently has in flight. Only In progress counts: an
560
+ // agent kept alive with its card in Review has finished its work and sits idle
561
+ // at its prompt, so it is not what the cap limits — agents working at once. It
562
+ // holds a PTY and a worktree until the card reaches Done. Counting jobs is
563
+ // enough, and there is no second source of truth to keep
564
+ // in sync.
565
+ //
566
+ // One exclusion: a job whose agent has died. A session that no longer exists
567
+ // cannot be occupying a slot, and cards are deliberately never auto-reverted to
568
+ // To do, so without this a single crashed agent would block its repo forever.
569
+ //
570
+ // A schedule's runs are ordinary cards and count like any other.
571
+ export function countInFlightByRepo(jobs, liveSessionIds = null) {
572
+ const counts = new Map();
573
+ for (const job of jobs) {
574
+ if (job.state !== 'in-progress') continue;
575
+ if (liveSessionIds && !liveSessionIds.has(job.agentSessionId)) continue;
576
+ counts.set(job.repoPath, (counts.get(job.repoPath) || 0) + 1);
577
+ }
578
+ return counts;
579
+ }
580
+
581
+ // Is this card allowed to go out right now? One-time cards always are — being
582
+ // in To do is the whole condition. A schedule fires when it is due.
583
+ export function isJobDue(job, now = Date.now()) {
584
+ // Paused: held in To do until someone resumes it. Checked before everything
585
+ // else and for every card, not just scheduled ones, because this is the one
586
+ // place every dispatch route passes through — a guard anywhere else would
587
+ // have to be repeated at each door. Firings missed while paused are not
588
+ // replayed: resuming re-arms nextRunAt from that moment (setJobPaused), the
589
+ // same rule a skipped firing follows.
590
+ if (job.paused) return false;
591
+ if (!isScheduled(job)) return true;
592
+ if (!job.nextRunAt) {
593
+ // No due time recorded — a card whose schedule was just edited, or one
594
+ // hand-edited in config.json. Fire it and let the firing re-arm it,
595
+ // rather than leaving it permanently stuck.
596
+ //
597
+ // EXCEPT when the schedule has no future occurrence at all. "0 0 30 2 *"
598
+ // parses fine and never matches, so it has no due time and never will:
599
+ // treating that as due would dispatch it on every single scan, for ever.
600
+ return job.schedule ? nextCronIso(job.schedule, now) !== null : true;
601
+ }
602
+ const at = Date.parse(job.nextRunAt);
603
+ return Number.isNaN(at) || at <= now;
604
+ }
605
+
606
+ export function selectDispatchableJobs(jobs, { maxPerRepo = MAX_AGENTS_PER_REPO, availableRepos = null, liveSessionIds = null, now = Date.now() } = {}) {
607
+ const counts = countInFlightByRepo(jobs, liveSessionIds);
608
+ const selected = [];
609
+ // Schedules are fired (see canFire), never dispatched themselves.
610
+ const todo = jobs
611
+ .filter(j => j.state === 'todo' && !isScheduled(j))
612
+ .sort((a, b) => String(a.postedAt).localeCompare(String(b.postedAt)));
613
+ for (const job of todo) {
614
+ // Paused. `continue`, not a break: one held card must not hold up the
615
+ // queue behind it.
616
+ if (!isJobDue(job, now)) continue;
617
+ // A repo that has been removed (or whose path vanished) can't be spawned
618
+ // into; leave the job queued rather than failing it.
619
+ if (availableRepos && !availableRepos.has(job.repoPath)) continue;
620
+ // The cap bounds how many agents the board piles onto one repo. A run
621
+ // waiting behind it waits in To do; its schedule holds off meanwhile (a
622
+ // run not yet started is still unfinished), so nothing queues up.
623
+ const inFlight = counts.get(job.repoPath) || 0;
624
+ if (inFlight >= maxPerRepo) continue;
625
+ counts.set(job.repoPath, inFlight + 1);
626
+ selected.push(job);
627
+ }
628
+ return selected;
629
+ }
630
+
631
+ // --- Prompt ---
632
+
633
+ // Delivered as a single argv to `claude`, never as simulated keystrokes, so it
634
+ // cannot race whatever the TUI happens to be showing. The preamble nudges the
635
+ // agent toward assumptions over questions (each question is a stall the user
636
+ // has to come clear by hand), points it at /ship as the single finishing step —
637
+ // /ship already merges, tests, reviews and fix-loops internally, so anything
638
+ // run ahead of it pays for that work twice — and tells it how the job gets
639
+ // marked done. It deliberately does not name the skills /ship subsumes: a
640
+ // dispatched agent arrives with no memory of them, and naming one to forbid it
641
+ // is what puts it on the table.
642
+ export function buildJobPrompt(job) {
643
+ const parts = [job.title];
644
+ if (job.detail) parts.push('', job.detail);
645
+ // Absolute paths outside the worktree, so nothing lands in the branch by
646
+ // accident. Claude Code's Read tool renders images, so a screenshot is
647
+ // enough on its own.
648
+ const files = Array.isArray(job.attachments) ? job.attachments.filter(a => a && a.path) : [];
649
+ if (files.length) parts.push('', 'Attached files (read them with your file tools):', ...files.map(a => ` ${a.path}`));
650
+ parts.push('', '---', ...oneTimePromptSuffix(jobAgent(job), jobRequiresPr(job), job.postedByBillion === true));
651
+ return parts.join('\n');
652
+ }
653
+
654
+ // A one-time job ends when its agent calls finish_job, which moves the card to
655
+ // Review. A card that requires a PR gets the /ship path first and hands the PR
656
+ // link over; one that does not gets no /ship at all — telling an agent doing
657
+ // research to run it would push it into inventing a change to ship.
658
+ //
659
+ // The same skill under each CLI's spelling: /ship in Claude Code, $ship in Codex.
660
+ // A card Billion posted has someone to ask who is not a person: Billion
661
+ // answers from its terminal, so a question to it does not stall the job.
662
+ function oneTimePromptSuffix(agent = DEFAULT_JOB_AGENT, requiresPr = true, fromBillion = false) {
663
+ const ship = agent === 'codex' ? '$ship' : '/ship';
664
+ const finish = requiresPr
665
+ ? [
666
+ `When the work is finished, run ${ship}. It is the whole path from there to the`,
667
+ 'pull request, so nothing else needs running first. Wait for it to finish.',
668
+ '',
669
+ 'Then call the finish_job tool (agent-007-board MCP server) with the pull',
670
+ 'request URL as pr_url. That moves this job to Review.',
671
+ ]
672
+ : [
673
+ 'This job does not need a pull request. When the work is finished, call the',
674
+ 'finish_job tool (agent-007-board MCP server) with a summary of what you did',
675
+ 'or found. That moves this job to Review, where the summary is what gets read.',
676
+ 'Put everything that matters in it: this worktree is removed once the job is',
677
+ 'done, and only committed, pushed work survives that.',
678
+ ];
679
+ const done = requiresPr ? `${ship} has opened the pull request and you have called finish_job`
680
+ : 'you have called finish_job';
681
+ return [
682
+ 'This task was dispatched from the Agent 007 job board. You are in a dedicated',
683
+ 'git worktree on your own branch, so work directly here.',
684
+ '',
685
+ 'Push with `git push -u origin HEAD`. Never put credentials or a token in a',
686
+ 'remote URL: git saves the URL, token included, in plain text. If the push needs a',
687
+ 'different GitHub account, `gh auth switch` is enough.',
688
+ '',
689
+ 'Prefer making a reasonable assumption over asking a question — every question',
690
+ 'stalls the job until a human notices. Record any assumptions you made in',
691
+ requiresPr ? 'the pull request description.' : 'your summary.',
692
+ ...(fromBillion ? [
693
+ '',
694
+ `${BILLION_NAME} posted this card. If you are blocked on a decision only it can`,
695
+ `make, ask it with the send_message tool (to: "${BILLION_NAME}") rather than waiting`,
696
+ 'for a person; its answer arrives in this terminal.',
697
+ ] : []),
698
+ '',
699
+ ...finish,
700
+ '',
701
+ `Do not end your turn until ${done}. There is no`,
702
+ 'one waiting to read a progress report and tell you to continue — if you stop',
703
+ 'to describe what you would do next, the job simply stalls there. If you find',
704
+ 'yourself about to write a summary ending in what comes next, do that thing',
705
+ 'instead. The only reasons to stop early are a question you genuinely cannot',
706
+ 'answer yourself, or a failure you cannot get past.',
707
+ ];
708
+ }
709
+
710
+ // The mode a card actually dispatches with. The card's own wins; a card
711
+ // without one inherits whatever the board is set to at that moment, which is
712
+ // the whole point of storing null rather than a snapshot of the board value.
713
+ //
714
+ // Each level is re-checked against the allowlist rather than trusted, and an
715
+ // invalid card mode falls through to the BOARD setting rather than skipping
716
+ // past it to the default. That distinction matters: a card can hold a mode
717
+ // that was valid when it was queued and is not any more (dropped from the
718
+ // allowlist by a later release, or hand-edited into config.json), and a board
719
+ // deliberately set to something strict is exactly the safety net that case
720
+ // should land in. Only when neither level survives does the default apply.
721
+ //
722
+ // Exported because dispatchOnce needs the same answer twice — once to build
723
+ // the argv, once to confirm nothing retuned the card while the agent spawned.
724
+ export function dispatchPermissionMode(job, boardMode = DEFAULT_PERMISSION_MODE) {
725
+ if (job && isValidPermissionMode(job.permissionMode)) return job.permissionMode;
726
+ if (isValidPermissionMode(boardMode)) return boardMode;
727
+ return DEFAULT_PERMISSION_MODE;
728
+ }
729
+
730
+ export function buildJobCommand(job, { permissionMode = DEFAULT_PERMISSION_MODE } = {}) {
731
+ // Resolved here rather than at the call site so every door into the
732
+ // dispatcher gets the same rule — and validated here as well as at the
733
+ // settings boundary, because this is the function that builds the argv and
734
+ // so the last place that can guarantee the mode is a single token and not a
735
+ // smuggled second flag.
736
+ const mode = dispatchPermissionMode(job, permissionMode);
737
+ // Attachments live outside the worktree, and Claude Code asks before it
738
+ // reads outside its working directory; --add-dir grants that up front so
739
+ // an unattended job does not stop at a permission prompt on its first
740
+ // screenshot. After the prompt, so the argv positions tests rely on hold.
741
+ const dirs = [...new Set((Array.isArray(job.attachments) ? job.attachments : []).filter(a => a && a.path).map(a => dirname(a.path)))];
742
+ if (jobAgent(job) === 'codex') {
743
+ // No --add-dir: every Codex sandbox, read-only included, reads anywhere
744
+ // on disk and gates only writes, and attachments are only ever read.
745
+ const flags = permissionModeFlags('codex', mode).join(' ');
746
+ return `codex ${flags ? `${flags} ` : ''}${quote(buildJobPrompt(job))}`;
747
+ }
748
+ return `claude ${permissionModeFlags('claude', mode).join(' ')} ${quote(buildJobPrompt(job))}${dirs.map(d => ` --add-dir ${quote(d)}`).join('')}`;
749
+ }
750
+
751
+ // --- Live status (derived, never stored) ---
752
+
753
+ // Why derived: the card's workflow state is durable, but "needs you" is a fact
754
+ // about a live PTY that changes second to second and is meaningless once the
755
+ // server restarts. Storing it would guarantee a stale badge.
756
+ export function deriveJobStatus(job, session, { now = Date.now(), stalledAfterMs = STALLED_AFTER_MS } = {}) {
757
+ if (job.state !== 'in-progress') return null;
758
+ if (!session || session.exited) return 'gone';
759
+ // MESSAGE is already exactly "agent is asking the user something" — the same
760
+ // signal that turns the tab dot orange and gives the office character a
761
+ // thought bubble (see MESSAGE_PATTERNS in lib/helpers.js).
762
+ if (session.state === 'MESSAGE') return 'needs-input';
763
+ // A TUI agent parked at its prompt reads as WAITING whether it asked a prose
764
+ // question or quietly finished without opening a PR. Both need a human, so
765
+ // both surface once the quiet window passes.
766
+ if (session.state === 'WAITING' && (now - (session.lastOutputAt || 0)) > stalledAfterMs) return 'stalled';
767
+ return 'running';
768
+ }
769
+
770
+ // --- Schedules ---
771
+
772
+ // May a due schedule post its next run? At most one run of a schedule is
773
+ // unfinished at a time, so an hourly job nobody reads cannot fill the board:
774
+ //
775
+ // - a run still in To do or In progress: skip this firing. Two runs at once
776
+ // would race each other, and a stuck run shows on its own card.
777
+ // - a run in Review that opened a PR: skip until that PR is merged or
778
+ // closed (either files the run to Done), or the card is done. A second
779
+ // dependency-bump PR on an unmerged one is noise.
780
+ // - a run in Review with no PR: fire. The new run supersedes it once it
781
+ // reaches Review itself (see supersededRuns), so the newest result is the
782
+ // one waiting to be read.
783
+ //
784
+ // Returns null to fire, or the reason it held off.
785
+ export function scheduleHold(schedule, jobs) {
786
+ const open = jobs.filter(j => j.scheduleId === schedule.id && j.state !== 'done');
787
+ if (open.some(j => j.state === 'in-progress')) return 'the previous run is still going';
788
+ const queued = open.find(j => j.state === 'todo');
789
+ if (queued) return queued.lastError ? `the previous run could not start: ${queued.lastError}` : 'the previous run has not started yet';
790
+ const prRun = open.find(j => j.state === 'review' && jobRequiresPr(j));
791
+ if (prRun) return prRun.prNumber ? `waiting on PR #${prRun.prNumber}` : 'waiting on the previous run\'s pull request';
792
+ return null;
793
+ }
794
+
795
+ // The Review runs of each schedule that a newer Review run has replaced: every
796
+ // no-PR run of a schedule except the one that reached Review last. Ranked by
797
+ // reviewAt, not postedAt: an older run sent back for a follow-up and returned
798
+ // is the newest result, and must not be filed away the moment it lands. PR runs are never superseded —
799
+ // scheduleHold keeps a schedule from firing past one.
800
+ export function supersededRuns(jobs) {
801
+ const newest = new Map();
802
+ // The newest is taken over every Review run, PR runs included, so a schedule
803
+ // switched to PR runs still replaces the no-PR run it left in Review.
804
+ const reviewRuns = jobs.filter(j => j.scheduleId && j.state === 'review');
805
+ for (const j of reviewRuns) {
806
+ const cur = newest.get(j.scheduleId);
807
+ // >= so a tie goes to the later card in the list, which was pushed later.
808
+ const at = (x) => String(x.reviewAt || x.postedAt);
809
+ if (!cur || at(j).localeCompare(at(cur)) >= 0) newest.set(j.scheduleId, j);
810
+ }
811
+ return reviewRuns
812
+ .filter(j => !jobRequiresPr(j) && newest.get(j.scheduleId) !== j)
813
+ .map(j => ({ old: j, by: newest.get(j.scheduleId) }));
814
+ }
815
+
816
+ // How many finished runs of each schedule the archive keeps. An hourly
817
+ // schedule posts ~8,760 runs a year, every one of them a card in config.json
818
+ // and in each board broadcast; past this many, the oldest go. The schedule's
819
+ // runCount keeps counting regardless.
820
+ export const MAX_FINISHED_RUNS = 50;
821
+
822
+ // The finished runs to delete: every schedule's done runs beyond the newest
823
+ // `keep`, oldest first by when they finished.
824
+ export function runsToPrune(jobs, keep = MAX_FINISHED_RUNS) {
825
+ const bySchedule = new Map();
826
+ for (const j of jobs) {
827
+ if (!j.scheduleId || j.state !== 'done') continue;
828
+ if (!bySchedule.has(j.scheduleId)) bySchedule.set(j.scheduleId, []);
829
+ bySchedule.get(j.scheduleId).push(j);
830
+ }
831
+ const prune = [];
832
+ for (const runs of bySchedule.values()) {
833
+ if (runs.length <= keep) continue;
834
+ runs.sort((a, b) => String(b.doneAt || '').localeCompare(String(a.doneAt || '')));
835
+ prune.push(...runs.slice(keep));
836
+ }
837
+ return prune;
838
+ }
839
+
840
+ // The run card a schedule posts. An ordinary one-time card, carrying what the
841
+ // schedule says its runs should be, and who the schedule belongs to. Pure:
842
+ // the server copies the schedule's attachment files into the run's own
843
+ // directory when it posts it (copyRunAttachments).
844
+ export function createRunJob(schedule) {
845
+ const result = createJob({
846
+ title: schedule.title,
847
+ detail: schedule.detail,
848
+ repoPath: schedule.repoPath,
849
+ type: 'one-time',
850
+ permissionMode: schedule.permissionMode,
851
+ agent: schedule.agent,
852
+ requiresPr: jobRequiresPr(schedule),
853
+ postedBy: schedule.postedBy,
854
+ postedByName: schedule.postedByName,
855
+ // Kept on every run: an agent-posted schedule runs unattended, again and
856
+ // again, and each run must still say a machine queued it.
857
+ postedByAgent: schedule.postedByAgent,
858
+ postedByBillion: schedule.postedByBillion === true,
859
+ });
860
+ if (result.error) return result;
861
+ result.job.scheduleId = schedule.id;
862
+ // An agent's rewrite of the schedule is what each run carries out.
863
+ result.job.editedByAgent = schedule.editedByAgent || null;
864
+ result.job.editedAt = schedule.editedAt || null;
865
+ return result;
866
+ }
867
+
868
+ // --- PR detection ---
869
+
870
+ // The two `gh pr list` queries the board runs, each kept beside the parser that
871
+ // reads its output so the requested --json fields and the fields the parser
872
+ // reads cannot drift apart. `--state` is the load-bearing part of each: it is
873
+ // the difference between "is there a PR to review" and "did that PR land", and
874
+ // getting the merged one wrong (--state closed) would take cards off the board
875
+ // for work that never shipped. Pure, so both are pinned by a test.
876
+ export function openPrListArgs(branchName) {
877
+ return ['pr', 'list', '--head', branchName, '--state', 'open', '--json', 'number,url,state,isDraft,isCrossRepository'];
878
+ }
879
+
880
+ export function mergedPrListArgs(branchName) {
881
+ return ['pr', 'list', '--head', branchName, '--state', 'merged', '--json', 'number,url,state,mergedAt'];
882
+ }
883
+
884
+ // A card whose PR was closed without merging is filed to Done (see
885
+ // checkMergedPullRequests). Asked about the card's own PR by number, not by
886
+ // listing the branch: a number is one answer, where a branch listing is
887
+ // capped (30 by default) and shared by every card that reused the name.
888
+ export function closedPrViewArgs(number) {
889
+ return ['pr', 'view', String(number), '--json', 'number,url,state,mergedAt'];
890
+ }
891
+
892
+ // The card's own PR, if it was closed WITHOUT merging, or null. A merged PR is
893
+ // never "closed" here: that is the merge path's to file away. Takes the one
894
+ // object `gh pr view` prints (or a list, for older callers).
895
+ export function parseClosedPr(stdout, number) {
896
+ if (number == null) return null;
897
+ let parsed;
898
+ try { parsed = JSON.parse(stdout); } catch { return null; }
899
+ const list = Array.isArray(parsed) ? parsed : (parsed && typeof parsed === 'object' ? [parsed] : null);
900
+ if (!list) return null;
901
+ const pr = list.find(p => p && p.number === number);
902
+ if (!pr || pr.mergedAt || String(pr.state || '').toUpperCase() !== 'CLOSED') return null;
903
+ return { url: pr.url || null, number: pr.number };
904
+ }
905
+
906
+ // Parses `gh pr list --head <branch> --json number,url,state,isDraft`. Returns
907
+ // the first OPEN pr, or null. Drafts count: opening a draft PR is still the
908
+ // author saying "this is ready to look at".
909
+ export function parsePrList(stdout) {
910
+ let list;
911
+ try { list = JSON.parse(stdout); } catch { return null; }
912
+ if (!Array.isArray(list)) return null;
913
+ // Never a PR from someone else's fork that happens to share the head ref
914
+ // name: the board would adopt it, and its author closing it would close the
915
+ // card and its agent.
916
+ const open = list.find(pr => (!pr.state || String(pr.state).toUpperCase() === 'OPEN') && !pr.isCrossRepository);
917
+ if (!open) return null;
918
+ return { url: open.url || null, number: open.number ?? null, isDraft: !!open.isDraft };
919
+ }
920
+
921
+ // Parses `gh pr list --head <branch> --state merged --json number,url,state,mergedAt`.
922
+ // Returns the merged PR that belongs to THIS job, or null.
923
+ //
924
+ // The identity check is the whole point, because `--head` matches the head ref
925
+ // NAME and that name outlives the branch. A merged PR stays in the listing
926
+ // forever, and board branch names are reused: the branch is deleted when its
927
+ // agent is retired, which frees the name both locally and (with GitHub's
928
+ // delete-on-merge) on the remote, so the next job with the same title gets it
929
+ // back. "Some PR on this branch merged" is therefore NOT "this card's PR
930
+ // merged", and treating them as the same files a card away for work that is
931
+ // still open — taking its PR number with it.
932
+ //
933
+ // - `number`: the card's PR of record. When it has one, only that PR can
934
+ // finish it. Nothing else on the branch is this card's PR.
935
+ // - `mergedAfter`: for a card with no PR of record, the earliest merge that
936
+ // could plausibly be its work (when its agent started, or when it reached
937
+ // Review). An older merge belongs to whatever used this branch name before.
938
+ // A PR with no mergedAt cannot be placed in time, so it is rejected — the
939
+ // cost of that is a card staying on the board, which is the safe direction.
940
+ //
941
+ // `--state merged` already filters server-side; the merged check here is
942
+ // belt-and-braces, because a PR CLOSED without merging must never read as
943
+ // merged: nothing landed. That case has its own path (parseClosedPr).
944
+ export function parseMergedPr(stdout, { number = null, mergedAfter = null } = {}) {
945
+ let list;
946
+ try { list = JSON.parse(stdout); } catch { return null; }
947
+ if (!Array.isArray(list)) return null;
948
+
949
+ const isMerged = pr => !!pr && (pr.mergedAt || String(pr.state || '').toUpperCase() === 'MERGED');
950
+ const shape = pr => ({ url: pr.url || null, number: pr.number ?? null, mergedAt: pr.mergedAt || null });
951
+
952
+ if (number != null) {
953
+ const exact = list.find(pr => isMerged(pr) && pr.number === number);
954
+ return exact ? shape(exact) : null;
955
+ }
956
+
957
+ const floor = mergedAfter ? Date.parse(mergedAfter) : NaN;
958
+ const candidate = list.find((pr) => {
959
+ if (!isMerged(pr)) return false;
960
+ if (Number.isNaN(floor)) return true; // nothing to compare against
961
+ const at = Date.parse(pr.mergedAt || '');
962
+ return !Number.isNaN(at) && at >= floor;
963
+ });
964
+ return candidate ? shape(candidate) : null;
965
+ }