circuschief 1.0.0 → 1.1.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.
Files changed (89) hide show
  1. package/package.json +1 -1
  2. package/packages/server/src/agents/AgentGateway.js +2 -0
  3. package/packages/server/src/agents/adapters/GeminiAdapter.js +105 -0
  4. package/packages/server/src/agents/adapters/cliUtils.js +15 -0
  5. package/packages/server/src/agents/adapters/codexCliRunner.js +1 -8
  6. package/packages/server/src/agents/adapters/geminiCliRunner.js +183 -0
  7. package/packages/server/src/agents/adapters/geminiEventMapper.js +195 -0
  8. package/packages/server/src/db/ProviderRepository.js +4 -2
  9. package/packages/server/src/db/migrations/index.js +9 -0
  10. package/packages/server/src/db/migrations/providerMigrations.js +88 -3
  11. package/packages/server/src/db/seedBaselineData.js +23 -1
  12. package/packages/server/src/schema.sql +1 -1
  13. package/packages/server/src/services/e2eSpawnCapture.js +47 -6
  14. package/packages/server/src/services/geminiSpawnHelper.js +47 -0
  15. package/packages/server/src/services/gitDiff.js +22 -47
  16. package/packages/server/src/services/gitService.js +6 -2
  17. package/packages/server/src/services/providerTestService.js +59 -1
  18. package/packages/server/src/services/queryParamBuilder.js +33 -1
  19. package/packages/server/src/services/sessionExecution.js +4 -0
  20. package/packages/server/src/services/sessionPrompts.js +22 -0
  21. package/packages/server/src/services/sessionProvider.js +41 -1
  22. package/packages/shared/src/constants.js +1 -1
  23. package/packages/shared/src/contracts/providers.js +1 -1
  24. package/packages/shared/src/types.js +7 -0
  25. package/packages/web/dist/assets/{ActiveSessionsView-Cxh8mHmB.js → ActiveSessionsView-EdNxmPmZ.js} +1 -1
  26. package/packages/web/dist/assets/{AgentLogsView-xdfI2bR6.js → AgentLogsView-C2wX0JPP.js} +1 -1
  27. package/packages/web/dist/assets/{ArchiveConfirmModal-DXZYdzHR.js → ArchiveConfirmModal-DJERn5XO.js} +1 -1
  28. package/packages/web/dist/assets/{CommandButtonDetailView-D8xfqLAp.js → CommandButtonDetailView-CBPI8-US.js} +1 -1
  29. package/packages/web/dist/assets/EffortLevelSelector-PaBpUveC.js +1 -0
  30. package/packages/web/dist/assets/{GeneralSettingsView-sPXkLlLy.js → GeneralSettingsView-Dw-x83R0.js} +1 -1
  31. package/packages/web/dist/assets/{InputWithButton-B-o0DgMH.js → InputWithButton-CHHcpF4I.js} +1 -1
  32. package/packages/web/dist/assets/{InterpolationHelp-Dxn1li4l.js → InterpolationHelp-CLNPz8s8.js} +1 -1
  33. package/packages/web/dist/assets/MarkdownEditor-DYi1igfT.js +2 -0
  34. package/packages/web/dist/assets/ModelSelector-Cko_yTO5.js +1 -0
  35. package/packages/web/dist/assets/{ModelSelector-BNYKujL-.css → ModelSelector-Dtwe5xLH.css} +1 -1
  36. package/packages/web/dist/assets/{NewSessionView-BR_COfgW.js → NewSessionView-DwUfBg70.js} +1 -1
  37. package/packages/web/dist/assets/{ProjectEditView-WImU7sNd.js → ProjectEditView-CSbsea3U.js} +1 -1
  38. package/packages/web/dist/assets/{ProjectListView-CYmmAcBD.js → ProjectListView-CEc_LWZL.js} +1 -1
  39. package/packages/web/dist/assets/{ProjectNewView-DEhqw3Jv.js → ProjectNewView-D4U0uRlp.js} +1 -1
  40. package/packages/web/dist/assets/ProvidersView-2KCOiY6Q.css +1 -0
  41. package/packages/web/dist/assets/ProvidersView-CD1j8BOv.js +1 -0
  42. package/packages/web/dist/assets/{QuickResponsesPanel-BqmnTd-D.js → QuickResponsesPanel-Dp39f12o.js} +1 -1
  43. package/packages/web/dist/assets/ResizableTextarea-BWywIqOv.js +1 -0
  44. package/packages/web/dist/assets/ResizableTextarea-DERSH3Wz.css +1 -0
  45. package/packages/web/dist/assets/{SessionCard-Bw77-KwD.js → SessionCard-B6d5ijDW.js} +1 -1
  46. package/packages/web/dist/assets/{SessionDetailView-B59TEkr-.js → SessionDetailView-DWbXdx7A.js} +19 -19
  47. package/packages/web/dist/assets/{SessionDetailView-CKVBnR4T.css → SessionDetailView-ULeIkWS0.css} +1 -1
  48. package/packages/web/dist/assets/{SessionFormOptions-hqijxc0S.js → SessionFormOptions-Dz9ik4Fo.js} +1 -1
  49. package/packages/web/dist/assets/{SessionListView-DYXHM9I-.js → SessionListView-C129buBe.js} +1 -1
  50. package/packages/web/dist/assets/{SessionLogStream-5NfVr9pF.js → SessionLogStream-BvXUNNBZ.js} +1 -1
  51. package/packages/web/dist/assets/{SettingsView-DI8ncOAV.js → SettingsView-DW1NvpX_.js} +1 -1
  52. package/packages/web/dist/assets/SlashCommandWizard-DleYBxrE.js +1 -0
  53. package/packages/web/dist/assets/{SummarySettingsView-C2Qs35mm.js → SummarySettingsView-CLUfcWvf.js} +1 -1
  54. package/packages/web/dist/assets/{TemplateDetailView-zVkIvgtu.js → TemplateDetailView-Cukb205e.js} +1 -1
  55. package/packages/web/dist/assets/{commandButtons-CoU3G4zK.js → commandButtons-DejH0rVN.js} +1 -1
  56. package/packages/web/dist/assets/{index-CLRsVASf.js → index-BD7Y3rBE.js} +1 -1
  57. package/packages/web/dist/assets/{index-uySCcnA_.css → index-Bd20AzX1.css} +1 -1
  58. package/packages/web/dist/assets/{index-CslU0psO.js → index-BgJiarKe.js} +1 -1
  59. package/packages/web/dist/assets/{index-9yF1uCCA.js → index-Bk32fSSG.js} +1 -1
  60. package/packages/web/dist/assets/{index-CAGdsDh7.js → index-BkA6pF2Z.js} +1 -1
  61. package/packages/web/dist/assets/{index-DsjWqc6R.js → index-Cltr-Ldt.js} +1 -1
  62. package/packages/web/dist/assets/{index-DI4NxaWD.js → index-Co-46Tp3.js} +1 -1
  63. package/packages/web/dist/assets/{index-DUa7adFh.js → index-Cpykk857.js} +1 -1
  64. package/packages/web/dist/assets/{index-C7Ww2auW.js → index-CtABl0D1.js} +1 -1
  65. package/packages/web/dist/assets/{index-BKstCaYU.js → index-Cuqk5m9S.js} +1 -1
  66. package/packages/web/dist/assets/{index-C2QFVD7d.js → index-CvXApbVC.js} +15 -15
  67. package/packages/web/dist/assets/{index-BhbH7eOk.js → index-D2gN-xEH.js} +1 -1
  68. package/packages/web/dist/assets/{index-Bo7PdwM5.js → index-Dd3WpmyQ.js} +1 -1
  69. package/packages/web/dist/assets/{index-c99Bo3JV.js → index-Dk6--9rj.js} +1 -1
  70. package/packages/web/dist/assets/{index-BjuRttEY.js → index-MZf7MlPX.js} +3 -3
  71. package/packages/web/dist/assets/{index-CP-SxOlV.js → index-NShCcwfj.js} +1 -1
  72. package/packages/web/dist/assets/{index-rkQx2tso.js → index-hA3VEuSq.js} +1 -1
  73. package/packages/web/dist/assets/{index-DOzONENy.js → index-p0mp3nca.js} +1 -1
  74. package/packages/web/dist/assets/{index-mT1JpxDc.js → index-qntNa5r_.js} +1 -1
  75. package/packages/web/dist/assets/{index-DZBpETI5.js → index-qq9ceNSK.js} +1 -1
  76. package/packages/web/dist/assets/{projectDefaults-B8esIcYq.js → projectDefaults-D9xkp2XR.js} +1 -1
  77. package/packages/web/dist/assets/{projects-C-8PSxKi.js → projects-BvLADGKx.js} +1 -1
  78. package/packages/web/dist/assets/{providers-oXifvvqN.js → providers-DZ-fOa4G.js} +1 -1
  79. package/packages/web/dist/assets/{sessions-Nq5VafSf.js → sessions-DETEyjPI.js} +1 -1
  80. package/packages/web/dist/assets/{settings-DtpuiyT6.js → settings-TWfbahn5.js} +1 -1
  81. package/packages/web/dist/index.html +2 -2
  82. package/packages/web/dist/assets/EffortLevelSelector-D2Hdzc_8.js +0 -1
  83. package/packages/web/dist/assets/MarkdownEditor-D4Kbb-9l.js +0 -2
  84. package/packages/web/dist/assets/ModelSelector-72C7MUH4.js +0 -1
  85. package/packages/web/dist/assets/ProvidersView-XZh3jkmH.js +0 -1
  86. package/packages/web/dist/assets/ProvidersView-bZemq_Rv.css +0 -1
  87. package/packages/web/dist/assets/ResizableTextarea-BQNw5e0C.css +0 -1
  88. package/packages/web/dist/assets/ResizableTextarea-DpWdIAP6.js +0 -1
  89. package/packages/web/dist/assets/SlashCommandWizard-BQ_rMzn-.js +0 -1
