severally-mcp 2.4.0 → 2.5.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.
Files changed (50) hide show
  1. package/CONFIG.md +1 -0
  2. package/README.ja.md +44 -19
  3. package/README.md +44 -18
  4. package/config.example.json +1 -1
  5. package/docs/how-it-works.ja.md +2 -0
  6. package/docs/how-it-works.md +2 -0
  7. package/package.json +3 -1
  8. package/plugins/severally/.claude-plugin/plugin.json +1 -1
  9. package/plugins/severally/.codex-plugin/plugin.json +1 -1
  10. package/plugins/severally/dist/severally-mcp.mjs +630 -271
  11. package/plugins/severally/skills/_template/SKILL.md.tmpl +2 -1
  12. package/plugins/severally/skills/_template/reference.md.tmpl +2 -2
  13. package/plugins/severally/skills/antigravity/severally/SKILL.md +2 -1
  14. package/plugins/severally/skills/antigravity/severally/reference.md +2 -2
  15. package/plugins/severally/skills/claude/severally/SKILL.md +2 -1
  16. package/plugins/severally/skills/claude/severally/reference.md +2 -2
  17. package/plugins/severally/skills/codex/severally/SKILL.md +2 -1
  18. package/plugins/severally/skills/codex/severally/reference.md +2 -2
  19. package/plugins/severally/skills/opencode/severally/SKILL.md +2 -1
  20. package/plugins/severally/skills/opencode/severally/reference.md +2 -2
  21. package/scripts/build.mjs +57 -54
  22. package/scripts/doctor.mjs +15 -4
  23. package/scripts/init-config.mjs +2 -1
  24. package/scripts/install.mjs +73 -9
  25. package/scripts/live-check.mjs +11 -5
  26. package/scripts/live-mcp-check.mjs +10 -4
  27. package/src/adapters/antigravity-sandbox.mjs +24 -6
  28. package/src/adapters/antigravity.mjs +80 -17
  29. package/src/adapters/claude-code.mjs +49 -8
  30. package/src/adapters/codex.mjs +56 -6
  31. package/src/adapters/opencode-sandbox.mjs +20 -2
  32. package/src/adapters/opencode.mjs +115 -19
  33. package/src/brief.mjs +84 -7
  34. package/src/consultants.mjs +193 -27
  35. package/src/cross-spawn-escape.d.ts +7 -0
  36. package/src/export.mjs +50 -6
  37. package/src/expose-paths.mjs +149 -7
  38. package/src/failures.mjs +8 -1
  39. package/src/jobs.mjs +632 -152
  40. package/src/jsonc.mjs +11 -3
  41. package/src/parse-result.mjs +91 -14
  42. package/src/platform.mjs +52 -2
  43. package/src/policy.mjs +151 -28
  44. package/src/redact.mjs +22 -3
  45. package/src/result-schema.mjs +13 -0
  46. package/src/run.mjs +158 -12
  47. package/src/schema.mjs +84 -11
  48. package/src/server.mjs +49 -14
  49. package/src/status.mjs +16 -4
  50. package/src/store.mjs +287 -6
package/src/jobs.mjs CHANGED
@@ -12,9 +12,10 @@ import {
12
12
  detectCaller,
13
13
  configProblem,
14
14
  consultantByName,
15
+ consultantNamed,
15
16
  consultantAvailable,
16
17
  } from './policy.mjs';
17
- import { vendorOf, sameModel, CLI_VENDORS } from './consultants.mjs';
18
+ import { vendorOf, sameModel, CLI_VENDORS, CLI_IDS } from './consultants.mjs';
18
19
  import { parseRequest, RequestError, charCount } from './schema.mjs';
19
20
  import { renderBrief, renderGuardrails, briefRecord } from './brief.mjs';
20
21
  import { CONSULT_RESULT_SCHEMA } from './result-schema.mjs';
@@ -29,6 +30,118 @@ import * as claudeCode from './adapters/claude-code.mjs';
29
30
  import * as antigravity from './adapters/antigravity.mjs';
30
31
  import * as opencode from './adapters/opencode.mjs';
31
32
 
33
+ /** @typedef {import('./run.mjs').Adapter} Adapter */
34
+ /** @typedef {import('./run.mjs').ProcHandle} ProcHandle */
35
+ /** @typedef {import('./consultants.mjs').Consultant} Consultant */
36
+ /** @typedef {import('./consultants.mjs').CliId} CliId */
37
+ /** @typedef {import('./store.mjs').Group} Group */
38
+ /** @typedef {import('./store.mjs').Seat} Seat */
39
+ /** @typedef {NonNullable<Seat['owner']>} JobOwner Who runs a consultation: the severally process, and when it counts as overdue. */
40
+ /** @typedef {import('./brief.mjs').PriorRound} PriorRound */
41
+ /** @typedef {import('./schema.mjs').ParsedRequest & {_snapshot?: string}} ParsedRequest A validated request (see parseRequest), with the copy of the exposed files the job manager adds. */
42
+
43
+ /**
44
+ * The tool arguments, not yet validated; only the keys inherit() and start() look at are named.
45
+ * @typedef {{target?: unknown, targets?: unknown[] | null | undefined, network?: unknown, context?: Record<string, unknown> | null | undefined, [key: string]: unknown}} RawRequest
46
+ */
47
+
48
+ /** @typedef {import('./store.mjs').JobFailure} JobFailure */
49
+ /** @typedef {import('./store.mjs').RecordEntry} RecordEntry */
50
+ /** @typedef {import('./store.mjs').JobRecord} JobRecord */
51
+ /** @typedef {import('./store.mjs').Reflection} Reflection */
52
+ /** @typedef {import('./store.mjs').Prediction} Prediction */
53
+ /** @typedef {import('./store.mjs').ResultItem} ResultItem */
54
+ /** @typedef {import('./store.mjs').ConsultResult} ConsultResult */
55
+ /** @typedef {import('./store.mjs').Round} Round */
56
+ /** @typedef {import('./store.mjs').BriefRecord} BriefRecord */
57
+ /** @typedef {import('./store.mjs').RetryRecord} RetryRecord */
58
+ /** @typedef {import('./store.mjs').IndexRow} IndexRow */
59
+ /** @typedef {import('./store.mjs').BriefExposeEntry} BriefExposeEntry */
60
+
61
+ /** @typedef {import('./store.mjs').RunningRecord} StoredRound A round, or the marker of one still running elsewhere. */
62
+
63
+ /**
64
+ * A consultation this process runs: the round, plus what only a live process has.
65
+ * @typedef {Round & {group_id: string, caller: string | null, retries: RetryRecord[], cancelRequested: boolean, _cancelFns: Array<() => void>, _request: ParsedRequest, _owner?: JobOwner | undefined, _handle?: ProcHandle | undefined, promise?: Promise<unknown>}} Job
66
+ */
67
+
68
+ /**
69
+ * What a view adds to a round.
70
+ * @typedef {object} ViewExtras
71
+ * @property {boolean} [cancellable]
72
+ * @property {string | null} [progress]
73
+ * @property {string} [brief_note]
74
+ * @property {ReturnType<typeof limitsSummary> | null} [limits]
75
+ * @property {string} [next_step]
76
+ * @property {string} [cancel_effect]
77
+ */
78
+
79
+ /**
80
+ * The brief as consult_get names it by default: its size and sha256, where it is kept, what was copied.
81
+ * @typedef {object} BriefSummary
82
+ * @property {number} chars
83
+ * @property {string} sha256
84
+ * @property {string} history_file
85
+ * @property {({skipped: Record<string, number>} & import('./expose-paths.mjs').ExposeTotals) | null} exposed
86
+ * @property {string} full_with
87
+ */
88
+
89
+ /** @typedef {Round & ViewExtras} FullView A round as consult_get shows it with `detail: "full"`. */
90
+
91
+ /** @typedef {Omit<Round, 'brief'> & ViewExtras & {brief: BriefRecord | BriefSummary | null}} JobView A round as consult_get shows it. */
92
+
93
+ /**
94
+ * A consult_record call, or one of its `records`, as sent: not yet validated. The job_id is only ever looked up, so it is not narrowed.
95
+ * @typedef {{job_id?: any, entries?: Array<{id?: unknown, verdict?: unknown, effect?: unknown, note?: unknown} | null>, reflection?: {delta?: unknown, related_item_ids?: unknown} | null, [key: string]: unknown}} RawRecord
96
+ */
97
+
98
+ /** @typedef {{job: Round, live: Job | undefined, entries?: RecordEntry[] | undefined, reflection?: Reflection | undefined}} RecordChange What a record call would write, checked and not yet written: only the entries this call names. */
99
+
100
+ /** @typedef {import('./expose-paths.mjs').ExposeManifest} ExposeManifest */
101
+
102
+ /**
103
+ * @typedef {object} GroupLink
104
+ * @property {Round & {nextRound: number}} parent
105
+ * @property {number} round
106
+ * @property {Round} [groupMember]
107
+ */
108
+
109
+ /**
110
+ * A follow-up to a fan-out: the group, where each member goes on from, and
111
+ * the members that cannot take one.
112
+ * @typedef {object} GroupParents
113
+ * @property {Group} group
114
+ * @property {GroupLink[]} links
115
+ * @property {Array<{job_id: string, target: string | null, reason: string}>} skipped
116
+ */
117
+
118
+ /**
119
+ * One consultant's column of a comparison.
120
+ * @typedef {object} ComparisonColumn
121
+ * @property {string} job_id
122
+ * @property {string} target
123
+ * @property {string} [cli]
124
+ * @property {string} [status]
125
+ * @property {string | null} [failure_kind]
126
+ * @property {string | null} stance
127
+ * @property {string | null} confidence
128
+ * @property {string | null} evidence_basis
129
+ * @property {string | null | undefined} summary
130
+ * @property {unknown[]} finding_points
131
+ * @property {Record<string, number>} severity_counts
132
+ * @property {unknown[]} decision_changers
133
+ * @property {unknown[]} alternative_options
134
+ * @property {unknown[]} unknowns
135
+ * @property {unknown[]} next_checks
136
+ * @property {unknown[]} remaining_disagreements
137
+ * @property {string | null | undefined} caveat
138
+ * @property {number | null} points_without_verdict
139
+ * @property {Record<string, number>} [omitted]
140
+ * @property {boolean} [truncated]
141
+ * @property {string} [full_with]
142
+ */
143
+
144
+ /** @type {Record<CliId, Adapter> & Record<string, Adapter | undefined>} */
32
145
  const ADAPTERS = { codex, 'claude-code': claudeCode, antigravity, opencode };
33
146
 
34
147
  // The recovery respawn runs only while this much of the original budget is
@@ -55,6 +168,7 @@ const API_KEY_VARS = ['GEMINI_API_KEY', 'GOOGLE_API_KEY'];
55
168
  const CREDENTIAL_FILE_VARS = ['GOOGLE_APPLICATION_CREDENTIALS'];
56
169
  const ALL_API_CREDENTIAL_VARS = [...API_KEY_VARS, ...CREDENTIAL_FILE_VARS];
57
170
 
