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.
- package/CODE_OF_CONDUCT.md +83 -0
- package/CONFIG.md +13 -7
- package/README.ja.md +157 -77
- package/README.md +162 -81
- package/bin/severally-mcp.mjs +3 -0
- package/config.example.json +26 -0
- package/docs/how-it-works.ja.md +11 -3
- package/docs/how-it-works.md +12 -4
- package/docs/install.ja.md +25 -6
- package/docs/install.md +30 -7
- package/package.json +5 -3
- package/plugins/severally/.claude-plugin/plugin.json +1 -1
- package/plugins/severally/.codex-plugin/plugin.json +4 -2
- package/plugins/severally/.mcp.json +2 -1
- package/plugins/severally/dist/severally-mcp.mjs +29639 -28215
- package/plugins/severally/skills/_template/SKILL.md.tmpl +19 -23
- package/plugins/severally/skills/_template/reference.md.tmpl +22 -10
- package/plugins/severally/skills/antigravity/severally/SKILL.md +19 -23
- package/plugins/severally/skills/antigravity/severally/reference.md +22 -10
- package/plugins/severally/skills/claude/severally/SKILL.md +19 -23
- package/plugins/severally/skills/claude/severally/reference.md +22 -10
- package/plugins/severally/skills/codex/severally/SKILL.md +19 -23
- package/plugins/severally/skills/codex/severally/reference.md +22 -10
- package/plugins/severally/skills/opencode/severally/SKILL.md +19 -23
- package/plugins/severally/skills/opencode/severally/reference.md +22 -10
- package/scripts/init-config.mjs +1 -1
- package/scripts/install.mjs +124 -9
- package/src/adapters/antigravity-sandbox.mjs +5 -1
- package/src/adapters/codex.mjs +17 -6
- package/src/brief.mjs +38 -16
- package/src/consultants.mjs +31 -10
- package/src/expose-paths.mjs +68 -5
- package/src/jobs.mjs +294 -56
- package/src/policy.mjs +49 -11
- package/src/redact.mjs +20 -1
- package/src/run.mjs +38 -5
- package/src/schema.mjs +35 -12
- package/src/server.mjs +96 -120
- package/src/status.mjs +63 -0
- 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.
|
|
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.
|
|
16
|
-
//
|
|
17
|
-
|
|
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)
|
|
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
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
|
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('
|
|
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
|
|
137
|
-
note: z.string().max(4000).optional().describe('what you checked and
|
|
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
|
|
141
|
-
related_item_ids: z.array(z.string().min(1)).max(50).optional().describe('
|
|
142
|
-
}).optional().describe('written after
|
|
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
|
|
185
|
-
'
|
|
186
|
-
'
|
|
187
|
-
'
|
|
188
|
-
'
|
|
189
|
-
'
|
|
190
|
-
'
|
|
191
|
-
'
|
|
192
|
-
'
|
|
193
|
-
'`
|
|
194
|
-
'
|
|
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
|
|
217
|
-
|
|
218
|
-
|
|
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
|
|
265
|
-
+ '
|
|
266
|
-
+ '
|
|
267
|
-
+ '
|
|
268
|
-
+ '
|
|
269
|
-
+ '
|
|
270
|
-
+ '
|
|
271
|
-
+ '
|
|
272
|
-
+ '
|
|
273
|
-
+ '
|
|
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('
|
|
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
|
|
318
|
-
+ '
|
|
319
|
-
+ '
|
|
320
|
-
+ '
|
|
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
|
|
345
|
-
+ '
|
|
346
|
-
+ '
|
|
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
|
-
|
|
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) {
|