severally-mcp 2.3.1 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/schema.mjs CHANGED
@@ -5,8 +5,16 @@
5
5
  import { z } from 'zod';
6
6
  import path from 'node:path';
7
7
  import {
8
- POLICY, CALLER_INPUTS, MODES, STANCES, artifactKinds,
9
- resolveCaller, availableConsultants, consultantAvailable, unavailableReason, brokenConsultant,
8
+ POLICY,
9
+ CALLER_INPUTS,
10
+ MODES,
11
+ STANCES,
12
+ artifactKinds,
13
+ resolveCaller,
14
+ availableConsultants,
15
+ consultantAvailable,
16
+ unavailableReason,
17
+ brokenConsultant,
10
18
  } from './policy.mjs';
11
19
  import { normalizeName, resolveConsultant } from './consultants.mjs';
12
20
 
@@ -36,7 +44,11 @@ const exposedPathSchema = z
36
44
  .refine((p) => path.isAbsolute(p), { message: 'context.expose_paths[] must be an absolute path' });
37
45
 
38
46
  // A revision for `git diff`: a ref, a commit, or HEAD~n -- never an option.
39
- const gitRevision = z.string().trim().min(1).max(200)
47
+ const gitRevision = z
48
+ .string()
49
+ .trim()
50
+ .min(1)
51
+ .max(200)
40
52
  .regex(/^[A-Za-z0-9._/~^@{}-]+$/, 'context.expose_diff.base must be a git revision (a branch, tag, commit or HEAD~n)')
41
53
  .refine((r) => !r.startsWith('-'), { message: 'context.expose_diff.base must not start with "-"' });
42
54
 
@@ -65,7 +77,11 @@ export const contextSchema = z
65
77
  // for it, its CLI, its vendor, or its model ("glm", "claude opus"). Which
66
78
  // consultant that is depends on the operator's config, so it is resolved in
67
79
  // parseRequest, not by an enum here.
68
- const targetSpec = z.string().trim().min(1, 'target must not be empty').max(120, 'target exceeds 120 characters')
80
+ const targetSpec = z
81
+ .string()
82
+ .trim()
83
+ .min(1, 'target must not be empty')
84
+ .max(120, 'target exceeds 120 characters')
69
85
  .regex(/^[^\p{Cc}\p{Cf}]+$/u, 'target must be a single line of printable characters');
70
86
 
71
87
  // Who asked for a consultation, as the lead reports it.
