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.
Files changed (40) hide show
  1. package/README.ja.md +44 -19
  2. package/README.md +44 -18
  3. package/package.json +3 -1
  4. package/plugins/severally/.claude-plugin/plugin.json +1 -1
  5. package/plugins/severally/.codex-plugin/plugin.json +1 -1
  6. package/plugins/severally/dist/severally-mcp.mjs +585 -255
  7. package/plugins/severally/skills/_template/reference.md.tmpl +2 -2
  8. package/plugins/severally/skills/antigravity/severally/reference.md +2 -2
  9. package/plugins/severally/skills/claude/severally/reference.md +2 -2
  10. package/plugins/severally/skills/codex/severally/reference.md +2 -2
  11. package/plugins/severally/skills/opencode/severally/reference.md +2 -2
  12. package/scripts/build.mjs +57 -54
  13. package/scripts/doctor.mjs +15 -4
  14. package/scripts/install.mjs +73 -9
  15. package/scripts/live-check.mjs +11 -5
  16. package/scripts/live-mcp-check.mjs +10 -4
  17. package/src/adapters/antigravity-sandbox.mjs +24 -6
  18. package/src/adapters/antigravity.mjs +80 -17
  19. package/src/adapters/claude-code.mjs +49 -8
  20. package/src/adapters/codex.mjs +56 -6
  21. package/src/adapters/opencode-sandbox.mjs +20 -2
  22. package/src/adapters/opencode.mjs +115 -19
  23. package/src/brief.mjs +84 -7
  24. package/src/consultants.mjs +184 -25
  25. package/src/cross-spawn-escape.d.ts +7 -0
  26. package/src/export.mjs +50 -6
  27. package/src/expose-paths.mjs +149 -7
  28. package/src/failures.mjs +8 -1
  29. package/src/jobs.mjs +578 -132
  30. package/src/jsonc.mjs +11 -3
  31. package/src/parse-result.mjs +91 -14
  32. package/src/platform.mjs +52 -2
  33. package/src/policy.mjs +151 -28
  34. package/src/redact.mjs +22 -3
  35. package/src/result-schema.mjs +13 -0
  36. package/src/run.mjs +158 -12
  37. package/src/schema.mjs +84 -11
  38. package/src/server.mjs +49 -14
  39. package/src/status.mjs +16 -4
  40. package/src/store.mjs +287 -6
