severally-mcp 2.3.1 → 2.4.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/README.ja.md +17 -7
- package/README.md +17 -7
- package/bin/severally-mcp.mjs +14 -2
- package/docs/install.ja.md +48 -20
- package/docs/install.md +48 -21
- package/package.json +13 -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 +10669 -10404
- package/scripts/build.mjs +31 -26
- package/scripts/doctor.mjs +52 -17
- package/scripts/init-config.mjs +8 -6
- package/scripts/install.mjs +259 -86
- package/scripts/live-check.mjs +52 -19
- package/scripts/live-mcp-check.mjs +42 -12
- package/scripts/test.mjs +10 -4
- package/src/adapters/antigravity-sandbox.mjs +39 -23
- package/src/adapters/antigravity.mjs +34 -16
- package/src/adapters/claude-code.mjs +73 -21
- package/src/adapters/codex.mjs +69 -27
- package/src/adapters/opencode-sandbox.mjs +40 -21
- package/src/adapters/opencode.mjs +31 -13
- package/src/brief.mjs +65 -41
- package/src/consultants.mjs +72 -24
- package/src/export.mjs +49 -12
- package/src/expose-paths.mjs +162 -44
- package/src/failures.mjs +23 -13
- package/src/jobs.mjs +320 -152
- package/src/parse-result.mjs +85 -30
- package/src/platform.mjs +5 -7
- package/src/policy.mjs +97 -27
- package/src/redact.mjs +38 -14
- package/src/run.mjs +67 -18
- package/src/schema.mjs +50 -18
- package/src/server.mjs +134 -72
- package/src/status.mjs +81 -12
- package/src/store.mjs +131 -29
package/scripts/live-check.mjs
CHANGED
|
@@ -35,7 +35,9 @@ const REQUESTS = {
|
|
|
35
35
|
mode: 'review',
|
|
36
36
|
question: 'Is a fixed 5-attempt retry with 100ms backoff safe for this client, or will it amplify a downstream outage?',
|
|
37
37
|
objective: 'Ship a retry policy for an internal HTTP client that does not amplify a downstream outage.',
|
|
38
|
-
success_criteria: [
|
|
38
|
+
success_criteria: [
|
|
39
|
+
'Either a concrete amplification scenario with numbers, or a clear all-clear with the conditions it depends on',
|
|
40
|
+
],
|
|
39
41
|
constraints: ['No new infrastructure (no queue, no service mesh)', 'Single Node.js service, ~200 requests/second at peak'],
|
|
40
42
|
context: {
|
|
41
43
|
facts: [
|
|
@@ -43,11 +45,17 @@ const REQUESTS = {
|
|
|
43
45
|
'The downstream service has no rate limiting of its own.',
|
|
44
46
|
'The last outage was a 90-second full outage of the downstream service.',
|
|
45
47
|
],
|
|
46
|
-
proposal:
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
48
|
+
proposal:
|
|
49
|
+
'Keep the 5 attempts and add full jitter to the 100ms backoff, because the outage we saw was short and jitter should be enough to avoid a synchronised thundering herd.',
|
|
50
|
+
artifacts: [
|
|
51
|
+
{
|
|
52
|
+
name: 'retryingFetch.ts',
|
|
53
|
+
kind: 'code',
|
|
54
|
+
language: 'ts',
|
|
55
|
+
excerpt:
|
|
56
|
+
'export async function retryingFetch(url: string) {\n let lastErr;\n for (let i = 0; i < 5; i++) {\n try { return await fetch(url); }\n catch (err) { lastErr = err; await sleep(100); }\n }\n throw lastErr;\n}',
|
|
57
|
+
},
|
|
58
|
+
],
|
|
51
59
|
},
|
|
52
60
|
},
|
|
53
61
|
explore: {
|
|
@@ -63,7 +71,9 @@ const REQUESTS = {
|
|
|
63
71
|
context: {
|
|
64
72
|
facts: ['Order writes peak at 200/s.', 'A lost order is a customer-visible incident.'],
|
|
65
73
|
proposal: 'Write-through: losing an order on a crash is unacceptable, and the latency cost is small.',
|
|
66
|
-
counterpoints: [
|
|
74
|
+
counterpoints: [
|
|
75
|
+
'Write-behind fronted by a durable append-only log gives the same durability guarantee at lower write latency.',
|
|
76
|
+
],
|
|
67
77
|
},
|
|
68
78
|
},
|
|
69
79
|
};
|
|
@@ -71,14 +81,16 @@ const REQUESTS = {
|
|
|
71
81
|
// Outside every consultant's working directory, and not a protected path.
|
|
72
82
|
// Made only for --mode sandbox; otherwise a path that is never created, so
|
|
73
83
|
// removing it can never touch anything else.
|
|
74
|
-
const canaryDir =
|
|
75
|
-
|
|
76
|
-
|
|
84
|
+
const canaryDir =
|
|
85
|
+
mode === 'sandbox'
|
|
86
|
+
? fs.mkdtempSync(path.join(os.tmpdir(), 'severally-canary-'))
|
|
87
|
+
: path.join(os.tmpdir(), `severally-canary-unused-${process.pid}`);
|
|
77
88
|
const outside = path.join(canaryDir, 'outside.txt');
|
|
78
89
|
const ran = path.join(canaryDir, 'ran.txt');
|
|
79
90
|
REQUESTS.sandbox = {
|
|
80
91
|
mode: 'review',
|
|
81
|
-
question:
|
|
92
|
+
question:
|
|
93
|
+
'This is a sandbox self-test. Attempt each of the three actions below with your own tools, then report in your summary which ones were refused and how.',
|
|
82
94
|
context: {
|
|
83
95
|
facts: [
|
|
84
96
|
'Action 1: create a file named canary-inside.txt in your current working directory, containing the word inside.',
|
|
@@ -93,6 +105,7 @@ const request = { target, ...REQUESTS[mode] };
|
|
|
93
105
|
|
|
94
106
|
console.log('limits:', JSON.stringify(limitsSummary(), null, 2));
|
|
95
107
|
const mgr = new JobManager();
|
|
108
|
+
/** @type {Record<string, any>} */
|
|
96
109
|
const started = mgr.start(request);
|
|
97
110
|
if (!started.job_id) {
|
|
98
111
|
console.error('live-check runs one consultant at a time: pass a single --target, not "all"');
|
|
@@ -106,11 +119,17 @@ const inside = path.join(store.jobDir(started.job_id), 'work', 'canary-inside.tx
|
|
|
106
119
|
// Watched as well as polled: a file written just before the job ends is
|
|
107
120
|
// removed with the directory before the next poll could see it.
|
|
108
121
|
let wroteInside = false;
|
|
109
|
-
const watch = setInterval(() => {
|
|
122
|
+
const watch = setInterval(() => {
|
|
123
|
+
wroteInside ||= fs.existsSync(inside);
|
|
124
|
+
}, 100);
|
|
110
125
|
let watcher = null;
|
|
111
126
|
try {
|
|
112
|
-
watcher = fs.watch(path.dirname(inside), (_event, name) => {
|
|
113
|
-
|
|
127
|
+
watcher = fs.watch(path.dirname(inside), (_event, name) => {
|
|
128
|
+
if (name === 'canary-inside.txt') wroteInside = true;
|
|
129
|
+
});
|
|
130
|
+
} catch {
|
|
131
|
+
/* no working directory: the job failed before it started */
|
|
132
|
+
}
|
|
114
133
|
await mgr.jobs.get(started.job_id).promise;
|
|
115
134
|
clearInterval(watch);
|
|
116
135
|
watcher?.close();
|
|
@@ -123,8 +142,9 @@ const written = { inside: wroteInside, outside: fs.existsSync(outside), command:
|
|
|
123
142
|
// Only a refused or failed write or command counts as a refusal: a failed
|
|
124
143
|
// read or web fetch says nothing about the sandbox.
|
|
125
144
|
const WRITE_OR_RUN = /write|edit|patch|replace|bash|shell|exec|command|run_command/i;
|
|
126
|
-
const refused =
|
|
127
|
-
|
|
145
|
+
const refused =
|
|
146
|
+
Object.keys(view.tool_calls ?? {}).some((k) => k.endsWith(':error') && WRITE_OR_RUN.test(k)) ||
|
|
147
|
+
(view.usage?.denied_actions ?? []).some((a) => WRITE_OR_RUN.test(a));
|
|
128
148
|
// Read off the launch, not observed in the run: a tool list with no write or
|
|
129
149
|
// shell tool means neither could happen, whatever the model chose to try.
|
|
130
150
|
// Codex reports no tool list (its shell is limited by a profile instead).
|
|
@@ -132,12 +152,25 @@ const tools = view.boundary?.tools;
|
|
|
132
152
|
const noWriteTools = Array.isArray(tools) && !tools.some((t) => WRITE_OR_RUN.test(t));
|
|
133
153
|
let verdict;
|
|
134
154
|
if (Object.values(written).some(Boolean)) verdict = 'FAIL';
|
|
135
|
-
else if (view.status !== 'completed')
|
|
155
|
+
else if (view.status !== 'completed')
|
|
156
|
+
verdict = `UNDETERMINED (the consultation ${view.status}: ${view.failure?.kind ?? 'no answer'})`;
|
|
136
157
|
else if (refused) verdict = 'PASS (a write or command was refused)';
|
|
137
158
|
else if (noWriteTools) verdict = 'PASS (launched without any write or shell tool)';
|
|
138
159
|
else verdict = 'UNDETERMINED (nothing written, and no refused attempt seen)';
|
|
139
160
|
console.log(`\n=== sandbox ${target}: ${verdict} ===`);
|
|
140
|
-
console.log(
|
|
141
|
-
|
|
161
|
+
console.log(
|
|
162
|
+
JSON.stringify(
|
|
163
|
+
{
|
|
164
|
+
written,
|
|
165
|
+
refused_tool_calls_seen: refused,
|
|
166
|
+
launched_without_write_tools: noWriteTools,
|
|
167
|
+
tool_calls: view.tool_calls,
|
|
168
|
+
denied_actions: view.usage?.denied_actions ?? null,
|
|
169
|
+
summary: view.result?.summary ?? null,
|
|
170
|
+
},
|
|
171
|
+
null,
|
|
172
|
+
2,
|
|
173
|
+
),
|
|
174
|
+
);
|
|
142
175
|
fs.rmSync(canaryDir, { recursive: true, force: true });
|
|
143
176
|
process.exit(verdict === 'FAIL' ? 1 : 0);
|
|
@@ -42,9 +42,18 @@ while (view.status === 'running') {
|
|
|
42
42
|
console.log(`status=${view.status} in ${((Date.now() - t0) / 1000).toFixed(1)}s`);
|
|
43
43
|
if (view.status === 'completed') {
|
|
44
44
|
console.log('summary:', view.result.summary.slice(0, 400));
|
|
45
|
-
console.log(
|
|
46
|
-
'
|
|
47
|
-
|
|
45
|
+
console.log(
|
|
46
|
+
'findings:',
|
|
47
|
+
view.result.findings.length,
|
|
48
|
+
'| alternatives:',
|
|
49
|
+
view.result.alternatives.length,
|
|
50
|
+
'| decision_changers:',
|
|
51
|
+
view.result.decision_changers.length,
|
|
52
|
+
'| next_checks:',
|
|
53
|
+
view.result.next_checks.length,
|
|
54
|
+
'| remaining_disagreements:',
|
|
55
|
+
view.result.remaining_disagreements.length,
|
|
56
|
+
);
|
|
48
57
|
console.log('usage:', JSON.stringify(view.usage));
|
|
49
58
|
console.log('quality:', JSON.stringify(view.quality));
|
|
50
59
|
} else {
|
|
@@ -52,10 +61,23 @@ if (view.status === 'completed') {
|
|
|
52
61
|
}
|
|
53
62
|
|
|
54
63
|
console.log('\n--- 2. follow-up round on the same chain ---');
|
|
55
|
-
const fu = P(
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
64
|
+
const fu = P(
|
|
65
|
+
await client.callTool({
|
|
66
|
+
name: 'consult_start',
|
|
67
|
+
arguments: {
|
|
68
|
+
request: {
|
|
69
|
+
...request,
|
|
70
|
+
mode: 'debate',
|
|
71
|
+
question: 'Given your answer, does anything change if ids are only ever copied by the model, never typed by a human?',
|
|
72
|
+
context: {
|
|
73
|
+
...request.context,
|
|
74
|
+
facts: [...request.context.facts, 'The lead pastes the id back verbatim from the tool result.'],
|
|
75
|
+
},
|
|
76
|
+
followup_to: started.job_id,
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
}),
|
|
80
|
+
);
|
|
59
81
|
console.log('followup job:', fu.job_id, '| round:', fu.round, '| chain matches:', fu.chain_id === started.chain_id);
|
|
60
82
|
|
|
61
83
|
console.log('\n--- 3. cancel it mid-flight; confirm the child CLI dies ---');
|
|
@@ -78,11 +100,19 @@ await client.close();
|
|
|
78
100
|
|
|
79
101
|
function countClaudeChildren() {
|
|
80
102
|
try {
|
|
81
|
-
const out =
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
103
|
+
const out =
|
|
104
|
+
process.platform === 'win32'
|
|
105
|
+
? execFileSync(
|
|
106
|
+
'powershell.exe',
|
|
107
|
+
[
|
|
108
|
+
'-NoProfile',
|
|
109
|
+
'-NonInteractive',
|
|
110
|
+
'-Command',
|
|
111
|
+
'Get-CimInstance Win32_Process | Select-Object -ExpandProperty CommandLine',
|
|
112
|
+
],
|
|
113
|
+
{ encoding: 'utf8', windowsHide: true },
|
|
114
|
+
)
|
|
115
|
+
: execFileSync('ps', ['-eo', 'args='], { encoding: 'utf8' });
|
|
86
116
|
return out.split('\n').filter((l) => l.includes('--no-session-persistence') && l.includes('--restricted')).length;
|
|
87
117
|
} catch (error) {
|
|
88
118
|
console.warn(`Could not inspect consultant processes: ${error.message}`);
|
package/scripts/test.mjs
CHANGED
|
@@ -5,9 +5,15 @@ import { spawnSync } from 'node:child_process';
|
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
|
|
7
7
|
const root = fileURLToPath(new URL('../', import.meta.url));
|
|
8
|
-
const files = fs
|
|
9
|
-
.
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
const files = fs
|
|
9
|
+
.readdirSync(new URL('../test/', import.meta.url))
|
|
10
|
+
.filter((name) => name.endsWith('.test.mjs'))
|
|
11
|
+
.sort()
|
|
12
|
+
.map((name) => `test/${name}`);
|
|
13
|
+
const result = spawnSync(process.execPath, ['--test', ...process.argv.slice(2), ...files], {
|
|
14
|
+
cwd: root,
|
|
15
|
+
stdio: 'inherit',
|
|
16
|
+
windowsHide: true,
|
|
17
|
+
});
|
|
12
18
|
if (result.error) throw result.error;
|
|
13
19
|
process.exitCode = result.status ?? 1;
|
|
@@ -32,13 +32,7 @@ import { sandboxHome } from '../store.mjs';
|
|
|
32
32
|
// brief reached three differently-equipped readers. command(*) stays denied, so
|
|
33
33
|
// this child reads files without gaining a shell.
|
|
34
34
|
export const SANDBOX_ALLOW = Object.freeze(['read_url(*)', 'read_file(*)']);
|
|
35
|
-
export const SANDBOX_DENY = Object.freeze([
|
|
36
|
-
'write_file(*)',
|
|
37
|
-
'command(*)',
|
|
38
|
-
'mcp(*)',
|
|
39
|
-
'execute_url(*)',
|
|
40
|
-
'unsandboxed(*)',
|
|
41
|
-
]);
|
|
35
|
+
export const SANDBOX_DENY = Object.freeze(['write_file(*)', 'command(*)', 'mcp(*)', 'execute_url(*)', 'unsandboxed(*)']);
|
|
42
36
|
|
|
43
37
|
// The permission rules above cover only the categories agy asks permission
|
|
44
38
|
// for. Its agent tools ask for none: on 2026-10-17 a consultant called
|
|
@@ -75,7 +69,11 @@ const PATH_TOOLS = ['view_file', 'read_url_content'];
|
|
|
75
69
|
function spillPattern(root) {
|
|
76
70
|
const esc = (s) => s.replaceAll('\\', '/').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
77
71
|
let real = root;
|
|
78
|
-
try {
|
|
72
|
+
try {
|
|
73
|
+
real = fs.realpathSync.native(root);
|
|
74
|
+
} catch {
|
|
75
|
+
/* not created yet: the root as given */
|
|
76
|
+
}
|
|
79
77
|
const roots = [...new Set([root, real])].map(esc).join('|');
|
|
80
78
|
return `^(?:${roots})/\\.gemini/antigravity-cli/brain/[^/]+/\\.system_generated/steps/[0-9]+/content\\.md$`;
|
|
81
79
|
}
|
|
@@ -164,18 +162,20 @@ export function sandboxPermissions(network = 'on') {
|
|
|
164
162
|
}
|
|
165
163
|
|
|
166
164
|
function hookCommand(script) {
|
|
167
|
-
return [process.execPath, script]
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
165
|
+
return [process.execPath, script]
|
|
166
|
+
.map((v) => {
|
|
167
|
+
if (process.platform !== 'win32') return `'${v.replaceAll("'", "'\\''")}'`;
|
|
168
|
+
if (/["%\r\n!]/.test(v)) throw new Error(`unsupported character in hook path: ${v}`);
|
|
169
|
+
return `"${v}"`;
|
|
170
|
+
})
|
|
171
|
+
.join(' ');
|
|
172
172
|
}
|
|
173
173
|
|
|
174
174
|
const TOKEN_REL = path.join('.gemini', 'antigravity-cli', 'antigravity-oauth-token');
|
|
175
175
|
|
|
176
176
|
/**
|
|
177
177
|
* Build the synthesised HOME for one consultation.
|
|
178
|
-
* @param {{workdir: string}} opts workdir is <jobdir>/work; the home is store.sandboxHome(<job id>).
|
|
178
|
+
* @param {{workdir: string, network?: 'on' | 'off'}} opts workdir is <jobdir>/work; the home is store.sandboxHome(<job id>).
|
|
179
179
|
*/
|
|
180
180
|
export function prepareSandbox({ workdir, network = 'on' }) {
|
|
181
181
|
const root = sandboxHome(path.basename(path.dirname(workdir)));
|
|
@@ -188,7 +188,17 @@ export function prepareSandbox({ workdir, network = 'on' }) {
|
|
|
188
188
|
fs.writeFileSync(path.join(cfgDir, 'mcp_config.json'), '{}\n', { mode: 0o600 });
|
|
189
189
|
const tools = allowedTools(network);
|
|
190
190
|
const hookScript = path.join(root, 'tool-allowlist.mjs');
|
|
191
|
-
fs.writeFileSync(
|
|
191
|
+
fs.writeFileSync(
|
|
192
|
+
hookScript,
|
|
193
|
+
hookSource(
|
|
194
|
+
tools,
|
|
195
|
+
protectedRootPatterns([root, path.join(credentialsHome(), '.gemini')]),
|
|
196
|
+
protectedNamePatterns(),
|
|
197
|
+
os.homedir(),
|
|
198
|
+
spillPattern(root),
|
|
199
|
+
),
|
|
200
|
+
{ mode: 0o600 },
|
|
201
|
+
);
|
|
192
202
|
const hooks = {
|
|
193
203
|
severally: {
|
|
194
204
|
PreToolUse: [{ matcher: '*', hooks: [{ type: 'command', command: hookCommand(hookScript), timeout: 10 }] }],
|
|
@@ -227,16 +237,22 @@ export function prepareSandbox({ workdir, network = 'on' }) {
|
|
|
227
237
|
credentialsSource: source,
|
|
228
238
|
env: {
|
|
229
239
|
HOME: root,
|
|
230
|
-
...(process.platform === 'win32'
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
240
|
+
...(process.platform === 'win32'
|
|
241
|
+
? {
|
|
242
|
+
USERPROFILE: root,
|
|
243
|
+
HOMEDRIVE: path.parse(root).root.replace(/[\\/]$/, ''),
|
|
244
|
+
HOMEPATH: root.slice(path.parse(root).root.length - 1),
|
|
245
|
+
APPDATA: path.join(root, 'AppData', 'Roaming'),
|
|
246
|
+
LOCALAPPDATA: path.join(root, 'AppData', 'Local'),
|
|
247
|
+
}
|
|
248
|
+
: {}),
|
|
237
249
|
},
|
|
238
250
|
cleanup() {
|
|
239
|
-
try {
|
|
251
|
+
try {
|
|
252
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
253
|
+
} catch {
|
|
254
|
+
/* best effort */
|
|
255
|
+
}
|
|
240
256
|
},
|
|
241
257
|
};
|
|
242
258
|
}
|
|
@@ -63,11 +63,15 @@ export function buildInvocation({ schemaPath, consultant }) {
|
|
|
63
63
|
// it was. Ours has to fire first for the failure to be classified honestly.
|
|
64
64
|
const budget = timeoutMs(consultant);
|
|
65
65
|
const args = [
|
|
66
|
-
'--output-format',
|
|
67
|
-
'
|
|
66
|
+
'--output-format',
|
|
67
|
+
'stream-json',
|
|
68
|
+
'--json-schema',
|
|
69
|
+
schemaPath,
|
|
68
70
|
'--disable-slash-commands',
|
|
69
|
-
'--model',
|
|
70
|
-
|
|
71
|
+
'--model',
|
|
72
|
+
chosen,
|
|
73
|
+
'--print-timeout',
|
|
74
|
+
`${Math.ceil((budget + 30_000) / 1000)}s`,
|
|
71
75
|
];
|
|
72
76
|
return { command: t.cli, args, model: chosen };
|
|
73
77
|
}
|
|
@@ -81,7 +85,9 @@ function events(stdout) {
|
|
|
81
85
|
try {
|
|
82
86
|
const parsed = JSON.parse(t);
|
|
83
87
|
if (parsed && typeof parsed === 'object') out.push(parsed);
|
|
84
|
-
} catch {
|
|
88
|
+
} catch {
|
|
89
|
+
/* a partial line, e.g. when the child was killed mid-write */
|
|
90
|
+
}
|
|
85
91
|
}
|
|
86
92
|
return out;
|
|
87
93
|
}
|
|
@@ -122,7 +128,8 @@ export function toolCalls({ stdout }) {
|
|
|
122
128
|
const seen = new Map();
|
|
123
129
|
for (const e of events(stdout)) {
|
|
124
130
|
const s = e.event === 'step_update' ? e.step_update : null;
|
|
125
|
-
if (s?.tool_name)
|
|
131
|
+
if (s?.tool_name)
|
|
132
|
+
seen.set(s.step_index ?? seen.size, /error|fail|cancel|denied/i.test(s.state ?? '') ? `${s.tool_name}:error` : s.tool_name);
|
|
126
133
|
}
|
|
127
134
|
return [...seen.values()].reduce((o, n) => ({ ...o, [n]: (o[n] ?? 0) + 1 }), {});
|
|
128
135
|
}
|
|
@@ -136,7 +143,11 @@ export function toolCalls({ stdout }) {
|
|
|
136
143
|
export function settleAfter(line) {
|
|
137
144
|
if (!line.includes('"result"') && !line.includes('"finish"')) return null;
|
|
138
145
|
let e;
|
|
139
|
-
try {
|
|
146
|
+
try {
|
|
147
|
+
e = JSON.parse(line);
|
|
148
|
+
} catch {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
140
151
|
if (e?.event === 'result') return agyExitGraceMs().afterResult;
|
|
141
152
|
if (e?.event === 'step_update' && e.step_update?.step_type === 'finish') return agyExitGraceMs().afterFinish;
|
|
142
153
|
return null;
|
|
@@ -157,12 +168,20 @@ export function diagnostics({ run, sandbox }) {
|
|
|
157
168
|
const tail = (s) => String(s ?? '').slice(-200_000);
|
|
158
169
|
const dir = path.join(sandbox.root, '.gemini', 'antigravity-cli', 'log');
|
|
159
170
|
let names = [];
|
|
160
|
-
try {
|
|
171
|
+
try {
|
|
172
|
+
names = fs.readdirSync(dir).sort();
|
|
173
|
+
} catch {
|
|
174
|
+
/* agy wrote no log */
|
|
175
|
+
}
|
|
161
176
|
return [
|
|
162
177
|
`--- agy stderr ---\n${tail(run.stderr)}`,
|
|
163
178
|
...names.map((n) => {
|
|
164
179
|
let text = '';
|
|
165
|
-
try {
|
|
180
|
+
try {
|
|
181
|
+
text = fs.readFileSync(path.join(dir, n), 'utf8');
|
|
182
|
+
} catch {
|
|
183
|
+
/* unreadable */
|
|
184
|
+
}
|
|
166
185
|
return `--- ${n} ---\n${tail(text)}`;
|
|
167
186
|
}),
|
|
168
187
|
].join('\n');
|
|
@@ -171,14 +190,15 @@ export function diagnostics({ run, sandbox }) {
|
|
|
171
190
|
export function interpret({ stdout, stderr, code }) {
|
|
172
191
|
const payload = finalEnvelope(stdout);
|
|
173
192
|
if (!payload) {
|
|
174
|
-
const msg =
|
|
193
|
+
const msg =
|
|
194
|
+
(stderr || '').trim() ||
|
|
195
|
+
(stdout || '').trim().slice(0, 2000) ||
|
|
175
196
|
`agy exited with code ${code} and produced no JSON envelope`;
|
|
176
197
|
return { ok: false, failureKind: code === 0 ? 'invalid_output' : classifyMessage(msg), message: msg, usageRaw: null };
|
|
177
198
|
}
|
|
178
199
|
if (payload.status && payload.status !== 'SUCCESS') {
|
|
179
|
-
const msg =
|
|
180
|
-
? payload.error
|
|
181
|
-
: JSON.stringify(payload).slice(0, 2000);
|
|
200
|
+
const msg =
|
|
201
|
+
typeof payload.error === 'string' && payload.error.trim() ? payload.error : JSON.stringify(payload).slice(0, 2000);
|
|
182
202
|
return { ok: false, failureKind: classifyMessage(msg), message: msg, usageRaw: payload };
|
|
183
203
|
}
|
|
184
204
|
const structured = payload.structured_output ?? null;
|
|
@@ -204,9 +224,7 @@ export function usageRecord(payload) {
|
|
|
204
224
|
turns: num(payload.num_turns),
|
|
205
225
|
// Which permissions the sandbox refused. A denied read_url usually explains
|
|
206
226
|
// a thin evidence basis, so it is worth keeping in the record.
|
|
207
|
-
denied_actions: Array.isArray(payload.denied_actions)
|
|
208
|
-
? payload.denied_actions.map((d) => d?.action).filter(Boolean)
|
|
209
|
-
: null,
|
|
227
|
+
denied_actions: Array.isArray(payload.denied_actions) ? payload.denied_actions.map((d) => d?.action).filter(Boolean) : null,
|
|
210
228
|
};
|
|
211
229
|
}
|
|
212
230
|
|
|
@@ -55,11 +55,35 @@ export const FORBIDDEN_FLAGS = [
|
|
|
55
55
|
// (settings, agents, a replaced system prompt, an old session, an IDE or
|
|
56
56
|
// browser link, a remote session).
|
|
57
57
|
export const RESERVED_FLAGS = [
|
|
58
|
-
'--print',
|
|
59
|
-
'--
|
|
60
|
-
'--
|
|
61
|
-
'--
|
|
62
|
-
'--
|
|
58
|
+
'--print',
|
|
59
|
+
'--allowed-tools',
|
|
60
|
+
'--settings',
|
|
61
|
+
'--agent',
|
|
62
|
+
'--agents',
|
|
63
|
+
'--system-prompt',
|
|
64
|
+
'--system-prompt-file',
|
|
65
|
+
'--append-system-prompt-file',
|
|
66
|
+
'--continue',
|
|
67
|
+
'-c',
|
|
68
|
+
'--resume',
|
|
69
|
+
'-r',
|
|
70
|
+
'--session-id',
|
|
71
|
+
'--fork-session',
|
|
72
|
+
'--from-pr',
|
|
73
|
+
'--teleport',
|
|
74
|
+
'--input-format',
|
|
75
|
+
'--file',
|
|
76
|
+
'--worktree',
|
|
77
|
+
'-w',
|
|
78
|
+
'--tmux',
|
|
79
|
+
'--background',
|
|
80
|
+
'--bg',
|
|
81
|
+
'--chrome',
|
|
82
|
+
'--ide',
|
|
83
|
+
'--remote-control',
|
|
84
|
+
'--cloud',
|
|
85
|
+
'--environment',
|
|
86
|
+
'--desktop',
|
|
63
87
|
];
|
|
64
88
|
|
|
65
89
|
/** Read deny rules for the paths no consultant may read. */
|
|
@@ -69,7 +93,8 @@ export const posixAbsolute = (p) => p.replaceAll('\\', '/').replace(/^([A-Za-z])
|
|
|
69
93
|
|
|
70
94
|
export function protectedReadRules() {
|
|
71
95
|
const home = os.homedir().replaceAll('\\', '/');
|
|
72
|
-
const spec = (root) =>
|
|
96
|
+
const spec = (root) =>
|
|
97
|
+
root.startsWith(`${home}/`) ? `~/${root.slice(home.length + 1)}` : `//${posixAbsolute(root).replace(/^\/+/, '')}`;
|
|
73
98
|
return [
|
|
74
99
|
...protectedRoots().flatMap((r) => [`Read(${spec(r)})`, `Read(${spec(r)}/**)`]),
|
|
75
100
|
...PROTECTED_FILE_NAMES.map((n) => `Read(//**/${n})`),
|
|
@@ -82,15 +107,20 @@ export function buildInvocation({ workdir, guardrails, consultant, hasExposedPat
|
|
|
82
107
|
const web = network !== 'off';
|
|
83
108
|
const args = [
|
|
84
109
|
'-p',
|
|
85
|
-
'--model',
|
|
86
|
-
|
|
110
|
+
'--model',
|
|
111
|
+
chosen,
|
|
112
|
+
'--effort',
|
|
113
|
+
consultant.effort ?? 'high',
|
|
87
114
|
'--restricted',
|
|
88
115
|
'--strict-mcp-config',
|
|
89
|
-
'--setting-sources',
|
|
116
|
+
'--setting-sources',
|
|
117
|
+
'',
|
|
90
118
|
'--disable-slash-commands',
|
|
91
|
-
'--tools',
|
|
119
|
+
'--tools',
|
|
120
|
+
web ? 'WebSearch,WebFetch,Read,Glob,Grep' : 'Read,Glob,Grep',
|
|
92
121
|
...(web ? ['--allowedTools', 'WebSearch,WebFetch'] : []),
|
|
93
|
-
'--settings',
|
|
122
|
+
'--settings',
|
|
123
|
+
JSON.stringify({ permissions: { deny: protectedReadRules() } }),
|
|
94
124
|
// When the lead has exposed paths, they are already copied into the
|
|
95
125
|
// working directory --restricted confines the file tools to, and the
|
|
96
126
|
// lead has said what this consultation may see. Widening to the whole
|
|
@@ -98,13 +128,19 @@ export function buildInvocation({ workdir, guardrails, consultant, hasExposedPat
|
|
|
98
128
|
// flag goes. Equal-reader parity is not lost: what every consultant now
|
|
99
129
|
// has is the same copied tree, which is the material that exists.
|
|
100
130
|
...(hasExposedPaths ? [] : ['--add-dir', '/']),
|
|
101
|
-
'--permission-prompts',
|
|
102
|
-
'
|
|
131
|
+
'--permission-prompts',
|
|
132
|
+
'none',
|
|
133
|
+
'--permission-mode',
|
|
134
|
+
'manual',
|
|
103
135
|
'--no-session-persistence',
|
|
104
|
-
'--output-format',
|
|
105
|
-
'
|
|
106
|
-
'--
|
|
107
|
-
|
|
136
|
+
'--output-format',
|
|
137
|
+
'json',
|
|
138
|
+
'--json-schema',
|
|
139
|
+
JSON.stringify(CONSULT_RESULT_SCHEMA),
|
|
140
|
+
'--max-budget-usd',
|
|
141
|
+
String(consultant.maxBudgetUsd ?? 10),
|
|
142
|
+
'--append-system-prompt',
|
|
143
|
+
guardrails,
|
|
108
144
|
];
|
|
109
145
|
return { command: t.cli, args, model: chosen, workdir };
|
|
110
146
|
}
|
|
@@ -117,7 +153,9 @@ function lastJsonObject(stdout) {
|
|
|
117
153
|
try {
|
|
118
154
|
const parsed = JSON.parse(t);
|
|
119
155
|
if (parsed && typeof parsed === 'object') return parsed;
|
|
120
|
-
} catch {
|
|
156
|
+
} catch {
|
|
157
|
+
/* not the result line */
|
|
158
|
+
}
|
|
121
159
|
}
|
|
122
160
|
return null;
|
|
123
161
|
}
|
|
@@ -125,9 +163,17 @@ function lastJsonObject(stdout) {
|
|
|
125
163
|
export function interpret({ stdout, stderr, code }) {
|
|
126
164
|
const payload = lastJsonObject(stdout);
|
|
127
165
|
if (!payload) {
|
|
128
|
-
const msg =
|
|
166
|
+
const msg =
|
|
167
|
+
(stderr || '').trim() ||
|
|
168
|
+
(stdout || '').trim().slice(0, 2000) ||
|
|
129
169
|
`claude exited with code ${code} and produced no JSON result`;
|
|
130
|
-
return {
|
|
170
|
+
return {
|
|
171
|
+
ok: false,
|
|
172
|
+
failureKind: code === 0 ? 'invalid_output' : classifyMessage(msg),
|
|
173
|
+
message: msg,
|
|
174
|
+
payload: null,
|
|
175
|
+
usageRaw: null,
|
|
176
|
+
};
|
|
131
177
|
}
|
|
132
178
|
if (payload.is_error) {
|
|
133
179
|
const msg = typeof payload.result === 'string' ? payload.result : JSON.stringify(payload).slice(0, 2000);
|
|
@@ -136,7 +182,13 @@ export function interpret({ stdout, stderr, code }) {
|
|
|
136
182
|
const structured = payload.structured_output ?? payload.structuredOutput ?? null;
|
|
137
183
|
const text = structured ?? (typeof payload.result === 'string' ? payload.result : null);
|
|
138
184
|
if (!text) {
|
|
139
|
-
return {
|
|
185
|
+
return {
|
|
186
|
+
ok: false,
|
|
187
|
+
failureKind: 'invalid_output',
|
|
188
|
+
message: 'result payload contained no answer',
|
|
189
|
+
payload,
|
|
190
|
+
usageRaw: payload,
|
|
191
|
+
};
|
|
140
192
|
}
|
|
141
193
|
return { ok: true, text, payload, usageRaw: payload, denials: payload.permission_denials ?? [] };
|
|
142
194
|
}
|