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/result-schema.mjs
CHANGED
|
@@ -3,9 +3,18 @@
|
|
|
3
3
|
// no validation keywords beyond type/enum) so it can be handed verbatim to
|
|
4
4
|
// Codex's --output-schema and Claude Code's --json-schema.
|
|
5
5
|
|
|
6
|
+
/** @param {string} desc */
|
|
6
7
|
const s = (desc) => ({ type: 'string', description: desc });
|
|
8
|
+
/**
|
|
9
|
+
* @param {string[]} values
|
|
10
|
+
* @param {string} desc
|
|
11
|
+
*/
|
|
7
12
|
const enumOf = (values, desc) => ({ type: 'string', enum: values, description: desc });
|
|
8
13
|
|
|
14
|
+
/**
|
|
15
|
+
* @param {Record<string, object>} properties
|
|
16
|
+
* @param {string} [description]
|
|
17
|
+
*/
|
|
9
18
|
const obj = (properties, description) => ({
|
|
10
19
|
type: 'object',
|
|
11
20
|
description,
|
|
@@ -14,6 +23,10 @@ const obj = (properties, description) => ({
|
|
|
14
23
|
additionalProperties: false,
|
|
15
24
|
});
|
|
16
25
|
|
|
26
|
+
/**
|
|
27
|
+
* @param {object} items
|
|
28
|
+
* @param {string} description
|
|
29
|
+
*/
|
|
17
30
|
const arr = (items, description) => ({ type: 'array', description, items });
|
|
18
31
|
|
|
19
32
|
export const CONSULT_RESULT_SCHEMA = obj(
|
package/src/run.mjs
CHANGED
|
@@ -5,10 +5,7 @@ import { spawnCommand as spawn } from './platform.mjs';
|
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { POLICY } from './policy.mjs';
|
|
7
7
|
|
|
8
|
-
const DROP_EXACT = new Set([
|
|
9
|
-
'CLAUDECODE', 'CLAUDE_PID', 'CLAUDE_EFFORT', 'AI_AGENT',
|
|
10
|
-
'ANTHROPIC_LOG', 'NODE_OPTIONS',
|
|
11
|
-
]);
|
|
8
|
+
const DROP_EXACT = new Set(['CLAUDECODE', 'CLAUDE_PID', 'CLAUDE_EFFORT', 'AI_AGENT', 'ANTHROPIC_LOG', 'NODE_OPTIONS']);
|
|
12
9
|
const DROP_PREFIX = ['CLAUDE_CODE_', 'SEVERALLY_', 'MCP_'];
|
|
13
10
|
|
|
14
11
|
// Each consultant sees only its own vendor's credentials.
|
|
@@ -49,6 +46,7 @@ const DROP_PREFIX = ['CLAUDE_CODE_', 'SEVERALLY_', 'MCP_'];
|
|
|
49
46
|
// The names came out of the binary with `strings`; only XDG_CONFIG_HOME was
|
|
50
47
|
// probed behaviourally, so read the AGY_* line as "no evidence of a
|
|
51
48
|
// config-dir override under that prefix", not as proof that none exists.
|
|
49
|
+
/** @type {Record<string, string[]>} */
|
|
52
50
|
const TARGET_DROP_PREFIX = {
|
|
53
51
|
codex: ['ANTHROPIC_', 'GEMINI_', 'GOOGLE_', 'AGY_', 'ANTIGRAVITY_', 'ZAI_'],
|
|
54
52
|
'claude-code': ['OPENAI_', 'CODEX_', 'GEMINI_', 'GOOGLE_', 'AGY_', 'ANTIGRAVITY_', 'ZAI_'],
|
|
@@ -65,6 +63,7 @@ const TARGET_DROP_PREFIX = {
|
|
|
65
63
|
// Exact names dropped for one target only (see the note above). Kept separate
|
|
66
64
|
// from DROP_EXACT so the two other consultants, whose isolation has been
|
|
67
65
|
// verified with these variables present, are not changed.
|
|
66
|
+
/** @type {Record<string, string[]>} */
|
|
68
67
|
const TARGET_DROP_EXACT = {
|
|
69
68
|
antigravity: ['XDG_CONFIG_HOME'],
|
|
70
69
|
};
|
|
@@ -74,10 +73,15 @@ const TARGET_DROP_EXACT = {
|
|
|
74
73
|
* sockets, session ids, entrypoint markers) is removed, as are the other two
|
|
75
74
|
* vendors' credentials -- each consultant keeps only its own; and a recursion
|
|
76
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>}
|
|
77
80
|
*/
|
|
78
81
|
export function childEnv(target, extra = {}, operator = {}) {
|
|
79
82
|
const { dropPrefixes, dropExact } = dropRules(target);
|
|
80
83
|
const keep = providerEnvNames(target);
|
|
84
|
+
/** @type {Record<string, string>} */
|
|
81
85
|
const env = {};
|
|
82
86
|
for (const [k, v] of Object.entries(process.env)) {
|
|
83
87
|
if (v === undefined) continue;
|
|
@@ -104,6 +108,7 @@ export function childEnv(target, extra = {}, operator = {}) {
|
|
|
104
108
|
const SECRET_PREFIX = ['AWS_', 'AZURE_', 'GITHUB_', 'GH_', 'GITLAB_', 'NPM_', 'DOCKER_', 'HF_', 'SLACK_', 'STRIPE_', 'OP_'];
|
|
105
109
|
const SECRET_SUFFIX = /(TOKEN|SECRET|PASSWORD|PASSWD|PASSPHRASE|API_?KEY|ACCESS_KEY|PRIVATE_KEY|CREDENTIALS?)$/i;
|
|
106
110
|
const SECRET_EXACT = new Set(['SSH_AUTH_SOCK', 'KUBECONFIG', 'DATABASE_URL']);
|
|
111
|
+
/** @type {Record<string, string[]>} */
|
|
107
112
|
const OWN_PREFIX = {
|
|
108
113
|
codex: ['OPENAI_', 'CODEX_'],
|
|
109
114
|
'claude-code': ['ANTHROPIC_'],
|
|
@@ -113,17 +118,25 @@ const OWN_PREFIX = {
|
|
|
113
118
|
|
|
114
119
|
// Names the operator's OpenCode provider definitions read as "{env:NAME}":
|
|
115
120
|
// the consultant needs them, wherever they were exported.
|
|
121
|
+
/** @param {string} target */
|
|
116
122
|
function providerEnvNames(target) {
|
|
117
|
-
|
|
118
|
-
|
|
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 ?? []));
|
|
119
126
|
}
|
|
120
127
|
|
|
128
|
+
/**
|
|
129
|
+
* @param {string} target
|
|
130
|
+
* @param {string} key
|
|
131
|
+
* @param {Set<string>} keep
|
|
132
|
+
*/
|
|
121
133
|
function foreignSecret(target, key, keep) {
|
|
122
134
|
if (keep.has(key) || (OWN_PREFIX[target] ?? []).some((p) => key.startsWith(p))) return false;
|
|
123
135
|
const upper = key.toUpperCase();
|
|
124
136
|
return SECRET_EXACT.has(upper) || SECRET_PREFIX.some((p) => upper.startsWith(p)) || SECRET_SUFFIX.test(key);
|
|
125
137
|
}
|
|
126
138
|
|
|
139
|
+
/** @param {string} target */
|
|
127
140
|
function dropRules(target) {
|
|
128
141
|
return {
|
|
129
142
|
dropPrefixes: [...DROP_PREFIX, ...(TARGET_DROP_PREFIX[target] ?? [])],
|
|
@@ -139,11 +152,15 @@ function dropRules(target) {
|
|
|
139
152
|
* by config is refused rather than honoured; so is any name the per-job
|
|
140
153
|
* sandbox sets (HOME and friends), which would otherwise be silently
|
|
141
154
|
* overwritten.
|
|
155
|
+
* @param {string} target
|
|
156
|
+
* @param {unknown} env
|
|
157
|
+
* @param {string[]} [sandboxKeys]
|
|
158
|
+
* @returns {string | null}
|
|
142
159
|
*/
|
|
143
160
|
export function operatorEnvProblem(target, env, sandboxKeys = []) {
|
|
144
161
|
if (env === null || typeof env !== 'object' || Array.isArray(env)) return 'must be an object of string values';
|
|
145
162
|
const { dropPrefixes, dropExact } = dropRules(target);
|
|
146
|
-
const fold = (k) => (process.platform === 'win32' ? k.toUpperCase() : k);
|
|
163
|
+
const fold = (/** @type {string} */ k) => (process.platform === 'win32' ? k.toUpperCase() : k);
|
|
147
164
|
const reserved = new Set(sandboxKeys.map(fold));
|
|
148
165
|
for (const [k, v] of Object.entries(env)) {
|
|
149
166
|
if (typeof v !== 'string') return `${k} must be a string`;
|
|
@@ -156,6 +173,10 @@ export function operatorEnvProblem(target, env, sandboxKeys = []) {
|
|
|
156
173
|
}
|
|
157
174
|
|
|
158
175
|
export class ProcHandle {
|
|
176
|
+
/**
|
|
177
|
+
* @param {import('node:child_process').ChildProcess} child
|
|
178
|
+
* @param {() => string} [readStdout]
|
|
179
|
+
*/
|
|
159
180
|
constructor(child, readStdout = () => '') {
|
|
160
181
|
this.child = child;
|
|
161
182
|
this.killed = false;
|
|
@@ -168,6 +189,7 @@ export class ProcHandle {
|
|
|
168
189
|
* Kill the child and every descendant it spawned -- also after the child
|
|
169
190
|
* itself has exited: a descendant still holding its pipes keeps the
|
|
170
191
|
* consultation from closing, and on POSIX it is still in the group.
|
|
192
|
+
* @param {NodeJS.Signals} [signal]
|
|
171
193
|
*/
|
|
172
194
|
stop(signal = 'SIGTERM') {
|
|
173
195
|
if (this.killed || !this.child) return;
|
|
@@ -179,14 +201,33 @@ export class ProcHandle {
|
|
|
179
201
|
// before its parent exits and its descendants become unreachable. Once
|
|
180
202
|
// it has exited, its pid may already be another process's: leave it.
|
|
181
203
|
if (this.child.exitCode !== null || this.child.signalCode !== null) return;
|
|
182
|
-
const killer = spawn(
|
|
183
|
-
|
|
184
|
-
|
|
204
|
+
const killer = spawn(
|
|
205
|
+
path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'taskkill.exe'),
|
|
206
|
+
['/pid', String(pid), '/T', '/F'],
|
|
207
|
+
{ windowsHide: true, stdio: 'ignore' },
|
|
208
|
+
);
|
|
209
|
+
const fallback = () => {
|
|
210
|
+
try {
|
|
211
|
+
this.child.kill('SIGKILL');
|
|
212
|
+
} catch {
|
|
213
|
+
/* gone */
|
|
214
|
+
}
|
|
215
|
+
};
|
|
185
216
|
killer.on('error', fallback);
|
|
186
|
-
killer.on('exit', (code) => {
|
|
217
|
+
killer.on('exit', (code) => {
|
|
218
|
+
if (code !== 0) fallback();
|
|
219
|
+
});
|
|
187
220
|
return;
|
|
188
221
|
}
|
|
189
|
-
try {
|
|
222
|
+
try {
|
|
223
|
+
process.kill(-pid, signal);
|
|
224
|
+
} catch {
|
|
225
|
+
try {
|
|
226
|
+
this.child.kill(signal);
|
|
227
|
+
} catch {
|
|
228
|
+
/* gone */
|
|
229
|
+
}
|
|
230
|
+
}
|
|
190
231
|
setTimeout(() => this.forceKill(), POLICY.killGraceMs).unref();
|
|
191
232
|
}
|
|
192
233
|
|
|
@@ -200,21 +241,153 @@ export class ProcHandle {
|
|
|
200
241
|
forceKill() {
|
|
201
242
|
const pid = this.child?.pid;
|
|
202
243
|
if (!pid || process.platform === 'win32') return;
|
|
203
|
-
try {
|
|
244
|
+
try {
|
|
245
|
+
process.kill(-pid, 'SIGKILL');
|
|
246
|
+
} catch {
|
|
247
|
+
/* gone */
|
|
248
|
+
}
|
|
204
249
|
}
|
|
205
250
|
}
|
|
206
251
|
|
|
207
252
|
/**
|
|
208
|
-
* @
|
|
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
|
|
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
|
|
209
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
|
+
|
|
210
361
|
/**
|
|
211
362
|
* `pipesForced` in the result: the child exited but something it started kept
|
|
212
363
|
* stdout or stderr open past the kill grace, so the group was stopped and the
|
|
213
364
|
* pipes closed from this side. A descendant outside the group (setsid, or on
|
|
214
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}}
|
|
215
378
|
*/
|
|
216
|
-
export function runChild({
|
|
217
|
-
|
|
379
|
+
export function runChild({
|
|
380
|
+
command,
|
|
381
|
+
args,
|
|
382
|
+
cwd,
|
|
383
|
+
env,
|
|
384
|
+
input,
|
|
385
|
+
timeoutMs,
|
|
386
|
+
onCancelSignal,
|
|
387
|
+
compactLine = null,
|
|
388
|
+
settleAfter = null,
|
|
389
|
+
stopSignal = 'SIGTERM',
|
|
390
|
+
}) {
|
|
218
391
|
const child = spawn(command, args, {
|
|
219
392
|
cwd,
|
|
220
393
|
// PWD is the parent's otherwise, and opencode takes its project root from
|
|
@@ -237,24 +410,34 @@ export function runChild({ command, args, cwd, env, input, timeoutMs, onCancelSi
|
|
|
237
410
|
// says it is done; past that it is stopped and `stalled` set. A later line
|
|
238
411
|
// can shorten the wait, never lengthen it.
|
|
239
412
|
let stalled = false;
|
|
240
|
-
|
|
413
|
+
/** @type {NodeJS.Timeout | undefined} */
|
|
414
|
+
let settleTimer;
|
|
241
415
|
let settleAt = Infinity;
|
|
242
|
-
const watch = (line) => {
|
|
243
|
-
const ms = settleAfter(line);
|
|
416
|
+
const watch = (/** @type {string} */ line) => {
|
|
417
|
+
const ms = settleAfter?.(line);
|
|
244
418
|
if (ms == null || Date.now() + ms >= settleAt) return;
|
|
245
419
|
settleAt = Date.now() + ms;
|
|
246
420
|
clearTimeout(settleTimer);
|
|
247
|
-
settleTimer = setTimeout(() => {
|
|
421
|
+
settleTimer = setTimeout(() => {
|
|
422
|
+
stalled = true;
|
|
423
|
+
handle.stop(stopSignal);
|
|
424
|
+
}, ms);
|
|
248
425
|
};
|
|
249
426
|
|
|
250
|
-
const append = (which, chunk) => {
|
|
427
|
+
const append = (/** @type {'out' | 'err'} */ which, /** @type {string | Buffer} */ chunk) => {
|
|
251
428
|
const text = chunk.toString('utf8');
|
|
252
429
|
if (which === 'out') {
|
|
253
|
-
if (stdout.length >= cap) {
|
|
430
|
+
if (stdout.length >= cap) {
|
|
431
|
+
truncated = true;
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
254
434
|
stdout += text.slice(0, cap - stdout.length);
|
|
255
435
|
if (stdout.length >= cap) truncated = true;
|
|
256
436
|
} else {
|
|
257
|
-
if (stderr.length >= cap) {
|
|
437
|
+
if (stderr.length >= cap) {
|
|
438
|
+
truncated = true;
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
258
441
|
stderr += text.slice(0, cap - stderr.length);
|
|
259
442
|
if (stderr.length >= cap) truncated = true;
|
|
260
443
|
}
|
|
@@ -265,15 +448,18 @@ export function runChild({ command, args, cwd, env, input, timeoutMs, onCancelSi
|
|
|
265
448
|
// partial line is bounded by the cap too; past it, the line is kept as is.
|
|
266
449
|
let pending = '';
|
|
267
450
|
const byLine = Boolean(compactLine || settleAfter);
|
|
268
|
-
const compact = compactLine ?? ((l) => l);
|
|
451
|
+
const compact = compactLine ?? ((/** @type {string} */ l) => l);
|
|
269
452
|
const flushLines = () => {
|
|
270
453
|
const lines = pending.split('\n');
|
|
271
|
-
pending = lines.pop();
|
|
454
|
+
pending = lines.pop() ?? '';
|
|
272
455
|
for (const l of lines) {
|
|
273
456
|
if (settleAfter) watch(l);
|
|
274
457
|
append('out', `${compact(l)}\n`);
|
|
275
458
|
}
|
|
276
|
-
if (pending.length > cap) {
|
|
459
|
+
if (pending.length > cap) {
|
|
460
|
+
append('out', pending);
|
|
461
|
+
pending = '';
|
|
462
|
+
}
|
|
277
463
|
};
|
|
278
464
|
// Decode as a stream: a multibyte character split across two chunks would
|
|
279
465
|
// otherwise turn into replacement characters inside the answer text.
|
|
@@ -298,11 +484,13 @@ export function runChild({ command, args, cwd, env, input, timeoutMs, onCancelSi
|
|
|
298
484
|
});
|
|
299
485
|
}
|
|
300
486
|
|
|
487
|
+
/** @type {Promise<ChildResult>} */
|
|
301
488
|
const done = new Promise((resolve) => {
|
|
302
489
|
let settled = false;
|
|
303
490
|
let pipesForced = false;
|
|
304
|
-
|
|
305
|
-
|
|
491
|
+
/** @type {NodeJS.Timeout | undefined} */
|
|
492
|
+
let orphanTimer;
|
|
493
|
+
const finish = (/** @type {{ code: number | null, signal: NodeJS.Signals | null, spawnError?: string }} */ payload) => {
|
|
306
494
|
if (settled) return;
|
|
307
495
|
settled = true;
|
|
308
496
|
clearTimeout(timer);
|
|
@@ -310,7 +498,12 @@ export function runChild({ command, args, cwd, env, input, timeoutMs, onCancelSi
|
|
|
310
498
|
clearTimeout(orphanTimer);
|
|
311
499
|
resolve({ stdout, stderr, truncated, timedOut, cancelled, stalled, pipesForced, ...payload });
|
|
312
500
|
};
|
|
313
|
-
const flushPending = () => {
|
|
501
|
+
const flushPending = () => {
|
|
502
|
+
if (byLine && pending) {
|
|
503
|
+
append('out', compact(pending));
|
|
504
|
+
pending = '';
|
|
505
|
+
}
|
|
506
|
+
};
|
|
314
507
|
child.on('error', (err) => finish({ code: null, signal: null, spawnError: err.message }));
|
|
315
508
|
// 'close' waits for the pipes as well as the child. Once the child has
|
|
316
509
|
// exited, a grace later the rest of its group is stopped, and a grace
|
|
@@ -336,7 +529,9 @@ export function runChild({ command, args, cwd, env, input, timeoutMs, onCancelSi
|
|
|
336
529
|
});
|
|
337
530
|
|
|
338
531
|
if (input !== undefined && input !== null) {
|
|
339
|
-
child.stdin.on('error', () => {
|
|
532
|
+
child.stdin.on('error', () => {
|
|
533
|
+
/* child may exit before draining stdin */
|
|
534
|
+
});
|
|
340
535
|
child.stdin.end(input);
|
|
341
536
|
} else {
|
|
342
537
|
child.stdin.end();
|