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.
- package/CODE_OF_CONDUCT.md +83 -0
- package/CONFIG.md +10 -6
- package/README.ja.md +132 -65
- package/README.md +136 -69
- package/bin/severally-mcp.mjs +3 -0
- package/config.example.json +26 -0
- package/docs/how-it-works.ja.md +4 -2
- package/docs/how-it-works.md +5 -3
- package/docs/install.ja.md +12 -5
- package/docs/install.md +14 -5
- package/package.json +4 -2
- package/plugins/severally/.claude-plugin/plugin.json +1 -1
- package/plugins/severally/.codex-plugin/plugin.json +1 -1
- package/plugins/severally/dist/severally-mcp.mjs +29493 -28302
- package/plugins/severally/skills/_template/SKILL.md.tmpl +16 -22
- package/plugins/severally/skills/_template/reference.md.tmpl +11 -5
- package/plugins/severally/skills/antigravity/severally/SKILL.md +16 -22
- package/plugins/severally/skills/antigravity/severally/reference.md +11 -5
- package/plugins/severally/skills/claude/severally/SKILL.md +16 -22
- package/plugins/severally/skills/claude/severally/reference.md +11 -5
- package/plugins/severally/skills/codex/severally/SKILL.md +16 -22
- package/plugins/severally/skills/codex/severally/reference.md +11 -5
- package/plugins/severally/skills/opencode/severally/SKILL.md +16 -22
- package/plugins/severally/skills/opencode/severally/reference.md +11 -5
- package/scripts/install.mjs +90 -5
- package/src/consultants.mjs +31 -10
- package/src/jobs.mjs +123 -11
- package/src/policy.mjs +44 -7
- package/src/run.mjs +38 -5
- package/src/schema.mjs +30 -8
- package/src/server.mjs +70 -122
- package/src/status.mjs +63 -0
- package/src/store.mjs +44 -1
package/scripts/install.mjs
CHANGED
|
@@ -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
|
|
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: () =>
|
|
283
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
package/src/consultants.mjs
CHANGED
|
@@ -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))
|
|
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))
|
|
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
|
|
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))
|
|
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
|
-
|
|
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
|
-
|
|
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)) {
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
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:
|
|
1194
|
-
?
|
|
1195
|
-
:
|
|
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
|
-
|
|
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
|
-
//
|
|
39
|
-
//
|
|
40
|
-
// read why, but every consultation is
|
|
41
|
-
// quietly re-enable a CLI the
|
|
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.
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
333
|
+
flushPending();
|
|
301
334
|
finish({ code, signal });
|
|
302
335
|
});
|
|
303
336
|
});
|