@@ -1,4 +1,4 @@
1
- import { OPENAI_MODELS } from '../../../../shared/src/index.js';
1
+ import { OPENAI_MODELS, GEMINI_MODELS } from '../../../../shared/src/index.js';
2
2
  import { addColumnIfMissing, tableExists } from './migrationUtils.js';
3
3
 
4
4
  function seedBuiltInAnthropicProvider(db) {
@@ -54,6 +54,27 @@ function seedBuiltInOpenAIProvider(db) {
54
54
  }
55
55
  }
56
56
 
57
+ function seedBuiltInGoogleProvider(db) {
58
+ const providerId = 'google-default';
59
+ const now = Date.now();
60
+
61
+ db.prepare(
62
+ `INSERT OR IGNORE INTO providers (
63
+ id, name, base_url, auth_token, kind, is_built_in, created_at, updated_at
64
+ )
65
+ VALUES (?, ?, NULL, NULL, 'google', 1, ?, ?)`
66
+ ).run(providerId, 'Google (Official)', now, now);
67
+
68
+ const insertModel = db.prepare(
69
+ `INSERT OR IGNORE INTO provider_models (id, provider_id, model_id, display_name, description, tier, created_at)
70
+ VALUES (?, ?, ?, ?, ?, 'custom', ?)`
71
+ );
72
+
73
+ for (const model of GEMINI_MODELS) {
74
+ insertModel.run(model.seedId, providerId, model.id, model.name, model.description, now);
75
+ }
76
+ }
77
+
57
78
  function seedBuiltInProviders(db) {
58
79
  seedBuiltInAnthropicProvider(db);
59
80
  seedBuiltInOpenAIProvider(db);
@@ -98,7 +119,7 @@ export const providerMigrations = [
98
119
  additional_env_vars TEXT,
99
120
  commit_attribution_override TEXT,
100
121
  is_built_in INTEGER NOT NULL DEFAULT 0,
101
- kind TEXT NOT NULL DEFAULT 'anthropic' CHECK(kind IN ('anthropic','openai')),
122
+ kind TEXT NOT NULL DEFAULT 'anthropic' CHECK(kind IN ('anthropic','openai','google')),
102
123
  created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
103
124
  updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
104
125
  );
@@ -123,7 +144,7 @@ export const providerMigrations = [
123
144
  db,
124
145
  'providers',
125
146
  'kind',
126
- "TEXT NOT NULL DEFAULT 'anthropic' CHECK(kind IN ('anthropic','openai'))"
147
+ "TEXT NOT NULL DEFAULT 'anthropic' CHECK(kind IN ('anthropic','openai','google'))"
127
148
  );
128
149
  },
