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
package/src/server.mjs CHANGED
@@ -9,7 +9,9 @@ import { requestSchema, RequestError } from './schema.mjs';
9
9
  import { JobManager, pointsOf } from './jobs.mjs';
10
10
  import { exportChain, ExportError } from './export.mjs';
11
11
 
12
- export const SEVERALLY_VERSION = '2.3.1';
12
+ /** @typedef {import('@modelcontextprotocol/server').CallToolResult} CallToolResult */
13
+
14
+ export const SEVERALLY_VERSION = '2.4.2';
13
15
 
14
16
  // consult_get waits this long when no wait_ms is given: a call that forgot it
15
17
  // would otherwise return at once and be repeated. Up to 45 s, under the 60 s
@@ -21,17 +23,39 @@ const DEFAULT_WAIT_MS = process.env.SEVERALLY_MAX_WAIT_MS ? POLICY.maxWaitMs : M
21
23
  // counts silence against the call keeps it open.
22
24
  const PROGRESS_EVERY_MS = 15_000;
23
25
 
26
+ /** @param {string} v */
24
27
  const semver = (v) => String(v).split('.').map(Number);
25
- const older = (a, b) => { const [x, y] = [semver(a), semver(b)]; for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] < y[i]; return false; };
28
+ /**
29
+ * @param {string} a
30
+ * @param {string} b
31
+ */
32
+ const older = (a, b) => {
33
+ const [x, y] = [semver(a), semver(b)];
34
+ for (let i = 0; i < 3; i++) {
35
+ const [p, q] = [x[i], y[i]];
36
+ if (p === undefined || q === undefined) return false;
37
+ if (p !== q) return p < q;
38
+ }
39
+ return false;
40
+ };
26
41
 
27
42
  /**
28
43
  * The other installed copies of severally, when one runs a different version
29
44
  * from this server: a fix reaches a client only through the copy it runs, and
30
45
  * an operator who updated one copy cannot see that another is behind. Only
31
46
  * version strings are compared. A copy that is missing is not reported.
47
+ * @param {string} [home]
48
+ * @param {string} [severallyHome]
32
49
  */
