claude-simple 0.2.8 → 0.2.10

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,10 +1,11 @@
1
1
  import { spawn } from 'child_process';
2
+ import fs from 'fs';
3
+ import path from 'path';
2
4
  import readline from 'readline';
3
5
  import crypto from 'crypto';
4
6
 
5
- // Grok's ACP transport is a long-lived JSON-RPC process. Keeping it alive is
6
- // important: it owns the real Grok session and lets the edge, rather than an
7
- // individual browser tab, serialize follow-up prompts.
7
+ // Persistent ACP (JSON-RPC over stdio). Advertise terminal+fs so Grok routes
8
+ // shell/file tools through client RPCs instead of its own broken terminal path.
8
9
  const clients = new Map();
9
10
  const queues = new Map();
10
11
  const queueAliases = new Map();
@@ -13,6 +14,147 @@ function profileKey(options) {
13
14
  return `${options.model || ''}\u001f${options.effort || ''}`;
14
15
  }
15
16
 
17
+ function envFromParams(envList) {
18
+ const env = { ...process.env };
19
+ if (!Array.isArray(envList)) return env;
20
+ for (const item of envList) {
21
+ if (!item || typeof item.name !== 'string') continue;
22
+ env[item.name] = item.value == null ? '' : String(item.value);
23
+ }
24
+ return env;
25
+ }
26
+
27
+ class TerminalHost {
28
+ constructor() {
29
+ this.terminals = new Map();
30
+ }
31
+
32
+ create(params = {}) {
33
+ const terminalId = `term_${crypto.randomUUID()}`;
34
+ const command = String(params.command || '');
35
+ const args = Array.isArray(params.args) ? params.args.map(String) : [];
36
+ const cwd = params.cwd || process.cwd();
37
+ const limit = Number.isFinite(params.outputByteLimit) && params.outputByteLimit > 0
38
+ ? params.outputByteLimit
39
+ : 1024 * 1024;
40
+ const env = envFromParams(params.env);
41
+
42
+ let child;
43
+ try {
44
+ child = spawn(command, args, {
45
+ cwd,
46
+ env,
47
+ stdio: ['ignore', 'pipe', 'pipe'],
48
+ shell: args.length === 0,
49
+ });
50
+ } catch (error) {
51
+ const err = new Error(error.message || 'Failed to start terminal');
52
+ err.code = -32000;
53
+ throw err;
54
+ }
55
+
56
+ const entry = {
57
+ id: terminalId,
58
+ child,
59
+ output: '',
60
+ truncated: false,
61
+ limit,
62
+ exitCode: null,
63
+ signal: null,
64
+ exited: false,
65
+ waiters: [],
66
+ };
67
+
68
+ const append = chunk => {
69
+ entry.output += chunk;
70
+ if (Buffer.byteLength(entry.output, 'utf8') > entry.limit) {
71
+ // Truncate from the start at a UTF-8-safe boundary.
72
+ let buf = Buffer.from(entry.output, 'utf8');
73
+ buf = buf.subarray(buf.length - entry.limit);
74
+ while (buf.length && (buf[0] & 0xc0) === 0x80) buf = buf.subarray(1);
75
+ entry.output = buf.toString('utf8');
76
+ entry.truncated = true;
77
+ }
78
+ };
79
+
80
+ child.stdout?.setEncoding('utf8');
81
+ child.stderr?.setEncoding('utf8');
82
+ child.stdout?.on('data', append);
83
+ child.stderr?.on('data', append);
84
+ child.on('error', err => {
85
+ append(`\n[terminal error] ${err.message}\n`);
86
+ if (!entry.exited) {
87
+ entry.exited = true;
88
+ entry.exitCode = 1;
89
+ entry.signal = null;
90
+ for (const resolve of entry.waiters) resolve({ exitCode: 1, signal: null });
91
+ entry.waiters = [];
92
+ }
93
+ });
94
+ child.on('close', (code, signal) => {
95
+ entry.exited = true;
96
+ entry.exitCode = code;
97
+ entry.signal = signal || null;
98
+ for (const resolve of entry.waiters) resolve({ exitCode: code, signal: signal || null });
99
+ entry.waiters = [];
100
+ });
101
+
102
+ this.terminals.set(terminalId, entry);
103
+ return { terminalId };
104
+ }
105
+
106
+ get(terminalId) {
107
+ const entry = this.terminals.get(terminalId);
108
+ if (!entry) {
109
+ const err = new Error(`Unknown terminal: ${terminalId}`);
110
+ err.code = -32000;
111
+ throw err;
112
+ }
113
+ return entry;
114
+ }
115
+
116
+ output(terminalId) {
117
+ const entry = this.get(terminalId);
118
+ const result = {
119
+ output: entry.output,
120
+ truncated: entry.truncated,
121
+ };
122
+ if (entry.exited) {
123
+ result.exitStatus = { exitCode: entry.exitCode, signal: entry.signal };
124
+ }
125
+ return result;
126
+ }
127
+
128
+ waitForExit(terminalId) {
129
+ const entry = this.get(terminalId);
130
+ if (entry.exited) return Promise.resolve({ exitCode: entry.exitCode, signal: entry.signal });
131
+ return new Promise(resolve => entry.waiters.push(resolve));
132
+ }
133
+
134
+ kill(terminalId) {
135
+ const entry = this.get(terminalId);
136
+ if (!entry.exited && entry.child?.pid) {
137
+ try { entry.child.kill('SIGTERM'); } catch { /* already gone */ }
138
+ }
139
+ return {};
140
+ }
141
+
142
+ release(terminalId) {
143
+ const entry = this.terminals.get(terminalId);
144
+ if (!entry) return {};
145
+ if (!entry.exited && entry.child?.pid) {
146
+ try { entry.child.kill('SIGTERM'); } catch { /* already gone */ }
147
+ setTimeout(() => {
148
+ if (!entry.exited && entry.child?.pid) {
149
+ try { entry.child.kill('SIGKILL'); } catch { /* already gone */ }
150
+ }
151
+ }, 800).unref?.();
152
+ }
153
+ this.terminals.delete(terminalId);
154
+ return {};
155
+ }
156
+ }
157
+
16
158
  class GrokACPClient {
17
159
  constructor({ cliPath, model, effort }) {
18
160
  const args = ['agent', '--always-approve'];
@@ -22,13 +164,17 @@ class GrokACPClient {
22
164
  this.child = spawn(cliPath || 'grok', args, { stdio: ['pipe', 'pipe', 'pipe'] });
23
165
  this.pending = new Map();
24
166
  this.listeners = new Map();
25
- this.nextID = 1;
167
+ // High ids avoid colliding with agent-originated request ids (often 0..N).
168
+ this.nextID = 100000;
169
+ this.terminals = new TerminalHost();
26
170
  this.ready = this.initialize();
27
171
  this.child.stderr.on('data', d => console.warn(`[grok acp] ${String(d).trim()}`));
28
172
  this.child.on('close', () => {
29
173
  for (const { reject } of this.pending.values()) reject(new Error('Grok ACP process stopped'));
30
174
  this.pending.clear();
31
- for (const fn of this.listeners.values()) fn({ type: 'error', message: 'Grok ACP process stopped', agent: 'grok' });
175
+ for (const fn of this.listeners.values()) {
176
+ fn({ type: 'error', message: 'Grok ACP process stopped', agent: 'grok' });
177
+ }
32
178
  this.listeners.clear();
33
179
  });
34
180
  const rl = readline.createInterface({ input: this.child.stdout, crlfDelay: Infinity });
@@ -38,19 +184,82 @@ class GrokACPClient {
38
184
  async initialize() {
39
185
  const init = await this.request('initialize', {
40
186
  protocolVersion: 1,
41
- clientCapabilities: { fs: { readTextFile: true, writeTextFile: true }, terminal: true },
42
- clientInfo: { name: 'claude-simple-ui', version: '0.2.7' },
187
+ clientCapabilities: {
188
+ fs: { readTextFile: true, writeTextFile: true },
189
+ terminal: true,
190
+ },
191
+ clientInfo: { name: 'claude-simple-ui', version: '0.2.9' },
43
192
  });
44
193
  const methods = new Set((init.authMethods || []).map(x => x?.id));
45
194
  const methodId = process.env.XAI_API_KEY && methods.has('xai.api_key')
46
195
  ? 'xai.api_key'
47
196
  : (methods.has('cached_token') ? 'cached_token' : null);
48
197
  if (methodId) await this.request('authenticate', { methodId, _meta: { headless: true } });
198
+ return init;
199
+ }
200
+
201
+ async handleClientRequest(msg) {
202
+ const method = msg.method;
203
+ const params = msg.params || {};
204
+ try {
205
+ let result = {};
206
+ if (method === 'fs/read_text_file') {
207
+ const filePath = String(params.path || '');
208
+ const content = await fs.promises.readFile(filePath, 'utf8');
209
+ const limit = Number.isFinite(params.limit) ? params.limit : null;
210
+ const line = Number.isFinite(params.line) ? params.line : null;
211
+ if (line != null || limit != null) {
212
+ const lines = content.split('\n');
213
+ const start = Math.max(0, (line || 1) - 1);
214
+ const end = limit != null ? start + limit : lines.length;
215
+ result = { content: lines.slice(start, end).join('\n') };
216
+ } else {
217
+ result = { content };
218
+ }
219
+ } else if (method === 'fs/write_text_file') {
220
+ const filePath = String(params.path || '');
221
+ await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
222
+ await fs.promises.writeFile(filePath, params.content ?? '', 'utf8');
223
+ result = {};
224
+ } else if (method === 'terminal/create') {
225
+ result = this.terminals.create(params);
226
+ } else if (method === 'terminal/output') {
227
+ result = this.terminals.output(params.terminalId);
228
+ } else if (method === 'terminal/wait_for_exit') {
229
+ result = await this.terminals.waitForExit(params.terminalId);
230
+ } else if (method === 'terminal/kill') {
231
+ result = this.terminals.kill(params.terminalId);
232
+ } else if (method === 'terminal/release') {
233
+ result = this.terminals.release(params.terminalId);
234
+ } else {
235
+ this.write({
236
+ jsonrpc: '2.0',
237
+ id: msg.id,
238
+ error: { code: -32601, message: `Unsupported client method: ${method}` },
239
+ });
240
+ return;
241
+ }
242
+ this.write({ jsonrpc: '2.0', id: msg.id, result });
243
+ } catch (error) {
244
+ this.write({
245
+ jsonrpc: '2.0',
246
+ id: msg.id,
247
+ error: { code: error.code || -32000, message: error.message || 'Client request failed' },
248
+ });
249
+ }
49
250
  }
50
251
 
51
252
  receive(line) {
52
253
  let msg;
53
254
  try { msg = JSON.parse(line); } catch { return; }
255
+
256
+ // Agent→client requests always carry a method. Check before pending so
257
+ // overlapping numeric ids cannot steal a client RPC as our response.
258
+ if (msg.id != null && msg.method) {
259
+ void this.handleClientRequest(msg);
260
+ return;
261
+ }
262
+
54
263
  if (msg.id != null && this.pending.has(msg.id)) {
55
264
  const pending = this.pending.get(msg.id);
56
265
  this.pending.delete(msg.id);
@@ -58,13 +267,7 @@ class GrokACPClient {
58
267
  else pending.resolve(msg.result || {});
59
268
  return;
60
269
  }
61
- // Grok's documented ACP client sample does not need client-side tool RPC.
62
- // Return a structured error for optional extension calls instead of leaving
63
- // an agent request hanging forever.
64
- if (msg.id != null && msg.method) {
65
- this.write({ jsonrpc: '2.0', id: msg.id, error: { code: -32601, message: `Unsupported client method: ${msg.method}` } });
66
- return;
67
- }
270
+
68
271
  if (msg.method === 'session/update') {
69
272
  const sid = msg.params?.sessionId;
70
273
  const fn = sid && this.listeners.get(sid);
@@ -109,37 +312,68 @@ class GrokACPClient {
109
312
  function clientFor(options, cliPath) {
110
313
  const key = profileKey(options);
111
314
  let client = clients.get(key);
112
- if (!client || client.child.exitCode != null) {
315
+ if (!client || client.child.exitCode != null || client.child.signalCode != null) {
113
316
  client = new GrokACPClient({ cliPath, model: options.model, effort: options.effort });
114
317
  clients.set(key, client);
115
318
  }
116
319
  return client;
117
320
  }
118
321
 
119
- function emitUpdate(update, send, sessionId, state) {
322
+ function emitUpdate(update, send, state) {
120
323
  const kind = update?.sessionUpdate;
121
324
  if (kind === 'agent_message_chunk' && update.content?.text) {
122
- if (!state.textOpen) { send({ type: 'assistant_stream_start', agent: 'grok', ts: Date.now() }); state.textOpen = true; }
325
+ if (state.thoughtOpen) {
326
+ send({ type: 'thought_stream_end', agent: 'grok' });
327
+ state.thoughtOpen = false;
328
+ }
329
+ if (!state.textOpen) {
330
+ send({ type: 'assistant_stream_start', agent: 'grok', ts: Date.now() });
331
+ state.textOpen = true;
332
+ }
123
333
  send({ type: 'assistant_delta', data: update.content.text, agent: 'grok' });
124
334
  } else if (kind === 'agent_thought_chunk' && update.content?.text) {
125
- if (!state.thoughtOpen) { send({ type: 'thought_stream_start', agent: 'grok' }); state.thoughtOpen = true; }
335
+ if (!state.thoughtOpen) {
336
+ send({ type: 'thought_stream_start', agent: 'grok' });
337
+ state.thoughtOpen = true;
338
+ }
126
339
  send({ type: 'thought_delta', data: update.content.text, agent: 'grok' });
127
340
  } else if (kind === 'tool_call' || kind === 'tool_call_update') {
128
- if (state.textOpen) { send({ type: 'assistant_stream_end', agent: 'grok' }); state.textOpen = false; }
129
- if (state.thoughtOpen) { send({ type: 'thought_stream_end', agent: 'grok' }); state.thoughtOpen = false; }
341
+ if (state.textOpen) {
342
+ send({ type: 'assistant_stream_end', agent: 'grok' });
343
+ state.textOpen = false;
344
+ }
345
+ if (state.thoughtOpen) {
346
+ send({ type: 'thought_stream_end', agent: 'grok' });
347
+ state.thoughtOpen = false;
348
+ }
130
349
  if (kind === 'tool_call') {
131
- send({ type: 'assistant', agent: 'grok', message: { content: [{
132
- type: 'tool_use', name: update.title || update.kind || 'tool', input: update.rawInput || update.input || {},
133
- }] } });
350
+ send({
351
+ type: 'assistant',
352
+ agent: 'grok',
353
+ message: {
354
+ content: [{
355
+ type: 'tool_use',
356
+ name: update.title || update.kind || 'tool',
357
+ input: update.rawInput || update.input || {},
358
+ }],
359
+ },
360
+ });
134
361
  }
135
362
  }
136
363
  }
137
364
 
138
365
  async function runOne(item, { cliPath, activeSessions, queueKey }) {
139
366
  const { command, options, send } = item;
140
- const client = clientFor(options, cliPath);
367
+ const effort = options.effort || 'low';
368
+ const client = clientFor({ ...options, effort }, cliPath);
141
369
  const localKey = options.sessionId || crypto.randomUUID();
142
- const entry = { kind: 'grok-acp', startTime: Date.now(), busy: true, key: localKey, sessionId: options.sessionId || null };
370
+ const entry = {
371
+ kind: 'grok-acp',
372
+ startTime: Date.now(),
373
+ busy: true,
374
+ key: localKey,
375
+ sessionId: options.sessionId || null,
376
+ };
143
377
  activeSessions.set(localKey, entry);
144
378
  const state = { textOpen: false, thoughtOpen: false };
145
379
  let sessionId = options.sessionId;
@@ -148,9 +382,14 @@ async function runOne(item, { cliPath, activeSessions, queueKey }) {
148
382
  queueAliases.set(sessionId, queueKey);
149
383
  entry.sessionId = sessionId;
150
384
  activeSessions.set(sessionId, entry);
151
- entry.abort = () => { client.write({ jsonrpc: '2.0', method: 'session/cancel', params: { sessionId } }); };
385
+ entry.abort = () => {
386
+ try {
387
+ client.write({ jsonrpc: '2.0', method: 'session/cancel', params: { sessionId } });
388
+ } catch { /* process may already be gone */ }
389
+ };
152
390
  if (!options.sessionId) send({ type: 'session-created', sessionId, agent: 'grok' });
153
- client.listeners.set(sessionId, update => emitUpdate(update, send, sessionId, state));
391
+ client.listeners.set(sessionId, update => emitUpdate(update, send, state));
392
+ send({ type: 'status', message: 'Grok ACP running…', agent: 'grok', sessionId });
154
393
  const result = await client.request('session/prompt', {
155
394
  sessionId,
156
395
  prompt: [{ type: 'text', text: command }],
@@ -158,10 +397,24 @@ async function runOne(item, { cliPath, activeSessions, queueKey }) {
158
397
  if (state.textOpen) send({ type: 'assistant_stream_end', agent: 'grok' });
159
398
  if (state.thoughtOpen) send({ type: 'thought_stream_end', agent: 'grok' });
160
399
  entry.busy = false;
161
- send({ type: 'result', is_error: result.stopReason === 'error', usage: result._meta?.usage || null, sessionId, agent: 'grok' });
162
- send({ type: 'complete', exitCode: result.stopReason === 'cancelled' ? 130 : 0, aborted: result.stopReason === 'cancelled', sessionId, agent: 'grok' });
400
+ send({
401
+ type: 'result',
402
+ is_error: result.stopReason === 'error',
403
+ usage: result._meta?.usage || null,
404
+ sessionId,
405
+ agent: 'grok',
406
+ });
407
+ send({
408
+ type: 'complete',
409
+ exitCode: result.stopReason === 'cancelled' ? 130 : 0,
410
+ aborted: result.stopReason === 'cancelled',
411
+ sessionId,
412
+ agent: 'grok',
413
+ });
163
414
  } catch (e) {
164
415
  entry.busy = false;
416
+ if (state.textOpen) send({ type: 'assistant_stream_end', agent: 'grok' });
417
+ if (state.thoughtOpen) send({ type: 'thought_stream_end', agent: 'grok' });
165
418
  send({ type: 'error', message: e.message, sessionId, agent: 'grok' });
166
419
  send({ type: 'complete', exitCode: 1, sessionId, agent: 'grok' });
167
420
  } finally {
@@ -171,19 +424,26 @@ async function runOne(item, { cliPath, activeSessions, queueKey }) {
171
424
  }
172
425
  }
173
426
 
174
- /** Queue all Grok follow-ups at the edge. The promise resolves after this item runs. */
427
+ /** Queue all Grok follow-ups at the edge. Promise resolves after this item runs. */
175
428
  export function runGrok(command, options, send, { cliPath, activeSessions }) {
176
- const key = (options.sessionId && queueAliases.get(options.sessionId)) || options.sessionId || `new:${options.cwd || ''}`;
429
+ const key = (options.sessionId && queueAliases.get(options.sessionId))
430
+ || options.sessionId
431
+ || `new:${options.cwd || ''}`;
177
432
  const queue = queues.get(key) || { items: [], running: false };
178
433
  queues.set(key, queue);
179
434
  return new Promise(resolve => {
180
435
  queue.items.push({ command, options, send, resolve });
181
436
  if (queue.running) {
182
- send({ type: 'status', message: `Grok follow-up queued (${queue.items.length - 1} ahead)`, sessionId: options.sessionId, agent: 'grok' });
437
+ send({
438
+ type: 'status',
439
+ message: `Grok follow-up queued (${queue.items.length - 1} ahead)`,
440
+ sessionId: options.sessionId,
441
+ agent: 'grok',
442
+ });
183
443
  return;
184
444
  }
185
445
  queue.running = true;
186
- const drain = async () => {
446
+ void (async () => {
187
447
  while (queue.items.length) {
188
448
  const item = queue.items.shift();
189
449
  await runOne(item, { cliPath, activeSessions, queueKey: key });
@@ -192,16 +452,19 @@ export function runGrok(command, options, send, { cliPath, activeSessions }) {
192
452
  queue.running = false;
193
453
  queues.delete(key);
194
454
  for (const [sid, alias] of queueAliases) if (alias === key) queueAliases.delete(sid);
195
- };
196
- void drain();
455
+ })();
197
456
  });
198
457
  }
199
458
 
200
459
  export async function interruptGrok(sessionId) {
201
460
  for (const client of clients.values()) {
202
461
  if (client.listeners.has(sessionId)) {
203
- client.write({ jsonrpc: '2.0', method: 'session/cancel', params: { sessionId } });
204
- return true;
462
+ try {
463
+ client.write({ jsonrpc: '2.0', method: 'session/cancel', params: { sessionId } });
464
+ return true;
465
+ } catch {
466
+ return false;
467
+ }
205
468
  }
206
469
  }
207
470
  return false;
@@ -38,7 +38,7 @@ export const AI_DEFAULTS = {
38
38
  auto_report_days: 1,
39
39
  };
40
40
 
41
- export const DEFAULT_SYSTEM = `你是 Claude Simple UI 的内置工作助手(Meta Agent)。你能访问本机上的 Claude / Codex / Grok 会话、项目笔记、Git 与文件,并生成「我做了啥」的工作报告。
41
+ export const DEFAULT_SYSTEM = `你是 Claude Simple UI 的内置工作助手(Meta Agent)。你能访问本机上的 Claude / Codex / Grok / Gemini 会话、项目笔记、Git 与文件,并生成「我做了啥」的工作报告。
42
42
 
43
43
  规则:
44
44
  1. 需要会话 id / 项目路径时,先 list_projects 或 search_activity / list_sessions,再深入 get_session_brief / get_session_summary。超长会话先读 get_session_brief;只有需要逐事件定位问题时才读 get_session_jsonl。
@@ -73,12 +73,12 @@ const TOOLS = [
73
73
  type: 'function',
74
74
  function: {
75
75
  name: 'list_sessions',
76
- description: '列出最近 agent 会话(Claude/Codex/Grok)元数据',
76
+ description: '列出最近 agent 会话(Claude/Codex/Grok/Gemini)元数据',
77
77
  parameters: {
78
78
  type: 'object',
79
79
  properties: {
80
80
  days: { type: 'integer', description: '只看最近 N 天,0=全部,默认 14' },
81
- agent: { type: 'string', enum: ['claude', 'codex', 'grok'], description: '可选过滤' },
81
+ agent: { type: 'string', enum: ['claude', 'codex', 'grok', 'gemini', 'cursor'], description: '可选过滤' },
82
82
  cwd: { type: 'string', description: '按工作目录过滤' },
83
83
  limit: { type: 'integer', description: '最多返回条数,默认 40' },
84
84
  },
@@ -153,13 +153,13 @@ const TOOLS = [
153
153
  type: 'function',
154
154
  function: {
155
155
  name: 'get_session_jsonl',
156
- description: '读取 Claude/Codex/Grok 指定会话的原始 JSONL 行,用于逐事件、工具调用或失败原因分析;内容可能敏感,只在当前回答中使用',
156
+ description: '读取 Claude/Codex/Grok/Gemini 指定会话的原始 JSONL 行,用于逐事件、工具调用或失败原因分析;内容可能敏感,只在当前回答中使用',
157
157
  parameters: {
158
158
  type: 'object',
159
159
  required: ['sessionId'],
160
160
  properties: {
161
161
  sessionId: { type: 'string' },
162
- agent: { type: 'string', enum: ['claude', 'codex', 'grok'], description: '建议明确指定来源 agent' },
162
+ agent: { type: 'string', enum: ['claude', 'codex', 'grok', 'gemini', 'cursor'], description: '建议明确指定来源 agent' },
163
163
  offset: { type: 'integer', description: '从第几行开始,默认 0' },
164
164
  maxLines: { type: 'integer', description: '每次最多 2000 行,默认 500' },
165
165
  },
@@ -500,6 +500,8 @@ export function createMetaAgent(deps) {
500
500
  claudeConfigDirs,
501
501
  codexHome,
502
502
  grokHome,
503
+ geminiHome,
504
+ cursorHome,
503
505
  } = deps;
504
506
 
505
507
  const configFile = path.join(rootDir, '.ai_config.json');
@@ -1508,6 +1510,8 @@ export function createMetaAgent(deps) {
1508
1510
  claudeConfigDirs: claudeConfigDirs(),
1509
1511
  codexHome: codexHome(),
1510
1512
  grokHome: grokHome(),
1513
+ geminiHome: geminiHome(),
1514
+ cursorHome: cursorHome(),
1511
1515
  };
1512
1516
  }
1513
1517
 
@@ -5,6 +5,7 @@
5
5
  import { promises as fs } from 'fs';
6
6
  import path from 'path';
7
7
  import os from 'os';
8
+ import { execFile } from 'child_process';
8
9
 
9
10
  /** Static fallbacks when local cache/config is missing. */
10
11
  export const FALLBACK_MODELS = {
@@ -37,6 +38,31 @@ export const FALLBACK_MODELS = {
37
38
  { value: 'grok-3-mini', label: 'grok-3-mini' },
38
39
  ],
39
40
  },
41
+ gemini: {
42
+ model: 'gemini-3.7-flash-high',
43
+ models: [
44
+ { value: 'gemini-3.7-flash-high', label: 'Gemini 3.7 Flash (High)' },
45
+ { value: 'gemini-3.7-flash-medium', label: 'Gemini 3.7 Flash (Medium)' },
46
+ { value: 'gemini-3.7-flash-low', label: 'Gemini 3.7 Flash (Low)' },
47
+ { value: 'gemini-3.6-flash-high', label: 'Gemini 3.6 Flash (High)' },
48
+ { value: 'gemini-3.6-flash-medium', label: 'Gemini 3.6 Flash (Medium)' },
49
+ { value: 'gemini-3.6-flash-low', label: 'Gemini 3.6 Flash (Low)' },
50
+ { value: 'gemini-3.5-flash-high', label: 'Gemini 3.5 Flash (High)' },
51
+ { value: 'gemini-3.5-flash-medium', label: 'Gemini 3.5 Flash (Medium)' },
52
+ { value: 'gemini-3.5-flash-low', label: 'Gemini 3.5 Flash (Low)' },
53
+ { value: 'gemini-3.1-pro-high', label: 'Gemini 3.1 Pro (High)' },
54
+ { value: 'gemini-3.1-pro-low', label: 'Gemini 3.1 Pro (Low)' },
55
+ ],
56
+ },
57
+ cursor: {
58
+ model: 'auto',
59
+ models: [
60
+ { value: 'auto', label: 'Auto' },
61
+ { value: 'gpt-5.3-codex', label: 'GPT-5.3 Codex' },
62
+ { value: 'composer-2.5', label: 'Composer 2.5' },
63
+ { value: 'claude-sonnet-5-thinking-high', label: 'Claude Sonnet 5' },
64
+ ],
65
+ },
40
66
  };
41
67
 
42
68
  const DEFAULT_EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'];
@@ -304,7 +330,8 @@ async function discoverGrok(grokHome) {
304
330
 
305
331
  const active = models.find(m => m.value === defaultModel);
306
332
  const efforts = active?.efforts || DEFAULT_EFFORTS;
307
- const effortDefault = active?.defaultEffort || tomlSectionString(cfgText, 'models', 'default_reasoning_effort') || null;
333
+ // This UI defaults Grok reasoning effort to low.
334
+ const effortDefault = 'low';
308
335
 
309
336
  return {
310
337
  model: defaultModel,
@@ -317,11 +344,89 @@ async function discoverGrok(grokHome) {
317
344
  };
318
345
  }
319
346
 
347
+ let geminiModelsCache = null;
348
+ let geminiModelsCachedAt = 0;
349
+
350
+ async function listAgyModels(cliPath) {
351
+ if (geminiModelsCache && Date.now() - geminiModelsCachedAt < 60_000) return geminiModelsCache;
352
+ // agy renders its model picker through a TTY. `script` supplies a tiny
353
+ // pseudo-terminal while keeping this request non-interactive.
354
+ const command = cliPath || 'agy';
355
+ const scriptArgs = process.platform === 'darwin'
356
+ ? ['-q', '/dev/null', command, 'models']
357
+ : ['-q', '-c', `${command} models`, '/dev/null'];
358
+ const output = await new Promise(resolve => {
359
+ execFile('script', scriptArgs, { timeout: 20_000, maxBuffer: 512 * 1024 }, (error, stdout) => {
360
+ resolve(error ? '' : String(stdout || ''));
361
+ });
362
+ });
363
+ const models = output.split(/\r?\n/)
364
+ .map(line => line.replace(/\x1b\[[0-?]*[ -\/]*[@-~]/g, '').trim())
365
+ .filter(line => /^gemini-/i.test(line))
366
+ .map(line => {
367
+ const [value, ...label] = line.split(/\t+/);
368
+ return { value: value.trim(), label: label.join(' ').trim() || shortLabel(value.trim()) };
369
+ });
370
+ geminiModelsCache = uniqModels(models);
371
+ geminiModelsCachedAt = Date.now();
372
+ return geminiModelsCache;
373
+ }
374
+
375
+ async function discoverGemini(geminiHome, cliPath) {
376
+ const home = geminiHome || path.join(os.homedir(), '.gemini');
377
+ const settings = await readJson(path.join(home, 'antigravity-cli', 'settings.json'))
378
+ || await readJson(path.join(home, 'settings.json'));
379
+ let models = await listAgyModels(cliPath);
380
+ let source = models.length ? 'agy' : 'fallback';
381
+ if (!models.length) models = FALLBACK_MODELS.gemini.models.map(m => ({ ...m }));
382
+ const configured = typeof settings?.model === 'string' ? settings.model.trim() : null;
383
+ const selected = configured && models.find(m => m.value === configured || m.label === configured);
384
+ const defaultModel = selected?.value || models[0]?.value || FALLBACK_MODELS.gemini.model;
385
+ if (configured) source += '+settings';
386
+ return {
387
+ model: defaultModel,
388
+ models: uniqModels(ensureDefaultInList(models, defaultModel)),
389
+ efforts: ['low', 'medium', 'high'],
390
+ effortDefault: defaultModel.match(/-(low|medium|high)$/i)?.[1] || null,
391
+ source,
392
+ };
393
+ }
394
+
395
+ let cursorModelsCache = null;
396
+ let cursorModelsCachedAt = 0;
397
+
398
+ async function listCursorModels(cliPath) {
399
+ if (cursorModelsCache && Date.now() - cursorModelsCachedAt < 60_000) return cursorModelsCache;
400
+ const output = await new Promise(resolve => {
401
+ execFile(cliPath || 'cursor-agent', ['--list-models'], { timeout: 20_000, maxBuffer: 1024 * 1024 }, (error, stdout) => {
402
+ resolve(error ? '' : String(stdout || ''));
403
+ });
404
+ });
405
+ const models = output.split(/\r?\n/)
406
+ .map(line => line.trim())
407
+ .map(line => {
408
+ const m = /^([^\s]+)\s+-\s+(.+)$/.exec(line);
409
+ return m ? { value: m[1], label: m[2] } : null;
410
+ })
411
+ .filter(Boolean);
412
+ cursorModelsCache = uniqModels(models);
413
+ cursorModelsCachedAt = Date.now();
414
+ return cursorModelsCache;
415
+ }
416
+
417
+ async function discoverCursor(cursorHome, cliPath) {
418
+ let models = await listCursorModels(cliPath);
419
+ const source = models.length ? 'cursor-agent' : 'fallback';
420
+ if (!models.length) models = FALLBACK_MODELS.cursor.models.map(m => ({ ...m }));
421
+ const defaultModel = models.find(m => m.value === 'auto')?.value || models[0]?.value || FALLBACK_MODELS.cursor.model;
422
+ return { model: defaultModel, models: uniqModels(ensureDefaultInList(models, defaultModel)), efforts: [], source };
423
+ }
424
+
320
425
  /**
321
- * @param {{ claudeConfigDirs?: string[], codexHome?: string, grokHome?: string, agents?: string[] }} opts
426
+ * @param {{ claudeConfigDirs?: string[], codexHome?: string, grokHome?: string, geminiHome?: string, geminiCliPath?: string, cursorHome?: string, cursorAgentPath?: string, agents?: string[] }} opts
322
427
  */
323
428
  export async function discoverAllAgentModels(opts = {}) {
324
- const agents = opts.agents?.length ? opts.agents : ['claude', 'codex', 'grok'];
429
+ const agents = opts.agents?.length ? opts.agents : ['claude', 'codex', 'grok', 'gemini', 'cursor'];
325
430
  const want = new Set(agents);
326
431
  const defaults = {};
327
432
 
@@ -334,6 +439,12 @@ export async function discoverAllAgentModels(opts = {}) {
334
439
  if (want.has('grok')) {
335
440
  defaults.grok = await discoverGrok(opts.grokHome);
336
441
  }
442
+ if (want.has('gemini')) {
443
+ defaults.gemini = await discoverGemini(opts.geminiHome, opts.geminiCliPath);
444
+ }
445
+ if (want.has('cursor')) {
446
+ defaults.cursor = await discoverCursor(opts.cursorHome, opts.cursorAgentPath);
447
+ }
337
448
 
338
449
  return defaults;
339
450
  }