@in-the-loop-labs/pair-review 3.9.1 → 4.0.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.md +10 -10
- package/package.json +5 -9
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/public/js/components/AdvancedConfigTab.js +1 -1
- package/public/js/components/AnalysisConfigModal.js +2 -2
- package/public/js/local.js +1 -1
- package/public/js/pr.js +3 -3
- package/public/js/utils/provider-model.js +1 -1
- package/src/ai/analyzer.js +4 -4
- package/src/ai/antigravity-provider.js +594 -0
- package/src/ai/index.js +1 -1
- package/src/ai/opencode-provider.js +1 -1
- package/src/ai/provider.js +5 -5
- package/src/ai/stream-parser.js +1 -52
- package/src/chat/acp-bridge.js +1 -1
- package/src/chat/chat-providers.js +1 -9
- package/src/config.js +3 -3
- package/src/database.js +1 -1
- package/src/main.js +4 -4
- package/src/routes/config.js +2 -2
- package/src/ai/gemini-provider.js +0 -752
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
// Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* Antigravity AI Provider
|
|
4
|
+
*
|
|
5
|
+
* Implements the AI provider interface for Google's Antigravity CLI (`agy`),
|
|
6
|
+
* the official successor to the Gemini CLI.
|
|
7
|
+
*
|
|
8
|
+
* ============================================================================
|
|
9
|
+
* HOW THIS ADAPTER DRIVES `agy` (verified against agy 1.0.16)
|
|
10
|
+
* ============================================================================
|
|
11
|
+
* The Antigravity CLI's non-interactive "print" mode (`agy -p`) IS a real
|
|
12
|
+
* agentic loop — it reads files, searches the tree, and runs shell commands to
|
|
13
|
+
* gather context, then prints a final answer. Verified empirically: given a
|
|
14
|
+
* high-entropy random token written to a file (and NEVER shown in the prompt),
|
|
15
|
+
* `agy -p` locates and reads that file and returns the exact token. So this is
|
|
16
|
+
* a full agentic review harness, on par with what the Gemini adapter offered.
|
|
17
|
+
*
|
|
18
|
+
* Practical consequences we design around:
|
|
19
|
+
* - PLAIN-TEXT OUTPUT. There is no JSON/stream output-format flag, so we
|
|
20
|
+
* parse the final text block with extractJSON() and fall back to the
|
|
21
|
+
* inherited LLM-extraction path.
|
|
22
|
+
* - PROMPT VIA STDIN. `agy` reads the prompt from BOTH the `-p` value and
|
|
23
|
+
* stdin. We put a small directive on `-p` and stream the (potentially
|
|
24
|
+
* large) analysis prompt over stdin. Delivering the whole prompt on argv
|
|
25
|
+
* would hit the per-argument length limit (E2BIG, ~128 KB on Linux) for
|
|
26
|
+
* large diffs, so stdin is the robust transport.
|
|
27
|
+
* - THE AGENTIC LOOP TAKES TIME. A single level commonly runs ~40-90s (tree
|
|
28
|
+
* search + file reads + reasoning). Short timeouts cut the loop off
|
|
29
|
+
* mid-flight and surface as "Error: timeout waiting for response"; we give
|
|
30
|
+
* it the caller's full timeout budget via --print-timeout.
|
|
31
|
+
* - TOOL APPROVAL. Without --dangerously-skip-permissions, shell/write tool
|
|
32
|
+
* requests can block on an approval prompt that never arrives
|
|
33
|
+
* non-interactively and eventually time out. We pass that flag on the
|
|
34
|
+
* analysis path so the loop runs unattended. This mirrors the Gemini
|
|
35
|
+
* adapter's posture: `agy` exposes no read-only tool allowlist, so we rely
|
|
36
|
+
* on prompt engineering (the analysis prompts instruct read-only,
|
|
37
|
+
* no-mutation behaviour) and on running inside the throwaway git worktree
|
|
38
|
+
* (cwd) to bound blast radius.
|
|
39
|
+
* - NO ACP MODE, so there is no Antigravity chat-provider counterpart to the
|
|
40
|
+
* old `gemini-acp`.
|
|
41
|
+
*
|
|
42
|
+
* The JSON-extraction fallback (getExtractionConfig) does NOT enable tools or
|
|
43
|
+
* skip permissions — it is a pure text->JSON reformat that needs no repo access.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
const path = require('path');
|
|
47
|
+
const { spawn } = require('child_process');
|
|
48
|
+
const { AIProvider, registerProvider, quoteShellArgs } = require('./provider');
|
|
49
|
+
const logger = require('../utils/logger');
|
|
50
|
+
const { extractJSON } = require('../utils/json-extractor');
|
|
51
|
+
const { CancellationError, isAnalysisCancelled } = require('../routes/shared');
|
|
52
|
+
const { wireAbortToChild, makeAbortError } = require('./abort-signal-wiring');
|
|
53
|
+
|
|
54
|
+
// Directory containing bin scripts (kept on PATH for parity with other providers)
|
|
55
|
+
const BIN_DIR = path.join(__dirname, '..', '..', 'bin');
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Fixed directive passed via `-p`. The real work lives in the prompt streamed
|
|
59
|
+
* over stdin; this frames the task, permits read-only agentic exploration of
|
|
60
|
+
* the worktree, and constrains the output shape.
|
|
61
|
+
*/
|
|
62
|
+
const ANALYSIS_DIRECTIVE =
|
|
63
|
+
'Perform the code-review task described in the input provided on standard input. You are ' +
|
|
64
|
+
'running non-interactively in the repository at the current working directory; you MAY use ' +
|
|
65
|
+
'your read-only tools (reading files, searching, and read-only git/shell commands) to gather ' +
|
|
66
|
+
'the context the instructions call for. Never create, modify, or delete files, and never run ' +
|
|
67
|
+
'mutating commands. When finished, output ONLY the exact result the instructions request (for ' +
|
|
68
|
+
'example, a single JSON object when JSON is requested), with no extra commentary and no ' +
|
|
69
|
+
'surrounding markdown code fences.';
|
|
70
|
+
|
|
71
|
+
/** Directive for the LLM JSON-extraction fallback (base class supplies the text on stdin). */
|
|
72
|
+
const EXTRACTION_DIRECTIVE =
|
|
73
|
+
'Read the input provided on standard input and return ONLY the raw JSON object it describes — ' +
|
|
74
|
+
'no explanation, no markdown, no code fences. Do not use any tools.';
|
|
75
|
+
|
|
76
|
+
// agy self-terminates print mode at --print-timeout; extraction has a 60s cap
|
|
77
|
+
// in the base class, so ask agy to give up just before that.
|
|
78
|
+
const EXTRACTION_PRINT_TIMEOUT_SECS = 55;
|
|
79
|
+
|
|
80
|
+
// The JS-side execute() timeout is a backstop: it fires this many ms AFTER agy's
|
|
81
|
+
// own --print-timeout budget, so agy self-terminates first (with its own error
|
|
82
|
+
// and cleanup) and this only intervenes if agy ignores its own timeout.
|
|
83
|
+
const TIMEOUT_BACKSTOP_GRACE_MS = 15000;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Antigravity model definitions with tier mappings.
|
|
87
|
+
*
|
|
88
|
+
* `id` is a clean, URL/attribute/config-safe slug used everywhere internally
|
|
89
|
+
* (UI picker, config keys, disabled_models, query params). `cliName` is the
|
|
90
|
+
* exact string the `agy --model` flag expects (as printed by `agy models`),
|
|
91
|
+
* which contains spaces and parentheses and must never leak into ids.
|
|
92
|
+
*
|
|
93
|
+
* We curate the Gemini-family models here — Antigravity is the Gemini CLI's
|
|
94
|
+
* successor and its native models are Gemini. `agy` also exposes Claude and
|
|
95
|
+
* GPT-OSS models; those remain reachable via a `providers.antigravity.models`
|
|
96
|
+
* config override but are intentionally left out of the default picker to keep
|
|
97
|
+
* the cross-provider model-mismatch guard and the UX unambiguous.
|
|
98
|
+
*/
|
|
99
|
+
const ANTIGRAVITY_MODELS = [
|
|
100
|
+
{
|
|
101
|
+
id: 'gemini-3.5-flash-low',
|
|
102
|
+
cliName: 'Gemini 3.5 Flash (Low)',
|
|
103
|
+
aliases: ['gemini-3.5-flash'],
|
|
104
|
+
name: '3.5 Flash (Low)',
|
|
105
|
+
tier: 'fast',
|
|
106
|
+
tagline: 'Rapid Sanity Check',
|
|
107
|
+
description: 'Cheapest, fastest pass — quick scans and the JSON-extraction fallback',
|
|
108
|
+
badge: 'Cheapest',
|
|
109
|
+
badgeClass: 'badge-speed'
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
id: 'gemini-3.5-flash-high',
|
|
113
|
+
cliName: 'Gemini 3.5 Flash (High)',
|
|
114
|
+
name: '3.5 Flash (High)',
|
|
115
|
+
tier: 'fast',
|
|
116
|
+
tagline: 'Quick Look',
|
|
117
|
+
description: 'Flash speed with more reasoning effort for a sharper first pass',
|
|
118
|
+
badge: 'Quick Look',
|
|
119
|
+
badgeClass: 'badge-speed'
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
id: 'gemini-3.1-pro-low',
|
|
123
|
+
cliName: 'Gemini 3.1 Pro (Low)',
|
|
124
|
+
aliases: ['gemini-3.1-pro'],
|
|
125
|
+
name: '3.1 Pro (Low)',
|
|
126
|
+
tier: 'balanced',
|
|
127
|
+
tagline: 'Standard PR Review',
|
|
128
|
+
description: 'Strong reasoning with a large context window — the reliable daily driver',
|
|
129
|
+
badge: 'Daily Driver',
|
|
130
|
+
badgeClass: 'badge-recommended',
|
|
131
|
+
default: true
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
id: 'gemini-3.1-pro-high',
|
|
135
|
+
cliName: 'Gemini 3.1 Pro (High)',
|
|
136
|
+
name: '3.1 Pro (High)',
|
|
137
|
+
tier: 'thorough',
|
|
138
|
+
tagline: 'Deep Dive',
|
|
139
|
+
description: 'Maximum reasoning effort for complex, architectural reviews',
|
|
140
|
+
badge: 'Deep Dive',
|
|
141
|
+
badgeClass: 'badge-power'
|
|
142
|
+
}
|
|
143
|
+
];
|
|
144
|
+
|
|
145
|
+
const DEFAULT_ANTIGRAVITY_MODEL = 'gemini-3.1-pro-low';
|
|
146
|
+
|
|
147
|
+
class AntigravityProvider extends AIProvider {
|
|
148
|
+
/**
|
|
149
|
+
* @param {string} model - Model identifier (clean id from ANTIGRAVITY_MODELS)
|
|
150
|
+
* @param {Object} configOverrides - Config overrides from providers config
|
|
151
|
+
* @param {string} configOverrides.command - Custom CLI command
|
|
152
|
+
* @param {string[]} configOverrides.extra_args - Additional CLI arguments
|
|
153
|
+
* @param {Object} configOverrides.env - Additional environment variables
|
|
154
|
+
* @param {Object[]} configOverrides.models - Custom model definitions
|
|
155
|
+
*/
|
|
156
|
+
constructor(model = DEFAULT_ANTIGRAVITY_MODEL, configOverrides = {}) {
|
|
157
|
+
super(model);
|
|
158
|
+
|
|
159
|
+
// Command precedence: ENV > config > default
|
|
160
|
+
const envCmd = process.env.PAIR_REVIEW_ANTIGRAVITY_CMD;
|
|
161
|
+
const configCmd = configOverrides.command;
|
|
162
|
+
this.agyCmd = envCmd || configCmd || 'agy';
|
|
163
|
+
this.configOverrides = configOverrides;
|
|
164
|
+
|
|
165
|
+
// For multi-word commands, use shell mode (same pattern as the other providers)
|
|
166
|
+
this.useShell = this.agyCmd.includes(' ');
|
|
167
|
+
|
|
168
|
+
// Env for the analysis model (provider env + selected-model env), resolved
|
|
169
|
+
// alias-aware through the shared helper so a model referenced by an alias
|
|
170
|
+
// picks up the same override as its canonical id.
|
|
171
|
+
this.extraEnv = this._resolveModelConfig(model).env;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Resolve a model id (or alias) to everything a CLI invocation needs, in ONE
|
|
176
|
+
* place. Consolidates the built-in and config-override lookups so the
|
|
177
|
+
* constructor, _composeArgs(), and getExtractionConfig() resolve a model
|
|
178
|
+
* identically. Mirrors claude-provider's _resolveModelConfig — the alternative
|
|
179
|
+
* is silent divergence where the same id picks up a cliName in one lookup but
|
|
180
|
+
* drops its env/extra_args in another.
|
|
181
|
+
*
|
|
182
|
+
* @param {string} model - The requested model id or alias.
|
|
183
|
+
* @returns {{builtIn: (Object|undefined), configModel: (Object|undefined),
|
|
184
|
+
* cliModel: string, extraArgs: string[], env: Object}}
|
|
185
|
+
* @private
|
|
186
|
+
*/
|
|
187
|
+
_resolveModelConfig(model) {
|
|
188
|
+
const configOverrides = this.configOverrides || {};
|
|
189
|
+
|
|
190
|
+
const builtIn = ANTIGRAVITY_MODELS.find(
|
|
191
|
+
m => m.id === model || (m.aliases || []).includes(model)
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
// A config override may target the built-in by any of its ids/aliases, or
|
|
195
|
+
// declare its own aliases; match on the union so no lookup diverges.
|
|
196
|
+
const modelKeys = new Set([model, builtIn?.id, ...(builtIn?.aliases || [])].filter(Boolean));
|
|
197
|
+
const configModel = configOverrides.models?.find(
|
|
198
|
+
m => modelKeys.has(m.id) || (m.aliases || []).some(a => modelKeys.has(a))
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
// Exact `agy --model` string. Config overrides honor the shared `cli_model`
|
|
202
|
+
// contract used across providers (documented in config.example.json) and
|
|
203
|
+
// fall back to `cliName`; built-ins carry `cliName`. Raw id is the last
|
|
204
|
+
// resort — agy tolerates an unknown name by using its default.
|
|
205
|
+
const cliModel =
|
|
206
|
+
configModel?.cli_model ||
|
|
207
|
+
configModel?.cliName ||
|
|
208
|
+
builtIn?.cliName ||
|
|
209
|
+
model;
|
|
210
|
+
|
|
211
|
+
// Merge order (lowest -> highest precedence): built-in, provider, per-model.
|
|
212
|
+
const extraArgs = [
|
|
213
|
+
...(builtIn?.extra_args || []),
|
|
214
|
+
...(configOverrides.extra_args || []),
|
|
215
|
+
...(configModel?.extra_args || [])
|
|
216
|
+
];
|
|
217
|
+
const env = {
|
|
218
|
+
...(builtIn?.env || {}),
|
|
219
|
+
...(configOverrides.env || {}),
|
|
220
|
+
...(configModel?.env || {})
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
return { builtIn, configModel, cliModel, extraArgs, env };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Translate a model id (or alias) into the exact string `agy --model` expects.
|
|
228
|
+
* Thin wrapper over _resolveModelConfig for call sites that only need the name.
|
|
229
|
+
* @param {string} model
|
|
230
|
+
* @returns {string}
|
|
231
|
+
*/
|
|
232
|
+
_resolveCliModel(model) {
|
|
233
|
+
return this._resolveModelConfig(model).cliModel;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Build the argument list (before any shell wrapping) for a single agy print
|
|
238
|
+
* invocation. The heavy prompt travels over stdin; `directive` is the small
|
|
239
|
+
* fixed `-p` value.
|
|
240
|
+
* @param {Object} opts
|
|
241
|
+
* @param {string} opts.model - Model id
|
|
242
|
+
* @param {string} opts.directive - The `-p` directive
|
|
243
|
+
* @param {number} opts.printTimeoutSecs - Value for --print-timeout
|
|
244
|
+
* @param {boolean} [opts.agentic=false] - If true, pass --dangerously-skip-permissions
|
|
245
|
+
* so the tool loop runs unattended (analysis path). Extraction leaves it off.
|
|
246
|
+
* @returns {string[]}
|
|
247
|
+
*/
|
|
248
|
+
_composeArgs({ model, directive, printTimeoutSecs, agentic = false }) {
|
|
249
|
+
const { cliModel, extraArgs } = this._resolveModelConfig(model);
|
|
250
|
+
const baseArgs = ['--print-timeout', `${printTimeoutSecs}s`, '--model', cliModel];
|
|
251
|
+
if (agentic) {
|
|
252
|
+
// agy has no fine-grained tool allowlist; auto-approve so read-only
|
|
253
|
+
// exploration (and git-diff-lines / git reads the prompts rely on) does
|
|
254
|
+
// not block non-interactively. Blast radius is bounded by the worktree.
|
|
255
|
+
baseArgs.push('--dangerously-skip-permissions');
|
|
256
|
+
}
|
|
257
|
+
baseArgs.push('-p', directive);
|
|
258
|
+
return [...baseArgs, ...extraArgs];
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Wrap args for shell vs direct spawn, mirroring the Claude provider pattern:
|
|
263
|
+
* multi-word commands run through a shell with fully quoted args.
|
|
264
|
+
* @param {string[]} args
|
|
265
|
+
* @returns {{command: string, args: string[]}}
|
|
266
|
+
*/
|
|
267
|
+
_wrapCommand(args) {
|
|
268
|
+
if (this.useShell) {
|
|
269
|
+
return { command: `${this.agyCmd} ${quoteShellArgs(args).join(' ')}`, args: [] };
|
|
270
|
+
}
|
|
271
|
+
return { command: this.agyCmd, args };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Execute Antigravity CLI with a prompt.
|
|
276
|
+
* @param {string} prompt - The full analysis prompt (delivered via stdin)
|
|
277
|
+
* @param {Object} options - Optional configuration
|
|
278
|
+
* @returns {Promise<Object>} Parsed response or raw fallback
|
|
279
|
+
*/
|
|
280
|
+
async execute(prompt, options = {}) {
|
|
281
|
+
return new Promise((resolve, reject) => {
|
|
282
|
+
const {
|
|
283
|
+
cwd = process.cwd(),
|
|
284
|
+
timeout = 600000,
|
|
285
|
+
level = 'unknown',
|
|
286
|
+
analysisId,
|
|
287
|
+
registerProcess,
|
|
288
|
+
logPrefix,
|
|
289
|
+
abortSignal
|
|
290
|
+
} = options;
|
|
291
|
+
|
|
292
|
+
const levelPrefix = logPrefix || `[Level ${level}]`;
|
|
293
|
+
// The caller's (council/advanced) configured timeout is the budget: agy
|
|
294
|
+
// receives it as its own --print-timeout and self-terminates there. The JS
|
|
295
|
+
// timer below is only a backstop for a process that ignores its own timeout.
|
|
296
|
+
const budgetMs = timeout || 600000;
|
|
297
|
+
const printTimeoutSecs = Math.max(1, Math.ceil(budgetMs / 1000));
|
|
298
|
+
const composed = this._composeArgs({ model: this.model, directive: ANALYSIS_DIRECTIVE, printTimeoutSecs, agentic: true });
|
|
299
|
+
const { command, args } = this._wrapCommand(composed);
|
|
300
|
+
|
|
301
|
+
logger.info(`${levelPrefix} Executing Antigravity CLI...`);
|
|
302
|
+
logger.info(`${levelPrefix} Writing prompt: ${prompt.length} bytes`);
|
|
303
|
+
|
|
304
|
+
const agy = spawn(command, args, {
|
|
305
|
+
cwd,
|
|
306
|
+
env: {
|
|
307
|
+
...process.env,
|
|
308
|
+
...this.extraEnv,
|
|
309
|
+
PATH: `${BIN_DIR}:${process.env.PATH}`
|
|
310
|
+
},
|
|
311
|
+
shell: this.useShell,
|
|
312
|
+
detached: this.useShell
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
const pid = agy.pid;
|
|
316
|
+
logger.info(`${levelPrefix} Spawned Antigravity CLI process: PID ${pid}`);
|
|
317
|
+
|
|
318
|
+
if (analysisId && registerProcess) {
|
|
319
|
+
registerProcess(analysisId, agy);
|
|
320
|
+
logger.info(`${levelPrefix} Registered process ${pid} for analysis ${analysisId}`);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Wire AbortSignal -> SIGTERM for tour/summary cancellation.
|
|
324
|
+
const abortWiring = wireAbortToChild(agy, abortSignal, { logPrefix: levelPrefix, shell: this.useShell });
|
|
325
|
+
|
|
326
|
+
let stdout = '';
|
|
327
|
+
let stderr = '';
|
|
328
|
+
let timeoutId = null;
|
|
329
|
+
let settled = false;
|
|
330
|
+
|
|
331
|
+
const settle = (fn, value) => {
|
|
332
|
+
if (settled) return;
|
|
333
|
+
settled = true;
|
|
334
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
335
|
+
abortWiring.detach();
|
|
336
|
+
fn(value);
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
const backstopMs = budgetMs + TIMEOUT_BACKSTOP_GRACE_MS;
|
|
340
|
+
timeoutId = setTimeout(() => {
|
|
341
|
+
logger.error(`${levelPrefix} Process ${pid} exceeded its ${budgetMs}ms timeout budget (backstop fired at ${backstopMs}ms)`);
|
|
342
|
+
agy.kill('SIGTERM');
|
|
343
|
+
settle(reject, new Error(`${levelPrefix} Antigravity CLI timed out after ${budgetMs}ms`));
|
|
344
|
+
}, backstopMs);
|
|
345
|
+
|
|
346
|
+
agy.stdout.on('data', (data) => {
|
|
347
|
+
stdout += data.toString();
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
agy.stderr.on('data', (data) => {
|
|
351
|
+
stderr += data.toString();
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
agy.on('close', (code) => {
|
|
355
|
+
if (settled) return;
|
|
356
|
+
|
|
357
|
+
// BackgroundQueue-driven cancellation — see claude-provider for rationale.
|
|
358
|
+
if (abortWiring.cancelled()) {
|
|
359
|
+
logger.info(`${levelPrefix} Antigravity CLI terminated by user cancel (exit code ${code})`);
|
|
360
|
+
settle(reject, makeAbortError(`${levelPrefix} Cancelled by user`));
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Check for cancellation signals (SIGTERM=143, SIGKILL=137)
|
|
365
|
+
const isCancellationCode = code === 143 || code === 137;
|
|
366
|
+
if (isCancellationCode && analysisId && isAnalysisCancelled(analysisId)) {
|
|
367
|
+
logger.info(`${levelPrefix} Antigravity CLI terminated due to analysis cancellation (exit code ${code})`);
|
|
368
|
+
settle(reject, new CancellationError(`${levelPrefix} Analysis cancelled by user`));
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (stderr.trim()) {
|
|
373
|
+
if (code !== 0) {
|
|
374
|
+
logger.error(`${levelPrefix} Antigravity CLI stderr (exit code ${code}): ${stderr}`);
|
|
375
|
+
} else {
|
|
376
|
+
logger.warn(`${levelPrefix} Antigravity CLI stderr (success): ${stderr}`);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (code !== 0) {
|
|
381
|
+
// agy prints "Error: timeout waiting for response" here if the
|
|
382
|
+
// agentic loop exceeded --print-timeout before producing an answer.
|
|
383
|
+
logger.error(`${levelPrefix} Antigravity CLI exited with code ${code}`);
|
|
384
|
+
settle(reject, new Error(`${levelPrefix} Antigravity CLI exited with code ${code}: ${stderr}`));
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
logger.info(`${levelPrefix} Antigravity CLI completed: ${stdout.length} bytes of output`);
|
|
389
|
+
|
|
390
|
+
// Print mode emits a single plain-text block; extract the JSON directly.
|
|
391
|
+
const extracted = extractJSON(stdout, level, levelPrefix);
|
|
392
|
+
if (extracted.success) {
|
|
393
|
+
logger.success(`${levelPrefix} Successfully parsed JSON response`);
|
|
394
|
+
const dataPreview = JSON.stringify(extracted.data, null, 2);
|
|
395
|
+
logger.debug(`${levelPrefix} [parsed_data] ${dataPreview.substring(0, 3000)}${dataPreview.length > 3000 ? '...' : ''}`);
|
|
396
|
+
if (extracted.data?.suggestions) {
|
|
397
|
+
const count = Array.isArray(extracted.data.suggestions) ? extracted.data.suggestions.length : 0;
|
|
398
|
+
logger.info(`${levelPrefix} [response] ${count} suggestions in parsed response`);
|
|
399
|
+
}
|
|
400
|
+
settle(resolve, extracted.data);
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Regex extraction failed — try the LLM-based extraction fallback.
|
|
405
|
+
logger.warn(`${levelPrefix} Regex extraction failed: ${extracted.error}`);
|
|
406
|
+
logger.info(`${levelPrefix} LLM fallback input length: ${stdout.length} characters (raw stdout)`);
|
|
407
|
+
logger.info(`${levelPrefix} Attempting LLM-based JSON extraction fallback...`);
|
|
408
|
+
|
|
409
|
+
// agy exited cleanly; the process backstop has nothing left to supervise.
|
|
410
|
+
// Disarm it before the LLM fallback (which spawns and supervises its own
|
|
411
|
+
// child) so a slow extraction can't trip an "agy timed out" rejection
|
|
412
|
+
// after agy already succeeded.
|
|
413
|
+
if (timeoutId) {
|
|
414
|
+
clearTimeout(timeoutId);
|
|
415
|
+
timeoutId = null;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
(async () => {
|
|
419
|
+
try {
|
|
420
|
+
const llmExtracted = await this.extractJSONWithLLM(stdout, { level, analysisId, registerProcess, logPrefix: levelPrefix });
|
|
421
|
+
if (llmExtracted.success) {
|
|
422
|
+
logger.success(`${levelPrefix} LLM extraction fallback succeeded`);
|
|
423
|
+
settle(resolve, llmExtracted.data);
|
|
424
|
+
} else {
|
|
425
|
+
logger.warn(`${levelPrefix} LLM extraction fallback also failed: ${llmExtracted.error}`);
|
|
426
|
+
logger.info(`${levelPrefix} Raw response preview: ${stdout.substring(0, 500)}...`);
|
|
427
|
+
settle(resolve, { raw: stdout, parsed: false });
|
|
428
|
+
}
|
|
429
|
+
} catch (llmError) {
|
|
430
|
+
logger.warn(`${levelPrefix} LLM extraction fallback error: ${llmError.message}`);
|
|
431
|
+
settle(resolve, { raw: stdout, parsed: false });
|
|
432
|
+
}
|
|
433
|
+
})();
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
agy.on('error', (error) => {
|
|
437
|
+
if (error.code === 'ENOENT') {
|
|
438
|
+
logger.error(`${levelPrefix} Antigravity CLI not found. Please ensure the Antigravity CLI is installed.`);
|
|
439
|
+
settle(reject, new Error(`${levelPrefix} Antigravity CLI not found. ${AntigravityProvider.getInstallInstructions()}`));
|
|
440
|
+
} else {
|
|
441
|
+
logger.error(`${levelPrefix} Antigravity process error: ${error}`);
|
|
442
|
+
settle(reject, error);
|
|
443
|
+
}
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
// Handle stdin errors (e.g., EPIPE if the process exits before the write completes)
|
|
447
|
+
agy.stdin.on('error', (err) => {
|
|
448
|
+
logger.error(`${levelPrefix} stdin error: ${err.message}`);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
// Deliver the full prompt via stdin (avoids argv length limits on large diffs)
|
|
452
|
+
agy.stdin.write(prompt, (err) => {
|
|
453
|
+
if (err) {
|
|
454
|
+
logger.error(`${levelPrefix} Failed to write prompt to stdin: ${err}`);
|
|
455
|
+
agy.kill('SIGTERM');
|
|
456
|
+
settle(reject, new Error(`${levelPrefix} Failed to write prompt to stdin: ${err}`));
|
|
457
|
+
}
|
|
458
|
+
});
|
|
459
|
+
agy.stdin.end();
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Build the exact { command, args } that execute() spawns for analysis.
|
|
465
|
+
* Exposed so out-of-band callers (e.g. the security verifier) can reproduce
|
|
466
|
+
* the real analysis invocation — including the shell-wrapping branch and the
|
|
467
|
+
* --dangerously-skip-permissions flag — without reaching into `_`-internals.
|
|
468
|
+
* The heavy prompt still travels over stdin; this covers argv only.
|
|
469
|
+
* @param {number} [printTimeoutSecs=60] - Value for --print-timeout.
|
|
470
|
+
* @returns {{command: string, args: string[]}}
|
|
471
|
+
*/
|
|
472
|
+
getAnalysisSpawnConfig(printTimeoutSecs = 60) {
|
|
473
|
+
const composed = this._composeArgs({
|
|
474
|
+
model: this.model,
|
|
475
|
+
directive: ANALYSIS_DIRECTIVE,
|
|
476
|
+
printTimeoutSecs,
|
|
477
|
+
agentic: true
|
|
478
|
+
});
|
|
479
|
+
return this._wrapCommand(composed);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Get CLI configuration for LLM extraction. The base class writes the
|
|
484
|
+
* extraction prompt to stdin; we bake the extraction directive into `-p`.
|
|
485
|
+
* @param {string} model - The model to use for extraction
|
|
486
|
+
* @returns {Object} Configuration for spawning the extraction process
|
|
487
|
+
*/
|
|
488
|
+
getExtractionConfig(model) {
|
|
489
|
+
const args = this._composeArgs({
|
|
490
|
+
model,
|
|
491
|
+
directive: EXTRACTION_DIRECTIVE,
|
|
492
|
+
printTimeoutSecs: EXTRACTION_PRINT_TIMEOUT_SECS
|
|
493
|
+
});
|
|
494
|
+
const { command, args: wrappedArgs } = this._wrapCommand(args);
|
|
495
|
+
// The LLM-extraction fallback is the COMMON path here (agy emits plain text,
|
|
496
|
+
// no JSON mode), so it must run with the same env analysis uses. Resolve env
|
|
497
|
+
// for the EXTRACTION model — which may be a different fast-tier model than
|
|
498
|
+
// this.model — rather than reusing this.extraEnv (the analysis model's env).
|
|
499
|
+
const { env } = this._resolveModelConfig(model);
|
|
500
|
+
return {
|
|
501
|
+
command,
|
|
502
|
+
args: wrappedArgs,
|
|
503
|
+
useShell: this.useShell,
|
|
504
|
+
promptViaStdin: true,
|
|
505
|
+
env
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Test if the Antigravity CLI is available (respects ENV > config > default).
|
|
511
|
+
* @param {number} [timeoutMs=10000] - Timeout for the probe.
|
|
512
|
+
* @returns {Promise<boolean>}
|
|
513
|
+
*/
|
|
514
|
+
async testAvailability(timeoutMs = 10000) {
|
|
515
|
+
return new Promise((resolve) => {
|
|
516
|
+
const useShell = this.useShell;
|
|
517
|
+
const command = useShell ? `${this.agyCmd} --version` : this.agyCmd;
|
|
518
|
+
const args = useShell ? [] : ['--version'];
|
|
519
|
+
|
|
520
|
+
const fullCmd = useShell ? command : `${command} ${args.join(' ')}`;
|
|
521
|
+
logger.debug(`Antigravity availability check: ${fullCmd}`);
|
|
522
|
+
|
|
523
|
+
const agy = spawn(command, args, {
|
|
524
|
+
env: {
|
|
525
|
+
...process.env,
|
|
526
|
+
PATH: `${BIN_DIR}:${process.env.PATH}`
|
|
527
|
+
},
|
|
528
|
+
shell: useShell
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
let stdout = '';
|
|
532
|
+
let settled = false;
|
|
533
|
+
|
|
534
|
+
const availabilityTimeout = setTimeout(() => {
|
|
535
|
+
if (settled) return;
|
|
536
|
+
settled = true;
|
|
537
|
+
logger.warn(`Antigravity CLI availability check timed out after ${Math.round(timeoutMs / 1000)}s`);
|
|
538
|
+
try { agy.kill(); } catch { /* ignore */ }
|
|
539
|
+
resolve(false);
|
|
540
|
+
}, timeoutMs);
|
|
541
|
+
|
|
542
|
+
agy.stdout.on('data', (data) => {
|
|
543
|
+
stdout += data.toString();
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
agy.on('close', (code) => {
|
|
547
|
+
if (settled) return;
|
|
548
|
+
settled = true;
|
|
549
|
+
clearTimeout(availabilityTimeout);
|
|
550
|
+
if (code === 0 && stdout.includes('.')) {
|
|
551
|
+
logger.info(`Antigravity CLI available: ${stdout.trim()}`);
|
|
552
|
+
resolve(true);
|
|
553
|
+
} else {
|
|
554
|
+
logger.warn('Antigravity CLI not available or returned unexpected output');
|
|
555
|
+
resolve(false);
|
|
556
|
+
}
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
agy.on('error', (error) => {
|
|
560
|
+
if (settled) return;
|
|
561
|
+
settled = true;
|
|
562
|
+
clearTimeout(availabilityTimeout);
|
|
563
|
+
logger.warn(`Antigravity CLI not available: ${error.message}`);
|
|
564
|
+
resolve(false);
|
|
565
|
+
});
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
static getProviderName() {
|
|
570
|
+
return 'Antigravity';
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
static getProviderId() {
|
|
574
|
+
return 'antigravity';
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
static getModels() {
|
|
578
|
+
return ANTIGRAVITY_MODELS;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
static getDefaultModel() {
|
|
582
|
+
return DEFAULT_ANTIGRAVITY_MODEL;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
static getInstallInstructions() {
|
|
586
|
+
return 'Install the Antigravity CLI: curl -fsSL https://antigravity.google/cli/install.sh | bash\n' +
|
|
587
|
+
'Or visit: https://antigravity.google/docs';
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// Register this provider
|
|
592
|
+
registerProvider('antigravity', AntigravityProvider);
|
|
593
|
+
|
|
594
|
+
module.exports = AntigravityProvider;
|
package/src/ai/index.js
CHANGED
|
@@ -49,7 +49,7 @@ const { createExecutableProviderClass } = require('./executable-provider');
|
|
|
49
49
|
// Load and register all providers
|
|
50
50
|
// Each provider self-registers when loaded
|
|
51
51
|
require('./claude-provider');
|
|
52
|
-
require('./
|
|
52
|
+
require('./antigravity-provider');
|
|
53
53
|
require('./codex-provider');
|
|
54
54
|
require('./copilot-provider');
|
|
55
55
|
require('./opencode-provider');
|
|
@@ -68,7 +68,7 @@ class OpenCodeProvider extends AIProvider {
|
|
|
68
68
|
this.useShell = opencodeCmd.includes(' ');
|
|
69
69
|
|
|
70
70
|
// SECURITY: OpenCode runs in a worktree with prompt engineering for read-only ops
|
|
71
|
-
// Similar to
|
|
71
|
+
// Similar to the Antigravity provider's security model - relies on:
|
|
72
72
|
// 1. Prompt engineering: Analysis prompts instruct AI to only read, never modify
|
|
73
73
|
// 2. Worktree isolation: Analysis runs in a git worktree, limiting blast radius
|
|
74
74
|
//
|
package/src/ai/provider.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* AI Provider Abstraction Layer
|
|
4
4
|
*
|
|
5
|
-
* Defines a common interface for AI providers (Claude,
|
|
5
|
+
* Defines a common interface for AI providers (Claude, Antigravity, etc.)
|
|
6
6
|
* and provides a factory function to create provider instances.
|
|
7
7
|
*/
|
|
8
8
|
|
|
@@ -688,7 +688,7 @@ function getProviderConfigOverrides(providerId) {
|
|
|
688
688
|
|
|
689
689
|
/**
|
|
690
690
|
* Register a provider class
|
|
691
|
-
* @param {string} id - Provider ID (e.g., 'claude', '
|
|
691
|
+
* @param {string} id - Provider ID (e.g., 'claude', 'antigravity')
|
|
692
692
|
* @param {typeof AIProvider} providerClass - The provider class
|
|
693
693
|
*/
|
|
694
694
|
function registerProvider(id, providerClass) {
|
|
@@ -908,7 +908,7 @@ function getAllProvidersInfo() {
|
|
|
908
908
|
|
|
909
909
|
/**
|
|
910
910
|
* Create a provider instance
|
|
911
|
-
* @param {string} providerId - Provider ID (e.g., 'claude', '
|
|
911
|
+
* @param {string} providerId - Provider ID (e.g., 'claude', 'antigravity')
|
|
912
912
|
* @param {string} model - Model to use (optional, uses default if not specified)
|
|
913
913
|
* @param {Object} overrides - Per-call config overrides that supersede global providerConfigOverrides (optional)
|
|
914
914
|
* @returns {AIProvider}
|
|
@@ -1011,8 +1011,8 @@ async function testProviderAvailability(providerId, timeoutMs) {
|
|
|
1011
1011
|
* Matches against both the canonical model `id` and any `aliases` so legacy
|
|
1012
1012
|
* model IDs (e.g. `gpt-5.4` before reasoning-effort variants were introduced)
|
|
1013
1013
|
* still resolve their tier for historical analysis runs.
|
|
1014
|
-
* @param {string} providerId - Provider ID (e.g., 'claude', '
|
|
1015
|
-
* @param {string} modelId - Model ID (e.g., 'sonnet', 'gemini-
|
|
1014
|
+
* @param {string} providerId - Provider ID (e.g., 'claude', 'antigravity')
|
|
1015
|
+
* @param {string} modelId - Model ID (e.g., 'sonnet', 'gemini-3.1-pro-low')
|
|
1016
1016
|
* @returns {string|null} Tier name or null if provider or model not found
|
|
1017
1017
|
*/
|
|
1018
1018
|
function getTierForModel(providerId, modelId) {
|