@@ -40,7 +40,10 @@ export const SANDBOX_DENY = Object.freeze(['write_file(*)', 'command(*)', 'mcp(*
40
40
  // call also passes a PreToolUse hook that allows these names and denies the
41
41
  // rest -- an allowlist, so a tool a later agy release adds is refused until it
42
42
  // is added here. The names are agy's own (its init toolset), not rule names.
43
- export const ALLOWED_TOOLS = Object.freeze(['view_file', 'search_web', 'read_url_content', 'finish']);
43
+ // agy 1.4.3 reads files with read_file; earlier releases called it view_file.
44
+ // With only view_file listed, every read on 1.4.3 was refused and the
45
+ // consultant answered from the brief alone (2026-10-20).
46
+ export const ALLOWED_TOOLS = Object.freeze(['view_file', 'read_file', 'search_web', 'read_url_content', 'finish']);
44
47
  const WEB_TOOLS = new Set(['search_web', 'read_url_content']);
45
48
 
46
49
  // agy pipes the pending call to the hook as {"toolCall":{"name":...,
@@ -59,15 +62,16 @@ const WEB_TOOLS = new Set(['search_web', 'read_url_content']);
59
62
  // expanded: a consultant that can browse must not read a credential and
60
63
  // carry it out in a URL. Other tools' parameters -- the final answer passed
61
64
  // to finish among them -- are text, not paths, and are not inspected.
62
- const PATH_TOOLS = ['view_file', 'read_url_content'];
65
+ const PATH_TOOLS = ['view_file', 'read_file', 'read_url_content'];
63
66
 
64
67
  // read_url_content does not hand a long page to the model: it saves it inside
65
68
  // the sandbox home and tells the model to view_file that copy (agy 1.4.0,
66
69
  // 2026-10-17). The sandbox home is protected as a whole, so without this one
67
70
  // exception no long page could be read. Only that file shape, only in this
68
71
  // sandbox: the rest of the home (credential link, transcripts) stays refused.
72
+ /** @param {string} root */
69
73
  function spillPattern(root) {
70
- const esc = (s) => s.replaceAll('\\', '/').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
74
+ const esc = (/** @type {string} */ s) => s.replaceAll('\\', '/').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
71
75
  let real = root;
72
76
  try {
73
77
  real = fs.realpathSync.native(root);
@@ -78,6 +82,13 @@ function spillPattern(root) {
78
82
  return `^(?:${roots})/\\.gemini/antigravity-cli/brain/[^/]+/\\.system_generated/steps/[0-9]+/content\\.md$`;
79
83
  }
80
84
 
85
+ /**
86
+ * @param {readonly string[]} allowed
87
+ * @param {string[]} rootPatterns
88
+ * @param {string[]} namePatterns
89
+ * @param {string} home
90
+ * @param {string} spill
91
+ */
81
92
  function hookSource(allowed, rootPatterns, namePatterns, home, spill) {
82
93
  return `import fs from 'node:fs';
83
94
  import path from 'node:path';
@@ -150,17 +161,24 @@ process.stdin.on('end', () => {
150
161
  `;
151
162
  }
152
163
 
153
- /** The tools the hook lets through: no web tools when the request turned the network off. */
164
+ /**
165
+ * The tools the hook lets through: no web tools when the request turned the network off.
166
+ * @param {'on' | 'off'} [network]
167
+ */
154
168
  export function allowedTools(network = 'on') {
155
169
  return ALLOWED_TOOLS.filter((t) => network !== 'off' || !WEB_TOOLS.has(t));
156
170
  }
157
171
 
158
- /** The settings.json permission rules; with the network off, read_url moves to deny. */
172
+ /**
173
+ * The settings.json permission rules; with the network off, read_url moves to deny.
174
+ * @param {'on' | 'off'} [network]
175
+ */
159
176
  export function sandboxPermissions(network = 'on') {
160
177
  if (network !== 'off') return { allow: [...SANDBOX_ALLOW], deny: [...SANDBOX_DENY] };
161
178
  return { allow: SANDBOX_ALLOW.filter((r) => r !== 'read_url(*)'), deny: [...SANDBOX_DENY, 'read_url(*)'] };
162
179
  }
163
180
 
181
+ /** @param {string} script */
164
182
  function hookCommand(script) {
165
183
  return [process.execPath, script]
166
184
  .map((v) => {
@@ -175,7 +193,7 @@ const TOKEN_REL = path.join('.gemini', 'antigravity-cli', 'antigravity-oauth-tok
175
193
 
176
194
  /**
177
195
  * Build the synthesised HOME for one consultation.
178
- * @param {{workdir: string, network?: 'on' | 'off'}} opts workdir is <jobdir>/work; the home is store.sandboxHome(<job id>).
196
+ * @param {{workdir: string, network?: 'on' | 'off' | undefined}} opts workdir is <jobdir>/work; the home is store.sandboxHome(<job id>).
179
197
  */
180
198
  export function prepareSandbox({ workdir, network = 'on' }) {
181
199
  const root = sandboxHome(path.basename(path.dirname(workdir)));
@@ -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;
@@ -76,8 +87,30 @@ export function buildInvocation({ schemaPath, consultant }) {
76
87
  return { command: t.cli, args, model: chosen };
77
88
  }
78
89
 
79
- /** 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
+ */
80
112
  function events(stdout) {
113
+ /** @type {AgyEvent[]} */
81
114
  const out = [];
82
115
  for (const line of (stdout || '').split('\n')) {
83
116
  const t = line.trim();
@@ -96,11 +129,11 @@ function events(stdout) {
96
129
  * The final envelope: the `result` event under stream-json, or a bare envelope
97
130
  * when agy was run with --output-format json (as older configs and the unit
98
131
  * tests do).
132
+ * @param {string} [stdout]
133
+ * @returns {AgyEnvelope | null}
99
134
  */
100
135
  function finalEnvelope(stdout) {
101
- const all = events(stdout);
102
- for (let i = all.length - 1; i >= 0; i--) {
103
- const e = all[i];
136
+ for (const e of events(stdout).reverse()) {
104
137
  if (e.event === 'result' && e.result && typeof e.result === 'object') return e.result;
105
138
  if (e.event === undefined && (e.status || e.response || e.structured_output)) return e;
106
139
  }
@@ -113,25 +146,35 @@ function finalEnvelope(stdout) {
113
146
  * one tool call", which is the difference between raising the budget and
114
147
  * narrowing the brief; and it lets the lead cut a heavy consultation short
115
148
  * instead of waiting out the whole budget for nothing.
149
+ * @param {{ stdout: string }} run
150
+ * @returns {string | null}
116
151
  */
117
152
  export function progress({ stdout }) {
118
- const steps = events(stdout).filter((e) => e.event === 'step_update' && e.step_update);
119
- if (!steps.length) return null;
120
- const last = steps[steps.length - 1].step_update;
121
- 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;
122
157
  const what = last.tool_name ? `tool ${last.tool_name}` : (last.step_type ?? 'a step');
123
158
  return `${seen} step(s), last was ${what} (${last.state ?? 'state unknown'})`;
124
159
  }
125
160
 
126
- /** 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
+ */
127
169
  export function toolCalls({ stdout }) {
170
+ /** @type {Map<unknown, string>} */
128
171
  const seen = new Map();
129
172
  for (const e of events(stdout)) {
130
173
  const s = e.event === 'step_update' ? e.step_update : null;
131
174
  if (s?.tool_name)
132
175
  seen.set(s.step_index ?? seen.size, /error|fail|cancel|denied/i.test(s.state ?? '') ? `${s.tool_name}:error` : s.tool_name);
133
176
  }
134
- 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 }), {});
135
178
  }
136
179
 
137
180
  /**
@@ -139,6 +182,8 @@ export function toolCalls({ stdout }) {
139
182
  * (job_790a7173c16d) agy wrote its finish step and then stayed alive for six
140
183
  * minutes; the server waits for the process to close, so without this the
141
184
  * consultation would have sat "running" until its budget ran out.
185
+ * @param {string} line
186
+ * @returns {number | null}
142
187
  */
143
188
  export function settleAfter(line) {
144
189
  if (!line.includes('"result"') && !line.includes('"finish"')) return null;
@@ -163,10 +208,12 @@ export const stopSignal = 'SIGQUIT';
163
208
  * goroutine dump and is written inside the sandbox HOME that is removed when
164
209
  * the consultation ends, and whatever reached the stderr pipe. Not redacted
165
210
  * here; the caller does that.
211
+ * @param {{ run: import('../run.mjs').ChildResult, sandbox: { root: string } }} args
166
212
  */
167
213
  export function diagnostics({ run, sandbox }) {
168
- const tail = (s) => String(s ?? '').slice(-200_000);
214
+ const tail = (/** @type {unknown} */ s) => String(s ?? '').slice(-200_000);
169
215
  const dir = path.join(sandbox.root, '.gemini', 'antigravity-cli', 'log');
216
+ /** @type {string[]} */
170
217
  let names = [];
171
218
  try {
172
219
  names = fs.readdirSync(dir).sort();
@@ -187,6 +234,10 @@ export function diagnostics({ run, sandbox }) {
187
234
  ].join('\n');
188
235
  }
189
236
 
237
+ /**
238
+ * @param {{ stdout?: string, stderr?: string, code?: number | null }} run
239
+ * @returns {Interpreted}
240
+ */
190
241
  export function interpret({ stdout, stderr, code }) {
191
242
  const payload = finalEnvelope(stdout);
192
243
  if (!payload) {
@@ -209,10 +260,16 @@ export function interpret({ stdout, stderr, code }) {
209
260
  return { ok: true, text, usageRaw: payload, denials: payload.denied_actions ?? [] };
210
261
  }
211
262
 
212
- 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);
213
270
  if (!payload) return null;
214
271
  const u = payload.usage ?? {};
215
- const num = (v) => (typeof v === 'number' ? v : null);
272
+ const num = (/** @type {unknown} */ v) => (typeof v === 'number' ? v : null);
216
273
  return {
217
274
  input_tokens: num(u.input_tokens),
218
275
  cached_input_tokens: num(u.cache_read_tokens),
@@ -224,7 +281,9 @@ export function usageRecord(payload) {
224
281
  turns: num(payload.num_turns),
225
282
  // Which permissions the sandbox refused. A denied read_url usually explains
226
283
  // a thin evidence basis, so it is worth keeping in the record.
227
- denied_actions: Array.isArray(payload.denied_actions) ? payload.denied_actions.map((d) => d?.action).filter(Boolean) : null,
284
+ denied_actions: Array.isArray(payload.denied_actions)
285
+ ? payload.denied_actions.flatMap((d) => (d?.action ? [d.action] : []))
286
+ : null,
228
287
  };
229
288
  }
230
289
 
@@ -232,15 +291,19 @@ export function usageRecord(payload) {
232
291
  * What this consultant was launched with, read off the invocation: the
233
292
  * boundary record kept beside each answer. Facts about the launch, not a
234
293
  * claim about what the consultant did.
294
+ * @param {{ sandbox: Sandbox | null }} req
295
+ * @returns {Boundary}
235
296
  */
236
297
  export function boundary({ sandbox }) {
298
+ // buildInvocation always runs with the sandbox prepareSandbox made.
299
+ const { tools } = /** @type {Sandbox} */ (sandbox);
237
300
  return {
238
301
  reads: 'whole disk',
239
302
  shell: 'none',
240
- web: sandbox.tools.includes('search_web'),
303
+ web: tools.includes('search_web'),
241
304
  writes: false,
242
305
  mcp: false,
243
306
  protected_paths: 'denied (tool hook)',
244
- tools: [...sandbox.tools],
307
+ tools: [...tools],
245
308
  };
246
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
@@ -89,11 +95,12 @@ export const RESERVED_FLAGS = [
89
95
  /** Read deny rules for the paths no consultant may read. */
90
96
  // Claude Code matches Windows paths in POSIX form (C:\\Users -> /c/Users), so
91
97
  // an absolute rule names the drive that way: //c/Users/..., not //C:/Users/....
92
- 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()}`);
93
100
 
94
101
  export function protectedReadRules() {
95
102
  const home = os.homedir().replaceAll('\\', '/');
96
- const spec = (root) =>
103
+ const spec = (/** @type {string} */ root) =>
97
104
  root.startsWith(`${home}/`) ? `~/${root.slice(home.length + 1)}` : `//${posixAbsolute(root).replace(/^\/+/, '')}`;
98
105
  return [
99
106
  ...protectedRoots().flatMap((r) => [`Read(${spec(r)})`, `Read(${spec(r)}/**)`]),
@@ -101,6 +108,10 @@ export function protectedReadRules() {
101
108
  ];
102
109
  }
103
110
 
111
+ /**
112
+ * @param {BuildRequest} req
113
+ * @returns {Invocation}
114
+ */
104
115
  export function buildInvocation({ workdir, guardrails, consultant, hasExposedPaths = false, network = 'on' }) {
105
116
  const t = POLICY.clis['claude-code'];
106
117
  const chosen = consultant.model;
@@ -145,10 +156,27 @@ export function buildInvocation({ workdir, guardrails, consultant, hasExposedPat
145
156
  return { command: t.cli, args, model: chosen, workdir };
146
157
  }
147
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
+ */
148
176
  function lastJsonObject(stdout) {
149
- const lines = (stdout || '').split('\n');
150
- for (let i = lines.length - 1; i >= 0; i--) {
151
- const t = lines[i].trim();
177
+ const lines = (stdout || '').split('\n').reverse();
178
+ for (const line of lines) {
179
+ const t = line.trim();
152
180
  if (!t.startsWith('{')) continue;
153
181
  try {
154
182
  const parsed = JSON.parse(t);
@@ -160,6 +188,10 @@ function lastJsonObject(stdout) {
160
188
  return null;
161
189
  }
162
190
 
191
+ /**
192
+ * @param {{ stdout?: string, stderr?: string, code?: number | null }} run
193
+ * @returns {Interpreted}
194
+ */
163
195
  export function interpret({ stdout, stderr, code }) {
164
196
  const payload = lastJsonObject(stdout);
165
197
  if (!payload) {
@@ -193,10 +225,16 @@ export function interpret({ stdout, stderr, code }) {
193
225
  return { ok: true, text, payload, usageRaw: payload, denials: payload.permission_denials ?? [] };
194
226
  }
195
227
 
196
- 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);
197
235
  if (!payload) return null;
198
236
  const u = payload.usage ?? {};
199
- const num = (v) => (typeof v === 'number' ? v : null);
237
+ const num = (/** @type {unknown} */ v) => (typeof v === 'number' ? v : null);
200
238
  return {
201
239
  input_tokens: num(u.input_tokens),
202
240
  cached_input_tokens: num(u.cache_read_input_tokens),
@@ -212,9 +250,12 @@ export function usageRecord(payload) {
212
250
  * What this consultant was launched with, read off the invocation: the
213
251
  * boundary record kept beside each answer. Facts about the launch, not a
214
252
  * claim about what the consultant did.
253
+ * @param {{ args: string[], hasExposedPaths?: boolean }} req
254
+ * @returns {Boundary}
215
255
  */
216
256
  export function boundary({ args, hasExposedPaths }) {
217
- 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]);
218
259
  return {
219
260
  reads: hasExposedPaths ? 'the exposed copy only' : 'whole disk',
220
261
  shell: 'none',
@@ -56,6 +56,13 @@ import path from 'node:path';
56
56
  import { POLICY, protectedRoots } from '../policy.mjs';
57
57
  import { classifyMessage } from '../failures.mjs';
58
58
 
59
+ /** @typedef {import('../run.mjs').BuildRequest} BuildRequest */
60
+ /** @typedef {import('../run.mjs').ChildResult} ChildResult */
61
+ /** @typedef {import('../run.mjs').Invocation} Invocation */
62
+ /** @typedef {import('../run.mjs').Interpreted} Interpreted */
63
+ /** @typedef {import('../run.mjs').UsageRecord} UsageRecord */
64
+ /** @typedef {import('../run.mjs').Boundary} Boundary */
65
+
59
66
  export const FORBIDDEN_FLAGS = [
60
67
  '--dangerously-bypass-approvals-and-sandbox',
61
68
  '--dangerously-bypass-hook-trust',
@@ -85,6 +92,7 @@ export const RESERVED_FLAGS = [
85
92
 
86
93
  const PROFILE = 'severally';
87
94
 
95
+ /** @param {string} dir @returns {string[]} */
88
96
  const listDir = (dir) => {
89
97
  try {
90
98
  return fs.readdirSync(dir);
@@ -120,6 +128,10 @@ export function filesystemPermissions() {
120
128
  return `{ ":root" = "read", ${denied.map((p) => `${JSON.stringify(p)} = "none"`).join(', ')} }`;
121
129
  }
122
130
 
131
+ /**
132
+ * @param {BuildRequest} req
133
+ * @returns {Invocation}
134
+ */
123
135
  export function buildInvocation({ workdir, schemaPath, consultant, network = 'on' }) {
124
136
  const t = POLICY.clis.codex;
125
137
  const chosen = consultant.model;
@@ -163,6 +175,22 @@ export function buildInvocation({ workdir, schemaPath, consultant, network = 'on
163
175
  return { command: t.cli, args, lastMessagePath, model: chosen };
164
176
  }
165
177
 
178
+ /**
179
+ * One JSONL event from `codex exec --json`, or the item inside it; the CLI
180
+ * controls both, so every field is optional.
181
+ * @typedef {object} CodexItem
182
+ * @property {unknown} [type]
183
+ * @property {unknown} [text]
184
+ * @property {unknown} [message]
185
+ * @property {unknown} [usage]
186
+ * @typedef {CodexItem & { error?: { message?: unknown }, item?: CodexItem }} CodexEvent
187
+ */
188
+
189
+ /**
190
+ * @param {CodexItem | null | undefined} node
191
+ * @param {Record<string, number>} acc
192
+ * @returns {void}
193
+ */
166
194
  function walkUsage(node, acc) {
167
195
  if (!node || typeof node !== 'object') return;
168
196
  if (node.usage && typeof node.usage === 'object') {
@@ -174,8 +202,13 @@ function walkUsage(node, acc) {
174
202
  for (const v of Object.values(node)) if (v && typeof v === 'object') walkUsage(v, acc);
175
203
  }
176
204
 
177
- /** Parse the JSONL event stream `codex exec --json` writes to stdout. */
205
+ /**
206
+ * Parse the JSONL event stream `codex exec --json` writes to stdout.
207
+ * @param {Partial<ChildResult> & { lastMessageText?: string }} run
208
+ * @returns {Interpreted}
209
+ */
178
210
  export function interpret({ stdout, stderr, code, lastMessageText }) {
211
+ /** @type {CodexEvent[]} */
179
212
  const events = [];
180
213
  for (const line of (stdout || '').split('\n')) {
181
214
  const t = line.trim();
@@ -188,14 +221,15 @@ export function interpret({ stdout, stderr, code, lastMessageText }) {
188
221
  }
189
222
 
190
223
  const errorEvents = events.filter((e) => e.type === 'error' || e.type === 'turn.failed');
224
+ /** @type {Record<string, number>} */
191
225
  const usage = {};
192
226
  for (const e of events) walkUsage(e, usage);
193
227
 
194
228
  let text = (lastMessageText || '').trim();
195
229
  if (!text) {
196
230
  // Fall back to the last assistant message carried in the event stream.
197
- for (let i = events.length - 1; i >= 0 && !text; i--) {
198
- const e = events[i];
231
+ for (const e of events.toReversed()) {
232
+ if (text) break;
199
233
  const item = e.item ?? e;
200
234
  const candidate =
201
235
  (item && item.type && String(item.type).includes('agent_message') && (item.text ?? item.message)) ||
@@ -237,6 +271,8 @@ export function interpret({ stdout, stderr, code, lastMessageText }) {
237
271
  * What this consultation has done so far. Codex already runs with `exec --json`,
238
272
  * so its event stream is there for the reading -- no flag change and no parser
239
273
  * change, only a use for events we were otherwise discarding.
274
+ * @param {{ stdout?: string }} run
275
+ * @returns {string | null}
240
276
  */
241
277
  export function progress({ stdout }) {
242
278
  const items = [];
@@ -260,8 +296,11 @@ export function progress({ stdout }) {
260
296
  * Tool calls seen in the event stream, counted by item type (and by tool for
261
297
  * MCP and collaboration calls, which is where a sub-agent spawn shows up);
262
298
  * `:error` when the item ended failed or declined.
299
+ * @param {{ stdout?: string }} run
300
+ * @returns {Record<string, number>}
263
301
  */
264
302
  export function toolCalls({ stdout }) {
303
+ /** @type {Map<unknown, string>} */
265
304
  const seen = new Map();
266
305
  for (const line of (stdout || '').split('\n')) {
267
306
  const t = line.trim();
@@ -279,13 +318,22 @@ export function toolCalls({ stdout }) {
279
318
  const name = item.status === 'failed' || item.status === 'declined' ? `${base}:error` : base;
280
319
  seen.set(item.id ?? `${name}#${seen.size}`, name);
281
320
  }
282
- return [...seen.values()].reduce((o, n) => ({ ...o, [n]: (o[n] ?? 0) + 1 }), {});
321
+ return [...seen.values()].reduce((/** @type {Record<string, number>} */ o, n) => ({ ...o, [n]: (o[n] ?? 0) + 1 }), {});
283
322
  }
284
323
 
324
+ /**
325
+ * @param {unknown} raw the usage figures interpret collected from the event stream
326
+ * @returns {UsageRecord}
327
+ */
285
328
  export function usageRecord(raw) {
286
- const u = raw ?? {};
329
+ // interpret's usageRaw is the Record<string, number> walkUsage filled (or null).
330
+ const u = /** @type {Record<string, number>} */ (raw ?? {});
331
+ /** @param {...string} names */
287
332
  const pick = (...names) => {
288
- for (const n of names) if (typeof u[n] === 'number') return u[n];
333
+ for (const n of names) {
334
+ const v = u[n];
335
+ if (typeof v === 'number') return v;
336
+ }
289
337
  return null;
290
338
  };
291
339
  return {
@@ -302,6 +350,8 @@ export function usageRecord(raw) {
302
350
  * What this consultant was launched with, read off the invocation: the
303
351
  * boundary record kept beside each answer. Facts about the launch, not a
304
352
  * claim about what the consultant did.
353
+ * @param {{ args: string[] }} launch
354
+ * @returns {Boundary}
305
355
  */
306
356
  export function boundary({ args }) {
307
357
  return {
@@ -48,6 +48,7 @@ import { sandboxHome } from '../store.mjs';
48
48
  // node:sqlite ships unflagged from Node 23 (and late 22.x); the engines field
49
49
  // allows older runtimes, where credential seeding degrades to a no-op and the
50
50
  // job fails with the CLI's own auth error instead.
51
+ /** @type {typeof import('node:sqlite').DatabaseSync | null} */
51
52
  let DatabaseSync = null;
52
53
  try {
53
54
  ({ DatabaseSync } = sqliteRequire(import.meta.url)('node:sqlite'));
@@ -103,9 +104,22 @@ function readRules() {
103
104
 
104
105
  const AUTH_REL = path.join('.local', 'share', 'opencode', 'auth.json');
105
106
 
107
+ /**
108
+ * The synthesised HOME of one consultation, as prepareSandbox returns it.
109
+ * @typedef {object} OpencodeSandbox
110
+ * @property {string} root
111
+ * @property {string} credentials 'copy' or 'missing'
112
+ * @property {string[]} tools
113
+ * @property {string} credentialsSource
114
+ * @property {Record<string, string>} env
115
+ * @property {() => void} cleanup
116
+ * @property {(provider: string) => boolean} seed insert the provider's credential into the session database
117
+ */
118
+
106
119
  /**
107
120
  * Build the synthesised HOME for one consultation.
108
- * @param {{workdir: string, network?: 'on' | 'off'}} opts workdir is <jobdir>/work; the home is store.sandboxHome(<job id>).
121
+ * @param {{workdir: string, network?: 'on' | 'off' | undefined}} opts workdir is <jobdir>/work; the home is store.sandboxHome(<job id>).
122
+ * @returns {OpencodeSandbox}
109
123
  */
110
124
  export function prepareSandbox({ workdir, network = 'on' }) {
111
125
  const root = sandboxHome(path.basename(path.dirname(workdir)));
@@ -163,6 +177,7 @@ export function prepareSandbox({ workdir, network = 'on' }) {
163
177
  credentials = 'copy';
164
178
  }
165
179
 
180
+ /** @type {OpencodeSandbox} */
166
181
  const sandbox = {
167
182
  root,
168
183
  credentials,
@@ -198,8 +213,8 @@ export function prepareSandbox({ workdir, network = 'on' }) {
198
213
  /* best effort */
199
214
  }
200
215
  },
216
+ seed: (provider) => seedCredentialFromAuth(sandbox, provider),
201
217
  };
202
- sandbox.seed = (provider) => seedCredentialFromAuth(sandbox, provider);
203
218
  return sandbox;
204
219
  }
205
220
 
@@ -214,6 +229,8 @@ export function prepareSandbox({ workdir, network = 'on' }) {
214
229
  * -- note "key" (the method), not the "api" auth.json uses. Only api-key
215
230
  * entries are seeded; OAuth tokens have a different shape and lifecycle.
216
231
  *
232
+ * @param {{ root: string }} sandbox
233
+ * @param {unknown} provider
217
234
  * @returns {boolean} true when a row was written and a retry is worth it.
218
235
  */
219
236
  export function seedCredentialFromAuth(sandbox, provider) {
@@ -225,6 +242,7 @@ export function seedCredentialFromAuth(sandbox, provider) {
225
242
  // CLI's own migrations, so an unbootstrapped sandbox is simply not seedable.
226
243
  if (!fs.existsSync(dbPath)) return false;
227
244
 
245
+ /** @type {{ label: import('node:sqlite').SQLInputValue, value: string } | null} */
228
246
  let row = null;
229
247
  // The operator's session database is what the 2.x CLI itself authenticates
230
248
  // with, so it outranks the copied auth.json: an operator who rotated their