dr-claw 1.1.2 → 1.1.4

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 (113) hide show
  1. package/README.md +286 -177
  2. package/README.zh-CN.md +286 -174
  3. package/dist/assets/{_basePickBy-C0n1uo4u.js → _basePickBy-7CEpiVqc.js} +1 -1
  4. package/dist/assets/{_baseUniq-BRNmhFkc.js → _baseUniq-yh8GoKah.js} +1 -1
  5. package/dist/assets/{arc-DOMBmC_A.js → arc-xxYot-So.js} +1 -1
  6. package/dist/assets/{architectureDiagram-VXUJARFQ-CtMWglbD.js → architectureDiagram-VXUJARFQ-Bb1NqOFB.js} +1 -1
  7. package/dist/assets/{blockDiagram-VD42YOAC-DK_gTkJy.js → blockDiagram-VD42YOAC-D8xMHk8C.js} +1 -1
  8. package/dist/assets/{c4Diagram-YG6GDRKO-DrDAvsn7.js → c4Diagram-YG6GDRKO-69UiBMVa.js} +1 -1
  9. package/dist/assets/channel-BJdHHkhR.js +1 -0
  10. package/dist/assets/{chunk-4BX2VUAB-CGbhdZcN.js → chunk-4BX2VUAB-DXrPhE6K.js} +1 -1
  11. package/dist/assets/{chunk-55IACEB6-sGeFSxen.js → chunk-55IACEB6-B3sw4AGX.js} +1 -1
  12. package/dist/assets/{chunk-B4BG7PRW-RxgHAU_P.js → chunk-B4BG7PRW-Dd4n17kR.js} +1 -1
  13. package/dist/assets/{chunk-DI55MBZ5-CvHUXZrx.js → chunk-DI55MBZ5-DuTb9gb8.js} +1 -1
  14. package/dist/assets/{chunk-FMBD7UC4-Sn7cXVNU.js → chunk-FMBD7UC4-CGErQ-ud.js} +1 -1
  15. package/dist/assets/{chunk-QN33PNHL-bDKOHppN.js → chunk-QN33PNHL-Bk5fuYbR.js} +1 -1
  16. package/dist/assets/{chunk-QZHKN3VN-B9kN5LGm.js → chunk-QZHKN3VN-2UdZTbge.js} +1 -1
  17. package/dist/assets/{chunk-TZMSLE5B-CCBe6len.js → chunk-TZMSLE5B-Bl-UYKEK.js} +1 -1
  18. package/dist/assets/{classDiagram-2ON5EDUG-DQoMNYlY.js → classDiagram-2ON5EDUG-EaoBDWqJ.js} +1 -1
  19. package/dist/assets/{classDiagram-v2-WZHVMYZB-DQoMNYlY.js → classDiagram-v2-WZHVMYZB-EaoBDWqJ.js} +1 -1
  20. package/dist/assets/clone-CwXTbwxl.js +1 -0
  21. package/dist/assets/{cose-bilkent-S5V4N54A-r6sLiHBW.js → cose-bilkent-S5V4N54A-qXNKLX2R.js} +1 -1
  22. package/dist/assets/{dagre-6UL2VRFP-AbsaR1Oa.js → dagre-6UL2VRFP-DmHvDWep.js} +1 -1
  23. package/dist/assets/{diagram-PSM6KHXK-DjCvdUyy.js → diagram-PSM6KHXK-C_Rj4DX3.js} +1 -1
  24. package/dist/assets/{diagram-QEK2KX5R-CqiocXkH.js → diagram-QEK2KX5R-BVr3mPXW.js} +1 -1
  25. package/dist/assets/{diagram-S2PKOQOG-D5ALeoII.js → diagram-S2PKOQOG-DVx6i0Qe.js} +1 -1
  26. package/dist/assets/{erDiagram-Q2GNP2WA-C1AeOsiz.js → erDiagram-Q2GNP2WA-BJQyv9_t.js} +1 -1
  27. package/dist/assets/{flowDiagram-NV44I4VS-C98ve_DO.js → flowDiagram-NV44I4VS-DCR8sPAE.js} +1 -1
  28. package/dist/assets/{ganttDiagram-JELNMOA3-3zdLvcxD.js → ganttDiagram-JELNMOA3-BcXFAthK.js} +1 -1
  29. package/dist/assets/{gitGraphDiagram-V2S2FVAM-nDuDMsHh.js → gitGraphDiagram-V2S2FVAM-CnS56w1x.js} +1 -1
  30. package/dist/assets/{graph-Blm-emkR.js → graph-DGtZXuDY.js} +1 -1
  31. package/dist/assets/index-C-sL-ag0.css +1 -0
  32. package/dist/assets/index-VdqxIFPL.js +670 -0
  33. package/dist/assets/{infoDiagram-HS3SLOUP-CmzE3q0R.js → infoDiagram-HS3SLOUP-DtI272ZD.js} +1 -1
  34. package/dist/assets/{journeyDiagram-XKPGCS4Q-CYaW7LrP.js → journeyDiagram-XKPGCS4Q-DHp_Kica.js} +1 -1
  35. package/dist/assets/{kanban-definition-3W4ZIXB7-mkmW7p0j.js → kanban-definition-3W4ZIXB7-CjgHMNku.js} +1 -1
  36. package/dist/assets/{layout-Ct2kFIId.js → layout-Cnc-DCSb.js} +1 -1
  37. package/dist/assets/{linear-CKG9EwIJ.js → linear-BtRgmV19.js} +1 -1
  38. package/dist/assets/{mermaid.core-CaKcUnnd.js → mermaid.core-B20uzv6N.js} +5 -5
  39. package/dist/assets/{mindmap-definition-VGOIOE7T-Bct-pYin.js → mindmap-definition-VGOIOE7T-bbcLXkWp.js} +1 -1
  40. package/dist/assets/{pieDiagram-ADFJNKIX-C8wFD4Jr.js → pieDiagram-ADFJNKIX-B4fEzWKO.js} +1 -1
  41. package/dist/assets/{quadrantDiagram-AYHSOK5B-CuiE_f46.js → quadrantDiagram-AYHSOK5B-B5G0SMbK.js} +1 -1
  42. package/dist/assets/{requirementDiagram-UZGBJVZJ-C4v1CqU8.js → requirementDiagram-UZGBJVZJ-DsqtOFgj.js} +1 -1
  43. package/dist/assets/{sankeyDiagram-TZEHDZUN-keAqqs-Y.js → sankeyDiagram-TZEHDZUN-CiGemFRE.js} +1 -1
  44. package/dist/assets/{sequenceDiagram-WL72ISMW-ClNbvKTB.js → sequenceDiagram-WL72ISMW-hEMvYbe9.js} +1 -1
  45. package/dist/assets/{stateDiagram-FKZM4ZOC-D8We-2vq.js → stateDiagram-FKZM4ZOC-Cn8VB7b1.js} +1 -1
  46. package/dist/assets/stateDiagram-v2-4FDKWEC3-DaH0PZOq.js +1 -0
  47. package/dist/assets/{timeline-definition-IT6M3QCI-DsvEMT5T.js → timeline-definition-IT6M3QCI-DChT1GUr.js} +1 -1
  48. package/dist/assets/{treemap-GDKQZRPO-TJrzN7Bj.js → treemap-GDKQZRPO-Dflv6a8z.js} +1 -1
  49. package/dist/assets/{xychartDiagram-PRI3JC2R-BdATmY5F.js → xychartDiagram-PRI3JC2R-Dr4urXR8.js} +1 -1
  50. package/dist/icons/nanoclaude.png +0 -0
  51. package/dist/index.html +2 -2
  52. package/dist/screenshots/screenshot_v1_2.png +0 -0
  53. package/package.json +61 -4
  54. package/public/icons/nanoclaude.png +0 -0
  55. package/public/screenshots/screenshot_v1_2.png +0 -0
  56. package/scripts/build-electron-icons.mjs +40 -0
  57. package/scripts/check-deps.js +28 -0
  58. package/scripts/native-runtime.mjs +128 -0
  59. package/server/__tests__/gemini-api.test.mjs +699 -0
  60. package/server/__tests__/gemini-session-index.test.mjs +95 -0
  61. package/server/__tests__/project-sync-dedup.test.mjs +173 -0
  62. package/server/__tests__/session-delete.test.mjs +152 -0
  63. package/server/claude-sdk.js +175 -14
  64. package/server/cli-chat.js +28 -17
  65. package/server/cli.js +48 -11
  66. package/server/data/news-log-arxiv.json +1 -0
  67. package/server/data/news-log-huggingface.json +1 -0
  68. package/server/data/news-results-arxiv.json +460 -0
  69. package/server/data/news-results-huggingface.json +817 -0
  70. package/server/data/research_interests_arxiv.json +50 -0
  71. package/server/data/research_interests_huggingface.json +4 -0
  72. package/server/database/db.js +101 -5
  73. package/server/database/init.sql +17 -1
  74. package/server/gemini-api.js +1838 -0
  75. package/server/gemini-cli.js +13 -2
  76. package/server/index.js +94 -16
  77. package/server/load-env.js +13 -1
  78. package/server/local-gpu.js +166 -24
  79. package/server/mcp-compute.js +223 -0
  80. package/server/middleware/auth.js +25 -6
  81. package/server/nano-claude-code.js +340 -0
  82. package/server/nanoSessionPaths.js +41 -0
  83. package/server/openai-codex.js +6 -2
  84. package/server/openrouter.js +37 -22
  85. package/server/projects.js +516 -110
  86. package/server/routes/agent.js +32 -7
  87. package/server/routes/auto-research.js +1 -1
  88. package/server/routes/claude-btw.js +55 -0
  89. package/server/routes/cli-auth.js +422 -58
  90. package/server/routes/commands.js +25 -1
  91. package/server/routes/community-tools.js +398 -0
  92. package/server/routes/compute.js +155 -0
  93. package/server/routes/git.js +9 -11
  94. package/server/routes/memory.js +143 -0
  95. package/server/routes/news.js +3 -0
  96. package/server/routes/skills.js +35 -0
  97. package/server/routes/user.js +3 -5
  98. package/server/utils/__tests__/safePath.test.js +93 -0
  99. package/server/utils/cliResolution.js +13 -1
  100. package/server/utils/codexSessionMessages.js +285 -0
  101. package/server/utils/geminiOAuth.js +333 -0
  102. package/server/utils/memoryPrompt.js +78 -0
  103. package/server/utils/safePath.js +51 -0
  104. package/server/utils/sessionIndex.js +2 -0
  105. package/server/utils/skillExpander.js +142 -0
  106. package/server/utils/spawnAsync.js +78 -0
  107. package/shared/modelConstants.js +19 -1
  108. package/dist/assets/channel-BFOE7OyC.js +0 -1
  109. package/dist/assets/clone-BulQSblH.js +0 -1
  110. package/dist/assets/index-B8Yutsnm.css +0 -1
  111. package/dist/assets/index-Cf6gyhc3.js +0 -654
  112. package/dist/assets/stateDiagram-v2-4FDKWEC3-BUURfjrE.js +0 -1
  113. package/scripts/install-python-tools.js +0 -64
