severally-mcp 2.3.1 → 2.4.2

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 (45) hide show
  1. package/README.ja.md +52 -17
  2. package/README.md +52 -16
  3. package/bin/severally-mcp.mjs +14 -2
  4. package/docs/install.ja.md +48 -20
  5. package/docs/install.md +48 -21
  6. package/package.json +15 -3
  7. package/plugins/severally/.claude-plugin/plugin.json +1 -1
  8. package/plugins/severally/.codex-plugin/plugin.json +1 -1
  9. package/plugins/severally/dist/severally-mcp.mjs +6573 -5978
  10. package/plugins/severally/skills/_template/reference.md.tmpl +2 -2
  11. package/plugins/severally/skills/antigravity/severally/reference.md +2 -2
  12. package/plugins/severally/skills/claude/severally/reference.md +2 -2
  13. package/plugins/severally/skills/codex/severally/reference.md +2 -2
  14. package/plugins/severally/skills/opencode/severally/reference.md +2 -2
  15. package/scripts/build.mjs +76 -68
  16. package/scripts/doctor.mjs +66 -20
  17. package/scripts/init-config.mjs +8 -6
  18. package/scripts/install.mjs +327 -90
  19. package/scripts/live-check.mjs +62 -23
  20. package/scripts/live-mcp-check.mjs +52 -16
  21. package/scripts/test.mjs +10 -4
  22. package/src/adapters/antigravity-sandbox.mjs +62 -28
  23. package/src/adapters/antigravity.mjs +111 -30
  24. package/src/adapters/claude-code.mjs +121 -28
  25. package/src/adapters/codex.mjs +125 -33
  26. package/src/adapters/opencode-sandbox.mjs +59 -22
  27. package/src/adapters/opencode.mjs +144 -30
  28. package/src/brief.mjs +148 -47
  29. package/src/consultants.mjs +255 -48
  30. package/src/cross-spawn-escape.d.ts +7 -0
  31. package/src/export.mjs +99 -18
  32. package/src/expose-paths.mjs +310 -50
  33. package/src/failures.mjs +31 -14
  34. package/src/jobs.mjs +863 -249
  35. package/src/jsonc.mjs +11 -3
  36. package/src/parse-result.mjs +172 -40
  37. package/src/platform.mjs +56 -8
  38. package/src/policy.mjs +245 -52
  39. package/src/redact.mjs +59 -16
  40. package/src/result-schema.mjs +13 -0
  41. package/src/run.mjs +225 -30
  42. package/src/schema.mjs +132 -27
  43. package/src/server.mjs +178 -81
  44. package/src/status.mjs +94 -13
  45. package/src/store.mjs +418 -35
@@ -30,6 +30,16 @@ import { POLICY } from '../policy.mjs';
30
30
  import { classifyMessage } from '../failures.mjs';
31
31
  import { spawnCommand } from '../platform.mjs';
32
32
 
33
+ /** @typedef {import('../run.mjs').BuildRequest} BuildRequest */
34
+ /** @typedef {import('../run.mjs').ChildResult} ChildResult */
35
+ /** @typedef {import('../run.mjs').Invocation} Invocation */
36
+ /** @typedef {import('../run.mjs').Interpreted} Interpreted */
37
+ /** @typedef {import('../run.mjs').Sandbox} Sandbox */
38
+ /** @typedef {import('../run.mjs').UsageRecord} UsageRecord */
39
+ /** @typedef {import('../run.mjs').Boundary} Boundary */
40
+ /** @typedef {import('../run.mjs').InterpretedOk} OpencodeOk */
41
+ /** @typedef {import('../run.mjs').InterpretedFail} InterpretedFail */
42
+
33
43
  // Checked against opencode 1.18.31. The list exists so a future edit that
34
44
  // reaches for one of them is refused rather than quietly widening the
35
45
  // consultant:
@@ -82,21 +92,28 @@ export { prepareSandbox } from './opencode-sandbox.mjs';
82
92
  // server lives and dies with the child's process group. 1.18.31 has no such
83
93
  // flag (unknown flags die with a usage dump), so support is probed once per
84
94
  // process through `run --help` and the invocation stays 1.x-exact without it.
95
+ /** @type {boolean | null} */
85
96
  let standaloneSupport = null;
97
+ /** @type {Promise<void> | null} */
86
98
  let probePromise = null;
87
99
 
88
100
  /** Probe the CLI's capabilities once per process; safe to call per job. */
