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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "circuschief",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Local-first web UI for managing Claude Code sessions",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,5 +1,6 @@
1
1
  import { ClaudeCodeAdapter } from './adapters/ClaudeCodeAdapter.js';
2
2
  import { CodexAdapter } from './adapters/CodexAdapter.js';
3
+ import { GeminiAdapter } from './adapters/GeminiAdapter.js';
3
4
 
4
5
  /**
5
6
  * Factory/registry for agent adapters.
@@ -17,6 +18,7 @@ export class AgentGateway {
17
18
  _registerDefaultAdapters() {
18
19
  this.registerAdapter('claude-code', ClaudeCodeAdapter);
19
20
  this.registerAdapter('codex', CodexAdapter);
21
+ this.registerAdapter('gemini', GeminiAdapter);
20
22
  }
21
23
 
22
24
  /**
@@ -0,0 +1,105 @@
1
+ import { BaseAgent } from '../BaseAgent.js';
2
+ import { executeGeminiCli } from './geminiCliRunner.js';
3
+ import { composeCliPrompt } from './cliUtils.js';
4
+ import { createGeminiSpawner } from '../../services/geminiSpawnHelper.js';
5
+
6
+ /**
7
+ * Module-level flag: once an ENOENT is observed for the Gemini CLI, remember
8
+ * it so subsequent calls can short-circuit.
9
+ */
10
+ const geminiCliState = { unavailable: false };
11
+
12
+ function markGeminiCliUnavailable() {
13
+ geminiCliState.unavailable = true;
14
+ }
15
+
16
+ /**
17
+ * Test-only: reset the module-level ENOENT cache.
18
+ * @private
19
+ */
20
+ export function _resetGeminiCliUnavailableForTests() {
21
+ geminiCliState.unavailable = false;
22
+ }
23
+
24
+ /**
25
+ * Adapter for Google Gemini CLI.
26
+ *
27
+ * Execution path:
28
+ * Spawns `gemini -p "prompt" --output-format stream-json -m <model>` and
29
+ * parses its newline-delimited JSON stdout. Uses {@link createGeminiEventMapper}
30
+ * to normalize events into the SDK-shaped envelope the rest of the app
31
+ * already understands for Claude Code.
32
+ *
33
+ * Capabilities:
34
+ * - streaming: true — stream-json provides real-time events
35
+ * - thinking: false — no separate thinking mode toggle
36
+ * - reasoningEffort: false — no effort level mapping yet
37
+ * - toolUse: true — Gemini CLI has built-in shell, file, and web tools
38
+ * - resume: false — no session resume support in headless mode
39
+ */
40
+ export class GeminiAdapter extends BaseAgent {
41
+ static capabilities = Object.freeze({
42
+ streaming: true,
43
+ thinking: false,
44
+ reasoningEffort: false,
45
+ toolUse: true,
46
+ resume: false,
47
+ });
48
+
49
+ /**
50
+ * @param {Object} [opts]
51
+ * @param {Function} [opts.spawnGeminiProcess] - Optional DI for testing.
52
+ * Shape matches {@link createGeminiSpawner} output.
53
+ * @param {Object} [opts.rest] - Passed to {@link BaseAgent}.
54
+ */
55
+ constructor({ spawnGeminiProcess, ...rest } = {}) {
56
+ super(rest);
57
+ this._spawnGemini = spawnGeminiProcess;
58
+ }
59
+
60
+ getCapabilities() {
61
+ return { ...GeminiAdapter.capabilities };
62
+ }
63
+
64
+ supportsResume() {
65
+ return false;
66
+ }
67
+
68
+ /**
69
+ * Execute a Gemini query and yield SDK-shaped events.
70
+ *
71
+ * @param {Object} queryParams
72
+ * @yields {Object} Normalized SDK events
73
+ */
74
+ async *execute(queryParams, _meta) {
75
+ const options = queryParams.options || {};
76
+ yield* this._executeCli(queryParams, options);
77
+ }
78
+
79
+ _executeCli(queryParams, options) {
80
+ const child = this._spawnGeminiChild(queryParams, options);
81
+ return executeGeminiCli(child, queryParams, options, markGeminiCliUnavailable);
82
+ }
83
+
84
+ _spawnGeminiChild(queryParams, options) {
85
+ const spawnFn = this._spawnGemini ?? createGeminiSpawner();
86
+ const { cwd, env, abortController, model, systemPrompt, approvalMode } = options;
87
+ const composedPrompt = composeCliPrompt(systemPrompt, queryParams.prompt);
88
+ const args = ['-p', composedPrompt, '--output-format', 'stream-json', '--skip-trust', '-m', model];
89
+ if (approvalMode) {
90
+ args.push(`--approval-mode=${approvalMode}`);
91
+ }
92
+
93
+ try {
94
+ return spawnFn({ command: 'gemini', args, cwd, env, signal: abortController?.signal });
95
+ } catch (err) {
96
+ if (err?.code === 'ENOENT') {
97
+ geminiCliState.unavailable = true;
98
+ const notFound = new Error('Gemini CLI not found');
99
+ notFound.code = 'GEMINI_CLI_NOT_FOUND';
100
+ throw notFound;
101
+ }
102
+ throw err;
103
+ }
104
+ }
105
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Compose a CLI prompt by prepending the system prompt (if provided) to the
3
+ * user prompt. Used by both Codex and Gemini CLI runners.
4
+ *
5
+ * @param {string|null} systemPrompt
6
+ * @param {string|null} prompt
7
+ * @returns {string}
8
+ */
9
+ export function composeCliPrompt(systemPrompt, prompt) {
10
+ const user = prompt ?? '';
11
+ if (typeof systemPrompt === 'string' && systemPrompt.length > 0) {
12
+ return `SYSTEM PROMPT:\n${systemPrompt}\n\nUSER:\n${user}`;
13
+ }
14
+ return user;
15
+ }
@@ -1,5 +1,6 @@
1
1
  import readline from 'readline';
2
2
  import { createCodexEventMapper } from './codexEventMapper.js';
3
+ import { composeCliPrompt } from './cliUtils.js';
3
4
 
4
5
  export async function *executeCodexCli(child, queryParams, options, markCliUnavailable) {
5
6
  const state = new CliState(options.model, markCliUnavailable);
@@ -76,14 +77,6 @@ function writePromptToStdin(child, prompt) {
76
77
  } catch { /* ignore */ }
77
78
  }
78
79
 
79
- function composeCliPrompt(systemPrompt, prompt) {
80
- const user = prompt ?? '';
81
- if (typeof systemPrompt === 'string' && systemPrompt.length > 0) {
82
- return `SYSTEM PROMPT:\n${systemPrompt}\n\nUSER:\n${user}`;
83
- }
84
- return user;
85
- }
86
-
87
80
  function attachStdoutReader(child, state) {
88
81
  const rl = readline.createInterface({ input: child.stdout });
89
82
  state.assign({ rl });
@@ -0,0 +1,183 @@
1
+ import readline from 'readline';
2
+ import { createGeminiEventMapper } from './geminiEventMapper.js';
3
+
4
+ /**
5
+ * Execute the Gemini CLI child process and yield SDK-shaped events.
6
+ *
7
+ * Unlike Codex, the prompt is NOT passed via stdin — it's passed as the `-p`
8
+ * CLI argument by GeminiAdapter._spawnGeminiChild(). This runner only reads
9
+ * stdout/stderr and handles process lifecycle.
10
+ *
11
+ * @param {import('child_process').ChildProcess} child
12
+ * @param {Object} queryParams
13
+ * @param {Object} options
14
+ * @param {Function} markCliUnavailable
15
+ * @yields {Object} SDK-shaped events
16
+ */
17
+ export async function *executeGeminiCli(child, queryParams, options, markCliUnavailable) {
18
+ const state = new CliState(options.model, markCliUnavailable);
19
+
20
+ attachAbortHandling(child, options.abortController, state);
21
+ attachStdoutReader(child, state);
22
+ attachStderrReader(child, state);
23
+ attachProcessLifecycleHandlers(child, state);
24
+
25
+ try {
26
+ yield* drainCliEvents(state);
27
+ } finally {
28
+ cleanupCli(options.abortController, state);
29
+ }
30
+ }
31
+
32
+ class CliState {
33
+ constructor(model, markCliUnavailable) {
34
+ this.pending = [];
35
+ this.error = null;
36
+ this.ended = false;
37
+ this.resolveNext = null;
38
+ this.rejectAll = null;
39
+ this.mapper = createGeminiEventMapper({ model });
40
+ this.rl = null;
41
+ this.killTimer = null;
42
+ this.onAbort = null;
43
+ this.stderrBuffer = '';
44
+ this.markCliUnavailable = markCliUnavailable;
45
+ }
46
+
47
+ assign(patch) {
48
+ Object.assign(this, patch);
49
+ }
50
+
51
+ pushEvents(events) {
52
+ for (const event of events) this.pending.push(event);
53
+ this.tickWaiter();
54
+ }
55
+
56
+ tickWaiter() {
57
+ if (!this.resolveNext) return;
58
+ const resolve = this.resolveNext;
59
+ this.resolveNext = null;
60
+ resolve();
61
+ }
62
+
63
+ failWith(error) {
64
+ this.error = error;
65
+ if (this.rejectAll) this.rejectAll(error);
66
+ }
67
+
68
+ markEnded() {
69
+ this.ended = true;
70
+ }
71
+ }
72
+
73
+ function attachAbortHandling(child, abortController, state) {
74
+ const onAbort = () => {
75
+ try { child.kill('SIGTERM'); } catch { /* ignore */ }
76
+ const timer = setTimeout(() => {
77
+ try { child.kill('SIGKILL'); } catch { /* ignore */ }
78
+ }, 2000);
79
+ state.assign({ killTimer: timer });
80
+ };
81
+ state.assign({ onAbort });
82
+ abortController?.signal?.addEventListener('abort', onAbort);
83
+ }
84
+
85
+ function attachStdoutReader(child, state) {
86
+ const rl = readline.createInterface({ input: child.stdout });
87
+ state.assign({ rl });
88
+ rl.on('line', (line) => handleCliStdoutLine(line, state));
89
+ }
90
+
91
+ function handleCliStdoutLine(line, state) {
92
+ const trimmed = line.trim();
93
+ if (!trimmed) return;
94
+ let parsed;
95
+ try {
96
+ parsed = JSON.parse(trimmed);
97
+ } catch {
98
+ return; // Ignore malformed JSON lines
99
+ }
100
+ try {
101
+ const mapped = state.mapper.map(parsed);
102
+ if (mapped.length > 0) state.pushEvents(mapped);
103
+ } catch (error) {
104
+ state.failWith(error);
105
+ }
106
+ }
107
+
108
+ function attachStderrReader(child, state) {
109
+ if (!child.stderr) return;
110
+ child.stderr.on('data', (chunk) => {
111
+ if (state.error || state.ended) return;
112
+ state.assign({ stderrBuffer: state.stderrBuffer + chunk.toString() });
113
+ });
114
+ }
115
+
116
+ function attachProcessLifecycleHandlers(child, state) {
117
+ child.on('error', (error) => {
118
+ if (error?.code === 'ENOENT') {
119
+ state.markCliUnavailable?.();
120
+ state.failWith(makeCliNotFoundError());
121
+ return;
122
+ }
123
+ state.failWith(error);
124
+ });
125
+
126
+ child.on('exit', (code) => handleChildExit(code, state));
127
+ }
128
+
129
+ function makeCliNotFoundError() {
130
+ const error = new Error('Gemini CLI not found');
131
+ error.code = 'GEMINI_CLI_NOT_FOUND';
132
+ return error;
133
+ }
134
+
135
+ function handleChildExit(code, state) {
136
+ state.markEnded();
137
+ if (code !== 0 && !state.error) {
138
+ state.failWith(makeCliExitError(code, state.stderrBuffer));
139
+ } else if (!state.error) {
140
+ finalizeMappedEvents(state);
141
+ }
142
+ state.tickWaiter();
143
+ }
144
+
145
+ function makeCliExitError(code, stderrBuffer) {
146
+ const stderrTrimmed = stderrBuffer.trim();
147
+ const error = stderrTrimmed.length > 0
148
+ ? new Error(stderrTrimmed)
149
+ : new Error(`Gemini CLI exited with code ${code}`);
150
+ error.code = 'GEMINI_CLI_EXIT';
151
+ error.exitCode = code;
152
+ return error;
153
+ }
154
+
155
+ function finalizeMappedEvents(state) {
156
+ try {
157
+ const events = state.mapper.finalize();
158
+ if (events.length > 0) state.pushEvents(events);
159
+ } catch { /* ignore */ }
160
+ }
161
+
162
+ async function *drainCliEvents(state) {
163
+ while (true) {
164
+ if (state.error) throw state.error;
165
+ if (state.pending.length > 0) {
166
+ yield state.pending.shift();
167
+ continue;
168
+ }
169
+ if (state.ended) break;
170
+ await new Promise((resolve, reject) => {
171
+ state.assign({ resolveNext: resolve, rejectAll: reject });
172
+ });
173
+ }
174
+ if (state.error) throw state.error;
175
+ }
176
+
177
+ function cleanupCli(abortController, state) {
178
+ if (state.onAbort) {
179
+ abortController?.signal?.removeEventListener('abort', state.onAbort);
180
+ }
181
+ if (state.killTimer) clearTimeout(state.killTimer);
182
+ try { state.rl?.close(); } catch { /* ignore */ }
183
+ }
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Gemini CLI event mapper.
3
+ *
4
+ * Translates Gemini CLI `--output-format stream-json` JSONL events into the
5
+ * normalized SDK-shaped events that Circus Chief already understands:
6
+ *
7
+ * - system(init)
8
+ * - stream_event(content_block_delta)
9
+ * - assistant
10
+ * - tool_result
11
+ * - result(success, usage)
12
+ *
13
+ * @param {Object} [options]
14
+ * @param {string} [options.model] - Model name to surface in the system(init) event.
15
+ * @returns {{
16
+ * map: (geminiEvent: Object) => Array<Object>,
17
+ * reset: () => void,
18
+ * finalize: () => Array<Object>
19
+ * }}
20
+ */
21
+ export function createGeminiEventMapper({ model } = {}) {
22
+ const state = new GeminiMapperState();
23
+ const warnedUnknownTypes = new Set();
24
+
25
+ function map(geminiEvent) {
26
+ if (!geminiEvent || typeof geminiEvent !== 'object') return [];
27
+
28
+ const type = geminiEvent.type;
29
+
30
+ if (type === 'init') {
31
+ return handleInit(geminiEvent, model);
32
+ }
33
+ if (type === 'message') {
34
+ return handleMessage(geminiEvent, state);
35
+ }
36
+ if (type === 'tool_use') {
37
+ return [...state.flushAccumulatedText(), ...handleToolUse(geminiEvent, state)];
38
+ }
39
+ if (type === 'tool_result') {
40
+ return [...state.flushAccumulatedText(), ...handleToolResult(geminiEvent, state)];
41
+ }
42
+ if (type === 'result') {
43
+ return [...state.flushAccumulatedText(), ...state.onResult(geminiEvent)];
44
+ }
45
+
46
+ // Unknown event type — warn once
47
+ if (type && !warnedUnknownTypes.has(type)) {
48
+ warnedUnknownTypes.add(type);
49
+ console.warn(`[geminiEventMapper] Unknown Gemini event type: "${type}"`);
50
+ }
51
+ return [];
52
+ }
53
+
54
+ return {
55
+ map,
56
+ reset: () => state.reset(),
57
+ finalize: () => state.finalize(),
58
+ };
59
+ }
60
+
61
+ // --- State class -----------------------------------------------------------
62
+
63
+ class GeminiMapperState {
64
+ constructor() {
65
+ this.reset();
66
+ }
67
+
68
+ reset() {
69
+ this.lastUsage = null;
70
+ this.terminated = false;
71
+ this.pendingToolUse = new Map();
72
+ this.accumulatedText = '';
73
+ }
74
+
75
+ /**
76
+ * Flush any accumulated delta text as a synthetic `assistant` event.
77
+ * Returns an array (possibly empty) of events to prepend.
78
+ */
79
+ flushAccumulatedText() {
80
+ if (!this.accumulatedText) return [];
81
+ const text = this.accumulatedText;
82
+ this.accumulatedText = '';
83
+ return [{
84
+ type: 'assistant',
85
+ message: { content: [{ type: 'text', text }] },
86
+ }];
87
+ }
88
+
89
+ appendDeltaText(text) {
90
+ this.accumulatedText += text;
91
+ }
92
+
93
+ clearAccumulatedText() {
94
+ this.accumulatedText = '';
95
+ }
96
+
97
+ finalize() {
98
+ if (this.terminated) return [];
99
+ this.terminated = true;
100
+ return [...this.flushAccumulatedText(), this.buildResultEvent()];
101
+ }
102
+
103
+ onResult(evt) {
104
+ if (evt.stats) {
105
+ this.lastUsage = {
106
+ input_tokens: evt.stats.input_tokens || 0,
107
+ output_tokens: evt.stats.output_tokens || 0,
108
+ };
109
+ }
110
+ this.terminated = true;
111
+ return [this.buildResultEvent()];
112
+ }
113
+
114
+ buildResultEvent() {
115
+ const usage = this.lastUsage || { input_tokens: 0, output_tokens: 0 };
116
+ return {
117
+ type: 'result',
118
+ subtype: 'success',
119
+ usage: {
120
+ input_tokens: usage.input_tokens || 0,
121
+ output_tokens: usage.output_tokens || 0,
122
+ },
123
+ };
124
+ }
125
+ }
126
+
127
+ // --- Event handlers --------------------------------------------------------
128
+
129
+ function handleInit(evt, constructorModel) {
130
+ return [{
131
+ type: 'system',
132
+ subtype: 'init',
133
+ session_id: evt.session_id || `gemini-${Date.now()}`,
134
+ model: evt.model || constructorModel || undefined,
135
+ }];
136
+ }
137
+
138
+ function handleMessage(evt, state) {
139
+ // Ignore user message echoes
140
+ if (evt.role === 'user') return [];
141
+
142
+ if (evt.role === 'assistant') {
143
+ const text = evt.content || '';
144
+
145
+ // Delta (streaming partial) — accumulate for later persistence AND emit for live streaming
146
+ if (evt.delta) {
147
+ state.appendDeltaText(text);
148
+ return [{
149
+ type: 'stream_event',
150
+ event: {
151
+ type: 'content_block_delta',
152
+ delta: { type: 'text_delta', text },
153
+ },
154
+ }];
155
+ }
156
+
157
+ // Full message (non-delta) replaces any prior streamed delta accumulation.
158
+ // Some Gemini streams include both live deltas and a final full message; only
159
+ // the full message should be persisted in that mixed case.
160
+ state.clearAccumulatedText();
161
+ return [{
162
+ type: 'assistant',
163
+ message: { content: [{ type: 'text', text }] },
164
+ }];
165
+ }
166
+
167
+ return [];
168
+ }
169
+
170
+ function handleToolUse(evt, state) {
171
+ const toolName = evt.tool_name || 'unknown';
172
+ const toolId = evt.tool_id || `tool-${Date.now()}`;
173
+ const parameters = evt.parameters || {};
174
+
175
+ // Track for matching tool_result back
176
+ state.pendingToolUse.set(toolId, toolName);
177
+
178
+ return [{
179
+ type: 'tool_result',
180
+ tool_name: toolName,
181
+ content: JSON.stringify(parameters),
182
+ }];
183
+ }
184
+
185
+ function handleToolResult(evt, state) {
186
+ const toolId = evt.tool_id || '';
187
+ const toolName = state.pendingToolUse.get(toolId) || 'unknown';
188
+ const output = evt.output || '';
189
+
190
+ return [{
191
+ type: 'tool_result',
192
+ tool_name: toolName,
193
+ content: output,
194
+ }];
195
+ }
@@ -5,10 +5,11 @@ import { normalizeCommitAttributionOverride } from '../../../shared/src/contract
5
5
 
6
6
  /**
7
7
  * Valid values for `providers.kind`. Maps 1:1 to an agent adapter:
8
- * - 'anthropic' 'claude-code'
8
+ * - 'anthropic' ��� 'claude-code'
9
9
  * - 'openai' → 'codex'
10
+ * - 'google' → 'gemini'
10
11
  */
11
- export const PROVIDER_KINDS = Object.freeze(['anthropic', 'openai']);
12
+ export const PROVIDER_KINDS = Object.freeze(['anthropic', 'openai', 'google']);
12
13
 
13
14
  /**
14
15
  * Mapping from provider kind to the agent adapter that should drive sessions
@@ -17,6 +18,7 @@ export const PROVIDER_KINDS = Object.freeze(['anthropic', 'openai']);
17
18
  export const AGENT_TYPE_BY_KIND = Object.freeze({
18
19
  anthropic: 'claude-code',
19
20
  openai: 'codex',
21
+ google: 'gemini',
20
22
  });
21
23
 
22
24
  const BUILT_IN_MUTABLE_FIELDS = Object.freeze(['commitAttributionOverride']);
@@ -255,6 +255,15 @@ export const allMigrations = validateMigrations([
255
255
  // --- Update built-in Opus model to 4.7 ---
256
256
  pr.get('providers-update-built-in-opus-4-7'),
257
257
 
258
+ // --- Widen providers kind CHECK constraint to include 'google' ---
259
+ pr.get('providers-widen-kind-check-google'),
260
+
261
+ // --- Seed built-in Google provider + Gemini models ---
262
+ pr.get('providers-seed-built-in-google'),
263
+
264
+ // --- Update expired Gemini Flash Lite preview model to stable GA ---
265
+ pr.get('providers-update-gemini-flash-lite-model'),
266
+
258
267
  // --- Project session defaults: add 'current' git mode ---
259
268
  p.get('project_session_defaults-git_mode-add-current'),
260
269