@bill10/agent-007 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +222 -0
  3. package/VERSION +1 -0
  4. package/bin/adduser.js +69 -0
  5. package/bin/agent-007.js +88 -0
  6. package/lib/cron.js +189 -0
  7. package/lib/helpers.js +541 -0
  8. package/lib/jobs.js +965 -0
  9. package/package.json +63 -0
  10. package/public/app.js +650 -0
  11. package/public/assets/characters/LICENSE +21 -0
  12. package/public/assets/characters/char_0.png +0 -0
  13. package/public/assets/characters/char_1.png +0 -0
  14. package/public/assets/characters/char_2.png +0 -0
  15. package/public/assets/characters/char_3.png +0 -0
  16. package/public/assets/characters/char_4.png +0 -0
  17. package/public/assets/characters/char_5.png +0 -0
  18. package/public/assets/furniture/bookshelf.png +0 -0
  19. package/public/assets/furniture/cactus.png +0 -0
  20. package/public/assets/furniture/chair_back.png +0 -0
  21. package/public/assets/furniture/chair_front.png +0 -0
  22. package/public/assets/furniture/chair_side.png +0 -0
  23. package/public/assets/furniture/coffee.png +0 -0
  24. package/public/assets/furniture/coffee_table.png +0 -0
  25. package/public/assets/furniture/desk.png +0 -0
  26. package/public/assets/furniture/desk2.png +0 -0
  27. package/public/assets/furniture/plant_2.png +0 -0
  28. package/public/assets/furniture/sofa_front.png +0 -0
  29. package/public/assets/furniture/sofa_side.png +0 -0
  30. package/public/assets/furniture/table_front.png +0 -0
  31. package/public/index.html +245 -0
  32. package/public/modules/auth.js +83 -0
  33. package/public/modules/explorer.js +760 -0
  34. package/public/modules/jobs.js +971 -0
  35. package/public/modules/office.js +2154 -0
  36. package/public/modules/paths.js +20 -0
  37. package/public/modules/shortcuts.js +54 -0
  38. package/public/modules/state.js +75 -0
  39. package/public/modules/terminal.js +651 -0
  40. package/public/modules/voice.js +393 -0
  41. package/public/modules/ws.js +56 -0
  42. package/public/style.css +1843 -0
  43. package/server/agent-mcp-bridge.js +45 -0
  44. package/server/agent-mcp.js +184 -0
  45. package/server/agent-transcripts.js +195 -0
  46. package/server/approvals.js +155 -0
  47. package/server/auth.js +162 -0
  48. package/server/billion.js +176 -0
  49. package/server/claude-trust.js +66 -0
  50. package/server/command-path.js +102 -0
  51. package/server/config.js +184 -0
  52. package/server/direct-run.js +33 -0
  53. package/server/git.js +630 -0
  54. package/server/http.js +276 -0
  55. package/server/jobs.js +2044 -0
  56. package/server/mcp.js +596 -0
  57. package/server/messages.js +319 -0
  58. package/server/permission-hook.js +47 -0
  59. package/server/pty.js +360 -0
  60. package/server/state.js +104 -0
  61. package/server/ws.js +583 -0
  62. package/server.js +306 -0
  63. package/templates/billion/COMPANY.md +14 -0
  64. package/templates/billion/STATE.md +17 -0
  65. package/templates/billion/charter.md +232 -0
  66. package/templates/billion/owner.md +11 -0