89
101
  export async function prepare() {
90
102
  if (standaloneSupport !== null) return;
91
103
  if (!probePromise) {
92
- probePromise = new Promise((resolve) => {
104
+ probePromise = new Promise((/** @type {() => void} */ resolve) => {
93
105
  let settled = false;
106
+ /** @type {NodeJS.Timeout | null} */
94
107
  let timer = null;
95
108
  // Only a SUCCESSFUL help invocation may cache its answer: a spawn
96
109
  // error, a timeout or a nonzero exit is transient, and caching it as
97
110
  // "unsupported" would pin a 2.x install to the shared-service route
98
111
  // for the process lifetime. Transient outcomes resolve without
99
112
  // caching, so the next job probes again.
113
+ /**
114
+ * @param {boolean} supported
115
+ * @param {boolean} cache
116
+ */
100
117
  const finishProbe = (supported, cache) => {
101
118
  if (settled) return;
102
119
  settled = true;
@@ -110,12 +127,18 @@ export async function prepare() {
110
127
  // STUB_* observables (a stubbed CLI cannot tell it ran), keeping the
111
128
  // STUB_HELP knobs so a test can still shape the advertised flag list
112
129
  // and the probe's latency.
113
- const env = Object.fromEntries(Object.entries(process.env)
114
- .filter(([k]) => !k.startsWith('STUB_') || k === 'STUB_HELP' || k === 'STUB_HELP_DELAY_MS'));
130
+ const env = Object.fromEntries(
131
+ Object.entries(process.env).filter(([k]) => !k.startsWith('STUB_') || k === 'STUB_HELP' || k === 'STUB_HELP_DELAY_MS'),
132
+ );
115
133
  const child = spawnCommand(POLICY.clis.opencode.cli, ['run', '--help'], { env, stdio: ['ignore', 'pipe', 'pipe'] });
116
134
  let out = '';
117
- child.stdout.on('data', (c) => { out += c; });
118
- child.stderr.on('data', (c) => { out += c; });
135
+ // stdio above is 'pipe' for both, so the streams exist.
136
+ /** @type {NonNullable<typeof child.stdout>} */ (child.stdout).on('data', (c) => {
137
+ out += c;
138
+ });
139
+ /** @type {NonNullable<typeof child.stderr>} */ (child.stderr).on('data', (c) => {
140
+ out += c;
141
+ });
119
142
  child.on('error', () => finishProbe(false, false));
120
143
  child.on('close', (code) => {
121
144
  // A nonzero exit proves nothing either way -- a diagnostic that
@@ -123,7 +146,14 @@ export async function prepare() {
123
146
  if (code !== 0) return finishProbe(false, false);
124
147
  finishProbe(out.includes('--standalone'), true);
125
148
  });
126
- timer = setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* gone */ } finishProbe(false, false); }, 10_000);
149
+ timer = setTimeout(() => {
150
+ try {
151
+ child.kill('SIGKILL');
152
+ } catch {
153
+ /* gone */
154
+ }
155
+ finishProbe(false, false);
156
+ }, 10_000);
127
157
  timer.unref();
128
158
  } catch {
129
159
  finishProbe(false, false);
@@ -150,6 +180,8 @@ export async function resetProbe() {
150
180
  * and the caller retries once. Strictly once per job; every other failure
151
181
  * kind -- an auth rejection, a usage limit, invalid output -- means a
152
182
  * respawn is wasted compute.
183
+ * @param {{ interpreted: Interpreted, sandbox: Sandbox | undefined, model: string }} args
184
+ * @returns {boolean}
153
185
  */
154
186
  export function recoverAuthFailure({ interpreted, sandbox, model }) {
155
187
  if (!interpreted || interpreted.ok !== false || interpreted.failureKind !== 'model_unavailable') return false;
@@ -164,13 +196,18 @@ export function recoverAuthFailure({ interpreted, sandbox, model }) {
164
196
  // adapter adds or leaves out by capability probe.
165
197
  export const RESERVED_FLAGS = ['--model', '--standalone', '--wizard'];
166
198
 
199
+ /**
200
+ * @param {BuildRequest} req
201
+ * @returns {Invocation}
202
+ */
167
203
  export function buildInvocation({ consultant }) {
168
204
  const t = POLICY.clis.opencode;
169
205
  const chosen = consultant.model;
170
206
  const effort = consultant.effort ?? 'high';
171
207
  const args = [
172
208
  'run',
173
- '--format', 'json',
209
+ '--format',
210
+ 'json',
174
211
  ...(standaloneSupport ? ['--standalone'] : []),
175
212
  // A failing 2.x run can exit nonzero with no error event and an empty
176
213
  // stderr (observed on GLM after ~5 min), leaving nothing to say why. Its
@@ -183,14 +220,51 @@ export function buildInvocation({ consultant }) {
183
220
  // name the operator wrote with its own `#variant` is passed as is, and a
184
221
  // bare trailing `#` means "no variant", for models that have none
185
222
  // ("Variant unavailable for opencode/nemotron-3-ultra-free: high").
186
- '-m', chosen.endsWith('#') ? chosen.slice(0, -1) : chosen.includes('#') ? chosen : `${chosen}#${effort}`,
187
- '--title', 'severally',
223
+ '-m',
224
+ chosen.endsWith('#') ? chosen.slice(0, -1) : chosen.includes('#') ? chosen : `${chosen}#${effort}`,
225
+ '--title',
226
+ 'severally',
188
227
  ];
189
228
  return { command: t.cli, args, model: chosen };
190
229
  }
191
230
 
192
- /** Every complete NDJSON object opencode wrote to stdout, oldest first. */
231
+ /**
232
+ * One NDJSON event from `opencode run --format json`; the CLI controls it, so
233
+ * every field is optional. `error` is {type, message} on 2.x and
234
+ * {name, data:{message}} on 1.x.
235
+ * @typedef {object} OpencodeEvent
236
+ * @property {string} [type]
237
+ * @property {{
238
+ * type?: string,
239
+ * text?: unknown,
240
+ * time?: { end?: unknown },
241
+ * tokens?: OpencodeTokens,
242
+ * cost?: unknown,
243
+ * tool?: string,
244
+ * callID?: string,
245
+ * id?: string,
246
+ * state?: { status?: string },
247
+ * }} [part]
248
+ * @property {{ message?: unknown, name?: unknown, data?: { message?: unknown } }} [error]
249
+ */
250
+
251
+ /**
252
+ * The token figures of a step-finish part.
253
+ * @typedef {{ input?: unknown, output?: unknown, total?: unknown, reasoning?: unknown, cache_read?: unknown, cache?: { read?: unknown } }} OpencodeTokens
254
+ */
255
+
256
+ /**
257
+ * The usage figures interpret collects from the event stream.
258
+ * @typedef {{ tokens?: OpencodeTokens | null, cost?: unknown, steps?: unknown }} OpencodeUsage
259
+ */
260
+
261
+ /**
262
+ * Every complete NDJSON object opencode wrote to stdout, oldest first.
263
+ * @param {string | undefined} stdout
264
+ * @returns {OpencodeEvent[]}
265
+ */
193
266
  export function events(stdout) {
267
+ /** @type {OpencodeEvent[]} */
194
268
  const out = [];
195
269
  for (const line of (stdout || '').split('\n')) {
196
270
  const t = line.trim();
@@ -198,7 +272,9 @@ export function events(stdout) {
198
272
  try {
199
273
  const parsed = JSON.parse(t);
200
274
  if (parsed && typeof parsed === 'object') out.push(parsed);
201
- } catch { /* a partial line, e.g. when the child was killed mid-write */ }
275
+ } catch {
276
+ /* a partial line, e.g. when the child was killed mid-write */
277
+ }
202
278
  }
203
279
  return out;
204
280
  }
@@ -210,19 +286,28 @@ export function events(stdout) {
210
286
  * filled rawCaptureMax and cut the final answer off the stream. Nothing here
211
287
  * reads tool results: interpret wants text/step_finish/error, and progress
212
288
  * only the tool name.
289
+ * @param {string} line
290
+ * @returns {string}
213
291
  */
214
292
  export function compactLine(line) {
215
293
  if (!line.includes('"tool_use"')) return line;
216
294
  try {
217
295
  const e = JSON.parse(line);
218
296
  if (e?.type !== 'tool_use') return line;
219
- return JSON.stringify({ type: e.type, part: { type: e.part?.type, tool: e.part?.tool, state: { status: e.part?.state?.status } } });
297
+ return JSON.stringify({
298
+ type: e.type,
299
+ part: { type: e.part?.type, tool: e.part?.tool, state: { status: e.part?.state?.status } },
300
+ });
220
301
  } catch {
221
302
  return line;
222
303
  }
223
304
  }
224
305
 
225
- /** One line naming the error opencode reported, in classifyMessage's terms. */
306
+ /**
307
+ * One line naming the error opencode reported, in classifyMessage's terms.
308
+ * @param {NonNullable<OpencodeEvent['error']>} error
309
+ * @returns {string}
310
+ */
226
311
  function errorMessage(error) {
227
312
  if (!error || typeof error !== 'object') return JSON.stringify(error);
228
313
  // 2.x: {type, message} -- the message is already a complete human sentence,
@@ -230,9 +315,7 @@ function errorMessage(error) {
230
315
  // message are joined, as before. A CLI upgrade must degrade the wording and
231
316
  // never the classification.
232
317
  if (typeof error.message === 'string' && error.message) return error.message;
233
- const detail = error.data && typeof error.data === 'object' && error.data.message
234
- ? String(error.data.message)
235
- : '';
318
+ const detail = error.data && typeof error.data === 'object' && error.data.message ? String(error.data.message) : '';
236
319
  return [error.name, detail].filter(Boolean).join(': ') || JSON.stringify(error);
237
320
  }
238
321
 
@@ -242,6 +325,8 @@ function errorMessage(error) {
242
325
  * log line lands). Raw stdout is never the message: it is the event stream
243
326
  * of a run that was working fine, its head says nothing about the failure, and
244
327
  * numbers inside it ("reasoning":403) would be classified as HTTP statuses.
328
+ * @param {{ errors: string[], stderr?: string | undefined, stdout?: string | undefined, code?: number | null | undefined, signal?: NodeJS.Signals | null | undefined, what: string }} run
329
+ * @returns {{ msg: string, kind: string }}
245
330
  */
246
331
  function failureMessage({ errors, stderr, stdout, code, signal, what }) {
247
332
  if (errors.length) {
@@ -258,20 +343,25 @@ function failureMessage({ errors, stderr, stdout, code, signal, what }) {
258
343
  * Parse the event stream `opencode run --format json` writes to stdout.
259
344
  * The answer is the LAST completed text part: a consultation can emit prose
260
345
  * between tool calls, and only the final part carries the reply contract.
346
+ * @param {Partial<ChildResult>} run
347
+ * @returns {OpencodeOk | InterpretedFail}
261
348
  */
262
349
  export function interpret({ stdout, stderr, code, signal = null, truncated = false }) {
263
350
  const all = events(stdout);
351
+ /** @type {string | null} */
264
352
  let text = null;
265
353
  let textIndex = -1;
266
354
  let lastErrorIndex = -1;
355
+ let lastError = '';
267
356
  let finishedAfterText = false;
268
357
  let tokens = null;
269
358
  let cost = null;
270
359
  let steps = 0;
360
+ /** @type {string[]} */
271
361
  const errors = [];
362
+ /** @type {string[]} */
272
363
  const earlierTextParts = [];
273
- for (let i = 0; i < all.length; i++) {
274
- const e = all[i];
364
+ for (const [i, e] of all.entries()) {
275
365
  const part = e.part;
276
366
  if (e.type === 'text' && part?.type === 'text' && part.time?.end && typeof part.text === 'string' && part.text.trim()) {
277
367
  // The last completed part wins as the answer, but every part that had
@@ -291,7 +381,8 @@ export function interpret({ stdout, stderr, code, signal = null, truncated = fal
291
381
  if (part.tokens && typeof part.tokens === 'object') tokens = part.tokens;
292
382
  if (typeof part.cost === 'number') cost = part.cost;
293
383
  } else if (e.type === 'error' && e.error) {
294
- errors.push(errorMessage(e.error));
384
+ lastError = errorMessage(e.error);
385
+ errors.push(lastError);
295
386
  lastErrorIndex = i;
296
387
  }
297
388
  }
@@ -311,7 +402,7 @@ export function interpret({ stdout, stderr, code, signal = null, truncated = fal
311
402
  // failure. What text exists is interim prose, and 2.x exits 0 even on
312
403
  // provider errors, so order -- not the exit code -- is what tells this
313
404
  // apart from the recovered-retry case below.
314
- const msg = errors[errors.length - 1];
405
+ const msg = lastError;
315
406
  return { ok: false, failureKind: classifyMessage(msg), message: msg, usageRaw };
316
407
  }
317
408
  if (code !== 0) {
@@ -321,7 +412,7 @@ export function interpret({ stdout, stderr, code, signal = null, truncated = fal
321
412
  const { msg, kind } = failureMessage({ errors, stderr, stdout, code, signal, what: 'after producing an answer' });
322
413
  return { ok: false, failureKind: kind, message: msg, usageRaw };
323
414
  }
324
- if (truncated && !(finishedAfterText && all.length > 0 && all[all.length - 1].type === 'step_finish')) {
415
+ if (truncated && !(finishedAfterText && all.at(-1)?.type === 'step_finish')) {
325
416
  // stdout hit the rawCaptureMax before the consultation finished, so the
326
417
  // final answer event may never have been read. Under truncation only a
327
418
  // stream that ENDS on a step-finish -- the terminal event of a completed
@@ -344,34 +435,53 @@ export function interpret({ stdout, stderr, code, signal = null, truncated = fal
344
435
  return { ok: true, text, earlierTextParts, usageRaw };
345
436
  }
346
437
 
438
+ /** The tools that read a file's contents (glob only lists names); a consultation whose calls to them all failed read none of its exposed files. */
439
+ export const FILE_READ_TOOLS = ['read', 'grep'];
440
+
347
441
  /**
348
442
  * What this consultation has done so far -- read while it runs. Separates
349
443
  * "working steadily through tool calls" from "wedged on one call", which is
350
444
  * the difference between raising the budget and narrowing the brief.
351
445
  */
352
- /** Tool calls seen in the event stream, one per call id; `:error` when the call failed or was refused. */
446
+ /**
447
+ * Tool calls seen in the event stream, one per call id; `:error` when the call failed or was refused.
448
+ * @param {{ stdout?: string }} run
449
+ * @returns {Record<string, number>}
450
+ */
353
451
  export function toolCalls({ stdout }) {
452
+ /** @type {Map<unknown, string>} */
354
453
  const seen = new Map();
355
454
  for (const e of events(stdout)) {
356
455
  if (e.type === 'tool_use' && e.part?.tool) {
357
456
  seen.set(e.part.callID ?? e.part.id ?? seen.size, e.part.state?.status === 'error' ? `${e.part.tool}:error` : e.part.tool);
358
457
  }
359
458
  }
360
- return [...seen.values()].reduce((o, n) => ({ ...o, [n]: (o[n] ?? 0) + 1 }), {});
459
+ return [...seen.values()].reduce((/** @type {Record<string, number>} */ o, n) => ({ ...o, [n]: (o[n] ?? 0) + 1 }), {});
361
460
  }
362
461
 
462
+ /**
463
+ * @param {{ stdout?: string | undefined }} run
464
+ * @returns {string | null}
465
+ */
363
466
  export function progress({ stdout }) {
364
- const seen = events(stdout).filter((e) => ['step_start', 'step_finish', 'text', 'tool_use'].includes(e.type));
365
- if (!seen.length) return null;
366
- const last = seen[seen.length - 1];
467
+ const seen = events(stdout).filter(
468
+ (e) => e.type !== undefined && ['step_start', 'step_finish', 'text', 'tool_use'].includes(e.type),
469
+ );
470
+ const last = seen.at(-1);
471
+ if (!last) return null;
367
472
  const what = last.type === 'tool_use' && last.part?.tool ? `tool ${last.part.tool}` : last.type;
368
473
  return `${seen.length} event(s), last was ${what}`;
369
474
  }
370
475
 
476
+ /**
477
+ * @param {unknown} raw the usage figures interpret collected from the event stream
478
+ * @returns {UsageRecord}
479
+ */
371
480
  export function usageRecord(raw) {
372
- const r = raw ?? {};
481
+ // interpret's usageRaw is the OpencodeUsage it built (or null).
482
+ const r = /** @type {OpencodeUsage | null} */ (raw) ?? {};
373
483
  const t = r.tokens && typeof r.tokens === 'object' ? r.tokens : {};
374
- const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
484
+ const num = (/** @type {unknown} */ v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
375
485
  const cache = t.cache && typeof t.cache === 'object' ? t.cache : {};
376
486
  return {
377
487
  input_tokens: num(t.input),
@@ -389,15 +499,19 @@ export function usageRecord(raw) {
389
499
  * What this consultant was launched with, read off the invocation: the
390
500
  * boundary record kept beside each answer. Facts about the launch, not a
391
501
  * claim about what the consultant did.
502
+ * @param {{ sandbox: Sandbox | null }} launch
503
+ * @returns {Boundary}
392
504
  */
393
505
  export function boundary({ sandbox }) {
506
+ // buildInvocation always runs with the sandbox prepareSandbox made.
507
+ const { tools } = /** @type {Sandbox} */ (sandbox);
394
508
  return {
395
509
  reads: 'whole disk',
396
510
  shell: 'none',
397
- web: sandbox.tools.includes('webfetch'),
511
+ web: tools.includes('webfetch'),
398
512
  writes: false,
399
513
  mcp: false,
400
514
  protected_paths: 'denied (permission rules)',
401
- tools: [...sandbox.tools],
515
+ tools: [...tools],
402
516
  };
403
517
  }