severally-mcp 2.0.0 → 2.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CODE_OF_CONDUCT.md +83 -0
  2. package/CONFIG.md +13 -7
  3. package/README.ja.md +157 -77
  4. package/README.md +162 -81
  5. package/bin/severally-mcp.mjs +3 -0
  6. package/config.example.json +26 -0
  7. package/docs/how-it-works.ja.md +11 -3
  8. package/docs/how-it-works.md +12 -4
  9. package/docs/install.ja.md +25 -6
  10. package/docs/install.md +30 -7
  11. package/package.json +5 -3
  12. package/plugins/severally/.claude-plugin/plugin.json +1 -1
  13. package/plugins/severally/.codex-plugin/plugin.json +4 -2
  14. package/plugins/severally/.mcp.json +2 -1
  15. package/plugins/severally/dist/severally-mcp.mjs +29639 -28215
  16. package/plugins/severally/skills/_template/SKILL.md.tmpl +19 -23
  17. package/plugins/severally/skills/_template/reference.md.tmpl +22 -10
  18. package/plugins/severally/skills/antigravity/severally/SKILL.md +19 -23
  19. package/plugins/severally/skills/antigravity/severally/reference.md +22 -10
  20. package/plugins/severally/skills/claude/severally/SKILL.md +19 -23
  21. package/plugins/severally/skills/claude/severally/reference.md +22 -10
  22. package/plugins/severally/skills/codex/severally/SKILL.md +19 -23
  23. package/plugins/severally/skills/codex/severally/reference.md +22 -10
  24. package/plugins/severally/skills/opencode/severally/SKILL.md +19 -23
  25. package/plugins/severally/skills/opencode/severally/reference.md +22 -10
  26. package/scripts/init-config.mjs +1 -1
  27. package/scripts/install.mjs +124 -9
  28. package/src/adapters/antigravity-sandbox.mjs +5 -1
  29. package/src/adapters/codex.mjs +17 -6
  30. package/src/brief.mjs +38 -16
  31. package/src/consultants.mjs +31 -10
  32. package/src/expose-paths.mjs +68 -5
  33. package/src/jobs.mjs +294 -56
  34. package/src/policy.mjs +49 -11
  35. package/src/redact.mjs +20 -1
  36. package/src/run.mjs +38 -5
  37. package/src/schema.mjs +35 -12
  38. package/src/server.mjs +96 -120
  39. package/src/status.mjs +63 -0
  40. package/src/store.mjs +44 -1
package/src/server.mjs CHANGED
@@ -4,17 +4,22 @@ import path from 'node:path';
4
4
  import { z } from 'zod';
5
5
  import { McpServer } from '@modelcontextprotocol/server';
6
6
  import { serveStdio } from '@modelcontextprotocol/server/stdio';
7
- import { POLICY, VERDICTS, availableConsultants, configProblem, limitsSummary } from './policy.mjs';
7
+ import { POLICY, VERDICTS, availableConsultants, configProblem, configWarnings, limitsSummary } from './policy.mjs';
8
8
  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.0.0';
12
+ export const SEVERALLY_VERSION = '2.3.1';
13
13
 
14
14
  // consult_get waits this long when no wait_ms is given: a call that forgot it
15
- // would otherwise return at once and be repeated. Never above 45 s, whatever
16
- // max_wait_ms allows, so the default stays under a 60 s client timeout.
17
- const DEFAULT_WAIT_MS = Math.min(POLICY.maxWaitMs, 45_000);
15
+ // would otherwise return at once and be repeated. Up to 45 s, under the 60 s
16
+ // some clients allow a tool call -- unless this client's own registration
17
+ // raised the limit (SEVERALLY_MAX_WAIT_MS), which says the client waits that
18
+ // long. The config file is shared by every client, so it cannot say that.
19
+ const DEFAULT_WAIT_MS = process.env.SEVERALLY_MAX_WAIT_MS ? POLICY.maxWaitMs : Math.min(POLICY.maxWaitMs, 45_000);
20
+ // While consult_get waits, the client hears from it this often: a client that
21
+ // counts silence against the call keeps it open.
22
+ const PROGRESS_EVERY_MS = 15_000;
18
23
 
19
24
  const semver = (v) => String(v).split('.').map(Number);
20
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; };
@@ -65,81 +70,53 @@ Budget is 1 initial round plus at most ${POLICY.maxRounds - 1} follow-ups per ch
65
70
  spent only on the specific points where you and the consultant actually diverge. Agreement is not evidence:
