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