severally-mcp 2.1.0 → 2.3.1

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 (33) hide show
  1. package/CODE_OF_CONDUCT.md +83 -0
  2. package/CONFIG.md +10 -6
  3. package/README.ja.md +132 -65
  4. package/README.md +136 -69
  5. package/bin/severally-mcp.mjs +3 -0
  6. package/config.example.json +26 -0
  7. package/docs/how-it-works.ja.md +4 -2
  8. package/docs/how-it-works.md +5 -3
  9. package/docs/install.ja.md +12 -5
  10. package/docs/install.md +14 -5
  11. package/package.json +4 -2
  12. package/plugins/severally/.claude-plugin/plugin.json +1 -1
  13. package/plugins/severally/.codex-plugin/plugin.json +1 -1
  14. package/plugins/severally/dist/severally-mcp.mjs +29493 -28302
  15. package/plugins/severally/skills/_template/SKILL.md.tmpl +16 -22
  16. package/plugins/severally/skills/_template/reference.md.tmpl +11 -5
  17. package/plugins/severally/skills/antigravity/severally/SKILL.md +16 -22
  18. package/plugins/severally/skills/antigravity/severally/reference.md +11 -5
  19. package/plugins/severally/skills/claude/severally/SKILL.md +16 -22
  20. package/plugins/severally/skills/claude/severally/reference.md +11 -5
  21. package/plugins/severally/skills/codex/severally/SKILL.md +16 -22
  22. package/plugins/severally/skills/codex/severally/reference.md +11 -5
  23. package/plugins/severally/skills/opencode/severally/SKILL.md +16 -22
  24. package/plugins/severally/skills/opencode/severally/reference.md +11 -5
  25. package/scripts/install.mjs +90 -5
  26. package/src/consultants.mjs +31 -10
  27. package/src/jobs.mjs +123 -11
  28. package/src/policy.mjs +44 -7
  29. package/src/run.mjs +38 -5
  30. package/src/schema.mjs +30 -8
  31. package/src/server.mjs +70 -122
  32. package/src/status.mjs +63 -0
  33. package/src/store.mjs +44 -1
@@ -249,8 +249,51 @@ const codexHome = process.env.CODEX_HOME ?? path.join(home, '.codex');
249
249
  // .mcp.json carries the same value for Claude Code, and its Codex manifest
250
250
  // with tool_timeout_sec 660 (codex 0.163 stops a tool call at 300 s by
251
251
  // default, progress notwithstanding). `codex mcp add` cannot set that
252
- // timeout, so a --manual Codex keeps 45 s.
252
+ // timeout, so a --manual Codex gets it as one line written into config.toml
253
+ // under [mcp_servers.severally] (see the Codex row), together with the env;
254
+ // both or neither, since the wait without the timeout would outlast Codex's
255
+ // own deadline.
253
256
  const LONG_WAIT = 'SEVERALLY_MAX_WAIT_MS=600000';
