@bill10/agent-007 0.11.0 → 0.12.1000

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/README.md CHANGED
@@ -13,7 +13,7 @@ Use it as far along as you need:
13
13
 
14
14
  1. **One task: one agent in a web terminal.** Start Claude Code, Codex or any CLI agent in the browser. Add a repo once; every agent gets its own git worktree and branch, so you never set one up by hand.
15
15
  2. **Many tasks across projects: many terminals, one window.** Every repo and every agent in one place, with live terminals, a file explorer, inline diffs, and a pixel office where each agent faces its screen while it works and turns to you when it needs you.
16
- 3. **Stop watching them: a job board.** Put tasks on the board and Claude Code or Codex workers pick them up, each in its own worktree, and move them To do -> In progress -> Review on their own, landing as a pull request (or a summary, for work that isn't code). Cards can run on a cron schedule, and agents can post cards and message each other.
16
+ 3. **Stop watching them: a job board.** Put tasks on the board and Claude Code or Codex workers pick them up, each in its own worktree, and move them To do -> In progress -> Review on their own, landing as a pull request (or a summary, for work that isn't code). Cards can run on a cron schedule and pick their model (strong for hard code, fast for docs), from the models the board finds installed, and agents can post cards and message each other.
17
17
  4. **Stop posting jobs: give Billion a goal.** Billion is one always-on agent that plans, posts the jobs, reviews what comes back and merges the PRs. It only asks you about money, access or anything irreversible.
18
18
 
19
19
  Claude Code, Codex, any terminal agent is supported -- use your existing subscriptions, no extra charge.
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.11.0.0
1
+ 0.12.1.0
package/lib/jobs.js CHANGED
@@ -395,17 +395,48 @@ export function isCodexSessionId(value) {
395
395
  // known — it is the board's current word. With neither, the CLI's own
396
396
  // default applies. Both go through their allowlists here, so this is the one
397
397
  // place that guarantees nothing but a permission reaches the argv.
398
- export function resumeCommand(agent, mode, flags, sessionId) {
398
+ //
399
+ // `model` is the card's model, so a worker picked for a strong model does not
400
+ // come back on the CLI's default.
401
+ export function resumeCommand(agent, mode, flags, sessionId, model = null) {
399
402
  const validMode = isValidPermissionMode(mode) ? mode : null;
400
403
  const own = validMode ? '' : normalizePermissionFlags(agent === 'codex' ? 'codex' : 'claude', flags).join(' ');
404
+ const m = modelFlag(agent === 'codex' ? 'codex' : 'claude', model);
401
405
  if (agent === 'codex') {
402
406
  const flag = validMode ? CODEX_MODE_FLAGS[validMode] : own;
403
407
  // The id reaches the argv, so only a real session id passes.
404
408
  const target = isCodexSessionId(sessionId) ? ` ${sessionId}` : '';
405
- return `codex resume${target}${flag ? ` ${flag}` : ''}`;
409
+ return `codex resume${target}${flag ? ` ${flag}` : ''}${m ? ` ${m}` : ''}`;
406
410
  }
407
411
  const flag = validMode ? `--permission-mode ${validMode}` : own;
408
- return `claude --continue${flag ? ` ${flag}` : ''}`;
412
+ return `claude --continue${flag ? ` ${flag}` : ''}${m ? ` ${m}` : ''}`;
413
+ }
414
+
415
+ // A card's model: a name its CLI's --model / -m takes. It reaches the argv, so
416
+ // besides being one the server discovered (server/models.js) it must be a
417
+ // single plain token — no space, quote or leading dash.
418
+ export function isSafeModelName(model) {
419
+ return typeof model === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:\[\]-]{0,99}$/.test(model);
420
+ }
421
+
422
+ // Empty is null: the CLI's own default. `available` is the discovered list per
423
+ // CLI; left out (a schedule's run, copying a model its schedule already
424
+ // passed), only the shape is checked.
425
+ export function resolveJobModel(model, agent, available) {
426
+ if (model === undefined || model === null || model === '') return { model: null };
427
+ const list = available ? (available[agent] || []) : null;
428
+ if (!isSafeModelName(model) || (list && !list.includes(model))) {
429
+ const shown = typeof model === 'string' ? `"${model.slice(0, 40)}"` : `a ${typeof model}`;
430
+ return { error: `Unknown model ${shown} for ${agent} \u2014 expected one of: ${list?.length ? list.join(', ') : '(none discovered; leave it empty for the CLI default)'}` };
431
+ }
432
+ return { model };
433
+ }
434
+
435
+ // The model flag for that CLI, or nothing for the default. Quoted though the
436
+ // name is already a plain token, the same as every other value put on the argv.
437
+ export function modelFlag(agent, model) {
438
+ if (!isSafeModelName(model)) return '';
439
+ return `${agent === 'codex' ? '-m' : '--model'} ${quote(model)}`;
409
440
  }
410
441
 
411
442
  export function resolveJobAgent(agent) {
@@ -468,7 +499,7 @@ export function newJobId() {
468
499
  export const MAX_TITLE_LEN = 200;
469
500
  export const MAX_DETAIL_LEN = 20000;
470
501
 
471
- export function createJob({ title, detail, repoPath, type, schedule, permissionMode, agent, requiresPr, postedBy, postedByName, postedByAgent, postedByBillion }) {
502
+ export function createJob({ title, detail, repoPath, type, schedule, permissionMode, agent, model, availableModels, requiresPr, postedBy, postedByName, postedByAgent, postedByBillion }) {
472
503
  const cleanTitle = String(title || '').trim().slice(0, MAX_TITLE_LEN);
473
504
  if (!cleanTitle) return { error: 'Title is required' };
474
505
  if (!repoPath) return { error: 'Repository is required' };
@@ -481,6 +512,8 @@ export function createJob({ title, detail, repoPath, type, schedule, permissionM
481
512
  if (mode.error) return { error: mode.error };
482
513
  const cli = resolveJobAgent(agent);
483
514
  if (cli.error) return { error: cli.error };
515
+ const chosen = resolveJobModel(model, cli.agent, availableModels);
516
+ if (chosen.error) return { error: chosen.error };
484
517
  return {
485
518
  job: {
486
519
  id: newJobId(),
@@ -504,7 +537,8 @@ export function createJob({ title, detail, repoPath, type, schedule, permissionM
504
537
  // Which CLI is spawned for it. Cards written before this existed have no
505
538
  // field, which jobAgent reads as claude.
506
539
  agent: cli.agent,
507
- // See jobRequiresPr. Only false when the poster said so.
540
+ // The model its CLI runs, or null for the CLI's default. See resolveJobModel.
541
+ model: chosen.model,
508
542
  // See jobRequiresPr. Unset takes the type's default.
509
543
  requiresPr: typeof requiresPr === 'boolean' ? requiresPr : defaultRequiresPr(resolved.type),
510
544
  detail: String(detail || '').trim().slice(0, MAX_DETAIL_LEN),
@@ -692,8 +726,8 @@ function oneTimePromptSuffix(agent = DEFAULT_JOB_AGENT, requiresPr = true, fromB
692
726
  ...(fromBillion ? [
693
727
  '',
694
728
  `${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.',
729
+ `make, ask it with the agent-007-board send_message tool (to: "${BILLION_NAME}") rather`,
730
+ 'than waiting for a person; its answer arrives in this terminal.',
697
731
  ] : []),
698
732
  '',
699
733
  ...finish,
@@ -742,10 +776,11 @@ export function buildJobCommand(job, { permissionMode = DEFAULT_PERMISSION_MODE
742
776
  if (jobAgent(job) === 'codex') {
743
777
  // No --add-dir: every Codex sandbox, read-only included, reads anywhere
744
778
  // on disk and gates only writes, and attachments are only ever read.
745
- const flags = permissionModeFlags('codex', mode).join(' ');
779
+ const flags = [...permissionModeFlags('codex', mode), modelFlag('codex', job.model)].filter(Boolean).join(' ');
746
780
  return `codex ${flags ? `${flags} ` : ''}${quote(buildJobPrompt(job))}`;
747
781
  }
748
- return `claude ${permissionModeFlags('claude', mode).join(' ')} ${quote(buildJobPrompt(job))}${dirs.map(d => ` --add-dir ${quote(d)}`).join('')}`;
782
+ const m = modelFlag('claude', job.model);
783
+ return `claude ${permissionModeFlags('claude', mode).join(' ')}${m ? ` ${m}` : ''} ${quote(buildJobPrompt(job))}${dirs.map(d => ` --add-dir ${quote(d)}`).join('')}`;
749
784
  }
750
785
 
751
786
  // --- Live status (derived, never stored) ---
@@ -849,6 +884,7 @@ export function createRunJob(schedule) {
849
884
  type: 'one-time',
850
885
  permissionMode: schedule.permissionMode,
851
886
  agent: schedule.agent,
887
+ model: schedule.model,
852
888
  requiresPr: jobRequiresPr(schedule),
853
889
  postedBy: schedule.postedBy,
854
890
  postedByName: schedule.postedByName,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bill10/agent-007",
3
- "version": "0.11.0",
3
+ "version": "0.12.1000",
4
4
  "description": "From web terminals for your coding agents to a self-running agent company: Claude Code and Codex in parallel git worktrees, a job board they pick work from, and one agent that runs the board from a goal.",
5
5
  "license": "MIT",
6
6
  "repository": {
package/public/index.html CHANGED
@@ -167,6 +167,11 @@
167
167
  <option value="codex">Codex</option>
168
168
  </select>
169
169
  </label>
170
+ <label title="Which model this job's agent runs. The list is what the board found for the chosen CLI on this computer; CLI default leaves the choice to the CLI.">Model
171
+ <select id="job-model">
172
+ <option value="">CLI default</option>
173
+ </select>
174
+ </label>
170
175
  <label title="How much this job's agent may do without asking. Leave it on the board default unless this one job needs something different. A Codex card offers auto (Codex's own default) and bypassPermissions (--dangerously-bypass-approvals-and-sandbox); a board set to another mode maps onto Codex's nearest flag: plan is read-only, manual asks before every command, dontAsk never asks.">Permissions
171
176
  <select id="job-permission-mode-field">
172
177
  <option value="">Board default</option>
@@ -284,6 +284,13 @@ function renderCard(job) {
284
284
  chip.title = 'Finishes with a summary from its agent instead of a pull request';
285
285
  title.appendChild(chip);
286
286
  }
287
+ if (job.model) {
288
+ const chip = document.createElement('span');
289
+ chip.className = 'job-card-type';
290
+ chip.textContent = job.model;
291
+ chip.title = `Runs on the ${job.model} model instead of the CLI's default`;
292
+ title.appendChild(chip);
293
+ }
287
294
  if (job.permissionMode) {
288
295
  // Only when the card overrides the board. The mode decides how much its
289
296
  // agent may do unasked, so a card carrying its own must say so on its face
@@ -730,6 +737,32 @@ function syncAgentField() {
730
737
  opt.textContent = codex ? opt.dataset.codexLabel : opt.dataset.claudeLabel;
731
738
  }
732
739
  markDangerousMode(permEl);
740
+ syncModelField();
741
+ }
742
+
743
+ // The discovered models for the chosen CLI (server/models.js), plus the one
744
+ // the card already holds if discovery no longer lists it, so opening the form
745
+ // never silently changes it.
746
+ let models = { claude: [], codex: [] };
747
+ let formModel = '';
748
+ let formAgent = 'claude';
749
+ function syncModelField() {
750
+ const el = document.getElementById('job-model');
751
+ if (!el) return;
752
+ const agent = document.getElementById('job-agent')?.value === 'codex' ? 'codex' : 'claude';
753
+ const list = [...(models[agent] || [])];
754
+ if (formModel && agent === formAgent && !list.includes(formModel)) list.push(formModel);
755
+ // The card's own value on open; after that, whatever is picked.
756
+ const keep = el.dataset.filled ? el.value : formModel;
757
+ el.dataset.filled = '1';
758
+ el.innerHTML = '<option value="">CLI default</option>';
759
+ for (const m of list) {
760
+ const opt = document.createElement('option');
761
+ opt.value = m;
762
+ opt.textContent = m;
763
+ el.appendChild(opt);
764
+ }
765
+ el.value = list.includes(keep) ? keep : '';
733
766
  }
734
767
 
735
768
  // A shape check only — five whitespace-separated fields, or a known @shorthand.
@@ -801,6 +834,12 @@ function openForm(jobId) {
801
834
  // which would silently freeze the card onto today's setting.
802
835
  if (permEl) permEl.value = job && job.permissionMode ? job.permissionMode : '';
803
836
  if (agentEl) agentEl.value = job && job.agent === 'codex' ? 'codex' : 'claude';
837
+ formModel = job && job.model ? job.model : '';
838
+ formAgent = agentEl ? agentEl.value : 'claude';
839
+ const modelEl = document.getElementById('job-model');
840
+ if (modelEl) delete modelEl.dataset.filled;
841
+ // The server looks again if its list is over 10 minutes old.
842
+ send({ type: 'models-refresh' });
804
843
  prPicked = false;
805
844
  prEl.value = job ? (job.requiresPr === false || (isScheduled(job) && job.requiresPr !== true) ? 'no' : 'yes') : 'yes';
806
845
  syncAgentField(); // also marks the danger colour on the permission select
@@ -849,7 +888,11 @@ function saveForm() {
849
888
  // The form always holds the complete list; an empty one on an edit means
850
889
  // "none left".
851
890
  const attachments = pendingAttachments.map(a => ({ name: a.name, data: a.data }));
891
+ const model = document.getElementById('job-model')?.value || '';
852
892
  const fields = { title, detail, repoPath, jobType, schedule, permissionMode, agent, requiresPr, attachments };
893
+ // An edit that leaves the model alone does not send it, so a card whose
894
+ // model discovery no longer lists can still be retitled.
895
+ if (!editingJobId || model !== formModel) fields.model = model;
853
896
  if (editingJobId) send({ type: 'job-update', jobId: editingJobId, ...fields });
854
897
  else send({ type: 'job-create', ...fields });
855
898
  closeForm();
@@ -899,6 +942,7 @@ export function handleJobsList(msg) {
899
942
  jobs.clear();
900
943
  for (const job of msg.jobs || []) jobs.set(job.id, job);
901
944
  if (msg.settings) setBoardSettings(msg.settings);
945
+ if (msg.models) { models = msg.models; syncModelField(); }
902
946
  renderToolbar();
903
947
  renderBoard();
904
948
  if (window._onBoardVisibilityChanged) window._onBoardVisibilityChanged();
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,
@@ -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
@@ -36,6 +36,12 @@ export const SERVER_INFO = { name: 'agent-007-board', version: '1' };
36
36
  // for and — deliberately — when to reach for it. "When the user asks" is the
37
37
  // operative clause: a job board full of work an agent queued for itself is not
38
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
+
39
45
  export const POST_JOB_TOOL = {
40
46
  name: 'post_job',
41
47
  description:
@@ -84,6 +90,10 @@ export const POST_JOB_TOOL = {
84
90
  'Optional. Which CLI the board spawns for this card: claude (Claude Code) '
85
91
  + 'or codex. Defaults to the one you are running as.',
86
92
  },
93
+ model: {
94
+ type: 'string',
95
+ description: MODEL_HELP,
96
+ },
87
97
  requires_pr: {
88
98
  type: 'boolean',
89
99
  description:
@@ -152,7 +162,7 @@ export const EDIT_JOB_TOOL = {
152
162
  name: 'edit_job',
153
163
  description:
154
164
  'Change a card that is still in To do: its title, detail, repository, '
155
- + '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 '
156
166
  + 'card its agent has already been handed the text, so a later edit would leave '
157
167
  + 'the card describing work nobody was asked to do. Pass only the fields that '
158
168
  + 'change; the rest are left alone. Ids come from list_jobs.',
@@ -177,6 +187,10 @@ export const EDIT_JOB_TOOL = {
177
187
  'Replaces the cron schedule (five fields, or an @shorthand). Pass an empty '
178
188
  + 'string to turn a scheduled card back into one that runs once.',
179
189
  },
190
+ model: {
191
+ type: 'string',
192
+ description: `${MODEL_HELP} Switching the card's agent without naming a model clears it.`,
193
+ },
180
194
  requires_pr: {
181
195
  type: 'boolean',
182
196
  description:
@@ -398,8 +412,21 @@ export const RESPAWN_AGENT_TOOL = {
398
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];
399
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];
400
414
 
401
- export function toolsFor(session) {
402
- 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));
403
430
  }
404
431
 
405
432
  const ok = (id, result) => ({ jsonrpc: '2.0', id, result });
@@ -428,6 +455,7 @@ const scheduleText = (job, sep = ', next ') =>
428
455
  function summaryLine(job) {
429
456
  const bits = [job.repo];
430
457
  if (job.agent === 'codex') bits.push('codex');
458
+ if (job.model) bits.push(`model ${job.model}`);
431
459
  if (job.type === 'scheduled') {
432
460
  bits.push(`schedule ${scheduleText(job)}`);
433
461
  }
@@ -449,6 +477,7 @@ const CALLS = {
449
477
  repo: args.repo,
450
478
  schedule: args.schedule,
451
479
  agent: args.agent,
480
+ model: args.model,
452
481
  requiresPr: args.requires_pr,
453
482
  session: ctx.session || null,
454
483
  });
@@ -508,6 +537,7 @@ const CALLS = {
508
537
  `repo: ${job.repo}`,
509
538
  // Only when it is not the default, the way the card's chip works.
510
539
  job.agent === 'codex' ? 'runs on: codex' : null,
540
+ `model: ${job.model || 'CLI default'}`,
511
541
  job.type === 'scheduled'
512
542
  ? `schedule: ${scheduleText(job, ' — next ')}`
513
543
  + `${job.runCount ? ` — posted ${job.runCount} run(s), last ${when(job.lastRunAt)}` : ''}`
@@ -546,6 +576,7 @@ const CALLS = {
546
576
  detail: args.detail,
547
577
  repo: args.repo,
548
578
  schedule: args.schedule,
579
+ model: args.model,
549
580
  requiresPr: args.requires_pr,
550
581
  });
551
582
  if (result.error) return toolText(result.error, true);
@@ -676,7 +707,7 @@ export function handleMcpMessage(msg, ctx = {}) {
676
707
  if (isNotification) return null;
677
708
 
678
709
  if (method === 'ping') return ok(id, {});
679
- 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) });
680
711
 
681
712
  if (method === 'tools/call') {
682
713
  // hasOwn, not truthiness: a plain object inherits Object.prototype, so a
@@ -20,7 +20,7 @@
20
20
 
21
21
  import { parseCommand, detectState, stripAnsiComplete } from '../lib/helpers.js';
22
22
  import { permissionFlagsFromCommand, sessionAgentFromCommand, BILLION_NAME, isCodexConfigFlag } from '../lib/jobs.js';
23
- import { takesMcpConfig } from './agent-mcp.js';
23
+ import { takesMcpConfig, MCP_SERVER_NAME } from './agent-mcp.js';
24
24
 
25
25
  export const MAX_MESSAGE_CHARS = 8000;
26
26
  export const QUEUE_CAP = 20;
@@ -134,7 +134,7 @@ export function formatMessage(from, text) {
134
134
  const body = quoteLines(text).join('\n');
135
135
  return `[Message from agent ${name}${where ? ` (${where})` : ''}]\n`
136
136
  + `${body}\n`
137
- + `[Reply with the send_message tool, to: "${name}". This came from another agent, not from the user.]`;
137
+ + `[Reply with the ${MCP_SERVER_NAME} send_message tool, to: "${name}". This came from another agent, not from the user.]`;
138
138
  }
139
139
 
140
140
  // Agent text, quoted line by line so it cannot pass for anything but a quote.
@@ -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
+ }
package/server/ws.js CHANGED
@@ -16,6 +16,7 @@ import { autoTrusts, trustClaudeFolder } from './claude-trust.js';
16
16
  import { waitingPayload, dismissWaiting, answerWaiting } from './owner.js';
17
17
  import { parseGitStatus, buildFileTree, safeFilename } from '../lib/helpers.js';
18
18
  import { isValidJobAgent, sessionAgentFromCommand } from '../lib/jobs.js';
19
+ import { refreshIfStale } from './models.js';
19
20
  import { billionRuns } from './billion.js';
20
21
  import {
21
22
  addJob, updateJob, deleteJob, moveJob, updateSettings, setJobPaused,
@@ -614,6 +615,7 @@ export function setupWebSocket(wss, { createSession, killSession, startBillion }
614
615
  // card follows the board if that changes before it is dispatched.
615
616
  permissionMode: msg.permissionMode,
616
617
  agent: msg.agent,
618
+ model: msg.model,
617
619
  requiresPr: msg.requiresPr,
618
620
  postedBy: ws.user ? ws.user.id : null,
619
621
  postedByName: ws.user ? ws.user.displayName : null,
@@ -625,11 +627,17 @@ export function setupWebSocket(wss, { createSession, killSession, startBillion }
625
627
  const result = updateJob(msg.jobId, {
626
628
  title: msg.title, detail: msg.detail, repoPath: msg.repoPath,
627
629
  type: msg.jobType, schedule: msg.schedule, attachments: msg.attachments,
628
- permissionMode: msg.permissionMode, agent: msg.agent, requiresPr: msg.requiresPr,
630
+ permissionMode: msg.permissionMode, agent: msg.agent, model: msg.model, requiresPr: msg.requiresPr,
629
631
  }, broadcast);
630
632
  if (result.error) ws.send(JSON.stringify({ type: 'notification', level: 'error', message: result.error }));
631
633
  break;
632
634
  }
635
+ // The card form opening: look again if the list is over 10 minutes
636
+ // old, so a CLI installed since shows up without a restart.
637
+ case 'models-refresh': {
638
+ if (refreshIfStale()) broadcastJobs(broadcast);
639
+ break;
640
+ }
633
641
  case 'job-pause': {
634
642
  const result = setJobPaused(msg.jobId, msg.paused, broadcast);
635
643
  if (result.error) ws.send(JSON.stringify({ type: 'notification', level: 'error', message: result.error }));
package/server.js CHANGED
@@ -39,6 +39,7 @@ import { parseCommand } from './lib/helpers.js';
39
39
  import { hasClaudeTranscript } from './server/agent-transcripts.js';
40
40
  import { autoTrusts, trustClaudeFolder } from './server/claude-trust.js';
41
41
  import { startTelegram, stopTelegram } from './server/owner.js';
42
+ import { startModelRefresh } from './server/models.js';
42
43
 
43
44
  const __dirname = dirname(fileURLToPath(import.meta.url));
44
45
  const app = express();
@@ -247,6 +248,8 @@ async function startup() {
247
248
  // Keeping one timer alive (instead of creating/destroying it on toggle) means
248
249
  // the Start button only has to flip a boolean, and a config restored with
249
250
  // running:true resumes dispatching without any extra wiring.
251
+ // Before the dispatcher: a card's model is checked against this list.
252
+ startModelRefresh();
250
253
  startDispatcher(createSession, broadcast, {
251
254
  onSessionCreated: (s) => broadcast(sessionPayload(s)),
252
255
  killSession,
@@ -181,6 +181,13 @@ you have not seen all of it, so deny it or leave it to the owner.
181
181
  happen on a rhythm (a weekly check, a nightly report) is one schedule card
182
182
  you post once (`post_job` with a schedule); each run comes back to you like
183
183
  any other card.
184
+ - **Choosing a model.** A card's `model` spends the owner's subscription
185
+ usage, so spend it where it matters. Use the strongest (`fable` or `opus`,
186
+ or the top Codex model) for core code, security, debugging, and any redo
187
+ of a card that was sent back; use a fast one (`sonnet` or `haiku`, or a
188
+ smaller Codex model) for docs, mechanical edits, research summaries and
189
+ scheduled reports. Leave it empty when unsure: the CLI's default. It is
190
+ your call, within the list `post_job` names for each agent.
184
191
 
185
192
  ## Merging
186
193