@@ -159,6 +175,8 @@ export function parseRequest(raw, { isFollowup = false, parentMode = null, group
159
175
  const issues = parsed.error.issues.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`);
160
176
  throw new RequestError(`request failed validation: ${issues.join('; ')}`, 'invalid_request', issues);
161
177
  }
178
+ // Filled in below with what the request resolves to (targets, caller, ...).
179
+ /** @type {Record<string, any>} */
162
180
  const req = parsed.data;
163
181
  // Left without a default in the schema, so that a follow-up can tell a list
164
182
  // it left out (inherited from the round before, jobs.mjs) from one it
@@ -180,8 +198,10 @@ export function parseRequest(raw, { isFollowup = false, parentMode = null, group
180
198
  if (hasSingle && hasMany) {
181
199
  throw new RequestError('pass either target (one consultant) or targets (several), not both', 'invalid_request');
182
200
  }
183
- const listed = () => availableConsultants().map((c) => `${c.name} (${c.cli}, ${c.model})`).join(', ')
184
- || 'none -- no consultant CLI is available here';
201
+ const listed = () =>
202
+ availableConsultants()
203
+ .map((c) => `${c.name} (${c.cli}, ${c.model})`)
204
+ .join(', ') || 'none -- no consultant CLI is available here';
185
205
  if (!hasSingle && !hasMany) {
186
206
  throw new RequestError(
187
207
  `name the consultant: target: a consultant, its CLI or its model (available: ${listed()}), or targets: [...] to ask several the same question, targets: ["all"] to ask everyone`,
@@ -198,7 +218,10 @@ export function parseRequest(raw, { isFollowup = false, parentMode = null, group
198
218
  if (isAll[0]) {
199
219
  chosen = availableConsultants();
200
220
  if (!chosen.length) {
201
- throw new RequestError('no consultant is available on this machine -- install one of the CLIs, or check the config file', 'target_unavailable');
221
+ throw new RequestError(
222
+ 'no consultant is available on this machine -- install one of the CLIs, or check the config file',
223
+ 'target_unavailable',
224
+ );
202
225
  }
203
226
  } else {
204
227
  // Resolved among the reachable consultants first, so one that is off does
@@ -211,15 +234,18 @@ export function parseRequest(raw, { isFollowup = false, parentMode = null, group
211
234
  let r = resolveConsultant(name, reachable, POLICY.ranking);
212
235
  if (r.none) r = resolveConsultant(name, POLICY.consultants, POLICY.ranking);
213
236
  if (r.none && brokenConsultant(name)) {
214
- throw new RequestError(`consultant ${JSON.stringify(name)} is not available: ${brokenConsultant(name)}; available: ${listed()}`, 'target_unavailable');
237
+ throw new RequestError(
238
+ `consultant ${JSON.stringify(name)} is not available: ${brokenConsultant(name)}; available: ${listed()}`,
239
+ 'target_unavailable',
240
+ );
215
241
  }
216
242
  if (r.none) {
217
243
  throw new RequestError(`no consultant answers to ${JSON.stringify(name)}; available: ${listed()}`, 'unknown_target');
218
244
  }
219
245
  if (r.ambiguous) {
220
246
  throw new RequestError(
221
- `${JSON.stringify(name)} fits more than one consultant and no ranking rule decides between `
222
- + `${r.ambiguous.map((c) => `${c.name} (${c.cli}, ${c.model})`).join(', ')}; name one of them`,
247
+ `${JSON.stringify(name)} fits more than one consultant and no ranking rule decides between ` +
248
+ `${r.ambiguous.map((c) => `${c.name} (${c.cli}, ${c.model})`).join(', ')}; name one of them`,
223
249
  'target_ambiguous',
224
250
  );
225
251
  }
@@ -228,8 +254,8 @@ export function parseRequest(raw, { isFollowup = false, parentMode = null, group
228
254
  const off = [...new Set(chosen.filter((c) => !consultantAvailable(c)))];
229
255
  if (off.length) {
230
256
  throw new RequestError(
231
- `consultant ${off.map((c) => `${JSON.stringify(c.name)} (${unavailableReason(c)})`).join(', ')} `
232
- + `is not available on this machine; available: ${listed()}`,
257
+ `consultant ${off.map((c) => `${JSON.stringify(c.name)} (${unavailableReason(c)})`).join(', ')} ` +
258
+ `is not available on this machine; available: ${listed()}`,
233
259
  'target_unavailable',
234
260
  );
235
261
  }
@@ -261,8 +287,7 @@ export function parseRequest(raw, { isFollowup = false, parentMode = null, group
261
287
  // one its context fits -- no proposal is explore, a proposal is review, a
262
288
  // proposal with the other side's claims is debate. Checked below like a
263
289
  // mode that was given.
264
- req.mode ??= parentMode
265
- ?? (!req.context.proposal ? 'explore' : req.context.counterpoints.length ? 'debate' : 'review');
290
+ req.mode ??= parentMode ?? (!req.context.proposal ? 'explore' : req.context.counterpoints.length ? 'debate' : 'review');
266
291
 
267
292
  const total = charCount(req);
268
293
  if (total > L.totalCharsMax) {
@@ -296,11 +321,18 @@ export function parseRequest(raw, { isFollowup = false, parentMode = null, group
296
321
  'expose_from_parent_needs_followup',
297
322
  );
298
323
  }
299
- if (req.context.facts.length === 0 && req.context.artifacts.length === 0 && req.context.expose_paths.length === 0
300
- && !req.context.expose_diff && !req.context.expose_from_parent) {
324
+ if (
325
+ req.context.facts.length === 0 &&
326
+ req.context.artifacts.length === 0 &&
327
+ req.context.expose_paths.length === 0 &&
328
+ !req.context.expose_diff &&
329
+ !req.context.expose_from_parent
330
+ ) {
301
331
  throw new RequestError(
302
- 'provide at least one entry in context.facts, context.artifacts or context.expose_paths: the consultant starts in an empty working directory and is not told where your repository is, so every fact it needs must be in the request'
303
- + (isFollowup ? '; on a follow-up, context.expose_from_parent: true copies again the files the earlier round was shown' : ''),
332
+ 'provide at least one entry in context.facts, context.artifacts or context.expose_paths: the consultant starts in an empty working directory and is not told where your repository is, so every fact it needs must be in the request' +
333
+ (isFollowup
334
+ ? '; on a follow-up, context.expose_from_parent: true copies again the files the earlier round was shown'
335
+ : ''),
304
336
  'context_required',
305
337
  );
306
338
  }
package/src/server.mjs CHANGED
@@ -9,7 +9,7 @@ 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
+ export const SEVERALLY_VERSION = '2.4.0';
13
13
 
14
14
  // consult_get waits this long when no wait_ms is given: a call that forgot it
15
15
  // would otherwise return at once and be repeated. Up to 45 s, under the 60 s
@@ -22,7 +22,11 @@ const DEFAULT_WAIT_MS = process.env.SEVERALLY_MAX_WAIT_MS ? POLICY.maxWaitMs : M
22
22
  const PROGRESS_EVERY_MS = 15_000;
23
23
 
24
24
  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; };
25
+ const older = (a, b) => {
26
+ const [x, y] = [semver(a), semver(b)];
27
+ for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] < y[i];
28
+ return false;
29
+ };
26
30
 
27
31
  /**
28
32
  * The other installed copies of severally, when one runs a different version
@@ -31,7 +35,13 @@ const older = (a, b) => { const [x, y] = [semver(a), semver(b)]; for (let i = 0;
31
35
  * version strings are compared. A copy that is missing is not reported.
32
36
  */
33
37
  export function staleInstall(home = os.homedir(), severallyHome = POLICY.home) {
34
- const read = (file) => { try { return fs.readFileSync(file, 'utf8'); } catch { return null; } };
38
+ const read = (file) => {
39
+ try {
40
+ return fs.readFileSync(file, 'utf8');
41
+ } catch {
42
+ return null;
43
+ }
44
+ };
35
45
  // The runtime's version is read from its bundle, which every install mode
36
46
  // replaces; its package.json is written by plugin installs only.
37
47
  const bundle = read(path.join(severallyHome, 'runtime', 'severally-mcp.mjs'));
@@ -39,7 +49,13 @@ export function staleInstall(home = os.homedir(), severallyHome = POLICY.home) {
39
49
  const found = {
40
50
  // This release names it SEVERALLY_VERSION; older ones only had it in the server info.
41
51
  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; } })(),
52
+ claude_skills_dir: (() => {
53
+ try {
54
+ return JSON.parse(plugin).version;
55
+ } catch {
56
+ return undefined;
57
+ }
58
+ })(),
43
59
  };
44
60
  const copies = { this_server: SEVERALLY_VERSION };
45
61
  // Only plain x.y.z is compared; anything else is left out rather than guessed at.
@@ -50,9 +66,10 @@ export function staleInstall(home = os.homedir(), severallyHome = POLICY.home) {
50
66
  return {
51
67
  copies,
52
68
  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',
69
+ fix:
70
+ `bring every copy to ${newest}: run \`npx -y severally-mcp@${newest} install\` (or \`node scripts/install.mjs\` from a checkout of that version) ` +
71
+ '(it updates the runtime and the ~/.claude/skills copy), run `/plugin marketplace update severally` for a ' +
72
+ 'marketplace install, then restart the clients',
56
73
  };
57
74
  }
