converse-mcp-server 2.27.0 → 2.28.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.
@@ -1,81 +1,113 @@
1
1
  /**
2
- * Gemini CLI Provider
2
+ * Gemini CLI Provider (Antigravity CLI / agy subprocess)
3
3
  *
4
- * Provider implementation for Google's Gemini models using the ai-sdk-provider-gemini-cli package.
5
- * Implements the unified interface: async invoke(messages, options) => { content, stop_reason, rawResponse }
4
+ * Provider implementation for Google's Gemini models via the Antigravity CLI
5
+ * (`agy`, v1.0.7+) in print mode (`agy -p`), authenticated through the user's
6
+ * Antigravity Google OAuth login. Replaces the previous
7
+ * `ai-sdk-provider-gemini-cli` implementation, whose OAuth credentials Google
8
+ * sunsets on 2026-06-18.
6
9
  *
7
- * Key features:
8
- * - Uses OAuth authentication from Gemini CLI (no API keys needed)
9
- * - Supports gemini-3-pro-preview model via Google Cloud Code endpoints
10
- * - Uses AI SDK v6 standard interfaces (generateText/streamText)
11
- * - Compatible with both chat and consensus tools
10
+ * Architecture: one-shot subprocess wrapper. Each invoke() serializes the full
11
+ * messages array into a single prompt, spawns `agy` under a pseudo-terminal,
12
+ * collects the printed response, and returns it. A PTY is REQUIRED: agy print
13
+ * mode silently drops stdout in any non-TTY context (upstream bug
14
+ * google-antigravity/antigravity-cli#76, unfixed as of v1.0.7).
12
15
  *
13
16
  * Authentication:
14
- * - Requires global Gemini CLI installation: npm install -g @google/gemini-cli
15
- * - User must authenticate once via: gemini (interactive CLI)
16
- * - Credentials stored in ~/.gemini/oauth_creds.json
17
+ * - Requires the Antigravity CLI (`agy`) installed and authenticated once
18
+ * interactively (`agy`) via Google OAuth. The first interactive login also
19
+ * establishes workspace trust for the user's home directory.
20
+ *
21
+ * The provider registry key remains 'gemini-cli' and the user-facing alias
22
+ * remains 'gemini' for routing/normalization stability. Only three user-facing
23
+ * model names are exposed: gemini (= gemini:pro), gemini:pro, gemini:flash.
17
24
  */
18
25
 
