@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/README.md +7 -5
- package/VERSION +1 -1
- package/lib/jobs.js +43 -7
- package/package.json +1 -1
- package/public/app.js +9 -4
- package/public/index.html +10 -0
- package/public/modules/explorer.js +1 -27
- package/public/modules/jobs.js +47 -0
- package/public/modules/state.js +8 -2
- package/public/modules/terminal.js +32 -2
- package/public/modules/waiting.js +164 -0
- package/public/style.css +116 -6
- package/server/http.js +5 -2
- package/server/jobs.js +33 -8
- package/server/mcp.js +53 -11
- package/server/models.js +76 -0
- package/server/owner.js +159 -31
- package/server/ws.js +25 -3
- package/server.js +3 -0
- package/templates/billion/charter.md +17 -4
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, .
|
|
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
|
-
|
|
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
|
@@ -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
|
|
330
|
-
+ '
|
|
331
|
-
+ 'up. One short message: the question, why, and what you recommend.
|
|
332
|
-
+ '
|
|
333
|
-
+ '
|
|
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
|
-
|
|
393
|
-
|
|
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
|
|
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(
|
|
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
|
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
|
+
}
|