@@ -0,0 +1,1838 @@
1
+ import crypto from 'crypto';
2
+ import { promises as fs } from 'fs';
3
+ import path from 'path';
4
+ import os from 'os';
5
+ import { exec, execFile } from 'child_process';
6
+ import { promisify } from 'util';
7
+ import { encodeProjectPath, ensureProjectSkillLinks, reconcileGeminiSessionIndex } from './projects.js';
8
+ import { writeProjectTemplates } from './templates/index.js';
9
+ import { classifyError } from '../shared/errorClassifier.js';
10
+ import { applyStageTagsToSession, recordIndexedSession } from './utils/sessionIndex.js';
11
+ import { createRequestId, waitForToolApproval, matchesToolPermission } from './utils/permissions.js';
12
+ import { getGeminiAuthHeaders } from './utils/geminiOAuth.js';
13
+ import { buildGeminiThinkingConfig } from '../shared/geminiThinkingSupport.js';
14
+ import { spawnGemini } from './gemini-cli.js';
15
+
16
+ const execAsync = promisify(exec);
17
+ const execFileAsync = promisify(execFile);
18
+
19
+ export const GEMINI_API_BASE = 'https://generativelanguage.googleapis.com/v1beta';
20
+ const CODE_ASSIST_BASE = 'https://cloudcode-pa.googleapis.com/v1internal';
21
+ const MAX_AGENT_TURNS = 30;
22
+ const BASH_TIMEOUT_MS = 120_000;
23
+ const MAX_OUTPUT_CHARS = 100_000;
24
+ const DEFAULT_CONTEXT_WINDOW = parseInt(process.env.GEMINI_CONTEXT_WINDOW || process.env.CONTEXT_WINDOW || '2000000', 10);
25
+ export const SYNTHETIC_THOUGHT_SIGNATURE = 'skip_thought_signature_validator';
26
+ const RETRYABLE_HTTP_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);
27
+ const GEMINI_PROJECT_ROOT_MARKER = '.project_root';
28
+ const GEMINI_CLI_SESSION_FILE_PREFIX = 'session-';
29
+ const GEMINI_MODEL_FALLBACK_CHAIN = [
30
+ 'gemini-3-flash-preview',
31
+ 'gemini-2.5-flash',
32
+ 'gemini-2.5-flash-lite',
33
+ ];
34
+
35
+ const activeGeminiApiSessions = new Map();
36
+ const codeAssistProjectCache = new Map();
37
+ const CODE_ASSIST_CACHE_TTL_MS = 30 * 60 * 1000; // 30 minutes
38
+
39
+ export const GEMINI_TOOL_DECLARATIONS = [
40
+ {
41
+ name: 'read_file',
42
+ description: 'Read the contents of a file at the given path. Returns numbered lines.',
43
+ parameters: {
44
+ type: 'OBJECT',
45
+ properties: {
46
+ path: { type: 'STRING', description: 'Absolute or project-relative file path.' },
47
+ file_path: { type: 'STRING', description: 'Alias for path.' },
48
+ offset: { type: 'INTEGER', description: 'Start line (1-indexed).' },
49
+ limit: { type: 'INTEGER', description: 'Max lines to return.' },
50
+ },
51
+ },
52
+ },
53
+ {
54
+ name: 'read_many_files',
55
+ description: 'Read multiple files and return their contents in a single response.',
56
+ parameters: {
57
+ type: 'OBJECT',
58
+ properties: {
59
+ paths: {
60
+ type: 'ARRAY',
61
+ items: { type: 'STRING' },
62
+ description: 'Absolute or project-relative file paths.',
63
+ },
64
+ },
65
+ required: ['paths'],
66
+ },
67
+ },
68
+ {
69
+ name: 'write_file',
70
+ description: 'Create or overwrite a file with the given content.',
71
+ parameters: {
72
+ type: 'OBJECT',
73
+ properties: {
74
+ path: { type: 'STRING', description: 'File path.' },
75
+ file_path: { type: 'STRING', description: 'Alias for path.' },
76
+ content: { type: 'STRING', description: 'Full file content to write.' },
77
+ },
78
+ required: ['content'],
79
+ },
80
+ },
81
+ {
82
+ name: 'replace',
83
+ description: 'Replace an exact substring in a file. old_string must appear exactly once.',
84
+ parameters: {
85
+ type: 'OBJECT',
86
+ properties: {
87
+ path: { type: 'STRING', description: 'File path.' },
88
+ file_path: { type: 'STRING', description: 'Alias for path.' },
89
+ old_str: { type: 'STRING', description: 'Exact text to find.' },
90
+ new_str: { type: 'STRING', description: 'Replacement text.' },
91
+ old_string: { type: 'STRING', description: 'Alias for old_str.' },
92
+ new_string: { type: 'STRING', description: 'Alias for new_str.' },
93
+ },
94
+ },
95
+ },
96
+ {
97
+ name: 'run_shell_command',
98
+ description: 'Execute a shell command. Use for git, npm, python, curl, etc.',
99
+ parameters: {
100
+ type: 'OBJECT',
101
+ properties: {
102
+ command: { type: 'STRING', description: 'Shell command to run.' },
103
+ cmd: { type: 'STRING', description: 'Alias for command.' },
104
+ },
105
+ },
106
+ },
107
+ {
108
+ name: 'glob',
109
+ description: 'Find files matching a glob pattern.',
110
+ parameters: {
111
+ type: 'OBJECT',
112
+ properties: {
113
+ pattern: { type: 'STRING', description: 'Glob pattern, e.g. "**/*.ts".' },
114
+ path: { type: 'STRING', description: 'Directory to search.' },
115
+ },
116
+ required: ['pattern'],
117
+ },
118
+ },
119
+ {
120
+ name: 'grep_search',
121
+ description: 'Search file contents for a regex pattern. Returns matching lines.',
122
+ parameters: {
123
+ type: 'OBJECT',
124
+ properties: {
125
+ pattern: { type: 'STRING', description: 'Regex pattern.' },
126
+ path: { type: 'STRING', description: 'File or directory to search.' },
127
+ include: { type: 'STRING', description: 'Optional file glob filter.' },
128
+ },
129
+ required: ['pattern'],
130
+ },
131
+ },
132
+ {
133
+ name: 'list_directory',
134
+ description: 'List directory contents with file types.',
135
+ parameters: {
136
+ type: 'OBJECT',
137
+ properties: {
138
+ path: { type: 'STRING', description: 'Directory path.' },
139
+ dir_path: { type: 'STRING', description: 'Alias for path.' },
140
+ },
141
+ },
142
+ },
143
+ {
144
+ name: 'web_fetch',
145
+ description: 'Fetch the text content of a URL.',
146
+ parameters: {
147
+ type: 'OBJECT',
148
+ properties: {
149
+ url: { type: 'STRING', description: 'URL to fetch.' },
150
+ },
151
+ required: ['url'],
152
+ },
153
+ },
154
+ {
155
+ name: 'google_web_search',
156
+ description: 'Search the web using a query. Returns summarized results.',
157
+ parameters: {
158
+ type: 'OBJECT',
159
+ properties: {
160
+ query: { type: 'STRING', description: 'Search query.' },
161
+ },
162
+ required: ['query'],
163
+ },
164
+ },
165
+ {
166
+ name: 'write_todos',
167
+ description: 'Create or update a structured task list for tracking progress.',
168
+ parameters: {
169
+ type: 'OBJECT',
170
+ properties: {
171
+ todos: {
172
+ type: 'ARRAY',
173
+ description: 'Array of TODO items.',
174
+ items: {
175
+ type: 'OBJECT',
176
+ properties: {
177
+ id: { type: 'STRING' },
178
+ content: { type: 'STRING' },
179
+ status: { type: 'STRING', enum: ['pending', 'in_progress', 'completed'] },
180
+ },
181
+ required: ['content', 'status'],
182
+ },
183
+ },
184
+ },
185
+ required: ['todos'],
186
+ },
187
+ },
188
+ ];
189
+
190
+ export const TOOL_NAME_MAP = {
191
+ read_file: 'Read',
192
+ read_many_files: 'Read',
193
+ write_file: 'Write',
194
+ replace: 'Edit',
195
+ run_shell_command: 'Bash',
196
+ glob: 'Glob',
197
+ grep_search: 'Grep',
198
+ list_directory: 'LS',
199
+ web_fetch: 'WebFetch',
200
+ google_web_search: 'WebSearch',
201
+ write_todos: 'TodoWrite',
202
+ };
203
+
204
+ export const READ_ONLY_TOOLS = new Set([
205
+ 'read_file',
206
+ 'read_many_files',
207
+ 'glob',
208
+ 'grep_search',
209
+ 'list_directory',
210
+ 'google_web_search',
211
+ ]);
212
+
213
+ function sendMessage(ws, data) {
214
+ try {
215
+ if (ws?.isSSEStreamWriter || ws?.isWebSocketWriter) {
216
+ ws.send(data);
217
+ } else if (typeof ws?.send === 'function') {
218
+ ws.send(JSON.stringify(data));
219
+ }
220
+ } catch (error) {
221
+ console.error('[GeminiAPI] Error sending message:', error.message);
222
+ }
223
+ }
224
+
225
+ function truncateOutput(text) {
226
+ if (text.length <= MAX_OUTPUT_CHARS) return text;
227
+ return `${text.slice(0, MAX_OUTPUT_CHARS)}\n…(truncated, ${text.length} total chars)`;
228
+ }
229
+
230
+ function resolveToolPath(workingDir, candidate) {
231
+ if (!candidate) return workingDir;
232
+ const resolved = path.isAbsolute(candidate) ? path.resolve(candidate) : path.resolve(workingDir, candidate);
233
+ if (!resolved.startsWith(workingDir + path.sep) && resolved !== workingDir) {
234
+ throw new Error(`Path "${candidate}" resolves outside project directory`);
235
+ }
236
+ return resolved;
237
+ }
238
+
239
+ function getFileArg(args = {}) {
240
+ return args.path || args.file_path;
241
+ }
242
+
243
+ function getDirectoryArg(args = {}) {
244
+ return args.path || args.dir_path;
245
+ }
246
+
247
+ function getCommandArg(args = {}) {
248
+ return args.command || args.cmd || '';
249
+ }
250
+
251
+ function normalizeToolArgs(args) {
252
+ if (!args || typeof args !== 'object') return {};
253
+ return args;
254
+ }
255
+
256
+ async function executeTool(name, rawArgs, workingDir) {
257
+ const args = normalizeToolArgs(rawArgs);
258
+
259
+ try {
260
+ switch (name) {
261
+ case 'read_file': {
262
+ const content = await fs.readFile(resolveToolPath(workingDir, getFileArg(args)), 'utf-8');
263
+ const lines = content.split('\n');
264
+ const start = Math.max(0, (args.offset || 1) - 1);
265
+ const end = args.limit ? start + args.limit : lines.length;
266
+ return truncateOutput(
267
+ lines
268
+ .slice(start, end)
269
+ .map((line, index) => `${String(start + index + 1).padStart(6)}|${line}`)
270
+ .join('\n'),
271
+ );
272
+ }
273
+
274
+ case 'read_many_files': {
275
+ const paths = Array.isArray(args.paths) ? args.paths : [];
276
+ if (paths.length === 0) {
277
+ return 'Error: paths must be a non-empty array';
278
+ }
279
+
280
+ const results = await Promise.all(paths.slice(0, 20).map(async (candidate) => {
281
+ try {
282
+ const resolved = resolveToolPath(workingDir, candidate);
283
+ const content = await fs.readFile(resolved, 'utf-8');
284
+ return `==> ${candidate} <==\n${content}`;
285
+ } catch (fileError) {
286
+ return `==> ${candidate} <==\nError: ${fileError.message}`;
287
+ }
288
+ }));
289
+ return truncateOutput(results.join('\n\n'));
290
+ }
291
+
292
+ case 'write_file': {
293
+ const filePath = resolveToolPath(workingDir, getFileArg(args));
294
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
295
+ await fs.writeFile(filePath, args.content || '', 'utf-8');
296
+ return `Wrote ${(args.content || '').length} chars → ${getFileArg(args)}`;
297
+ }
298
+
299
+ case 'replace': {
300
+ const filePath = resolveToolPath(workingDir, getFileArg(args));
301
+ const oldString = args.old_str ?? args.old_string;
302
+ const newString = args.new_str ?? args.new_string ?? '';
303
+ if (!oldString) {
304
+ return 'Error: replace requires old_str or old_string';
305
+ }
306
+
307
+ const source = await fs.readFile(filePath, 'utf-8');
308
+ const count = source.split(oldString).length - 1;
309
+ if (count === 0) return `Error: old_str not found in ${getFileArg(args)}`;
310
+ if (count > 1) return `Error: old_str matches ${count} times — add more context to make it unique`;
311
+ await fs.writeFile(filePath, source.replace(oldString, newString), 'utf-8');
312
+ return `Edited ${getFileArg(args)}`;
313
+ }
314
+
315
+ case 'run_shell_command': {
316
+ try {
317
+ const { stdout, stderr } = await execAsync(getCommandArg(args), {
318
+ cwd: workingDir,
319
+ timeout: BASH_TIMEOUT_MS,
320
+ maxBuffer: 5 * 1024 * 1024,
321
+ env: { ...process.env, HOME: os.homedir() },
322
+ });
323
+ let output = stdout || '';
324
+ if (stderr) output += `${output ? '\n' : ''}STDERR:\n${stderr}`;
325
+ return truncateOutput(output || '(no output)');
326
+ } catch (error) {
327
+ return truncateOutput(
328
+ `Exit code ${error.code ?? 1}\n${error.stdout || ''}${error.stderr ? `\nSTDERR:\n${error.stderr}` : ''}\n${error.message}`,
329
+ );
330
+ }
331
+ }
332
+
333
+ case 'glob': {
334
+ const searchDir = resolveToolPath(workingDir, args.path);
335
+ const pattern = String(args.pattern || '');
336
+ try {
337
+ const { stdout } = await execFileAsync(
338
+ 'rg', ['--files', '--glob', pattern],
339
+ { cwd: searchDir, timeout: 30_000, maxBuffer: 1024 * 1024 },
340
+ );
341
+ const lines = stdout.split('\n').filter(Boolean).slice(0, 300);
342
+ return truncateOutput(lines.join('\n') || '(no matches)');
343
+ } catch {
344
+ try {
345
+ const { stdout } = await execFileAsync(
346
+ 'find', ['.', '-name', pattern, '-type', 'f'],
347
+ { cwd: searchDir, timeout: 30_000, maxBuffer: 1024 * 1024 },
348
+ );
349
+ const lines = stdout.split('\n').filter(Boolean).slice(0, 300);
350
+ return truncateOutput(lines.join('\n') || '(no matches)');
351
+ } catch {
352
+ return '(no matches)';
353
+ }
354
+ }
355
+ }
356
+
357
+ case 'grep_search': {
358
+ const target = resolveToolPath(workingDir, args.path);
359
+ const rgArgs = ['--line-number', '--max-count', '100', '--max-columns', '200'];
360
+ if (args.include) rgArgs.push('--glob', String(args.include));
361
+ rgArgs.push(String(args.pattern || ''), target);
362
+ try {
363
+ const { stdout } = await execFileAsync('rg', rgArgs, {
364
+ cwd: workingDir,
365
+ timeout: 30_000,
366
+ maxBuffer: 2 * 1024 * 1024,
367
+ });
368
+ return truncateOutput(stdout || '(no matches)');
369
+ } catch (error) {
370
+ if (error.code === 1) return '(no matches)';
371
+ return `Error: ${error.message}`;
372
+ }
373
+ }
374
+
375
+ case 'list_directory': {
376
+ const entries = await fs.readdir(resolveToolPath(workingDir, getDirectoryArg(args)), { withFileTypes: true });
377
+ return entries.map((entry) => `${entry.isDirectory() ? 'd' : 'f'} ${entry.name}`).join('\n') || '(empty)';
378
+ }
379
+
380
+ case 'web_fetch': {
381
+ const fetchUrl = new URL(String(args.url || ''));
382
+ const hostname = fetchUrl.hostname.toLowerCase();
383
+ if (
384
+ hostname === 'localhost' ||
385
+ hostname === '127.0.0.1' ||
386
+ hostname === '::1' ||
387
+ hostname === '0.0.0.0' ||
388
+ hostname.endsWith('.local') ||
389
+ hostname.startsWith('169.254.') ||
390
+ hostname.startsWith('10.') ||
391
+ hostname.startsWith('192.168.') ||
392
+ /^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(hostname) ||
393
+ fetchUrl.protocol === 'file:'
394
+ ) {
395
+ return 'Error: URL points to a private/internal network address (blocked)';
396
+ }
397
+ const response = await fetch(fetchUrl.href, {
398
+ headers: { 'User-Agent': 'Dr. Claw Gemini API Agent' },
399
+ signal: AbortSignal.timeout(30_000),
400
+ redirect: 'manual',
401
+ });
402
+ return truncateOutput(await response.text());
403
+ }
404
+
405
+ case 'google_web_search': {
406
+ const encodedQuery = encodeURIComponent(String(args.query || ''));
407
+ try {
408
+ const response = await fetch(`https://html.duckduckgo.com/html/?q=${encodedQuery}`, {
409
+ headers: { 'User-Agent': 'Dr. Claw Gemini API Agent' },
410
+ signal: AbortSignal.timeout(15_000),
411
+ });
412
+ const html = await response.text();
413
+ const results = [];
414
+ const titleRegex = /<a rel="nofollow" class="result__a" href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/g;
415
+ let match;
416
+ while ((match = titleRegex.exec(html)) !== null && results.length < 8) {
417
+ results.push({
418
+ url: match[1],
419
+ title: match[2].replace(/<[^>]+>/g, '').trim(),
420
+ });
421
+ }
422
+ const snippetRegex = /<a class="result__snippet"[^>]*>([\s\S]*?)<\/a>/g;
423
+ let index = 0;
424
+ while ((match = snippetRegex.exec(html)) !== null && index < results.length) {
425
+ results[index].snippet = match[1].replace(/<[^>]+>/g, '').trim();
426
+ index += 1;
427
+ }
428
+ if (results.length === 0) return '(no results found)';
429
+ return results
430
+ .map((result, resultIndex) => `${resultIndex + 1}. ${result.title}\n ${result.url}\n ${result.snippet || ''}`)
431
+ .join('\n\n');
432
+ } catch (error) {
433
+ return `Search error: ${error.message}`;
434
+ }
435
+ }
436
+
437
+ case 'write_todos': {
438
+ const todos = Array.isArray(args.todos) ? args.todos : [];
439
+ return `TODO list updated:\n${todos.map((todo) => `- [${todo.status}] ${todo.content}`).join('\n')}`;
440
+ }
441
+
442
+ default:
443
+ return `Unknown tool: ${name}`;
444
+ }
445
+ } catch (error) {
446
+ return `Error executing ${name}: ${error.message}`;
447
+ }
448
+ }
449
+
450
+ function permissionMatches(entry, toolName, toolArgs) {
451
+ const mappedName = TOOL_NAME_MAP[toolName] || toolName;
452
+ return matchesToolPermission(entry, toolName, toolArgs) || matchesToolPermission(entry, mappedName, toolArgs);
453
+ }
454
+
455
+ async function checkToolPermission(toolName, toolArgs, permissionMode, allowedTools, disallowedTools, ws, sessionId, skipPermissions = false) {
456
+ if (permissionMode === 'bypassPermissions' || skipPermissions) return true;
457
+
458
+ if (disallowedTools.some((entry) => permissionMatches(entry, toolName, toolArgs))) return false;
459
+
460
+ if (permissionMode === 'plan') return READ_ONLY_TOOLS.has(toolName);
461
+
462
+ if (permissionMode === 'acceptEdits' && ['write_file', 'replace'].includes(toolName)) return true;
463
+
464
+ if (allowedTools.some((entry) => permissionMatches(entry, toolName, toolArgs))) return true;
465
+
466
+ if (READ_ONLY_TOOLS.has(toolName)) return true;
467
+
468
+ const requestId = createRequestId();
469
+ sendMessage(ws, {
470
+ type: 'claude-permission-request',
471
+ requestId,
472
+ toolName: TOOL_NAME_MAP[toolName] || toolName,
473
+ input: toolArgs,
474
+ sessionId,
475
+ });
476
+
477
+ const decision = await waitForToolApproval(requestId);
478
+ if (!decision || decision.cancelled || !decision.allow) return false;
479
+ if (decision.rememberEntry) allowedTools.push(decision.rememberEntry);
480
+ return true;
481
+ }
482
+
483
+ async function buildSystemPrompt(workingDir) {
484
+ const parts = [
485
+ `You are a powerful agentic AI research assistant. You operate in the project directory: ${workingDir}`,
486
+ '',
487
+ 'You have tools to read and write files, run shell commands, search code, and browse the web.',
488
+ 'Use them proactively to explore the project, gather information, and produce high-quality output.',
489
+ 'Always prefer using tools over guessing about file contents or project structure.',
490
+ '',
491
+ ];
492
+
493
+ try {
494
+ const agentsMd = await fs.readFile(path.join(workingDir, 'AGENTS.md'), 'utf-8');
495
+ if (agentsMd.trim()) parts.push('# Project Instructions (AGENTS.md)\n', agentsMd, '');
496
+ } catch {}
497
+
498
+ try {
499
+ const geminiMd = await fs.readFile(path.join(workingDir, 'GEMINI.md'), 'utf-8');
500
+ if (geminiMd.trim()) parts.push('# Project Instructions (GEMINI.md)\n', geminiMd, '');
501
+ } catch {}
502
+
503
+ try {
504
+ const raw = await fs.readFile(path.join(workingDir, 'instance.json'), 'utf-8');
505
+ const instanceData = JSON.parse(raw);
506
+ if (instanceData.research_topic || instanceData.stages) {
507
+ parts.push('# Project Metadata (instance.json)\n', JSON.stringify(instanceData, null, 2), '');
508
+ }
509
+ } catch {}
510
+
511
+ try {
512
+ const skillsDir = path.join(workingDir, '.agents', 'skills');
513
+ const entries = await fs.readdir(skillsDir).catch(() => []);
514
+ if (entries.length > 0) {
515
+ parts.push(`# Available Skills (${entries.length})\n`);
516
+ for (const entry of entries.slice(0, 10)) {
517
+ try {
518
+ const skillMd = await fs.readFile(path.join(skillsDir, entry, 'SKILL.md'), 'utf-8');
519
+ if (skillMd.trim()) parts.push(`## Skill: ${entry}\n${skillMd.slice(0, 3000)}\n`);
520
+ } catch {
521
+ parts.push(`- ${entry}`);
522
+ }
523
+ }
524
+ parts.push('');
525
+ }
526
+ } catch {}
527
+
528
+ return parts.join('\n');
529
+ }
530
+
531
+ async function sessionsDir() {
532
+ const dir = path.join(os.homedir(), '.gemini', 'sessions');
533
+ await fs.mkdir(dir, { recursive: true });
534
+ return dir;
535
+ }
536
+
537
+ function sanitizeSessionId(sessionId) {
538
+ return String(sessionId || '').replace(/[/\\]/g, '_').replace(/\.\./g, '_');
539
+ }
540
+
541
+ async function sessionFilePath(sessionId) {
542
+ const dir = await sessionsDir();
543
+ return path.join(dir, `${sanitizeSessionId(sessionId)}.jsonl`);
544
+ }
545
+
546
+ async function ensureSessionMetadata(sessionId, { cwd, sessionMode = 'research', summary = null }) {
547
+ if (!sessionId || sessionId.startsWith('new-session-') || sessionId.startsWith('temp-')) return;
548
+
549
+ const filePath = await sessionFilePath(sessionId);
550
+ try {
551
+ await fs.access(filePath);
552
+ return;
553
+ } catch {}
554
+
555
+ const timestamp = new Date().toISOString();
556
+ const entry = {
557
+ type: 'session_meta',
558
+ payload: {
559
+ id: sessionId,
560
+ cwd,
561
+ timestamp,
562
+ sessionMode,
563
+ ...(summary ? { summary, title: summary } : {}),
564
+ },
565
+ cwd,
566
+ timestamp,
567
+ ...(summary ? { summary, title: summary } : {}),
568
+ };
569
+ await fs.writeFile(filePath, `${JSON.stringify(entry)}\n`, 'utf-8');
570
+ }
571
+
572
+ async function appendSession(sessionId, entry) {
573
+ if (!sessionId || sessionId.startsWith('new-session-') || sessionId.startsWith('temp-')) return;
574
+ const filePath = await sessionFilePath(sessionId);
575
+ await fs.appendFile(
576
+ filePath,
577
+ `${JSON.stringify({ ...entry, timestamp: entry.timestamp || new Date().toISOString() })}\n`,
578
+ 'utf-8',
579
+ );
580
+ }
581
+
582
+ async function loadHistory(sessionId) {
583
+ if (!sessionId) return [];
584
+
585
+ try {
586
+ const filePath = await sessionFilePath(sessionId);
587
+ const raw = await fs.readFile(filePath, 'utf-8');
588
+ const contents = [];
589
+ const toolNamesById = new Map();
590
+
591
+ for (const line of raw.trim().split('\n').filter(Boolean)) {
592
+ const entry = JSON.parse(line);
593
+ if (entry.type === 'session_meta') continue;
594
+
595
+ if (entry.type === 'tool_use') {
596
+ const toolName = entry.rawToolName || entry.toolName;
597
+ const toolArgs = entry.toolInput || {};
598
+ const toolCallId = entry.toolCallId || entry.tool_call_id;
599
+ if (toolCallId && toolName) {
600
+ toolNamesById.set(toolCallId, toolName);
601
+ }
602
+ if (toolName) {
603
+ contents.push({
604
+ role: 'model',
605
+ parts: [{ functionCall: { name: toolName, args: toolArgs } }],
606
+ });
607
+ }
608
+ continue;
609
+ }
610
+
611
+ if (entry.type === 'tool_result') {
612
+ const toolCallId = entry.toolCallId || entry.tool_call_id;
613
+ const toolName = entry.toolName || toolNamesById.get(toolCallId);
614
+ if (toolName) {
615
+ contents.push({
616
+ role: 'user',
617
+ parts: [{
618
+ functionResponse: {
619
+ name: toolName,
620
+ response: { result: entry.output || entry.content || '' },
621
+ },
622
+ }],
623
+ });
624
+ }
625
+ continue;
626
+ }
627
+
628
+ const role = entry.role || entry.message?.role;
629
+ const content = entry.content || entry.message?.content;
630
+ if (!role || !content) continue;
631
+
632
+ const text = typeof content === 'string'
633
+ ? content
634
+ : Array.isArray(content)
635
+ ? content.map((part) => part?.text || (typeof part === 'string' ? part : '')).join('\n')
636
+ : '';
637
+
638
+ if (!text.trim()) continue;
639
+
640
+ contents.push({
641
+ role: role === 'assistant' ? 'model' : 'user',
642
+ parts: [{ text }],
643
+ });
644
+ }
645
+
646
+ return contents;
647
+ } catch {
648
+ return [];
649
+ }
650
+ }
651
+
652
+ function slugifyGeminiProjectName(text) {
653
+ return (String(text || 'project')
654
+ .toLowerCase()
655
+ .replace(/[^a-z0-9]/g, '-')
656
+ .replace(/-+/g, '-')
657
+ .replace(/^-|-$/g, '') || 'project');
658
+ }
659
+
660
+ function normalizeGeminiProjectPath(projectPath) {
661
+ return path.resolve(projectPath || process.cwd());
662
+ }
663
+
664
+ async function ensureGeminiCliProjectOwnership(projectIdentifier, projectPath) {
665
+ const normalizedProjectPath = normalizeGeminiProjectPath(projectPath);
666
+ const baseDirs = [
667
+ path.join(os.homedir(), '.gemini', 'tmp'),
668
+ path.join(os.homedir(), '.gemini', 'history'),
669
+ ];
670
+
671
+ for (const baseDir of baseDirs) {
672
+ const projectDir = path.join(baseDir, projectIdentifier);
673
+ const markerPath = path.join(projectDir, GEMINI_PROJECT_ROOT_MARKER);
674
+
675
+ await fs.mkdir(projectDir, { recursive: true });
676
+
677
+ try {
678
+ const owner = (await fs.readFile(markerPath, 'utf8')).trim();
679
+ if (normalizeGeminiProjectPath(owner) !== normalizedProjectPath) {
680
+ throw new Error(`Gemini project identifier ${projectIdentifier} is already owned by ${owner}`);
681
+ }
682
+ } catch (error) {
683
+ if (error?.code === 'ENOENT') {
684
+ await fs.writeFile(markerPath, normalizedProjectPath, { encoding: 'utf8', flag: 'wx' });
685
+ continue;
686
+ }
687
+ throw error;
688
+ }
689
+ }
690
+ }
691
+
692
+ async function ensureGeminiCliProjectIdentifier(projectPath) {
693
+ const normalizedProjectPath = normalizeGeminiProjectPath(projectPath);
694
+ const geminiDir = path.join(os.homedir(), '.gemini');
695
+ const registryPath = path.join(geminiDir, 'projects.json');
696
+
697
+ await fs.mkdir(geminiDir, { recursive: true });
698
+
699
+ let registry = { projects: {} };
700
+ try {
701
+ const raw = await fs.readFile(registryPath, 'utf8');
702
+ const parsed = JSON.parse(raw);
703
+ if (parsed && typeof parsed === 'object' && parsed.projects && typeof parsed.projects === 'object') {
704
+ registry = { projects: parsed.projects };
705
+ }
706
+ } catch {}
707
+
708
+ const existingIdentifier = registry.projects[normalizedProjectPath];
709
+ if (existingIdentifier) {
710
+ await ensureGeminiCliProjectOwnership(existingIdentifier, normalizedProjectPath);
711
+ return existingIdentifier;
712
+ }
713
+
714
+ const baseSlug = slugifyGeminiProjectName(path.basename(normalizedProjectPath) || 'project');
715
+ const usedIdentifiers = new Map(Object.entries(registry.projects || {}));
716
+
717
+ for (let suffix = 0; suffix < 1000; suffix += 1) {
718
+ const candidate = suffix === 0 ? baseSlug : `${baseSlug}-${suffix}`;
719
+ const mappedOwner = Array.from(usedIdentifiers.entries()).find(([, value]) => value === candidate)?.[0];
720
+ if (mappedOwner && normalizeGeminiProjectPath(mappedOwner) !== normalizedProjectPath) {
721
+ continue;
722
+ }
723
+
724
+ try {
725
+ await ensureGeminiCliProjectOwnership(candidate, normalizedProjectPath);
726
+ registry.projects[normalizedProjectPath] = candidate;
727
+ await fs.writeFile(registryPath, `${JSON.stringify(registry, null, 2)}\n`, 'utf8');
728
+ return candidate;
729
+ } catch (error) {
730
+ if (String(error?.message || '').includes('already owned by')) {
731
+ continue;
732
+ }
733
+ throw error;
734
+ }
735
+ }
736
+
737
+ throw new Error(`Unable to claim a Gemini project identifier for ${normalizedProjectPath}`);
738
+ }
739
+
740
+ async function readGeminiSessionEntries(sessionId) {
741
+ if (!sessionId) return [];
742
+
743
+ try {
744
+ const filePath = await sessionFilePath(sessionId);
745
+ const raw = await fs.readFile(filePath, 'utf8');
746
+ return raw
747
+ .split('\n')
748
+ .map((line) => line.trim())
749
+ .filter(Boolean)
750
+ .map((line) => JSON.parse(line));
751
+ } catch {
752
+ return [];
753
+ }
754
+ }
755
+
756
+ async function findGeminiCliConversationFile(chatsDir, sessionId) {
757
+ try {
758
+ const files = await fs.readdir(chatsDir);
759
+ for (const fileName of files) {
760
+ if (!fileName.startsWith(GEMINI_CLI_SESSION_FILE_PREFIX) || !fileName.endsWith('.json')) continue;
761
+ const filePath = path.join(chatsDir, fileName);
762
+ try {
763
+ const conversation = JSON.parse(await fs.readFile(filePath, 'utf8'));
764
+ if (conversation?.sessionId === sessionId) {
765
+ return { filePath, conversation };
766
+ }
767
+ } catch {}
768
+ }
769
+ } catch {}
770
+
771
+ return null;
772
+ }
773
+
774
+ function buildGeminiCliConversation(entries, sessionId, projectIdentifier, model) {
775
+ const conversationMessages = [];
776
+ let firstTimestamp = null;
777
+ let lastTimestamp = null;
778
+ const pendingToolUses = new Map();
779
+
780
+ const rememberTimestamp = (timestamp) => {
781
+ if (!timestamp) return;
782
+ if (!firstTimestamp || timestamp < firstTimestamp) firstTimestamp = timestamp;
783
+ if (!lastTimestamp || timestamp > lastTimestamp) lastTimestamp = timestamp;
784
+ };
785
+
786
+ for (const entry of entries) {
787
+ if (!entry || entry.type === 'session_meta') continue;
788
+
789
+ const timestamp = entry.timestamp || new Date().toISOString();
790
+ rememberTimestamp(timestamp);
791
+
792
+ if (entry.type === 'message') {
793
+ const role = entry.role || entry.message?.role;
794
+ const content = entry.content || entry.message?.content;
795
+ if (!role || content == null) continue;
796
+
797
+ if (role === 'user') {
798
+ conversationMessages.push({
799
+ id: crypto.randomUUID(),
800
+ timestamp,
801
+ type: 'user',
802
+ content,
803
+ });
804
+ } else if (role === 'assistant' || role === 'model') {
805
+ conversationMessages.push({
806
+ id: crypto.randomUUID(),
807
+ timestamp,
808
+ type: 'gemini',
809
+ content,
810
+ thoughts: [],
811
+ model,
812
+ });
813
+ }
814
+ continue;
815
+ }
816
+
817
+ if (entry.type === 'tool_use') {
818
+ const toolCallId = entry.toolCallId || entry.tool_call_id || crypto.randomUUID();
819
+ pendingToolUses.set(toolCallId, entry);
820
+ continue;
821
+ }
822
+
823
+ if (entry.type === 'tool_result') {
824
+ const toolCallId = entry.toolCallId || entry.tool_call_id || crypto.randomUUID();
825
+ const toolUse = pendingToolUses.get(toolCallId) || {};
826
+ pendingToolUses.delete(toolCallId);
827
+
828
+ conversationMessages.push({
829
+ id: crypto.randomUUID(),
830
+ timestamp,
831
+ type: 'gemini',
832
+ content: '',
833
+ thoughts: [],
834
+ model,
835
+ toolCalls: [{
836
+ id: toolCallId,
837
+ name: entry.toolName || toolUse.toolName || toolUse.rawToolName || 'unknown_tool',
838
+ args: toolUse.toolInput || {},
839
+ result: entry.output || entry.content || '',
840
+ status: entry.isError ? 'error' : 'success',
841
+ timestamp,
842
+ }],
843
+ });
844
+ }
845
+ }
846
+
847
+ if (conversationMessages.length === 0) {
848
+ return null;
849
+ }
850
+
851
+ return {
852
+ sessionId,
853
+ projectHash: projectIdentifier,
854
+ startTime: firstTimestamp || new Date().toISOString(),
855
+ lastUpdated: lastTimestamp || firstTimestamp || new Date().toISOString(),
856
+ messages: conversationMessages,
857
+ kind: 'main',
858
+ };
859
+ }
860
+
861
+ function formatGeminiCliSessionFileName(sessionId, startTime) {
862
+ const safeTimestamp = new Date(startTime || Date.now())
863
+ .toISOString()
864
+ .slice(0, 16)
865
+ .replace(/:/g, '-');
866
+ return `${GEMINI_CLI_SESSION_FILE_PREFIX}${safeTimestamp}-${String(sessionId).slice(0, 8)}.json`;
867
+ }
868
+
869
+ async function bridgeGeminiSessionToCli(sessionId, projectPath, { model } = {}) {
870
+ if (!sessionId || !projectPath) {
871
+ return { ready: false, hasConversationContext: false };
872
+ }
873
+
874
+ const projectIdentifier = await ensureGeminiCliProjectIdentifier(projectPath);
875
+ const chatsDir = path.join(os.homedir(), '.gemini', 'tmp', projectIdentifier, 'chats');
876
+ await fs.mkdir(chatsDir, { recursive: true });
877
+
878
+ const existingConversation = await findGeminiCliConversationFile(chatsDir, sessionId);
879
+ const entries = await readGeminiSessionEntries(sessionId);
880
+ const conversation = buildGeminiCliConversation(entries, sessionId, projectIdentifier, model);
881
+ if (!conversation && existingConversation) {
882
+ return {
883
+ ready: true,
884
+ sessionId,
885
+ chatFilePath: existingConversation.filePath,
886
+ hasConversationContext: Array.isArray(existingConversation.conversation?.messages)
887
+ && existingConversation.conversation.messages.some((message) => message?.type === 'gemini'),
888
+ };
889
+ }
890
+
891
+ if (!conversation) {
892
+ return { ready: false, hasConversationContext: false };
893
+ }
894
+
895
+ const filePath = existingConversation?.filePath
896
+ || path.join(chatsDir, formatGeminiCliSessionFileName(sessionId, conversation.startTime));
897
+ await fs.writeFile(filePath, `${JSON.stringify(conversation, null, 2)}\n`, 'utf8');
898
+
899
+ return {
900
+ ready: true,
901
+ sessionId,
902
+ chatFilePath: filePath,
903
+ hasConversationContext: conversation.messages.some((message) => message?.type === 'gemini'),
904
+ };
905
+ }
906
+
907
+ function buildGeminiCliFallbackPrompt(command, { hasConversationContext = false } = {}) {
908
+ const normalizedCommand = String(command || '').trim();
909
+
910
+ if (hasConversationContext) {
911
+ const prefix = 'Continue the current task from the existing session context. The previous direct API request hit a transient capacity error. Do not restart from scratch or repeat completed work unless necessary.';
912
+ return normalizedCommand
913
+ ? `${prefix}\n\nOriginal user request:\n${normalizedCommand}`
914
+ : prefix;
915
+ }
916
+
917
+ return normalizedCommand || 'Continue the current task.';
918
+ }
919
+
920
+ async function fallbackToGeminiCli(command, options, ws, currentSessionId, requestError) {
921
+ const workingDirectory = options.cwd || options.projectPath || process.cwd();
922
+ const fallbackOptions = {
923
+ ...options,
924
+ cwd: workingDirectory,
925
+ projectPath: workingDirectory,
926
+ };
927
+
928
+ let bridgedSession = { ready: false, hasConversationContext: false };
929
+ try {
930
+ bridgedSession = await bridgeGeminiSessionToCli(currentSessionId, workingDirectory, {
931
+ model: options.model,
932
+ });
933
+ } catch (error) {
934
+ console.warn('[GeminiAPI] Failed to bridge session into Gemini CLI storage:', error.message);
935
+ }
936
+
937
+ const fallbackPrompt = buildGeminiCliFallbackPrompt(command, {
938
+ hasConversationContext: bridgedSession.hasConversationContext,
939
+ });
940
+
941
+ if (bridgedSession.ready) {
942
+ fallbackOptions.sessionId = currentSessionId;
943
+ } else {
944
+ fallbackOptions.sessionId = null;
945
+ }
946
+
947
+ activeGeminiApiSessions.delete(currentSessionId);
948
+
949
+ try {
950
+ console.warn(`[GeminiAPI] ${requestError.errorMessage}. Falling back to Gemini CLI harness.`);
951
+ await spawnGemini(fallbackPrompt, fallbackOptions, ws);
952
+ return true;
953
+ } catch (error) {
954
+ console.error('[GeminiAPI] Gemini CLI fallback failed:', error);
955
+ return false;
956
+ }
957
+ }
958
+
959
+ export function buildRequestBody(model, contents, systemInstruction, thinkingMode, toolDeclarations) {
960
+ const requestBody = {
961
+ systemInstruction,
962
+ contents,
963
+ };
964
+
965
+ if (Array.isArray(toolDeclarations) && toolDeclarations.length > 0) {
966
+ requestBody.tools = [{ functionDeclarations: toolDeclarations }];
967
+ requestBody.toolConfig = { functionCallingConfig: { mode: 'AUTO' } };
968
+ }
969
+
970
+ const thinkingConfig = buildGeminiThinkingConfig(model, thinkingMode);
971
+ if (thinkingConfig) {
972
+ requestBody.generationConfig = { thinkingConfig };
973
+ }
974
+
975
+ return requestBody;
976
+ }
977
+
978
+ export function buildModelTurnParts(text, functionCalls, { requireSyntheticThoughtSignatures = false } = {}) {
979
+ const parts = [];
980
+ if (text) {
981
+ parts.push({ text });
982
+ }
983
+
984
+ for (const [index, functionCall] of (functionCalls || []).entries()) {
985
+ const part = {
986
+ functionCall: {
987
+ name: functionCall.name,
988
+ args: functionCall.args || {},
989
+ },
990
+ };
991
+
992
+ const thoughtSignature = functionCall.thoughtSignature
993
+ || (requireSyntheticThoughtSignatures && index === 0 ? SYNTHETIC_THOUGHT_SIGNATURE : null);
994
+ if (thoughtSignature) {
995
+ part.thoughtSignature = thoughtSignature;
996
+ }
997
+
998
+ parts.push(part);
999
+ }
1000
+
1001
+ return parts;
1002
+ }
1003
+
1004
+ function getCodeAssistCacheKey(authHeaders) {
1005
+ return authHeaders?.Authorization || JSON.stringify(authHeaders || {});
1006
+ }
1007
+
1008
+ function getGeminiApiMaxAttempts() {
1009
+ const parsed = parseInt(process.env.GEMINI_API_MAX_ATTEMPTS || '3', 10);
1010
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 3;
1011
+ }
1012
+
1013
+ function getGeminiApiRetryBaseMs() {
1014
+ const parsed = parseInt(process.env.GEMINI_API_RETRY_BASE_MS || '750', 10);
1015
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : 750;
1016
+ }
1017
+
1018
+ function getGeminiApiRetryMaxMs() {
1019
+ const parsed = parseInt(process.env.GEMINI_API_RETRY_MAX_MS || '4000', 10);
1020
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : 4000;
1021
+ }
1022
+
1023
+ function isRetryableGeminiStatus(status) {
1024
+ return RETRYABLE_HTTP_STATUS.has(status);
1025
+ }
1026
+
1027
+ function parseRetryAfterMs(headers) {
1028
+ if (!headers || typeof headers.get !== 'function') return null;
1029
+
1030
+ const retryAfter = headers.get('retry-after');
1031
+ if (!retryAfter) return null;
1032
+
1033
+ const seconds = Number(retryAfter);
1034
+ if (Number.isFinite(seconds) && seconds >= 0) {
1035
+ return seconds * 1000;
1036
+ }
1037
+
1038
+ const when = Date.parse(retryAfter);
1039
+ if (Number.isFinite(when)) {
1040
+ return Math.max(0, when - Date.now());
1041
+ }
1042
+
1043
+ return null;
1044
+ }
1045
+
1046
+ function resolveRetryDelayMs(attemptIndex, headers) {
1047
+ const retryAfterMs = parseRetryAfterMs(headers);
1048
+ if (retryAfterMs !== null) return retryAfterMs;
1049
+
1050
+ const baseMs = getGeminiApiRetryBaseMs();
1051
+ if (baseMs <= 0) return 0;
1052
+
1053
+ return Math.min(getGeminiApiRetryMaxMs(), baseMs * (2 ** attemptIndex));
1054
+ }
1055
+
1056
+ async function waitForRetryDelay(delayMs, signal) {
1057
+ if (!Number.isFinite(delayMs) || delayMs <= 0) return;
1058
+
1059
+ await new Promise((resolve, reject) => {
1060
+ const timer = setTimeout(() => {
1061
+ signal?.removeEventListener?.('abort', onAbort);
1062
+ resolve();
1063
+ }, delayMs);
1064
+
1065
+ function onAbort() {
1066
+ clearTimeout(timer);
1067
+ signal?.removeEventListener?.('abort', onAbort);
1068
+ reject(new DOMException('The operation was aborted.', 'AbortError'));
1069
+ }
1070
+
1071
+ if (signal?.aborted) {
1072
+ onAbort();
1073
+ return;
1074
+ }
1075
+
1076
+ signal?.addEventListener?.('abort', onAbort, { once: true });
1077
+ });
1078
+ }
1079
+
1080
+ async function executeGeminiRequestWithRetry(requestFn, {
1081
+ signal,
1082
+ operationName,
1083
+ errorFormatter,
1084
+ } = {}) {
1085
+ const maxAttempts = getGeminiApiMaxAttempts();
1086
+
1087
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
1088
+ try {
1089
+ const response = await requestFn();
1090
+ if (response.ok) {
1091
+ return { response };
1092
+ }
1093
+
1094
+ const bodyText = await response.text();
1095
+ const errorMessage = errorFormatter
1096
+ ? errorFormatter(response.status, bodyText)
1097
+ : summarizeGeminiApiError(response.status, bodyText);
1098
+ const classified = classifyError(`${response.status} ${errorMessage} ${bodyText}`);
1099
+ const isRetryable = classified.isRetryable || isRetryableGeminiStatus(response.status);
1100
+ const error = {
1101
+ status: response.status,
1102
+ bodyText,
1103
+ errorMessage,
1104
+ errorType: classified.errorType,
1105
+ isRetryable,
1106
+ };
1107
+
1108
+ if (!isRetryable || attempt === maxAttempts - 1) {
1109
+ return { error };
1110
+ }
1111
+
1112
+ const delayMs = resolveRetryDelayMs(attempt, response.headers);
1113
+ console.warn(
1114
+ `[GeminiAPI] ${operationName || 'request'} failed (${response.status}) ` +
1115
+ `attempt ${attempt + 1}/${maxAttempts}; retrying in ${delayMs}ms`,
1116
+ );
1117
+ await waitForRetryDelay(delayMs, signal);
1118
+ } catch (error) {
1119
+ if (error?.name === 'AbortError') {
1120
+ throw error;
1121
+ }
1122
+
1123
+ const errorMessage = error?.message || 'Network error';
1124
+ const classified = classifyError(errorMessage);
1125
+ const normalizedError = {
1126
+ status: null,
1127
+ bodyText: '',
1128
+ errorMessage,
1129
+ errorType: classified.errorType,
1130
+ isRetryable: classified.isRetryable,
1131
+ };
1132
+
1133
+ if (!normalizedError.isRetryable || attempt === maxAttempts - 1) {
1134
+ return { error: normalizedError };
1135
+ }
1136
+
1137
+ const delayMs = resolveRetryDelayMs(attempt);
1138
+ console.warn(
1139
+ `[GeminiAPI] ${operationName || 'request'} threw before completion ` +
1140
+ `attempt ${attempt + 1}/${maxAttempts}; retrying in ${delayMs}ms: ${errorMessage}`,
1141
+ );
1142
+ await waitForRetryDelay(delayMs, signal);
1143
+ }
1144
+ }
1145
+
1146
+ return {
1147
+ error: {
1148
+ status: null,
1149
+ bodyText: '',
1150
+ errorMessage: `${operationName || 'Request'} failed after retries`,
1151
+ errorType: 'unknown',
1152
+ isRetryable: true,
1153
+ },
1154
+ };
1155
+ }
1156
+
1157
+ function sendGeminiApiError(ws, sessionId, error) {
1158
+ sendMessage(ws, {
1159
+ type: 'gemini-error',
1160
+ error: error.errorMessage,
1161
+ errorType: error.errorType || 'unknown',
1162
+ isRetryable: Boolean(error.isRetryable),
1163
+ details: error.bodyText ? error.bodyText.slice(0, 12000) : undefined,
1164
+ sessionId,
1165
+ });
1166
+ }
1167
+
1168
+ function summarizeCodeAssistBootstrapError(status, bodyText) {
1169
+ try {
1170
+ const parsed = JSON.parse(String(bodyText || '').trim());
1171
+ const message = parsed.error?.message || parsed.message;
1172
+ if (message) return `Code Assist onboarding failed (${status}): ${message}`;
1173
+ } catch {}
1174
+
1175
+ const text = String(bodyText || '').trim();
1176
+ return text
1177
+ ? `Code Assist onboarding failed (${status}): ${text}`
1178
+ : `Code Assist onboarding failed (${status})`;
1179
+ }
1180
+
1181
+ async function loadCodeAssistProject(authHeaders, signal) {
1182
+ const cacheKey = getCodeAssistCacheKey(authHeaders);
1183
+ const cachedEntry = codeAssistProjectCache.get(cacheKey);
1184
+ if (cachedEntry && (Date.now() - cachedEntry.ts) < CODE_ASSIST_CACHE_TTL_MS) {
1185
+ return cachedEntry.projectId;
1186
+ }
1187
+
1188
+ const configuredProject = process.env.GOOGLE_CLOUD_PROJECT || process.env.GOOGLE_CLOUD_PROJECT_ID || undefined;
1189
+ const body = {
1190
+ cloudaicompanionProject: configuredProject,
1191
+ metadata: {
1192
+ ideType: 'IDE_UNSPECIFIED',
1193
+ platform: 'PLATFORM_UNSPECIFIED',
1194
+ pluginType: 'GEMINI',
1195
+ ...(configuredProject ? { duetProject: configuredProject } : {}),
1196
+ },
1197
+ };
1198
+
1199
+ const request = await executeGeminiRequestWithRetry(() => fetch(`${CODE_ASSIST_BASE}:loadCodeAssist`, {
1200
+ method: 'POST',
1201
+ headers: {
1202
+ ...authHeaders,
1203
+ 'Content-Type': 'application/json',
1204
+ },
1205
+ body: JSON.stringify(body),
1206
+ signal,
1207
+ }), {
1208
+ signal,
1209
+ operationName: 'Code Assist bootstrap',
1210
+ errorFormatter: summarizeCodeAssistBootstrapError,
1211
+ });
1212
+
1213
+ if (request.error) {
1214
+ const error = new Error(request.error.errorMessage);
1215
+ Object.assign(error, request.error);
1216
+ throw error;
1217
+ }
1218
+
1219
+ const data = await request.response.json();
1220
+ const projectId = data?.cloudaicompanionProject || configuredProject;
1221
+ if (!projectId) {
1222
+ throw new Error('Code Assist onboarding succeeded but no project ID was returned.');
1223
+ }
1224
+
1225
+ codeAssistProjectCache.set(cacheKey, { projectId, ts: Date.now() });
1226
+ return projectId;
1227
+ }
1228
+
1229
+ async function streamGenerateContent(model, body, authHeaders, signal, options = {}) {
1230
+ const { authMethod, sessionId, codeAssistProjectId } = options;
1231
+
1232
+ if (authMethod === 'oauth') {
1233
+ const requestBody = {
1234
+ model,
1235
+ project: codeAssistProjectId,
1236
+ user_prompt_id: sessionId || crypto.randomUUID(),
1237
+ request: {
1238
+ ...body,
1239
+ session_id: sessionId,
1240
+ },
1241
+ };
1242
+
1243
+ return fetch(`${CODE_ASSIST_BASE}:streamGenerateContent?alt=sse`, {
1244
+ method: 'POST',
1245
+ headers: {
1246
+ ...authHeaders,
1247
+ 'Content-Type': 'application/json',
1248
+ },
1249
+ body: JSON.stringify(requestBody),
1250
+ signal,
1251
+ });
1252
+ }
1253
+
1254
+ return fetch(`${GEMINI_API_BASE}/models/${encodeURIComponent(model)}:streamGenerateContent?alt=sse`, {
1255
+ method: 'POST',
1256
+ headers: {
1257
+ ...authHeaders,
1258
+ 'Content-Type': 'application/json',
1259
+ },
1260
+ body: JSON.stringify(body),
1261
+ signal,
1262
+ });
1263
+ }
1264
+
1265
+ function collectUsageTokens(usageMetadata) {
1266
+ if (!usageMetadata || typeof usageMetadata !== 'object') return 0;
1267
+ if (Number.isFinite(usageMetadata.totalTokenCount)) return usageMetadata.totalTokenCount;
1268
+
1269
+ return [
1270
+ usageMetadata.promptTokenCount,
1271
+ usageMetadata.cachedContentTokenCount,
1272
+ usageMetadata.candidatesTokenCount,
1273
+ usageMetadata.toolUsePromptTokenCount,
1274
+ usageMetadata.thoughtsTokenCount,
1275
+ ].reduce((sum, value) => sum + (Number.isFinite(value) ? value : 0), 0);
1276
+ }
1277
+
1278
+ export async function consumeGeminiStream(response, { onText, onAbortCheck } = {}) {
1279
+ const reader = response.body.getReader();
1280
+ const decoder = new TextDecoder();
1281
+ let buffer = '';
1282
+ let content = '';
1283
+ const functionCalls = [];
1284
+ let finishReason = null;
1285
+ let usageMetadata = null;
1286
+
1287
+ while (true) {
1288
+ const { done, value } = await reader.read();
1289
+ if (done) break;
1290
+ if (onAbortCheck?.()) {
1291
+ await reader.cancel();
1292
+ break;
1293
+ }
1294
+
1295
+ buffer += decoder.decode(value, { stream: true });
1296
+ const lines = buffer.split('\n');
1297
+ buffer = lines.pop() || '';
1298
+
1299
+ for (const rawLine of lines) {
1300
+ const line = rawLine.trim();
1301
+ if (!line.startsWith('data: ')) continue;
1302
+
1303
+ const payload = line.slice(6).trim();
1304
+ if (!payload || payload === '[DONE]') continue;
1305
+
1306
+ let parsed;
1307
+ try {
1308
+ parsed = JSON.parse(payload);
1309
+ } catch {
1310
+ continue;
1311
+ }
1312
+
1313
+ const normalizedPayload = parsed.response || parsed;
1314
+
1315
+ if (normalizedPayload.usageMetadata) {
1316
+ usageMetadata = normalizedPayload.usageMetadata;
1317
+ }
1318
+
1319
+ const candidate = normalizedPayload.candidates?.[0];
1320
+ if (!candidate) continue;
1321
+ if (candidate.finishReason) finishReason = candidate.finishReason;
1322
+
1323
+ const parts = candidate.content?.parts || [];
1324
+ for (const part of parts) {
1325
+ if (typeof part.text === 'string' && part.text.length > 0) {
1326
+ content += part.text;
1327
+ onText?.(part.text);
1328
+ }
1329
+ if (part.functionCall?.name) {
1330
+ functionCalls.push({
1331
+ name: part.functionCall.name,
1332
+ args: part.functionCall.args || {},
1333
+ ...(part.thoughtSignature ? { thoughtSignature: part.thoughtSignature } : {}),
1334
+ });
1335
+ }
1336
+ }
1337
+ }
1338
+ }
1339
+
1340
+ if (buffer.trim().startsWith('data: ')) {
1341
+ const payload = buffer.trim().slice(6).trim();
1342
+ if (payload && payload !== '[DONE]') {
1343
+ try {
1344
+ const parsed = JSON.parse(payload);
1345
+ const normalizedPayload = parsed.response || parsed;
1346
+ if (normalizedPayload.usageMetadata) usageMetadata = normalizedPayload.usageMetadata;
1347
+ const candidate = normalizedPayload.candidates?.[0];
1348
+ if (candidate?.finishReason) finishReason = candidate.finishReason;
1349
+ const parts = candidate?.content?.parts || [];
1350
+ for (const part of parts) {
1351
+ if (typeof part.text === 'string' && part.text.length > 0) {
1352
+ content += part.text;
1353
+ onText?.(part.text);
1354
+ }
1355
+ if (part.functionCall?.name) {
1356
+ functionCalls.push({
1357
+ name: part.functionCall.name,
1358
+ args: part.functionCall.args || {},
1359
+ ...(part.thoughtSignature ? { thoughtSignature: part.thoughtSignature } : {}),
1360
+ });
1361
+ }
1362
+ }
1363
+ } catch {}
1364
+ }
1365
+ }
1366
+
1367
+ return { content, functionCalls, finishReason, usageMetadata };
1368
+ }
1369
+
1370
+ function summarizeGeminiApiError(status, bodyText, fallbackModel) {
1371
+ const text = String(bodyText || '').trim();
1372
+ const isCapacity429 = status === 429 || /RESOURCE_EXHAUSTED|MODEL_CAPACITY_EXHAUSTED|No capacity available/i.test(text);
1373
+ if (isCapacity429) {
1374
+ const modelMatch =
1375
+ text.match(/No capacity available for model ([a-zA-Z0-9._-]+)/i) ||
1376
+ text.match(/"model"\s*:\s*"([^"]+)"/i);
1377
+ const modelName = modelMatch?.[1] || fallbackModel || 'current model';
1378
+ return `Model capacity exhausted for ${modelName} (HTTP 429). Please retry in a moment or switch to a Flash/Lite model.`;
1379
+ }
1380
+
1381
+ try {
1382
+ const parsed = JSON.parse(text);
1383
+ return parsed.error?.message || parsed.message || `Gemini API error (${status})`;
1384
+ } catch {
1385
+ return text || `Gemini API error (${status})`;
1386
+ }
1387
+ }
1388
+
1389
+ function isCapacityExhaustedError(error) {
1390
+ if (!error) return false;
1391
+ if (error.status === 429) return true;
1392
+ const combinedText = `${error.errorMessage || ''} ${error.bodyText || ''}`;
1393
+ return /RESOURCE_EXHAUSTED|MODEL_CAPACITY_EXHAUSTED|No capacity available|capacity exhausted/i.test(combinedText);
1394
+ }
1395
+
1396
+ function getNextGeminiFallbackModel(model) {
1397
+ const currentIndex = GEMINI_MODEL_FALLBACK_CHAIN.indexOf(model);
1398
+ if (currentIndex === -1) return null;
1399
+ return GEMINI_MODEL_FALLBACK_CHAIN[currentIndex + 1] || null;
1400
+ }
1401
+
1402
+ export async function queryGeminiApi(command, options = {}, ws) {
1403
+ const {
1404
+ sessionId,
1405
+ cwd,
1406
+ projectPath,
1407
+ model: requestedModel = 'gemini-2.5-flash',
1408
+ env,
1409
+ sessionMode,
1410
+ stageTagKeys,
1411
+ stageTagSource = 'task_context',
1412
+ systemPrompt: customSystemPrompt,
1413
+ permissionMode = 'bypassPermissions',
1414
+ toolsSettings,
1415
+ thinkingMode,
1416
+ userId,
1417
+ } = options;
1418
+
1419
+ const workingDirectory = cwd || projectPath || process.cwd();
1420
+ const currentSessionId = sessionId || `gemini-${crypto.randomUUID()}`;
1421
+ const abortController = new AbortController();
1422
+ const allowedTools = [...(toolsSettings?.allowedTools || [])];
1423
+ const disallowedTools = [...(toolsSettings?.disallowedTools || [])];
1424
+ const sessionStartTime = Date.now();
1425
+ let activeModel = requestedModel;
1426
+
1427
+ let auth = null;
1428
+ if (env?.GEMINI_API_KEY) {
1429
+ auth = { headers: { 'x-goog-api-key': env.GEMINI_API_KEY }, authMethod: 'api-key' };
1430
+ } else if (env?.GOOGLE_API_KEY) {
1431
+ auth = { headers: { 'x-goog-api-key': env.GOOGLE_API_KEY }, authMethod: 'api-key' };
1432
+ } else {
1433
+ auth = await getGeminiAuthHeaders(userId);
1434
+ }
1435
+
1436
+ if (!auth?.headers) {
1437
+ return { authFailed: true };
1438
+ }
1439
+
1440
+ let codeAssistProjectId = null;
1441
+ if (auth.authMethod === 'oauth') {
1442
+ try {
1443
+ codeAssistProjectId = await loadCodeAssistProject(auth.headers, abortController.signal);
1444
+ } catch (error) {
1445
+ if (error?.status === 401 || error?.status === 403 || error?.errorType === 'auth') {
1446
+ console.warn('[GeminiAPI] Code Assist auth bootstrap failed:', error.message);
1447
+ return { authFailed: true };
1448
+ }
1449
+ if (error?.isRetryable) {
1450
+ const fellBackToCli = await fallbackToGeminiCli(command, {
1451
+ ...options,
1452
+ cwd: workingDirectory,
1453
+ projectPath: workingDirectory,
1454
+ model: activeModel,
1455
+ sessionId: currentSessionId,
1456
+ }, ws, currentSessionId, {
1457
+ errorMessage: error?.message || 'Code Assist bootstrap failed',
1458
+ errorType: error?.errorType || 'unknown',
1459
+ isRetryable: Boolean(error?.isRetryable),
1460
+ bodyText: error?.bodyText || '',
1461
+ });
1462
+ if (fellBackToCli) {
1463
+ return { cliFallback: true };
1464
+ }
1465
+ }
1466
+ sendGeminiApiError(ws, currentSessionId, {
1467
+ errorMessage: error?.message || 'Code Assist bootstrap failed',
1468
+ errorType: error?.errorType || 'unknown',
1469
+ isRetryable: Boolean(error?.isRetryable),
1470
+ bodyText: error?.bodyText || '',
1471
+ });
1472
+ return;
1473
+ }
1474
+ }
1475
+
1476
+ try {
1477
+ if (workingDirectory) {
1478
+ try {
1479
+ await ensureProjectSkillLinks(workingDirectory);
1480
+ await writeProjectTemplates(workingDirectory);
1481
+ } catch (error) {
1482
+ console.warn('[GeminiAPI] Project template init warning:', error.message);
1483
+ }
1484
+ }
1485
+
1486
+ if (currentSessionId && workingDirectory) {
1487
+ applyStageTagsToSession({
1488
+ sessionId: currentSessionId,
1489
+ projectPath: workingDirectory,
1490
+ stageTagKeys,
1491
+ source: stageTagSource,
1492
+ });
1493
+ }
1494
+
1495
+ activeGeminiApiSessions.set(currentSessionId, {
1496
+ status: 'running',
1497
+ abortController,
1498
+ startTime: sessionStartTime,
1499
+ });
1500
+
1501
+ const userText = String(command || '').replace(/\s*\[Context:[^\]]*\]\s*/gi, '').trim();
1502
+ const sessionDisplayName = userText.slice(0, 100) || 'Gemini Session';
1503
+
1504
+ if (workingDirectory) {
1505
+ recordIndexedSession({
1506
+ sessionId: currentSessionId,
1507
+ provider: 'gemini',
1508
+ projectPath: workingDirectory,
1509
+ sessionMode: sessionMode || 'research',
1510
+ displayName: sessionDisplayName,
1511
+ stageTagKeys,
1512
+ tagSource: stageTagSource,
1513
+ });
1514
+ }
1515
+
1516
+ await ensureSessionMetadata(currentSessionId, {
1517
+ cwd: workingDirectory,
1518
+ sessionMode: sessionMode || 'research',
1519
+ summary: sessionDisplayName,
1520
+ });
1521
+
1522
+ sendMessage(ws, {
1523
+ type: 'session-created',
1524
+ sessionId: currentSessionId,
1525
+ provider: 'gemini',
1526
+ mode: sessionMode || 'research',
1527
+ startTime: sessionStartTime,
1528
+ displayName: sessionDisplayName,
1529
+ projectName: workingDirectory ? encodeProjectPath(workingDirectory) : undefined,
1530
+ });
1531
+
1532
+ const systemText = customSystemPrompt || await buildSystemPrompt(workingDirectory);
1533
+ const systemInstruction = { parts: [{ text: systemText }] };
1534
+ const contents = [];
1535
+
1536
+ if (sessionId) {
1537
+ const history = await loadHistory(sessionId);
1538
+ if (history.length > 0) {
1539
+ console.log(`[GeminiAPI] Resumed session ${sessionId} with ${history.length} history entries`);
1540
+ contents.push(...history);
1541
+ }
1542
+ }
1543
+
1544
+ contents.push({ role: 'user', parts: [{ text: String(command || '') }] });
1545
+ await appendSession(currentSessionId, {
1546
+ type: 'message',
1547
+ role: 'user',
1548
+ content: String(command || ''),
1549
+ cwd: workingDirectory,
1550
+ }).catch(() => {});
1551
+
1552
+ const toolDeclarations = permissionMode === 'plan'
1553
+ ? GEMINI_TOOL_DECLARATIONS.filter((tool) => READ_ONLY_TOOLS.has(tool.name))
1554
+ : GEMINI_TOOL_DECLARATIONS;
1555
+
1556
+ let turn = 0;
1557
+ while (turn < MAX_AGENT_TURNS) {
1558
+ turn += 1;
1559
+ const currentSession = activeGeminiApiSessions.get(currentSessionId);
1560
+ if (!currentSession || currentSession.status === 'aborted') break;
1561
+
1562
+ const requestBody = buildRequestBody(
1563
+ activeModel,
1564
+ contents,
1565
+ systemInstruction,
1566
+ thinkingMode,
1567
+ toolDeclarations,
1568
+ );
1569
+
1570
+ const request = await executeGeminiRequestWithRetry(
1571
+ () => streamGenerateContent(activeModel, requestBody, auth.headers, abortController.signal, {
1572
+ authMethod: auth.authMethod,
1573
+ sessionId: currentSessionId,
1574
+ codeAssistProjectId,
1575
+ }),
1576
+ {
1577
+ signal: abortController.signal,
1578
+ operationName: `Gemini turn ${turn}`,
1579
+ errorFormatter: (status, bodyText) => summarizeGeminiApiError(status, bodyText, activeModel),
1580
+ },
1581
+ );
1582
+
1583
+ if (request.error) {
1584
+ if (request.error.status === 401 || request.error.status === 403) {
1585
+ sendGeminiApiError(ws, currentSessionId, request.error);
1586
+ sendMessage(ws, { type: 'gemini-complete', sessionId: currentSessionId, exitCode: 1 });
1587
+ return { authFailed: true };
1588
+ }
1589
+ if (request.error.isRetryable && isCapacityExhaustedError(request.error)) {
1590
+ const fallbackModel = getNextGeminiFallbackModel(activeModel);
1591
+ if (fallbackModel) {
1592
+ sendMessage(ws, {
1593
+ type: 'gemini-status',
1594
+ sessionId: currentSessionId,
1595
+ data: {
1596
+ status: `Model busy, retrying with ${fallbackModel}...`,
1597
+ can_interrupt: true,
1598
+ startTime: sessionStartTime,
1599
+ },
1600
+ });
1601
+ activeModel = fallbackModel;
1602
+ turn -= 1;
1603
+ continue;
1604
+ }
1605
+ }
1606
+ if (request.error.isRetryable) {
1607
+ const fellBackToCli = await fallbackToGeminiCli(command, {
1608
+ ...options,
1609
+ cwd: workingDirectory,
1610
+ projectPath: workingDirectory,
1611
+ model: activeModel,
1612
+ sessionId: currentSessionId,
1613
+ }, ws, currentSessionId, request.error);
1614
+ if (fellBackToCli) {
1615
+ return { cliFallback: true };
1616
+ }
1617
+ }
1618
+ sendGeminiApiError(ws, currentSessionId, request.error);
1619
+ return;
1620
+ }
1621
+
1622
+ const result = await consumeGeminiStream(request.response, {
1623
+ onText(delta) {
1624
+ sendMessage(ws, {
1625
+ type: 'gemini-response',
1626
+ sessionId: currentSessionId,
1627
+ data: {
1628
+ type: 'content_block_delta',
1629
+ index: 0,
1630
+ delta: { type: 'text_delta', text: delta },
1631
+ },
1632
+ });
1633
+ },
1634
+ onAbortCheck() {
1635
+ const session = activeGeminiApiSessions.get(currentSessionId);
1636
+ return !session || session.status === 'aborted';
1637
+ },
1638
+ });
1639
+
1640
+ sendMessage(ws, {
1641
+ type: 'token-budget',
1642
+ sessionId: currentSessionId,
1643
+ data: {
1644
+ used: Math.round(collectUsageTokens(result.usageMetadata)),
1645
+ total: DEFAULT_CONTEXT_WINDOW,
1646
+ },
1647
+ });
1648
+
1649
+ if (result.content) {
1650
+ sendMessage(ws, {
1651
+ type: 'gemini-response',
1652
+ sessionId: currentSessionId,
1653
+ data: { type: 'content_block_stop', index: 0 },
1654
+ });
1655
+ }
1656
+
1657
+ if (result.functionCalls.length === 0) {
1658
+ if (result.content) {
1659
+ await appendSession(currentSessionId, {
1660
+ type: 'message',
1661
+ role: 'assistant',
1662
+ content: result.content,
1663
+ }).catch(() => {});
1664
+ }
1665
+ break;
1666
+ }
1667
+
1668
+ const allFunctionCallMeta = [];
1669
+ const functionResponses = [];
1670
+
1671
+ if (result.content) {
1672
+ await appendSession(currentSessionId, {
1673
+ type: 'message',
1674
+ role: 'assistant',
1675
+ content: result.content,
1676
+ }).catch(() => {});
1677
+ }
1678
+
1679
+ for (let index = 0; index < result.functionCalls.length; index += 1) {
1680
+ const functionCall = result.functionCalls[index];
1681
+ const toolCallId = `tool_${crypto.randomUUID()}`;
1682
+ const toolArgs = normalizeToolArgs(functionCall.args);
1683
+
1684
+ sendMessage(ws, {
1685
+ type: 'gemini-response',
1686
+ sessionId: currentSessionId,
1687
+ data: {
1688
+ role: 'assistant',
1689
+ content: [{
1690
+ type: 'tool_use',
1691
+ id: toolCallId,
1692
+ name: functionCall.name,
1693
+ input: toolArgs,
1694
+ }],
1695
+ },
1696
+ });
1697
+
1698
+ await appendSession(currentSessionId, {
1699
+ type: 'tool_use',
1700
+ toolName: functionCall.name,
1701
+ rawToolName: functionCall.name,
1702
+ toolInput: toolArgs,
1703
+ toolCallId,
1704
+ }).catch(() => {});
1705
+
1706
+ allFunctionCallMeta.push({
1707
+ name: functionCall.name,
1708
+ args: toolArgs,
1709
+ thoughtSignature: functionCall.thoughtSignature,
1710
+ });
1711
+
1712
+ const allowed = await checkToolPermission(
1713
+ functionCall.name,
1714
+ toolArgs,
1715
+ permissionMode,
1716
+ allowedTools,
1717
+ disallowedTools,
1718
+ ws,
1719
+ currentSessionId,
1720
+ toolsSettings?.skipPermissions === true,
1721
+ );
1722
+
1723
+ const output = allowed
1724
+ ? await executeTool(functionCall.name, toolArgs, workingDirectory)
1725
+ : `Permission denied for tool: ${functionCall.name}`;
1726
+ const isError = !allowed || (typeof output === 'string' && output.startsWith('Error'));
1727
+
1728
+ sendMessage(ws, {
1729
+ type: 'gemini-response',
1730
+ sessionId: currentSessionId,
1731
+ data: {
1732
+ role: 'user',
1733
+ content: [{
1734
+ type: 'tool_result',
1735
+ tool_use_id: toolCallId,
1736
+ content: output.slice(0, 2000),
1737
+ is_error: isError,
1738
+ }],
1739
+ },
1740
+ });
1741
+
1742
+ await appendSession(currentSessionId, {
1743
+ type: 'tool_result',
1744
+ toolName: functionCall.name,
1745
+ toolCallId,
1746
+ output,
1747
+ isError,
1748
+ }).catch(() => {});
1749
+
1750
+ functionResponses.push({
1751
+ functionResponse: {
1752
+ name: functionCall.name,
1753
+ id: toolCallId,
1754
+ response: { result: output },
1755
+ },
1756
+ });
1757
+ }
1758
+
1759
+ // Push model turn with ALL function calls once, then ALL responses once
1760
+ contents.push({
1761
+ role: 'model',
1762
+ parts: buildModelTurnParts(result.content, allFunctionCallMeta, {
1763
+ requireSyntheticThoughtSignatures: auth.authMethod === 'oauth',
1764
+ }),
1765
+ });
1766
+ contents.push({
1767
+ role: 'user',
1768
+ parts: functionResponses,
1769
+ });
1770
+ }
1771
+
1772
+ if (workingDirectory) {
1773
+ try {
1774
+ await reconcileGeminiSessionIndex(workingDirectory, {
1775
+ sessionId: currentSessionId,
1776
+ projectName: encodeProjectPath(workingDirectory),
1777
+ });
1778
+ } catch (error) {
1779
+ console.warn('[GeminiAPI] Session reconciliation warning:', error.message);
1780
+ }
1781
+ }
1782
+
1783
+ sendMessage(ws, { type: 'gemini-complete', sessionId: currentSessionId, exitCode: 0 });
1784
+ } catch (error) {
1785
+ if (error.name === 'AbortError') {
1786
+ sendMessage(ws, {
1787
+ type: 'gemini-complete',
1788
+ sessionId: currentSessionId,
1789
+ exitCode: 1,
1790
+ aborted: true,
1791
+ });
1792
+ return;
1793
+ }
1794
+
1795
+ console.error('[GeminiAPI] Unexpected error:', error);
1796
+ throw error;
1797
+ } finally {
1798
+ const session = activeGeminiApiSessions.get(currentSessionId);
1799
+ if (session) {
1800
+ session.status = 'completed';
1801
+ }
1802
+ }
1803
+ }
1804
+
1805
+ export function abortGeminiApiSession(sessionId) {
1806
+ const session = activeGeminiApiSessions.get(sessionId);
1807
+ if (!session) return false;
1808
+ session.status = 'aborted';
1809
+ session.abortController?.abort();
1810
+ activeGeminiApiSessions.delete(sessionId);
1811
+ return true;
1812
+ }
1813
+
1814
+ export function isGeminiApiSessionActive(sessionId) {
1815
+ return activeGeminiApiSessions.get(sessionId)?.status === 'running';
1816
+ }
1817
+
1818
+ export function getGeminiApiSessionStartTime(sessionId) {
1819
+ return activeGeminiApiSessions.get(sessionId)?.startTime || null;
1820
+ }
1821
+
1822
+ export function getActiveGeminiApiSessions() {
1823
+ return Array.from(activeGeminiApiSessions.entries())
1824
+ .filter(([, session]) => session.status === 'running')
1825
+ .map(([sessionId, session]) => ({
1826
+ sessionId,
1827
+ startTime: session.startTime,
1828
+ }));
1829
+ }
1830
+
1831
+ setInterval(() => {
1832
+ const now = Date.now();
1833
+ for (const [sessionId, session] of activeGeminiApiSessions.entries()) {
1834
+ if (session.status !== 'running' && now - (session.startTime || 0) > 30 * 60 * 1000) {
1835
+ activeGeminiApiSessions.delete(sessionId);
1836
+ }
1837
+ }
1838
+ }, 5 * 60 * 1000);