19
- import { existsSync } from 'node:fs';
20
- import { homedir } from 'node:os';
21
- import { join } from 'node:path';
26
+ import { existsSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
27
+ import { homedir, tmpdir } from 'node:os';
28
+ import { join, delimiter } from 'node:path';
29
+ import { randomUUID } from 'node:crypto';
22
30
  import { debugLog, debugError } from '../utils/console.js';
23
31
  import { ProviderError, ErrorCodes, StopReasons } from './interface.js';
24
32
 
25
- // Supported Gemini CLI models with their configurations
33
+ // Prompts at or below this length pass directly as the -p argv value (fast
34
+ // path). Larger prompts are written to a file and -p carries a bootstrap
35
+ // instruction. Keeps argv well under the Windows 32,767-char CreateProcess
36
+ // ceiling (error 206).
37
+ const ARGV_PROMPT_LIMIT = 24000;
38
+
39
+ // Default print timeout (ms) when the tool layer passes none.
40
+ const DEFAULT_TIMEOUT_MS = 600000;
41
+
42
+ // Extra wall-clock grace before the JS-side hard kill fires (ms).
43
+ const HARD_KILL_GRACE_MS = 15000;
44
+
45
+ // After pty.kill(), force-settle if onExit never fires (ms).
46
+ const POST_KILL_GRACE_MS = 5000;
47
+
48
+ // PTY width: wide enough that response lines rarely soft-wrap (soft-wrap inserts
49
+ // \r\n indistinguishable from real newlines). rows are irrelevant in print mode.
50
+ const PTY_COLS = 1000;
51
+
52
+ /**
53
+ * Supported Gemini models. Only three user-facing names are exposed; each maps
54
+ * to an agy display-name base that gets a reasoning-effort suffix appended at
55
+ * spawn time. All are text-only (print mode has no image input channel).
56
+ */
26
57
  const SUPPORTED_MODELS = {
27
58
  gemini: {
28
59
  modelName: 'gemini',
29
- friendlyName: 'Gemini 3.5 Flash (via CLI)',
30
- contextWindow: 1048576, // 1M tokens
60
+ friendlyName: 'Gemini 3.1 Pro (via Antigravity CLI)',
61
+ contextWindow: 1048576,
62
+ maxOutputTokens: 65536,
63
+ supportsStreaming: true,
64
+ supportsImages: false,
65
+ supportsTemperature: false,
66
+ supportsWebSearch: false,
67
+ supportsThinking: true,
68
+ timeout: DEFAULT_TIMEOUT_MS,
69
+ description:
70
+ 'Gemini 3.1 Pro via Antigravity CLI (agy) - requires Antigravity Google OAuth login',
71
+ aliases: ['gemini-cli'],
72
+ // agy display-name base; reasoning_effort selects the parenthesized variant
73
+ agyModelBase: 'Gemini 3.1 Pro',
74
+ },
75
+ 'gemini:pro': {
76
+ modelName: 'gemini:pro',
77
+ friendlyName: 'Gemini 3.1 Pro (via Antigravity CLI)',
78
+ contextWindow: 1048576,
31
79
  maxOutputTokens: 65536,
32
80
  supportsStreaming: true,
33
- supportsImages: true, // Base64 only (no URLs)
34
- supportsTemperature: true,
81
+ supportsImages: false,
82
+ supportsTemperature: false,
83
+ supportsWebSearch: false,
35
84
  supportsThinking: true,
36
- supportsWebSearch: true,
37
- timeout: 600000, // 10 minutes
85
+ timeout: DEFAULT_TIMEOUT_MS,
38
86
  description:
39
- 'Gemini 3.5 Flash via OAuth - frontier agentic/coding at Flash speed (requires Gemini CLI authentication)',
40
- aliases: [
41
- 'gemini-cli',
42
- 'gemini-3.5-flash',
43
- 'gemini-3.5',
44
- 'gemini3.5',
45
- 'flash',
46
- 'flash-3.5',
47
- 'gemini-flash',
48
- 'gemini-flash-3.5',
49
- ],
50
- // Internal SDK model name passed to the Google Cloud Code endpoint
51
- sdkModelName: 'gemini-3.5-flash',
87
+ 'Gemini 3.1 Pro via Antigravity CLI (agy) - explicit alias of `gemini`',
88
+ aliases: [],
89
+ agyModelBase: 'Gemini 3.1 Pro',
52
90
  },
53
- 'gemini-3.1-pro-preview': {
54
- modelName: 'gemini-3.1-pro-preview',
55
- friendlyName: 'Gemini 3.1 Pro Preview (via CLI)',
56
- contextWindow: 1048576, // 1M tokens
57
- maxOutputTokens: 64000,
91
+ 'gemini:flash': {
92
+ modelName: 'gemini:flash',
93
+ friendlyName: 'Gemini 3.5 Flash (via Antigravity CLI)',
94
+ contextWindow: 1048576,
95
+ maxOutputTokens: 65536,
58
96
  supportsStreaming: true,
59
- supportsImages: true, // Base64 only (no URLs)
60
- supportsTemperature: true,
97
+ supportsImages: false,
98
+ supportsTemperature: false,
99
+ supportsWebSearch: false,
61
100
  supportsThinking: true,
62
- supportsWebSearch: true,
63
- timeout: 600000, // 10 minutes
101
+ timeout: DEFAULT_TIMEOUT_MS,
64
102
  description:
65
- 'Gemini 3.1 Pro Preview via OAuth - requires Gemini CLI authentication',
66
- aliases: [
67
- 'gemini-3.1-pro',
68
- 'gemini-3.1',
69
- 'gemini-pro',
70
- 'gemini-3-pro',
71
- 'pro',
72
- ],
73
- sdkModelName: 'gemini-3.1-pro-preview',
103
+ 'Gemini 3.5 Flash via Antigravity CLI (agy) - requires Antigravity Google OAuth login',
104
+ aliases: ['flash'],
105
+ agyModelBase: 'Gemini 3.5 Flash',
74
106
  },
75
107
  };
76
108
 
77
109
  /**
78
- * Custom error class for Gemini CLI provider errors
110
+ * Custom error class for Gemini CLI (agy) provider errors
79
111
  */
80
112
  class GeminiCliProviderError extends ProviderError {
81
113
  constructor(message, code, originalError = null) {
@@ -84,484 +116,679 @@ class GeminiCliProviderError extends ProviderError {
84
116
  }
85
117
  }
86
118
 
119
+ // ---------------------------------------------------------------------------
120
+ // Pure helpers (exported for unit testing)
121
+ // ---------------------------------------------------------------------------
122
+
123
+ let _cachedAgyPath; // undefined = not probed; null = probed, not found
124
+
87
125
  /**
88
- * Check if OAuth credentials file exists
89
- * @returns {boolean} True if credentials file exists
126
+ * Locate the agy binary: PATH first, then the platform install fallback.
127
+ * Result is cached at module level (null cached if not found).
128
+ * @returns {string|null} Absolute path to agy, or null if not found
90
129
  */
91
- function hasOAuthCredentials() {
92
- try {
93
- const credsPath = join(homedir(), '.gemini', 'oauth_creds.json');
94
- return existsSync(credsPath);
95
- } catch (error) {
96
- debugError('[Gemini CLI] Error checking OAuth credentials', error);
97
- return false;
130
+ export function findAgyBinary() {
131
+ if (_cachedAgyPath !== undefined) {
132
+ return _cachedAgyPath;
98
133
  }
99
- }
100
134
 
101
- /**
102
- * Dynamically import Gemini CLI SDK (lazy loading)
103
- * This keeps the SDK as an optional dependency
104
- */
105
- async function getGeminiCliSDK() {
135
+ const isWindows = process.platform === 'win32';
136
+ const exe = isWindows ? 'agy.exe' : 'agy';
137
+
138
+ // 1. PATH lookup
139
+ const pathEnv = process.env.PATH || process.env.Path || '';
140
+ for (const dir of pathEnv.split(delimiter)) {
141
+ if (!dir) continue;
142
+ try {
143
+ const candidate = join(dir, exe);
144
+ if (existsSync(candidate)) {
145
+ _cachedAgyPath = candidate;
146
+ return _cachedAgyPath;
147
+ }
148
+ } catch {
149
+ // ignore malformed PATH entries
150
+ }
151
+ }
152
+
153
+ // 2. Platform install fallback
106
154
  try {
107
- // Use dynamic import to load SDK only when needed
108
- const { createGeminiProvider } = await import('ai-sdk-provider-gemini-cli');
109
- return createGeminiProvider;
110
- } catch (error) {
111
- throw new GeminiCliProviderError(
112
- 'Gemini CLI SDK not installed. Install with: npm install ai-sdk-provider-gemini-cli',
113
- 'GEMINI_CLI_NOT_INSTALLED',
114
- error,
115
- );
155
+ if (isWindows) {
156
+ const localAppData =
157
+ process.env.LOCALAPPDATA || join(homedir(), 'AppData', 'Local');
158
+ const candidate = join(localAppData, 'agy', 'bin', 'agy.exe');
159
+ if (existsSync(candidate)) {
160
+ _cachedAgyPath = candidate;
161
+ return _cachedAgyPath;
162
+ }
163
+ } else {
164
+ const candidate = join(homedir(), '.local', 'bin', 'agy');
165
+ if (existsSync(candidate)) {
166
+ _cachedAgyPath = candidate;
167
+ return _cachedAgyPath;
168
+ }
169
+ }
170
+ } catch {
171
+ // ignore
116
172
  }
173
+
174
+ _cachedAgyPath = null;
175
+ return _cachedAgyPath;
117
176
  }
118
177
 
119
178
  /**
120
- * Dynamically import AI SDK (lazy loading)
179
+ * Map a reasoning_effort value to the agy parenthesized variant suffix.
180
+ * Flash supports Low/Medium/High; Pro supports Low/High (no Medium).
181
+ * @param {string} base - agy model base ('Gemini 3.5 Flash' / 'Gemini 3.1 Pro')
182
+ * @param {string} [reasoningEffort]
183
+ * @returns {string} e.g. '(Low)', '(Medium)', '(High)'
121
184
  */
122
- async function getAISDK() {
123
- try {
124
- const { generateText, streamText } = await import('ai');
125
- return { generateText, streamText };
126
- } catch (error) {
127
- throw new GeminiCliProviderError(
128
- 'AI SDK not installed. Install with: npm install ai',
129
- 'AI_SDK_NOT_INSTALLED',
130
- error,
131
- );
185
+ function effortSuffix(base, reasoningEffort) {
186
+ const isPro = /pro/i.test(base);
187
+ const effort = (reasoningEffort || '').toLowerCase();
188
+
189
+ switch (effort) {
190
+ case 'none':
191
+ case 'minimal':
192
+ case 'low':
193
+ return '(Low)';
194
+ case 'medium':
195
+ // Pro has no Medium variant — fall back to High
196
+ return isPro ? '(High)' : '(Medium)';
197
+ case 'high':
198
+ case 'max':
199
+ return '(High)';
200
+ default:
201
+ // unset → High
202
+ return '(High)';
132
203
  }
133
204
  }
134
205
 
135
206
  /**
136
- * Create stream generator for Gemini CLI streaming responses
137
- * Yields normalized events compatible with ProviderStreamNormalizer
207
+ * Resolve a user-facing model name + reasoning_effort to the agy display name
208
+ * passed via --model. Strips the gemini: prefix (case-insensitive), maps the
209
+ * alias, and appends the effort suffix. Full agy display names pass through
210
+ * verbatim so power users aren't blocked.
211
+ * @param {string} model - e.g. 'gemini', 'gemini:flash', or a full agy name
212
+ * @param {string} [reasoningEffort]
213
+ * @returns {string} agy --model value, e.g. 'Gemini 3.1 Pro (High)'
138
214
  */
139
- async function* createStreamingGenerator(
140
- modelInstance,
141
- messages,
142
- options,
143
- signal,
144
- userFacingModelName = 'gemini',
145
- ) {
146
- const { streamText } = await getAISDK();
215
+ export function resolveAgyModel(model, reasoningEffort) {
216
+ const raw = typeof model === 'string' ? model.trim() : '';
147
217
 
148
- try {
149
- const streamOptions = {
150
- model: modelInstance,
151
- messages,
152
- ...options,
153
- };
218
+ // Full agy display-name passthrough (already contains a parenthesized variant)
219
+ if (/\(.*\)\s*$/.test(raw) && /gemini/i.test(raw)) {
220
+ return raw;
221
+ }
154
222
 
155
- if (signal) {
156
- streamOptions.abortSignal = signal;
157
- }
223
+ let name = raw;
224
+ if (name.toLowerCase().startsWith('gemini:')) {
225
+ name = name.slice('gemini:'.length).trim();
226
+ }
158
227
 
159
- const result = await streamText(streamOptions);
228
+ const nameLower = name.toLowerCase();
229
+
230
+ // Determine the agy base
231
+ let base;
232
+ if (
233
+ !nameLower ||
234
+ nameLower === 'gemini' ||
235
+ nameLower === 'gemini-cli' ||
236
+ nameLower === 'pro'
237
+ ) {
238
+ base = SUPPORTED_MODELS.gemini.agyModelBase;
239
+ } else if (nameLower === 'flash') {
240
+ base = SUPPORTED_MODELS['gemini:flash'].agyModelBase;
241
+ } else {
242
+ // Unknown suffix: pass through verbatim (power-user agy display name)
243
+ return raw;
244
+ }
160
245
 
161
- // Yield start event
162
- yield {
163
- type: 'start',
164
- provider: 'gemini-cli',
165
- model: userFacingModelName,
166
- };
246
+ return `${base} ${effortSuffix(base, reasoningEffort)}`;
247
+ }
167
248
 
168
- // Stream text chunks
169
- for await (const chunk of result.textStream) {
170
- // Check for cancellation
171
- if (signal?.aborted) {
172
- throw new GeminiCliProviderError('Request cancelled', 'CANCELLED');
173
- }
249
+ /**
250
+ * Serialize the full messages array into a single role-labeled prompt string.
251
+ * System message becomes a <system> preamble; prior turns render as
252
+ * User:/Assistant: blocks; ends with an instruction to answer the final user
253
+ * message directly without role labels. Throws on image content parts.
254
+ * @param {Array} messages - Converse-format messages
255
+ * @returns {string}
256
+ */
257
+ export function buildPrompt(messages) {
258
+ if (!Array.isArray(messages) || messages.length === 0) {
259
+ throw new GeminiCliProviderError(
260
+ 'Messages must be a non-empty array',
261
+ ErrorCodes.INVALID_MESSAGES,
262
+ );
263
+ }
174
264
 
175
- // Yield delta event with content chunk (normalized format)
176
- yield {
177
- type: 'delta',
178
- data: {
179
- textDelta: chunk,
180
- },
181
- };
265
+ const renderContent = (content) => {
266
+ if (typeof content === 'string') {
267
+ return content;
182
268
  }
183
-
184
- // Get final usage stats and metadata
185
- const usage = await result.usage;
186
- const finishReason = await result.finishReason;
187
-
188
- // Yield usage event
189
- if (usage) {
190
- const tokens = extractUsageTokens(usage);
191
- yield {
192
- type: 'usage',
193
- usage: {
194
- input_tokens: tokens.input,
195
- output_tokens: tokens.output,
196
- total_tokens: tokens.total,
197
- cached_input_tokens: 0,
198
- },
199
- };
269
+ if (Array.isArray(content)) {
270
+ const parts = [];
271
+ for (const part of content) {
272
+ if (part?.type === 'image') {
273
+ throw new GeminiCliProviderError(
274
+ 'Images are not supported by the gemini provider (Antigravity CLI print mode has no image input channel)',
275
+ ErrorCodes.INVALID_REQUEST,
276
+ );
277
+ }
278
+ if (part?.type === 'text' && typeof part.text === 'string') {
279
+ parts.push(part.text);
280
+ }
281
+ }
282
+ return parts.join('\n');
200
283
  }
201
-
202
- // Yield end event
203
- yield {
204
- type: 'end',
205
- stop_reason: mapFinishReason(finishReason),
206
- finish_reason: getRawFinishReason(finishReason),
207
- };
208
- } catch (error) {
209
- if (signal?.aborted) {
210
- throw new GeminiCliProviderError('Request cancelled', 'CANCELLED');
284
+ return '';
285
+ };
286
+
287
+ const systemParts = [];
288
+ const turns = [];
289
+
290
+ for (const message of messages) {
291
+ const role = message?.role;
292
+ const text = renderContent(message?.content);
293
+ if (role === 'system') {
294
+ if (text) systemParts.push(text);
295
+ } else if (role === 'assistant') {
296
+ turns.push(`Assistant: ${text}`);
297
+ } else {
298
+ // treat anything else (user/tool/unknown) as a user turn
299
+ turns.push(`User: ${text}`);
211
300
  }
212
- throw error;
213
301
  }
302
+
303
+ const sections = [];
304
+ if (systemParts.length > 0) {
305
+ sections.push(`<system>\n${systemParts.join('\n\n')}\n</system>`);
306
+ }
307
+
308
+ if (turns.length > 1) {
309
+ sections.push(
310
+ 'The following is a conversation transcript. Read the full transcript, then write the assistant\'s next reply to the final User message. Respond directly without any role label.',
311
+ );
312
+ sections.push(turns.join('\n\n'));
313
+ } else {
314
+ // Single user turn — strip the label, just ask directly.
315
+ const onlyTurn = turns[0] || '';
316
+ sections.push(onlyTurn.replace(/^User:\s*/, ''));
317
+ }
318
+
319
+ return sections.join('\n\n');
214
320
  }
215
321
 
216
322
  /**
217
- * Map AI SDK v6 finish reasons to our StopReasons enum
218
- * AI SDK v6 returns finishReason as { unified: 'stop', raw: 'STOP' }
219
- * @param {Object|string} finishReason - The finish reason (object in v6, string in v5)
323
+ * Clean agy PTY output: strip ANSI escape sequences (CSI, OSC, charset
324
+ * selection), resolve carriage-return overwrites, trim trailing whitespace.
325
+ * Pure function so it can be unit-tested against captured raw output.
326
+ * @param {string} raw
327
+ * @returns {string}
220
328
  */
221
- function mapFinishReason(finishReason) {
222
- // AI SDK v6 returns an object with 'unified' property
223
- const reason =
224
- typeof finishReason === 'object' ? finishReason?.unified : finishReason;
225
-
226
- switch (reason) {
227
- case 'stop':
228
- return StopReasons.STOP;
229
- case 'length':
230
- case 'max-tokens':
231
- return StopReasons.LENGTH;
232
- case 'content-filter':
233
- return StopReasons.CONTENT_FILTER;
234
- case 'tool-calls':
235
- return StopReasons.TOOL_USE;
236
- case 'error':
237
- return StopReasons.ERROR;
238
- default:
239
- return StopReasons.OTHER;
329
+ export function cleanAgyOutput(raw) {
330
+ if (typeof raw !== 'string' || raw.length === 0) {
331
+ return '';
240
332
  }
333
+
334
+ let s = raw;
335
+
336
+ // Strip OSC sequences: ESC ] ... terminated by BEL (\x07) or ST (ESC \).
337
+ s = s.replace(/\x1b\][\s\S]*?(?:\x07|\x1b\\)/g, '');
338
+
339
+ // Strip CSI sequences: ESC [ parameter-bytes (0x30-0x3F) intermediate-bytes
340
+ // (0x20-0x2F) final-byte (0x40-0x7E). Full grammar so truecolor / less common
341
+ // sequences don't leak their tail as text.
342
+ s = s.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '');
343
+
344
+ // Strip charset selection (ESC ( X / ESC ) X) and other single-char escapes
345
+ // (ESC =, ESC >, and any remaining ESC + final byte).
346
+ s = s.replace(/\x1b[()][AB0-2]/g, '');
347
+ s = s.replace(/\x1b[=>]/g, '');
348
+ s = s.replace(/\x1b[@-Z\\-_]/g, '');
349
+
350
+ // Resolve carriage-return overwrites within each line: a lone \r (not part of
351
+ // a \r\n line break) means the cursor returned to column 0 and overwrote.
352
+ // Normalize CRLF first so we only handle bare CRs.
353
+ s = s.replace(/\r\n/g, '\n');
354
+ s = s
355
+ .split('\n')
356
+ .map((line) => {
357
+ if (!line.includes('\r')) return line;
358
+ // Last CR-delimited segment wins (spinner frames overwrite each other)
359
+ const segments = line.split('\r');
360
+ return segments[segments.length - 1];
361
+ })
362
+ .join('\n');
363
+
364
+ // Strip any remaining lone control chars (BEL, etc.) except tab and newline.
365
+ s = s.replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '');
366
+
367
+ // Trim trailing whitespace/newlines
368
+ return s.replace(/\s+$/, '');
241
369
  }
242
370
 
371
+ // ---------------------------------------------------------------------------
372
+ // Subprocess runner
373
+ // ---------------------------------------------------------------------------
374
+
243
375
  /**
244
- * Extract raw finish reason string for metadata
245
- * AI SDK v6 returns finishReason as { unified: 'stop', raw: 'STOP' }
246
- * @param {Object|string} finishReason - The finish reason
247
- * @returns {string} The raw finish reason string
376
+ * Lazily import @lydell/node-pty. Kept lazy so the module loads even when the
377
+ * native binary is unavailable (e.g. unit tests that mock the layer).
248
378
  */
249
- function getRawFinishReason(finishReason) {
250
- if (typeof finishReason === 'object') {
251
- return finishReason?.unified || finishReason?.raw || 'stop';
379
+ async function getPty() {
380
+ try {
381
+ const mod = await import('@lydell/node-pty');
382
+ return mod.default || mod;
383
+ } catch (error) {
384
+ throw new GeminiCliProviderError(
385
+ '@lydell/node-pty is not installed. Run: pnpm add @lydell/node-pty',
386
+ ErrorCodes.API_ERROR,
387
+ error,
388
+ );
252
389
  }
253
- return finishReason || 'stop';
254
390
  }
255
391
 
256
392
  /**
257
- * Extract usage tokens from AI SDK v6 hierarchical structure
258
- * AI SDK v6 usage: { inputTokens: { total: N }, outputTokens: { total: N } }
259
- * AI SDK v5 usage: { promptTokens: N, completionTokens: N, totalTokens: N }
260
- * @param {Object} usage - The usage object from AI SDK
261
- * @returns {Object} Normalized token counts
393
+ * Spawn agy under a PTY, deliver the prompt, collect output, resolve on exit.
394
+ *
395
+ * @param {object} params
396
+ * @param {string} params.prompt - Fully serialized prompt
397
+ * @param {string} params.model - Resolved agy --model value
398
+ * @param {number} params.timeoutMs - Print timeout in ms
399
+ * @param {AbortSignal} [params.signal]
400
+ * @param {object} [params.ptyLib] - Injected pty module (tests)
401
+ * @param {string} [params.agyPath] - Override binary path (tests)
402
+ * @returns {Promise<{output: string, exitCode: number}>}
262
403
  */
263
- function extractUsageTokens(usage) {
264
- if (!usage) {
265
- return { input: 0, output: 0, total: 0 };
404
+ export async function runAgy({
405
+ prompt,
406
+ model,
407
+ timeoutMs = DEFAULT_TIMEOUT_MS,
408
+ signal,
409
+ ptyLib,
410
+ agyPath,
411
+ }) {
412
+ const binary = agyPath || findAgyBinary();
413
+ if (!binary) {
414
+ throw new GeminiCliProviderError(
415
+ 'Antigravity CLI (agy) not found. Install it and run `agy` once to log in (https://antigravity.google)',
416
+ ErrorCodes.MISSING_API_KEY,
417
+ );
266
418
  }
267
419
 
268
- // AI SDK v6 hierarchical structure
269
- if (usage.inputTokens && typeof usage.inputTokens === 'object') {
270
- const input = usage.inputTokens.total || 0;
271
- const output = usage.outputTokens?.total || 0;
272
- return { input, output, total: input + output };
420
+ if (signal?.aborted) {
421
+ throw new GeminiCliProviderError('Request cancelled', 'CANCELLED');
273
422
  }
274
423
 
275
- // AI SDK flat structure (backwards compatibility)
276
- const input = usage.promptTokens || usage.inputTokens || 0;
277
- const output = usage.completionTokens || usage.outputTokens || 0;
278
- const total = usage.totalTokens || input + output;
279
- return { input, output, total };
280
- }
424
+ const pty = ptyLib || (await getPty());
281
425
 
282
- /**
283
- * Convert messages from Converse internal format to AI SDK ModelMessage format
284
- *
285
- * Converse format (used by other providers like Anthropic):
286
- * - Images: { type: 'image', source: { type: 'base64', media_type: '...', data: '...' } }
287
- *
288
- * AI SDK ModelMessage format (required by generateText/streamText):
289
- * - Images: { type: 'image', image: '...' } (base64 string, Buffer, or URL)
290
- * - Text: { type: 'text', text: '...' }
291
- *
292
- * Note: The AI SDK validates ModelMessage format before passing to providers.
293
- * We must use 'image' property (not 'data') for the AI SDK to accept the message.
294
- *
295
- * @param {Array} messages - Messages in Converse internal format
296
- * @returns {Array} Messages in AI SDK ModelMessage format
297
- */
298
- function convertToModelMessages(messages) {
299
- return messages.map((message) => {
300
- // If content is a string, no conversion needed
301
- if (typeof message.content === 'string') {
302
- return message;
426
+ // Per-call cwd under HOME (covered by agy workspace trust on first login).
427
+ // On POSIX, restrict the dir to the owner (0700) since it may hold prompt.md.
428
+ const runId = randomUUID();
429
+ let runDir = join(homedir(), '.converse', 'agy-runs', runId);
430
+ const mkOpts =
431
+ process.platform === 'win32'
432
+ ? { recursive: true }
433
+ : { recursive: true, mode: 0o700 };
434
+ try {
435
+ mkdirSync(runDir, mkOpts);
436
+ } catch (error) {
437
+ // Fall back to a per-call dir under tmpdir if HOME isn't writable. Still a
438
+ // unique dir (never a bare tmpdir) so concurrent calls don't collide and
439
+ // cleanup still applies.
440
+ debugError(
441
+ '[Gemini CLI] Failed to create run dir, falling back to tmp',
442
+ error,
443
+ );
444
+ runDir = join(tmpdir(), 'converse-agy-runs', runId);
445
+ try {
446
+ mkdirSync(runDir, mkOpts);
447
+ } catch (fallbackErr) {
448
+ throw new GeminiCliProviderError(
449
+ `Failed to create agy run directory: ${fallbackErr.message}`,
450
+ ErrorCodes.API_ERROR,
451
+ fallbackErr,
452
+ );
303
453
  }
454
+ }
455
+ // Decide prompt delivery: argv (fast) vs file (large-prompt bootstrap).
456
+ let promptArg;
457
+ if (prompt.length > ARGV_PROMPT_LIMIT) {
458
+ const promptFile = join(runDir, 'prompt.md');
459
+ // 0600 on POSIX — prompt may contain sensitive context.
460
+ const writeOpts =
461
+ process.platform === 'win32'
462
+ ? { encoding: 'utf8' }
463
+ : { encoding: 'utf8', mode: 0o600 };
464
+ writeFileSync(promptFile, prompt, writeOpts);
465
+ // Reference the absolute path to minimize the agent's file-search flailing.
466
+ promptArg = `Read the file located at ${promptFile} and respond to its contents directly. Do not summarize the file; answer it.`;
467
+ } else {
468
+ promptArg = prompt;
469
+ }
304
470
 
305
- // If content is an array, convert each part
306
- if (Array.isArray(message.content)) {
307
- const convertedContent = message.content.map((part) => {
308
- // Text parts are already in correct format
309
- if (part.type === 'text') {
310
- return part;
311
- }
471
+ const timeoutSeconds = Math.ceil(timeoutMs / 1000);
472
+ // --sandbox is intentionally omitted: it blocks the large-prompt file read in
473
+ // print mode (verified 2026-06-10 — agy times out unable to read prompt.md).
474
+ const args = [
475
+ '-p',
476
+ promptArg,
477
+ '--model',
478
+ model,
479
+ '--print-timeout',
480
+ `${timeoutSeconds}s`,
481
+ ];
482
+
483
+ return new Promise((resolve, reject) => {
484
+ let child;
485
+ let output = '';
486
+ let settled = false;
487
+ // Set when abort/timeout has requested termination. Once set, a subsequent
488
+ // onExit (which kill() may fire synchronously) must NOT resolve as a normal
489
+ // exit — the termination error wins.
490
+ let terminationError = null;
491
+ let hardTimer = null;
492
+ let postKillTimer = null;
493
+ let onDataSub = null;
494
+ let onExitSub = null;
495
+
496
+ const cleanup = () => {
497
+ if (hardTimer) {
498
+ clearTimeout(hardTimer);
499
+ hardTimer = null;
500
+ }
501
+ if (postKillTimer) {
502
+ clearTimeout(postKillTimer);
503
+ postKillTimer = null;
504
+ }
505
+ if (signal) {
506
+ signal.removeEventListener('abort', onAbort);
507
+ }
508
+ try {
509
+ onDataSub?.dispose?.();
510
+ } catch {
511
+ /* ignore */
512
+ }
513
+ try {
514
+ onExitSub?.dispose?.();
515
+ } catch {
516
+ /* ignore */
517
+ }
518
+ // Best-effort run-dir cleanup; never throw. On abort the killed process
519
+ // may still hold a handle on prompt.md (EBUSY), so retry once detached.
520
+ try {
521
+ rmSync(runDir, { recursive: true, force: true });
522
+ } catch (err) {
523
+ debugLog('[Gemini CLI] Run dir cleanup failed, retrying: %s', err?.message);
524
+ setTimeout(() => {
525
+ try {
526
+ rmSync(runDir, { recursive: true, force: true });
527
+ } catch (retryErr) {
528
+ debugLog(
529
+ '[Gemini CLI] Run dir cleanup retry failed: %s',
530
+ retryErr?.message,
531
+ );
532
+ }
533
+ }, 2000).unref?.();
534
+ }
535
+ };
312
536
 
313
- // Convert image from Converse format to AI SDK ModelMessage format
314
- if (part.type === 'image' && part.source) {
315
- return {
316
- type: 'image',
317
- image: part.source.data, // AI SDK expects 'image' property (not 'data')
318
- };
319
- }
537
+ const settleResolve = (value) => {
538
+ if (settled) return;
539
+ settled = true;
540
+ cleanup();
541
+ resolve(value);
542
+ };
320
543
 
321
- // If already in AI SDK v5 format, return as-is
322
- if (part.type === 'image' && part.image) {
323
- return part;
324
- }
544
+ const settleReject = (err) => {
545
+ if (settled) return;
546
+ settled = true;
547
+ cleanup();
548
+ reject(err);
549
+ };
325
550
 
326
- // Handle file parts (already in correct format)
327
- if (part.type === 'file' && part.data) {
328
- return part;
329
- }
551
+ // Terminate the child for a known reason (abort/timeout). Records the error
552
+ // first so a synchronous onExit from kill() rejects rather than resolves,
553
+ // then schedules a post-kill grace timer so we never hang if onExit never
554
+ // fires.
555
+ const terminate = (err) => {
556
+ if (settled) return;
557
+ terminationError = err;
558
+ try {
559
+ child?.kill();
560
+ } catch (killErr) {
561
+ debugLog('[Gemini CLI] pty.kill() failed: %s', killErr?.message);
562
+ }
563
+ if (!postKillTimer) {
564
+ postKillTimer = setTimeout(() => {
565
+ settleReject(terminationError);
566
+ }, POST_KILL_GRACE_MS);
567
+ }
568
+ };
569
+
570
+ function onAbort() {
571
+ terminate(new GeminiCliProviderError('Request cancelled', 'CANCELLED'));
572
+ }
330
573
 
331
- // Unknown part type, return as-is and let SDK handle it
332
- debugLog(`[Gemini CLI] Unknown content part type: ${part.type}`);
333
- return part;
574
+ try {
575
+ child = pty.spawn(binary, args, {
576
+ name: 'xterm-256color',
577
+ cols: PTY_COLS,
578
+ rows: 30,
579
+ cwd: runDir,
580
+ env: process.env,
334
581
  });
582
+ } catch (error) {
583
+ cleanup();
584
+ reject(
585
+ new GeminiCliProviderError(
586
+ `Failed to spawn agy: ${error.message}`,
587
+ ErrorCodes.API_ERROR,
588
+ error,
589
+ ),
590
+ );
591
+ return;
592
+ }
593
+
594
+ onDataSub = child.onData((data) => {
595
+ output += data;
596
+ });
597
+
598
+ onExitSub = child.onExit(({ exitCode }) => {
599
+ // If termination was requested, the abort/timeout error wins over a
600
+ // (possibly kill()-induced) exit.
601
+ if (terminationError) {
602
+ settleReject(terminationError);
603
+ } else {
604
+ settleResolve({ output, exitCode });
605
+ }
606
+ });
607
+
608
+ hardTimer = setTimeout(() => {
609
+ terminate(
610
+ new GeminiCliProviderError(
611
+ `Antigravity CLI (agy) timed out after ${timeoutMs}ms`,
612
+ ErrorCodes.TIMEOUT_ERROR,
613
+ ),
614
+ );
615
+ }, timeoutMs + HARD_KILL_GRACE_MS);
335
616
 
336
- return {
337
- ...message,
338
- content: convertedContent,
339
- };
617
+ if (signal) {
618
+ signal.addEventListener('abort', onAbort, { once: true });
619
+ // Guard the window between the early aborted-check and listener
620
+ // registration: if it aborted in between, the listener won't fire.
621
+ if (signal.aborted) {
622
+ onAbort();
623
+ }
340
624
  }
625
+ });
626
+ }
341
627
 
342
- // Unknown content type, return as-is
343
- return message;
628
+ /**
629
+ * Yield the passthrough event sequence for stream mode:
630
+ * start -> delta(fullText) -> usage(zeroed) -> end
631
+ */
632
+ async function* createStreamingGenerator(fullText, userFacingModel) {
633
+ yield {
634
+ type: 'start',
635
+ provider: 'gemini-cli',
636
+ model: userFacingModel,
637
+ };
638
+ yield {
639
+ type: 'delta',
640
+ data: { textDelta: fullText },
641
+ };
642
+ yield {
643
+ type: 'usage',
644
+ usage: {
645
+ input_tokens: 0,
646
+ output_tokens: 0,
647
+ total_tokens: 0,
648
+ cached_input_tokens: 0,
649
+ },
650
+ };
651
+ yield {
652
+ type: 'end',
653
+ stop_reason: 'stop',
654
+ finish_reason: 'stop',
655
+ };
656
+ }
657
+
658
+ /**
659
+ * Run agy and return the cleaned response text, mapping failure modes to
660
+ * provider errors.
661
+ */
662
+ async function executeAgy(messages, options) {
663
+ const { model = 'gemini', reasoning_effort, signal, timeout } = options;
664
+
665
+ const prompt = buildPrompt(messages);
666
+ const agyModel = resolveAgyModel(model, reasoning_effort);
667
+ const timeoutMs =
668
+ typeof timeout === 'number' && timeout > 0 ? timeout : DEFAULT_TIMEOUT_MS;
669
+
670
+ const { output, exitCode } = await runAgy({
671
+ prompt,
672
+ model: agyModel,
673
+ timeoutMs,
674
+ signal,
344
675
  });
676
+
677
+ const cleaned = cleanAgyOutput(output);
678
+
679
+ if (exitCode !== 0) {
680
+ const tail = cleaned.slice(-500);
681
+ throw new GeminiCliProviderError(
682
+ `Antigravity CLI (agy) exited with code ${exitCode}. ${tail ? `Output tail: ${tail}` : 'No output.'} If this persists, run \`agy\` interactively once to authenticate (Antigravity Google OAuth).`,
683
+ ErrorCodes.API_ERROR,
684
+ );
685
+ }
686
+
687
+ if (!cleaned) {
688
+ throw new GeminiCliProviderError(
689
+ 'Antigravity CLI (agy) returned empty output. This usually means the CLI is not authenticated — run `agy` interactively once to authenticate (Antigravity Google OAuth). (See upstream bug google-antigravity/antigravity-cli#76 for the non-TTY case.)',
690
+ ErrorCodes.API_ERROR,
691
+ );
692
+ }
693
+
694
+ return cleaned;
345
695
  }
346
696
 
347
697
  /**
348
- * Gemini CLI Provider Implementation
698
+ * Gemini CLI (Antigravity) Provider Implementation
349
699
  */
350
700
  export const geminiCliProvider = {
351
701
  /**
352
- * Invoke Gemini CLI with messages and options
702
+ * Invoke agy with messages and options.
353
703
  * @param {Array} messages - Message array (Converse format)
354
704
  * @param {Object} options - Invocation options
355
705
  * @returns {Promise<Object>|AsyncGenerator} Response or stream generator
356
706
  */
357
707
  async invoke(messages, options = {}) {
358
- const {
359
- model = 'gemini',
360
- config,
361
- stream = false,
362
- signal,
363
- reasoning_effort,
364
- temperature,
365
- use_websearch,
366
- } = options;
367
-
368
- // Validate configuration
369
- if (!config) {
370
- throw new GeminiCliProviderError(
371
- 'Configuration is required',
372
- ErrorCodes.MISSING_API_KEY,
373
- );
374
- }
708
+ const { model = 'gemini', stream = false, signal } = options;
375
709
 
376
- // Check OAuth credentials
377
- if (!hasOAuthCredentials()) {
378
- throw new GeminiCliProviderError(
379
- 'Gemini CLI authentication required. Run: gemini (interactive CLI) to authenticate',
380
- ErrorCodes.INVALID_API_KEY,
381
- );
710
+ if (signal?.aborted) {
711
+ throw new GeminiCliProviderError('Request cancelled', 'CANCELLED');
382
712
  }
383
713
 
384
- try {
385
- // Get model configuration to map user-facing name to SDK model name
386
- const modelConfig = this.getModelConfig(model);
387
- if (!modelConfig) {
388
- throw new GeminiCliProviderError(
389
- `Model ${model} not supported by Gemini CLI provider`,
390
- ErrorCodes.MODEL_NOT_FOUND,
391
- );
392
- }
393
-
394
- // Get the SDK model name (e.g., "gemini" -> "gemini-3-pro-preview")
395
- const sdkModelName = modelConfig.sdkModelName || model;
396
-
397
- // Get SDKs
398
- const createGeminiProvider = await getGeminiCliSDK();
399
- const { generateText } = await getAISDK();
400
-
401
- // Create provider instance with OAuth authentication
402
- const gemini = createGeminiProvider({
403
- authType: 'oauth-personal',
404
- });
405
-
406
- // Create model instance with SDK model name
407
- const modelInstance = gemini(sdkModelName);
408
-
409
- // Convert messages from Converse format to AI SDK ModelMessage format
410
- const convertedMessages = convertToModelMessages(messages);
411
-
412
- // Build AI SDK options
413
- const aiOptions = {
414
- messages: convertedMessages,
415
- };
416
-
417
- // Add optional parameters
418
- if (temperature !== undefined) {
419
- aiOptions.temperature = temperature;
420
- }
421
-
422
- // Note: reasoning_effort and use_websearch are not directly supported by AI SDK
423
- // These would need to be handled at the API level if the provider supports them
424
- if (reasoning_effort !== undefined) {
425
- debugLog(
426
- '[Gemini CLI] Parameter "reasoning_effort" not directly supported (ignored)',
427
- );
428
- }
429
- if (use_websearch) {
430
- debugLog(
431
- '[Gemini CLI] Parameter "use_websearch" not directly supported (ignored)',
432
- );
433
- }
434
-
435
- // Streaming mode
436
- if (stream) {
437
- return createStreamingGenerator(
438
- modelInstance,
439
- convertedMessages,
440
- aiOptions,
441
- signal,
442
- model, // Pass user-facing model name for metadata
443
- );
444
- }
445
-
446
- // Synchronous mode
447
- const startTime = Date.now();
448
-
449
- const result = await generateText({
450
- model: modelInstance,
451
- ...aiOptions,
452
- ...(signal && { abortSignal: signal }),
453
- });
454
-
455
- const responseTime = Date.now() - startTime;
456
-
457
- // Extract content from AI SDK v6 response format
458
- const content = result.content?.[0]?.text || result.text || '';
459
-
460
- // Extract usage tokens with AI SDK v6 compatibility
461
- const tokens = extractUsageTokens(result.usage);
462
-
463
- return {
464
- content,
465
- stop_reason: mapFinishReason(result.finishReason),
466
- rawResponse: result,
467
- metadata: {
468
- provider: 'gemini-cli',
469
- model,
470
- usage: result.usage
471
- ? {
472
- input_tokens: tokens.input,
473
- output_tokens: tokens.output,
474
- total_tokens: tokens.total,
475
- cached_input_tokens: 0,
476
- }
477
- : null,
478
- response_time_ms: responseTime,
479
- finish_reason: getRawFinishReason(result.finishReason),
480
- },
481
- };
482
- } catch (error) {
483
- debugError('[Gemini CLI] Execution error', error);
484
-
485
- // Map common errors to standard error codes
486
- if (
487
- error.message?.includes('authentication') ||
488
- error.message?.includes('oauth') ||
489
- error.message?.includes('credentials')
490
- ) {
491
- throw new GeminiCliProviderError(
492
- 'Gemini CLI authentication failed. Run: gemini (interactive CLI) to authenticate',
493
- ErrorCodes.INVALID_API_KEY,
494
- error,
495
- );
496
- }
497
-
498
- if (error.message?.includes('rate limit')) {
499
- throw new GeminiCliProviderError(
500
- 'Rate limit exceeded',
501
- ErrorCodes.RATE_LIMIT_EXCEEDED,
502
- error,
503
- );
504
- }
505
-
506
- if (error.message?.includes('timeout')) {
507
- throw new GeminiCliProviderError(
508
- 'Request timeout',
509
- ErrorCodes.TIMEOUT_ERROR,
510
- error,
511
- );
512
- }
513
-
514
- // Re-throw as Gemini CLI error
515
- throw new GeminiCliProviderError(
516
- error.message || 'Gemini CLI execution failed',
517
- ErrorCodes.API_ERROR,
518
- error,
519
- );
714
+ if (stream) {
715
+ // Run agy first (one-shot), then replay as a passthrough stream.
716
+ const fullText = await executeAgy(messages, options);
717
+ return createStreamingGenerator(fullText, model);
520
718
  }
719
+
720
+ const startTime = Date.now();
721
+ const content = await executeAgy(messages, options);
722
+ const responseTime = Date.now() - startTime;
723
+
724
+ return {
725
+ content,
726
+ stop_reason: StopReasons.STOP,
727
+ rawResponse: { content },
728
+ metadata: {
729
+ provider: 'gemini-cli',
730
+ model,
731
+ usage: null,
732
+ response_time_ms: responseTime,
733
+ finish_reason: 'stop',
734
+ },
735
+ };
521
736
  },
522
737
 
523
738
  /**
524
- * Validate Gemini CLI configuration
525
- * Gemini CLI uses OAuth authentication (no API keys needed)
739
+ * Validate configuration. agy uses OAuth (no env keys); always true.
740
+ * Availability is determined by isAvailable (binary presence).
526
741
  */
527
742
  validateConfig(_config) {
528
- // Check if OAuth credentials file exists
529
- return hasOAuthCredentials();
743
+ return true;
530
744
  },
531
745
 
532
746
  /**
533
- * Check if Gemini CLI provider is available
747
+ * Check if the provider is available (agy binary present).
534
748
  */
535
- isAvailable(config) {
536
- return this.validateConfig(config);
749
+ isAvailable(_config) {
750
+ return findAgyBinary() !== null;
537
751
  },
538
752
 
539
753
  /**
540
- * Get supported Gemini CLI models
754
+ * Get supported Gemini models.
541
755
  */
542
756
  getSupportedModels() {
543
757
  return SUPPORTED_MODELS;
544
758
  },
545
759
 
546
760
  /**
547
- * Get model configuration for specific model
761
+ * Get model configuration for a specific model (alias-aware, prefix-aware).
548
762
  */
549
763
  getModelConfig(modelName) {
550
- const modelNameLower = modelName.toLowerCase();
764
+ if (typeof modelName !== 'string') return null;
765
+
766
+ const name = modelName.toLowerCase().trim();
551
767
 
552
- // Check exact match
553
- if (SUPPORTED_MODELS[modelNameLower]) {
554
- return SUPPORTED_MODELS[modelNameLower];
768
+ // Full agy display-name passthrough → matching base config.
769
+ if (/gemini 3\.5 flash/i.test(modelName)) {
770
+ return SUPPORTED_MODELS['gemini:flash'];
771
+ }
772
+ if (/gemini 3\.1 pro/i.test(modelName)) {
773
+ return SUPPORTED_MODELS.gemini;
555
774
  }
556
775
 
557
- // Check aliases
558
- for (const [supportedModel, config] of Object.entries(SUPPORTED_MODELS)) {
559
- if (config.aliases) {
560
- for (const alias of config.aliases) {
561
- if (alias.toLowerCase() === modelNameLower) {
562
- return config;
563
- }
564
- }
776
+ if (name === 'pro') {
777
+ return SUPPORTED_MODELS['gemini:pro'];
778
+ }
779
+
780
+ // Exact key match (gemini, gemini:pro, gemini:flash)
781
+ if (SUPPORTED_MODELS[name]) {
782
+ return SUPPORTED_MODELS[name];
783
+ }
784
+
785
+ // Alias match
786
+ for (const config of Object.values(SUPPORTED_MODELS)) {
787
+ if (
788
+ config.aliases &&
789
+ config.aliases.some((alias) => alias.toLowerCase() === name)
790
+ ) {
791
+ return config;
565
792
  }
566
793
  }
567
794