58
75
 
@@ -73,7 +90,11 @@ check the grounds behind a point before you adopt it, and record what you adopte
73
90
  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
91
 
75
92
  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)'}
93
+ ${
94
+ availableConsultants()
95
+ .map((c) => ` ${c.name}: ${c.cli}, ${c.model}`)
96
+ .join('\n') || ' (none -- no consultant CLI is installed)'
97
+ }
77
98
  Pass the word the user used: a name, a CLI ("codex", "agy"), a vendor or a model ("claude opus"). One
78
99
  that fits several, unresolved by the operator's ranking, tier and version, is refused with the
79
100
  candidates (target_ambiguous); one not listed is refused (target_unavailable). Your own CLI on a fresh
@@ -107,29 +128,38 @@ Model, permissions, rounds, timeout and size caps are fixed by this server.`;
107
128
  // element of its `records`.
108
129
  const recordShape = {
109
130
  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'),
131
+ entries: z
132
+ .array(
133
+ z.object({
134
+ id: z.string().min(1).describe('f1, u1, c1 ... from the result'),
135
+ verdict: z.enum(VERDICTS),
136
+ effect: z.string().max(4000).optional().describe('what it changed, or why it is unverified'),
137
+ note: z.string().max(4000).optional().describe('what you checked and found'),
138
+ }),
139
+ )
140
+ .min(1)
141
+ .max(100)
142
+ .optional(),
143
+ reflection: z
144
+ .object({
145
+ delta: z.string().min(1).max(4000).describe('what the answer added over what you expected ("nothing new" counts)'),
146
+ related_item_ids: z.array(z.string().min(1)).max(50).optional().describe('f1, u1, c1 ...'),
147
+ })
148
+ .optional()
149
+ .describe('written after reading the answer'),
120
150
  };
121
151
 
122
152
  export function createServer(manager = new JobManager()) {
123
153
  // Read once: the copies on disk change only when someone reinstalls, and then
124
154
  // this server is the one to restart.
125
155
  const stale = staleInstall();
126
- const server = new McpServer(
127
- { name: 'severally', version: SEVERALLY_VERSION },
128
- { instructions: SERVER_INSTRUCTIONS },
129
- );
156
+ const server = new McpServer({ name: 'severally', version: SEVERALLY_VERSION }, { instructions: SERVER_INSTRUCTIONS });
130
157
 
131
- 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 });
158
+ const ok = (payload) => ({ content: [{ type: /** @type {const} */ ('text'), text: JSON.stringify(payload, null, 2) }] });
159
+ const fail = (payload) => ({
160
+ content: [{ type: /** @type {const} */ ('text'), text: JSON.stringify(payload, null, 2) }],
161
+ isError: true,
162
+ });
133
163
 
134
164
  server.registerTool(
135
165
  'consult_start',
@@ -164,7 +194,7 @@ export function createServer(manager = new JobManager()) {
164
194
  'again while status is running. completed carries the structured answer; failed carries ' +
165
195
  'failure.kind (timeout, auth, usage_limit, rate_limited, model_unavailable, invalid_output, cli_error, ' +
166
196
  '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 ' +
197
+ "(quality.evidence_basis, quality.caveat). group_id fetches a fan-out: each member's status, failure and " +
168
198
  'job_id, and `comparison.by_target` with every stance, summary and finding id side by side, cut where ' +
169
199
  '`truncated` says so. ids: ["f2", "u1"] with a job_id returns just those points, whole, with their grounds ' +
170
200
  'and your verdict (`recorded`). A finished consultation names its brief rather than repeating it; detail: ' +
@@ -174,8 +204,14 @@ export function createServer(manager = new JobManager()) {
174
204
  group_id: z.string().min(1).optional(),
175
205
  wait_ms: z.number().int().min(0).max(POLICY.maxWaitMs).optional(),
176
206
  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'),
207
+ ids: z
208
+ .array(z.string().min(1))
209
+ .min(1)
210
+ .max(100)
211
+ .optional()
212
+ .describe(
213
+ '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',
214
+ ),
179
215
  }),
180
216
  },
181
217
  async ({ job_id, group_id, wait_ms, detail, ids }, ctx) => {
@@ -183,16 +219,26 @@ export function createServer(manager = new JobManager()) {
183
219
  return fail({ error: 'invalid_request', message: 'pass exactly one of job_id or group_id' });
184
220
  }
185
221
  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' });
222
+ return fail({
223
+ error: 'invalid_request',
224
+ message: "ids goes with job_id alone: a point id belongs to one consultant's answer",
225
+ });
187
226
  }
188
227
  const req = ctx?.mcpReq;
189
228
  const token = req?._meta?.progressToken;
190
229
  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);
230
+ const timer =
231
+ token === undefined
232
+ ? null
233
+ : setInterval(() => {
234
+ beats += 1;
235
+ req
236
+ .notify({
237
+ method: 'notifications/progress',
238
+ params: { progressToken: token, progress: beats, message: 'the consultation is still running' },
239
+ })
240
+ .catch(() => {});
241
+ }, PROGRESS_EVERY_MS);
196
242
  let view;
197
243
  try {
198
244
  // A request the client cancels stops the wait with it.
@@ -203,7 +249,10 @@ export function createServer(manager = new JobManager()) {
203
249
  clearInterval(timer);
204
250
  }
205
251
  if (!view) {
206
- return fail({ error: 'unknown_job', message: `no consultation with ${group_id ? 'group_id' : 'job_id'} "${group_id ?? job_id}"` });
252
+ return fail({
253
+ error: 'unknown_job',
254
+ message: `no consultation with ${group_id ? 'group_id' : 'job_id'} "${group_id ?? job_id}"`,
255
+ });
207
256
  }
208
257
  // Still running: the poll answer as it is; ids applies once there is an answer.
209
258
  if (!ids || ['running', 'queued', 'cancelling'].includes(view.status)) return ok(view);
@@ -223,8 +272,8 @@ export function createServer(manager = new JobManager()) {
223
272
  annotations: { readOnlyHint: false, idempotentHint: true, openWorldHint: false },
224
273
  description:
225
274
  '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.',
275
+ 'Pass group_id instead of job_id to stop every consultant in a fan-out. One another severally server runs ' +
276
+ 'is asked to stop, and that server stops it within a second.',
228
277
  inputSchema: z.object({
229
278
  job_id: z.string().min(1).optional(),
230
279
  group_id: z.string().min(1).optional(),
@@ -236,7 +285,10 @@ export function createServer(manager = new JobManager()) {
236
285
  }
237
286
  const view = group_id ? manager.cancelGroup(group_id) : manager.cancel(job_id);
238
287
  if (!view) {
239
- return fail({ error: 'unknown_job', message: `no consultation with ${group_id ? 'group_id' : 'job_id'} "${group_id ?? job_id}"` });
288
+ return fail({
289
+ error: 'unknown_job',
290
+ message: `no consultation with ${group_id ? 'group_id' : 'job_id'} "${group_id ?? job_id}"`,
291
+ });
240
292
  }
241
293
  return ok(view);
242
294
  },
@@ -248,35 +300,44 @@ export function createServer(manager = new JobManager()) {
248
300
  title: 'Record what checking a point showed',
249
301
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
250
302
  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.',
303
+ 'Write your verdict on points of an answer after checking them: ids f1 ... (findings), u1 ... (unknowns), ' +
304
+ 'c1 ... (next_checks). verdict is what checking showed -- confirmed (holds here), not_applicable (true in ' +
305
+ 'general, not here), unverifiable, unverified (not checked yet; say why in effect) -- not whether you adopted ' +
306
+ 'it. effect is what it changed about your decision, note the evidence. A follow-up shows the consultant each ' +
307
+ 'verdict and effect (credential-masked), never the note. The same id again replaces the entry. Saved beside ' +
308
+ 'the answer in ~/.severally/history, so a job from an earlier session can be recorded against; the server ' +
309
+ 'never infers a verdict. Answers with counts; consult_get reads the entries back. reflection records what the ' +
310
+ 'answer added over your consult_start prediction. records: [{ job_id, entries, reflection? }, ...] records ' +
311
+ 'every member of a fan-out in one call; all are checked first and nothing is written if any job or id is ' +
312
+ 'wrong. A job whose write fails is listed under results with its error.',
261
313
  inputSchema: z.object({
262
314
  ...recordShape,
263
315
  job_id: recordShape.job_id.optional(),
264
- records: z.array(z.object(recordShape)).min(1).max(POLICY.maxConcurrent).optional()
316
+ records: z
317
+ .array(z.object(recordShape))
318
+ .min(1)
319
+ .max(POLICY.maxConcurrent)
320
+ .optional()
265
321
  .describe('one per fan-out member, instead of job_id/entries/reflection'),
266
322
  }),
267
323
  },
268
324
  async ({ job_id, entries, reflection, records }) => {
269
325
  try {
270
326
  if (records !== undefined) {
271
- const out = manager.record(Object.fromEntries(Object.entries({ job_id, entries, reflection, records }).filter(([, v]) => v !== undefined)));
327
+ const out = manager.record(
328
+ Object.fromEntries(Object.entries({ job_id, entries, reflection, records }).filter(([, v]) => v !== undefined)),
329
+ );
272
330
  return out.not_saved ? fail(out) : ok(out);
273
331
  }
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
- }));
332
+ if (!job_id)
333
+ return fail({ error: 'invalid_request', message: 'pass job_id (with entries and/or reflection), or records' });
334
+ return ok(
335
+ manager.record({
336
+ job_id,
337
+ ...(entries === undefined ? {} : { entries }),
338
+ ...(reflection === undefined ? {} : { reflection }),
339
+ }),
340
+ );
280
341
  } catch (err) {
281
342
  if (err instanceof RequestError) {
282
343
  return fail({ error: err.code, message: err.message, details: err.details ?? null });
@@ -292,10 +353,10 @@ export function createServer(manager = new JobManager()) {
292
353
  title: 'Export a consultation as a record to commit',
293
354
  annotations: { readOnlyHint: true, openWorldHint: false },
294
355
  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.',
356
+ 'Render one consultation (chain_id) or fan-out (group_id) as Markdown: the brief as sent, each answer as ' +
357
+ 'it came back, and the verdicts on each point, unchecked ones marked. Nothing is summarised or scored. ' +
358
+ 'The text is returned, not written: put it beside the code the decision is about. Reads the history, so ' +
359
+ "an earlier session's consultation can be exported.",
299
360
  inputSchema: z.object({
300
361
  chain_id: z.string().min(1).optional(),
301
362
  group_id: z.string().min(1).optional(),
@@ -317,16 +378,17 @@ export function createServer(manager = new JobManager()) {
317
378
  title: 'List recent consultations',
318
379
  annotations: { readOnlyHint: true, openWorldHint: false },
319
380
  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.',
381
+ "Recent consultations, earlier sessions' too (this_session is false), newest first, with their ids, " +
382
+ 'status, summary and verdicts; and usage_totals, the tokens (and cost) each consultant reported this session, ' +
383
+ 'retries included. A figure a CLI does not report is named under not_reported, not counted as zero.',
323
384
  inputSchema: z.object({ limit: z.number().int().min(1).max(100).optional() }),
324
385
  },
325
- async ({ limit }) => ok({
326
- jobs: manager.list({ limit: limit ?? 20 }),
327
- usage_totals: manager.usageTotals(),
328
- limits: limitsSummary(),
329
- }),
386
+ async ({ limit }) =>
387
+ ok({
388
+ jobs: manager.list({ limit: limit ?? 20 }),
389
+ usage_totals: manager.usageTotals(),
390
+ limits: limitsSummary(),
391
+ }),
330
392
  );
331
393
 
332
394
  return { server, manager };
@@ -336,17 +398,17 @@ export async function main() {
336
398
  // A config mistake is said here, at start, as well as on every refused
337
399
  // consult_start: the operator may be the only one who reads stderr.
338
400
  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`);
