@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.
- package/LICENSE +21 -0
- package/README.md +222 -0
- package/VERSION +1 -0
- package/bin/adduser.js +69 -0
- package/bin/agent-007.js +88 -0
- package/lib/cron.js +189 -0
- package/lib/helpers.js +541 -0
- package/lib/jobs.js +965 -0
- package/package.json +63 -0
- package/public/app.js +650 -0
- package/public/assets/characters/LICENSE +21 -0
- package/public/assets/characters/char_0.png +0 -0
- package/public/assets/characters/char_1.png +0 -0
- package/public/assets/characters/char_2.png +0 -0
- package/public/assets/characters/char_3.png +0 -0
- package/public/assets/characters/char_4.png +0 -0
- package/public/assets/characters/char_5.png +0 -0
- package/public/assets/furniture/bookshelf.png +0 -0
- package/public/assets/furniture/cactus.png +0 -0
- package/public/assets/furniture/chair_back.png +0 -0
- package/public/assets/furniture/chair_front.png +0 -0
- package/public/assets/furniture/chair_side.png +0 -0
- package/public/assets/furniture/coffee.png +0 -0
- package/public/assets/furniture/coffee_table.png +0 -0
- package/public/assets/furniture/desk.png +0 -0
- package/public/assets/furniture/desk2.png +0 -0
- package/public/assets/furniture/plant_2.png +0 -0
- package/public/assets/furniture/sofa_front.png +0 -0
- package/public/assets/furniture/sofa_side.png +0 -0
- package/public/assets/furniture/table_front.png +0 -0
- package/public/index.html +245 -0
- package/public/modules/auth.js +83 -0
- package/public/modules/explorer.js +760 -0
- package/public/modules/jobs.js +971 -0
- package/public/modules/office.js +2154 -0
- package/public/modules/paths.js +20 -0
- package/public/modules/shortcuts.js +54 -0
- package/public/modules/state.js +75 -0
- package/public/modules/terminal.js +651 -0
- package/public/modules/voice.js +393 -0
- package/public/modules/ws.js +56 -0
- package/public/style.css +1843 -0
- package/server/agent-mcp-bridge.js +45 -0
- package/server/agent-mcp.js +184 -0
- package/server/agent-transcripts.js +195 -0
- package/server/approvals.js +155 -0
- package/server/auth.js +162 -0
- package/server/billion.js +176 -0
- package/server/claude-trust.js +66 -0
- package/server/command-path.js +102 -0
- package/server/config.js +184 -0
- package/server/direct-run.js +33 -0
- package/server/git.js +630 -0
- package/server/http.js +276 -0
- package/server/jobs.js +2044 -0
- package/server/mcp.js +596 -0
- package/server/messages.js +319 -0
- package/server/permission-hook.js +47 -0
- package/server/pty.js +360 -0
- package/server/state.js +104 -0
- package/server/ws.js +583 -0
- package/server.js +306 -0
- package/templates/billion/COMPANY.md +14 -0
- package/templates/billion/STATE.md +17 -0
- package/templates/billion/charter.md +232 -0
- 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
|
+
}
|