@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 +1 -1
- package/VERSION +1 -1
- package/lib/jobs.js +45 -9
- package/package.json +1 -1
- package/public/index.html +5 -0
- package/public/modules/jobs.js +44 -0
- package/server/http.js +3 -0
- package/server/jobs.js +33 -8
- package/server/mcp.js +35 -4
- package/server/messages.js +2 -2
- package/server/models.js +76 -0
- package/server/ws.js +9 -1
- package/server.js +3 -0
- package/templates/billion/charter.md +7 -0
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.
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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.
|
|
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>
|
package/public/modules/jobs.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
402
|
-
|
|
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
|
package/server/messages.js
CHANGED
|
@@ -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.
|
package/server/models.js
ADDED
|
@@ -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
|
|