401
+ if (problem)
402
+ process.stderr.write(`severally: config needs fixing; every consultation will be refused until it is -- ${problem}\n`);
403
+ for (const w of configWarnings())
404
+ process.stderr.write(`severally: config mistake; the consultant or CLI it is in stays unused until it is fixed -- ${w}\n`);
341
405
  if (availableConsultants().length === 0) {
342
406
  process.stderr.write(
343
407
  'severally: no consultant CLI found on PATH (codex / claude / agy / opencode); every consultation will be refused\n',
344
408
  );
345
409
  }
346
410
  if (process.env.SEVERALLY_ACTIVE === '1') {
347
- process.stderr.write(
348
- 'severally: refusing to start inside a severally consultant session (recursion barrier)\n',
349
- );
411
+ process.stderr.write('severally: refusing to start inside a severally consultant session (recursion barrier)\n');
350
412
  process.exit(2);
351
413
  }
352
414
  // One manager for the process; serveStdio builds the server once the client's
package/src/status.mjs CHANGED
@@ -1,16 +1,38 @@
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
  ];
@@ -35,6 +57,46 @@ function registration({ cmd, checks }) {
35
57
  return ran ? 'not registered' : `could not tell (${cmd} ${checks.map((a) => a.join(' ')).join(' / ')} failed)`;
36
58
  }
