severally-mcp 2.4.0 → 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 +44 -19
- package/README.md +44 -18
- package/package.json +3 -1
- 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 +585 -255
- 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 +57 -54
- package/scripts/doctor.mjs +15 -4
- package/scripts/install.mjs +73 -9
- package/scripts/live-check.mjs +11 -5
- package/scripts/live-mcp-check.mjs +10 -4
- package/src/adapters/antigravity-sandbox.mjs +24 -6
- package/src/adapters/antigravity.mjs +80 -17
- package/src/adapters/claude-code.mjs +49 -8
- package/src/adapters/codex.mjs +56 -6
- package/src/adapters/opencode-sandbox.mjs +20 -2
- package/src/adapters/opencode.mjs +115 -19
- package/src/brief.mjs +84 -7
- package/src/consultants.mjs +184 -25
- package/src/cross-spawn-escape.d.ts +7 -0
- package/src/export.mjs +50 -6
- package/src/expose-paths.mjs +149 -7
- package/src/failures.mjs +8 -1
- package/src/jobs.mjs +578 -132
- package/src/jsonc.mjs +11 -3
- package/src/parse-result.mjs +91 -14
- package/src/platform.mjs +52 -2
- package/src/policy.mjs +151 -28
- package/src/redact.mjs +22 -3
- package/src/result-schema.mjs +13 -0
- package/src/run.mjs +158 -12
- package/src/schema.mjs +84 -11
- package/src/server.mjs +49 -14
- package/src/status.mjs +16 -4
- package/src/store.mjs +287 -6
package/src/run.mjs
CHANGED
|
@@ -46,6 +46,7 @@ const DROP_PREFIX = ['CLAUDE_CODE_', 'SEVERALLY_', 'MCP_'];
|
|
|
46
46
|
// The names came out of the binary with `strings`; only XDG_CONFIG_HOME was
|
|
47
47
|
// probed behaviourally, so read the AGY_* line as "no evidence of a
|
|
48
48
|
// config-dir override under that prefix", not as proof that none exists.
|
|
49
|
+
/** @type {Record<string, string[]>} */
|
|
49
50
|
const TARGET_DROP_PREFIX = {
|
|
50
51
|
codex: ['ANTHROPIC_', 'GEMINI_', 'GOOGLE_', 'AGY_', 'ANTIGRAVITY_', 'ZAI_'],
|
|
51
52
|
'claude-code': ['OPENAI_', 'CODEX_', 'GEMINI_', 'GOOGLE_', 'AGY_', 'ANTIGRAVITY_', 'ZAI_'],
|
|
@@ -62,6 +63,7 @@ const TARGET_DROP_PREFIX = {
|
|
|
62
63
|
// Exact names dropped for one target only (see the note above). Kept separate
|
|
63
64
|
// from DROP_EXACT so the two other consultants, whose isolation has been
|
|
64
65
|
// verified with these variables present, are not changed.
|
|
66
|
+
/** @type {Record<string, string[]>} */
|
|
65
67
|
const TARGET_DROP_EXACT = {
|
|
66
68
|
antigravity: ['XDG_CONFIG_HOME'],
|
|
67
69
|
};
|
|
@@ -71,10 +73,15 @@ const TARGET_DROP_EXACT = {
|
|
|
71
73
|
* sockets, session ids, entrypoint markers) is removed, as are the other two
|
|
72
74
|
* vendors' credentials -- each consultant keeps only its own; and a recursion
|
|
73
75
|
* marker is added.
|
|
76
|
+
* @param {string} target
|
|
77
|
+
* @param {Record<string, string>} [extra]
|
|
78
|
+
* @param {Record<string, string>} [operator]
|
|
79
|
+
* @returns {Record<string, string>}
|
|
74
80
|
*/
|
|
75
81
|
export function childEnv(target, extra = {}, operator = {}) {
|
|
76
82
|
const { dropPrefixes, dropExact } = dropRules(target);
|
|
77
83
|
const keep = providerEnvNames(target);
|
|
84
|
+
/** @type {Record<string, string>} */
|
|
78
85
|
const env = {};
|
|
79
86
|
for (const [k, v] of Object.entries(process.env)) {
|
|
80
87
|
if (v === undefined) continue;
|
|
@@ -101,6 +108,7 @@ export function childEnv(target, extra = {}, operator = {}) {
|
|
|
101
108
|
const SECRET_PREFIX = ['AWS_', 'AZURE_', 'GITHUB_', 'GH_', 'GITLAB_', 'NPM_', 'DOCKER_', 'HF_', 'SLACK_', 'STRIPE_', 'OP_'];
|
|
102
109
|
const SECRET_SUFFIX = /(TOKEN|SECRET|PASSWORD|PASSWD|PASSPHRASE|API_?KEY|ACCESS_KEY|PRIVATE_KEY|CREDENTIALS?)$/i;
|
|
103
110
|
const SECRET_EXACT = new Set(['SSH_AUTH_SOCK', 'KUBECONFIG', 'DATABASE_URL']);
|
|
111
|
+
/** @type {Record<string, string[]>} */
|
|
104
112
|
const OWN_PREFIX = {
|
|
105
113
|
codex: ['OPENAI_', 'CODEX_'],
|
|
106
114
|
'claude-code': ['ANTHROPIC_'],
|
|
@@ -110,17 +118,25 @@ const OWN_PREFIX = {
|
|
|
110
118
|
|
|
111
119
|
// Names the operator's OpenCode provider definitions read as "{env:NAME}":
|
|
112
120
|
// the consultant needs them, wherever they were exported.
|
|
121
|
+
/** @param {string} target */
|
|
113
122
|
function providerEnvNames(target) {
|
|
114
|
-
|
|
115
|
-
|
|
123
|
+
// Only OpenCode's section carries provider definitions.
|
|
124
|
+
const provider = JSON.stringify(target === 'opencode' ? POLICY.clis.opencode.provider : {});
|
|
125
|
+
return new Set([...provider.matchAll(/\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g)].flatMap(([, name]) => name ?? []));
|
|
116
126
|
}
|
|
117
127
|
|
|
128
|
+
/**
|
|
129
|
+
* @param {string} target
|
|
130
|
+
* @param {string} key
|
|
131
|
+
* @param {Set<string>} keep
|
|
132
|
+
*/
|
|
118
133
|
function foreignSecret(target, key, keep) {
|
|
119
134
|
if (keep.has(key) || (OWN_PREFIX[target] ?? []).some((p) => key.startsWith(p))) return false;
|
|
120
135
|
const upper = key.toUpperCase();
|
|
121
136
|
return SECRET_EXACT.has(upper) || SECRET_PREFIX.some((p) => upper.startsWith(p)) || SECRET_SUFFIX.test(key);
|
|
122
137
|
}
|
|
123
138
|
|
|
139
|
+
/** @param {string} target */
|
|
124
140
|
function dropRules(target) {
|
|
125
141
|
return {
|
|
126
142
|
dropPrefixes: [...DROP_PREFIX, ...(TARGET_DROP_PREFIX[target] ?? [])],
|
|
@@ -136,11 +152,15 @@ function dropRules(target) {
|
|
|
136
152
|
* by config is refused rather than honoured; so is any name the per-job
|
|
137
153
|
* sandbox sets (HOME and friends), which would otherwise be silently
|
|
138
154
|
* overwritten.
|
|
155
|
+
* @param {string} target
|
|
156
|
+
* @param {unknown} env
|
|
157
|
+
* @param {string[]} [sandboxKeys]
|
|
158
|
+
* @returns {string | null}
|
|
139
159
|
*/
|
|
140
160
|
export function operatorEnvProblem(target, env, sandboxKeys = []) {
|
|
141
161
|
if (env === null || typeof env !== 'object' || Array.isArray(env)) return 'must be an object of string values';
|
|
142
162
|
const { dropPrefixes, dropExact } = dropRules(target);
|
|
143
|
-
const fold = (k) => (process.platform === 'win32' ? k.toUpperCase() : k);
|
|
163
|
+
const fold = (/** @type {string} */ k) => (process.platform === 'win32' ? k.toUpperCase() : k);
|
|
144
164
|
const reserved = new Set(sandboxKeys.map(fold));
|
|
145
165
|
for (const [k, v] of Object.entries(env)) {
|
|
146
166
|
if (typeof v !== 'string') return `${k} must be a string`;
|
|
@@ -153,6 +173,10 @@ export function operatorEnvProblem(target, env, sandboxKeys = []) {
|
|
|
153
173
|
}
|
|
154
174
|
|
|
155
175
|
export class ProcHandle {
|
|
176
|
+
/**
|
|
177
|
+
* @param {import('node:child_process').ChildProcess} child
|
|
178
|
+
* @param {() => string} [readStdout]
|
|
179
|
+
*/
|
|
156
180
|
constructor(child, readStdout = () => '') {
|
|
157
181
|
this.child = child;
|
|
158
182
|
this.killed = false;
|
|
@@ -165,6 +189,7 @@ export class ProcHandle {
|
|
|
165
189
|
* Kill the child and every descendant it spawned -- also after the child
|
|
166
190
|
* itself has exited: a descendant still holding its pipes keeps the
|
|
167
191
|
* consultation from closing, and on POSIX it is still in the group.
|
|
192
|
+
* @param {NodeJS.Signals} [signal]
|
|
168
193
|
*/
|
|
169
194
|
stop(signal = 'SIGTERM') {
|
|
170
195
|
if (this.killed || !this.child) return;
|
|
@@ -225,13 +250,131 @@ export class ProcHandle {
|
|
|
225
250
|
}
|
|
226
251
|
|
|
227
252
|
/**
|
|
228
|
-
* @
|
|
253
|
+
* @typedef {object} ChildResult
|
|
254
|
+
* @property {number | null} code
|
|
255
|
+
* @property {NodeJS.Signals | null} signal
|
|
256
|
+
* @property {string} stdout
|
|
257
|
+
* @property {string} stderr
|
|
258
|
+
* @property {boolean} truncated
|
|
259
|
+
* @property {boolean} timedOut
|
|
260
|
+
* @property {boolean} cancelled
|
|
261
|
+
* @property {boolean} stalled
|
|
262
|
+
* @property {boolean} pipesForced
|
|
263
|
+
* @property {string} [spawnError]
|
|
264
|
+
*/
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* What an adapter's buildInvocation returns.
|
|
268
|
+
* @typedef {object} Invocation
|
|
269
|
+
* @property {string} command
|
|
270
|
+
* @property {string[]} args
|
|
271
|
+
* @property {string} model
|
|
272
|
+
* @property {string} [workdir]
|
|
273
|
+
* @property {string} [lastMessagePath]
|
|
274
|
+
*/
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* The synthesised HOME of one consultation, as an adapter's prepareSandbox
|
|
278
|
+
* returns it; what the adapters share of it.
|
|
279
|
+
* @typedef {object} Sandbox
|
|
280
|
+
* @property {string} root
|
|
281
|
+
* @property {string} credentials 'symlink', 'copy' or 'missing'
|
|
282
|
+
* @property {string} credentialsSource
|
|
283
|
+
* @property {readonly string[]} tools
|
|
284
|
+
* @property {Record<string, string>} env
|
|
285
|
+
* @property {() => void} cleanup
|
|
286
|
+
* @property {(provider: string) => boolean} [seed] insert the provider's credential into the session database (OpenCode only)
|
|
287
|
+
*/
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* What a consultant was launched with, read off the invocation: the boundary
|
|
291
|
+
* record kept beside each answer. Facts about the launch, not a claim about
|
|
292
|
+
* what the consultant did.
|
|
293
|
+
* @typedef {object} Boundary
|
|
294
|
+
* @property {string} reads
|
|
295
|
+
* @property {string} shell
|
|
296
|
+
* @property {boolean} web
|
|
297
|
+
* @property {boolean} writes
|
|
298
|
+
* @property {boolean} mcp
|
|
299
|
+
* @property {string} protected_paths
|
|
300
|
+
* @property {string[] | null} tools
|
|
229
301
|
*/
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* What a consultation used, as far as its CLI says; null where it does not say.
|
|
305
|
+
* @typedef {object} UsageRecord
|
|
306
|
+
* @property {number | null} input_tokens
|
|
307
|
+
* @property {number | null} cached_input_tokens
|
|
308
|
+
* @property {number | null} output_tokens
|
|
309
|
+
* @property {number | null} total_tokens
|
|
310
|
+
* @property {number | null} cost_usd
|
|
311
|
+
* @property {number | null} web_search_requests
|
|
312
|
+
* @property {number | null} [turns]
|
|
313
|
+
* @property {number | null} [thinking_tokens]
|
|
314
|
+
* @property {number | null} [reasoning_tokens]
|
|
315
|
+
* @property {string[] | null} [denied_actions]
|
|
316
|
+
*/
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* What jobs.mjs hands buildInvocation; each adapter reads the fields it needs.
|
|
320
|
+
* @typedef {object} BuildRequest
|
|
321
|
+
* @property {string} workdir
|
|
322
|
+
* @property {string} schemaPath
|
|
323
|
+
* @property {string} guardrails
|
|
324
|
+
* @property {Sandbox | null} sandbox the adapter's own prepareSandbox result, if it has one
|
|
325
|
+
* @property {import('./consultants.mjs').Consultant} consultant
|
|
326
|
+
* @property {boolean} [hasExposedPaths]
|
|
327
|
+
* @property {'on' | 'off' | undefined} [network]
|
|
328
|
+
*/
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* What an adapter's interpret makes of a finished child: an answer (text is a
|
|
332
|
+
* JSON object or the raw text to mine one out of), or a classified failure.
|
|
333
|
+
* @typedef {{ ok: true, text: string | Record<string, unknown>, usageRaw: unknown, payload?: unknown, events?: number, denials?: unknown[], recoveredError?: { failureKind: string, message: string } | null, earlierTextParts?: string[] }} InterpretedOk
|
|
334
|
+
* @typedef {{ ok: false, failureKind: string, message: string, usageRaw: unknown, payload?: unknown, events?: number }} InterpretedFail
|
|
335
|
+
* @typedef {InterpretedOk | InterpretedFail} Interpreted
|
|
336
|
+
*/
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* The shape every adapter module (codex, claude-code, antigravity, opencode)
|
|
340
|
+
* exports; the optional members are the ones only some CLIs have.
|
|
341
|
+
* @typedef {object} Adapter
|
|
342
|
+
* @property {readonly string[]} FORBIDDEN_FLAGS
|
|
343
|
+
* @property {readonly string[]} RESERVED_FLAGS
|
|
344
|
+
* @property {(req: BuildRequest) => Invocation} buildInvocation
|
|
345
|
+
* @property {(run: Partial<ChildResult> & { lastMessageText?: string }) => Interpreted} interpret
|
|
346
|
+
* @property {(raw: unknown) => UsageRecord | null} usageRecord
|
|
347
|
+
* @property {(args: { args: string[], sandbox: Sandbox | null, network?: 'on' | 'off' | undefined, hasExposedPaths?: boolean }) => Boundary} [boundary]
|
|
348
|
+
* @property {(run: { stdout: string }) => string | null} [progress]
|
|
349
|
+
* @property {(run: { stdout: string }) => Record<string, number> | null} [toolCalls]
|
|
350
|
+
* @property {readonly string[]} [FILE_READ_TOOLS] the names toolCalls reports for reading a file
|
|
351
|
+
* @property {readonly string[]} [ALLOWED_TOOLS] the only tools the consultant may call; any other is refused
|
|
352
|
+
* @property {() => Promise<void>} [prepare]
|
|
353
|
+
* @property {(args: { workdir: string, network?: 'on' | 'off' | undefined }) => Sandbox} [prepareSandbox]
|
|
354
|
+
* @property {(args: { run: ChildResult, sandbox: Sandbox }) => string} [diagnostics]
|
|
355
|
+
* @property {(args: { interpreted: Interpreted, sandbox: Sandbox, model: string }) => boolean} [recoverAuthFailure]
|
|
356
|
+
* @property {(line: string) => string} [compactLine]
|
|
357
|
+
* @property {(line: string) => number | null | undefined} [settleAfter]
|
|
358
|
+
* @property {NodeJS.Signals} [stopSignal]
|
|
359
|
+
*/
|
|
360
|
+
|
|
230
361
|
/**
|
|
231
362
|
* `pipesForced` in the result: the child exited but something it started kept
|
|
232
363
|
* stdout or stderr open past the kill grace, so the group was stopped and the
|
|
233
364
|
* pipes closed from this side. A descendant outside the group (setsid, or on
|
|
234
365
|
* Windows once the parent is gone) may still be running.
|
|
366
|
+
* @param {object} options
|
|
367
|
+
* @param {string} options.command
|
|
368
|
+
* @param {readonly string[]} options.args
|
|
369
|
+
* @param {string} [options.cwd]
|
|
370
|
+
* @param {NodeJS.ProcessEnv} [options.env]
|
|
371
|
+
* @param {string | null} [options.input]
|
|
372
|
+
* @param {number} options.timeoutMs
|
|
373
|
+
* @param {(cancel: () => void) => void} [options.onCancelSignal]
|
|
374
|
+
* @param {((line: string) => string) | null} [options.compactLine]
|
|
375
|
+
* @param {((line: string) => number | null | undefined) | null} [options.settleAfter]
|
|
376
|
+
* @param {NodeJS.Signals} [options.stopSignal]
|
|
377
|
+
* @returns {{handle: ProcHandle, done: Promise<ChildResult>, pid: number | undefined}}
|
|
235
378
|
*/
|
|
236
379
|
export function runChild({
|
|
237
380
|
command,
|
|
@@ -267,10 +410,11 @@ export function runChild({
|
|
|
267
410
|
// says it is done; past that it is stopped and `stalled` set. A later line
|
|
268
411
|
// can shorten the wait, never lengthen it.
|
|
269
412
|
let stalled = false;
|
|
270
|
-
|
|
413
|
+
/** @type {NodeJS.Timeout | undefined} */
|
|
414
|
+
let settleTimer;
|
|
271
415
|
let settleAt = Infinity;
|
|
272
|
-
const watch = (line) => {
|
|
273
|
-
const ms = settleAfter(line);
|
|
416
|
+
const watch = (/** @type {string} */ line) => {
|
|
417
|
+
const ms = settleAfter?.(line);
|
|
274
418
|
if (ms == null || Date.now() + ms >= settleAt) return;
|
|
275
419
|
settleAt = Date.now() + ms;
|
|
276
420
|
clearTimeout(settleTimer);
|
|
@@ -280,7 +424,7 @@ export function runChild({
|
|
|
280
424
|
}, ms);
|
|
281
425
|
};
|
|
282
426
|
|
|
283
|
-
const append = (which, chunk) => {
|
|
427
|
+
const append = (/** @type {'out' | 'err'} */ which, /** @type {string | Buffer} */ chunk) => {
|
|
284
428
|
const text = chunk.toString('utf8');
|
|
285
429
|
if (which === 'out') {
|
|
286
430
|
if (stdout.length >= cap) {
|
|
@@ -304,10 +448,10 @@ export function runChild({
|
|
|
304
448
|
// partial line is bounded by the cap too; past it, the line is kept as is.
|
|
305
449
|
let pending = '';
|
|
306
450
|
const byLine = Boolean(compactLine || settleAfter);
|
|
307
|
-
const compact = compactLine ?? ((l) => l);
|
|
451
|
+
const compact = compactLine ?? ((/** @type {string} */ l) => l);
|
|
308
452
|
const flushLines = () => {
|
|
309
453
|
const lines = pending.split('\n');
|
|
310
|
-
pending = lines.pop();
|
|
454
|
+
pending = lines.pop() ?? '';
|
|
311
455
|
for (const l of lines) {
|
|
312
456
|
if (settleAfter) watch(l);
|
|
313
457
|
append('out', `${compact(l)}\n`);
|
|
@@ -340,11 +484,13 @@ export function runChild({
|
|
|
340
484
|
});
|
|
341
485
|
}
|
|
342
486
|
|
|
487
|
+
/** @type {Promise<ChildResult>} */
|
|
343
488
|
const done = new Promise((resolve) => {
|
|
344
489
|
let settled = false;
|
|
345
490
|
let pipesForced = false;
|
|
346
|
-
|
|
347
|
-
|
|
491
|
+
/** @type {NodeJS.Timeout | undefined} */
|
|
492
|
+
let orphanTimer;
|
|
493
|
+
const finish = (/** @type {{ code: number | null, signal: NodeJS.Signals | null, spawnError?: string }} */ payload) => {
|
|
348
494
|
if (settled) return;
|
|
349
495
|
settled = true;
|
|
350
496
|
clearTimeout(timer);
|
package/src/schema.mjs
CHANGED
|
@@ -18,7 +18,14 @@ import {
|
|
|
18
18
|
} from './policy.mjs';
|
|
19
19
|
import { normalizeName, resolveConsultant } from './consultants.mjs';
|
|
20
20
|
|
|
21
|
+
/** @typedef {import('./consultants.mjs').Consultant} Consultant */
|
|
22
|
+
/** @typedef {import('./consultants.mjs').CliId} CliId */
|
|
23
|
+
|
|
21
24
|
const L = POLICY.input;
|
|
25
|
+
/**
|
|
26
|
+
* @param {number} max
|
|
27
|
+
* @param {string} label
|
|
28
|
+
*/
|
|
22
29
|
const trimmed = (max, label) =>
|
|
23
30
|
z.string().trim().min(1, `${label} must not be empty`).max(max, `${label} exceeds ${max} characters`);
|
|
24
31
|
|
|
@@ -144,10 +151,59 @@ export const requestSchema = z
|
|
|
144
151
|
})
|
|
145
152
|
.strict();
|
|
146
153
|
|
|
154
|
+
/**
|
|
155
|
+
* What charCount reads of a request, once parseRequest has filled in its defaults.
|
|
156
|
+
* @typedef {{
|
|
157
|
+
* question: string,
|
|
158
|
+
* objective?: string | null | undefined,
|
|
159
|
+
* constraints?: string[] | undefined,
|
|
160
|
+
* success_criteria?: string[] | undefined,
|
|
161
|
+
* context: { facts: string[], counterpoints: string[], proposal?: string | null | undefined, artifacts: { name: string, excerpt: string }[] },
|
|
162
|
+
* }} CountedRequest
|
|
163
|
+
*/
|
|
164
|
+
|
|
165
|
+
/** @typedef {'explore' | 'review' | 'debate'} Mode */
|
|
166
|
+
/** @typedef {z.output<typeof requestSchema>} ValidRequest */
|
|
167
|
+
/** @typedef {z.output<typeof contextSchema>} ValidContext */
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* A request as parseRequest returns it: validated, defaults filled in, the
|
|
171
|
+
* consultant(s) resolved. `target` is the first of `targets`.
|
|
172
|
+
* @typedef {Omit<
|
|
173
|
+
* ValidRequest,
|
|
174
|
+
* 'target' | 'targets' | 'caller' | 'caller_model' | 'initiator' | 'followup_to' | 'mode' | 'network' | 'success_criteria' | 'constraints' | 'context'
|
|
175
|
+
* > & {
|
|
176
|
+
* target: string,
|
|
177
|
+
* targets: string[],
|
|
178
|
+
* fanout: boolean,
|
|
179
|
+
* caller: CliId | null,
|
|
180
|
+
* caller_model: string | null,
|
|
181
|
+
* initiator: string | null,
|
|
182
|
+
* followup_to: string | null,
|
|
183
|
+
* mode: Mode,
|
|
184
|
+
* network: 'on' | 'off',
|
|
185
|
+
* success_criteria: string[],
|
|
186
|
+
* constraints: string[],
|
|
187
|
+
* context: Omit<ValidContext, 'constraints' | 'success_criteria' | 'facts' | 'counterpoints' | 'artifacts' | 'proposal'> & {
|
|
188
|
+
* facts: string[],
|
|
189
|
+
* counterpoints: string[],
|
|
190
|
+
* artifacts: ValidContext['artifacts'] & {},
|
|
191
|
+
* proposal: string | null,
|
|
192
|
+
* },
|
|
193
|
+
* }} ParsedRequest
|
|
194
|
+
*/
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* parseRequest's working copy: the request as it is until the defaults are filled in.
|
|
198
|
+
* `constraints` and `success_criteria` may still sit inside the context at first.
|
|
199
|
+
* @typedef {ParsedRequest & { context: { constraints?: string[] | undefined, success_criteria?: string[] | undefined } }} DraftRequest
|
|
200
|
+
*/
|
|
201
|
+
|
|
202
|
+
/** @param {CountedRequest} req */
|
|
147
203
|
export function charCount(req) {
|
|
148
204
|
let n = req.question.length + (req.objective?.length ?? 0);
|
|
149
|
-
for (const c of req.constraints) n += c.length;
|
|
150
|
-
for (const c of req.success_criteria) n += c.length;
|
|
205
|
+
for (const c of req.constraints ?? []) n += c.length;
|
|
206
|
+
for (const c of req.success_criteria ?? []) n += c.length;
|
|
151
207
|
for (const f of req.context.facts) n += f.length;
|
|
152
208
|
for (const c of req.context.counterpoints) n += c.length;
|
|
153
209
|
if (req.context.proposal) n += req.context.proposal.length;
|
|
@@ -156,6 +212,11 @@ export function charCount(req) {
|
|
|
156
212
|
}
|
|
157
213
|
|
|
158
214
|
export class RequestError extends Error {
|
|
215
|
+
/**
|
|
216
|
+
* @param {string} message
|
|
217
|
+
* @param {string} [code]
|
|
218
|
+
* @param {unknown[]} [details]
|
|
219
|
+
*/
|
|
159
220
|
constructor(message, code = 'invalid_request', details = undefined) {
|
|
160
221
|
super(message);
|
|
161
222
|
this.name = 'RequestError';
|
|
@@ -168,6 +229,9 @@ export class RequestError extends Error {
|
|
|
168
229
|
* Validate + normalise a consult request.
|
|
169
230
|
* `isFollowup` is decided by the caller (the job store resolves followup_to first);
|
|
170
231
|
* `groupFollowup` when followup_to named a fan-out, whose members are the targets.
|
|
232
|
+
* @param {unknown} raw
|
|
233
|
+
* @param {{ isFollowup?: boolean, parentMode?: Mode | null, groupFollowup?: boolean }} [options]
|
|
234
|
+
* @returns {ParsedRequest}
|
|
171
235
|
*/
|
|
172
236
|
export function parseRequest(raw, { isFollowup = false, parentMode = null, groupFollowup = false } = {}) {
|
|
173
237
|
const parsed = requestSchema.safeParse(raw);
|
|
@@ -175,24 +239,31 @@ export function parseRequest(raw, { isFollowup = false, parentMode = null, group
|
|
|
175
239
|
const issues = parsed.error.issues.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`);
|
|
176
240
|
throw new RequestError(`request failed validation: ${issues.join('; ')}`, 'invalid_request', issues);
|
|
177
241
|
}
|
|
178
|
-
// Filled in below with what the request resolves to (targets, caller, ...)
|
|
179
|
-
|
|
180
|
-
|
|
242
|
+
// Filled in below with what the request resolves to (targets, caller, ...), so
|
|
243
|
+
// until it returns it is only partly a ParsedRequest: the validated data is
|
|
244
|
+
// typed as the finished shape and every field the shape adds is set before the return.
|
|
245
|
+
const req = /** @type {DraftRequest} */ (parsed.data);
|
|
181
246
|
// Left without a default in the schema, so that a follow-up can tell a list
|
|
182
247
|
// it left out (inherited from the round before, jobs.mjs) from one it
|
|
183
248
|
// emptied on purpose: the tool layer applies the schema's defaults first.
|
|
184
|
-
|
|
185
|
-
|
|
249
|
+
/** @param {'constraints' | 'success_criteria'} k */
|
|
250
|
+
const hoist = (k) => {
|
|
251
|
+
const inner = req.context[k];
|
|
252
|
+
if (inner === undefined) return;
|
|
186
253
|
if (req[k] !== undefined) {
|
|
187
254
|
throw new RequestError(`pass ${k} once: at the top of the request or inside context, not both`, 'invalid_request');
|
|
188
255
|
}
|
|
189
|
-
req[k] =
|
|
256
|
+
req[k] = inner;
|
|
190
257
|
delete req.context[k];
|
|
191
|
-
}
|
|
258
|
+
};
|
|
259
|
+
hoist('constraints');
|
|
260
|
+
hoist('success_criteria');
|
|
192
261
|
req.network ??= 'on';
|
|
193
262
|
req.success_criteria ??= [];
|
|
194
263
|
req.constraints ??= [];
|
|
195
|
-
|
|
264
|
+
req.context.facts ??= [];
|
|
265
|
+
req.context.counterpoints ??= [];
|
|
266
|
+
req.context.artifacts ??= [];
|
|
196
267
|
const hasSingle = req.target !== undefined && req.target !== null;
|
|
197
268
|
const hasMany = Array.isArray(req.targets) && req.targets.length > 0;
|
|
198
269
|
if (hasSingle && hasMany) {
|
|
@@ -208,12 +279,14 @@ export function parseRequest(raw, { isFollowup = false, parentMode = null, group
|
|
|
208
279
|
'target_required',
|
|
209
280
|
);
|
|
210
281
|
}
|
|
282
|
+
/** @type {string[]} */
|
|
211
283
|
const named = hasSingle ? [req.target] : req.targets;
|
|
212
284
|
const isAll = named.map((n) => normalizeName(n) === 'all');
|
|
213
285
|
if (isAll.some(Boolean) && named.length > 1) {
|
|
214
286
|
throw new RequestError('"all" already means every available consultant; pass it alone', 'all_mixed');
|
|
215
287
|
}
|
|
216
288
|
|
|
289
|
+
/** @type {Consultant[]} */
|
|
217
290
|
let chosen;
|
|
218
291
|
if (isAll[0]) {
|
|
219
292
|
chosen = availableConsultants();
|
|
@@ -275,7 +348,7 @@ export function parseRequest(raw, { isFollowup = false, parentMode = null, group
|
|
|
275
348
|
);
|
|
276
349
|
}
|
|
277
350
|
req.targets = unique;
|
|
278
|
-
req.target = unique[0]; // single-target consumers (brief, history, adapters) keep working
|
|
351
|
+
req.target = /** @type {string} */ (unique[0]); // non-empty: every name in `named` resolved to a consultant; single-target consumers (brief, history, adapters) keep working
|
|
279
352
|
req.fanout = unique.length > 1;
|
|
280
353
|
req.caller = resolveCaller(req.caller ?? '') ?? null;
|
|
281
354
|
req.caller_model = req.caller_model ?? null;
|
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,10 +23,19 @@ 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);
|
|
28
|
+
/**
|
|
29
|
+
* @param {string} a
|
|
30
|
+
* @param {string} b
|
|
31
|
+
*/
|
|
25
32
|
const older = (a, b) => {
|
|
26
33
|
const [x, y] = [semver(a), semver(b)];
|
|
27
|
-
for (let i = 0; i < 3; i++)
|
|
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
|
+
}
|
|
28
39
|
return false;
|
|
29
40
|
};
|
|
30
41
|
|
|
@@ -33,8 +44,11 @@ const older = (a, b) => {
|
|
|
33
44
|
* from this server: a fix reaches a client only through the copy it runs, and
|
|
34
45
|
* an operator who updated one copy cannot see that another is behind. Only
|
|
35
46
|
* version strings are compared. A copy that is missing is not reported.
|
|
47
|
+
* @param {string} [home]
|
|
48
|
+
* @param {string} [severallyHome]
|
|
36
49
|
*/
|
|
37
50
|
export function staleInstall(home = os.homedir(), severallyHome = POLICY.home) {
|
|
51
|
+
/** @param {string} file */
|
|
38
52
|
const read = (file) => {
|
|
39
53
|
try {
|
|
40
54
|
return fs.readFileSync(file, 'utf8');
|
|
@@ -50,6 +64,7 @@ export function staleInstall(home = os.homedir(), severallyHome = POLICY.home) {
|
|
|
50
64
|
// This release names it SEVERALLY_VERSION; older ones only had it in the server info.
|
|
51
65
|
runtime: bundle?.match(/(?:SEVERALLY_VERSION = |name: ["']severally["'], version: )["'](\d+\.\d+\.\d+)["']/)?.[1],
|
|
52
66
|
claude_skills_dir: (() => {
|
|
67
|
+
if (plugin === null) return undefined;
|
|
53
68
|
try {
|
|
54
69
|
return JSON.parse(plugin).version;
|
|
55
70
|
} catch {
|
|
@@ -57,15 +72,18 @@ export function staleInstall(home = os.homedir(), severallyHome = POLICY.home) {
|
|
|
57
72
|
}
|
|
58
73
|
})(),
|
|
59
74
|
};
|
|
75
|
+
/** @type {Record<string, string>} */
|
|
60
76
|
const copies = { this_server: SEVERALLY_VERSION };
|
|
61
77
|
// Only plain x.y.z is compared; anything else is left out rather than guessed at.
|
|
62
|
-
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;
|
|
63
79
|
const versions = Object.values(copies);
|
|
64
80
|
if (versions.every((v) => v === SEVERALLY_VERSION)) return null;
|
|
65
81
|
const newest = versions.reduce((a, b) => (older(a, b) ? b : a));
|
|
66
82
|
return {
|
|
67
83
|
copies,
|
|
68
|
-
behind: Object.
|
|
84
|
+
behind: Object.entries(copies)
|
|
85
|
+
.filter(([, v]) => older(v, newest))
|
|
86
|
+
.map(([k]) => k),
|
|
69
87
|
fix:
|
|
70
88
|
`bring every copy to ${newest}: run \`npx -y severally-mcp@${newest} install\` (or \`node scripts/install.mjs\` from a checkout of that version) ` +
|
|
71
89
|
'(it updates the runtime and the ~/.claude/skills copy), run `/plugin marketplace update severally` for a ' +
|
|
@@ -149,15 +167,27 @@ const recordShape = {
|
|
|
149
167
|
.describe('written after reading the answer'),
|
|
150
168
|
};
|
|
151
169
|
|
|
170
|
+
/** @param {unknown} err */
|
|
171
|
+
const errorMessage = (err) =>
|
|
172
|
+
String((typeof err === 'object' && err !== null && 'message' in err ? err.message : undefined) ?? err);
|
|
173
|
+
|
|
152
174
|
export function createServer(manager = new JobManager()) {
|
|
153
175
|
// Read once: the copies on disk change only when someone reinstalls, and then
|
|
154
176
|
// this server is the one to restart.
|
|
155
177
|
const stale = staleInstall();
|
|
156
178
|
const server = new McpServer({ name: 'severally', version: SEVERALLY_VERSION }, { instructions: SERVER_INSTRUCTIONS });
|
|
157
179
|
|
|
158
|
-
|
|
180
|
+
/**
|
|
181
|
+
* @param {unknown} payload
|
|
182
|
+
* @returns {CallToolResult}
|
|
183
|
+
*/
|
|
184
|
+
const ok = (payload) => ({ content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] });
|
|
185
|
+
/**
|
|
186
|
+
* @param {unknown} payload
|
|
187
|
+
* @returns {CallToolResult}
|
|
188
|
+
*/
|
|
159
189
|
const fail = (payload) => ({
|
|
160
|
-
content: [{ type:
|
|
190
|
+
content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
|
|
161
191
|
isError: true,
|
|
162
192
|
});
|
|
163
193
|
|
|
@@ -177,7 +207,7 @@ export function createServer(manager = new JobManager()) {
|
|
|
177
207
|
if (err instanceof RequestError) {
|
|
178
208
|
return fail({ error: err.code, message: err.message, details: err.details ?? null, limits: limitsSummary() });
|
|
179
209
|
}
|
|
180
|
-
return fail({ error: 'internal_error', message:
|
|
210
|
+
return fail({ error: 'internal_error', message: errorMessage(err) });
|
|
181
211
|
}
|
|
182
212
|
},
|
|
183
213
|
);
|
|
@@ -241,12 +271,13 @@ export function createServer(manager = new JobManager()) {
|
|
|
241
271
|
}, PROGRESS_EVERY_MS);
|
|
242
272
|
let view;
|
|
243
273
|
try {
|
|
244
|
-
// 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.
|
|
245
276
|
view = group_id
|
|
246
277
|
? await manager.waitGroup(group_id, wait_ms ?? DEFAULT_WAIT_MS, { detail }, req?.signal)
|
|
247
|
-
: 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);
|
|
248
279
|
} finally {
|
|
249
|
-
clearInterval(timer);
|
|
280
|
+
clearInterval(timer ?? undefined);
|
|
250
281
|
}
|
|
251
282
|
if (!view) {
|
|
252
283
|
return fail({
|
|
@@ -257,7 +288,8 @@ export function createServer(manager = new JobManager()) {
|
|
|
257
288
|
// Still running: the poll answer as it is; ids applies once there is an answer.
|
|
258
289
|
if (!ids || ['running', 'queued', 'cancelling'].includes(view.status)) return ok(view);
|
|
259
290
|
try {
|
|
260
|
-
|
|
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));
|
|
261
293
|
} catch (err) {
|
|
262
294
|
if (err instanceof RequestError) return fail({ error: err.code, message: err.message });
|
|
263
295
|
throw err;
|
|
@@ -283,7 +315,8 @@ export function createServer(manager = new JobManager()) {
|
|
|
283
315
|
if (Boolean(job_id) === Boolean(group_id)) {
|
|
284
316
|
return fail({ error: 'invalid_request', message: 'pass exactly one of job_id or group_id' });
|
|
285
317
|
}
|
|
286
|
-
|
|
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));
|
|
287
320
|
if (!view) {
|
|
288
321
|
return fail({
|
|
289
322
|
error: 'unknown_job',
|
|
@@ -324,6 +357,7 @@ export function createServer(manager = new JobManager()) {
|
|
|
324
357
|
async ({ job_id, entries, reflection, records }) => {
|
|
325
358
|
try {
|
|
326
359
|
if (records !== undefined) {
|
|
360
|
+
/** @type {Record<string, unknown>} */
|
|
327
361
|
const out = manager.record(
|
|
328
362
|
Object.fromEntries(Object.entries({ job_id, entries, reflection, records }).filter(([, v]) => v !== undefined)),
|
|
329
363
|
);
|
|
@@ -342,7 +376,7 @@ export function createServer(manager = new JobManager()) {
|
|
|
342
376
|
if (err instanceof RequestError) {
|
|
343
377
|
return fail({ error: err.code, message: err.message, details: err.details ?? null });
|
|
344
378
|
}
|
|
345
|
-
return fail({ error: 'internal_error', message:
|
|
379
|
+
return fail({ error: 'internal_error', message: errorMessage(err) });
|
|
346
380
|
}
|
|
347
381
|
},
|
|
348
382
|
);
|
|
@@ -367,7 +401,7 @@ export function createServer(manager = new JobManager()) {
|
|
|
367
401
|
return ok(exportChain({ chain_id, group_id }));
|
|
368
402
|
} catch (err) {
|
|
369
403
|
if (err instanceof ExportError) return fail({ error: err.code, message: err.message });
|
|
370
|
-
return fail({ error: 'internal_error', message:
|
|
404
|
+
return fail({ error: 'internal_error', message: errorMessage(err) });
|
|
371
405
|
}
|
|
372
406
|
},
|
|
373
407
|
);
|
|
@@ -420,6 +454,7 @@ export async function main() {
|
|
|
420
454
|
manager.sweepInterrupted();
|
|
421
455
|
|
|
422
456
|
let closing = false;
|
|
457
|
+
/** @param {string} signal */
|
|
423
458
|
const bye = async (signal) => {
|
|
424
459
|
if (closing) return;
|
|
425
460
|
closing = true;
|