171
+ /** @param {Record<string, string | undefined>} env */
58
172
  function hasApiCredential(env) {
59
173
  if (API_KEY_VARS.some((name) => env[name])) return true;
60
174
  return CREDENTIAL_FILE_VARS.some((name) => env[name] && fs.existsSync(env[name]));
@@ -64,11 +178,12 @@ function hasApiCredential(env) {
64
178
  // listed as not reported rather than silently missing from the totals.
65
179
  const USAGE_FIGURES = ['input_tokens', 'cached_input_tokens', 'output_tokens', 'total_tokens', 'cost_usd'];
66
180
 
67
- const id = (prefix) => `${prefix}_${crypto.randomBytes(6).toString('hex')}`;
181
+ const id = (/** @type {string} */ prefix) => `${prefix}_${crypto.randomBytes(6).toString('hex')}`;
68
182
 
69
183
  // Jobs this process is running, across every JobManager in it. A marker owned
70
184
  // by this pid but missing here was left by an earlier process that had the
71
185
  // same pid, so it is as dead as one whose pid no longer exists.
186
+ /** @type {Set<string>} */
72
187
  const LIVE_HERE = new Set();
73
188
 
74
189
  // What became of the process that started a consultation: 'alive', 'dead',
@@ -76,6 +191,11 @@ const LIVE_HERE = new Set();
76
191
  // (budget, retries included, plus the kill grace), so either that process is
77
192
  // stuck or the pid now belongs to something else. Nothing is killed on the
78
193
  // strength of this.
194
+ /**
195
+ * @param {string} jobId
196
+ * @param {JobOwner | null | undefined} owner
197
+ * @returns {'alive' | 'dead' | 'overdue'}
198
+ */
79
199
  function ownerState(jobId, owner) {
80
200
  if (!owner) return 'dead';
81
201
  let pidAlive;
@@ -85,7 +205,7 @@ function ownerState(jobId, owner) {
85
205
  process.kill(owner.pid, 0);
86
206
  pidAlive = true;
87
207
  } catch (err) {
88
- pidAlive = err.code === 'EPERM';
208
+ pidAlive = err instanceof Error && 'code' in err && err.code === 'EPERM';
89
209
  }
90
210
  if (!pidAlive) return 'dead';
91
211
  return Date.now() > Date.parse(owner.deadline_at) ? 'overdue' : 'alive';
@@ -93,9 +213,13 @@ function ownerState(jobId, owner) {
93
213
 
94
214
  export class JobManager {
95
215
  constructor() {
216
+ /** @type {NodeJS.Timeout | null} */
96
217
  this._cancelWatch = null;
218
+ /** @type {Map<string, Job>} */
97
219
  this.jobs = new Map();
220
+ /** @type {Map<string, Group>} */
98
221
  this.groups = new Map();
222
+ /** @type {string[]} */
99
223
  this.order = [];
100
224
  store.ensureDirs();
101
225
  }
@@ -103,6 +227,10 @@ export class JobManager {
103
227
  // A consultation this process does not hold: finished (its round file), or
104
228
  // still in progress (its marker). A marker whose owner is gone becomes a
105
229
  // failed round, `interrupted`, written once by whichever process claims it.
230
+ /**
231
+ * @param {string} jobId
232
+ * @returns {StoredRound | null}
233
+ */
106
234
  #fromDisk(jobId) {
107
235
  const done = store.loadRound(jobId);
108
236
  if (done) return done;
@@ -117,6 +245,12 @@ export class JobManager {
117
245
  // something the next reader finishes: the round is written (the index
118
246
  // takes it once), then the marker goes. A round already on disk wins -- its
119
247
  // owner finished and died before removing the marker.
248
+ /**
249
+ * @param {string} jobId
250
+ * @param {StoredRound} rec
251
+ * @param {'alive' | 'dead' | 'overdue'} state
252
+ * @returns {Round}
253
+ */
120
254
  #interrupt(jobId, rec, state) {
121
255
  const done = store.loadRound(jobId);
122
256
  if (done) {
@@ -151,22 +285,26 @@ export class JobManager {
151
285
  for (const jobId of store.listRunning()) {
152
286
  const rec = store.loadRunning(jobId);
153
287
  const state = rec && ownerState(jobId, rec.owner);
154
- if (rec && state !== 'alive') this.#interrupt(jobId, rec, state);
288
+ if (rec && state && state !== 'alive') this.#interrupt(jobId, rec, state);
155
289
  }
156
290
  }
157
291
 
292
+ /**
293
+ * @param {StoredRound} rec
294
+ * @returns {FullView}
295
+ */
158
296
  #diskView(rec) {
159
- const elsewhere = Boolean(rec.owner);
160
297
  const { owner, ...view } = rec;
161
298
  return {
162
299
  ...view,
163
- cancellable: false,
300
+ // consult_cancel asks the process that owns it to stop it.
301
+ cancellable: Boolean(owner) && (view.status === 'running' || view.status === 'queued'),
164
302
  progress: null,
165
303
  record: view.record ? recordSummary(view) : null,
166
- brief: elsewhere ? null : view.brief,
167
- limits: elsewhere ? null : limitsSummary(),
168
- next_step: elsewhere
169
- ? `running in another severally process (pid ${owner.pid}); poll again later -- it can only be cancelled from that process`
304
+ brief: owner ? null : view.brief,
305
+ limits: owner ? null : limitsSummary(),
306
+ next_step: owner
307
+ ? `running in another severally process (pid ${owner.pid}); poll again later, or stop it with consult_cancel`
170
308
  : nextStep(view),
171
309
  };
172
310
  }
@@ -175,6 +313,10 @@ export class JobManager {
175
313
  return [...this.jobs.values()].filter((j) => j.status === 'running');
176
314
  }
177
315
 
316
+ /**
317
+ * @param {Job} job
318
+ * @returns {Round}
319
+ */
178
320
  #record(job) {
179
321
  return {
180
322
  job_id: job.job_id,
@@ -218,13 +360,27 @@ export class JobManager {
218
360
 
219
361
  // `detail: "full"` returns the brief as stored; by default a finished
220
362
  // consultation names it (size, sha256, round file) instead of repeating it.
221
- /** @param {string} jobId @param {{detail?: string}} [opts] */
363
+ /**
364
+ * @param {string} jobId
365
+ * @param {{detail?: string | undefined}} [opts]
366
+ * @returns {JobView | null}
367
+ */
222
368
  view(jobId, { detail } = {}) {
223
369
  const job = this.jobs.get(jobId);
224
370
  if (!job) {
225
371
  const rec = this.#fromDisk(jobId);
226
372
  return rec ? compactBrief(this.#diskView(rec), detail) : null;
227
373
  }
374
+ return this.#jobView(job, detail);
375
+ }
376
+
377
+ /**
378
+ * @param {Job} job
379
+ * @param {string | undefined} detail
380
+ * @returns {JobView}
381
+ */
382
+ #jobView(job, detail) {
383
+ /** @type {FullView} */
228
384
  const rec = this.#record(job);
229
385
  rec.cancellable = job.status === 'running' || job.status === 'queued';
230
386
  // While it runs, say what it is doing. "running" for twenty minutes tells
@@ -259,10 +415,18 @@ export class JobManager {
259
415
  // job_id and group_id can still be followed up and recorded against. The
260
416
  // index only picks and orders; each row shown is read from its round, which
261
417
  // holds the verdicts written since. Memory wins over disk for the same job.
418
+ /** @param {{limit?: number}} [opts] */
262
419
  list({ limit = 20 } = {}) {
263
- const here = this.order.map((jid) => this.jobs.get(jid));
264
- const seen = new Set(here.map((j) => j.job_id));
265
- const elsewhere = [];
420
+ // Sorted by when each was created; a round file is read only for the rows shown.
421
+ /** @type {Array<{at: string, load: () => Round | null}>} */
422
+ const candidates = [];
423
+ const seen = new Set();
424
+ for (const jid of this.order) {
425
+ const job = this.jobs.get(jid);
426
+ if (!job) continue;
427
+ seen.add(job.job_id);
428
+ candidates.push({ at: job.created_at ?? '', load: () => job });
429
+ }
266
430
  // Read only: a marker whose owner is gone is shown as interrupted, and
267
431
  // left for consult_get or the startup sweep to close out.
268
432
  for (const jobId of store.listRunning()) {
@@ -272,21 +436,24 @@ export class JobManager {
272
436
  const state = ownerState(jobId, live.owner);
273
437
  // A round saved just before its owner died is the answer, whatever the marker says.
274
438
  const saved = state === 'alive' ? null : store.loadRoundAt(live);
275
- elsewhere.push({
276
- rec: state === 'alive' ? live : (saved ?? { ...live, status: 'failed', failure: { kind: 'interrupted' } }),
277
- });
439
+ // The marker of a round whose owner is gone: shown as interrupted, with no failure detail to give.
440
+ const rec =
441
+ state === 'alive'
442
+ ? live
443
+ : (saved ?? /** @type {Round} */ ({ ...live, status: 'failed', failure: { kind: 'interrupted' } }));
444
+ candidates.push({ at: rec.created_at ?? '', load: () => rec });
278
445
  }
279
446
  for (const row of store.indexRows())
280
447
  if (!seen.has(row.job_id)) {
281
448
  seen.add(row.job_id);
282
- elsewhere.push({ row });
449
+ candidates.push({ at: row.created_at ?? '', load: () => store.loadRoundAt(row) });
283
450
  }
284
- const at = (x) => x.created_at ?? x.rec?.created_at ?? x.row?.created_at ?? '';
451
+ /** @type {Round[]} */
285
452
  const picked = [];
286
- // Round files are read only for the rows shown; one that cannot be read is passed over.
287
- for (const x of [...here, ...elsewhere].sort((p, q) => (at(q) < at(p) ? -1 : at(q) > at(p) ? 1 : 0))) {
453
+ // One that cannot be read is passed over.
454
+ for (const x of candidates.sort((p, q) => (q.at < p.at ? -1 : q.at > p.at ? 1 : 0))) {
288
455
  if (picked.length >= limit) break;
289
- const j = x.row ? store.loadRoundAt(x.row) : (x.rec ?? x);
456
+ const j = x.load();
290
457
  if (j) picked.push(j);
291
458
  }
292
459
  return picked.map((j) => {
@@ -321,10 +488,12 @@ export class JobManager {
321
488
  * but named under `not_reported`. Nothing is estimated.
322
489
  */
323
490
  usageTotals() {
491
+ /** @type {Record<string, Record<string, number> & {consultations: number, attempts: number}>} */
324
492
  const by = {};
493
+ /** @type {Record<string, Set<string>>} */
325
494
  const reported = {};
326
495
  for (const j of this.jobs.values()) {
327
- const attempts = [...(j.retries ?? []).map((r) => r.usage), j.usage].filter(Boolean);
496
+ const attempts = [...(j.retries ?? []).map((r) => r.usage), j.usage].filter((u) => u != null);
328
497
  if (!attempts.length) continue;
329
498
  const t = (by[j.target] ??= { consultations: 0, attempts: 0 });
330
499
  const seen = (reported[j.target] ??= new Set());
@@ -338,12 +507,15 @@ export class JobManager {
338
507
  }
339
508
  }
340
509
  }
510
+ /** @type {Record<string, Record<string, number | string[]>>} */
511
+ const out = {};
341
512
  for (const [target, t] of Object.entries(by)) {
342
- t.not_reported = USAGE_FIGURES.filter((k) => !reported[target].has(k));
513
+ out[target] = { ...t, not_reported: USAGE_FIGURES.filter((k) => !reported[target]?.has(k)) };
343
514
  }
344
- return by;
515
+ return out;
345
516
  }
346
517
 
518
+ /** @param {RawRequest} rawRequest the tool arguments, not yet validated */
347
519
  start(rawRequest) {
348
520
  if (process.env.SEVERALLY_ACTIVE === '1') {
349
521
  throw new RequestError(
@@ -359,7 +531,9 @@ export class JobManager {
359
531
  // Resolve the chain before validating, so follow-up rules can be applied.
360
532
  const followupTo =
361
533
  typeof rawRequest?.followup_to === 'string' && rawRequest.followup_to.trim() ? rawRequest.followup_to.trim() : null;
534
+ /** @type {(Round & {nextRound: number}) | null} */
362
535
  let parent = null;
536
+ /** @type {GroupParents | null} */
363
537
  let fromGroup = null;
364
538
  if (followupTo?.startsWith('group_')) {
365
539
  // A follow-up to a fan-out goes to every member that can take one; the
@@ -375,11 +549,11 @@ export class JobManager {
375
549
  } else if (followupTo) {
376
550
  // A job from an earlier server session is read back from the history,
377
551
  // so a restart does not end a consultation the lead is still checking.
378
- parent = this.jobs.get(followupTo) ?? store.loadRound(followupTo);
379
- if (!parent) throw new RequestError(`followup_to references unknown job "${followupTo}"`, 'unknown_job');
380
- if (parent.status !== 'completed') {
552
+ const found = this.jobs.get(followupTo) ?? store.loadRound(followupTo);
553
+ if (!found) throw new RequestError(`followup_to references unknown job "${followupTo}"`, 'unknown_job');
554
+ if (found.status !== 'completed') {
381
555
  throw new RequestError(
382
- `followup_to job "${followupTo}" is ${parent.status}; only a completed consultation can be followed up`,
556
+ `followup_to job "${followupTo}" is ${found.status}; only a completed consultation can be followed up`,
383
557
  'followup_not_completed',
384
558
  );
385
559
  }
@@ -387,23 +561,24 @@ export class JobManager {
387
561
  // round goes after every round already there: numbering it parent + 1
388
562
  // would overwrite a later round's history file. A later round that
389
563
  // failed or was cancelled does not block a retry from the parent.
390
- const rounds = this.#chainRounds(parent.chain_id);
391
- const later = rounds.find((j) => j.round > parent.round && !['failed', 'cancelled'].includes(j.status));
564
+ const rounds = this.#chainRounds(found.chain_id);
565
+ const later = rounds.find((j) => j.round > found.round && !['failed', 'cancelled'].includes(j.status));
392
566
  if (later) {
393
567
  throw new RequestError(
394
- `job "${followupTo}" is round ${parent.round}, and this consultation already has round ${later.round} (job "${later.job_id}", ${later.status}); follow up the latest round`,
568
+ `job "${followupTo}" is round ${found.round}, and this consultation already has round ${later.round} (job "${later.job_id}", ${later.status}); follow up the latest round`,
395
569
  'followup_not_latest',
396
570
  );
397
571
  }
398
- parent = { ...parent, nextRound: Math.max(...rounds.map((j) => j.round)) + 1 };
572
+ parent = { ...found, nextRound: Math.max(...rounds.map((j) => j.round)) + 1 };
399
573
  }
400
574
 
401
575
  if (parent && (rawRequest.target ?? null) === null && !rawRequest.targets?.length) {
402
576
  rawRequest = { ...rawRequest, target: parent.target };
403
577
  }
404
578
  // A fan-out's members were all handed the same brief, so any one of them holds it.
405
- if (parent || fromGroup)
406
- rawRequest = inherit(rawRequest, fromGroup ? (fromGroup.links[0].groupMember ?? fromGroup.links[0].parent) : parent);
579
+ const first = fromGroup?.links[0];
580
+ const inheritFrom = first ? (first.groupMember ?? first.parent) : parent;
581
+ if (inheritFrom) rawRequest = inherit(rawRequest, inheritFrom);
407
582
 
408
583
  const req = parseRequest(rawRequest, {
409
584
  isFollowup: Boolean(parent || fromGroup),
@@ -439,19 +614,22 @@ export class JobManager {
439
614
  // A follow-up's seat also names the chain and round it opens, so that two
440
615
  // servers continuing one consultation at once cannot both take a round
441
616
  // number: neither sees the other's round in its history until it ends.
442
- const chainOf = (i) => (fromGroup ? fromGroup.links[i] : parent ? { parent, round } : null);
443
- const seats = req.targets.map((target, i) => ({
444
- job_id: id('job'),
445
- ...(chainOf(i) ? { chain_id: chainOf(i).parent.chain_id, round: chainOf(i).round } : {}),
446
- // Twice the budget plus an hour: the deadline is wall-clock, the budget is
447
- // not, so a suspended laptop must not make a live owner look overdue.
448
- owner: {
449
- pid: process.pid,
450
- deadline_at: new Date(
451
- Date.now() + 2 * timeoutMs(consultantByName(target)) + POLICY.killGraceMs + 3_600_000,
452
- ).toISOString(),
453
- },
454
- }));
617
+ const chainOf = (/** @type {number} */ i) => (fromGroup ? fromGroup.links[i] : parent ? { parent, round } : null);
618
+ const seats = req.targets.map((/** @type {string} */ target, /** @type {number} */ i) => {
619
+ const link = chainOf(i);
620
+ return {
621
+ job_id: id('job'),
622
+ ...(link ? { chain_id: link.parent.chain_id, round: link.round } : {}),
623
+ // Twice the budget plus an hour: the deadline is wall-clock, the budget is
624
+ // not, so a suspended laptop must not make a live owner look overdue.
625
+ owner: {
626
+ pid: process.pid,
627
+ deadline_at: new Date(
628
+ Date.now() + 2 * timeoutMs(consultantByName(target)) + POLICY.killGraceMs + 3_600_000,
629
+ ).toISOString(),
630
+ },
631
+ };
632
+ });
455
633
  const taken = store.takeSeats(seats, POLICY.maxConcurrent, (seat) => ownerState(seat.job_id, seat.owner) === 'alive');
456
634
  if (taken.chainBusy) {
457
635
  const { live, mine } = taken.chainBusy;
@@ -472,6 +650,7 @@ export class JobManager {
472
650
  // A request refused from here on gives its seats back.
473
651
  try {
474
652
  // Each target's parent round and the round it opens, in req.targets order.
653
+ /** @type {Array<{parent: Round | null, round: number, groupMember?: Round}>} */
475
654
  const links = fromGroup?.links ?? req.targets.map(() => ({ parent, round }));
476
655
  return this.#admitted(req, links, seats, fromGroup);
477
656
  } catch (err) {
@@ -484,10 +663,16 @@ export class JobManager {
484
663
  // round of its own chain. A member that is still running, never answered or
485
664
  // has used its rounds is skipped and named; if that is every member, the
486
665
  // request is refused.
666
+ /**
667
+ * @param {string} groupId
668
+ * @returns {GroupParents}
669
+ */
487
670
  #groupParents(groupId) {
488
671
  const group = this.groups.get(groupId) ?? store.loadGroup(groupId);
489
672
  if (!group) throw new RequestError(`followup_to references unknown group "${groupId}"`, 'unknown_group');
673
+ /** @type {GroupLink[]} */
490
674
  const links = [];
675
+ /** @type {GroupParents['skipped']} */
491
676
  const skipped = [];
492
677
  for (const jid of group.job_ids) {
493
678
  const member = this.jobs.get(jid) ?? store.loadRound(jid);
@@ -501,20 +686,27 @@ export class JobManager {
501
686
  }
502
687
  const rounds = this.#chainRounds(member.chain_id);
503
688
  const latest = rounds.filter((j) => !['failed', 'cancelled'].includes(j.status)).sort((a, b) => b.round - a.round)[0];
689
+ if (!latest) {
690
+ skipped.push({
691
+ job_id: member.job_id,
692
+ target: member.target,
693
+ reason: `${member.status}, so there is no answer to follow up`,
694
+ });
695
+ continue;
696
+ }
504
697
  const nextRound = Math.max(...rounds.map((j) => j.round)) + 1;
505
698
  // The name is kept, not re-resolved: a consultant renamed or turned off
506
699
  // since is skipped, never continued by whichever one answers to it now.
507
700
  const consultant = consultantByName(member.target);
508
- const reason = !latest
509
- ? `${member.status}, so there is no answer to follow up`
510
- : latest.status !== 'completed'
701
+ const reason =
702
+ latest.status !== 'completed'
511
703
  ? `round ${latest.round} is still ${latest.status}`
512
704
  : nextRound > POLICY.maxRounds
513
705
  ? `has used its ${POLICY.maxRounds} rounds`
514
706
  : !consultant || !consultantAvailable(consultant)
515
707
  ? 'this consultant is no longer configured or available here'
516
708
  : null;
517
- if (reason) skipped.push({ job_id: (latest ?? member).job_id, target: member.target, reason });
709
+ if (reason) skipped.push({ job_id: latest.job_id, target: member.target, reason });
518
710
  else links.push({ parent: { ...latest, nextRound }, round: nextRound, groupMember: member });
519
711
  }
520
712
  if (!links.length) {
@@ -526,21 +718,30 @@ export class JobManager {
526
718
  return { group, links, skipped };
527
719
  }
528
720
 
721
+ /**
722
+ * @param {ParsedRequest} req
723
+ * @param {Array<{parent: Round | null, round: number, groupMember?: Round}>} links
724
+ * @param {Seat[]} seats
725
+ * @param {GroupParents | null} [fromGroup]
726
+ */
529
727
  #admitted(req, links, seats, fromGroup = null) {
530
728
  // What expose_from_parent copies again: for a group, what that fan-out
531
729
  // exposed (one manifest, shared by its members); otherwise the parent's.
532
- const parent = links[0].groupMember ?? links[0].parent;
730
+ const first = links[0];
731
+ const parent = first?.groupMember ?? first?.parent;
533
732
  const groupId = id('group');
534
733
  // Copied once for the whole request, before a round is counted or a job
535
734
  // exists, into the group's own directory: every member is then handed the
536
735
  // same bytes (the manifest carries their sha256), and a path that is
537
736
  // missing or a tree that blows the cap is the lead's to fix now rather
538
737
  // than a job that fails having already spent a round of this chain.
738
+ /** @type {ExposeManifest | null} */
539
739
  let exposeManifest = null;
540
- const parentEntries = req.context.expose_from_parent ? (parent.brief?.expose_paths?.entries ?? []) : [];
740
+ /** @type {BriefExposeEntry[]} */
741
+ const parentEntries = req.context.expose_from_parent ? (parent?.brief?.expose_paths?.entries ?? []) : [];
541
742
  if (req.context.expose_from_parent && parentEntries.length === 0) {
542
743
  throw new RequestError(
543
- `context.expose_from_parent: job "${parent.job_id}" exposed no files, so there is nothing to copy again; name them in context.expose_paths`,
744
+ `context.expose_from_parent: job "${parent?.job_id}" exposed no files, so there is nothing to copy again; name them in context.expose_paths`,
544
745
  'nothing_to_inherit',
545
746
  );
546
747
  }
@@ -564,7 +765,10 @@ export class JobManager {
564
765
  if (req.context.expose_diff) {
565
766
  exposeManifest.skipped.push({ source: d.source, reason: 'replaced', exposed_as: d.exposed_as });
566
767
  } else {
567
- if (reexposeChange(snapshot, d, exposeManifest)) exposeManifest.entries.at(-1).inherited = true;
768
+ if (reexposeChange(snapshot, d, exposeManifest)) {
769
+ const added = exposeManifest.entries.at(-1);
770
+ if (added) added.inherited = true;
771
+ }
568
772
  }
569
773
  }
570
774
  if (req.context.expose_diff) {
@@ -573,7 +777,7 @@ export class JobManager {
573
777
  if (diff) {
574
778
  req.context.artifacts = [
575
779
  ...req.context.artifacts,
576
- { name: `git diff ${exposeManifest.entries.at(-1).base}`, kind: 'diff', language: 'diff', excerpt: diff },
780
+ { name: `git diff ${exposeManifest.entries.at(-1)?.base}`, kind: 'diff', language: 'diff', excerpt: diff },
577
781
  ];
578
782
  }
579
783
  }
@@ -587,21 +791,27 @@ export class JobManager {
587
791
  !req.context.artifacts.length
588
792
  ) {
589
793
  throw new RequestError(
590
- `context.expose_from_parent: nothing job "${parent.job_id}" exposed is there any more, and this request has no facts or artifacts`,
794
+ `context.expose_from_parent: nothing job "${parent?.job_id}" exposed is there any more, and this request has no facts or artifacts`,
591
795
  'nothing_to_inherit',
592
796
  );
593
797
  }
594
798
  } catch (err) {
595
799
  store.cleanupJobDir(groupId);
596
- if (err instanceof RequestError || !err.code) throw err;
800
+ if (err instanceof RequestError || !(err instanceof Error) || !('code' in err) || !err.code) throw err;
597
801
  throw new RequestError(err.message, err.code === 'too_large' ? 'expose_paths_too_large' : 'expose_path_invalid');
598
802
  }
599
803
  req._snapshot = path.join(snapshot, 'workspace');
600
804
  }
601
- const members = req.targets.map((target, i) => {
602
- const { parent: from, round } = links[i];
805
+ const members = req.targets.map((/** @type {string} */ target, /** @type {number} */ i) => {
806
+ // One link and one seat per target: start() builds them from req.targets.
807
+ const link = links[i];
808
+ const seat = seats[i];
809
+ if (!link || !seat) throw new Error(`no follow-up link or seat for target "${target}"`);
810
+ const { parent: from, round } = link;
603
811
  const chainId = from ? from.chain_id : id('chain');
604
- const jobId = seats[i].job_id;
812
+ const jobId = seat.job_id;
813
+ const consultant = consultantNamed(target);
814
+ /** @type {Job} */
605
815
  const job = {
606
816
  job_id: jobId,
607
817
  chain_id: chainId,
@@ -638,9 +848,9 @@ export class JobManager {
638
848
  status: 'queued',
639
849
  // What this consultant is: the CLI it runs on, the model and its
640
850
  // lineage, as the operator defined it.
641
- cli: consultantByName(target).cli,
642
- model: consultantByName(target).model,
643
- vendor: consultantByName(target).vendor,
851
+ cli: consultant.cli,
852
+ model: consultant.model,
853
+ vendor: consultant.vendor,
644
854
  created_at: new Date().toISOString(),
645
855
  started_at: null,
646
856
  finished_at: null,
@@ -657,18 +867,21 @@ export class JobManager {
657
867
  this.order.push(jobId);
658
868
  LIVE_HERE.add(jobId);
659
869
  this.#watchCancels();
660
- job._owner = seats[i].owner;
870
+ job._owner = seat.owner;
661
871
  this.#mark(job);
662
872
  return job;
663
873
  });
664
874
  this.#evict();
665
875
 
876
+ const [firstMember] = members;
877
+ if (!firstMember) throw new Error('a consultation needs a target');
878
+ /** @type {Group} */
666
879
  const group = {
667
880
  group_id: groupId,
668
881
  created_at: new Date().toISOString(),
669
882
  mode: req.mode,
670
- question: members[0].question, // already redacted, and byte-identical across the group
671
- job_ids: members.map((m) => m.job_id),
883
+ question: firstMember.question, // already redacted, and byte-identical across the group
884
+ job_ids: members.map((/** @type {Job} */ m) => m.job_id),
672
885
  };
673
886
  this.groups.set(groupId, group);
674
887
  try {
@@ -694,13 +907,13 @@ export class JobManager {
694
907
  if (req._snapshot) store.cleanupJobDir(groupId);
695
908
 
696
909
  // The members of a follow-up to a group can be at different rounds.
697
- const { round } = members[0];
698
- const sameRound = members.every((m) => m.round === round);
910
+ const { round } = firstMember;
911
+ const sameRound = members.every((/** @type {Job} */ m) => m.round === round);
699
912
  const common = {
700
913
  group_id: groupId,
701
914
  ...(sameRound ? { round, rounds_remaining: POLICY.maxRounds - round } : {}),
702
915
  ...(fromGroup ? { followup_to_group: fromGroup.group.group_id, skipped: fromGroup.skipped } : {}),
703
- ...(parentEntries.length ? { exposed_from_parent: inheritedSummary(exposeManifest) } : {}),
916
+ ...(parentEntries.length && exposeManifest ? { exposed_from_parent: inheritedSummary(exposeManifest) } : {}),
704
917
  mode: req.mode,
705
918
  // Counted, not refused: a brief of facts alone is a legitimate
706
919
  // consultation, but one about code that hands over no file is one the
@@ -718,7 +931,7 @@ export class JobManager {
718
931
  };
719
932
 
720
933
  if (!req.fanout) {
721
- const job = members[0];
934
+ const job = firstMember;
722
935
  return {
723
936
  ...common,
724
937
  job_id: job.job_id,
@@ -732,7 +945,7 @@ export class JobManager {
732
945
  return {
733
946
  ...common,
734
947
  question: group.question, // the redacted text every member was actually sent
735
- jobs: members.map((m) => ({
948
+ jobs: members.map((/** @type {Job} */ m) => ({
736
949
  job_id: m.job_id,
737
950
  chain_id: m.chain_id,
738
951
  target: m.target,
@@ -749,20 +962,34 @@ export class JobManager {
749
962
  // (memory wins: it can hold a verdict written since the round was saved).
750
963
  // Each point goes with the lead's verdict on it, so the consultant answers
751
964
  // knowing which of its points were checked and how they came out.
965
+ /**
966
+ * @param {string} chainId
967
+ * @returns {Round[]}
968
+ */
752
969
  #chainRounds(chainId) {
970
+ /** @type {Map<number, Round>} */
753
971
  const byRound = new Map(store.loadChain(chainId).map((j) => [j.round, j]));
754
972
  for (const j of this.jobs.values()) if (j.chain_id === chainId) byRound.set(j.round, j);
755
973
  return [...byRound.values()];
756
974
  }
757
975
 
976
+ /**
977
+ * @param {string} chainId
978
+ * @returns {PriorRound[]}
979
+ */
758
980
  #chainHistory(chainId) {
759
981
  return this.#chainRounds(chainId)
760
- .filter((j) => j.status === 'completed' && j.result)
982
+ .filter(/** @returns {j is Round & {result: ConsultResult}} */ (j) => j.status === 'completed' && Boolean(j.result))
761
983
  .sort((a, b) => a.round - b.round)
762
984
  .map((j) => {
763
985
  const verdicts = new Map((j.record?.entries ?? []).map((e) => [e.id, e]));
764
986
  // The checked points first, then the unchecked ones, each with the
765
987
  // lead's verdict and effect when there is one.
988
+ /**
989
+ * @template {ResultItem} T
990
+ * @param {T[]} items
991
+ * @param {(x: T) => {point: string, grounds?: string | null}} shape
992
+ */
766
993
  const ordered = (items, shape) =>
767
994
  [...items.filter((x) => verdicts.has(x.id)), ...items.filter((x) => !verdicts.has(x.id))].map((x) => ({
768
995
  id: x.id,
@@ -770,7 +997,7 @@ export class JobManager {
770
997
  verdict: verdicts.get(x.id)?.verdict ?? null,
771
998
  effect: verdicts.get(x.id)?.effect ?? null,
772
999
  }));
773
- const checked = (x) => x.verdict !== null;
1000
+ const checked = (/** @type {{verdict: unknown}} */ x) => x.verdict !== null;
774
1001
  const findings = ordered(j.result.findings, (f) => ({ point: f.point, grounds: f.grounds ?? null }));
775
1002
  return {
776
1003
  round: j.round,
@@ -788,6 +1015,7 @@ export class JobManager {
788
1015
  #evict() {
789
1016
  while (this.order.length > POLICY.maxJobsRetained) {
790
1017
  const oldest = this.order.shift();
1018
+ if (oldest === undefined) break;
791
1019
  const j = this.jobs.get(oldest);
792
1020
  if (j && (j.status === 'running' || j.status === 'queued')) {
793
1021
  this.order.push(oldest); // never evict live work
@@ -802,6 +1030,7 @@ export class JobManager {
802
1030
  // last member there is nothing left to compare, so drop the group rather than
803
1031
  // keep answering as a complete fan-out with no answers in it (and keep the
804
1032
  // Map, which holds the question text, from growing without bound).
1033
+ /** @param {string} groupId */
805
1034
  #dropGroupIfEmpty(groupId) {
806
1035
  const group = this.groups.get(groupId);
807
1036
  if (!group) return;
@@ -809,8 +1038,15 @@ export class JobManager {
809
1038
  this.groups.delete(groupId);
810
1039
  }
811
1040
 
1041
+ /**
1042
+ * @param {Job} job
1043
+ * @param {ParsedRequest} req
1044
+ * @param {import('./brief.mjs').BriefChain} chain
1045
+ * @param {string} [brief]
1046
+ * @returns {Promise<Job>}
1047
+ */
812
1048
  async #execute(job, req, chain, brief) {
813
- const consultant = consultantByName(job.target);
1049
+ const consultant = consultantNamed(job.target);
814
1050
  const cli = consultant.cli;
815
1051
  const adapter = ADAPTERS[cli];
816
1052
  const workdir = store.makeWorkdir(job.job_id);
@@ -822,7 +1058,7 @@ export class JobManager {
822
1058
  try {
823
1059
  fs.cpSync(req._snapshot, path.join(workdir, 'workspace'), { recursive: true });
824
1060
  } catch (err) {
825
- return this.#fail(job, 'spawn_error', `could not copy the exposed files: ${err.message}`);
1061
+ return this.#fail(job, 'spawn_error', `could not copy the exposed files: ${err instanceof Error ? err.message : err}`);
826
1062
  }
827
1063
  }
828
1064
  // start() renders the brief and hands it in: every member of a fan-out is
@@ -830,6 +1066,7 @@ export class JobManager {
830
1066
  // added on a follow-up to the group.
831
1067
  const text = brief ?? renderBrief(req, chain);
832
1068
  const schemaPath = store.writeJobArtifact(job.job_id, 'response-schema.json', JSON.stringify(CONSULT_RESULT_SCHEMA, null, 2));
1069
+ // Left out, not undefined, when the request names none.
833
1070
  const sandbox = adapter.prepareSandbox ? adapter.prepareSandbox({ workdir, network: req.network }) : null;
834
1071
 
835
1072
  try {
@@ -886,28 +1123,37 @@ export class JobManager {
886
1123
  // its latency is part of this job's duration and budget, not free time
887
1124
  // before the meter starts.
888
1125
  if (adapter.prepare) await adapter.prepare();
889
- const invocation = adapter.buildInvocation({
890
- workdir,
891
- schemaPath,
892
- guardrails: renderGuardrails(Boolean(req._snapshot), req.network),
893
- sandbox,
894
- consultant,
895
- hasExposedPaths: Boolean(req._snapshot),
896
- network: req.network,
897
- });
1126
+ // Built again for the fallback model, so the operator's extra args and
1127
+ // the forbidden-flag check apply to that run exactly as to the first.
1128
+ // Returns the invocation, or the problem with the operator's args.
1129
+ /** @param {import('./consultants.mjs').Consultant} c @returns {import('./run.mjs').Invocation | string} */
1130
+ const launch = (c) => {
1131
+ const invocation = adapter.buildInvocation({
1132
+ workdir,
1133
+ schemaPath,
1134
+ guardrails: renderGuardrails(Boolean(req._snapshot), req.network),
1135
+ sandbox,
1136
+ consultant: c,
1137
+ hasExposedPaths: Boolean(req._snapshot),
1138
+ network: req.network,
1139
+ });
1140
+ const problem = extraArgsProblem(cli, invocation.args, t.extraArgs);
1141
+ if (problem) return problem;
1142
+ invocation.args = withExtraArgs(invocation.args, t.extraArgs);
1143
+ assertNoForbiddenFlags(cli, invocation.args);
1144
+ // What this consultant was actually launched with, read off the
1145
+ // invocation and the sandbox rather than restated from intent, so the
1146
+ // record says what held for this run.
1147
+ job.boundary =
1148
+ adapter.boundary?.({ args: invocation.args, sandbox, network: req.network, hasExposedPaths: Boolean(req._snapshot) }) ??
1149
+ null;
1150
+ return invocation;
1151
+ };
1152
+ const first = launch(consultant);
1153
+ if (typeof first === 'string') return this.#fail(job, 'spawn_error', `config clis.${cli}.args: ${first}`);
1154
+ let invocation = first;
898
1155
 
899
- const argsProblem = extraArgsProblem(cli, invocation.args, t.extraArgs);
900
- if (argsProblem) return this.#fail(job, 'spawn_error', `config clis.${cli}.args: ${argsProblem}`);
901
- invocation.args = withExtraArgs(invocation.args, t.extraArgs);
902
- assertNoForbiddenFlags(cli, invocation.args);
903
- // What this consultant was actually launched with, read off the
904
- // invocation and the sandbox rather than restated from intent, so the
905
- // record says what held for this run.
906
- job.boundary =
907
- adapter.boundary?.({ args: invocation.args, sandbox, network: req.network, hasExposedPaths: Boolean(req._snapshot) }) ??
908
- null;
909
-
910
- const attempt = (timeout) => {
1156
+ const attempt = (/** @type {number} */ timeout) => {
911
1157
  const { handle, done } = runChild({
912
1158
  command: invocation.command,
913
1159
  args: invocation.args,
@@ -915,7 +1161,9 @@ export class JobManager {
915
1161
  env,
916
1162
  input: text,
917
1163
  timeoutMs: timeout,
918
- onCancelSignal: (fn) => job._cancelFns.push(fn),
1164
+ onCancelSignal: (fn) => {
1165
+ job._cancelFns.push(fn);
1166
+ },
919
1167
  compactLine: adapter.compactLine,
920
1168
  settleAfter: adapter.settleAfter,
921
1169
  stopSignal: adapter.stopSignal,
@@ -933,9 +1181,11 @@ export class JobManager {
933
1181
  // Same consultant, same model, same brief: a re-run never picks another
934
1182
  // mind. Every earlier failure is kept in job.retries.
935
1183
  let run = await attempt(budgetMs);
1184
+ /** @type {import('./run.mjs').Interpreted} */
936
1185
  let interpreted;
937
1186
  let authTried = false;
938
1187
  let autoRetries = 0;
1188
+ let fallenBack = false;
939
1189
  for (;;) {
940
1190
  // Read off the consultant's own event stream, last attempt only; null
941
1191
  // where the CLI does not stream its tool calls (Claude Code).
@@ -976,7 +1226,7 @@ export class JobManager {
976
1226
  interpreted = adapter.interpret({ ...run, lastMessageText });
977
1227
  // An answer after an error the CLI reported is kept only if it parses;
978
1228
  // otherwise it is that error, re-run like any other.
979
- const { recoveredError } = interpreted;
1229
+ const recoveredError = interpreted.ok ? interpreted.recoveredError : null;
980
1230
  if (interpreted.ok && recoveredError && !parses(interpreted.text)) {
981
1231
  interpreted = { ok: false, ...recoveredError, usageRaw: interpreted.usageRaw };
982
1232
  }
@@ -1007,6 +1257,25 @@ export class JobManager {
1007
1257
  continue;
1008
1258
  }
1009
1259
  }
1260
+ // The operator's fallback for a model whose account quota is used up
1261
+ // (a re-run on the same model would fail the same way). It is a
1262
+ // different mind from the one asked, so the answer says so below.
1263
+ if (interpreted.failureKind === 'usage_limit' && consultant.fallbackModel && !fallenBack && remaining >= MIN_RETRY_MS) {
1264
+ fallenBack = true;
1265
+ const fallback = consultant.fallbackModel;
1266
+ const next = launch({ ...consultant, model: fallback });
1267
+ if (typeof next === 'string') return this.#fail(job, 'spawn_error', `config clis.${cli}.args: ${next}`);
1268
+ invocation = next;
1269
+ job.retries.push({
1270
+ failure_kind: interpreted.failureKind,
1271
+ reason: `${job.model} quota used up; re-run on ${fallback}`,
1272
+ at: new Date().toISOString(),
1273
+ usage: adapter.usageRecord(interpreted.usageRaw),
1274
+ });
1275
+ job.model = fallback;
1276
+ run = await attempt(remaining);
1277
+ continue;
1278
+ }
1010
1279
  const wait = POLICY.retryBackoffMs * 3 ** autoRetries;
1011
1280
  if (
1012
1281
  AUTO_RETRY_KINDS.includes(interpreted.failureKind) &&
@@ -1020,7 +1289,7 @@ export class JobManager {
1020
1289
  at: new Date().toISOString(),
1021
1290
  usage: adapter.usageRecord(interpreted.usageRaw),
1022
1291
  });
1023
- await new Promise((resolve) => {
1292
+ await new Promise((/** @type {(value?: unknown) => void} */ resolve) => {
1024
1293
  const timer = setTimeout(resolve, wait);
1025
1294
  job._cancelFns.push(() => {
1026
1295
  clearTimeout(timer);
@@ -1079,6 +1348,9 @@ export class JobManager {
1079
1348
  `the consultant's CLI reported an error mid-run and then finished its answer: ${redact(interpreted.recoveredError.message).slice(0, 300)}`,
1080
1349
  leftover &&
1081
1350
  'the answer parsed, but a process the consultant started kept its output open after it exited and was stopped (one that left its process group may still be running)',
1351
+ fallenBack && `answered by ${job.model}: ${consultant.model}'s quota was used up`,
1352
+ req._snapshot && unreadCaveat(job.tool_calls, adapter.FILE_READ_TOOLS),
1353
+ refusedToolCaveat(job.tool_calls, adapter.ALLOWED_TOOLS),
1082
1354
  ].filter(Boolean);
1083
1355
  // No `advice_usable` verdict here. It was always true, so it carried no
1084
1356
  // information -- and worse, it read as the server vouching for advice it
@@ -1101,6 +1373,13 @@ export class JobManager {
1101
1373
  }
1102
1374
  }
1103
1375
 
1376
+ /**
1377
+ * @param {Job} job
1378
+ * @param {string} kind
1379
+ * @param {unknown} message
1380
+ * @param {string | null} [detail]
1381
+ * @returns {Job}
1382
+ */
1104
1383
  #fail(job, kind, message, detail) {
1105
1384
  if (job.status === 'completed' || job.status === 'failed' || job.status === 'cancelled') return job;
1106
1385
  job.status = kind === 'cancelled' ? 'cancelled' : 'failed';
@@ -1117,6 +1396,7 @@ export class JobManager {
1117
1396
  }
1118
1397
 
1119
1398
  // The in-progress marker other processes read (see store.writeRunning).
1399
+ /** @param {Job} job */
1120
1400
  #mark(job) {
1121
1401
  try {
1122
1402
  store.writeRunning({ ...this.#record(job), owner: job._owner });
@@ -1125,6 +1405,7 @@ export class JobManager {
1125
1405
  }
1126
1406
  }
1127
1407
 
1408
+ /** @param {Job} job */
1128
1409
  #finish(job) {
1129
1410
  try {
1130
1411
  store.persistRound(this.#record(job));
@@ -1145,7 +1426,7 @@ export class JobManager {
1145
1426
  // consultant did not produce and a word outside VERDICTS, then stores what
1146
1427
  // was written. It never decides that a finding was adopted, and it never
1147
1428
  // fills in a verdict the lead did not write.
1148
- /** @param {Record<string, any>} [input] */
1429
+ /** @param {RawRecord} [input] */
1149
1430
  record(input = {}) {
1150
1431
  if (input.records !== undefined) return this.#recordMany(input);
1151
1432
  const allowed = ['job_id', 'entries', 'reflection'];
@@ -1169,6 +1450,7 @@ export class JobManager {
1169
1450
  // record is checked before anything is written, so a bad id leaves the
1170
1451
  // history as it was; then each job is written on its own, and a write that
1171
1452
  // fails is named in the answer rather than reported as saved.
1453
+ /** @param {RawRecord} input */
1172
1454
  #recordMany(input) {
1173
1455
  const extra = Object.keys(input).filter((k) => k !== 'records');
1174
1456
  if (extra.length) {
@@ -1181,10 +1463,12 @@ export class JobManager {
1181
1463
  if (!Array.isArray(records) || records.length === 0) {
1182
1464
  throw new RequestError('records must be a non-empty array of { job_id, entries?, reflection? }', 'records_required');
1183
1465
  }
1466
+ /** @type {Array<{index: number, job_id: string | null, error: string, message: string}>} */
1184
1467
  const problems = [];
1468
+ /** @type {RecordChange[]} */
1185
1469
  const changes = [];
1186
1470
  const seen = new Set();
1187
- records.forEach((r, i) => {
1471
+ records.forEach((/** @type {RawRecord} */ r, i) => {
1188
1472
  try {
1189
1473
  if (seen.has(r?.job_id))
1190
1474
  throw new RequestError(`job "${r.job_id}" appears twice; give each job one record`, 'duplicate_job');
@@ -1212,7 +1496,10 @@ export class JobManager {
1212
1496
 
1213
1497
  // Everything that can be wrong with a record, found before anything is
1214
1498
  // written: what the job's record and reflection become, or a RequestError.
1215
- /** @param {{job_id?: string, entries?: any[], reflection?: any}} rec */
1499
+ /**
1500
+ * @param {RawRecord} rec
1501
+ * @returns {RecordChange}
1502
+ */
1216
1503
  #checkRecord({ job_id: jobId, entries, reflection }) {
1217
1504
  if (entries === undefined && reflection === undefined) {
1218
1505
  throw new RequestError(
@@ -1240,7 +1527,8 @@ export class JobManager {
1240
1527
 
1241
1528
  const ids = recordableIds(job.result);
1242
1529
  const now = new Date().toISOString();
1243
- const merged = new Map((job.record?.entries ?? []).map((e) => [e.id, e]));
1530
+ /** @type {Map<string, RecordEntry>} */
1531
+ const written = new Map();
1244
1532
 
1245
1533
  for (const entry of entries ?? []) {
1246
1534
  const id = typeof entry?.id === 'string' ? entry.id.trim() : '';
@@ -1257,7 +1545,7 @@ export class JobManager {
1257
1545
  'invalid_verdict',
1258
1546
  );
1259
1547
  }
1260
- merged.set(id, {
1548
+ written.set(id, {
1261
1549
  id,
1262
1550
  verdict,
1263
1551
  effect: text(entry?.effect),
@@ -1271,6 +1559,7 @@ export class JobManager {
1271
1559
  // lead predicted can still arrive with the evidence that settles it, and a
1272
1560
  // surprise can still be wrong, so a match/miss verdict would rate the
1273
1561
  // consultation on the wrong axis.
1562
+ /** @type {Reflection | undefined} */
1274
1563
  let newReflection;
1275
1564
  if (reflection !== undefined) {
1276
1565
  const delta = text(reflection?.delta);
@@ -1289,30 +1578,40 @@ export class JobManager {
1289
1578
  newReflection = { delta, related_item_ids: related, recorded_at: now };
1290
1579
  }
1291
1580
 
1292
- const newRecord =
1293
- entries === undefined
1294
- ? undefined
1295
- : { updated_at: now, entries: ids.filter((id) => merged.has(id)).map((id) => merged.get(id)) };
1296
- return { job, live, record: newRecord, reflection: newReflection };
1581
+ return { job, live, entries: entries === undefined ? undefined : [...written.values()], reflection: newReflection };
1297
1582
  }
1298
1583
 
1299
- #saveRecord({ job, live, record, reflection }) {
1584
+ /** @param {RecordChange} change */
1585
+ #saveRecord({ job, live, entries, reflection }) {
1300
1586
  const was = { record: job.record, reflection: job.reflection };
1301
- if (reflection !== undefined) job.reflection = reflection;
1302
- if (record !== undefined) job.record = record;
1587
+ // Another server may have written verdicts or a reflection to this round
1588
+ // since this one loaded it. Start from the file, so this call replaces
1589
+ // only the ids it names (and the reflection, if it passes one) and keeps
1590
+ // the rest; the merge goes back into memory too, so consult_get and a
1591
+ // follow-up's brief show the same record.
1592
+ // ponytail: read-then-write, not locked; two writes in the same instant can still lose one.
1593
+ const disk = store.loadRoundAt(job);
1594
+ const base = disk?.result ? disk : job;
1595
+ job.reflection = reflection ?? base.reflection;
1596
+ if (entries !== undefined) {
1597
+ const merged = new Map((base.record?.entries ?? []).map((e) => [e.id, e]));
1598
+ for (const e of entries) merged.set(e.id, e);
1599
+ const ids = job.result ? recordableIds(job.result) : [];
1600
+ job.record = { updated_at: new Date().toISOString(), entries: ids.flatMap((id) => merged.get(id) ?? []) };
1601
+ } else if (base.record) job.record = base.record;
1303
1602
  // An update to a round that already happened, so the index line stays as it
1304
1603
  // was: the history has one line per consultation, not one per edit.
1305
1604
  // Unlike the round itself, a verdict exists nowhere else once this server
1306
1605
  // exits, so a failed write is the lead's to know about, not best effort.
1307
1606
  try {
1308
- store.persistRound(live ? this.#record(job) : job, { appendIndex: false });
1607
+ store.persistRound(live ? this.#record(live) : job, { appendIndex: false });
1309
1608
  } catch (e) {
1310
1609
  // Not saved, so not shown as recorded either.
1311
1610
  Object.assign(job, was);
1312
1611
  return {
1313
1612
  job_id: job.job_id,
1314
1613
  error: 'record_not_saved',
1315
- message: `the verdict for "${job.job_id}" could not be written to the history (${e.message}); record it again`,
1614
+ message: `the verdict for "${job.job_id}" could not be written to the history (${e instanceof Error ? e.message : e}); record it again`,
1316
1615
  };
1317
1616
  }
1318
1617
  // Counts only: the entries are what the lead just wrote, and
@@ -1327,6 +1626,12 @@ export class JobManager {
1327
1626
  };
1328
1627
  }
1329
1628
 
1629
+ /**
1630
+ * @param {string} jobId
1631
+ * @param {number | undefined} waitMs
1632
+ * @param {{detail?: string | undefined}} [opts]
1633
+ * @param {AbortSignal} [signal]
1634
+ */
1330
1635
  async wait(jobId, waitMs, opts, signal) {
1331
1636
  const job = this.jobs.get(jobId);
1332
1637
  if (!job) return this.#waitOnDisk(() => this.view(jobId, opts), waitMs, signal);
@@ -1340,6 +1645,13 @@ export class JobManager {
1340
1645
 
1341
1646
  // A consultation another process runs has no promise here, so its history
1342
1647
  // is re-read once a second until it is no longer running or the wait is up.
1648
+ /**
1649
+ * @template {{status: string}} T
1650
+ * @param {() => T | null} read
1651
+ * @param {number | undefined} waitMs
1652
+ * @param {AbortSignal} [signal]
1653
+ * @returns {Promise<T | null>}
1654
+ */
1343
1655
  async #waitOnDisk(read, waitMs, signal) {
1344
1656
  const until = Date.now() + Math.min(Math.max(0, waitMs ?? 0), POLICY.maxWaitMs);
1345
1657
  let view = read();
@@ -1364,6 +1676,7 @@ export class JobManager {
1364
1676
  }, CANCEL_POLL_MS).unref();
1365
1677
  }
1366
1678
 
1679
+ /** @param {string} jobId */
1367
1680
  cancel(jobId) {
1368
1681
  const job = this.jobs.get(jobId);
1369
1682
  if (!job) {
@@ -1379,18 +1692,22 @@ export class JobManager {
1379
1692
  };
1380
1693
  }
1381
1694
  if (job.status !== 'running' && job.status !== 'queued') {
1382
- return { ...this.view(jobId), cancel_effect: `job was already ${job.status}` };
1695
+ return { ...this.#jobView(job, undefined), cancel_effect: `job was already ${job.status}` };
1383
1696
  }
1384
1697
  job.cancelRequested = true;
1385
1698
  for (const fn of job._cancelFns) fn();
1386
1699
  if (job._handle) job._handle.stop();
1387
- return { ...this.view(jobId), status: 'cancelling', cancel_effect: 'consultant process group signalled' };
1700
+ return { ...this.#jobView(job, undefined), status: 'cancelling', cancel_effect: 'consultant process group signalled' };
1388
1701
  }
1389
1702
 
1390
1703
  // Every field here is derived from the one `members` array it is handed, so a
1391
1704
  // group view can never report one status in `members` and another in
1392
1705
  // `comparison` or `status` for the same job.
1393
- /** @param {any} group @param {any[]} members @param {{detail?: string}} [opts] */
1706
+ /**
1707
+ * @param {Group} group
1708
+ * @param {JobView[]} members
1709
+ * @param {{detail?: string | undefined}} [opts]
1710
+ */
1394
1711
  #shapeGroup(group, members, { detail } = {}) {
1395
1712
  const full = detail === 'full';
1396
1713
  const cancelling = members.some((m) => m.status === 'cancelling');
@@ -1470,16 +1787,29 @@ export class JobManager {
1470
1787
  // While no one has finished there is nothing to set side by side, and an
1471
1788
  // empty column per consultant is only context spent on every poll.
1472
1789
  const anyFinished = members.some((m) => ['completed', 'failed', 'cancelled'].includes(m.status));
1473
- if (anyFinished) view.comparison = comparison(members, full ? Infinity : GROUP_CHARS - printed(view));
1474
- return view;
1790
+ return anyFinished ? { ...view, comparison: comparison(members, full ? Infinity : GROUP_CHARS - printed(view)) } : view;
1475
1791
  }
1476
1792
 
1793
+ /**
1794
+ * @param {string} groupId
1795
+ * @param {{detail?: string | undefined}} [opts]
1796
+ */
1477
1797
  groupView(groupId, opts) {
1478
1798
  const group = this.groups.get(groupId) ?? store.loadGroup(groupId);
1479
1799
  if (!group) return null;
1480
- return this.#shapeGroup(group, group.job_ids.map((jid) => this.view(jid, opts)).filter(Boolean), opts);
1800
+ return this.#shapeGroup(
1801
+ group,
1802
+ group.job_ids.map((jid) => this.view(jid, opts)).filter((v) => v != null),
1803
+ opts,
1804
+ );
1481
1805
  }
1482
1806
 
1807
+ /**
1808
+ * @param {string} groupId
1809
+ * @param {number | undefined} waitMs
1810
+ * @param {{detail?: string | undefined}} [opts]
1811
+ * @param {AbortSignal} [signal]
1812
+ */
1483
1813
  async waitGroup(groupId, waitMs, opts, signal) {
1484
1814
  const group = this.groups.get(groupId);
1485
1815
  // A fan-out from another process or an earlier session: nothing here to
@@ -1493,6 +1823,7 @@ export class JobManager {
1493
1823
  return this.groupView(groupId, opts);
1494
1824
  }
1495
1825
 
1826
+ /** @param {string} groupId */
1496
1827
  cancelGroup(groupId) {
1497
1828
  const group = this.groups.get(groupId);
1498
1829
  if (!group) {
@@ -1519,7 +1850,7 @@ export class JobManager {
1519
1850
  // Shape the view from the cancel() results, not from a fresh read: the
1520
1851
  // children exit asynchronously, so a signalled member reads as
1521
1852
  // "cancelling" until it is reaped, and the whole payload must say so.
1522
- const members = group.job_ids.map((jid) => this.cancel(jid)).filter(Boolean);
1853
+ const members = group.job_ids.map((jid) => this.cancel(jid)).filter((v) => v != null);
1523
1854
  const total = group.job_ids.length;
1524
1855
  return {
1525
1856
  ...this.#shapeGroup(group, members),
@@ -1577,8 +1908,13 @@ export class JobManager {
1577
1908
  // files of that diff are the same entry for this purpose.
1578
1909
  // ponytail: a diff recorded before 1.11 hashed its files in git's order, not
1579
1910
  // path order, so its re-copy can read as changed when it is not.
1911
+ /**
1912
+ * @param {ExposeManifest} manifest
1913
+ * @param {BriefExposeEntry[]} parentEntries
1914
+ */
1580
1915
  function markSinceParent(manifest, parentEntries) {
1581
- const key = (source, kind) => `${kind === 'change_files' ? 'diff' : kind}\0${source}`;
1916
+ const key = (/** @type {string} */ source, /** @type {string} */ kind) =>
1917
+ `${kind === 'change_files' ? 'diff' : kind}\0${source}`;
1582
1918
  const before = new Map(parentEntries.map((e) => [key(e.source, e.kind), e.sha256]));
1583
1919
  for (const e of manifest.entries) {
1584
1920
  const k = key(e.source, e.kind);
@@ -1589,6 +1925,7 @@ function markSinceParent(manifest, parentEntries) {
1589
1925
  // What consult_start says a follow-up copied again: the entries it shares
1590
1926
  // with the parent round, the ones left out and why, and how many files that
1591
1927
  // added to this round.
1928
+ /** @param {ExposeManifest} manifest */
1592
1929
  function inheritedSummary(manifest) {
1593
1930
  const carried = manifest.entries.filter((e) => e.inherited);
1594
1931
  return {
@@ -1604,6 +1941,10 @@ function inheritedSummary(manifest) {
1604
1941
  // is the raw output stream, unparsed: material the lead may read, never an
1605
1942
  // answer, which is why it sits beside the failure and not in `result`.
1606
1943
  const PARTIAL_TAIL_CHARS = 8_000;
1944
+ /**
1945
+ * @param {import('./run.mjs').ChildResult} run
1946
+ * @returns {import('./store.mjs').PartialOutput | null}
1947
+ */
1607
1948
  function partialOutput(run) {
1608
1949
  const out = String(run.stdout ?? '');
1609
1950
  if (!out.trim()) return null;
@@ -1621,10 +1962,16 @@ function partialOutput(run) {
1621
1962
  // Waits for `promise` (null: for nothing), at most `ms`, and no longer than
1622
1963
  // `signal` stays unaborted. The timer goes with the wait: a ten-minute wait
1623
1964
  // cut short leaves nothing behind.
1965
+ /**
1966
+ * @param {Promise<unknown> | null | undefined} promise
1967
+ * @param {number} ms
1968
+ * @param {AbortSignal} [signal]
1969
+ */
1624
1970
  async function settled(promise, ms, signal) {
1625
1971
  let timer;
1972
+ /** @type {(() => void) | undefined} */
1626
1973
  let stop;
1627
- const until = new Promise((resolve) => {
1974
+ const until = new Promise((/** @type {(value?: unknown) => void} */ resolve) => {
1628
1975
  stop = resolve;
1629
1976
  timer = setTimeout(resolve, ms);
1630
1977
  if (signal?.aborted) resolve();
@@ -1634,10 +1981,11 @@ async function settled(promise, ms, signal) {
1634
1981
  await (promise ? Promise.race([promise, until]) : until);
1635
1982
  } finally {
1636
1983
  clearTimeout(timer);
1637
- signal?.removeEventListener('abort', stop);
1984
+ if (stop) signal?.removeEventListener('abort', stop);
1638
1985
  }
1639
1986
  }
1640
1987
 
1988
+ /** @param {string | Record<string, unknown>} text */
1641
1989
  function parses(text) {
1642
1990
  try {
1643
1991
  normalizeResult(text);
@@ -1648,6 +1996,10 @@ function parses(text) {
1648
1996
  }
1649
1997
  }
1650
1998
 
1999
+ /**
2000
+ * @param {ConsultResult} result
2001
+ * @returns {string[]}
2002
+ */
1651
2003
  function recordableIds(result) {
1652
2004
  return [
1653
2005
  ...result.findings.map((f) => f.id),
@@ -1658,7 +2010,12 @@ function recordableIds(result) {
1658
2010
 
1659
2011
  // How many of each word, in the order they first appear. The only arithmetic
1660
2012
  // this server does on anyone's judgement.
2013
+ /**
2014
+ * @param {Iterable<string>} values
2015
+ * @returns {Record<string, number>}
2016
+ */
1661
2017
  function tally(values) {
2018
+ /** @type {Record<string, number>} */
1662
2019
  const out = {};
1663
2020
  for (const v of values) out[v] = (out[v] ?? 0) + 1;
1664
2021
  return out;
@@ -1667,17 +2024,27 @@ function tally(values) {
1667
2024
  // Counting is mechanical and stays that way: how many points the answer has,
1668
2025
  // how many carry a verdict, which ones do not. No judgement about whether the
1669
2026
  // consultation was worth it -- that is the lead's to draw from the entries.
2027
+ /**
2028
+ * @param {Round} job
2029
+ * @returns {JobRecord}
2030
+ */
1670
2031
  function recordSummary(job) {
1671
- const ids = recordableIds(job.result);
1672
- const written = new Set(job.record.entries.map((e) => e.id));
2032
+ // Callers pass a round that has a record, and a record is only written against an answer (#checkRecord).
2033
+ const record = /** @type {JobRecord} */ (job.record);
2034
+ const ids = recordableIds(/** @type {ConsultResult} */ (job.result));
2035
+ const written = new Set(record.entries.map((e) => e.id));
1673
2036
  return {
1674
- ...job.record,
1675
- verdicts: tally(job.record.entries.map((e) => e.verdict)),
2037
+ ...record,
2038
+ verdicts: tally(record.entries.map((e) => e.verdict)),
1676
2039
  coverage: { recordable: ids.length, recorded: written.size },
1677
2040
  unrecorded: ids.filter((id) => !written.has(id)),
1678
2041
  };
1679
2042
  }
1680
2043
 
2044
+ /**
2045
+ * @param {unknown} value
2046
+ * @returns {string | null}
2047
+ */
1681
2048
  function text(value) {
1682
2049
  if (typeof value !== 'string') return null;
1683
2050
  const t = redact(value).trim();
@@ -1690,23 +2057,29 @@ function text(value) {
1690
2057
  // a key left out is inherited -- one passed, even as an empty list, replaces
1691
2058
  // it -- and the validation that follows sees the result. Files are not:
1692
2059
  // copying them again stays expose_from_parent, named in the call (schema.mjs).
2060
+ /**
2061
+ * @param {RawRequest} raw
2062
+ * @param {Round} round
2063
+ * @returns {RawRequest}
2064
+ */
1693
2065
  function inherit(raw, round) {
1694
2066
  const { brief } = round;
1695
2067
  // A round kept off the web stays off: the material it carries over is the same.
2068
+ /** @type {RawRequest} */
1696
2069
  const out = raw.network === undefined && round.network ? { ...raw, network: round.network } : { ...raw };
1697
2070
  if (!brief) return out;
1698
- for (const k of ['objective', 'success_criteria', 'constraints']) {
1699
- if (
1700
- out[k] === undefined &&
1701
- raw.context?.[k] === undefined &&
1702
- brief[k] != null &&
1703
- !(Array.isArray(brief[k]) && !brief[k].length)
1704
- )
1705
- out[k] = brief[k];
2071
+ /** @type {Array<'objective' | 'success_criteria' | 'constraints'>} */
2072
+ const topKeys = ['objective', 'success_criteria', 'constraints'];
2073
+ for (const k of topKeys) {
2074
+ const v = brief[k];
2075
+ if (out[k] === undefined && raw.context?.[k] === undefined && v != null && !(Array.isArray(v) && !v.length)) out[k] = v;
1706
2076
  }
1707
2077
  const ctx = { ...(raw.context ?? {}) };
1708
- for (const k of ['facts', 'proposal', 'counterpoints']) {
1709
- if (ctx[k] === undefined && brief[k] != null && !(Array.isArray(brief[k]) && !brief[k].length)) ctx[k] = brief[k];
2078
+ /** @type {Array<'facts' | 'proposal' | 'counterpoints'>} */
2079
+ const contextKeys = ['facts', 'proposal', 'counterpoints'];
2080
+ for (const k of contextKeys) {
2081
+ const v = brief[k];
2082
+ if (ctx[k] === undefined && v != null && !(Array.isArray(v) && !v.length)) ctx[k] = v;
1710
2083
  }
1711
2084
  // The diff expose_diff added is not the lead's material: it is taken again
1712
2085
  // (or replaced) through expose_from_parent, against the files as they are now.
@@ -1723,6 +2096,7 @@ function inherit(raw, round) {
1723
2096
  return out;
1724
2097
  }
1725
2098
 
2099
+ /** @param {ReturnType<typeof normalizeResult>} normalized */
1726
2100
  function adviceCaveat(normalized) {
1727
2101
  const { result, quality } = normalized;
1728
2102
  const notes = [];
@@ -1745,6 +2119,44 @@ function adviceCaveat(normalized) {
1745
2119
  return notes.length ? notes.join('; ') : null;
1746
2120
  }
1747
2121
 
2122
+ // Files were handed over, but no call to a file-reading tool succeeded. On
2123
+ // 2026-10-20 an agy release renamed its read tool, every read was refused,
2124
+ // and the answer still cited line numbers. Only for a CLI that reports its
2125
+ // tool calls and names its reading tools; the others say nothing here.
2126
+ /**
2127
+ * @param {Record<string, number> | null | undefined} calls
2128
+ * @param {readonly string[] | undefined} readTools
2129
+ * @returns {string | null}
2130
+ */
2131
+ function unreadCaveat(calls, readTools) {
2132
+ if (!calls || !readTools) return null;
2133
+ if (readTools.some((t) => calls[t])) return null;
2134
+ const failed = readTools.reduce((n, t) => n + (calls[`${t}:error`] ?? 0), 0);
2135
+ const how = failed ? `${failed} failed or were refused` : 'none was made';
2136
+ return `files were exposed, but no call to read one succeeded (${how})`;
2137
+ }
2138
+
2139
+ // A refused call to a tool outside the allowlist that looks like a file
2140
+ // reader is most often the CLI's read tool renamed in a new release; naming
2141
+ // it lets one consultation show the cause. The tools severally refuses on
2142
+ // purpose (agy tries run_command even for a trivial question) are left out:
2143
+ // reporting them would be noise, and they are not renamed readers.
2144
+ // ponytail: a name heuristic; a reader named otherwise goes unreported until the unread note shows it.
2145
+ /**
2146
+ * @param {Record<string, number> | null | undefined} calls
2147
+ * @param {readonly string[] | undefined} allowed
2148
+ * @returns {string | null}
2149
+ */
2150
+ function refusedToolCaveat(calls, allowed) {
2151
+ if (!calls || !allowed) return null;
2152
+ const names = Object.keys(calls)
2153
+ .filter((k) => k.endsWith(':error'))
2154
+ .map((k) => k.slice(0, -':error'.length))
2155
+ .filter((t) => !allowed.includes(t) && /read|view|open/i.test(t));
2156
+ if (!names.length) return null;
2157
+ return `the consultant's CLI called ${names.join(', ')}, which severally does not allow, and was refused; a new CLI release may have renamed its file-reading tool`;
2158
+ }
2159
+
1748
2160
  // A lead may legitimately name its own CLI as the consultant: a fresh child
1749
2161
  // session with no shared context is still a useful clean-context re-read.
1750
2162
  // But it is not an independent opinion -- the answer comes from the same
@@ -1752,10 +2164,17 @@ function adviceCaveat(normalized) {
1752
2164
  // lineage is the consultant's model's, not its CLI's: OpenCode can run GLM
1753
2165
  // for a GLM lead and DeepSeek for anyone.
1754
2166
 
2167
+ /**
2168
+ * @param {string | null | undefined} caller
2169
+ * @param {string | null | undefined} callerModel
2170
+ * @param {Consultant} consultant
2171
+ * @returns {string | null}
2172
+ */
1755
2173
  function sameVendorCaveat(caller, callerModel, consultant) {
1756
2174
  // The lead's lineage: its declared model if that names one, else its CLI's
1757
2175
  // own. An unknown lineage on either side is no caveat -- not a guess.
1758
- const callerVendor = (callerModel ? vendorOf(callerModel) : null) ?? (caller ? (CLI_VENDORS[caller] ?? null) : null);
2176
+ const callerCli = CLI_IDS.find((c) => c === caller);
2177
+ const callerVendor = (callerModel ? vendorOf(callerModel) : null) ?? (callerCli ? (CLI_VENDORS[callerCli] ?? null) : null);
1759
2178
  const vendor = consultant.vendor;
1760
2179
  if (!callerVendor || !vendor || callerVendor !== vendor) return null;
1761
2180
  // Say what a fresh session of the same lineage removes and what it keeps.
@@ -1785,15 +2204,20 @@ function sameVendorCaveat(caller, callerModel, consultant) {
1785
2204
  // A mechanical side-by-side. The server deliberately does not decide whether
1786
2205
  // the consultants agree: inventing agreement that is not there is exactly the
1787
2206
  // failure a second opinion is supposed to prevent.
2207
+ /**
2208
+ * @param {JobView[]} members
2209
+ * @param {number} budget
2210
+ */
1788
2211
  function comparison(members, budget) {
1789
2212
  // Each column first pays for its fixed fields; the rest is its budget.
1790
2213
  const each = (budget - COMPARISON_NOTE_CHARS) / Math.max(1, members.length) - COLUMN_FIXED_CHARS;
1791
- let columns = members.map((m) => byTarget(m, each));
2214
+ const firstPass = members.map((m) => ({ m, column: byTarget(m, each) }));
2215
+ let columns = firstPass.map((x) => x.column);
1792
2216
  // What the short answers did not spend goes to the ones that were cut.
1793
2217
  const cutShort = columns.filter((c) => c.truncated).length;
1794
2218
  if (each !== Infinity && cutShort) {
1795
2219
  const spare = columns.reduce((n, c) => n + (c.truncated ? 0 : Math.max(0, each + COLUMN_FIXED_CHARS - printed(c))), 0);
1796
- columns = members.map((m, i) => (columns[i].truncated ? byTarget(m, each + spare / cutShort) : columns[i]));
2220
+ columns = firstPass.map(({ m, column }) => (column.truncated ? byTarget(m, each + spare / cutShort) : column));
1797
2221
  }
1798
2222
  return {
1799
2223
  by_target: columns,
@@ -1812,17 +2236,32 @@ function comparison(members, budget) {
1812
2236
  // shortened to fit the share before any is left out. A cut is flagged, never hidden: `truncated` says so,
1813
2237
  // `omitted` counts the items a list could not hold, and `full_with` names the
1814
2238
  // call that has everything.
2239
+ /**
2240
+ * @param {JobView} m
2241
+ * @param {number} budget
2242
+ * @returns {ComparisonColumn}
2243
+ */
1815
2244
  function byTarget(m, budget) {
1816
2245
  const full = budget === Infinity;
1817
2246
  let left = budget;
1818
2247
  let truncated = false;
2248
+ /** @type {Record<string, number>} */
1819
2249
  const omitted = {};
1820
- const clip = (s, max) => {
2250
+ const clip = (/** @type {string | null | undefined} */ s, /** @type {number} */ max) => {
1821
2251
  const out = full ? s : cut(s, max);
1822
2252
  if (out !== s) truncated = true;
1823
2253
  return out;
1824
2254
  };
1825
2255
  const item = Math.min(ITEM_CHARS, budget / 10);
2256
+ /**
2257
+ * @template T
2258
+ * @param {string} name
2259
+ * @param {T[] | undefined} items
2260
+ * @param {number} fields
2261
+ * @param {(x: T, n: number) => unknown} build
2262
+ * @param {number} part
2263
+ * @param {number} [floor]
2264
+ */
1826
2265
  const list = (name, items = [], fields, build, part, floor = MIN_TEXT_CHARS) => {
1827
2266
  if (full) return items.map((x) => build(x, Infinity));
1828
2267
  let share = left * part;
@@ -1851,6 +2290,7 @@ function byTarget(m, budget) {
1851
2290
  const summary = clip(m.result?.summary ?? null, Math.min(SUMMARY_CHARS, budget / 3));
1852
2291
  const caveat = clip(m.quality?.caveat ?? null, Math.min(CAVEAT_CHARS, budget / 3));
1853
2292
  if (!full) left -= printed(summary) + printed(caveat);
2293
+ /** @type {Partial<ConsultResult>} */
1854
2294
  const r = m.result ?? {};
1855
2295
  // The findings first, with up to 60% of what is left and their points cut
1856
2296
  // as short as it takes: they are what gets recorded against, so their ids
@@ -1880,6 +2320,7 @@ function byTarget(m, budget) {
1880
2320
  (d, n) => Object.fromEntries(Object.entries(d).map(([k, v]) => [k, clip(v, n)])),
1881
2321
  1,
1882
2322
  );
2323
+ /** @type {ComparisonColumn} */
1883
2324
  const entry = {
1884
2325
  job_id: m.job_id,
1885
2326
  target: m.target,
@@ -1949,6 +2390,10 @@ const MIN_TEXT_CHARS = 40;
1949
2390
  * points being checked, without the rest of the answer. A projection, not a
1950
2391
  * summary. An id the answer does not have is refused with the ones it has.
1951
2392
  */
2393
+ /**
2394
+ * @param {JobView} view
2395
+ * @param {string[]} ids
2396
+ */
1952
2397
  export function pointsOf(view, ids) {
1953
2398
  if (!view.result) {
1954
2399
  throw new RequestError(`consultation "${view.job_id}" is ${view.status} and has no answer to take points from`, 'no_result');
@@ -1965,10 +2410,13 @@ export function pointsOf(view, ids) {
1965
2410
  const verdicts = new Map((view.record?.entries ?? []).map((e) => [e.id, e]));
1966
2411
  const lists = { finding: r.findings, unknown: r.unknowns, next_check: r.next_checks };
1967
2412
  const points = [...new Set(ids)].map((id) => {
1968
- const [kind, item] = Object.entries(lists)
1969
- .map(([k, l]) => [k, l.find((x) => x.id === id)])
1970
- .find(([, x]) => x);
1971
- return { kind, ...item, recorded: verdicts.get(id) ?? null };
2413
+ // Every id was checked against recordableIds above, so one list has it.
2414
+ const [point] = Object.entries(lists).flatMap(([kind, l]) => {
2415
+ const item = l.find((x) => x.id === id);
2416
+ return item ? [{ kind, ...item }] : [];
2417
+ });
2418
+ if (!point) throw new RequestError(`"${id}" is not a point in this answer`, 'unknown_entry_id');
2419
+ return { ...point, recorded: verdicts.get(id) ?? null };
1972
2420
  });
1973
2421
  return {
1974
2422
  job_id: view.job_id,
@@ -1997,6 +2445,7 @@ export function pointsOf(view, ids) {
1997
2445
 
1998
2446
  // What a value takes as the server prints it, nested four levels into the
1999
2447
  // response: two-space JSON, every line indented eight more.
2448
+ /** @param {unknown} v */
2000
2449
  function printed(v) {
2001
2450
  const j = JSON.stringify(v, null, 2) ?? '';
2002
2451
  return j.length + 8 * j.split('\n').length;
@@ -2004,10 +2453,15 @@ function printed(v) {
2004
2453
 
2005
2454
  // A text cut to `max` characters as printed in JSON, so a run of characters
2006
2455
  // that JSON escapes (a control character takes six) cannot outgrow the cut.
2456
+ /**
2457
+ * @param {string | null | undefined} s
2458
+ * @param {number} max
2459
+ * @returns {string | null | undefined}
2460
+ */
2007
2461
  function cut(s, max) {
2008
2462
  if (typeof s !== 'string') return s;
2009
2463
  const room = Math.max(0, Math.floor(max));
2010
- const width = (t) => JSON.stringify(t).length - 2;
2464
+ const width = (/** @type {string} */ t) => JSON.stringify(t).length - 2;
2011
2465
  if (width(s) <= room) return s;
2012
2466
  // One character of the room goes to the ellipsis, and a surrogate pair is
2013
2467
  // never split.
@@ -2021,6 +2475,11 @@ function cut(s, max) {
2021
2475
  // is named rather than repeated: its size and sha256 (of the JSON stored in
2022
2476
  // the round file, so both can be checked against it), where it is kept, and
2023
2477
  // what was copied -- skipped files counted by reason, not listed.
2478
+ /**
2479
+ * @param {FullView} view
2480
+ * @param {string} [detail]
2481
+ * @returns {JobView}
2482
+ */
2024
2483
  function compactBrief(view, detail) {
2025
2484
  if (detail === 'full' || !view.brief) return view;
2026
2485
  const json = JSON.stringify(view.brief);
@@ -2032,12 +2491,18 @@ function compactBrief(view, detail) {
2032
2491
  sha256: crypto.createHash('sha256').update(json).digest('hex'),
2033
2492
  // Under the severally home (~/.severally unless the operator moved it).
2034
2493
  history_file: path.relative(POLICY.home, store.roundPath(view.chain_id, view.round)),
2035
- exposed: ex ? { ...ex.totals, skipped: tally((ex.skipped ?? []).map((x) => x.reason)) } : null,
2494
+ exposed: ex
2495
+ ? { ...ex.totals, skipped: tally((ex.skipped ?? []).map((/** @type {{reason: string}} */ x) => x.reason)) }
2496
+ : null,
2036
2497
  full_with: `consult_get({ job_id: "${view.job_id}", detail: "full" }) or consult_export`,
2037
2498
  },
2038
2499
  };
2039
2500
  }
2040
2501
 
2502
+ /**
2503
+ * @param {Round} job
2504
+ * @returns {string}
2505
+ */
2041
2506
  function nextStep(job) {
2042
2507
  if (job.status === 'running' || job.status === 'queued') return 'poll consult_get again, or consult_cancel to stop it';
2043
2508
  if (job.status === 'completed') {
@@ -2072,9 +2537,9 @@ function nextStep(job) {
2072
2537
  // Every spelling a token could name a flag by: `--name[=v]` as written, and for
2073
2538
  // a single dash both a short flag with its value attached (`-sdanger-full-access`
2074
2539
  // in clap) and a Go-style long flag (`-dangerously-skip-permissions`).
2075
- const flagNames = (a) => {
2540
+ const flagNames = (/** @type {string} */ a) => {
2076
2541
  if (!a.startsWith('-') || a === '-') return [];
2077
- const name = a.split('=')[0];
2542
+ const [name = ''] = a.split('=');
2078
2543
  return a.startsWith('--') ? [name] : [name, a.slice(0, 2), `-${name}`];
2079
2544
  };
2080
2545
 
@@ -2085,6 +2550,12 @@ const flagNames = (a) => {
2085
2550
  * adapter's FORBIDDEN_FLAGS and RESERVED_FLAGS (aliases and other flags that
2086
2551
  * undo it). Values (tokens without a leading dash) are not inspected.
2087
2552
  */
2553
+ /**
2554
+ * @param {CliId} target
2555
+ * @param {readonly string[]} adapterArgs
2556
+ * @param {unknown} extra
2557
+ * @returns {string | null}
2558
+ */
2088
2559
  export function extraArgsProblem(target, adapterArgs, extra) {
2089
2560
  if (!Array.isArray(extra) || !extra.every((a) => typeof a === 'string')) return 'must be an array of strings';
2090
2561
  const adapter = ADAPTERS[target];
@@ -2098,11 +2569,20 @@ export function extraArgsProblem(target, adapterArgs, extra) {
2098
2569
  }
2099
2570
 
2100
2571
  /** Extra args go before a trailing `-` (Codex's read-the-prompt-from-stdin marker), else at the end. */
2572
+ /**
2573
+ * @param {string[]} args
2574
+ * @param {readonly string[]} extra
2575
+ * @returns {string[]}
2576
+ */
2101
2577
  export function withExtraArgs(args, extra) {
2102
2578
  if (extra.length === 0) return args;
2103
2579
  return args.at(-1) === '-' ? [...args.slice(0, -1), ...extra, '-'] : [...args, ...extra];
2104
2580
  }
2105
2581
 
2582
+ /**
2583
+ * @param {CliId} target
2584
+ * @param {readonly string[]} args
2585
+ */
2106
2586
  export function assertNoForbiddenFlags(target, args) {
2107
2587
  const forbidden = ADAPTERS[target].FORBIDDEN_FLAGS;
2108
2588
  const hit = args.find((a) => forbidden.includes(a));