66
71
  check the grounds behind a point before you adopt it, and record what you adopted, rejected or held, and why.`;
67
72
 
68
- const startDescription = `Start a consultation with another agent (or several, via targets). Returns a job_id (or a group_id for several) immediately; the work runs in the background.
73
+ 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.
69
74
 
70
75
  target: which consultant to ask. This machine can reach (name: CLI, model):
71
76
  ${availableConsultants().map((c) => ` ${c.name}: ${c.cli}, ${c.model}`).join('\n') || ' (none -- no consultant CLI is installed)'}
72
- Pass the name the user used: a consultant's name, its CLI ("codex", "agy"), its vendor ("openai") or
73
- its model ("glm", "claude opus", "gpt-6.1"). A name that fits several consultants goes to the
74
- operator's ranking first, then to the higher tier (opus over sonnet) and the newer version; when
75
- nothing separates them it is refused with the candidates -- ask the user which one, do not guess.
76
- A consultant that is not listed above is refused up front, so do not retry it -- say which ones
77
- are available instead.
78
- Consulting your own CLI on a fresh session is encouraged -- it sheds this session's history and
79
- drift -- and the result is marked as same lineage.
80
- targets: ask several consultants the same question at once (mutually exclusive with target, no duplicates).
81
- targets: ["all"] asks every consultant listed above; "all" cannot be mixed with names.
82
- Every member gets the byte-identical brief and one group_id; poll it with consult_get({ group_id }).
83
- A follow-up (followup_to) always names one consultant -- fan-out is never available on a follow-up.
84
- caller: optional -- the CLI you are running in ("codex" / "claude-code" / "antigravity" / "opencode"), so the
85
- server can annotate a same-vendor consultation.
86
- caller_model: optional -- the model you are running on (e.g. "claude-opus-5"), self-declared and never checked.
87
- With it, a same-vendor caveat can say "same lineage, different model" and the record keeps who asked
88
- whom; it never changes which model the consultant runs.
89
- initiator: optional -- "user" when the user asked for this consultation; leave it out when you started it
90
- yourself. Self-declared, never checked, gates nothing; it lets the record tell the two apart.
91
- mode: optional. Left out, it is read from context: no proposal is explore, a proposal is review, a proposal
92
- with counterpoints is debate; a follow-up keeps its chain's mode. To gather opinions without showing
93
- yours, leave context.proposal empty.
94
- explore - hand over objective/constraints/facts and withhold your own preferred solution, to get independent
95
- options, alternative problem framings and blind spots. context.proposal MUST be empty on round 1.
96
- review - hand over your current proposal AND the reasoning behind it, to get weaknesses, counterexamples,
97
- concrete improvements and the conditions under which it holds. context.proposal is required.
98
- debate - hand over the disputed point, your position (context.proposal), the other side's claims
99
- (context.counterpoints) and the extra evidence, to get what would change the judgement, how to test
100
- it, and which disagreements remain. Both are required.
101
-
102
- The consultant starts in an empty working directory and is not told where your repository is, so it sees your
103
- code only if you hand it over in this call. When the question is about code in a repository on this machine,
104
- name the files it is about by absolute path in context.expose_paths: those files and directories are copied
105
- read-only into its working directory (under ./workspace) and are then the only part of your repository it has.
106
- At most 20 entries, 500 files and 5 MB in total; symlinks and protected files are skipped rather than copied,
107
- and text files are credential-masked exactly as the brief is. A follow-up starts in a fresh working directory
108
- with no files: pass context.expose_from_parent: true to copy again, as they are now, the paths the round it follows
109
- exposed (a changed or vanished entry is marked; an earlier expose_diff brings back the files it touched, not the
110
- diff), and add expose_paths for anything new. It is never implied: leave it off when the follow-up does not need them. Put the facts it needs into context.facts, and keep
111
- context.artifacts for what is not a file here (test output, a log); an excerpt there goes alongside the
112
- exposed files, not instead of them. For a change you are making, pass
113
- context.expose_diff: { repo: "<absolute repository path>", base?: "<revision, default HEAD>" } instead or as
114
- well: the diff goes in the brief and every file it touches is copied under ./workspace/repo/ at its repository
115
- path. Model, permissions, round count, timeout and size
116
- caps are fixed by this server and cannot be raised from a request.
117
-
118
- network: optional -- "on" (default) or "off". "off" takes web search and browsing away from every consultant
119
- in this round: use it when the brief holds material that must not leave the machine through a URL.
120
- Whatever the setting, consultants are refused credential and history paths (~/.ssh, cloud keys, other
121
- tools' sessions, severally's own history, .env files) where the CLI can enforce it; each answer's
122
- \`boundary\` says what held for that consultant.
123
-
124
- prediction: optional -- { expected, worry }: the bottom line you expect back and, in one sentence, what you are
125
- most worried about. Stored with the consultation and NEVER sent to the consultant. It can only be
126
- written here, before the consultant runs, so that afterwards you cannot rewrite what you thought
127
- beforehand; consult_record takes the other half (reflection) once you have read the answer.`;
77
+ Pass the word the user used: a name, a CLI ("codex", "agy"), a vendor or a model ("claude opus"). One
78
+ that fits several, unresolved by the operator's ranking, tier and version, is refused with the
79
+ candidates (target_ambiguous); one not listed is refused (target_unavailable). Your own CLI on a fresh
80
+ session is a valid consultant, marked same lineage.
81
+ targets: several consultants, the same question (not with target, no duplicates); targets: ["all"] is every
82
+ consultant listed above. Every member gets the byte-identical brief (a follow-up adds its own earlier rounds) and one group_id, for consult_get
83
+ and consult_cancel. A fan-out over the concurrency cap is refused before any member starts.
84
+ followup_to: the job_id of the answer to continue, or a group_id to ask every member of that fan-out again
85
+ (each on its own chain; a member that cannot take one is skipped and listed). A follow-up needs only
86
+ followup_to and question: the consultant, mode, network, objective, constraints, success_criteria,
87
+ facts, proposal, counterpoints and artifacts are the earlier round's unless you pass them, and one you pass,
88
+ even empty, replaces it. Files are not carried over: context.expose_from_parent: true copies again,
89
+ as they are now, what the earlier round exposed. A job's follow-up cannot change the consultant.
90
+ mode: optional, read from context when left out: no proposal is explore, a proposal is review, a proposal with
91
+ counterpoints is debate. review and debate need context.proposal (your plan and its reasoning); debate
92
+ also needs context.counterpoints (the other side's claims); explore refuses a proposal on round 1.
93
+ context: the consultant starts in an empty working directory and is not told where your repository is, so
94
+ give it at least one of: facts, artifacts (material that is not a file here: test output, a log),
95
+ expose_paths (absolute paths copied read-only under ./workspace; a directory in a git work tree copies
96
+ the files git lists; at most 20 entries, 500 files, 5 MB; symlinks and protected files are skipped,
97
+ text is credential-masked like the brief), expose_diff: { repo, base? } (the diff in the brief, the
98
+ files it touches under ./workspace/repo/).
99
+ network: "on" (default) or "off" (no web search or browsing for any consultant this round). Credential and
100
+ history paths are refused either way where the CLI can enforce it; each answer's \`boundary\` says what held.
101
+ caller, caller_model: the CLI and model you run on (self-declared), for the same-vendor caveat and the record.
102
+ initiator: "user" when the user asked for this; leave it out when you started it yourself.
103
+ prediction: { expected, worry }, kept and never sent; it can only be written here, before the answer.
104
+ Model, permissions, rounds, timeout and size caps are fixed by this server.`;
128
105
 
129
106
  // One consultation's record: the single form of consult_record, and each
130
107
  // element of its `records`.
131
108
  const recordShape = {
132
- job_id: z.string().min(1).describe('the consultation to record against -- the job_id consult_get returned for it'),
109
+ job_id: z.string().min(1).describe('the job_id consult_get returned'),
133
110
  entries: z.array(z.object({
134
- id: z.string().min(1).describe('the point this verdict is about: f1, u1, c1 ... as returned in the result'),
111
+ id: z.string().min(1).describe('f1, u1, c1 ... from the result'),
135
112
  verdict: z.enum(VERDICTS),
136
- effect: z.string().max(4000).optional().describe('what it changed about your decision, or why it is still unverified'),
137
- note: z.string().max(4000).optional().describe('what you checked and what you found'),
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'),
138
115
  })).min(1).max(100).optional(),
139
116
  reflection: z.object({
140
- delta: z.string().min(1).max(4000).describe('what the answer added over what you already expected -- including "nothing new", which is a real outcome'),
141
- related_item_ids: z.array(z.string().min(1)).max(50).optional().describe('the points this is about: f1, u1, c1 ...'),
142
- }).optional().describe('written after you have read the answer. There is deliberately no hit/miss label: a point you predicted can still arrive with the evidence that settles it, and a surprise can still be wrong.'),
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'),
143
120
  };
144
121
 
145
122
  export function createServer(manager = new JobManager()) {
@@ -181,22 +158,17 @@ export function createServer(manager = new JobManager()) {
181
158
  title: 'Get consultation status or result',
182
159
  annotations: { readOnlyHint: true, openWorldHint: false },
183
160
  description:
184
- `Fetch the state of a consultation. It waits up to ${DEFAULT_WAIT_MS} ms for it to finish before ` +
185
- 'answering, which stays under the request timeout MCP clients apply; wait_ms sets another wait, and ' +
186
- 'wait_ms: 0 answers at once. A typical consultation takes one to five minutes, so expect to call this ' +
187
- 'several times, and do something else in between. A completed job carries the structured ' +
188
- 'answer; a failed one carries failure.kind (timeout, auth, usage_limit, rate_limited, model_unavailable, ' +
189
- 'invalid_output, cli_error, spawn_error, interrupted) — that is "no advice was obtained", which is different from advice that arrived ' +
190
- 'with thin evidence (see quality.evidence_basis). ' +
191
- 'Pass group_id instead of job_id to fetch a fan-out; the wait then covers every consultant in it. ' +
192
- 'A fan-out comes back sized to read in one go: each member is its status, failure and job_id, and ' +
193
- '`comparison.by_target` holds every consultant\'s stance, summary and findings (with their ids) side by ' +
194
- 'side, cut to fixed lengths where `truncated` says so. consult_get({ job_id, ids: ["f2", "u1"] }) on a ' +
195
- 'member returns just those points, each whole with its grounds and your verdict (`recorded`): fetch every point you ' +
196
- 'mean to adopt, that decides your call, that rests on thin grounds, or that every consultant assumes -- ' +
197
- 'not only where they diverge. consult_get({ job_id }) returns the whole answer. A finished ' +
198
- 'consultation names its brief (size, sha256, round file) rather than repeating it; detail: "full" ' +
199
- 'returns the brief, and for a fan-out every member\'s full answer.',
161
+ `Fetch a consultation's state or answer. It waits up to ${DEFAULT_WAIT_MS} ms (under this client's tool-call ` +
162
+ 'timeout) for it to finish; wait_ms sets another wait, 0 answers at once. While waiting it sends a progress ' +
163
+ 'notification every 15 s when asked; a cancelled call stops waiting. Most take one to ten minutes: call ' +
164
+ 'again while status is running. completed carries the structured answer; failed carries ' +
165
+ 'failure.kind (timeout, auth, usage_limit, rate_limited, model_unavailable, invalid_output, cli_error, ' +
166
+ '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 ' +
168
+ 'job_id, and `comparison.by_target` with every stance, summary and finding id side by side, cut where ' +
169
+ '`truncated` says so. ids: ["f2", "u1"] with a job_id returns just those points, whole, with their grounds ' +
170
+ 'and your verdict (`recorded`). A finished consultation names its brief rather than repeating it; detail: ' +
171
+ '"full" returns the brief, and for a fan-out every member\'s full answer.',
200
172
  inputSchema: z.object({
201
173
  job_id: z.string().min(1).optional(),
202
174
  group_id: z.string().min(1).optional(),
@@ -206,16 +178,30 @@ export function createServer(manager = new JobManager()) {
206
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
179
  }),
208
180
  },
209
- async ({ job_id, group_id, wait_ms, detail, ids }) => {
181
+ async ({ job_id, group_id, wait_ms, detail, ids }, ctx) => {
210
182
  if (Boolean(job_id) === Boolean(group_id)) {
211
183
  return fail({ error: 'invalid_request', message: 'pass exactly one of job_id or group_id' });
212
184
  }
213
185
  if (ids && (group_id || detail)) {
214
186
  return fail({ error: 'invalid_request', message: 'ids goes with job_id alone: a point id belongs to one consultant\'s answer' });
215
187
  }
216
- const view = group_id
217
- ? await manager.waitGroup(group_id, wait_ms ?? DEFAULT_WAIT_MS, { detail })
218
- : await manager.wait(job_id, wait_ms ?? DEFAULT_WAIT_MS, { detail });
188
+ const req = ctx?.mcpReq;
189
+ const token = req?._meta?.progressToken;
190
+ 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);
196
+ let view;
197
+ try {
198
+ // A request the client cancels stops the wait with it.
199
+ view = group_id
200
+ ? 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);
202
+ } finally {
203
+ clearInterval(timer);
204
+ }
219
205
  if (!view) {
220
206
  return fail({ error: 'unknown_job', message: `no consultation with ${group_id ? 'group_id' : 'job_id'} "${group_id ?? job_id}"` });
221
207
  }
@@ -237,7 +223,8 @@ export function createServer(manager = new JobManager()) {
237
223
  annotations: { readOnlyHint: false, idempotentHint: true, openWorldHint: false },
238
224
  description:
239
225
  'Stop a running consultation and kill the consultant process and everything it spawned. ' +
240
- 'Pass group_id instead of job_id to stop every consultant in a fan-out.',
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.',
241
228
  inputSchema: z.object({
242
229
  job_id: z.string().min(1).optional(),
243
230
  group_id: z.string().min(1).optional(),
@@ -261,30 +248,21 @@ export function createServer(manager = new JobManager()) {
261
248
  title: 'Record what checking a point showed',
262
249
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
263
250
  description:
264
- 'Write your own verdict against one or more points of an answer, after you have checked them in the '
265
- + 'repository. Ids come from the result: findings are f1, f2 ..., unknowns u1 ..., next_checks c1 ... . '
266
- + 'verdict says what checking showed -- "confirmed" (it holds here), "not_applicable" (true in general, '
267
- + 'not for this codebase), "unverifiable" (cannot be settled with what you can reach), "unverified" (not '
268
- + 'checked yet, and say in effect why not). It does not say whether you adopted the point. effect is what '
269
- + 'it changed about your decision; note is the evidence you used. A follow-up to this job shows the '
270
- + 'consultant each finding\'s verdict and effect (credential-masked, like the brief), never the note. '
271
- + 'Recording the same id again replaces '
272
- + 'that entry. This server stores what you write and counts the verdicts; it never infers one, and never '
273
- + 'decides a consultation was worth it. The entry is saved beside the answer and the brief in '
274
- + '~/.severally/history, which is what makes the decision readable a month from now; the job is '
275
- + 'read back from that history, so a consultation from an earlier session can still be recorded against. '
276
- + 'Pass `reflection` to record what the answer added over what you already expected, when the '
277
- + 'consultation was started with a `prediction`. A prediction itself cannot be written here: it goes '
278
- + 'in consult_start, before the consultant runs, which is the only thing that makes it a prediction. '
279
- + 'For a fan-out, record every member in one call: records: [{ job_id, entries, reflection? }, ...] in '
280
- + 'place of job_id/entries/reflection. Every record is checked first; if any job or id is wrong, nothing is '
281
- + 'written and each bad one is named. A job whose write fails is listed under results with its error, '
282
- + 'not counted as saved.',
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.',
283
261
  inputSchema: z.object({
284
262
  ...recordShape,
285
263
  job_id: recordShape.job_id.optional(),
286
264
  records: z.array(z.object(recordShape)).min(1).max(POLICY.maxConcurrent).optional()
287
- .describe('several consultations at once -- one per member of a fan-out -- instead of job_id/entries/reflection'),
265
+ .describe('one per fan-out member, instead of job_id/entries/reflection'),
288
266
  }),
289
267
  },
290
268
  async ({ job_id, entries, reflection, records }) => {
@@ -314,12 +292,10 @@ export function createServer(manager = new JobManager()) {
314
292
  title: 'Export a consultation as a record to commit',
315
293
  annotations: { readOnlyHint: true, openWorldHint: false },
316
294
  description:
317
- 'Render one consultation (chain_id) or one fan-out (group_id) as Markdown: the brief as it was sent, '
318
- + 'each consultant\'s answer as it came back, and the verdicts recorded against each point -- with the '
319
- + 'ones nobody checked marked as unchecked. Nothing is summarised across consultants and nothing is '
320
- + 'scored. The text is returned, not written: put it wherever the decision belongs in the repository '
321
- + '(a decision record next to the code it is about), which is the only place a teammate will find it. '
322
- + 'Reads the on-disk history, so a consultation from an earlier session can still be exported.',
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.',
323
299
  inputSchema: z.object({
324
300
  chain_id: z.string().min(1).optional(),
325
301
  group_id: z.string().min(1).optional(),
@@ -341,10 +317,9 @@ export function createServer(manager = new JobManager()) {
341
317
  title: 'List recent consultations',
342
318
  annotations: { readOnlyHint: true, openWorldHint: false },
343
319
  description:
344
- 'Recent consultations from this session, newest first, with their status, one-line summary and who '
345
- + 'asked for them (initiator), and usage_totals: the token (and, where the CLI reports one, cost) figures each consultant reported this '
346
- + 'session, summed per consultant over every attempt, automatic re-runs included. A figure a CLI does not '
347
- + 'report is not counted as zero; it is named under not_reported.',
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.',
348
323
  inputSchema: z.object({ limit: z.number().int().min(1).max(100).optional() }),
349
324
  },
350
325
  async ({ limit }) => ok({
@@ -362,6 +337,7 @@ export async function main() {
362
337
  // consult_start: the operator may be the only one who reads stderr.
363
338
  const problem = configProblem();
364
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`);
365
341
  if (availableConsultants().length === 0) {
366
342
  process.stderr.write(
367
343
  'severally: no consultant CLI found on PATH (codex / claude / agy / opencode); every consultation will be refused\n',
package/src/status.mjs ADDED
@@ -0,0 +1,63 @@
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';
6
+ import { SEVERALLY_VERSION, staleInstall } from './server.mjs';
7
+ import { findCommand, execCommandSync } from './platform.mjs';
8
+
9
+ // How each client lists severally, plugin installs first. A command that exits 0
10
+ // and names severally counts as registered.
11
+ 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']] },
14
+ { label: 'Antigravity', cmd: 'agy', checks: [['mcp', 'list']] },
15
+ { label: 'OpenCode', cmd: 'opencode', checks: [['mcp', 'list']] },
16
+ ];
17
+
18
+ // "registered": the client's own listing names severally (it may still be
19
+ // turned off there). "not registered": the client answered, without it.
20
+ // No answer at all (a timeout, a failure to start): unknown.
21
+ function registration({ cmd, checks }) {
22
+ if (!findCommand(cmd)) return 'not installed';
23
+ let ran = false;
24
+ for (const args of checks) {
25
+ try {
26
+ const out = execCommandSync(cmd, args, { encoding: 'utf8', timeout: 20_000, stdio: ['ignore', 'pipe', 'pipe'] });
27
+ ran = true;
28
+ if (/(^|[\s@:])severally([\s@:]|$)/m.test(out)) return 'registered (listed by the client)';
29
+ } catch (err) {
30
+ // It ran and said no (`mcp get` of a missing server exits non-zero); a
31
+ // timeout or a failure to start says nothing either way.
32
+ if (typeof err.status === 'number') ran = true;
33
+ }
34
+ }
35
+ return ran ? 'not registered' : `could not tell (${cmd} ${checks.map((a) => a.join(' ')).join(' / ')} failed)`;
36
+ }
37
+
38
+ export function status() {
39
+ const lines = [`severally ${SEVERALLY_VERSION}`, ''];
40
+ const problem = configProblem();
41
+ lines.push(`config: ${problem ? `needs fixing, every consultation is refused until it is -- ${problem}` : 'ok'}`);
42
+ for (const w of configWarnings()) lines.push(` mistake (only that consultant or CLI is out of use): ${w}`);
43
+ lines.push(`network: ${POLICY.network === 'off' ? 'off for every consultation (server.network)' : 'per request'}`, '');
44
+
45
+ lines.push('consultants:');
46
+ for (const c of POLICY.consultants) {
47
+ lines.push(` ${c.name}: ${c.cli}, ${c.model} ${consultantAvailable(c) ? 'available' : `not available: ${unavailableReason(c)}`}`);
48
+ }
49
+ for (const name of brokenConsultantNames()) lines.push(` ${name}: not available: ${brokenConsultant(name)}`);
50
+ lines.push('');
51
+
52
+ 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', '');
56
+
57
+ lines.push('clients:');
58
+ 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');
61
+ process.stdout.write(`${lines.join('\n')}\n`);
62
+ return problem ? 1 : 0;
63
+ }
package/src/store.mjs CHANGED
@@ -63,6 +63,21 @@ function indexRow(jobId) {
63
63
  return null;
64
64
  }
65
65
 
66
+ /** Every row of the history index, in the order written; a damaged line is skipped. */
67
+ export function indexRows() {
68
+ return readIfExists(path.join(POLICY.home, 'history', 'index.jsonl')).split('\n').flatMap((line) => {
69
+ try {
70
+ const row = line.trim() ? JSON.parse(line) : null;
71
+ return row && typeof row === 'object' && JOB_ID.test(row.job_id) ? [row] : [];
72
+ } catch { return []; }
73
+ });
74
+ }
75
+
76
+ /** The round file an index row points at, or null. */
77
+ export function loadRoundAt(row) {
78
+ try { return JSON.parse(fs.readFileSync(roundPath(row.chain_id, row.round), 'utf8')); } catch { return null; }
79
+ }
80
+
66
81
  export const roundPath = (chainId, round) =>
67
82
  path.join(POLICY.home, 'history', chainId, `round-${String(round).padStart(2, '0')}.json`);
68
83
 
@@ -199,13 +214,28 @@ function withSeatLock(fn) {
199
214
  export function takeSeats(seats, cap, held) {
200
215
  return withSeatLock(() => {
201
216
  const live = [];
202
- for (const name of fs.readdirSync(seatsDir())) {
217
+ const names = fs.readdirSync(seatsDir());
218
+ // A cancel asked for after its consultation ended has no seat left to go with.
219
+ for (const name of names.filter((n) => n.endsWith('.cancel'))) {
220
+ if (!names.includes(name.replace(/\.cancel$/, '.json'))) try { fs.rmSync(path.join(seatsDir(), name), { force: true }); } catch { /* best effort */ }
221
+ }
222
+ for (const name of names) {
203
223
  if (!name.endsWith('.json')) continue;
204
224
  let seat = null;
205
225
  try { seat = JSON.parse(fs.readFileSync(path.join(seatsDir(), name), 'utf8')); } catch { /* half-written or damaged: free */ }
206
226
  if (seat && held(seat)) live.push(seat);
207
227
  else try { fs.rmSync(path.join(seatsDir(), name), { force: true }); } catch { /* best effort */ }
208
228
  }
229
+ // A chain runs one round at a time, across processes: a live seat on the
230
+ // same chain, or a round file already at the number chosen, means another
231
+ // server continued it first.
232
+ for (const mine of seats.filter((x) => x.chain_id)) {
233
+ let other = live.find((x) => x.chain_id === mine.chain_id);
234
+ if (!other && fs.existsSync(roundPath(mine.chain_id, mine.round))) {
235
+ try { other = { job_id: JSON.parse(fs.readFileSync(roundPath(mine.chain_id, mine.round), 'utf8')).job_id, round: mine.round }; } catch { other = { job_id: '(unreadable)', round: mine.round }; }
236
+ }
237
+ if (other) return { ok: false, chainBusy: { live: other, mine } };
238
+ }
209
239
  if (live.length + seats.length > cap) {
210
240
  return { ok: false, live: live.length, elsewhere: live.filter((x) => x.owner?.pid !== process.pid).length };
211
241
  }
@@ -220,6 +250,19 @@ export function takeSeats(seats, cap, held) {
220
250
  export function releaseSeat(jobId) {
221
251
  if (!JOB_ID.test(jobId)) return;
222
252
  try { fs.rmSync(seatFile(jobId), { force: true }); } catch { /* best effort */ }
253
+ clearCancel(jobId);
254
+ }
255
+
256
+ // A cancel asked for by a server that does not run the consultation: left
257
+ // beside its seat, for the server that does to find and act on.
258
+ const cancelFile = (jobId) => path.join(seatsDir(), `${jobId}.cancel`);
259
+ export function requestCancel(jobId) {
260
+ if (!JOB_ID.test(jobId)) return false;
261
+ try { fs.mkdirSync(seatsDir(), { recursive: true, mode: 0o700 }); fs.writeFileSync(cancelFile(jobId), '', { mode: 0o600 }); return true; } catch { return false; }
262
+ }
263
+ export const cancelRequested = (jobId) => JOB_ID.test(jobId) && fs.existsSync(cancelFile(jobId));
264
+ export function clearCancel(jobId) {
265
+ if (JOB_ID.test(jobId)) try { fs.rmSync(cancelFile(jobId), { force: true }); } catch { /* best effort */ }
223
266
  }
224
267
 
225
268
  export function loadGroup(groupId) {