129
150
  },
@@ -162,4 +183,68 @@ export const providerMigrations = [
162
183
  ).run('anthropic-opus-4-7', providerId, 'claude-opus-4-7', 'Opus 4.7', 'Most capable (default)', 'opus', Date.now());
163
184
  },
164
185
  },
186
+ {
187
+ name: 'providers-widen-kind-check-google',
188
+ up(db) {
189
+ // SQLite CHECK constraints are baked into the table definition and can't
190
+ // be altered in-place. Recreate the table with the wider CHECK.
191
+ //
192
+ // IMPORTANT: Disable foreign key enforcement during the table swap.
193
+ // provider_models has ON DELETE CASCADE referencing providers. SQLite
194
+ // fires that cascade when DROP TABLE deletes parent rows, which would
195
+ // wipe all provider_models data. Disabling FK enforcement prevents the
196
+ // cascade. We re-enable it immediately after the rename.
197
+ db.pragma('foreign_keys = OFF');
198
+ try {
199
+ db.exec(`
200
+ CREATE TABLE IF NOT EXISTS providers_new (
201
+ id TEXT PRIMARY KEY,
202
+ name TEXT NOT NULL,
203
+ base_url TEXT,
204
+ auth_token TEXT,
205
+ api_timeout_ms INTEGER,
206
+ additional_env_vars TEXT,
207
+ commit_attribution_override TEXT,
208
+ is_built_in INTEGER NOT NULL DEFAULT 0,
209
+ kind TEXT NOT NULL DEFAULT 'anthropic' CHECK(kind IN ('anthropic','openai','google')),
210
+ created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
211
+ updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
212
+ );
213
+
214
+ INSERT OR IGNORE INTO providers_new SELECT * FROM providers;
215
+
216
+ DROP TABLE providers;
217
+
218
+ ALTER TABLE providers_new RENAME TO providers;
219
+
220
+ CREATE INDEX IF NOT EXISTS idx_provider_models_provider ON provider_models(provider_id);
221
+ `);
222
+ } finally {
223
+ db.pragma('foreign_keys = ON');
224
+ }
225
+ },
226
+ },
227
+ {
228
+ name: 'providers-seed-built-in-google',
229
+ up(db) { seedBuiltInGoogleProvider(db); },
230
+ },
231
+ {
232
+ name: 'providers-update-gemini-flash-lite-model',
233
+ up(db) {
234
+ // The preview model 'gemini-2.5-flash-lite-preview-06-17' has been
235
+ // removed by Google. Update to the stable GA model ID.
236
+ db.prepare(
237
+ `UPDATE provider_models
238
+ SET model_id = ?, description = ?
239
+ WHERE id = ? AND provider_id = ?`
240
+ ).run('gemini-2.5-flash-lite', 'Lightweight & cost-efficient', 'google-gemini-2-5-flash-lite', 'google-default');
241
+
242
+ // Also update any sessions that were using the old model ID
243
+ db.prepare(
244
+ `UPDATE sessions
245
+ SET model = ?
246
+ WHERE model = ?`
247
+ ).run('gemini-2.5-flash-lite', 'gemini-2.5-flash-lite-preview-06-17');
248
+ },
249
+ },
165
250
  ];
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { OPENAI_MODELS } from '../../../shared/src/index.js';
2
+ import { OPENAI_MODELS, GEMINI_MODELS } from '../../../shared/src/index.js';
3
3
 