37
59
 
60
+ // Each CLI's own word on its login, read without sending a prompt. Only the
61
+ // verdict is kept: `claude auth status` also prints the account's email.
62
+ // Antigravity has no such command, so it is not checked.
63
+ const LOGIN = {
64
+ 'claude-code': { cmd: 'claude', args: ['auth', 'status', '--json'], read: (out) => JSON.parse(out).loggedIn === true },
65
+ // Codex prints its answer on stderr; the exit status carries the verdict.
66
+ codex: { cmd: 'codex', args: ['login', 'status'], read: (out, ok) => ok },
67
+ // OpenCode can also take keys from the environment, so no saved credential
68
+ // is a warning, not a verdict.
69
+ opencode: {
70
+ cmd: 'opencode',
71
+ args: ['auth', 'list'],
72
+ read: (out) => {
73
+ const n = out.split('\n').filter((l) => /\bstored\b/.test(l)).length;
74
+ return n ? `${n} saved credential${n > 1 ? 's' : ''}` : 'no saved credentials (keys in the environment still work)';
75
+ },
76
+ },
77
+ };
78
+
79
+ function login(cli) {
80
+ const check = LOGIN[cli];
81
+ if (!check) return 'not checked (no login status command)';
82
+ if (!findCommand(check.cmd)) return 'not installed';
83
+ let out;
84
+ let ok = true;
85
+ try {
86
+ out = execCommandSync(check.cmd, check.args, { encoding: 'utf8', timeout: 20_000, stdio: ['ignore', 'pipe', 'pipe'] });
87
+ } catch (err) {
88
+ if (typeof err.status !== 'number') return `could not tell (${check.cmd} ${check.args.join(' ')} failed)`;
89
+ out = `${err.stdout ?? ''}`;
90
+ ok = false;
91
+ }
92
+ try {
93
+ const v = check.read(out, ok);
94
+ return v === true ? 'logged in' : v || 'not logged in';
95
+ } catch {
96
+ return `could not tell (${check.cmd} ${check.args.join(' ')} answered in an unexpected form)`;
97
+ }
98
+ }
99
+
38
100
  export function status() {
39
101
  const lines = [`severally ${SEVERALLY_VERSION}`, ''];
40
102
  const problem = configProblem();
@@ -44,20 +106,27 @@ export function status() {
44
106
 
45
107
  lines.push('consultants:');
46
108
  for (const c of POLICY.consultants) {
47
- lines.push(` ${c.name}: ${c.cli}, ${c.model} ${consultantAvailable(c) ? 'available' : `not available: ${unavailableReason(c)}`}`);
109
+ lines.push(
110
+ ` ${c.name}: ${c.cli}, ${c.model} ${consultantAvailable(c) ? 'available' : `not available: ${unavailableReason(c)}`}`,
111
+ );
48
112
  }
49
113
  for (const name of brokenConsultantNames()) lines.push(` ${name}: not available: ${brokenConsultant(name)}`);
50
114
  lines.push('');
51
115
 
52
116
  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', '');
117
+ lines.push(
118
+ stale
119
+ ? `copies: ${Object.entries(stale.copies)
120
+ .map(([k, v]) => `${k} ${v}${stale.behind.includes(k) ? ' (behind)' : ''}`)
121
+ .join(', ')}\n ${stale.fix}`
122
+ : 'copies: every installed copy is this version',
123
+ '',
124
+ );
56
125
 
57
126
  lines.push('clients:');
58
127
  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');
128
+ lines.push('', 'login (out of quota is not checked; the first consultation shows it):');
129
+ for (const cli of [...new Set(POLICY.consultants.map((c) => c.cli))]) lines.push(` ${cli.padEnd(12)} ${login(cli)}`);
61
130
  process.stdout.write(`${lines.join('\n')}\n`);
62
131
  return problem ? 1 : 0;
63
132
  }