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.
Files changed (45) hide show
  1. package/README.ja.md +52 -17
  2. package/README.md +52 -16
  3. package/bin/severally-mcp.mjs +14 -2
  4. package/docs/install.ja.md +48 -20
  5. package/docs/install.md +48 -21
  6. package/package.json +15 -3
  7. package/plugins/severally/.claude-plugin/plugin.json +1 -1
  8. package/plugins/severally/.codex-plugin/plugin.json +1 -1
  9. package/plugins/severally/dist/severally-mcp.mjs +6573 -5978
  10. package/plugins/severally/skills/_template/reference.md.tmpl +2 -2
  11. package/plugins/severally/skills/antigravity/severally/reference.md +2 -2
  12. package/plugins/severally/skills/claude/severally/reference.md +2 -2
  13. package/plugins/severally/skills/codex/severally/reference.md +2 -2
  14. package/plugins/severally/skills/opencode/severally/reference.md +2 -2
  15. package/scripts/build.mjs +76 -68
  16. package/scripts/doctor.mjs +66 -20
  17. package/scripts/init-config.mjs +8 -6
  18. package/scripts/install.mjs +327 -90
  19. package/scripts/live-check.mjs +62 -23
  20. package/scripts/live-mcp-check.mjs +52 -16
  21. package/scripts/test.mjs +10 -4
  22. package/src/adapters/antigravity-sandbox.mjs +62 -28
  23. package/src/adapters/antigravity.mjs +111 -30
  24. package/src/adapters/claude-code.mjs +121 -28
  25. package/src/adapters/codex.mjs +125 -33
  26. package/src/adapters/opencode-sandbox.mjs +59 -22
  27. package/src/adapters/opencode.mjs +144 -30
  28. package/src/brief.mjs +148 -47
  29. package/src/consultants.mjs +255 -48
  30. package/src/cross-spawn-escape.d.ts +7 -0
  31. package/src/export.mjs +99 -18
  32. package/src/expose-paths.mjs +310 -50
  33. package/src/failures.mjs +31 -14
  34. package/src/jobs.mjs +863 -249
  35. package/src/jsonc.mjs +11 -3
  36. package/src/parse-result.mjs +172 -40
  37. package/src/platform.mjs +56 -8
  38. package/src/policy.mjs +245 -52
  39. package/src/redact.mjs +59 -16
  40. package/src/result-schema.mjs +13 -0
  41. package/src/run.mjs +225 -30
  42. package/src/schema.mjs +132 -27
  43. package/src/server.mjs +178 -81
  44. package/src/status.mjs +94 -13
  45. package/src/store.mjs +418 -35
@@ -18,7 +18,14 @@ import path from 'node:path';
18
18
  import { POLICY, agyExitGraceMs, timeoutMs } from '../policy.mjs';
19
19
  import { classifyMessage } from '../failures.mjs';
20
20
 
21
- export { prepareSandbox } from './antigravity-sandbox.mjs';
21
+ export { prepareSandbox, ALLOWED_TOOLS } from './antigravity-sandbox.mjs';
22
+
23
+ /** @typedef {import('../run.mjs').BuildRequest} BuildRequest */
24
+ /** @typedef {import('../run.mjs').Invocation} Invocation */
25
+ /** @typedef {import('../run.mjs').Interpreted} Interpreted */
26
+ /** @typedef {import('../run.mjs').Sandbox} Sandbox */
27
+ /** @typedef {import('../run.mjs').UsageRecord} UsageRecord */
28
+ /** @typedef {import('../run.mjs').Boundary} Boundary */
22
29
 
23
30
  // Checked against `agy --help` at 1.1.28. These are inert in print mode as the
24
31
  // adapter builds it today; the list exists so a future edit that reaches for