257
+ const AGY_WAIT = 'SEVERALLY_MAX_WAIT_MS=165000';
258
+ const agyConfig = path.join(home, '.gemini', 'config', 'mcp_config.json');
259
+ const CODEX_TIMEOUT_SEC = 660;
260
+ const codexConfig = path.join(codexHome, 'config.toml');
261
+ // The user's own tool_timeout_sec, read before --force removes the table that holds it.
262
+ let priorCodexTimeout;
263
+
264
+ // Where [mcp_servers.severally] sits in config.toml, and what it holds.
265
+ // A minimal text read, not a TOML parser: the table runs to the next header
266
+ // that is not one of its own sub-tables (codex may put env in one).
267
+ function codexTable() {
268
+ let lines;
269
+ try { lines = fs.readFileSync(codexConfig, 'utf8').split('\n'); } catch (err) { return { reason: `cannot read ${codexConfig}: ${err.message}` }; }
270
+ const start = lines.findIndex((l) => /^\s*\[\s*mcp_servers\.severally\s*\]/.test(l));
271
+ if (start < 0) return { reason: `${codexConfig} has no [mcp_servers.severally] table` };
272
+ let end = lines.findIndex((l, i) => i > start && /^\s*\[/.test(l) && !/^\s*\[\s*mcp_servers\.severally\./.test(l));
273
+ if (end < 0) end = lines.length;
274
+ const block = lines.slice(start + 1, end).join('\n');
275
+ const timeout = block.match(/^\s*tool_timeout_sec\s*=\s*(\d+(?:\.\d+)?)/m)?.[1];
276
+ return { lines, start, timeout: timeout === undefined ? undefined : Number(timeout), wait: /SEVERALLY_MAX_WAIT_MS\s*=\s*"?\d+/.test(block) };
277
+ }
278
+
279
+ // The wait follows the timeout Codex will enforce: 15 s under the user's own
280
+ // tool_timeout_sec when that is short, else the full 600000 ms.
281
+ function codexPlan() {
282
+ const sec = priorCodexTimeout ?? CODEX_TIMEOUT_SEC;
283
+ return { sec, ms: sec < 615 ? Math.max(0, Math.floor((sec - 15) * 1000)) : 600000 };
284
+ }
285
+
286
+ // Inserts tool_timeout_sec right after the table header. Returns why it could
287
+ // not, or null.
288
+ function writeCodexTimeout(sec) {
289
+ const t = codexTable();
290
+ if (t.reason) return t.reason;
291
+ if (t.timeout !== undefined) return null; // never overwrite the user's value
292
+ t.lines.splice(t.start + 1, 0, `tool_timeout_sec = ${sec}`);
293
+ try { fs.writeFileSync(codexConfig, t.lines.join('\n')); } catch (err) { return `cannot write ${codexConfig}: ${err.message}`; }
294
+ return null;
295
+ }
296
+
254
297
  const claudeSkillsDir = path.join(home, '.claude', 'skills');
255
298
 
256
299
  // ---------------------------------------------------------------- clients
@@ -279,8 +322,35 @@ const CLIENTS = [
279
322
  label: 'Codex',
280
323
  backup: [path.join(codexHome, 'config.toml')],
281
324
  isRegistered: () => tryRun('codex', ['mcp', 'get', 'severally'], { real: true }).ok,
282
- remove: () => tryRun('codex', ['mcp', 'remove', 'severally']),
283
- add: (bin) => tryRun('codex', ['mcp', 'add', 'severally', '--', ...bin]),
325
+ remove: () => {
326
+ priorCodexTimeout = codexTable().timeout;
327
+ return tryRun('codex', ['mcp', 'remove', 'severally']);
328
+ },
329
+ // The long wait is the env plus tool_timeout_sec in config.toml, both or neither.
330
+ add: (bin) => {
331
+ const { sec, ms } = codexPlan();
332
+ const r = tryRun('codex', ['mcp', 'add', 'severally', '--env', `SEVERALLY_MAX_WAIT_MS=${ms}`, '--', ...bin]);
333
+ if (!r.ok) return r;
334
+ if (dryRun) {
335
+ log(` [dry-run] write tool_timeout_sec = ${sec} into ${codexConfig} under [mcp_servers.severally]`);
336
+ return r;
337
+ }
338
+ const why = writeCodexTimeout(sec);
339
+ if (!why) {
340
+ log(` consult_get waits up to ${ms / 1000} s (SEVERALLY_MAX_WAIT_MS=${ms}, tool_timeout_sec = ${sec})`);
341
+ return r;
342
+ }
343
+ const back = tryRun('codex', ['mcp', 'remove', 'severally']);
344
+ if (!back.ok) return back;
345
+ log(` the long consult_get wait was not set (it stays 45 s): ${why}`);
346
+ return tryRun('codex', ['mcp', 'add', 'severally', '--', ...bin]);
347
+ },
348
+ keptNote: () => {
349
+ const t = codexTable();
350
+ if (t.timeout === undefined || !t.wait) {
351
+ log(` this registration lacks the long consult_get wait (45 s); --force adds it (SEVERALLY_MAX_WAIT_MS and tool_timeout_sec = ${CODEX_TIMEOUT_SEC})`);
352
+ }
353
+ },
284
354
  skillFrom: path.join(PLUGIN, 'skills', 'codex', 'severally'),
285
355
  skillTo: path.join(codexHome, 'skills', 'severally'),
286
356
  },
@@ -297,7 +367,14 @@ const CLIENTS = [
297
367
  return r.ok && /severally/.test(r.out);
298
368
  },
299
369
  remove: () => tryRun('agy', ['mcp', 'remove', 'severally']),
300
- add: (bin) => tryRun('agy', ['mcp', 'add', 'severally', ...bin]),
370
+ // agy stops an MCP tool call at 3 minutes, progress notifications or not
371
+ // (measured: a 310 s call ended at 180 s), so its wait is 15 s under that.
372
+ add: (bin) => tryRun('agy', ['mcp', 'add', '--env', AGY_WAIT, 'severally', ...bin]),
373
+ keptNote: () => {
374
+ if (!/SEVERALLY_MAX_WAIT_MS/.test(fs.existsSync(agyConfig) ? fs.readFileSync(agyConfig, 'utf8') : '')) {
375
+ log(' this registration lacks the longer consult_get wait (45 s); --force adds it (SEVERALLY_MAX_WAIT_MS=165000)');
376
+ }
377
+ },
301
378
  skillFrom: path.join(PLUGIN, 'skills', 'antigravity', 'severally'),
302
379
  skillTo: path.join(home, '.gemini', 'config', 'skills', 'severally'),
303
380
  },
@@ -326,7 +403,14 @@ const CLIENTS = [
326
403
  // reachable only inside whatever directory the installer happened to
327
404
  // run from.
328
405
  remove: () => ({ ok: true, out: 'opencode has no mcp remove; re-adding replaces the entry' }),
329
- add: (bin) => tryRun('opencode', ['mcp', 'add', 'severally', '--global', '--', ...bin]),
406
+ // OpenCode sets no deadline of its own on a tool call (a 610 s call came
407
+ // back), so it gets the long consult_get wait with no timeout to pair.
408
+ add: (bin) => tryRun('opencode', ['mcp', 'add', 'severally', '--global', '--env', LONG_WAIT, '--', ...bin]),
409
+ keptNote: () => {
410
+ const text = [path.join(xdgConfig, 'opencode', 'opencode.json'), path.join(xdgConfig, 'opencode', 'opencode.jsonc')]
411
+ .map((f) => { try { return fs.readFileSync(f, 'utf8'); } catch { return ''; } }).join('\n');
412
+ if (!text.includes('SEVERALLY_MAX_WAIT_MS')) log(' this registration lacks the long consult_get wait (45 s); --force adds it (SEVERALLY_MAX_WAIT_MS)');
413
+ },
330
414
  // A zero exit from `mcp add` can be a usage dump in disguise (see above),
331
415
  // so this client's registration is verified by listing after adding --
332
416
  // and the recorded command is compared against this install's.
@@ -381,6 +465,7 @@ function installClientDirectly(client, bin) { step(`Registering with ${client.l
381
465
  if (client.isRegistered() && !force) {
382
466
  keptRegistrations = true;
383
467
  log(' already registered; leaving it as it is (use --force to re-register)');
468
+ client.keptNote?.();
384
469
  } else {
385
470
  if (client.isRegistered()) client.remove();
386
471
  const r = client.add(bin);
@@ -212,25 +212,41 @@ const clamp = (v, min, max) => Math.min(max, Math.max(min, v));
212
212
  * CLI's availability, decided by policy.mjs.
213
213
  */
214
214
  export function buildConsultants(config) {
215
+ // `problems` is every mistake, for the operator to read; `global` the ones
216
+ // that leave the whole file untrustworthy. A mistake inside one consultant
217
+ // or one CLI is kept to it: that consultant cannot be used (`broken`), that
218
+ // CLI is off (`brokenClis`) -- the closed side either way, so nothing the
219
+ // operator turned off comes back on.
215
220
  const problems = [];
221
+ const global = [];
222
+ const broken = {};
223
+ const brokenClis = {};
224
+ const fault = (msg) => { problems.push(msg); global.push(msg); };
216
225
  const cfg = isObject(config) ? config : {};
217
- if (!isObject(config)) problems.push('the config file must hold a JSON object');
226
+ if (!isObject(config)) fault('the config file must hold a JSON object');
218
227
  for (const key of Object.keys(cfg)) {
219
- if (!TOP_KEYS.includes(key)) problems.push(`unknown top-level key "${key}" (allowed: ${TOP_KEYS.join(', ')})`);
228
+ if (!TOP_KEYS.includes(key)) fault(`unknown top-level key "${key}" (allowed: ${TOP_KEYS.join(', ')})`);
229
+ }
230
+
231
+ // The one server key that is not a number to clamp: a wrong value cannot
232
+ // be read as either setting, and reading it as "on" would open the web.
233
+ if (isObject(cfg.server) && cfg.server.network !== undefined && !['on', 'off'].includes(cfg.server.network)) {
234
+ fault('server.network must be "on" or "off"');
220
235
  }
221
236
 
222
237
  let ranking = [];
223
238
  if (cfg.ranking !== undefined) {
224
239
  if (Array.isArray(cfg.ranking) && cfg.ranking.every((r) => typeof r === 'string' && r.trim() !== '')) ranking = cfg.ranking.map((r) => r.trim());
225
- else problems.push('ranking must be an array of non-empty strings');
240
+ else fault('ranking must be an array of non-empty strings');
226
241
  }
227
242
 
228
243
  if (cfg.clis !== undefined) {
229
- if (!isObject(cfg.clis)) problems.push('clis must be an object keyed by CLI');
244
+ if (!isObject(cfg.clis)) fault('clis must be an object keyed by CLI');
230
245
  else {
231
246
  for (const [id, def] of Object.entries(cfg.clis)) {
232
247
  if (!CLI_IDS.includes(id)) { problems.push(`clis.${id}: unknown CLI (known: ${CLI_IDS.join(', ')})`); continue; }
233
- if (!isObject(def)) { problems.push(`clis.${id} must be an object`); continue; }
248
+ const before = problems.length;
249
+ if (!isObject(def)) { problems.push(`clis.${id} must be an object`); brokenClis[id] = problems.slice(before); continue; }
234
250
  for (const k of Object.keys(def)) if (!CLI_KEYS.includes(k)) problems.push(`clis.${id}.${k}: unknown key (allowed: ${CLI_KEYS.join(', ')})`);
235
251
  // A wrong type must not fall back to a default: enabled: "false"
236
252
  // read as "unset" would turn on the CLI the operator meant to stop.
@@ -242,6 +258,7 @@ export function buildConsultants(config) {
242
258
  if (id !== 'opencode') problems.push(`clis.${id}.provider: only OpenCode takes provider definitions`);
243
259
  else if (!(isObject(def.provider) && Object.values(def.provider).every(isObject))) problems.push(`clis.${id}.provider: must be an object of provider objects, as in opencode.json`);
244
260
  }
261
+ if (problems.length > before) brokenClis[id] = problems.slice(before);
245
262
  }
246
263
  }
247
264
  }
@@ -250,15 +267,15 @@ export function buildConsultants(config) {
250
267
  if (cfg.consultants === undefined) {
251
268
  for (const cli of CLI_IDS) consultants.push(make(cli, { cli, model: DEFAULT_MODELS[cli] }));
252
269
  } else if (!isObject(cfg.consultants)) {
253
- problems.push('consultants must be an object keyed by name');
270
+ fault('consultants must be an object keyed by name');
254
271
  } else {
255
272
  const seen = new Map();
256
273
  for (const [name, def] of Object.entries(cfg.consultants)) {
257
274
  const at = `consultants.${name}`;
258
275
  if (!/^[a-z0-9][a-z0-9.-]{0,63}$/.test(name)) { problems.push(`${at}: a name is lower-case letters, digits, "." and "-", at most 64 characters`); continue; }
259
276
  if (RESERVED_NAMES.includes(name)) { problems.push(`${at}: "${name}" is reserved`); continue; }
260
- if (!isObject(def)) { problems.push(`${at} must be an object`); continue; }
261
277
  const before = problems.length;
278
+ if (!isObject(def)) { problems.push(`${at} must be an object`); broken[name] = problems.slice(before); continue; }
262
279
  for (const k of Object.keys(def)) if (!CONSULTANT_KEYS.includes(k)) problems.push(`${at}.${k}: unknown key (allowed: ${CONSULTANT_KEYS.join(', ')})`);
263
280
  if (!CLI_IDS.includes(def.cli)) problems.push(`${at}.cli: must be one of ${CLI_IDS.join(', ')}`);
264
281
  if (typeof def.model !== 'string' || def.model.trim() === '') problems.push(`${at}.model: required`);
@@ -272,14 +289,18 @@ export function buildConsultants(config) {
272
289
  if (def.max_budget_usd !== undefined && def.cli !== 'claude-code') problems.push(`${at}.max_budget_usd: only a claude-code consultant has a budget`);
273
290
  // agy takes the effort from the model name and rejects --effort.
274
291
  if (def.effort !== undefined && def.cli === 'antigravity') problems.push(`${at}.effort: Antigravity takes the effort from the model name (e.g. gemini-3.8-flash-low)`);
275
- if (problems.length > before) continue;
292
+ if (problems.length > before) { broken[name] = problems.slice(before); continue; }
276
293
  const pair = `${def.cli}\n${def.model.trim()}`;
277
- if (seen.has(pair)) { problems.push(`${at}: same cli and model as consultants.${seen.get(pair)}; asking one model twice adds no independence`); continue; }
294
+ if (seen.has(pair)) {
295
+ problems.push(`${at}: same cli and model as consultants.${seen.get(pair)}; asking one model twice adds no independence`);
296
+ broken[name] = problems.slice(before);
297
+ continue;
298
+ }
278
299
  seen.set(pair, name);
279
300
  consultants.push(make(name, def));
280
301
  }
281
302
  }
282
- return { consultants: Object.freeze(consultants), ranking: Object.freeze(ranking), problems };
303
+ return { consultants: Object.freeze(consultants), ranking: Object.freeze(ranking), problems, global, broken, brokenClis };
283
304
  }
284
305
 
285
306
  function make(name, def) {
package/src/jobs.mjs CHANGED
@@ -27,6 +27,9 @@ const ADAPTERS = { codex, 'claude-code': claudeCode, antigravity, opencode };
27
27
  // anyway would silently extend the job's advertised wall-clock deadline.
28
28
  const MIN_RETRY_MS = 5_000;
29
29
 
30
+ // How often a server looks for a cancel another server asked for.
31
+ const CANCEL_POLL_MS = 1_000;
32
+
30
33
  // The second way a consultant can be authenticated. A linked token file and an
31
34
  // API key / ADC file are alternatives, not both required, so the presence of
32
35
  // either has to satisfy the credential check below. These are the names
@@ -226,14 +229,39 @@ export class JobManager {
226
229
  return compactBrief(rec, detail);
227
230
  }
228
231
 
232
+ // This session's consultations and the history's, so an earlier session's
233
+ // job_id and group_id can still be followed up and recorded against. The
234
+ // index only picks and orders; each row shown is read from its round, which
235
+ // holds the verdicts written since. Memory wins over disk for the same job.
229
236
  list({ limit = 20 } = {}) {
230
- return this.order
231
- .slice(-limit)
232
- .reverse()
233
- .map((jid) => {
234
- const j = this.jobs.get(jid);
237
+ const here = this.order.map((jid) => this.jobs.get(jid));
238
+ const seen = new Set(here.map((j) => j.job_id));
239
+ const elsewhere = [];
240
+ // Read only: a marker whose owner is gone is shown as interrupted, and
241
+ // left for consult_get or the startup sweep to close out.
242
+ for (const jobId of store.listRunning()) {
243
+ const live = seen.has(jobId) ? null : store.loadRunning(jobId);
244
+ if (!live) continue;
245
+ seen.add(jobId);
246
+ const state = ownerState(jobId, live.owner);
247
+ // A round saved just before its owner died is the answer, whatever the marker says.
248
+ const saved = state === 'alive' ? null : store.loadRoundAt(live);
249
+ elsewhere.push({ rec: state === 'alive' ? live : saved ?? { ...live, status: 'failed', failure: { kind: 'interrupted' } } });
250
+ }
251
+ for (const row of store.indexRows()) if (!seen.has(row.job_id)) { seen.add(row.job_id); elsewhere.push({ row }); }
252
+ const at = (x) => x.created_at ?? x.rec?.created_at ?? x.row?.created_at ?? '';
253
+ const picked = [];
254
+ // Round files are read only for the rows shown; one that cannot be read is passed over.
255
+ for (const x of [...here, ...elsewhere].sort((p, q) => (at(q) < at(p) ? -1 : at(q) > at(p) ? 1 : 0))) {
256
+ if (picked.length >= limit) break;
257
+ const j = x.row ? store.loadRoundAt(x.row) : x.rec ?? x;
258
+ if (j) picked.push(j);
259
+ }
260
+ return picked
261
+ .map((j) => {
235
262
  return {
236
263
  job_id: j.job_id,
264
+ group_id: j.group_id ?? null,
237
265
  chain_id: j.chain_id,
238
266
  round: j.round,
239
267
  target: j.target,
@@ -246,7 +274,8 @@ export class JobManager {
246
274
  duration_ms: j.duration_ms,
247
275
  summary: j.result ? j.result.summary.slice(0, 200) : null,
248
276
  recorded: Boolean(j.record),
249
- verdicts: j.record ? tally(j.record.entries.map((e) => e.verdict)) : null,
277
+ verdicts: j.record?.entries ? tally(j.record.entries.map((e) => e.verdict)) : null,
278
+ this_session: this.jobs.has(j.job_id),
250
279
  predicted: Boolean(j.prediction),
251
280
  reflected: Boolean(j.reflection),
252
281
  initiator: j.initiator ?? null,
@@ -334,11 +363,19 @@ export class JobManager {
334
363
  parent = { ...parent, nextRound: Math.max(...rounds.map((j) => j.round)) + 1 };
335
364
  }
336
365
 
366
+ if (parent && (rawRequest.target ?? null) === null && !rawRequest.targets?.length) {
367
+ rawRequest = { ...rawRequest, target: parent.target };
368
+ }
369
+ // A fan-out's members were all handed the same brief, so any one of them holds it.
370
+ if (parent || fromGroup) rawRequest = inherit(rawRequest, fromGroup ? fromGroup.links[0].groupMember ?? fromGroup.links[0].parent : parent);
371
+
337
372
  const req = parseRequest(rawRequest, {
338
373
  isFollowup: Boolean(parent || fromGroup),
339
374
  parentMode: parent?.mode ?? fromGroup?.group.mode ?? null,
340
375
  groupFollowup: Boolean(fromGroup),
341
376
  });
377
+ // The operator's setting only ever closes: a request cannot reopen the web.
378
+ if (POLICY.network === 'off') req.network = 'off';
342
379
 
343
380
  if (fromGroup && fromGroup.links.some((l, i) => l.parent.target !== req.targets[i])) {
344
381
  throw new RequestError(
@@ -363,8 +400,13 @@ export class JobManager {
363
400
  // Admit or refuse the whole fan-out: a half-started group would leave the
364
401
  // lead comparing answers to a question only some consultants were asked.
365
402
  // The cap counts every severally process on this home, through the seats.
366
- const seats = req.targets.map((target) => ({
403
+ // A follow-up's seat also names the chain and round it opens, so that two
404
+ // servers continuing one consultation at once cannot both take a round
405
+ // number: neither sees the other's round in its history until it ends.
406
+ const chainOf = (i) => (fromGroup ? fromGroup.links[i] : parent ? { parent, round } : null);
407
+ const seats = req.targets.map((target, i) => ({
367
408
  job_id: id('job'),
409
+ ...(chainOf(i) ? { chain_id: chainOf(i).parent.chain_id, round: chainOf(i).round } : {}),
368
410
  // Twice the budget plus an hour: the deadline is wall-clock, the budget is
369
411
  // not, so a suspended laptop must not make a live owner look overdue.
370
412
  owner: {
@@ -373,6 +415,14 @@ export class JobManager {
373
415
  },
374
416
  }));
375
417
  const taken = store.takeSeats(seats, POLICY.maxConcurrent, (seat) => ownerState(seat.job_id, seat.owner) === 'alive');
418
+ if (taken.chainBusy) {
419
+ const { live, mine } = taken.chainBusy;
420
+ throw new RequestError(
421
+ `another severally process has already continued this consultation: job "${live.job_id}" is round ${live.round ?? mine.round}`
422
+ + '; wait for it (consult_get) and follow up the latest round',
423
+ 'followup_not_latest',
424
+ );
425
+ }
376
426
  if (!taken.ok) {
377
427
  throw new RequestError(
378
428
  `this would run ${taken.live + seats.length} consultations at once; the cap is ${POLICY.maxConcurrent}`
@@ -550,6 +600,7 @@ export class JobManager {
550
600
  this.jobs.set(jobId, job);
551
601
  this.order.push(jobId);
552
602
  LIVE_HERE.add(jobId);
603
+ this.#watchCancels();
553
604
  job._owner = seats[i].owner;
554
605
  this.#mark(job);
555
606
  return job;
@@ -891,8 +942,12 @@ export class JobManager {
891
942
  break;
892
943
  }
893
944
 
945
+ // Without an answer, a process left holding the pipes is the failure to report.
946
+ const leftover = run.pipesForced
947
+ && `${invocation.command} exited, but a process it started kept its output open and was stopped`;
894
948
  if (!interpreted.ok) {
895
949
  job.usage = adapter.usageRecord(interpreted.usageRaw);
950
+ if (leftover) return this.#fail(job, 'cli_error', leftover, interpreted.message);
896
951
  return this.#fail(job, interpreted.failureKind, interpreted.message);
897
952
  }
898
953
 
@@ -916,6 +971,7 @@ export class JobManager {
916
971
  }
917
972
  if (!normalized) {
918
973
  job.usage = adapter.usageRecord(interpreted.usageRaw);
974
+ if (leftover) return this.#fail(job, 'cli_error', leftover, err.message);
919
975
  return this.#fail(job, 'invalid_output', err.message, redact(String(err.detail ?? '')).slice(0, 1200));
920
976
  }
921
977
  } else {
@@ -928,6 +984,7 @@ export class JobManager {
928
984
  adviceCaveat(normalized),
929
985
  sameVendorCaveat(job.caller, job.caller_model, consultant),
930
986
  interpreted.recoveredError && `the consultant's CLI reported an error mid-run and then finished its answer: ${redact(interpreted.recoveredError.message).slice(0, 300)}`,
987
+ leftover && '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)',
931
988
  ].filter(Boolean);
932
989
  // No `advice_usable` verdict here. It was always true, so it carried no
933
990
  // information -- and worse, it read as the server vouching for advice it
@@ -1183,16 +1240,31 @@ export class JobManager {
1183
1240
  return view;
1184
1241
  }
1185
1242
 
1243
+ // Cancels another server asked for (store.requestCancel), checked while
1244
+ // this one runs anything. ponytail: polled once a second, not signalled;
1245
+ // a watch on the slots directory if that delay ever matters.
1246
+ #watchCancels() {
1247
+ this._cancelWatch ??= setInterval(() => {
1248
+ for (const job of this.jobs.values()) {
1249
+ if ((job.status === 'running' || job.status === 'queued') && store.cancelRequested(job.job_id)) {
1250
+ store.clearCancel(job.job_id);
1251
+ this.cancel(job.job_id);
1252
+ }
1253
+ }
1254
+ }, CANCEL_POLL_MS).unref();
1255
+ }
1256
+
1186
1257
  cancel(jobId) {
1187
1258
  const job = this.jobs.get(jobId);
1188
1259
  if (!job) {
1189
1260
  const view = this.view(jobId);
1190
1261
  if (!view) return null;
1262
+ if (view.status !== 'running' && view.status !== 'queued') return { ...view, cancel_effect: `job was already ${view.status}` };
1191
1263
  return {
1192
1264
  ...view,
1193
- cancel_effect: view.status === 'running' || view.status === 'queued'
1194
- ? 'nothing signalled: this consultation runs in another severally process, and only that process can cancel it'
1195
- : `job was already ${view.status}`,
1265
+ cancel_effect: store.requestCancel(jobId)
1266
+ ? `asked the severally process running it to stop it; that takes up to ${CANCEL_POLL_MS / 1000} s, and consult_get shows when it has`
1267
+ : 'nothing signalled: the request to the severally process running it could not be written',
1196
1268
  };
1197
1269
  }
1198
1270
  if (job.status !== 'running' && job.status !== 'queued') {
@@ -1309,7 +1381,12 @@ export class JobManager {
1309
1381
  if (!group) {
1310
1382
  const view = this.groupView(groupId);
1311
1383
  if (!view) return null;
1312
- return { ...view, cancel_effect: 'nothing signalled: this fan-out was started by another severally process or before a restart, and only a live process can cancel its own consultants' };
1384
+ // Started by another server: each member still running there is asked to stop.
1385
+ const asked = (store.loadGroup(groupId)?.job_ids ?? [])
1386
+ .map((jid) => this.cancel(jid)).filter((r) => /^asked/.test(r?.cancel_effect ?? '')).length;
1387
+ return { ...view, cancel_effect: asked
1388
+ ? `asked the severally process running it to stop ${asked} consultation(s); that takes up to ${CANCEL_POLL_MS / 1000} s, and consult_get shows when it has`
1389
+ : 'nothing to signal: no consultant in this fan-out is still running' };
1313
1390
  }
1314
1391
  // Count what is actually about to be signalled before signalling it:
1315
1392
  // cancel() short-circuits on a member that has already finished, so a
@@ -1484,6 +1561,37 @@ function text(value) {
1484
1561
  return t.length > POLICY.output.itemTextMax ? `${t.slice(0, POLICY.output.itemTextMax)}\n…[truncated by severally]` : t;
1485
1562
  }
1486
1563
 
1564
+ // A follow-up restates what the round before was given unless it says
1565
+ // otherwise: the lead sends the new question, not the whole brief again. Only
1566
+ // a key left out is inherited -- one passed, even as an empty list, replaces
1567
+ // it -- and the validation that follows sees the result. Files are not:
1568
+ // copying them again stays expose_from_parent, named in the call (schema.mjs).
1569
+ function inherit(raw, round) {
1570
+ const { brief } = round;
1571
+ // A round kept off the web stays off: the material it carries over is the same.
1572
+ const out = raw.network === undefined && round.network ? { ...raw, network: round.network } : { ...raw };
1573
+ if (!brief) return out;
1574
+ for (const k of ['objective', 'success_criteria', 'constraints']) {
1575
+ if (out[k] === undefined && raw.context?.[k] === undefined && brief[k] != null && !(Array.isArray(brief[k]) && !brief[k].length)) out[k] = brief[k];
1576
+ }
1577
+ const ctx = { ...(raw.context ?? {}) };
1578
+ for (const k of ['facts', 'proposal', 'counterpoints']) {
1579
+ if (ctx[k] === undefined && brief[k] != null && !(Array.isArray(brief[k]) && !brief[k].length)) ctx[k] = brief[k];
1580
+ }
1581
+ // The diff expose_diff added is not the lead's material: it is taken again
1582
+ // (or replaced) through expose_from_parent, against the files as they are now.
1583
+ // It is the last artifact (appended after the lead's own), so only that one is dropped.
1584
+ const artifacts = [...(brief.artifacts ?? [])];
1585
+ const last = artifacts.at(-1);
1586
+ if (brief.expose_paths?.entries?.some((e) => e.kind === 'diff') && last?.kind === 'diff' && last.name.startsWith('git diff ')) artifacts.pop();
1587
+ if (ctx.artifacts === undefined && artifacts.length) {
1588
+ // Stored with null for an absent language or source; the request schema takes them as optional strings.
1589
+ ctx.artifacts = artifacts.map((a) => Object.fromEntries(Object.entries(a).filter(([, v]) => v != null)));
1590
+ }
1591
+ out.context = ctx;
1592
+ return out;
1593
+ }
1594
+
1487
1595
  function adviceCaveat(normalized) {
1488
1596
  const { result, quality } = normalized;
1489
1597
  const notes = [];
@@ -1491,7 +1599,11 @@ function adviceCaveat(normalized) {
1491
1599
  notes.push('the consultant reported its evidence as insufficient: treat this as a request for more material, not as advice');
1492
1600
  } else if (result.evidence_basis === 'thin') {
1493
1601
  notes.push('the consultant reported thin evidence: weigh the findings accordingly');
1602
+ } else if (!result.evidence_basis) {
1603
+ notes.push('the consultant did not say how well its evidence supports the answer (evidence_basis)');
1494
1604
  }
1605
+ // Read first in a comparison; a missing one is shown as null there, and named here.
1606
+ if (!result.stance) notes.push('the answer gives no bottom line (stance): read the summary and findings for one before relying on it');
1495
1607
  if (quality.findings_without_grounds > 0) {
1496
1608
  notes.push(`${quality.findings_without_grounds} finding(s) arrived without grounds: do not adopt them without checking`);
1497
1609
  }
package/src/policy.mjs CHANGED
@@ -35,21 +35,50 @@ const CONFIG = (() => {
35
35
  }
36
36
  })();
37
37
 
38
- // Every mistake in the file, not only a syntax error. A config with any of
39
- // them is not run on defaults: the server still starts, so the operator can
40
- // read why, but every consultation is refused (jobs.mjs) -- falling back would
41
- // quietly re-enable a CLI the operator turned off.
38
+ // A mistake that leaves the whole file untrustworthy (unreadable, an unknown
39
+ // top-level key, a section of the wrong shape) is not run on defaults: the
40
+ // server still starts, so the operator can read why, but every consultation is
41
+ // refused (jobs.mjs) -- falling back would quietly re-enable a CLI the
42
+ // operator turned off. A mistake inside one consultant or one CLI only takes
43
+ // that one out of use, with the reason.
42
44
  const BUILT = buildConsultants(CONFIG);
43
- if (!configError && BUILT.problems.length) configError = `${CONFIG_PATH}: ${BUILT.problems.join('; ')}`;
45
+ if (!configError && BUILT.global.length) configError = `${CONFIG_PATH}: ${BUILT.global.join('; ')}`;
46
+ // A value that is neither, from the environment, cannot be read as either: reading it as "on" would open the web.
47
+ if (!configError && ![undefined, '', 'on', 'off'].includes(process.env.SEVERALLY_NETWORK)) {
48
+ configError = `SEVERALLY_NETWORK must be "on" or "off", not ${JSON.stringify(process.env.SEVERALLY_NETWORK)}`;
49
+ }
44
50
  const FROM = configError ? buildConsultants({}) : BUILT;
51
+ const LOCAL = configError ? [] : BUILT.problems.filter((p) => !BUILT.global.includes(p));
45
52
 
46
- /** What is wrong with the config file, or null. Reported at startup; while set, nothing can be started. */
53
+ /** What is wrong with the config file as a whole, or null. Reported at startup; while set, nothing can be started. */
47
54
  export function configProblem() {
48
55
  return configError;
49
56
  }
50
57
 
58
+ /** The mistakes kept to one consultant or one CLI, each of which is out of use until fixed. */
59
+ export function configWarnings() {
60
+ return LOCAL.map((p) => `${CONFIG_PATH}: ${p}`);
61
+ }
62
+
63
+ /** The names of the consultants out of use because of a mistake in their own config section. */
64
+ export function brokenConsultantNames() {
65
+ return configError ? [] : Object.keys(BUILT.broken);
66
+ }
67
+
68
+ /** Why the consultant of this name is out of use because of a config mistake, or null. */
69
+ export function brokenConsultant(name) {
70
+ const p = !configError && Object.hasOwn(BUILT.broken, name) ? BUILT.broken[name] : null;
71
+ return p ? `the config file has a mistake in it: ${p.join('; ')}` : null;
72
+ }
73
+
51
74
  const usable = configError ? {} : CONFIG;
52
- const cfgCli = (id) => (usable.clis && typeof usable.clis === 'object' ? usable.clis[id] ?? {} : {});
75
+ // A CLI whose own section has a mistake is off, whatever else the section says.
76
+ const cfgCli = (id) => {
77
+ if (Object.hasOwn(BUILT.brokenClis, id) && !configError) {
78
+ return { enabled: false, note: `turned off: the config file has a mistake in clis.${id}: ${BUILT.brokenClis[id].join('; ')}` };
79
+ }
80
+ return usable.clis && typeof usable.clis === 'object' ? usable.clis[id] ?? {} : {};
81
+ };
53
82
  const cfgServer = usable.server && typeof usable.server === 'object' ? usable.server : {};
54
83
 
55
84
  /**
@@ -218,6 +247,10 @@ export const POLICY = Object.freeze({
218
247
  // clients allow a tool call, so a long wait does not blow up as a client-side
219
248
  // timeout while the consultation is still healthy; a client registration
220
249
  // that is known to wait longer raises it (scripts/install.mjs).
250
+ // "off" takes the web away from every consultation, whatever the request
251
+ // asks; "on" leaves it to each request's own network.
252
+ // Either source can close it; neither can reopen what the other closed.
253
+ network: [process.env.SEVERALLY_NETWORK, cfgServer.network].includes('off') ? 'off' : 'on',
221
254
  maxWaitMs: limit('SEVERALLY_MAX_WAIT_MS', cfgServer.max_wait_ms, 45_000, 0, 600_000),
222
255
 
223
256
  input: Object.freeze({
@@ -389,11 +422,15 @@ export function limitsSummary() {
389
422
  const by = (f) => Object.fromEntries(list.map((c) => [c.name, f(c)]));
390
423
  return {
391
424
  available_targets: list.map((c) => c.name),
425
+ // A consultant or CLI out of use because of a mistake in its own section.
426
+ ...(LOCAL.length ? { config_mistakes: configWarnings() } : {}),
392
427
  clis: by((c) => c.cli),
393
428
  models: by((c) => c.model),
394
429
  // Per consultant, because they no longer share one budget.
395
430
  timeout_ms: by(timeoutMs),
396
431
  max_rounds_per_chain: POLICY.maxRounds,
432
+ // "off": the operator has taken the web away from every consultation.
433
+ network: POLICY.network,
397
434
  max_concurrent_jobs: POLICY.maxConcurrent,
398
435
  max_wait_ms: POLICY.maxWaitMs,
399
436
  input_char_budget: POLICY.input.totalCharsMax,
package/src/run.mjs CHANGED
@@ -164,15 +164,21 @@ export class ProcHandle {
164
164
  this.stdoutSoFar = readStdout;
165
165
  }
166
166
 
167
- /** Kill the child and every descendant it spawned. */
167
+ /**
168
+ * Kill the child and every descendant it spawned -- also after the child
169
+ * itself has exited: a descendant still holding its pipes keeps the
170
+ * consultation from closing, and on POSIX it is still in the group.
171
+ */
168
172
  stop(signal = 'SIGTERM') {
169
- if (this.killed || !this.child || this.child.exitCode !== null) return;
173
+ if (this.killed || !this.child) return;
170
174
  this.killed = true;
171
175
  const pid = this.child.pid;
172
176
  if (!pid) return;
173
177
  if (process.platform === 'win32') {
174
178
  // Negative PIDs/process groups are POSIX-only. Kill the Windows tree
175
- // before its parent exits and its descendants become unreachable.
179
+ // before its parent exits and its descendants become unreachable. Once
180
+ // it has exited, its pid may already be another process's: leave it.
181
+ if (this.child.exitCode !== null || this.child.signalCode !== null) return;
176
182
  const killer = spawn(path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'taskkill.exe'),
177
183
  ['/pid', String(pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' });
178
184
  const fallback = () => { try { this.child.kill('SIGKILL'); } catch { /* gone */ } };
@@ -201,6 +207,12 @@ export class ProcHandle {
201
207
  /**
202
208
  * @returns {{handle: ProcHandle, done: Promise<{code, signal, stdout, stderr, timedOut, cancelled, truncated}>}}
203
209
  */
210
+ /**
211
+ * `pipesForced` in the result: the child exited but something it started kept
212
+ * stdout or stderr open past the kill grace, so the group was stopped and the
213
+ * pipes closed from this side. A descendant outside the group (setsid, or on
214
+ * Windows once the parent is gone) may still be running.
215
+ */
204
216
  export function runChild({ command, args, cwd, env, input, timeoutMs, onCancelSignal, compactLine = null,
205
217
  settleAfter = null, stopSignal = 'SIGTERM' }) {
206
218
  const child = spawn(command, args, {
@@ -288,16 +300,37 @@ export function runChild({ command, args, cwd, env, input, timeoutMs, onCancelSi
288
300
 
289
301
  const done = new Promise((resolve) => {
290
302
  let settled = false;
303
+ let pipesForced = false;
304
+ let orphanTimer = null;
291
305
  const finish = (payload) => {
292
306
  if (settled) return;
293
307
  settled = true;
294
308
  clearTimeout(timer);
295
309
  clearTimeout(settleTimer);
296
- resolve({ stdout, stderr, truncated, timedOut, cancelled, stalled, ...payload });
310
+ clearTimeout(orphanTimer);
311
+ resolve({ stdout, stderr, truncated, timedOut, cancelled, stalled, pipesForced, ...payload });
297
312
  };
313
+ const flushPending = () => { if (byLine && pending) { append('out', compact(pending)); pending = ''; } };
298
314
  child.on('error', (err) => finish({ code: null, signal: null, spawnError: err.message }));
315
+ // 'close' waits for the pipes as well as the child. Once the child has
316
+ // exited, a grace later the rest of its group is stopped, and a grace
317
+ // after that the pipes are closed from here, whoever still holds them.
318
+ child.on('exit', (code, signal) => {
319
+ const grace = POLICY.killGraceMs;
320
+ orphanTimer = setTimeout(() => {
321
+ pipesForced = true;
322
+ handle.stop(stopSignal);
323
+ orphanTimer = setTimeout(() => {
324
+ handle.forceKill();
325
+ flushPending();
326
+ child.stdout.destroy();
327
+ child.stderr.destroy();
328
+ finish({ code, signal });
329
+ }, grace);
330
+ }, grace);
331
+ });
299
332
  child.on('close', (code, signal) => {
300
- if (byLine && pending) { append('out', compact(pending)); pending = ''; }
333
+ flushPending();
301
334
  finish({ code, signal });
302
335
  });
303
336
  });