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
|
@@ -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',
|
|
67
|
-
'
|
|
77
|
+
'--output-format',
|
|
78
|
+
'stream-json',
|
|
79
|
+
'--json-schema',
|
|
80
|
+
schemaPath,
|
|
68
81
|
'--disable-slash-commands',
|
|
69
|
-
'--model',
|
|
70
|
-
|
|
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
|
-
/**
|
|
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 {
|
|
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
|
|
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).
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
const seen = new Set(steps.map((s) => s.
|
|
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
|
-
/**
|
|
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)
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
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:
|
|
303
|
+
web: tools.includes('search_web'),
|
|
223
304
|
writes: false,
|
|
224
305
|
mcp: false,
|
|
225
306
|
protected_paths: 'denied (tool hook)',
|
|
226
|
-
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',
|
|
59
|
-
'--
|
|
60
|
-
'--
|
|
61
|
-
'--
|
|
62
|
-
'--
|
|
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 = (
|
|
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 = (
|
|
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',
|
|
86
|
-
|
|
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',
|
|
130
|
+
'--tools',
|
|
131
|
+
web ? 'WebSearch,WebFetch,Read,Glob,Grep' : 'Read,Glob,Grep',
|
|
92
132
|
...(web ? ['--allowedTools', 'WebSearch,WebFetch'] : []),
|
|
93
|
-
'--settings',
|
|
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',
|
|
102
|
-
'
|
|
142
|
+
'--permission-prompts',
|
|
143
|
+
'none',
|
|
144
|
+
'--permission-mode',
|
|
145
|
+
'manual',
|
|
103
146
|
'--no-session-persistence',
|
|
104
|
-
'--output-format',
|
|
105
|
-
'
|
|
106
|
-
'--
|
|
107
|
-
|
|
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 (
|
|
115
|
-
const t =
|
|
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 {
|
|
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 =
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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',
|