@@ -50,6 +57,10 @@ export const FORBIDDEN_FLAGS = [
50
57
  // --effort too: the effort is chosen through the model name, in one place.
51
58
  export const RESERVED_FLAGS = ['--print', '-p', '--prompt', '--project', '--remote-control', '--effort'];
52
59
 
60
+ /**
61
+ * @param {BuildRequest} req
62
+ * @returns {Invocation}
63
+ */
53
64
  export function buildInvocation({ schemaPath, consultant }) {
54
65
  const t = POLICY.clis.antigravity;
55
66
  const chosen = consultant.model;
@@ -63,17 +74,43 @@ export function buildInvocation({ schemaPath, consultant }) {
63
74
  // it was. Ours has to fire first for the failure to be classified honestly.
64
75
  const budget = timeoutMs(consultant);
65
76
  const args = [
66
- '--output-format', 'stream-json',
67
- '--json-schema', schemaPath,
77
+ '--output-format',
78
+ 'stream-json',
79
+ '--json-schema',
80
+ schemaPath,
68
81
  '--disable-slash-commands',
69
- '--model', chosen,
70
- '--print-timeout', `${Math.ceil((budget + 30_000) / 1000)}s`,
82
+ '--model',
83
+ chosen,
84
+ '--print-timeout',
85
+ `${Math.ceil((budget + 30_000) / 1000)}s`,
71
86
  ];
72
87
  return { command: t.cli, args, model: chosen };
73
88
  }
74
89
 
75
- /** Every complete NDJSON object agy wrote, oldest first. */
90
+ /**
91
+ * The result envelope agy prints; the CLI controls it, so every field is optional.
92
+ * @typedef {object} AgyEnvelope
93
+ * @property {unknown} [status]
94
+ * @property {unknown} [error]
95
+ * @property {unknown} [response]
96
+ * @property {string | Record<string, unknown>} [structured_output]
97
+ * @property {({ action?: string } | null)[]} [denied_actions]
98
+ * @property {{ input_tokens?: unknown, cache_read_tokens?: unknown, output_tokens?: unknown, total_tokens?: unknown, thinking_tokens?: unknown }} [usage]
99
+ * @property {unknown} [num_turns]
100
+ */
101
+
102
+ /**
103
+ * One NDJSON event: a `result` or `step_update` wrapper, or a bare envelope.
104
+ * @typedef {AgyEnvelope & { event?: string, result?: AgyEnvelope, step_update?: { step_index?: number, tool_name?: string, step_type?: string, state?: string } }} AgyEvent
105
+ */
106
+
107
+ /**
108
+ * Every complete NDJSON object agy wrote, oldest first.
109
+ * @param {string} [stdout]
110
+ * @returns {AgyEvent[]}
111
+ */
76
112
  function events(stdout) {
113
+ /** @type {AgyEvent[]} */
77
114
  const out = [];
78
115
  for (const line of (stdout || '').split('\n')) {
79
116
  const t = line.trim();
@@ -81,7 +118,9 @@ function events(stdout) {
81
118
  try {
82
119
  const parsed = JSON.parse(t);
83
120
  if (parsed && typeof parsed === 'object') out.push(parsed);
84
- } catch { /* a partial line, e.g. when the child was killed mid-write */ }
121
+ } catch {
122
+ /* a partial line, e.g. when the child was killed mid-write */
123
+ }
85
124
  }
86
125
  return out;
87
126
  }
@@ -90,11 +129,11 @@ function events(stdout) {
90
129
  * The final envelope: the `result` event under stream-json, or a bare envelope
91
130
  * when agy was run with --output-format json (as older configs and the unit
92
131
  * tests do).
132
+ * @param {string} [stdout]
133
+ * @returns {AgyEnvelope | null}
93
134
  */
94
135
  function finalEnvelope(stdout) {
95
- const all = events(stdout);
96
- for (let i = all.length - 1; i >= 0; i--) {
97
- const e = all[i];
136
+ for (const e of events(stdout).reverse()) {
98
137
  if (e.event === 'result' && e.result && typeof e.result === 'object') return e.result;
99
138
  if (e.event === undefined && (e.status || e.response || e.structured_output)) return e;
100
139
  }
@@ -107,24 +146,35 @@ function finalEnvelope(stdout) {
107
146
  * one tool call", which is the difference between raising the budget and
108
147
  * narrowing the brief; and it lets the lead cut a heavy consultation short
109
148
  * instead of waiting out the whole budget for nothing.
149
+ * @param {{ stdout: string }} run
150
+ * @returns {string | null}
110
151
  */
111
152
  export function progress({ stdout }) {
112
- const steps = events(stdout).filter((e) => e.event === 'step_update' && e.step_update);
113
- if (!steps.length) return null;
114
- const last = steps[steps.length - 1].step_update;
115
- const seen = new Set(steps.map((s) => s.step_update.step_index)).size;
153
+ const steps = events(stdout).flatMap((e) => (e.event === 'step_update' && e.step_update ? [e.step_update] : []));
154
+ const last = steps.at(-1);
155
+ if (!last) return null;
156
+ const seen = new Set(steps.map((s) => s.step_index)).size;
116
157
  const what = last.tool_name ? `tool ${last.tool_name}` : (last.step_type ?? 'a step');
117
158
  return `${seen} step(s), last was ${what} (${last.state ?? 'state unknown'})`;
118
159
  }
119
160
 
120
- /** Tool calls seen in the step stream, one per step index; `:error` when the step failed or was refused. */
161
+ /** The tools that read a file; a consultation whose calls to them all failed read none of its exposed files. */
162
+ export const FILE_READ_TOOLS = ['view_file', 'read_file'];
163
+
164
+ /**
165
+ * Tool calls seen in the step stream, one per step index; `:error` when the step failed or was refused.
166
+ * @param {{ stdout: string }} run
167
+ * @returns {Record<string, number>}
168
+ */
121
169
  export function toolCalls({ stdout }) {
170
+ /** @type {Map<unknown, string>} */
122
171
  const seen = new Map();
123
172
  for (const e of events(stdout)) {
124
173
  const s = e.event === 'step_update' ? e.step_update : null;
125
- if (s?.tool_name) seen.set(s.step_index ?? seen.size, /error|fail|cancel|denied/i.test(s.state ?? '') ? `${s.tool_name}:error` : s.tool_name);
174
+ if (s?.tool_name)
175
+ seen.set(s.step_index ?? seen.size, /error|fail|cancel|denied/i.test(s.state ?? '') ? `${s.tool_name}:error` : s.tool_name);
126
176
  }
127
- return [...seen.values()].reduce((o, n) => ({ ...o, [n]: (o[n] ?? 0) + 1 }), {});
177
+ return [...seen.values()].reduce((/** @type {Record<string, number>} */ o, n) => ({ ...o, [n]: (o[n] ?? 0) + 1 }), {});
128
178
  }
129
179
 
130
180
  /**
@@ -132,11 +182,17 @@ export function toolCalls({ stdout }) {
132
182
  * (job_790a7173c16d) agy wrote its finish step and then stayed alive for six
133
183
  * minutes; the server waits for the process to close, so without this the
134
184
  * consultation would have sat "running" until its budget ran out.
185
+ * @param {string} line
186
+ * @returns {number | null}
135
187
  */
136
188
  export function settleAfter(line) {
137
189
  if (!line.includes('"result"') && !line.includes('"finish"')) return null;
138
190
  let e;
139
- try { e = JSON.parse(line); } catch { return null; }
191
+ try {
192
+ e = JSON.parse(line);
193
+ } catch {
194
+ return null;
195
+ }
140
196
  if (e?.event === 'result') return agyExitGraceMs().afterResult;
141
197
  if (e?.event === 'step_update' && e.step_update?.step_type === 'finish') return agyExitGraceMs().afterFinish;
142
198
  return null;
@@ -152,33 +208,48 @@ export const stopSignal = 'SIGQUIT';
152
208
  * goroutine dump and is written inside the sandbox HOME that is removed when
153
209
  * the consultation ends, and whatever reached the stderr pipe. Not redacted
154
210
  * here; the caller does that.
211
+ * @param {{ run: import('../run.mjs').ChildResult, sandbox: { root: string } }} args
155
212
  */
156
213
  export function diagnostics({ run, sandbox }) {
157
- const tail = (s) => String(s ?? '').slice(-200_000);
214
+ const tail = (/** @type {unknown} */ s) => String(s ?? '').slice(-200_000);
158
215
  const dir = path.join(sandbox.root, '.gemini', 'antigravity-cli', 'log');
216
+ /** @type {string[]} */
159
217
  let names = [];
160
- try { names = fs.readdirSync(dir).sort(); } catch { /* agy wrote no log */ }
218
+ try {
219
+ names = fs.readdirSync(dir).sort();
220
+ } catch {
221
+ /* agy wrote no log */
222
+ }
161
223
  return [
162
224
  `--- agy stderr ---\n${tail(run.stderr)}`,
163
225
  ...names.map((n) => {
164
226
  let text = '';
165
- try { text = fs.readFileSync(path.join(dir, n), 'utf8'); } catch { /* unreadable */ }
227
+ try {
228
+ text = fs.readFileSync(path.join(dir, n), 'utf8');
229
+ } catch {
230
+ /* unreadable */
231
+ }
166
232
  return `--- ${n} ---\n${tail(text)}`;
167
233
  }),
168
234
  ].join('\n');
169
235
  }
170
236
 
237
+ /**
238
+ * @param {{ stdout?: string, stderr?: string, code?: number | null }} run
239
+ * @returns {Interpreted}
240
+ */
171
241
  export function interpret({ stdout, stderr, code }) {
172
242
  const payload = finalEnvelope(stdout);
173
243
  if (!payload) {
174
- const msg = (stderr || '').trim() || (stdout || '').trim().slice(0, 2000) ||
244
+ const msg =
245
+ (stderr || '').trim() ||
246
+ (stdout || '').trim().slice(0, 2000) ||
175
247
  `agy exited with code ${code} and produced no JSON envelope`;
176
248
  return { ok: false, failureKind: code === 0 ? 'invalid_output' : classifyMessage(msg), message: msg, usageRaw: null };
177
249
  }
178
250
  if (payload.status && payload.status !== 'SUCCESS') {
179
- const msg = typeof payload.error === 'string' && payload.error.trim()
180
- ? payload.error
181
- : JSON.stringify(payload).slice(0, 2000);
251
+ const msg =
252
+ typeof payload.error === 'string' && payload.error.trim() ? payload.error : JSON.stringify(payload).slice(0, 2000);
182
253
  return { ok: false, failureKind: classifyMessage(msg), message: msg, usageRaw: payload };
183
254
  }
184
255
  const structured = payload.structured_output ?? null;
@@ -189,10 +260,16 @@ export function interpret({ stdout, stderr, code }) {
189
260
  return { ok: true, text, usageRaw: payload, denials: payload.denied_actions ?? [] };
190
261
  }
191
262
 
192
- export function usageRecord(payload) {
263
+ /**
264
+ * @param {unknown} raw the agy result envelope, as interpret kept it
265
+ * @returns {UsageRecord | null}
266
+ */
267
+ export function usageRecord(raw) {
268
+ // interpret's usageRaw is the AgyEnvelope it found (or null).
269
+ const payload = /** @type {AgyEnvelope | null} */ (raw);
193
270
  if (!payload) return null;
194
271
  const u = payload.usage ?? {};
195
- const num = (v) => (typeof v === 'number' ? v : null);
272
+ const num = (/** @type {unknown} */ v) => (typeof v === 'number' ? v : null);
196
273
  return {
197
274
  input_tokens: num(u.input_tokens),
198
275
  cached_input_tokens: num(u.cache_read_tokens),
@@ -205,7 +282,7 @@ export function usageRecord(payload) {
205
282
  // Which permissions the sandbox refused. A denied read_url usually explains
206
283
  // a thin evidence basis, so it is worth keeping in the record.
207
284
  denied_actions: Array.isArray(payload.denied_actions)
208
- ? payload.denied_actions.map((d) => d?.action).filter(Boolean)
285
+ ? payload.denied_actions.flatMap((d) => (d?.action ? [d.action] : []))
209
286
  : null,
210
287
  };
211
288
  }
@@ -214,15 +291,19 @@ export function usageRecord(payload) {
214
291
  * What this consultant was launched with, read off the invocation: the
215
292
  * boundary record kept beside each answer. Facts about the launch, not a
216
293
  * claim about what the consultant did.
294
+ * @param {{ sandbox: Sandbox | null }} req
295
+ * @returns {Boundary}
217
296
  */
218
297
  export function boundary({ sandbox }) {
298
+ // buildInvocation always runs with the sandbox prepareSandbox made.
299
+ const { tools } = /** @type {Sandbox} */ (sandbox);
219
300
  return {
220
301
  reads: 'whole disk',
221
302
  shell: 'none',
222
- web: sandbox.tools.includes('search_web'),
303
+ web: tools.includes('search_web'),
223
304
  writes: false,
224
305
  mcp: false,
225
306
  protected_paths: 'denied (tool hook)',
226
- tools: [...sandbox.tools],
307
+ tools: [...tools],
227
308
  };
228
309
  }
@@ -33,6 +33,12 @@ import { POLICY, PROTECTED_FILE_NAMES, protectedRoots } from '../policy.mjs';
33
33
  import { classifyMessage } from '../failures.mjs';
34
34
  import { CONSULT_RESULT_SCHEMA } from '../result-schema.mjs';
35
35
 
36
+ /** @typedef {import('../run.mjs').BuildRequest} BuildRequest */
37
+ /** @typedef {import('../run.mjs').Invocation} Invocation */
38
+ /** @typedef {import('../run.mjs').Interpreted} Interpreted */
39
+ /** @typedef {import('../run.mjs').UsageRecord} UsageRecord */
40
+ /** @typedef {import('../run.mjs').Boundary} Boundary */
41
+
36
42
  // --add-dir is not listed: the adapter passes `--add-dir /` itself, and
37
43
  // assertNoForbiddenFlags refuses any other value. --restricted confines the
38
44
  // file tools to the working directories, and without expose_paths that
@@ -55,42 +61,77 @@ export const FORBIDDEN_FLAGS = [
55
61
  // (settings, agents, a replaced system prompt, an old session, an IDE or
56
62
  // browser link, a remote session).
57
63
  export const RESERVED_FLAGS = [
58
- '--print', '--allowed-tools', '--settings', '--agent', '--agents',
59
- '--system-prompt', '--system-prompt-file', '--append-system-prompt-file',
60
- '--continue', '-c', '--resume', '-r', '--session-id', '--fork-session', '--from-pr', '--teleport',
61
- '--input-format', '--file', '--worktree', '-w', '--tmux', '--background', '--bg',
62
- '--chrome', '--ide', '--remote-control', '--cloud', '--environment', '--desktop',
64
+ '--print',
65
+ '--allowed-tools',
66
+ '--settings',
67
+ '--agent',
68
+ '--agents',
69
+ '--system-prompt',
70
+ '--system-prompt-file',
71
+ '--append-system-prompt-file',
72
+ '--continue',
73
+ '-c',
74
+ '--resume',
75
+ '-r',
76
+ '--session-id',
77
+ '--fork-session',
78
+ '--from-pr',
79
+ '--teleport',
80
+ '--input-format',
81
+ '--file',
82
+ '--worktree',
83
+ '-w',
84
+ '--tmux',
85
+ '--background',
86
+ '--bg',
87
+ '--chrome',
88
+ '--ide',
89
+ '--remote-control',
90
+ '--cloud',
91
+ '--environment',
92
+ '--desktop',
63
93
  ];
64
94
 
65
95
  /** Read deny rules for the paths no consultant may read. */
66
96
  // Claude Code matches Windows paths in POSIX form (C:\\Users -> /c/Users), so
67
97
  // an absolute rule names the drive that way: //c/Users/..., not //C:/Users/....
68
- export const posixAbsolute = (p) => p.replaceAll('\\', '/').replace(/^([A-Za-z]):/, (_, d) => `/${d.toLowerCase()}`);
98
+ export const posixAbsolute = (/** @type {string} */ p) =>
99
+ p.replaceAll('\\', '/').replace(/^([A-Za-z]):/, (_, /** @type {string} */ d) => `/${d.toLowerCase()}`);
69
100
 
70
101
  export function protectedReadRules() {
71
102
  const home = os.homedir().replaceAll('\\', '/');
72
- const spec = (root) => (root.startsWith(`${home}/`) ? `~/${root.slice(home.length + 1)}` : `//${posixAbsolute(root).replace(/^\/+/, '')}`);
103
+ const spec = (/** @type {string} */ root) =>
104
+ root.startsWith(`${home}/`) ? `~/${root.slice(home.length + 1)}` : `//${posixAbsolute(root).replace(/^\/+/, '')}`;
73
105
  return [
74
106
  ...protectedRoots().flatMap((r) => [`Read(${spec(r)})`, `Read(${spec(r)}/**)`]),
75
107
  ...PROTECTED_FILE_NAMES.map((n) => `Read(//**/${n})`),
76
108
  ];
77
109
  }
78
110
 
111
+ /**
112
+ * @param {BuildRequest} req
113
+ * @returns {Invocation}
114
+ */
79
115
  export function buildInvocation({ workdir, guardrails, consultant, hasExposedPaths = false, network = 'on' }) {
80
116
  const t = POLICY.clis['claude-code'];
81
117
  const chosen = consultant.model;
82
118
  const web = network !== 'off';
83
119
  const args = [
84
120
  '-p',
85
- '--model', chosen,
86
- '--effort', consultant.effort ?? 'high',
121
+ '--model',
122
+ chosen,
123
+ '--effort',
124
+ consultant.effort ?? 'high',
87
125
  '--restricted',
88
126
  '--strict-mcp-config',
89
- '--setting-sources', '',
127
+ '--setting-sources',
128
+ '',
90
129
  '--disable-slash-commands',
91
- '--tools', web ? 'WebSearch,WebFetch,Read,Glob,Grep' : 'Read,Glob,Grep',
130
+ '--tools',
131
+ web ? 'WebSearch,WebFetch,Read,Glob,Grep' : 'Read,Glob,Grep',
92
132
  ...(web ? ['--allowedTools', 'WebSearch,WebFetch'] : []),
93
- '--settings', JSON.stringify({ permissions: { deny: protectedReadRules() } }),
133
+ '--settings',
134
+ JSON.stringify({ permissions: { deny: protectedReadRules() } }),
94
135
  // When the lead has exposed paths, they are already copied into the
95
136
  // working directory --restricted confines the file tools to, and the
96
137
  // lead has said what this consultation may see. Widening to the whole
@@ -98,36 +139,73 @@ export function buildInvocation({ workdir, guardrails, consultant, hasExposedPat
98
139
  // flag goes. Equal-reader parity is not lost: what every consultant now
99
140
  // has is the same copied tree, which is the material that exists.
100
141
  ...(hasExposedPaths ? [] : ['--add-dir', '/']),
101
- '--permission-prompts', 'none',
102
- '--permission-mode', 'manual',
142
+ '--permission-prompts',
143
+ 'none',
144
+ '--permission-mode',
145
+ 'manual',
103
146
  '--no-session-persistence',
104
- '--output-format', 'json',
105
- '--json-schema', JSON.stringify(CONSULT_RESULT_SCHEMA),
106
- '--max-budget-usd', String(consultant.maxBudgetUsd ?? 10),
107
- '--append-system-prompt', guardrails,
147
+ '--output-format',
148
+ 'json',
149
+ '--json-schema',
150
+ JSON.stringify(CONSULT_RESULT_SCHEMA),
151
+ '--max-budget-usd',
152
+ String(consultant.maxBudgetUsd ?? 10),
153
+ '--append-system-prompt',
154
+ guardrails,
108
155
  ];
109
156
  return { command: t.cli, args, model: chosen, workdir };
110
157
  }
111
158
 
159
+ /**
160
+ * The result JSON the CLI prints; the CLI controls it, so every field is optional.
161
+ * @typedef {object} ClaudeResult
162
+ * @property {unknown} [is_error]
163
+ * @property {unknown} [result]
164
+ * @property {string | Record<string, unknown>} [structured_output]
165
+ * @property {string | Record<string, unknown>} [structuredOutput]
166
+ * @property {unknown[]} [permission_denials]
167
+ * @property {{ input_tokens?: unknown, cache_read_input_tokens?: unknown, output_tokens?: unknown, server_tool_use?: { web_search_requests?: unknown } }} [usage]
168
+ * @property {unknown} [total_cost_usd]
169
+ * @property {unknown} [num_turns]
170
+ */
171
+
172
+ /**
173
+ * @param {string} [stdout]
174
+ * @returns {ClaudeResult | null}
175
+ */
112
176
  function lastJsonObject(stdout) {
113
- const lines = (stdout || '').split('\n');
114
- for (let i = lines.length - 1; i >= 0; i--) {
115
- const t = lines[i].trim();
177
+ const lines = (stdout || '').split('\n').reverse();
178
+ for (const line of lines) {
179
+ const t = line.trim();
116
180
  if (!t.startsWith('{')) continue;
117
181
  try {
118
182
  const parsed = JSON.parse(t);
119
183
  if (parsed && typeof parsed === 'object') return parsed;
120
- } catch { /* not the result line */ }
184
+ } catch {
185
+ /* not the result line */
186
+ }
121
187
  }
122
188
  return null;
123
189
  }
124
190
 
191
+ /**
192
+ * @param {{ stdout?: string, stderr?: string, code?: number | null }} run
193
+ * @returns {Interpreted}
194
+ */
125
195
  export function interpret({ stdout, stderr, code }) {
126
196
  const payload = lastJsonObject(stdout);
127
197
  if (!payload) {
128
- const msg = (stderr || '').trim() || (stdout || '').trim().slice(0, 2000) ||
198
+ const msg =
199
+ (stderr || '').trim() ||
200
+ (stdout || '').trim().slice(0, 2000) ||
129
201
  `claude exited with code ${code} and produced no JSON result`;
130
- return { ok: false, failureKind: code === 0 ? 'invalid_output' : classifyMessage(msg), message: msg, payload: null, usageRaw: null };
202
+ return {
203
+ ok: false,
204
+ failureKind: code === 0 ? 'invalid_output' : classifyMessage(msg),
205
+ message: msg,
206
+ payload: null,
207
+ usageRaw: null,
208
+ };
131
209
  }
132
210
  if (payload.is_error) {
133
211
  const msg = typeof payload.result === 'string' ? payload.result : JSON.stringify(payload).slice(0, 2000);
@@ -136,15 +214,27 @@ export function interpret({ stdout, stderr, code }) {
136
214
  const structured = payload.structured_output ?? payload.structuredOutput ?? null;
137
215
  const text = structured ?? (typeof payload.result === 'string' ? payload.result : null);
138
216
  if (!text) {
139
- return { ok: false, failureKind: 'invalid_output', message: 'result payload contained no answer', payload, usageRaw: payload };
217
+ return {
218
+ ok: false,
219
+ failureKind: 'invalid_output',
220
+ message: 'result payload contained no answer',
221
+ payload,
222
+ usageRaw: payload,
223
+ };
140
224
  }
141
225
  return { ok: true, text, payload, usageRaw: payload, denials: payload.permission_denials ?? [] };
142
226
  }
143
227
 
144
- export function usageRecord(payload) {
228
+ /**
229
+ * @param {unknown} raw the CLI's result JSON, as interpret kept it
230
+ * @returns {UsageRecord | null}
231
+ */
232
+ export function usageRecord(raw) {
233
+ // interpret's usageRaw is the ClaudeResult it parsed (or null).
234
+ const payload = /** @type {ClaudeResult | null} */ (raw);
145
235
  if (!payload) return null;
146
236
  const u = payload.usage ?? {};
147
- const num = (v) => (typeof v === 'number' ? v : null);
237
+ const num = (/** @type {unknown} */ v) => (typeof v === 'number' ? v : null);
148
238
  return {
149
239
  input_tokens: num(u.input_tokens),
150
240
  cached_input_tokens: num(u.cache_read_input_tokens),
@@ -160,9 +250,12 @@ export function usageRecord(payload) {
160
250
  * What this consultant was launched with, read off the invocation: the
161
251
  * boundary record kept beside each answer. Facts about the launch, not a
162
252
  * claim about what the consultant did.
253
+ * @param {{ args: string[], hasExposedPaths?: boolean }} req
254
+ * @returns {Boundary}
163
255
  */
164
256
  export function boundary({ args, hasExposedPaths }) {
165
- const tools = args[args.indexOf('--tools') + 1];
257
+ // buildInvocation always passes --tools with a value.
258
+ const tools = /** @type {string} */ (args[args.indexOf('--tools') + 1]);
166
259
  return {
167
260
  reads: hasExposedPaths ? 'the exposed copy only' : 'whole disk',
168
261
  shell: 'none',