package/server/mcp.js ADDED
@@ -0,0 +1,596 @@
1
+ // The board's MCP server — how an agent you are talking to can put a card on
2
+ // the job board when you ask it to.
3
+ //
4
+ // Why MCP and not a command on PATH: an agent does not enumerate its PATH, so a
5
+ // binary sitting there is invisible. An MCP tool arrives in the agent's tool
6
+ // list with a name and a description, which is real discovery — and it is a
7
+ // capability, not an instruction. Nothing tells an agent to post jobs; the tool
8
+ // is simply there when the user asks for one.
9
+ //
10
+ // Transport is JSON HTTP served by the app's own Express server. Claude Code
11
+ // connects directly; Codex uses agent-mcp-bridge.js to forward stdio requests
12
+ // while keeping the board credential in its per-session file.
13
+ //
14
+ // Kept free of Express and of the job store so the protocol is testable on its
15
+ // own: handleMcpMessage takes a parsed message and a context, and returns the
16
+ // reply object (or null for a notification, which gets no reply by JSON-RPC).
17
+ // Every tool is a thin wrapper over one injected board function: this module
18
+ // owns the wire text an agent reads, and nothing about how cards are stored.
19
+
20
+ // lib/jobs.js is the pure half of the board — no store, no Express — so the
21
+ // column names come from there rather than being spelled out a second time.
22
+ import { JOB_STATES, STATE_LABELS, JOB_AGENTS } from '../lib/jobs.js';
23
+ import { APPROVAL_WAIT_MS } from './agent-mcp.js';
24
+
25
+ // Echoed back from the client's own initialize when it sends one. MCP clients
26
+ // negotiate this, and answering with whatever the client asked for is the
27
+ // behaviour this server wants — there is nothing here that varies by protocol
28
+ // revision.
29
+ export const DEFAULT_PROTOCOL_VERSION = '2025-06-18';
30
+
31
+ export const SERVER_INFO = { name: 'agent-007-board', version: '1' };
32
+
33
+ // The description is the whole discovery mechanism, so it says what the tool is
34
+ // for and — deliberately — when to reach for it. "When the user asks" is the
35
+ // operative clause: a job board full of work an agent queued for itself is not
36
+ // what this is for.
37
+ export const POST_JOB_TOOL = {
38
+ name: 'post_job',
39
+ description:
40
+ 'Post a job card to the Agent 007 job board, in the To do column. Use this when '
41
+ + 'the user asks you to add something to the board, queue work for later, or hand '
42
+ + 'a task to another agent — not for work you are already doing. The board '
43
+ + 'dispatches each card to a fresh agent in its own git worktree and branch, so '
44
+ + 'the detail must be everything that agent needs to do the work unattended: it '
45
+ + 'will not have this conversation. Pass `schedule` to make it a recurring job '
46
+ + 'instead: the card becomes a schedule, and each time it comes due it posts a '
47
+ + 'run card of its own that goes through the board like any job.',
48
+ inputSchema: {
49
+ type: 'object',
50
+ properties: {
51
+ title: {
52
+ type: 'string',
53
+ description: 'One line naming the work, as it should read on the card.',
54
+ },
55
+ detail: {
56
+ type: 'string',
57
+ description:
58
+ 'Everything the agent picking this up needs: context, constraints, files, '
59
+ + 'how to tell it is done. Written for someone who was not in this conversation.',
60
+ },
61
+ repo: {
62
+ type: 'string',
63
+ description:
64
+ 'Which repository to run the job in — a full path or just the folder name. '
65
+ + 'Defaults to the repository this terminal is working in.',
66
+ },
67
+ schedule: {
68
+ type: 'string',
69
+ description:
70
+ 'Optional. Supplying this makes the card a SCHEDULED job that runs again '
71
+ + 'on every match instead of once: a five-field cron expression in the '
72
+ + "server's local time (\"0 9 * * 1-5\" = 09:00 on weekdays), or one of "
73
+ + '@hourly, @daily, @weekly, @monthly, @yearly. Its runs report a summary '
74
+ + 'unless requires_pr is true. A schedule holds off while its last run is '
75
+ + 'still going or its PR is open, and a newer no-PR run replaces the last '
76
+ + 'one in Review. Omit this for ordinary work that should happen once.',
77
+ },
78
+ agent: {
79
+ type: 'string',
80
+ enum: JOB_AGENTS,
81
+ description:
82
+ 'Optional. Which CLI the board spawns for this card: claude (Claude Code) '
83
+ + 'or codex. Defaults to the one you are running as.',
84
+ },
85
+ requires_pr: {
86
+ type: 'boolean',
87
+ description:
88
+ 'Optional. Whether the work ends in a pull request (on a schedule: whether '
89
+ + 'each run does). Defaults to true for a one-time job and false for a '
90
+ + 'schedule; pass false for work that is not a code change — '
91
+ + 'research, an investigation, an ops chore — so the agent reports a '
92
+ + 'summary instead of opening a PR.',
93
+ },
94
+ },
95
+ required: ['title'],
96
+ additionalProperties: false,
97
+ },
98
+ };
99
+
100
+ // Reading the board is a separate tool from writing to it so an agent can be
101
+ // asked "what is on the board?" without the answer costing a card. The board is
102
+ // one shared wall — every connected client sees every card — so these show the
103
+ // whole board rather than only what this agent posted.
104
+ export const LIST_JOBS_TOOL = {
105
+ name: 'list_jobs',
106
+ description:
107
+ 'List the cards on the Agent 007 job board — To do, In progress and Review, '
108
+ + 'with the id of each. Use this when the user asks what is on the board, what '
109
+ + 'is queued or running, or before editing a card, since editing needs the id. '
110
+ + 'Finished cards are archived off the board: pass state "done" to see those.',
111
+ inputSchema: {
112
+ type: 'object',
113
+ properties: {
114
+ state: {
115
+ type: 'string',
116
+ enum: JOB_STATES,
117
+ description:
118
+ 'Optional. Show only this column: todo, in-progress, review, or done '
119
+ + '(the finished archive). Omit for the whole board, archive excluded.',
120
+ },
121
+ repo: {
122
+ type: 'string',
123
+ description:
124
+ 'Optional. Show only cards for this repository — a full path or just the '
125
+ + 'folder name. Omit for every repository the board knows.',
126
+ },
127
+ },
128
+ additionalProperties: false,
129
+ },
130
+ };
131
+
132
+ export const READ_JOB_TOOL = {
133
+ name: 'read_job',
134
+ description:
135
+ 'Read one card on the Agent 007 job board in full, including the detail the '
136
+ + 'job agent is given, its branch and pull request, and any error the board hit. '
137
+ + 'Use this when the user asks what a card says or how it is going. Ids come '
138
+ + 'from list_jobs.',
139
+ inputSchema: {
140
+ type: 'object',
141
+ properties: {
142
+ id: { type: 'string', description: 'The card id, as list_jobs reports it.' },
143
+ },
144
+ required: ['id'],
145
+ additionalProperties: false,
146
+ },
147
+ };
148
+
149
+ export const EDIT_JOB_TOOL = {
150
+ name: 'edit_job',
151
+ description:
152
+ 'Change a card that is still in To do: its title, detail, repository, '
153
+ + 'schedule or whether it requires a pull request. Only To do cards can be edited — once the board has dispatched a '
154
+ + 'card its agent has already been handed the text, so a later edit would leave '
155
+ + 'the card describing work nobody was asked to do. Pass only the fields that '
156
+ + 'change; the rest are left alone. Ids come from list_jobs.',
157
+ inputSchema: {
158
+ type: 'object',
159
+ properties: {
160
+ id: { type: 'string', description: 'The card id, as list_jobs reports it.' },
161
+ title: { type: 'string', description: 'Replaces the line naming the work.' },
162
+ detail: {
163
+ type: 'string',
164
+ description:
165
+ 'Replaces the whole detail body — this is not appended to what is there, '
166
+ + 'so read the card first if you mean to add to it.',
167
+ },
168
+ repo: {
169
+ type: 'string',
170
+ description: 'Move the card to another repository — a full path or folder name.',
171
+ },
172
+ schedule: {
173
+ type: 'string',
174
+ description:
175
+ 'Replaces the cron schedule (five fields, or an @shorthand). Pass an empty '
176
+ + 'string to turn a scheduled card back into one that runs once.',
177
+ },
178
+ requires_pr: {
179
+ type: 'boolean',
180
+ description:
181
+ 'Whether the work ends in a pull request. '
182
+ + 'Pass false for work that is not a code change — '
183
+ + 'research, an investigation, an ops chore — so the agent reports a '
184
+ + 'summary instead of opening a PR.',
185
+ },
186
+ },
187
+ required: ['id'],
188
+ additionalProperties: false,
189
+ },
190
+ };
191
+
192
+ // How a board-dispatched agent reports that its job is done. The card moves to
193
+ // Review; the agent keeps running there until the card reaches Done.
194
+ export const FINISH_JOB_TOOL = {
195
+ name: 'finish_job',
196
+ description:
197
+ 'Report that the job-board job you were dispatched to do is finished, which '
198
+ + 'moves its card to Review. Only for an agent the board dispatched, and only '
199
+ + 'once the work is done. If the job requires a pull request, run your ship skill (/ship, or $ship in Codex) first, '
200
+ + 'wait for it to open the PR, and pass its URL as pr_url. If it does not, pass '
201
+ + 'a summary of what you did or found — that is what the reviewer reads.',
202
+ inputSchema: {
203
+ type: 'object',
204
+ properties: {
205
+ pr_url: {
206
+ type: 'string',
207
+ description: 'The pull request URL. Required when the job requires a pull request.',
208
+ },
209
+ summary: {
210
+ type: 'string',
211
+ description:
212
+ 'What you did or found, written for someone who was not watching. Required '
213
+ + 'when the job needs no pull request; optional otherwise.',
214
+ },
215
+ },
216
+ additionalProperties: false,
217
+ },
218
+ };
219
+
220
+ // Messaging lives on this same server because it is the one both CLIs already
221
+ // load: Claude Code's own SendMessage reaches only other Claude Code sessions.
222
+ export const LIST_AGENTS_TOOL = {
223
+ name: 'list_agents',
224
+ description:
225
+ 'List the other agents running in Agent 007 that you can message with '
226
+ + 'send_message — Claude Code and Codex alike — with the repo and branch each '
227
+ + 'is on, what it is doing, and its job card if it has one. Use this when the '
228
+ + 'user asks you to ask, tell or coordinate with another agent.',
229
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
230
+ };
231
+
232
+ export const SEND_MESSAGE_TOOL = {
233
+ name: 'send_message',
234
+ description:
235
+ 'Send a message to another agent running in Agent 007, whether it runs on '
236
+ + 'Claude Code or Codex. It arrives in that agent\'s terminal as a new turn '
237
+ + 'once the agent is idle at its prompt, marked as coming from you, and any '
238
+ + 'reply comes back to you the same way — this call does not wait for one. '
239
+ + 'Use it when the user asks you to ask, tell or coordinate with another '
240
+ + 'agent; do not start conversations of your own accord. Names come from '
241
+ + 'list_agents.',
242
+ inputSchema: {
243
+ type: 'object',
244
+ properties: {
245
+ to: { type: 'string', description: 'The agent\'s name, as list_agents prints it.' },
246
+ message: {
247
+ type: 'string',
248
+ description: 'What to say. The recipient was not in this conversation, so '
249
+ + 'include the context it needs to answer.',
250
+ },
251
+ },
252
+ required: ['to', 'message'],
253
+ additionalProperties: false,
254
+ },
255
+ };
256
+
257
+ // Billion's alone (server/billion.js): listed only for its session.
258
+ export const BILLION_READY_TOOL = {
259
+ name: 'billion_ready',
260
+ description:
261
+ 'Open your inbox: until you call this, messages from agents and job board '
262
+ + 'notices wait instead of being typed into your terminal. Call it when your '
263
+ + 'introduction is done and at the start of every operating cycle; calling it '
264
+ + 'again does nothing.',
265
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
266
+ };
267
+
268
+ export const ADD_REPO_TOOL = {
269
+ name: 'add_repo',
270
+ description:
271
+ 'Add a git repository to the Agent 007 board, so job cards can be posted in it '
272
+ + 'and it shows in the owner\'s left panel. Use it after creating a new '
273
+ + 'project\'s repo (with its remote and a pushed main). Adding one already '
274
+ + 'on the board does nothing.',
275
+ inputSchema: {
276
+ type: 'object',
277
+ properties: {
278
+ path: { type: 'string', description: 'Absolute path to the repository on this machine (~/ is allowed).' },
279
+ },
280
+ required: ['path'],
281
+ additionalProperties: false,
282
+ },
283
+ };
284
+
285
+ export const CLOSE_JOB_TOOL = {
286
+ name: 'close_job',
287
+ description:
288
+ 'Close one of your own cards that is in Review. accept: true files a card with '
289
+ + 'no pull request as Done (a card with a PR is filed away when you merge the '
290
+ + 'PR, or close it to drop the work). accept: false sends it back to To do '
291
+ + 'with your note added to its detail, for a fresh worker to redo; if it had a '
292
+ + 'pull request, close that one afterwards. Either way its worker is closed.',
293
+ inputSchema: {
294
+ type: 'object',
295
+ properties: {
296
+ id: { type: 'string', description: 'The card id, as list_jobs reports it.' },
297
+ accept: { type: 'boolean', description: 'true: Done. false: back to To do.' },
298
+ note: { type: 'string', description: 'Required when sending it back: what the next worker must do differently.' },
299
+ },
300
+ required: ['id', 'accept'],
301
+ additionalProperties: false,
302
+ },
303
+ };
304
+
305
+ export const ANSWER_PERMISSION_TOOL = {
306
+ name: 'answer_permission',
307
+ description:
308
+ 'Answer a worker\'s permission request, which arrives in your terminal as '
309
+ + '"[Approval <id>] …". allow lets the worker go ahead; deny refuses, and your '
310
+ + 'reason is what the worker reads; owner leaves it to the owner, who then sees '
311
+ + `the worker's dialog. Unanswered requests go to the owner after ${APPROVAL_WAIT_MS / 60000} minutes.`,
312
+ inputSchema: {
313
+ type: 'object',
314
+ properties: {
315
+ id: { type: 'string', description: 'The id from the [Approval <id>] line.' },
316
+ decision: { type: 'string', enum: ['allow', 'deny', 'owner'] },
317
+ reason: { type: 'string', description: 'For deny: what the worker should do instead.' },
318
+ },
319
+ required: ['id', 'decision'],
320
+ additionalProperties: false,
321
+ },
322
+ };
323
+
324
+ export const TOOLS = [POST_JOB_TOOL, LIST_JOBS_TOOL, READ_JOB_TOOL, EDIT_JOB_TOOL, FINISH_JOB_TOOL, LIST_AGENTS_TOOL, SEND_MESSAGE_TOOL];
325
+ const BILLION_TOOLS = [BILLION_READY_TOOL, ADD_REPO_TOOL, CLOSE_JOB_TOOL, ANSWER_PERMISSION_TOOL];
326
+
327
+ export function toolsFor(session) {
328
+ return session?.isBillion ? [...TOOLS, ...BILLION_TOOLS] : TOOLS;
329
+ }
330
+
331
+ const ok = (id, result) => ({ jsonrpc: '2.0', id, result });
332
+ const fail = (id, code, message) => ({ jsonrpc: '2.0', id, error: { code, message } });
333
+
334
+ // A tool that failed is not a protocol error: MCP reports it as a normal result
335
+ // with isError, so the model reads the reason and can correct itself. A JSON-RPC
336
+ // error would surface to the agent as "the tool is broken" instead.
337
+ const toolText = (text, isError = false) => ({ content: [{ type: 'text', text }], isError });
338
+
339
+ // A stored time as the server's clock reads it (agents talk to this board over
340
+ // loopback, so that is the reader's clock too). The stored value is ISO; an
341
+ // agent reporting "next 2026-09-04T09:00:00.000Z" to a person is making them do
342
+ // the conversion.
343
+ const when = (iso) => (iso ? new Date(iso).toLocaleString() : null);
344
+
345
+ // The cron and its next firing, built once: four builders used to spell this
346
+ // out with three different separators, so the same fact read three ways.
347
+ const scheduleText = (job, sep = ', next ') =>
348
+ `${job.schedule}${job.nextRunAt ? `${sep}${when(job.nextRunAt)}` : ''}`;
349
+
350
+ // One line per card: what it is, and the id needed to read or edit it. Kept
351
+ // lean deliberately — who posted it and the whole detail body are what read_job
352
+ // is for, and a board of twenty cards is answering "what is queued?", not
353
+ // twenty questions.
354
+ function summaryLine(job) {
355
+ const bits = [job.repo];
356
+ if (job.agent === 'codex') bits.push('codex');
357
+ if (job.type === 'scheduled') {
358
+ bits.push(`schedule ${scheduleText(job)}`);
359
+ }
360
+ if (job.scheduleId) bits.push('a scheduled run');
361
+ // The live state of the agent working it, when there is one, is the part a
362
+ // person actually asks about ("is it stuck?").
363
+ if (job.agentName) bits.push(`${job.agentName}${job.status ? ` ${job.status}` : ''}`);
364
+ // So an agent can tell its own cards apart without a read_job per card.
365
+ if (job.postedByAgent) bits.push(`posted by ${job.postedByAgent}`);
366
+ if (job.prUrl) bits.push(job.prUrl);
367
+ return ` ${job.id} ${job.title}\n ${bits.filter(Boolean).join(' · ')}`;
368
+ }
369
+
370
+ const CALLS = {
371
+ [POST_JOB_TOOL.name]: (args, ctx) => {
372
+ const result = ctx.postJob({
373
+ title: args.title,
374
+ detail: args.detail,
375
+ repo: args.repo,
376
+ schedule: args.schedule,
377
+ agent: args.agent,
378
+ requiresPr: args.requires_pr,
379
+ session: ctx.session || null,
380
+ });
381
+ if (result.error) return toolText(result.error, true);
382
+
383
+ const where = result.repoName ? ` in ${result.repoName}` : '';
384
+ // Read back the schedule the board actually stored, and when it next fires.
385
+ // A cron expression is easy to get subtly wrong ("0 0 * * 0" is not weekly
386
+ // to everyone), and a concrete next-run time is what makes the mistake
387
+ // visible while the user is still in the conversation to correct it.
388
+ const fires = result.job.schedule ? ` on a schedule (${scheduleText(result.job)})` : '';
389
+ const column = result.job.schedule ? '' : result.job.requiresPr === false ? ' (To do, no pull request)' : ' (To do)';
390
+ const line = `Posted "${result.job.title}"${where}${fires} to the Agent 007 job board${column}.`;
391
+ // The dispatcher note matters: with the board stopped the card sits there
392
+ // doing nothing, and an agent reporting "queued it" without saying so would
393
+ // leave the user believing work had started.
394
+ const note = result.dispatcherRunning
395
+ ? ''
396
+ : ' The board dispatcher is stopped, so it waits there until the board is started.';
397
+ // The id, because editing a card needs it and the agent has it right here.
398
+ return toolText(`${line}${note}\nid: ${result.job.id}`);
399
+ },
400
+
401
+ [LIST_JOBS_TOOL.name]: (args, ctx) => {
402
+ const result = ctx.listJobs({ state: args.state, repo: args.repo });
403
+ if (result.error) return toolText(result.error, true);
404
+
405
+ const scope = [result.state ? STATE_LABELS[result.state] : null, result.repoName]
406
+ .filter(Boolean).join(' · ');
407
+ if (!result.jobs.length) {
408
+ // An empty board and a filtered-out board read the same otherwise, and
409
+ // the archive is invisible by default — say which this is.
410
+ const archive = result.archived ? ` ${result.archived} finished card(s) are archived (state: "done").` : '';
411
+ return toolText(`Nothing on the Agent 007 job board${scope ? ` for ${scope}` : ''}.${archive}`);
412
+ }
413
+ // Grouped by column in board order, so the shape of the answer is the shape
414
+ // of the board the user is looking at.
415
+ const groups = JOB_STATES
416
+ .map(state => [state, result.jobs.filter(job => job.state === state)])
417
+ .filter(([, jobs]) => jobs.length)
418
+ .map(([state, jobs]) => `${STATE_LABELS[state]} (${jobs.length})\n${jobs.map(summaryLine).join('\n')}`);
419
+ const head = `${result.jobs.length} card(s) on the Agent 007 job board${scope ? ` — ${scope}` : ''}:`;
420
+ const archive = result.archived
421
+ ? `\n\n${result.archived} finished card(s) are archived off the board (state: "done").`
422
+ : '';
423
+ return toolText(`${head}\n\n${groups.join('\n\n')}${archive}`);
424
+ },
425
+
426
+ [READ_JOB_TOOL.name]: (args, ctx) => {
427
+ const result = ctx.readJob(args.id);
428
+ if (result.error) return toolText(result.error, true);
429
+ const job = result.job;
430
+ const lines = [
431
+ `${job.title}`,
432
+ `id: ${job.id}`,
433
+ `column: ${STATE_LABELS[job.state] || job.state}${job.status ? ` (${job.status})` : ''}`,
434
+ `repo: ${job.repo}`,
435
+ // Only when it is not the default, the way the card's chip works.
436
+ job.agent === 'codex' ? 'runs on: codex' : null,
437
+ job.type === 'scheduled'
438
+ ? `schedule: ${scheduleText(job, ' — next ')}`
439
+ + `${job.runCount ? ` — posted ${job.runCount} run(s), last ${when(job.lastRunAt)}` : ''}`
440
+ + `${job.requiresPr ? ', each run opens a pull request' : ''}`
441
+ + `${job.lastSkipReason ? ` — last held off: ${job.lastSkipReason}` : ''}`
442
+ : `schedule: runs once${job.requiresPr ? '' : ', no pull request'}`
443
+ + `${job.scheduleId ? ` (a run of schedule ${job.scheduleId})` : ''}`,
444
+ `posted: ${when(job.postedAt)}`
445
+ + `${job.postedByName ? ` by ${job.postedByName}` : ''}`
446
+ + `${job.postedByAgent ? ` (typed by ${job.postedByAgent})` : ''}`,
447
+ job.agentName ? `agent: ${job.agentName}, started ${when(job.startedAt)}` : null,
448
+ job.branchName ? `branch: ${job.branchName}` : null,
449
+ job.prUrl ? `pull request: ${job.prUrl}${job.prMergedAt ? ` (merged ${when(job.prMergedAt)})` : job.prClosedAt ? ` (closed without merging ${when(job.prClosedAt)})` : ''}` : null,
450
+ job.resultSummary ? `result: ${job.resultSummary}` : null,
451
+ job.attachments.length ? `attachments: ${job.attachments.join(', ')}` : null,
452
+ // Whoever last changed the text, so a card an agent rewrote never reads
453
+ // as if the person who queued it wrote what is there now.
454
+ job.editedByAgent ? `edited by ${job.editedByAgent}${job.editedAt ? ` on ${when(job.editedAt)}` : ''}` : null,
455
+ // Surfaced, not swallowed: a card that failed to dispatch looks identical
456
+ // to one waiting its turn unless the reason is said out loud.
457
+ job.lastError ? `last error: ${job.lastError}` : null,
458
+ job.prCheckError ? `pull request check: ${job.prCheckError}` : null,
459
+ // Kept in step by hand with editableInPlace in server/jobs.js and with
460
+ // EDIT_JOB_TOOL's description above: three statements of one rule.
461
+ job.state === 'todo' ? null : 'This card has left To do, so edit_job can no longer change it.',
462
+ '',
463
+ job.detail || '(no detail on this card)',
464
+ ];
465
+ return toolText(lines.filter(line => line !== null).join('\n'));
466
+ },
467
+
468
+ [EDIT_JOB_TOOL.name]: (args, ctx) => {
469
+ const result = ctx.editJob({
470
+ id: args.id,
471
+ title: args.title,
472
+ detail: args.detail,
473
+ repo: args.repo,
474
+ schedule: args.schedule,
475
+ requiresPr: args.requires_pr,
476
+ });
477
+ if (result.error) return toolText(result.error, true);
478
+ const job = result.job;
479
+ const fires = job.type === 'scheduled' ? ` It runs ${scheduleText(job)}.` : '';
480
+ return toolText(
481
+ `Updated ${result.changed.join(', ')} on "${job.title}" (${job.repo}), still in To do.${fires}`,
482
+ );
483
+ },
484
+
485
+ // Async: checking the PR is a network call. handleMcpMessage passes the
486
+ // promise through, and the route awaits it.
487
+ [FINISH_JOB_TOOL.name]: async (args, ctx) => {
488
+ const result = await ctx.finishJob({ prUrl: args.pr_url, summary: args.summary });
489
+ if (result.error) return toolText(result.error, true);
490
+ return toolText(`"${result.job.title}" is in Review. You are done — end your turn here.`);
491
+ },
492
+
493
+ [BILLION_READY_TOOL.name]: (args, ctx) => {
494
+ const result = ctx.billionReady ? ctx.billionReady() : { error: 'Only Billion has an inbox to open.' };
495
+ if (result.error) return toolText(result.error, true);
496
+ return toolText(result.waiting
497
+ ? `Inbox open. ${result.waiting} message(s) will arrive one at a time as you come to rest at your prompt.`
498
+ : 'Inbox open. Nothing is waiting.');
499
+ },
500
+
501
+ [ADD_REPO_TOOL.name]: async (args, ctx) => {
502
+ const result = await ctx.addRepo(args.path);
503
+ if (result.error) return toolText(result.error, true);
504
+ return toolText(`${result.path} is on the board as "${result.slug}". post_job can use it now.`);
505
+ },
506
+
507
+ [CLOSE_JOB_TOOL.name]: async (args, ctx) => {
508
+ const result = await ctx.closeJob({ id: args.id, accept: args.accept === true, note: args.note });
509
+ if (result.error) return toolText(result.error, true);
510
+ return toolText(result.accepted
511
+ ? `"${result.job.title}" is Done and its worker is closed.`
512
+ : `"${result.job.title}" is back in To do with your note; a fresh worker picks it up on the next dispatch.`
513
+ + (result.oldPrUrl ? ` Its old pull request is still open: close ${result.oldPrUrl}.` : ''));
514
+ },
515
+
516
+ [ANSWER_PERMISSION_TOOL.name]: (args, ctx) => {
517
+ const result = ctx.answerPermission({ id: args.id, decision: args.decision, reason: args.reason });
518
+ if (result.error) return toolText(result.error, true);
519
+ if (result.cut) return toolText(`That request was cut short, so your allow went to the owner instead: ${result.worker}'s dialog is showing for them now.`);
520
+ return toolText(result.choice === 'owner'
521
+ ? `Left to the owner: ${result.worker}'s dialog is showing for them now.`
522
+ : `${result.worker} has your answer: ${result.choice}.`);
523
+ },
524
+
525
+ [LIST_AGENTS_TOOL.name]: (args, ctx) => {
526
+ const agents = ctx.listAgents();
527
+ if (!agents.length) return toolText('No other agents are running that you can message.');
528
+ const lines = agents.map(a => {
529
+ const bits = [a.agent, a.repoSlug, a.branchName, a.state?.toLowerCase(),
530
+ a.jobTitle ? `job: ${a.jobTitle}` : null,
531
+ a.pending ? `${a.pending} message(s) waiting for it` : null];
532
+ return ` ${a.name}\n ${bits.filter(Boolean).join(' · ')}`;
533
+ });
534
+ return toolText(`${agents.length} agent(s) you can message:\n${lines.join('\n')}`);
535
+ },
536
+
537
+ [SEND_MESSAGE_TOOL.name]: (args, ctx) => {
538
+ const result = ctx.sendMessage({ to: args.to, text: args.message });
539
+ if (result.error) return toolText(result.error, true);
540
+ // Say which, so an agent does not report "asked it" and then wait on an
541
+ // answer that cannot come until the other one stops working.
542
+ return toolText(result.delivered
543
+ ? `Delivered to ${result.to.name}. Its reply, if any, will arrive as a message in this terminal.`
544
+ : `Queued for ${result.to.name} (position ${result.queued}); it gets the message once it is next free at its prompt. Its reply, if any, will arrive as a message in this terminal.`);
545
+ },
546
+ };
547
+
548
+ /**
549
+ * Handle one JSON-RPC message.
550
+ *
551
+ * @param msg parsed JSON-RPC request or notification
552
+ * @param ctx { session, postJob, listJobs, readJob, editJob, finishJob, listAgents,
553
+ * sendMessage } — the agent
554
+ * this token belongs to, and the injected board functions
555
+ * (server/jobs.js, the write ones bound to a broadcast), kept
556
+ * as parameters so this module never imports the job store.
557
+ * @returns the reply object, or null when the message is a notification — or
558
+ * a promise of the reply, for a tool that has to wait (finish_job).
559
+ */
560
+ export function handleMcpMessage(msg, ctx = {}) {
561
+ const { id, method, params } = msg || {};
562
+ // JSON-RPC notifications carry no id and MUST NOT be answered. The client
563
+ // sends notifications/initialized right after the handshake.
564
+ const isNotification = id === undefined || id === null;
565
+
566
+ if (method === 'initialize') {
567
+ return ok(id, {
568
+ protocolVersion: params?.protocolVersion || DEFAULT_PROTOCOL_VERSION,
569
+ capabilities: { tools: { listChanged: false } },
570
+ serverInfo: SERVER_INFO,
571
+ });
572
+ }
573
+
574
+ if (isNotification) return null;
575
+
576
+ if (method === 'ping') return ok(id, {});
577
+ if (method === 'tools/list') return ok(id, { tools: toolsFor(ctx.session) });
578
+
579
+ if (method === 'tools/call') {
580
+ // hasOwn, not truthiness: a plain object inherits Object.prototype, so a
581
+ // call naming "valueOf" or "toString" would otherwise find a function on
582
+ // the chain and run it — a 500 for the first, and a result of
583
+ // "[object Undefined]" for the second, neither of them a tool.
584
+ const name = params?.name;
585
+ if (typeof name !== 'string' || !Object.hasOwn(CALLS, name)
586
+ || !toolsFor(ctx.session).some(tool => tool.name === name)) {
587
+ return fail(id, -32602, `Unknown tool: ${name}`);
588
+ }
589
+ const result = CALLS[name](params?.arguments || {}, ctx);
590
+ return result instanceof Promise ? result.then(r => ok(id, r)) : ok(id, result);
591
+ }
592
+
593
+ // Everything else, including the client's own discovery probes. JSON-RPC says
594
+ // method-not-found; Claude Code handles it and carries on.
595
+ return fail(id, -32601, `Method not found: ${method}`);
596
+ }