4
4
  export const BUILT_IN_ANTHROPIC_PROVIDER = {
5
5
  id: 'anthropic-default',
@@ -13,6 +13,12 @@ export const BUILT_IN_OPENAI_PROVIDER = {
13
13
  kind: 'openai',
14
14
  };
15
15
 
16
+ export const BUILT_IN_GOOGLE_PROVIDER = {
17
+ id: 'google-default',
18
+ name: 'Google (Official)',
19
+ kind: 'google',
20
+ };
21
+
16
22
  export const BUILT_IN_ANTHROPIC_MODELS = [
17
23
  { id: 'anthropic-haiku', providerId: BUILT_IN_ANTHROPIC_PROVIDER.id, modelId: 'claude-haiku-4-5-20251001', displayName: 'Haiku 4.5', description: 'Fast & lightweight', tier: 'haiku' },
18
24
  { id: 'anthropic-sonnet', providerId: BUILT_IN_ANTHROPIC_PROVIDER.id, modelId: 'claude-sonnet-4-6', displayName: 'Sonnet 4.6', description: 'Balanced', tier: 'sonnet' },
@@ -29,6 +35,15 @@ export const BUILT_IN_OPENAI_MODELS = OPENAI_MODELS.map((model) => ({
29
35
  tier: 'custom',
30
36
  }));
31
37
 
38
+ export const BUILT_IN_GOOGLE_MODELS = GEMINI_MODELS.map((model) => ({
39
+ id: model.seedId,
40
+ providerId: BUILT_IN_GOOGLE_PROVIDER.id,
41
+ modelId: model.id,
42
+ displayName: model.name,
43
+ description: model.description,
44
+ tier: 'custom',
45
+ }));
46
+
32
47
  export const DEFAULT_QUICK_RESPONSES = [
33
48
  { label: 'Put a plan on the canvas', content: 'Put a plan on the canvas to get this done', autoSubmit: false, sortOrder: 0 },
34
49
  { label: 'Yes', content: 'Yes', autoSubmit: true, sortOrder: 1 },
@@ -87,6 +102,13 @@ function seedBuiltInProviders(db) {
87
102
  VALUES (?, ?, ?, ?, ?, ?, ?)`
88
103
  );
89
104
 
105
+ // Note: Google provider and models are NOT seeded here because seedBaselineData
106
+ // runs before migrations. On existing databases the providers table still has
107
+ // CHECK(kind IN ('anthropic','openai')), so an INSERT with kind='google' would
108
+ // be silently ignored by INSERT OR IGNORE, and the subsequent model inserts
109
+ // would fail with a FOREIGN KEY constraint. The 'providers-seed-built-in-google'
110
+ // migration handles seeding Google for both fresh and existing databases after
111
+ // the 'providers-widen-kind-check-google' migration has widened the CHECK constraint.
90
112
  for (const model of [...BUILT_IN_ANTHROPIC_MODELS, ...BUILT_IN_OPENAI_MODELS]) {
91
113
  insertModel.run(model.id, model.providerId, model.modelId, model.displayName, model.description, model.tier, now);
92
114
  }
@@ -43,7 +43,7 @@ CREATE TABLE IF NOT EXISTS providers (
43
43
  additional_env_vars TEXT,
44
44
  commit_attribution_override TEXT,
45
45
  is_built_in INTEGER NOT NULL DEFAULT 0,
46
- kind TEXT NOT NULL DEFAULT 'anthropic' CHECK(kind IN ('anthropic','openai')),
46
+ kind TEXT NOT NULL DEFAULT 'anthropic' CHECK(kind IN ('anthropic','openai','google')),
47
47
  created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
48
48
  updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
49
49
  );
@@ -49,9 +49,12 @@ export function createCapturedSpawnProcess(agentType) {
49
49
  });
50
50
  };
51
51
 
52
- if (agentType === 'claude-code') {
52
+ if (agentType === 'claude-code' || agentType === 'gemini') {
53
+ // Claude Code and Gemini don't pass prompts via stdin (they use CLI args).
54
+ // Complete after a short delay to simulate process execution.
53
55
  setTimeout(complete, 10);
54
56
  } else {
57
+ // Codex passes the prompt via stdin; complete when stdin is closed.
55
58
  stdin.once('finish', complete);
56
59
  }
57
60
 
@@ -59,12 +62,15 @@ export function createCapturedSpawnProcess(agentType) {
59
62
  }
60
63
 
61
64
  function summarizeSpawnEnv(env = {}) {
62
- if (!Object.prototype.hasOwnProperty.call(env, 'CIRCUSCHIEF_COMMIT_ATTRIBUTION')) {
63
- return {};
65
+ const summary = {};
66
+ if (Object.prototype.hasOwnProperty.call(env, 'CIRCUSCHIEF_COMMIT_ATTRIBUTION')) {
67
+ summary.CIRCUSCHIEF_COMMIT_ATTRIBUTION = env.CIRCUSCHIEF_COMMIT_ATTRIBUTION;
64
68
  }
65
- return {
66
- CIRCUSCHIEF_COMMIT_ATTRIBUTION: env.CIRCUSCHIEF_COMMIT_ATTRIBUTION,
67
- };
69
+ if (Object.prototype.hasOwnProperty.call(env, 'GEMINI_CLI_TRUST_WORKSPACE')) {
70
+ summary.GEMINI_CLI_TRUST_WORKSPACE = env.GEMINI_CLI_TRUST_WORKSPACE;
71
+ }
72
+ if (Object.keys(summary).length === 0) return {};
73
+ return summary;
68
74
  }
69
75
 
70
76
  function summarizeSpawnOptions(agentType, spawnOptions) {
@@ -78,6 +84,15 @@ function summarizeSpawnOptions(agentType, spawnOptions) {
78
84
  };
79
85
  }
80
86
 
87
+ if (agentType === 'gemini') {
88
+ return {
89
+ model: valueAfter(args, '-m'),
90
+ outputFormat: valueAfter(args, '--output-format'),
91
+ approvalMode: optionValue(args, '--approval-mode'),
92
+ prompt: valueAfter(args, '-p'),
93
+ };
94
+ }
95
+
81
96
  return {
82
97
  model: valueAfter(args, '-m'),
83
98
  sandbox: valueAfter(args, '--sandbox'),
@@ -91,6 +106,14 @@ function valueAfter(args, flag) {
91
106
  return args[index + 1] ?? null;
92
107
  }
93
108
 
109
+ function optionValue(args, flag) {
110
+ const separateValue = valueAfter(args, flag);
111
+ if (separateValue !== null) return separateValue;
112
+ const prefix = `${flag}=`;
113
+ const arg = args.find((value) => value.startsWith(prefix));
114
+ return arg ? arg.slice(prefix.length) : null;
115
+ }
116
+
94
117
  function valuesAfter(args, flag) {
95
118
  const values = [];
96
119
  for (let index = 0; index < args.length; index += 1) {
@@ -116,6 +139,24 @@ function writeCapturedAgentEvents(agentType, stdout) {
116
139
  return;
117
140
  }
118
141
 
142
+ if (agentType === 'gemini') {
143
+ // Gemini CLI stream-json format: init → message → result
144
+ writeJsonLine(stdout, {
145
+ type: 'init',
146
+ session_id: `e2e-gemini-${Date.now()}`,
147
+ });
148
+ writeJsonLine(stdout, {
149
+ type: 'message',
150
+ role: 'assistant',
151
+ content: 'E2E spawn capture response.',
152
+ });
153
+ writeJsonLine(stdout, {
154
+ type: 'result',
155
+ stats: { input_tokens: 0, output_tokens: 0 },
156
+ });
157
+ return;
158
+ }
159
+
119
160
  writeJsonLine(stdout, {
120
161
  type: 'system',
121
162
  subtype: 'init',
@@ -0,0 +1,47 @@
1
+ import { spawn } from 'child_process';
2
+ import { createRobustEnv } from './nodeSpawnHelper.js';
3
+ import {
4
+ captureSpawnAttempt,
5
+ createCapturedSpawnProcess,
6
+ isE2ESpawnCaptureEnabled,
7
+ } from './e2eSpawnCapture.js';
8
+
9
+ /**
10
+ * Create a custom spawn function for the Gemini CLI.
11
+ *
12
+ * Mirrors {@link createCodexSpawner} but for the `gemini` command.
13
+ *
14
+ * As with other CLI helpers:
15
+ * - The command 'node' is replaced with {@link process.execPath} so child
16
+ * processes use the same Node binary.
17
+ * - `createRobustEnv` guarantees the Node bin directory is on PATH.
18
+ *
19
+ * @returns {Function} Spawn function of shape (options) => childProcess
20
+ */
21
+ export function createGeminiSpawner() {
22
+ return (options) => {
23
+ const { command, args, cwd, env, signal } = options;
24
+ // Replace 'node' with the absolute path to the current Node executable
25
+ const actualCommand = command === 'node' ? process.execPath : command;
26
+
27
+ // Ensure PATH includes the directory containing Node
28
+ const robustEnv = createRobustEnv(env);
29
+
30
+ // Trust the workspace automatically in headless/automated mode.
31
+ // Without this, Gemini CLI refuses to run in untrusted directories.
32
+ robustEnv.GEMINI_CLI_TRUST_WORKSPACE = 'true';
33
+
34
+ if (isE2ESpawnCaptureEnabled()) {
35
+ captureSpawnAttempt('gemini', { ...options, env: robustEnv });
36
+ return createCapturedSpawnProcess('gemini');
37
+ }
38
+
39
+ return spawn(actualCommand, args, {
40
+ cwd,
41
+ stdio: ['pipe', 'pipe', 'pipe'],
42
+ signal,
43
+ env: robustEnv,
44
+ windowsHide: true,
45
+ });
46
+ };
47
+ }
@@ -6,11 +6,7 @@ import { git } from './gitService.js';
6
6
  * @returns {Promise<string>}
7
7
  */
8
8
  export async function getDiff(directory) {
9
- try {
10
- return await git(directory, 'diff');
11
- } catch {
12
- return '';
13
- }
9
+ return git(directory, 'diff');
14
10
  }
15
11
 
16
12
  /**
@@ -19,11 +15,7 @@ export async function getDiff(directory) {
19
15
  * @returns {Promise<string>}
20
16
  */
21
17
  export async function getStagedDiff(directory) {
22
- try {
23
- return await git(directory, 'diff --cached');
24
- } catch {
25
- return '';
26
- }
18
+ return git(directory, 'diff --cached');
27
19
  }
28
20
 
29
21
  /**
@@ -36,7 +28,8 @@ export async function getUntrackedFiles(directory) {
36
28
  const output = await git(directory, 'ls-files --others --exclude-standard');
37
29
  if (!output) return [];
38
30
  return output.split('\n').filter((line) => line.trim());
39
- } catch {
31
+ } catch (error) {
32
+ console.warn(`Failed to get untracked files for ${directory}:`, error.message);
40
33
  return [];
41
34
  }
42
35
  }
@@ -48,11 +41,7 @@ export async function getUntrackedFiles(directory) {
48
41
  * @returns {Promise<string>}
49
42
  */
50
43
  export async function getDiffAgainstBranch(directory, branch) {
51
- try {
52
- return await git(directory, `diff ${branch}`);
53
- } catch {
54
- return '';
55
- }
44
+ return git(directory, `diff ${branch}`);
56
45
  }
57
46
 
58
47
  /**
@@ -62,11 +51,7 @@ export async function getDiffAgainstBranch(directory, branch) {
62
51
  * @returns {Promise<string>}
63
52
  */
64
53
  export async function getStagedDiffAgainstBranch(directory, branch) {
65
- try {
66
- return await git(directory, `diff --cached ${branch}`);
67
- } catch {
68
- return '';
69
- }
54
+ return git(directory, `diff --cached ${branch}`);
70
55
  }
71
56
 
72
57
  /**
@@ -78,11 +63,16 @@ export async function getStagedDiffAgainstBranch(directory, branch) {
78
63
  * @returns {Promise<string>}
79
64
  */
80
65
  export async function getDiffBetweenRefs(directory, fromRef, toRef) {
81
- try {
82
- return await git(directory, `diff ${fromRef} ${toRef}`);
83
- } catch {
84
- return '';
85
- }
66
+ return git(directory, `diff ${fromRef} ${toRef}`);
67
+ }
68
+
69
+ function addGitPathOutput(files, output) {
70
+ if (!output) return;
71
+
72
+ output.split('\n').forEach((file) => {
73
+ const trimmed = file.trim();
74
+ if (trimmed) files.add(trimmed);
75
+ });
86
76
  }
87
77
 
88
78
  /**
@@ -94,35 +84,20 @@ export async function getDiffBetweenRefs(directory, fromRef, toRef) {
94
84
  */
95
85
  export async function getModifiedFilesCount(directory, branch) {
96
86
  try {
97
- // Get all modified files in one command using --name-only
98
- // This includes: committed changes vs branch + staged
99
- const committedAndStaged = await git(
87
+ const committed = await git(
100
88
  directory,
101
89
  `diff --name-only ${branch}...HEAD`
102
90
  );
103
91
 
104
- // Get unstaged changes (working tree vs index)
92
+ const staged = await git(directory, 'diff --cached --name-only');
105
93
  const unstaged = await git(directory, 'diff --name-only');
106
-
107
- // Get untracked files
108
94
  const untracked = await getUntrackedFiles(directory);
109
95
 
110
- // Combine all files into a Set to get unique count
111
96
  const allFiles = new Set();
112
-
113
- if (committedAndStaged) {
114
- committedAndStaged.split('\n').forEach(f => {
115
- if (f.trim()) allFiles.add(f.trim());
116
- });
117
- }
118
-
119
- if (unstaged) {
120
- unstaged.split('\n').forEach(f => {
121
- if (f.trim()) allFiles.add(f.trim());
122
- });
123
- }
124
-
125
- untracked.forEach(f => allFiles.add(f));
97
+ addGitPathOutput(allFiles, committed);
98
+ addGitPathOutput(allFiles, staged);
99
+ addGitPathOutput(allFiles, unstaged);
100
+ untracked.forEach((file) => allFiles.add(file));
126
101
 
127
102
  return allFiles.size;
128
103
  } catch (error) {
@@ -30,6 +30,7 @@ export {
30
30
  } from './gitWorktree.js';
31
31
 
32
32
  const execAsync = promisify(exec);
33
+ export const DEFAULT_GIT_MAX_BUFFER = 100 * 1024 * 1024;
33
34
 
34
35
  // Cache for default branch detection: directory -> { branch, timestamp }
35
36
  const defaultBranchCache = new Map();
@@ -70,10 +71,14 @@ function evictOldestCacheEntries() {
70
71
  * @param {Object} [opts]
71
72
  * @param {Object} [opts.env]
72
73
  * @param {number} [opts.timeout]
74
+ * @param {number} [opts.maxBuffer]
73
75
  * @returns {Promise<string>}
74
76
  */
75
77
  export async function git(directory, command, opts = {}) {
76
- const execOpts = { cwd: directory };
78
+ const execOpts = {
79
+ cwd: directory,
80
+ maxBuffer: opts.maxBuffer ?? DEFAULT_GIT_MAX_BUFFER,
81
+ };
77
82
  if (opts.env) execOpts.env = opts.env;
78
83
  if (opts.timeout) execOpts.timeout = opts.timeout;
79
84
  const { stdout } = await execAsync(`git ${command}`, execOpts);
@@ -290,4 +295,3 @@ export async function pinAuthorInWorktree(worktreePath, projectDir, { env } = {}
290
295
 
291
296
  return true;
292
297
  }
293
-
@@ -1,5 +1,6 @@
1
1
  import Anthropic from '@anthropic-ai/sdk';
2
2
  import OpenAI from 'openai';
3
+ import { createGeminiSpawner } from './geminiSpawnHelper.js';
3
4
 
4
5
  /**
5
6
  * Test a provider configuration by making a minimal API call.
@@ -22,11 +23,14 @@ import OpenAI from 'openai';
22
23
  * @param {number} [config.apiTimeoutMs] - API timeout in milliseconds
23
24
  * @returns {Promise<{success: boolean, message: string, details?: Object}>}
24
25
  */
25
- export async function testProviderConnection(config) {
26
+ export async function testProviderConnection(config, deps = {}) {
26
27
  const { kind = 'anthropic' } = config || {};
27
28
  if (kind === 'openai') {
28
29
  return testOpenAIConnection(config);
29
30
  }
31
+ if (kind === 'google') {
32
+ return testGoogleConnection(config, deps);
33
+ }
30
34
  return testAnthropicConnection(config);
31
35
  }
32
36
 
@@ -127,6 +131,60 @@ async function testOpenAIChatEndpoint(client, config) {
127
131
  });
128
132
  }
129
133
 
134
+ /**
135
+ * Google/Gemini connection test. Spawns `gemini -p "Hi" --output-format json`
136
+ * and checks for a clean exit. No SDK dependency needed.
137
+ * @private
138
+ */
139
+ async function testGoogleConnection(config, deps = {}) {
140
+ try {
141
+ const env = {};
142
+ if (config.authToken) env.GEMINI_API_KEY = config.authToken;
143
+ const timeoutMs = config.apiTimeoutMs || 30000;
144
+ const spawnGeminiProcess = deps.spawnGeminiProcess || createGeminiSpawner();
145
+ const child = spawnGeminiProcess({
146
+ command: 'gemini',
147
+ args: ['-p', 'Hi', '--output-format', 'json', '--skip-trust', '--approval-mode=auto_edit', '-m', 'gemini-2.5-flash'],
148
+ cwd: config.workingDirectory,
149
+ env,
150
+ });
151
+
152
+ return await new Promise((resolve) => {
153
+ let stderr = '';
154
+ let killed = false;
155
+
156
+ const timer = setTimeout(() => {
157
+ killed = true;
158
+ try { child.kill('SIGTERM'); } catch { /* ignore */ }
159
+ resolve(failureResponse(new Error(`Gemini CLI timed out after ${timeoutMs}ms`)));
160
+ }, timeoutMs);
161
+
162
+ child.stdout?.on('data', () => { /* drain */ });
163
+ child.stderr?.on('data', (d) => { stderr += d; });
164
+ child.on('error', (error) => {
165
+ clearTimeout(timer);
166
+ if (killed) return;
167
+ if (error.code === 'ENOENT') {
168
+ resolve(failureResponse(new Error('Gemini CLI not found. Install via: npm install -g @google/gemini-cli')));
169
+ } else {
170
+ resolve(failureResponse(error));
171
+ }
172
+ });
173
+ child.on('exit', (code) => {
174
+ clearTimeout(timer);
175
+ if (killed) return;
176
+ if (code === 0) {
177
+ resolve(connectionSuccess({ model: 'gemini-2.5-flash' }));
178
+ } else {
179
+ resolve(failureResponse(new Error(stderr.trim() || `Gemini CLI exited with code ${code}`)));
180
+ }
181
+ });
182
+ });
183
+ } catch (error) {
184
+ return failureResponse(error);
185
+ }
186
+ }
187
+
130
188
  function connectionSuccess(details) {
131
189
  return {
132
190
  success: true,
@@ -1,6 +1,7 @@
1
1
  import { createClaudeCodeSpawner } from './nodeSpawnHelper.js';
2
2
  import {
3
3
  buildSystemPromptConfig,
4
+ getGeminiApprovalModeForSession,
4
5
  getPermissionModeForSession,
5
6
  getSandboxModeForSession,
6
7
  } from './sessionPrompts.js';
@@ -64,6 +65,34 @@ function buildCodexQueryParams({
64
65
  };
65
66
  }
66
67
 
68
+ /**
69
+ * Build query parameters for the Gemini adapter.
70
+ *
71
+ * Gemini CLI is prompt-driven via `-p` flag. It does not need Claude-specific
72
+ * options (permissionMode, settingSources, resume) or Codex-specific options
73
+ * (sandboxMode, effortLevel).
74
+ *
75
+ * @returns {Object}
76
+ */
77
+ function buildGeminiQueryParams({
78
+ prompt, workingDirectory, controller, session, sessionId, systemPrompt, model, sessionEnv,
79
+ }) {
80
+ const isVCR = Boolean(process.env.VCR_MODE);
81
+ const effectiveModel = isVCR ? 'gemini-2.5-flash' : model;
82
+
83
+ return {
84
+ prompt,
85
+ options: {
86
+ cwd: workingDirectory,
87
+ abortController: controller,
88
+ env: sessionEnv,
89
+ model: effectiveModel,
90
+ approvalMode: getGeminiApprovalModeForSession(session?.mode),
91
+ systemPrompt: buildSystemPromptConfig(sessionId, session.projectId, systemPrompt, session.mode),
92
+ },
93
+ };
94
+ }
95
+
67
96
  /**
68
97
  * Build query parameters for executing a session via the configured agent.
69
98
  * Shared by runSession, continueSession, and continueSessionWithExistingMessage.
@@ -78,7 +107,7 @@ function buildCodexQueryParams({
78
107
  * @param {string|null} options.model - Model to use
79
108
  * @param {Object} options.sessionEnv - Environment variables for the session
80
109
  * @param {string|null} [options.resumeSessionId] - Session ID to resume (null for new session)
81
- * @param {string} [options.agentType] - 'claude-code' (default) | 'codex'
110
+ * @param {string} [options.agentType] - 'claude-code' (default) | 'codex' | 'gemini'
82
111
  * @returns {Object} Query parameters for agent.execute()
83
112
  */
84
113
  export function buildQueryParams(options) {
@@ -86,5 +115,8 @@ export function buildQueryParams(options) {
86
115
  if (agentType === 'codex') {
87
116
  return buildCodexQueryParams(options);
88
117
  }
118
+ if (agentType === 'gemini') {
119
+ return buildGeminiQueryParams(options);
120
+ }
89
121
  return buildClaudeCodeQueryParams(options);
90
122
  }
@@ -1,5 +1,6 @@
1
1
  import { sessions, messages, attachments, conversations } from '../database.js';
2
2
  import { createCodexSpawner } from './codexSpawnHelper.js';
3
+ import { createGeminiSpawner } from './geminiSpawnHelper.js';
3
4
  import { resolveProviderFromModel, resolveProviderMetadataFromModel, buildSessionEnv } from './sessionProvider.js';
4
5
  import { agentGateway } from '../agents/AgentGateway.js';
5
6
  import { LoggingAgentWrapper } from '../agents/LoggingAgentWrapper.js';
@@ -37,6 +38,9 @@ function buildAgentConfig(agentType) {
37
38
  if (agentType === 'codex') {
38
39
  return { spawnCodexProcess: createCodexSpawner() };
39
40
  }
41
+ if (agentType === 'gemini') {
42
+ return { spawnGeminiProcess: createGeminiSpawner() };
43
+ }
40
44
  return {};
41
45
  }
42
46
 
@@ -134,6 +134,28 @@ export function getSandboxModeForSession(mode) {
134
134
  }
135
135
  }
136
136
 
137
+ /**
138
+ * Map session mode to Gemini CLI --approval-mode.
139
+ *
140
+ * plan -> plan
141
+ * standard -> auto_edit
142
+ * yolo -> yolo
143
+ *
144
+ * @param {string} mode - Session mode ('plan', 'standard', 'yolo')
145
+ * @returns {string} Gemini CLI approval mode
146
+ */
147
+ export function getGeminiApprovalModeForSession(mode) {
148
+ switch (mode) {
149
+ case 'plan':
150
+ return 'plan';
151
+ case 'yolo':
152
+ return 'yolo';
153
+ case 'standard':
154
+ default:
155
+ return 'auto_edit';
156
+ }
157
+ }
158
+
137
159
  /** Plan mode system prompt instructions */
138
160
  export const PLAN_MODE_PROMPT = `## Plan Mode Active
139
161