33
50
  export function staleInstall(home = os.homedir(), severallyHome = POLICY.home) {
34
- const read = (file) => { try { return fs.readFileSync(file, 'utf8'); } catch { return null; } };
51
+ /** @param {string} file */
52
+ const read = (file) => {
53
+ try {
54
+ return fs.readFileSync(file, 'utf8');
55
+ } catch {
56
+ return null;
57
+ }
58
+ };
35
59
  // The runtime's version is read from its bundle, which every install mode
36
60
  // replaces; its package.json is written by plugin installs only.
37
61
  const bundle = read(path.join(severallyHome, 'runtime', 'severally-mcp.mjs'));
@@ -39,20 +63,31 @@ export function staleInstall(home = os.homedir(), severallyHome = POLICY.home) {
39
63
  const found = {
40
64
  // This release names it SEVERALLY_VERSION; older ones only had it in the server info.
41
65
  runtime: bundle?.match(/(?:SEVERALLY_VERSION = |name: ["']severally["'], version: )["'](\d+\.\d+\.\d+)["']/)?.[1],
42
- claude_skills_dir: (() => { try { return JSON.parse(plugin).version; } catch { return undefined; } })(),
66
+ claude_skills_dir: (() => {
67
+ if (plugin === null) return undefined;
68
+ try {
69
+ return JSON.parse(plugin).version;
70
+ } catch {
71
+ return undefined;
72
+ }
73
+ })(),
43
74
  };
75
+ /** @type {Record<string, string>} */
44
76
  const copies = { this_server: SEVERALLY_VERSION };
45
77
  // Only plain x.y.z is compared; anything else is left out rather than guessed at.
46
- for (const [name, v] of Object.entries(found)) if (/^\d+\.\d+\.\d+$/.test(v ?? '')) copies[name] = v;
78
+ for (const [name, v] of Object.entries(found)) if (v !== undefined && /^\d+\.\d+\.\d+$/.test(v)) copies[name] = v;
47
79
  const versions = Object.values(copies);
48
80
  if (versions.every((v) => v === SEVERALLY_VERSION)) return null;
49
81
  const newest = versions.reduce((a, b) => (older(a, b) ? b : a));
50
82
  return {
51
83
  copies,
52
- behind: Object.keys(copies).filter((k) => older(copies[k], newest)),
53
- fix: `bring every copy to ${newest}: run \`npx -y severally-mcp@${newest} install\` (or \`node scripts/install.mjs\` from a checkout of that version) `
54
- + '(it updates the runtime and the ~/.claude/skills copy), run `/plugin marketplace update severally` for a '
55
- + 'marketplace install, then restart the clients',
84
+ behind: Object.entries(copies)
85
+ .filter(([, v]) => older(v, newest))
86
+ .map(([k]) => k),
87
+ fix:
88
+ `bring every copy to ${newest}: run \`npx -y severally-mcp@${newest} install\` (or \`node scripts/install.mjs\` from a checkout of that version) ` +
89
+ '(it updates the runtime and the ~/.claude/skills copy), run `/plugin marketplace update severally` for a ' +
90
+ 'marketplace install, then restart the clients',
56
91
  };
57
92
  }
58
93
 
@@ -73,7 +108,11 @@ check the grounds behind a point before you adopt it, and record what you adopte
73
108
  const startDescription = `Start a consultation with another agent (or several, via targets). Returns a job_id (or a group_id for several) at once; the work runs in the background.
74
109
 
75
110
  target: which consultant to ask. This machine can reach (name: CLI, model):
76
- ${availableConsultants().map((c) => ` ${c.name}: ${c.cli}, ${c.model}`).join('\n') || ' (none -- no consultant CLI is installed)'}
111
+ ${
112
+ availableConsultants()
113
+ .map((c) => ` ${c.name}: ${c.cli}, ${c.model}`)
114
+ .join('\n') || ' (none -- no consultant CLI is installed)'
115
+ }
77
116
  Pass the word the user used: a name, a CLI ("codex", "agy"), a vendor or a model ("claude opus"). One
78
117
  that fits several, unresolved by the operator's ranking, tier and version, is refused with the
79
118
  candidates (target_ambiguous); one not listed is refused (target_unavailable). Your own CLI on a fresh
@@ -107,29 +146,50 @@ Model, permissions, rounds, timeout and size caps are fixed by this server.`;
107
146
  // element of its `records`.
108
147
  const recordShape = {
109
148
  job_id: z.string().min(1).describe('the job_id consult_get returned'),
110
- entries: z.array(z.object({
111
- id: z.string().min(1).describe('f1, u1, c1 ... from the result'),
112
- verdict: z.enum(VERDICTS),
113
- effect: z.string().max(4000).optional().describe('what it changed, or why it is unverified'),
114
- note: z.string().max(4000).optional().describe('what you checked and found'),
115
- })).min(1).max(100).optional(),
116
- reflection: z.object({
117
- delta: z.string().min(1).max(4000).describe('what the answer added over what you expected ("nothing new" counts)'),
118
- related_item_ids: z.array(z.string().min(1)).max(50).optional().describe('f1, u1, c1 ...'),
119
- }).optional().describe('written after reading the answer'),
149
+ entries: z
150
+ .array(
151
+ z.object({
152
+ id: z.string().min(1).describe('f1, u1, c1 ... from the result'),
153
+ verdict: z.enum(VERDICTS),
154
+ effect: z.string().max(4000).optional().describe('what it changed, or why it is unverified'),
155
+ note: z.string().max(4000).optional().describe('what you checked and found'),
156
+ }),
157
+ )
158
+ .min(1)
159
+ .max(100)
160
+ .optional(),
161
+ reflection: z
162
+ .object({
163
+ delta: z.string().min(1).max(4000).describe('what the answer added over what you expected ("nothing new" counts)'),
164
+ related_item_ids: z.array(z.string().min(1)).max(50).optional().describe('f1, u1, c1 ...'),
165
+ })
166
+ .optional()
167
+ .describe('written after reading the answer'),
120
168
  };
121
169
 
170
+ /** @param {unknown} err */
171
+ const errorMessage = (err) =>
172
+ String((typeof err === 'object' && err !== null && 'message' in err ? err.message : undefined) ?? err);
173
+
122
174
  export function createServer(manager = new JobManager()) {
123
175
  // Read once: the copies on disk change only when someone reinstalls, and then
124
176
  // this server is the one to restart.
125
177
  const stale = staleInstall();
126
- const server = new McpServer(
127
- { name: 'severally', version: SEVERALLY_VERSION },
128
- { instructions: SERVER_INSTRUCTIONS },
129
- );
178
+ const server = new McpServer({ name: 'severally', version: SEVERALLY_VERSION }, { instructions: SERVER_INSTRUCTIONS });
130
179
 
180
+ /**
181
+ * @param {unknown} payload
182
+ * @returns {CallToolResult}
183
+ */
131
184
  const ok = (payload) => ({ content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] });
132
- const fail = (payload) => ({ content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], isError: true });
185
+ /**
186
+ * @param {unknown} payload
187
+ * @returns {CallToolResult}
188
+ */
189
+ const fail = (payload) => ({
190
+ content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
191
+ isError: true,
192
+ });
133
193
 
134
194
  server.registerTool(
135
195
  'consult_start',
@@ -147,7 +207,7 @@ export function createServer(manager = new JobManager()) {
147
207
  if (err instanceof RequestError) {
148
208
  return fail({ error: err.code, message: err.message, details: err.details ?? null, limits: limitsSummary() });
149
209
  }
150
- return fail({ error: 'internal_error', message: String(err?.message ?? err) });
210
+ return fail({ error: 'internal_error', message: errorMessage(err) });
151
211
  }
152
212
  },
153
213
  );
@@ -164,7 +224,7 @@ export function createServer(manager = new JobManager()) {
164
224
  'again while status is running. completed carries the structured answer; failed carries ' +
165
225
  'failure.kind (timeout, auth, usage_limit, rate_limited, model_unavailable, invalid_output, cli_error, ' +
166
226
  'spawn_error, interrupted), meaning no advice was obtained -- not the same as advice with thin evidence ' +
167
- '(quality.evidence_basis, quality.caveat). group_id fetches a fan-out: each member\'s status, failure and ' +
227
+ "(quality.evidence_basis, quality.caveat). group_id fetches a fan-out: each member's status, failure and " +
168
228
  'job_id, and `comparison.by_target` with every stance, summary and finding id side by side, cut where ' +
169
229
  '`truncated` says so. ids: ["f2", "u1"] with a job_id returns just those points, whole, with their grounds ' +
170
230
  'and your verdict (`recorded`). A finished consultation names its brief rather than repeating it; detail: ' +
@@ -174,8 +234,14 @@ export function createServer(manager = new JobManager()) {
174
234
  group_id: z.string().min(1).optional(),
175
235
  wait_ms: z.number().int().min(0).max(POLICY.maxWaitMs).optional(),
176
236
  detail: z.enum(['full']).optional(),
177
- ids: z.array(z.string().min(1)).min(1).max(100).optional()
178
- .describe('with job_id: return only these points of the finished answer (f1, u1, c1 ...), each whole with its grounds and, under `recorded`, the verdict you wrote'),
237
+ ids: z
238
+ .array(z.string().min(1))
239
+ .min(1)
240
+ .max(100)
241
+ .optional()
242
+ .describe(
243
+ 'with job_id: return only these points of the finished answer (f1, u1, c1 ...), each whole with its grounds and, under `recorded`, the verdict you wrote',
244
+ ),
179
245
  }),
180
246
  },
181
247
  async ({ job_id, group_id, wait_ms, detail, ids }, ctx) => {
@@ -183,32 +249,47 @@ export function createServer(manager = new JobManager()) {
183
249
  return fail({ error: 'invalid_request', message: 'pass exactly one of job_id or group_id' });
184
250
  }
185
251
  if (ids && (group_id || detail)) {
186
- return fail({ error: 'invalid_request', message: 'ids goes with job_id alone: a point id belongs to one consultant\'s answer' });
252
+ return fail({
253
+ error: 'invalid_request',
254
+ message: "ids goes with job_id alone: a point id belongs to one consultant's answer",
255
+ });
187
256
  }
188
257
  const req = ctx?.mcpReq;
189
258
  const token = req?._meta?.progressToken;
190
259
  let beats = 0;
191
- const timer = token === undefined ? null : setInterval(() => {
192
- beats += 1;
193
- req.notify({ method: 'notifications/progress', params: { progressToken: token, progress: beats, message: 'the consultation is still running' } })
194
- .catch(() => {});
195
- }, PROGRESS_EVERY_MS);
260
+ const timer =
261
+ token === undefined
262
+ ? null
263
+ : setInterval(() => {
264
+ beats += 1;
265
+ req
266
+ .notify({
267
+ method: 'notifications/progress',
268
+ params: { progressToken: token, progress: beats, message: 'the consultation is still running' },
269
+ })
270
+ .catch(() => {});
271
+ }, PROGRESS_EVERY_MS);
196
272
  let view;
197
273
  try {
198
- // A request the client cancels stops the wait with it.
274
+ // A request the client cancels stops the wait with it. Without group_id,
275
+ // job_id is set: exactly one of the two was checked above.
199
276
  view = group_id
200
277
  ? await manager.waitGroup(group_id, wait_ms ?? DEFAULT_WAIT_MS, { detail }, req?.signal)
201
- : await manager.wait(job_id, wait_ms ?? DEFAULT_WAIT_MS, { detail }, req?.signal);
278
+ : await manager.wait(/** @type {string} */ (job_id), wait_ms ?? DEFAULT_WAIT_MS, { detail }, req?.signal);
202
279
  } finally {
203
- clearInterval(timer);
280
+ clearInterval(timer ?? undefined);
204
281
  }
205
282
  if (!view) {
206
- return fail({ error: 'unknown_job', message: `no consultation with ${group_id ? 'group_id' : 'job_id'} "${group_id ?? job_id}"` });
283
+ return fail({
284
+ error: 'unknown_job',
285
+ message: `no consultation with ${group_id ? 'group_id' : 'job_id'} "${group_id ?? job_id}"`,
286
+ });
207
287
  }
208
288
  // Still running: the poll answer as it is; ids applies once there is an answer.
209
289
  if (!ids || ['running', 'queued', 'cancelling'].includes(view.status)) return ok(view);
210
290
  try {
211
- return ok(pointsOf(view, ids));
291
+ // ids comes with job_id alone (checked above), so this is one job's view.
292
+ return ok(pointsOf(/** @type {import('./jobs.mjs').JobView} */ (view), ids));
212
293
  } catch (err) {
213
294
  if (err instanceof RequestError) return fail({ error: err.code, message: err.message });
214
295
  throw err;
@@ -223,8 +304,8 @@ export function createServer(manager = new JobManager()) {
223
304
  annotations: { readOnlyHint: false, idempotentHint: true, openWorldHint: false },
224
305
  description:
225
306
  'Stop a running consultation and kill the consultant process and everything it spawned. ' +
226
- 'Pass group_id instead of job_id to stop every consultant in a fan-out. One another severally server runs '
227
- + 'is asked to stop, and that server stops it within a second.',
307
+ 'Pass group_id instead of job_id to stop every consultant in a fan-out. One another severally server runs ' +
308
+ 'is asked to stop, and that server stops it within a second.',
228
309
  inputSchema: z.object({
229
310
  job_id: z.string().min(1).optional(),
230
311
  group_id: z.string().min(1).optional(),
@@ -234,9 +315,13 @@ export function createServer(manager = new JobManager()) {
234
315
  if (Boolean(job_id) === Boolean(group_id)) {
235
316
  return fail({ error: 'invalid_request', message: 'pass exactly one of job_id or group_id' });
236
317
  }
237
- const view = group_id ? manager.cancelGroup(group_id) : manager.cancel(job_id);
318
+ // Without group_id, job_id is set: exactly one of the two was checked above.
319
+ const view = group_id ? manager.cancelGroup(group_id) : manager.cancel(/** @type {string} */ (job_id));
238
320
  if (!view) {
239
- return fail({ error: 'unknown_job', message: `no consultation with ${group_id ? 'group_id' : 'job_id'} "${group_id ?? job_id}"` });
321
+ return fail({
322
+ error: 'unknown_job',
323
+ message: `no consultation with ${group_id ? 'group_id' : 'job_id'} "${group_id ?? job_id}"`,
324
+ });
240
325
  }
241
326
  return ok(view);
242
327
  },
@@ -248,40 +333,50 @@ export function createServer(manager = new JobManager()) {
248
333
  title: 'Record what checking a point showed',
249
334
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
250
335
  description:
251
- 'Write your verdict on points of an answer after checking them: ids f1 ... (findings), u1 ... (unknowns), '
252
- + 'c1 ... (next_checks). verdict is what checking showed -- confirmed (holds here), not_applicable (true in '
253
- + 'general, not here), unverifiable, unverified (not checked yet; say why in effect) -- not whether you adopted '
254
- + 'it. effect is what it changed about your decision, note the evidence. A follow-up shows the consultant each '
255
- + 'verdict and effect (credential-masked), never the note. The same id again replaces the entry. Saved beside '
256
- + 'the answer in ~/.severally/history, so a job from an earlier session can be recorded against; the server '
257
- + 'never infers a verdict. Answers with counts; consult_get reads the entries back. reflection records what the '
258
- + 'answer added over your consult_start prediction. records: [{ job_id, entries, reflection? }, ...] records '
259
- + 'every member of a fan-out in one call; all are checked first and nothing is written if any job or id is '
260
- + 'wrong. A job whose write fails is listed under results with its error.',
336
+ 'Write your verdict on points of an answer after checking them: ids f1 ... (findings), u1 ... (unknowns), ' +
337
+ 'c1 ... (next_checks). verdict is what checking showed -- confirmed (holds here), not_applicable (true in ' +
338
+ 'general, not here), unverifiable, unverified (not checked yet; say why in effect) -- not whether you adopted ' +
339
+ 'it. effect is what it changed about your decision, note the evidence. A follow-up shows the consultant each ' +
340
+ 'verdict and effect (credential-masked), never the note. The same id again replaces the entry. Saved beside ' +
341
+ 'the answer in ~/.severally/history, so a job from an earlier session can be recorded against; the server ' +
342
+ 'never infers a verdict. Answers with counts; consult_get reads the entries back. reflection records what the ' +
343
+ 'answer added over your consult_start prediction. records: [{ job_id, entries, reflection? }, ...] records ' +
344
+ 'every member of a fan-out in one call; all are checked first and nothing is written if any job or id is ' +
345
+ 'wrong. A job whose write fails is listed under results with its error.',
261
346
  inputSchema: z.object({
262
347
  ...recordShape,
263
348
  job_id: recordShape.job_id.optional(),
264
- records: z.array(z.object(recordShape)).min(1).max(POLICY.maxConcurrent).optional()
349
+ records: z
350
+ .array(z.object(recordShape))
351
+ .min(1)
352
+ .max(POLICY.maxConcurrent)
353
+ .optional()
265
354
  .describe('one per fan-out member, instead of job_id/entries/reflection'),
266
355
  }),
267
356
  },
268
357
  async ({ job_id, entries, reflection, records }) => {
269
358
  try {
270
359
  if (records !== undefined) {
271
- const out = manager.record(Object.fromEntries(Object.entries({ job_id, entries, reflection, records }).filter(([, v]) => v !== undefined)));
360
+ /** @type {Record<string, unknown>} */
361
+ const out = manager.record(
362
+ Object.fromEntries(Object.entries({ job_id, entries, reflection, records }).filter(([, v]) => v !== undefined)),
363
+ );
272
364
  return out.not_saved ? fail(out) : ok(out);
273
365
  }
274
- if (!job_id) return fail({ error: 'invalid_request', message: 'pass job_id (with entries and/or reflection), or records' });
275
- return ok(manager.record({
276
- job_id,
277
- ...(entries === undefined ? {} : { entries }),
278
- ...(reflection === undefined ? {} : { reflection }),
279
- }));
366
+ if (!job_id)
367
+ return fail({ error: 'invalid_request', message: 'pass job_id (with entries and/or reflection), or records' });
368
+ return ok(
369
+ manager.record({
370
+ job_id,
371
+ ...(entries === undefined ? {} : { entries }),
372
+ ...(reflection === undefined ? {} : { reflection }),
373
+ }),
374
+ );
280
375
  } catch (err) {
281
376
  if (err instanceof RequestError) {
282
377
  return fail({ error: err.code, message: err.message, details: err.details ?? null });
283
378
  }
284
- return fail({ error: 'internal_error', message: String(err?.message ?? err) });
379
+ return fail({ error: 'internal_error', message: errorMessage(err) });
285
380
  }
286
381
  },
287
382
  );
@@ -292,10 +387,10 @@ export function createServer(manager = new JobManager()) {
292
387
  title: 'Export a consultation as a record to commit',
293
388
  annotations: { readOnlyHint: true, openWorldHint: false },
294
389
  description:
295
- 'Render one consultation (chain_id) or fan-out (group_id) as Markdown: the brief as sent, each answer as '
296
- + 'it came back, and the verdicts on each point, unchecked ones marked. Nothing is summarised or scored. '
297
- + 'The text is returned, not written: put it beside the code the decision is about. Reads the history, so '
298
- + 'an earlier session\'s consultation can be exported.',
390
+ 'Render one consultation (chain_id) or fan-out (group_id) as Markdown: the brief as sent, each answer as ' +
391
+ 'it came back, and the verdicts on each point, unchecked ones marked. Nothing is summarised or scored. ' +
392
+ 'The text is returned, not written: put it beside the code the decision is about. Reads the history, so ' +
393
+ "an earlier session's consultation can be exported.",
299
394
  inputSchema: z.object({
300
395
  chain_id: z.string().min(1).optional(),
301
396
  group_id: z.string().min(1).optional(),
@@ -306,7 +401,7 @@ export function createServer(manager = new JobManager()) {
306
401
  return ok(exportChain({ chain_id, group_id }));
307
402
  } catch (err) {
308
403
  if (err instanceof ExportError) return fail({ error: err.code, message: err.message });
309
- return fail({ error: 'internal_error', message: String(err?.message ?? err) });
404
+ return fail({ error: 'internal_error', message: errorMessage(err) });
310
405
  }
311
406
  },
312
407
  );
@@ -317,16 +412,17 @@ export function createServer(manager = new JobManager()) {
317
412
  title: 'List recent consultations',
318
413
  annotations: { readOnlyHint: true, openWorldHint: false },
319
414
  description:
320
- 'Recent consultations, earlier sessions\' too (this_session is false), newest first, with their ids, '
321
- + 'status, summary and verdicts; and usage_totals, the tokens (and cost) each consultant reported this session, '
322
- + 'retries included. A figure a CLI does not report is named under not_reported, not counted as zero.',
415
+ "Recent consultations, earlier sessions' too (this_session is false), newest first, with their ids, " +
416
+ 'status, summary and verdicts; and usage_totals, the tokens (and cost) each consultant reported this session, ' +
417
+ 'retries included. A figure a CLI does not report is named under not_reported, not counted as zero.',
323
418
  inputSchema: z.object({ limit: z.number().int().min(1).max(100).optional() }),
324
419
  },
325
- async ({ limit }) => ok({
326
- jobs: manager.list({ limit: limit ?? 20 }),
327
- usage_totals: manager.usageTotals(),
328
- limits: limitsSummary(),
329
- }),
420
+ async ({ limit }) =>
421
+ ok({
422
+ jobs: manager.list({ limit: limit ?? 20 }),
423
+ usage_totals: manager.usageTotals(),
424
+ limits: limitsSummary(),
425
+ }),
330
426
  );
331
427
 
332
428
  return { server, manager };
@@ -336,17 +432,17 @@ export async function main() {
336
432
  // A config mistake is said here, at start, as well as on every refused
337
433
  // consult_start: the operator may be the only one who reads stderr.
338
434
  const problem = configProblem();
339
- if (problem) process.stderr.write(`severally: config needs fixing; every consultation will be refused until it is -- ${problem}\n`);
340
- for (const w of configWarnings()) process.stderr.write(`severally: config mistake; the consultant or CLI it is in stays unused until it is fixed -- ${w}\n`);
435
+ if (problem)
436
+ process.stderr.write(`severally: config needs fixing; every consultation will be refused until it is -- ${problem}\n`);
437
+ for (const w of configWarnings())
438
+ process.stderr.write(`severally: config mistake; the consultant or CLI it is in stays unused until it is fixed -- ${w}\n`);
341
439
  if (availableConsultants().length === 0) {
342
440
  process.stderr.write(
343
441
  'severally: no consultant CLI found on PATH (codex / claude / agy / opencode); every consultation will be refused\n',
344
442
  );
345
443
  }
346
444
  if (process.env.SEVERALLY_ACTIVE === '1') {
347
- process.stderr.write(
348
- 'severally: refusing to start inside a severally consultant session (recursion barrier)\n',
349
- );
445
+ process.stderr.write('severally: refusing to start inside a severally consultant session (recursion barrier)\n');
350
446
  process.exit(2);
351
447
  }
352
448
  // One manager for the process; serveStdio builds the server once the client's
@@ -358,6 +454,7 @@ export async function main() {
358
454
  manager.sweepInterrupted();
359
455
 
360
456
  let closing = false;
457
+ /** @param {string} signal */
361
458
  const bye = async (signal) => {
362
459
  if (closing) return;
363
460
  closing = true;
package/src/status.mjs CHANGED
@@ -1,23 +1,54 @@
1
1
  // `severally-mcp status`: what this machine has set up, read without starting a
2
- // consultation, so it spends no quota. It says what it can see and no more: a
3
- // CLI being registered and installed does not show it is logged in, and that is
4
- // reported as not checked rather than as working.
5
- import { POLICY, configProblem, configWarnings, unavailableReason, consultantAvailable, brokenConsultantNames, brokenConsultant } from './policy.mjs';
2
+ // consultation, so it spends no quota. It says what it can see and no more:
3
+ // login is read from each CLI's own status command where it has one, and where
4
+ // it has none it is reported as not checked rather than as working.
5
+ import {
6
+ POLICY,
7
+ configProblem,
8
+ configWarnings,
9
+ unavailableReason,
10
+ consultantAvailable,
11
+ brokenConsultantNames,
12
+ brokenConsultant,
13
+ } from './policy.mjs';
6
14
  import { SEVERALLY_VERSION, staleInstall } from './server.mjs';
7
15
  import { findCommand, execCommandSync } from './platform.mjs';
8
16
 
9
17
  // How each client lists severally, plugin installs first. A command that exits 0
10
18
  // and names severally counts as registered.
11
19
  const CLIENTS = [
12
- { label: 'Claude Code', cmd: 'claude', checks: [['plugin', 'list'], ['mcp', 'get', 'severally']] },
13
- { label: 'Codex', cmd: 'codex', checks: [['plugin', 'list'], ['mcp', 'get', 'severally']] },
20
+ {
21
+ label: 'Claude Code',
22
+ cmd: 'claude',
23
+ checks: [
24
+ ['plugin', 'list'],
25
+ ['mcp', 'get', 'severally'],
26
+ ],
27
+ },
28
+ {
29
+ label: 'Codex',
30
+ cmd: 'codex',
31
+ checks: [
32
+ ['plugin', 'list'],
33
+ ['mcp', 'get', 'severally'],
34
+ ],
35
+ },
14
36
  { label: 'Antigravity', cmd: 'agy', checks: [['mcp', 'list']] },
15
37
  { label: 'OpenCode', cmd: 'opencode', checks: [['mcp', 'list']] },
16
38
  ];
17
39
 
40
+ /**
41
+ * A field of whatever was thrown, or undefined when it carries none.
42
+ * @param {unknown} e
43
+ * @param {string} key
44
+ * @returns {unknown}
45
+ */
46
+ const thrown = (e, key) => (typeof e === 'object' && e !== null ? Reflect.get(e, key) : undefined);
47
+
18
48
  // "registered": the client's own listing names severally (it may still be
19
49
  // turned off there). "not registered": the client answered, without it.
20
50
  // No answer at all (a timeout, a failure to start): unknown.
51
+ /** @param {{ cmd: string, checks: string[][] }} client */
21
52
  function registration({ cmd, checks }) {
22
53
  if (!findCommand(cmd)) return 'not installed';
23
54
  let ran = false;
@@ -29,12 +60,55 @@ function registration({ cmd, checks }) {
29
60
  } catch (err) {
30
61
  // It ran and said no (`mcp get` of a missing server exits non-zero); a
31
62
  // timeout or a failure to start says nothing either way.
32
- if (typeof err.status === 'number') ran = true;
63
+ if (typeof thrown(err, 'status') === 'number') ran = true;
33
64
  }
34
65
  }
35
66
  return ran ? 'not registered' : `could not tell (${cmd} ${checks.map((a) => a.join(' ')).join(' / ')} failed)`;
36
67
  }
37
68
 
69
+ // Each CLI's own word on its login, read without sending a prompt. Only the
70
+ // verdict is kept: `claude auth status` also prints the account's email.
71
+ // Antigravity has no such command, so it is not checked.
72
+ /** @type {Record<string, { cmd: string, args: string[], read: (out: string, ok: boolean) => boolean | string }>} */
73
+ const LOGIN = {
74
+ 'claude-code': { cmd: 'claude', args: ['auth', 'status', '--json'], read: (out) => JSON.parse(out).loggedIn === true },
75
+ // Codex prints its answer on stderr; the exit status carries the verdict.
76
+ codex: { cmd: 'codex', args: ['login', 'status'], read: (out, ok) => ok },
77
+ // OpenCode can also take keys from the environment, so no saved credential
78
+ // is a warning, not a verdict.
79
+ opencode: {
80
+ cmd: 'opencode',
81
+ args: ['auth', 'list'],
82
+ read: (out) => {
83
+ const n = out.split('\n').filter((l) => /\bstored\b/.test(l)).length;
84
+ return n ? `${n} saved credential${n > 1 ? 's' : ''}` : 'no saved credentials (keys in the environment still work)';
85
+ },
86
+ },
87
+ };
88
+
89
+ /** @param {string} cli */
90
+ function login(cli) {
91
+ const check = LOGIN[cli];
92
+ if (!check) return 'not checked (no login status command)';
93
+ if (!findCommand(check.cmd)) return 'not installed';
94
+ /** @type {string} */
95
+ let out;
96
+ let ok = true;
97
+ try {
98
+ out = execCommandSync(check.cmd, check.args, { encoding: 'utf8', timeout: 20_000, stdio: ['ignore', 'pipe', 'pipe'] });
99
+ } catch (e) {
100
+ if (typeof thrown(e, 'status') !== 'number') return `could not tell (${check.cmd} ${check.args.join(' ')} failed)`;
101
+ out = `${thrown(e, 'stdout') ?? ''}`;
102
+ ok = false;
103
+ }
104
+ try {
105
+ const v = check.read(out, ok);
106
+ return v === true ? 'logged in' : v || 'not logged in';
107
+ } catch {
108
+ return `could not tell (${check.cmd} ${check.args.join(' ')} answered in an unexpected form)`;
109
+ }
110
+ }
111
+
38
112
  export function status() {
39
113
  const lines = [`severally ${SEVERALLY_VERSION}`, ''];
40
114
  const problem = configProblem();
@@ -44,20 +118,27 @@ export function status() {
44
118
 
45
119
  lines.push('consultants:');
46
120
  for (const c of POLICY.consultants) {
47
- lines.push(` ${c.name}: ${c.cli}, ${c.model} ${consultantAvailable(c) ? 'available' : `not available: ${unavailableReason(c)}`}`);
121
+ lines.push(
122
+ ` ${c.name}: ${c.cli}, ${c.model} ${consultantAvailable(c) ? 'available' : `not available: ${unavailableReason(c)}`}`,
123
+ );
48
124
  }
49
125
  for (const name of brokenConsultantNames()) lines.push(` ${name}: not available: ${brokenConsultant(name)}`);
50
126
  lines.push('');
51
127
 
52
128
  const stale = staleInstall();
53
- lines.push(stale
54
- ? `copies: ${Object.entries(stale.copies).map(([k, v]) => `${k} ${v}${stale.behind.includes(k) ? ' (behind)' : ''}`).join(', ')}\n ${stale.fix}`
55
- : 'copies: every installed copy is this version', '');
129
+ lines.push(
130
+ stale
131
+ ? `copies: ${Object.entries(stale.copies)
132
+ .map(([k, v]) => `${k} ${v}${stale.behind.includes(k) ? ' (behind)' : ''}`)
133
+ .join(', ')}\n ${stale.fix}`
134
+ : 'copies: every installed copy is this version',
135
+ '',
136
+ );
56
137
 
57
138
  lines.push('clients:');
58
139
  for (const client of CLIENTS) lines.push(` ${client.label.padEnd(12)} ${registration(client)}`);
59
- lines.push('', 'login: not checked -- an installed, registered CLI may still be logged out or out of quota; '
60
- + 'the first consultation is what shows it');
140
+ lines.push('', 'login (out of quota is not checked; the first consultation shows it):');
141
+ for (const cli of [...new Set(POLICY.consultants.map((c) => c.cli))]) lines.push(` ${cli.padEnd(12)} ${login(cli)}`);
61
142
  process.stdout.write(`${lines.join('\n')}\n`);
62
143
  return problem ? 1 : 0;
63
144
  }