severally-mcp 2.4.0 → 2.5.0
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/CONFIG.md +1 -0
- package/README.ja.md +44 -19
- package/README.md +44 -18
- package/config.example.json +1 -1
- package/docs/how-it-works.ja.md +2 -0
- package/docs/how-it-works.md +2 -0
- package/package.json +3 -1
- package/plugins/severally/.claude-plugin/plugin.json +1 -1
- package/plugins/severally/.codex-plugin/plugin.json +1 -1
- package/plugins/severally/dist/severally-mcp.mjs +630 -271
- package/plugins/severally/skills/_template/SKILL.md.tmpl +2 -1
- package/plugins/severally/skills/_template/reference.md.tmpl +2 -2
- package/plugins/severally/skills/antigravity/severally/SKILL.md +2 -1
- package/plugins/severally/skills/antigravity/severally/reference.md +2 -2
- package/plugins/severally/skills/claude/severally/SKILL.md +2 -1
- package/plugins/severally/skills/claude/severally/reference.md +2 -2
- package/plugins/severally/skills/codex/severally/SKILL.md +2 -1
- package/plugins/severally/skills/codex/severally/reference.md +2 -2
- package/plugins/severally/skills/opencode/severally/SKILL.md +2 -1
- package/plugins/severally/skills/opencode/severally/reference.md +2 -2
- package/scripts/build.mjs +57 -54
- package/scripts/doctor.mjs +15 -4
- package/scripts/init-config.mjs +2 -1
- package/scripts/install.mjs +73 -9
- package/scripts/live-check.mjs +11 -5
- package/scripts/live-mcp-check.mjs +10 -4
- package/src/adapters/antigravity-sandbox.mjs +24 -6
- package/src/adapters/antigravity.mjs +80 -17
- package/src/adapters/claude-code.mjs +49 -8
- package/src/adapters/codex.mjs +56 -6
- package/src/adapters/opencode-sandbox.mjs +20 -2
- package/src/adapters/opencode.mjs +115 -19
- package/src/brief.mjs +84 -7
- package/src/consultants.mjs +193 -27
- package/src/cross-spawn-escape.d.ts +7 -0
- package/src/export.mjs +50 -6
- package/src/expose-paths.mjs +149 -7
- package/src/failures.mjs +8 -1
- package/src/jobs.mjs +632 -152
- package/src/jsonc.mjs +11 -3
- package/src/parse-result.mjs +91 -14
- package/src/platform.mjs +52 -2
- package/src/policy.mjs +151 -28
- package/src/redact.mjs +22 -3
- package/src/result-schema.mjs +13 -0
- package/src/run.mjs +158 -12
- package/src/schema.mjs +84 -11
- package/src/server.mjs +49 -14
- package/src/status.mjs +16 -4
- package/src/store.mjs +287 -6
package/scripts/live-check.mjs
CHANGED
|
@@ -18,10 +18,15 @@ import fs from 'node:fs';
|
|
|
18
18
|
import os from 'node:os';
|
|
19
19
|
import path from 'node:path';
|
|
20
20
|
import { JobManager } from '../src/jobs.mjs';
|
|
21
|
+
/** @typedef {import('../src/jobs.mjs').JobView} JobView */
|
|
21
22
|
import { limitsSummary } from '../src/policy.mjs';
|
|
22
23
|
import * as store from '../src/store.mjs';
|
|
23
24
|
|
|
24
25
|
const argv = process.argv.slice(2);
|
|
26
|
+
/**
|
|
27
|
+
* @param {string} name
|
|
28
|
+
* @param {string} dflt
|
|
29
|
+
*/
|
|
25
30
|
const arg = (name, dflt) => {
|
|
26
31
|
const i = argv.indexOf(`--${name}`);
|
|
27
32
|
return i === -1 ? dflt : argv[i + 1];
|
|
@@ -30,6 +35,7 @@ const arg = (name, dflt) => {
|
|
|
30
35
|
const target = arg('target', 'antigravity');
|
|
31
36
|
const mode = arg('mode', 'review');
|
|
32
37
|
|
|
38
|
+
/** @type {Record<string, Record<string, unknown>>} */
|
|
33
39
|
const REQUESTS = {
|
|
34
40
|
review: {
|
|
35
41
|
mode: 'review',
|
|
@@ -101,13 +107,12 @@ REQUESTS.sandbox = {
|
|
|
101
107
|
},
|
|
102
108
|
};
|
|
103
109
|
|
|
104
|
-
const request = { target, ...REQUESTS[mode] };
|
|
110
|
+
const request = { target, ...(mode === undefined ? {} : REQUESTS[mode]) };
|
|
105
111
|
|
|
106
112
|
console.log('limits:', JSON.stringify(limitsSummary(), null, 2));
|
|
107
113
|
const mgr = new JobManager();
|
|
108
|
-
/** @type {Record<string, any>} */
|
|
109
114
|
const started = mgr.start(request);
|
|
110
|
-
if (!started
|
|
115
|
+
if (!('job_id' in started)) {
|
|
111
116
|
console.error('live-check runs one consultant at a time: pass a single --target, not "all"');
|
|
112
117
|
process.exit(2);
|
|
113
118
|
}
|
|
@@ -130,10 +135,11 @@ try {
|
|
|
130
135
|
} catch {
|
|
131
136
|
/* no working directory: the job failed before it started */
|
|
132
137
|
}
|
|
133
|
-
await mgr.jobs.get(started.job_id)
|
|
138
|
+
await mgr.jobs.get(started.job_id)?.promise;
|
|
134
139
|
clearInterval(watch);
|
|
135
140
|
watcher?.close();
|
|
136
|
-
|
|
141
|
+
// The job was started above in this process, so it exists.
|
|
142
|
+
const view = /** @type {JobView} */ (mgr.view(started.job_id));
|
|
137
143
|
console.log(`\n=== finished in ${((Date.now() - t0) / 1000).toFixed(1)}s: ${view.status} ===`);
|
|
138
144
|
console.log(JSON.stringify(view, null, 2));
|
|
139
145
|
if (mode !== 'sandbox') process.exit(view.status === 'completed' ? 0 : 1);
|
|
@@ -11,10 +11,15 @@ import { execFileSync } from 'node:child_process';
|
|
|
11
11
|
|
|
12
12
|
// The globally installed server, unless another path is given.
|
|
13
13
|
const BIN = process.argv[2] ?? 'severally-mcp';
|
|
14
|
-
|
|
14
|
+
// process.env holds only strings at runtime; its type also admits undefined.
|
|
15
|
+
const env = /** @type {Record<string, string>} */ ({ ...process.env });
|
|
15
16
|
const client = new Client({ name: 'live-mcp', version: '1' });
|
|
16
17
|
await client.connect(new StdioClientTransport({ command: BIN, args: [], env }));
|
|
17
|
-
|
|
18
|
+
/** @typedef {import('@modelcontextprotocol/client').CallToolResult} CallToolResult */
|
|
19
|
+
/** @typedef {import('@modelcontextprotocol/client').TextContent} TextContent */
|
|
20
|
+
// The server answers every tool call with one text item, so content[0] is TextContent.
|
|
21
|
+
/** @param {CallToolResult} r A tool result whose first content item is JSON text. */
|
|
22
|
+
const P = (r) => JSON.parse(/** @type {TextContent} */ (r.content[0]).text);
|
|
18
23
|
|
|
19
24
|
const request = {
|
|
20
25
|
target: 'claude-code',
|
|
@@ -86,7 +91,7 @@ const before = countClaudeChildren();
|
|
|
86
91
|
const cancelled = P(await client.callTool({ name: 'consult_cancel', arguments: { job_id: fu.job_id } }));
|
|
87
92
|
console.log('cancel ->', cancelled.status, '|', cancelled.cancel_effect);
|
|
88
93
|
let after = before;
|
|
89
|
-
for (let i = 0; i < 40 &&
|
|
94
|
+
for (let i = 0; i < 40 && before !== null && before > 0 && after !== null && after >= before; i++) {
|
|
90
95
|
await new Promise((r) => setTimeout(r, 250));
|
|
91
96
|
after = countClaudeChildren();
|
|
92
97
|
}
|
|
@@ -115,7 +120,8 @@ function countClaudeChildren() {
|
|
|
115
120
|
: execFileSync('ps', ['-eo', 'args='], { encoding: 'utf8' });
|
|
116
121
|
return out.split('\n').filter((l) => l.includes('--no-session-persistence') && l.includes('--restricted')).length;
|
|
117
122
|
} catch (error) {
|
|
118
|
-
|
|
123
|
+
// execFileSync throws an Error.
|
|
124
|
+
console.warn(`Could not inspect consultant processes: ${/** @type {Error} */ (error).message}`);
|
|
119
125
|
return null;
|
|
120
126
|
}
|
|
121
127
|
}
|
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
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).
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
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;
|
|
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
|
-
/**
|
|
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
|
-
|
|
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)
|
|
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:
|
|
303
|
+
web: tools.includes('search_web'),
|
|
241
304
|
writes: false,
|
|
242
305
|
mcp: false,
|
|
243
306
|
protected_paths: 'denied (tool hook)',
|
|
244
|
-
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 = (
|
|
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 (
|
|
151
|
-
const t =
|
|
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
|
-
|
|
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
|
-
|
|
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',
|
package/src/adapters/codex.mjs
CHANGED
|
@@ -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
|
-
/**
|
|
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 (
|
|
198
|
-
|
|
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
|
-
|
|
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)
|
|
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 {
|