@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.
@@ -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('./gemini-provider');
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 Gemini's security model - relies on:
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
  //
@@ -2,7 +2,7 @@
2
2
  /**
3
3
  * AI Provider Abstraction Layer
4
4
  *
5
- * Defines a common interface for AI providers (Claude, Gemini, etc.)
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', 'gemini')
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', 'gemini')
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', 'gemini')
1015
- * @param {string} modelId - Model ID (e.g., 'sonnet', 'gemini-2.5-pro')
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) {