@bill10/agent-007 0.10.0 → 0.12.0

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/public/style.css CHANGED
@@ -752,11 +752,6 @@ body {
752
752
  /* The one way back for a stopped Billion, so it reads as the row's action. */
753
753
  .explorer-billion-start { margin-left: auto; color: var(--accent); }
754
754
  /* notify_owner's questions, under Billion's row until dismissed */
755
- .explorer-waiting { list-style: none; margin: 0 0 4px; padding: 0 10px 0 22px; font-size: 11px; }
756
- .explorer-waiting-head { color: var(--accent); font-weight: 600; padding: 2px 0; }
757
- .explorer-waiting-item { display: flex; align-items: flex-start; gap: 4px; color: var(--text-muted); padding: 2px 0; }
758
- .explorer-waiting-item span { flex: 1; min-width: 0; overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
759
- .explorer-waiting-item button { flex-shrink: 0; }
760
755
 
761
756
  .explorer-branch-name {
762
757
  overflow: hidden;
@@ -1181,7 +1176,7 @@ body {
1181
1176
  .mobile-nav { display: none; }
1182
1177
  @media (max-width: 700px) {
1183
1178
  .divider, .explorer-reopen-btn, #btn-toggle-explorer { display: none !important; }
1184
- .explorer-billion-start, .explorer-waiting-item button { min-width: 44px; min-height: 44px; }
1179
+ .explorer-billion-start, .waiting-dismiss { min-width: 44px; min-height: 44px; }
1185
1180
  #explorer-panel, #office-panel, #terminal-panel {
1186
1181
  display: none; width: 100% !important; min-width: 0; max-width: none; flex: 1;
1187
1182
  }
@@ -1207,6 +1202,10 @@ body {
1207
1202
  cursor: pointer;
1208
1203
  }
1209
1204
  .mobile-nav button:hover, .mobile-nav button[aria-current="true"] { color: var(--accent); }
1205
+ .mobile-nav-waiting { display: inline-flex; align-items: center; justify-content: center; gap: 5px; }
1206
+ .mobile-nav-waiting .board-tab-badge { letter-spacing: 0; }
1207
+ .waiting-choice, .waiting-send { min-height: 44px; }
1208
+ .waiting-reply input { min-height: 44px; font-size: 16px; }
1210
1209
  /* Overlays sized for desktop */
1211
1210
  .spawn-form-inner, .dir-browser-inner { width: auto; max-width: 100%; }
1212
1211
  .spawn-form-main, .spawn-presets { flex-wrap: wrap; }
@@ -1302,6 +1301,117 @@ body {
1302
1301
  text-align: center;
1303
1302
  }
1304
1303
 
1304
+ /* ============================================================
1305
+ Waiting on you
1306
+ Billion's questions to the owner. A pinned tab next to Jobs,
1307
+ same viewport, same badge; each open question is a card with
1308
+ its choices and a reply line.
1309
+ ============================================================ */
1310
+
1311
+ .waiting-board {
1312
+ position: absolute;
1313
+ inset: 0;
1314
+ display: flex;
1315
+ flex-direction: column;
1316
+ background: var(--bg-panel);
1317
+ overflow-y: auto;
1318
+ z-index: 5;
1319
+ }
1320
+ .waiting-list {
1321
+ display: flex;
1322
+ flex-direction: column;
1323
+ gap: 10px;
1324
+ width: 100%;
1325
+ max-width: 720px;
1326
+ padding: 14px 12px 24px;
1327
+ }
1328
+ .waiting-empty { color: var(--text-dim); font-size: 12px; padding: 24px 2px; }
1329
+
1330
+ .waiting-card {
1331
+ display: flex;
1332
+ flex-direction: column;
1333
+ gap: 8px;
1334
+ padding: 10px 12px;
1335
+ background: var(--bg-dark);
1336
+ border: 1px solid var(--border);
1337
+ border-left: 2px solid var(--state-message);
1338
+ border-radius: 3px;
1339
+ }
1340
+ .waiting-card-head { display: flex; align-items: center; gap: 8px; }
1341
+ .waiting-card-n { color: var(--accent); font-size: 11px; font-weight: 700; flex-shrink: 0; }
1342
+ .waiting-card-time { color: var(--text-dim); font-size: 10px; flex: 1; }
1343
+ .waiting-card-text {
1344
+ margin: 0;
1345
+ color: var(--text);
1346
+ font-size: 12px;
1347
+ line-height: 1.5;
1348
+ white-space: pre-wrap;
1349
+ overflow-wrap: anywhere;
1350
+ }
1351
+
1352
+ .waiting-choices { display: flex; flex-wrap: wrap; gap: 6px; }
1353
+ .waiting-choice {
1354
+ display: inline-flex;
1355
+ align-items: center;
1356
+ gap: 6px;
1357
+ padding: 5px 12px;
1358
+ font-size: 12px;
1359
+ font-family: inherit;
1360
+ color: var(--text);
1361
+ background: transparent;
1362
+ border: 1px solid var(--border);
1363
+ border-radius: 3px;
1364
+ cursor: pointer;
1365
+ }
1366
+ .waiting-choice:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
1367
+ .waiting-choice.recommended { border-color: var(--accent); }
1368
+ .waiting-choice-tag {
1369
+ color: var(--accent);
1370
+ font-size: 9px;
1371
+ letter-spacing: 1px;
1372
+ text-transform: uppercase;
1373
+ }
1374
+
1375
+ .waiting-reply { display: flex; gap: 6px; }
1376
+ .waiting-reply input {
1377
+ flex: 1;
1378
+ min-width: 0;
1379
+ padding: 5px 8px;
1380
+ font-size: 12px;
1381
+ font-family: inherit;
1382
+ color: var(--text);
1383
+ background: var(--bg-panel);
1384
+ border: 1px solid var(--border);
1385
+ border-radius: 3px;
1386
+ }
1387
+ .waiting-reply input:focus { outline: none; border-color: var(--accent); }
1388
+ .waiting-send {
1389
+ padding: 5px 12px;
1390
+ font-size: 11px;
1391
+ font-family: inherit;
1392
+ color: var(--accent-contrast);
1393
+ background: var(--accent);
1394
+ border: none;
1395
+ border-radius: 3px;
1396
+ cursor: pointer;
1397
+ }
1398
+ .waiting-card :disabled { opacity: 0.55; cursor: default; }
1399
+ .waiting-error { margin: 0; color: var(--state-disconnected); font-size: 11px; }
1400
+
1401
+ .waiting-answered { color: var(--text-muted); font-size: 11px; margin-top: 6px; }
1402
+ .waiting-answered summary {
1403
+ cursor: pointer;
1404
+ color: var(--text-dim);
1405
+ font-size: 9px;
1406
+ letter-spacing: 2px;
1407
+ text-transform: uppercase;
1408
+ padding: 4px 0;
1409
+ }
1410
+ .waiting-answered ul { list-style: none; margin: 4px 0 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
1411
+ .waiting-answered-item { display: grid; grid-template-columns: auto 1fr; column-gap: 8px; row-gap: 2px; }
1412
+ .waiting-answered-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1413
+ .waiting-answered-answer { grid-column: 2; color: var(--text-dim); overflow-wrap: anywhere; }
1414
+
1305
1415
  /* An agent the board created, not you. Subtle — it is still an ordinary agent
1306
1416
  you can type into; the marker only explains where it came from. */
1307
1417
  .terminal-tab.board-spawned .dot {
package/server/http.js CHANGED
@@ -18,6 +18,7 @@ import { requestApproval, answerApproval } from './approvals.js';
18
18
  import { agentSummaries, sendMessage, flushMessages, pendingMessages, readAgentScreen } from './messages.js';
19
19
  import { handleMcpMessage } from './mcp.js';
20
20
  import { notifyOwner } from './owner.js';
21
+ import { availableModels } from './models.js';
21
22
 
22
23
  // --- Origin Check Middleware (B2) ---
23
24
  // Rejects cross-origin requests from disallowed origins. localhost is always
@@ -106,6 +107,7 @@ export function setupRoutes(app, staticDir, { broadcast, killSession, respawnAge
106
107
  try {
107
108
  reply = await handleMcpMessage(req.body, {
108
109
  session: req.agentSession,
110
+ models: availableModels(),
109
111
  postJob: (fields) => postJobForAgent({ ...fields, user: userById(req.agentSession.ownerId) }, broadcast),
110
112
  listJobs: listJobsForAgent,
111
113
  readJob: readJobForAgent,
@@ -130,8 +132,8 @@ export function setupRoutes(app, staticDir, { broadcast, killSession, respawnAge
130
132
  answerPermission: ({ id, decision, reason }) => (req.agentSession.isBillion
131
133
  ? answerApproval(id, decision, reason)
132
134
  : { error: 'Only Billion answers permission requests.' }),
133
- notifyOwner: (text) => (req.agentSession.isBillion
134
- ? notifyOwner(text, { broadcast })
135
+ notifyOwner: (text, { choices, recommended } = {}) => (req.agentSession.isBillion
136
+ ? notifyOwner(text, { choices, recommended, broadcast })
135
137
  : { error: 'Only Billion can notify the owner.' }),
136
138
  // Never logged: a screen can hold a secret that scrolled by.
137
139
  readAgentScreen: ({ name, lines }) => readAgentScreen({
@@ -195,6 +197,7 @@ export function setupRoutes(app, staticDir, { broadcast, killSession, respawnAge
195
197
  type: body.type,
196
198
  schedule: body.schedule,
197
199
  agent: body.agent,
200
+ model: body.model,
198
201
  requiresPr: body.requiresPr ?? body.requires_pr,
199
202
  session,
200
203
  user: req.user || (session ? userById(session.ownerId) : null),
package/server/jobs.js CHANGED
@@ -27,8 +27,9 @@ import {
27
27
  MAX_TITLE_LEN, MAX_DETAIL_LEN, isScheduled, jobType, resolveJobType, jobRequiresPr,
28
28
  scheduleHold, supersededRuns, createRunJob, runsToPrune, defaultRequiresPr, isJobDue, STATE_LABELS,
29
29
  jobAgent, jobAgentFromCommand, resolveJobAgent, resumeCommand, isValidJobAgent, recordedPermissionFlags,
30
- BILLION_NAME, envPermissionMode,
30
+ BILLION_NAME, envPermissionMode, resolveJobModel,
31
31
  } from '../lib/jobs.js';
32
+ import { availableModels } from './models.js';
32
33
  import { nextCronIso } from '../lib/cron.js';
33
34
 
34
35
  // --- Board settings ---
@@ -127,7 +128,8 @@ export function jobsPayload() {
127
128
  // The .env defaults ride along, so the toolbar can show the mode workers
128
129
  // really start in while no mode has been picked there.
129
130
  const envModes = { claude: envPermissionMode('claude'), codex: envPermissionMode('codex') };
130
- return { type: 'jobs-list', jobs, settings: { ...boardSettings(), envModes } };
131
+ // The models each CLI's dropdown offers (server/models.js).
132
+ return { type: 'jobs-list', jobs, settings: { ...boardSettings(), envModes }, models: availableModels() };
131
133
  }
132
134
 
133
135
  export function broadcastJobs(broadcast) {
@@ -303,8 +305,8 @@ function clearFinishedAttachments() {
303
305
 
304
306
  // --- CRUD ---
305
307
 
306
- export function addJob({ title, detail, repoPath, type, schedule, permissionMode, agent, requiresPr, postedBy, postedByName, postedByAgent, postedByBillion, attachments }, broadcast) {
307
- const result = createJob({ title, detail, repoPath, type, schedule, permissionMode, agent, requiresPr, postedBy, postedByName, postedByAgent, postedByBillion });
308
+ export function addJob({ title, detail, repoPath, type, schedule, permissionMode, agent, model, requiresPr, postedBy, postedByName, postedByAgent, postedByBillion, attachments }, broadcast) {
309
+ const result = createJob({ title, detail, repoPath, type, schedule, permissionMode, agent, model, availableModels: availableModels(), requiresPr, postedBy, postedByName, postedByAgent, postedByBillion });
308
310
  if (result.error) return result;
309
311
  const plan = planAttachments(result.job, attachments);
310
312
  if (plan?.error) return plan;
@@ -366,7 +368,7 @@ function scheduleTypeError(schedule) {
366
368
  : null;
367
369
  }
368
370
 
369
- export function postJobForAgent({ title, detail, repo, schedule, type, agent, requiresPr, session, user }, broadcast) {
371
+ export function postJobForAgent({ title, detail, repo, schedule, type, agent, model, requiresPr, session, user }, broadcast) {
370
372
  // The repo the calling agent is working in is the overwhelmingly likely
371
373
  // answer, so an agent only names one when it means a different repo.
372
374
  const resolved = resolveRepoRef(repo || (session && session.repoPath) || '');
@@ -403,6 +405,9 @@ export function postJobForAgent({ title, detail, repo, schedule, type, agent, re
403
405
  // Unnamed, the card runs on the same CLI as the agent posting it; a person
404
406
  // at the HTTP door with no session gets the board default.
405
407
  agent: agent || (session ? jobAgentFromCommand(session.command) : undefined),
408
+ // Checked against the discovered list by createJob, and put on the argv
409
+ // as one token by buildJobCommand.
410
+ model,
406
411
  requiresPr,
407
412
  // No permissionMode: an agent posting a card must not be able to pick the
408
413
  // mode the board will spawn with, which would be a way around every gate
@@ -450,6 +455,7 @@ function jobSummary(job) {
450
455
  state: job.state,
451
456
  type: jobType(job),
452
457
  agent: jobAgent(job),
458
+ model: job.model || null,
453
459
  requiresPr: jobRequiresPr(job),
454
460
  schedule: job.schedule || null,
455
461
  nextRunAt: job.nextRunAt || null,
@@ -543,7 +549,7 @@ export function readJobForAgent(jobId) {
543
549
  // land says so out loud and leaves its name on the card, because the whole
544
550
  // hazard is an edit nobody sees. Reading stays board-wide — every browser
545
551
  // already sees every card — but writing does not.
546
- export function editJobForAgent({ id, title, detail, repo, schedule, requiresPr, session, user }, broadcast) {
552
+ export function editJobForAgent({ id, title, detail, repo, schedule, model, requiresPr, session, user }, broadcast) {
547
553
  const job = allJobs().find(j => j.id === id);
548
554
  if (!job) return { error: `No job with id "${id}" — list the board to see the ids.` };
549
555
  const gate = editableInPlace(job);
@@ -593,6 +599,11 @@ export function editJobForAgent({ id, title, detail, repo, schedule, requiresPr,
593
599
  fields.type = text ? 'scheduled' : 'one-time';
594
600
  if (text !== (job.schedule || '')) changed.push('schedule');
595
601
  }
602
+ if (model !== undefined && (model || null) !== (job.model || null)) {
603
+ // updateJob validates it against the card's agent.
604
+ fields.model = model;
605
+ changed.push('model');
606
+ }
596
607
  if (requiresPr !== undefined) {
597
608
  if (typeof requiresPr !== 'boolean') return { error: 'requires_pr must be true or false' };
598
609
  // Always passed on, so a type change in the same call cannot swap it for
@@ -602,7 +613,7 @@ export function editJobForAgent({ id, title, detail, repo, schedule, requiresPr,
602
613
  }
603
614
 
604
615
  if (!changed.length) {
605
- return { error: 'Nothing to change — pass a new title, detail, repo, schedule or requires_pr.' };
616
+ return { error: 'Nothing to change — pass a new title, detail, repo, schedule, model or requires_pr.' };
606
617
  }
607
618
  const result = updateJob(job.id, fields, broadcast);
608
619
  if (result.error) return result;
@@ -815,6 +826,16 @@ export function updateJob(jobId, fields, broadcast) {
815
826
  cli = resolveJobAgent(fields.agent);
816
827
  if (cli.error) return { error: cli.error };
817
828
  }
829
+ // Checked against the agent the card will have. A card switched to another
830
+ // CLI with no model named drops its old one, which belongs to the old CLI.
831
+ const nextAgent = cli ? cli.agent : jobAgent(job);
832
+ let chosen = null;
833
+ if (fields.model !== undefined) {
834
+ chosen = resolveJobModel(fields.model, nextAgent, availableModels());
835
+ if (chosen.error) return { error: chosen.error };
836
+ } else if (nextAgent !== jobAgent(job)) {
837
+ chosen = { model: null };
838
+ }
818
839
  // Type and schedule move together: "scheduled with no cron" and "one-time
819
840
  // carrying a cron" are both incoherent, so they are resolved as a pair and
820
841
  // rejected as a pair.
@@ -839,6 +860,7 @@ export function updateJob(jobId, fields, broadcast) {
839
860
  if (fields.repoPath) job.repoPath = fields.repoPath;
840
861
  if (mode) job.permissionMode = mode.permissionMode;
841
862
  if (cli) job.agent = cli.agent;
863
+ if (chosen) job.model = chosen.model;
842
864
  const typeChanged = !!resolved && resolved.type !== jobType(job);
843
865
  if (resolved) {
844
866
  job.type = resolved.type;
@@ -1327,7 +1349,10 @@ export function orphanResumePlan(orphan, homes) {
1327
1349
  const sessionId = agent !== 'codex' ? null
1328
1350
  : probed ? probed.codexSessionId
1329
1351
  : codexSessionIdFor(orphan.worktreePath, homes);
1330
- return { agent, mode, flags, command: resumeCommand(agent, mode, flags, sessionId) };
1352
+ // The card's model, when the card is for this CLI: a worker picked for a
1353
+ // strong model comes back on it, not the CLI's default.
1354
+ const model = card && jobAgent(card) === agent ? card.model || null : null;
1355
+ return { agent, mode, flags, command: resumeCommand(agent, mode, flags, sessionId, model) };
1331
1356
  }
1332
1357
 
1333
1358
  export function resumeCommandForOrphan(orphan, homes) {
package/server/mcp.js CHANGED
@@ -22,6 +22,7 @@
22
22
  import { JOB_STATES, STATE_LABELS, JOB_AGENTS } from '../lib/jobs.js';
23
23
  import { APPROVAL_WAIT_MS } from './agent-mcp.js';
24
24
  import { SCREEN_LINES_DEFAULT, SCREEN_LINES_MAX, quoteLines, oneLine } from './messages.js';
25
+ import { MAX_CHOICES, MAX_CHOICE_CHARS } from './owner.js';
25
26
 
26
27
  // Echoed back from the client's own initialize when it sends one. MCP clients
27
28
  // negotiate this, and answering with whatever the client asked for is the
@@ -35,6 +36,12 @@ export const SERVER_INFO = { name: 'agent-007-board', version: '1' };
35
36
  // for and — deliberately — when to reach for it. "When the user asks" is the
36
37
  // operative clause: a job board full of work an agent queued for itself is not
37
38
  // what this is for.
39
+ // The model field's description. toolsFor appends the models discovered on
40
+ // this machine right now, since those are the only values the board accepts.
41
+ const MODEL_HELP = 'Optional. Which model the card\'s CLI runs, from the list below for its '
42
+ + 'agent; empty for the CLI\'s default. A strong model for core code, security and '
43
+ + 'debugging; a fast one for docs, mechanical edits and research.';
44
+
38
45
  export const POST_JOB_TOOL = {
39
46
  name: 'post_job',
40
47
  description:
@@ -83,6 +90,10 @@ export const POST_JOB_TOOL = {
83
90
  'Optional. Which CLI the board spawns for this card: claude (Claude Code) '
84
91
  + 'or codex. Defaults to the one you are running as.',
85
92
  },
93
+ model: {
94
+ type: 'string',
95
+ description: MODEL_HELP,
96
+ },
86
97
  requires_pr: {
87
98
  type: 'boolean',
88
99
  description:
@@ -151,7 +162,7 @@ export const EDIT_JOB_TOOL = {
151
162
  name: 'edit_job',
152
163
  description:
153
164
  'Change a card that is still in To do: its title, detail, repository, '
154
- + 'schedule or whether it requires a pull request. Only To do cards can be edited — once the board has dispatched a '
165
+ + 'schedule, model or whether it requires a pull request. Only To do cards can be edited — once the board has dispatched a '
155
166
  + 'card its agent has already been handed the text, so a later edit would leave '
156
167
  + 'the card describing work nobody was asked to do. Pass only the fields that '
157
168
  + 'change; the rest are left alone. Ids come from list_jobs.',
@@ -176,6 +187,10 @@ export const EDIT_JOB_TOOL = {
176
187
  'Replaces the cron schedule (five fields, or an @shorthand). Pass an empty '
177
188
  + 'string to turn a scheduled card back into one that runs once.',
178
189
  },
190
+ model: {
191
+ type: 'string',
192
+ description: `${MODEL_HELP} Switching the card's agent without naming a model clears it.`,
193
+ },
179
194
  requires_pr: {
180
195
  type: 'boolean',
181
196
  description:
@@ -326,15 +341,23 @@ export const NOTIFY_OWNER_TOOL = {
326
341
  name: 'notify_owner',
327
342
  description:
328
343
  'Put a question or a decision in front of the owner when they may be away '
329
- + 'from the terminal: it is pinned in the "Waiting on you" list at the top of '
330
- + 'the owner\'s browser, and sent to their phone over Telegram when that is set '
331
- + 'up. One short message: the question, why, and what you recommend. Their '
332
- + 'reply, if they answer from Telegram, arrives in this terminal as '
333
- + '"[Owner via Telegram] <text>". At most a few per minute.',
344
+ + 'from the terminal: it goes in the "Waiting on you" tab of the owner\'s '
345
+ + 'browser, numbered (Q3), and to their phone over Telegram when that is set '
346
+ + 'up. One short message: the question, why, and what you recommend. When the '
347
+ + 'answer is a pick, pass choices (yes/no, maybe one alternative) and mark the '
348
+ + 'one you recommend: the owner answers with one tap. Their answer arrives in '
349
+ + 'this terminal as "[Owner via app] Q3: <answer>" or "[Owner via Telegram] Q3: '
350
+ + '<answer>". At most a few per minute.',
334
351
  inputSchema: {
335
352
  type: 'object',
336
353
  properties: {
337
354
  text: { type: 'string', description: 'The message, written to be read on a phone.' },
355
+ choices: {
356
+ type: 'array', minItems: 2, maxItems: MAX_CHOICES,
357
+ items: { type: 'string', minLength: 1, maxLength: MAX_CHOICE_CHARS },
358
+ description: `2 to ${MAX_CHOICES} short answers the owner can tap. The owner can still type something else.`,
359
+ },
360
+ recommended: { type: 'string', description: 'The choice you recommend; must be one of choices.' },
338
361
  },
339
362
  required: ['text'],
340
363
  additionalProperties: false,
@@ -389,8 +412,21 @@ export const RESPAWN_AGENT_TOOL = {
389
412
  export const TOOLS = [POST_JOB_TOOL, LIST_JOBS_TOOL, READ_JOB_TOOL, EDIT_JOB_TOOL, FINISH_JOB_TOOL, LIST_AGENTS_TOOL, SEND_MESSAGE_TOOL];
390
413
  const BILLION_TOOLS = [BILLION_READY_TOOL, ADD_REPO_TOOL, CLOSE_JOB_TOOL, ANSWER_PERMISSION_TOOL, NOTIFY_OWNER_TOOL, READ_AGENT_SCREEN_TOOL, RESPAWN_AGENT_TOOL];
391
414
 
392
- export function toolsFor(session) {
393
- return session?.isBillion ? [...TOOLS, ...BILLION_TOOLS] : TOOLS;
415
+ // `models` is { claude: [...], codex: [...] } as server/models.js last found them.
416
+ export function toolsFor(session, models) {
417
+ const tools = session?.isBillion ? [...TOOLS, ...BILLION_TOOLS] : TOOLS;
418
+ if (!models) return tools;
419
+ const known = JOB_AGENTS.map(a => `${a}: ${models[a]?.length ? models[a].join(', ') : '(none found; leave empty)'}`).join('; ');
420
+ return tools.map(tool => (tool.inputSchema.properties.model ? {
421
+ ...tool,
422
+ inputSchema: {
423
+ ...tool.inputSchema,
424
+ properties: {
425
+ ...tool.inputSchema.properties,
426
+ model: { ...tool.inputSchema.properties.model, description: `${tool.inputSchema.properties.model.description} Available now — ${known}.` },
427
+ },
428
+ },
429
+ } : tool));
394
430
  }
395
431
 
396
432
  const ok = (id, result) => ({ jsonrpc: '2.0', id, result });
@@ -419,6 +455,7 @@ const scheduleText = (job, sep = ', next ') =>
419
455
  function summaryLine(job) {
420
456
  const bits = [job.repo];
421
457
  if (job.agent === 'codex') bits.push('codex');
458
+ if (job.model) bits.push(`model ${job.model}`);
422
459
  if (job.type === 'scheduled') {
423
460
  bits.push(`schedule ${scheduleText(job)}`);
424
461
  }
@@ -440,6 +477,7 @@ const CALLS = {
440
477
  repo: args.repo,
441
478
  schedule: args.schedule,
442
479
  agent: args.agent,
480
+ model: args.model,
443
481
  requiresPr: args.requires_pr,
444
482
  session: ctx.session || null,
445
483
  });
@@ -499,6 +537,7 @@ const CALLS = {
499
537
  `repo: ${job.repo}`,
500
538
  // Only when it is not the default, the way the card's chip works.
501
539
  job.agent === 'codex' ? 'runs on: codex' : null,
540
+ `model: ${job.model || 'CLI default'}`,
502
541
  job.type === 'scheduled'
503
542
  ? `schedule: ${scheduleText(job, ' — next ')}`
504
543
  + `${job.runCount ? ` — posted ${job.runCount} run(s), last ${when(job.lastRunAt)}` : ''}`
@@ -537,6 +576,7 @@ const CALLS = {
537
576
  detail: args.detail,
538
577
  repo: args.repo,
539
578
  schedule: args.schedule,
579
+ model: args.model,
540
580
  requiresPr: args.requires_pr,
541
581
  });
542
582
  if (result.error) return toolText(result.error, true);
@@ -588,9 +628,11 @@ const CALLS = {
588
628
  },
589
629
 
590
630
  [NOTIFY_OWNER_TOOL.name]: async (args, ctx) => {
591
- const result = ctx.notifyOwner ? await ctx.notifyOwner(args.text) : { error: 'Only Billion can notify the owner.' };
631
+ const result = ctx.notifyOwner
632
+ ? await ctx.notifyOwner(args.text, { choices: args.choices, recommended: args.recommended })
633
+ : { error: 'Only Billion can notify the owner.' };
592
634
  if (result.error) return toolText(result.error, true);
593
- return toolText('Sent to the owner on Telegram and pinned under "Waiting on you". Keep working on everything else; their reply, if any, arrives here as [Owner via Telegram].');
635
+ return toolText(`Sent to the owner on Telegram and put under "Waiting on you" as Q${result.n}. Keep working on everything else; their answer, if any, arrives here as [Owner via app] Q${result.n}: … or [Owner via Telegram] Q${result.n}: ….`);
594
636
  },
595
637
 
596
638
  // Quoted line by line, like a message body, so the screen cannot pass for
@@ -665,7 +707,7 @@ export function handleMcpMessage(msg, ctx = {}) {
665
707
  if (isNotification) return null;
666
708
 
667
709
  if (method === 'ping') return ok(id, {});
668
- if (method === 'tools/list') return ok(id, { tools: toolsFor(ctx.session) });
710
+ if (method === 'tools/list') return ok(id, { tools: toolsFor(ctx.session, ctx.models) });
669
711
 
670
712
  if (method === 'tools/call') {
671
713
  // hasOwn, not truthiness: a plain object inherits Object.prototype, so a
@@ -0,0 +1,76 @@
1
+ // Which models a card may name, per CLI, discovered on this machine so nobody
2
+ // keeps a list. Nothing here goes to the network: Claude Code's aliases are
3
+ // fixed words its --model resolves to the newest model itself, and Codex
4
+ // already caches the account's model list on disk.
5
+ //
6
+ // A CLI that is not on the PATH the board spawns with offers nothing — a card
7
+ // could not run there anyway — and so does anything that fails to read. An
8
+ // empty list leaves only the CLI's own default on offer.
9
+
10
+ import { readFileSync } from 'fs';
11
+ import { homedir } from 'os';
12
+ import { join } from 'path';
13
+ import { commandExists } from './command-path.js';
14
+ import { isSafeModelName } from '../lib/jobs.js';
15
+
16
+ // `claude --model` takes these as "the latest model of that family"
17
+ // (claude --help names fable, opus and sonnet; haiku is in the same alias
18
+ // table in the 2.1.283 binary). Aliases rather than full names so a new
19
+ // release needs no change here.
20
+ export const CLAUDE_ALIASES = ['fable', 'opus', 'sonnet', 'haiku'];
21
+
22
+ const codexHome = (env) => env.CODEX_HOME || join(homedir(), '.codex');
23
+
24
+ // Codex (0.156) keeps the models its account can use in
25
+ // $CODEX_HOME/models_cache.json: { fetched_at, client_version, models: [{ slug,
26
+ // visibility, priority, ... }] }. Only `visibility: "list"` entries are kept:
27
+ // that is Codex's own filter for its /model picker, and it is what hides the
28
+ // internal ones (codex-auto-review, the reviewer's model; gpt-reserve) that
29
+ // are not for a coding session. Picker order is `priority`, lowest first. A
30
+ // slug must also pass isSafeModelName, since it ends up on a command line.
31
+ // The CLI has no plain list-models subcommand (its app-server's model/list is
32
+ // a JSON-RPC session, not a one-shot), so this reads the cache its picker is
33
+ // built from — read-only; nothing here ever writes under CODEX_HOME.
34
+ export function parseCodexModels(text) {
35
+ let models;
36
+ try { models = JSON.parse(text)?.models; } catch { return []; }
37
+ if (!Array.isArray(models)) return [];
38
+ return models
39
+ .filter(m => m && m.visibility === 'list' && isSafeModelName(m.slug))
40
+ .sort((a, b) => (Number(a.priority) || 0) - (Number(b.priority) || 0))
41
+ .map(m => m.slug);
42
+ }
43
+
44
+ export function discoverModels({ env = process.env, exists = (f) => commandExists(f, env), read = (p) => readFileSync(p, 'utf8') } = {}) {
45
+ let codex = [];
46
+ if (exists('codex')) {
47
+ try { codex = parseCodexModels(read(join(codexHome(env), 'models_cache.json'))); } catch { /* no cache yet */ }
48
+ }
49
+ return { claude: exists('claude') ? [...CLAUDE_ALIASES] : [], codex };
50
+ }
51
+
52
+ // The current answer, refreshed at most every 10 minutes when asked and hourly
53
+ // regardless (see startModelRefresh).
54
+ const STALE_MS = 10 * 60 * 1000;
55
+ let cached = { claude: [], codex: [] };
56
+ let cachedAt = 0;
57
+
58
+ export function refreshModels(opts) {
59
+ cached = discoverModels(opts);
60
+ cachedAt = Date.now();
61
+ return cached;
62
+ }
63
+
64
+ export function availableModels() { return cached; }
65
+
66
+ // Whether a refresh changed anything, so the caller only repaints boards when it did.
67
+ export function refreshIfStale(now = Date.now()) {
68
+ if (now - cachedAt < STALE_MS) return false;
69
+ const before = JSON.stringify(cached);
70
+ return JSON.stringify(refreshModels()) !== before;
71
+ }
72
+
73
+ export function startModelRefresh() {
74
+ refreshModels();
75
+ setInterval(refreshModels, 60 * 60 * 1000).unref();
76
+ }