dr-claw 1.1.2

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 (287) hide show
  1. package/LICENSE +1380 -0
  2. package/NOTICE +17 -0
  3. package/README.md +875 -0
  4. package/README.zh-CN.md +857 -0
  5. package/dist/api-docs.html +878 -0
  6. package/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  7. package/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  8. package/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  9. package/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  10. package/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  11. package/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  12. package/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  13. package/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  14. package/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  15. package/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  16. package/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  17. package/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  18. package/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  19. package/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  20. package/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  21. package/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  22. package/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  23. package/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  24. package/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  25. package/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  26. package/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  27. package/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  28. package/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  29. package/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  30. package/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  31. package/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  32. package/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  33. package/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  34. package/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  35. package/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  36. package/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  37. package/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  38. package/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  39. package/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  40. package/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  41. package/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  42. package/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  43. package/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  44. package/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  45. package/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  46. package/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  47. package/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  48. package/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  49. package/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  50. package/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  51. package/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  52. package/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  53. package/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  54. package/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  55. package/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  56. package/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  57. package/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  58. package/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  59. package/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  60. package/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  61. package/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  62. package/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  63. package/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  64. package/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  65. package/dist/assets/_basePickBy-C0n1uo4u.js +1 -0
  66. package/dist/assets/_baseUniq-BRNmhFkc.js +1 -0
  67. package/dist/assets/arc-DOMBmC_A.js +1 -0
  68. package/dist/assets/architectureDiagram-VXUJARFQ-CtMWglbD.js +36 -0
  69. package/dist/assets/blockDiagram-VD42YOAC-DK_gTkJy.js +122 -0
  70. package/dist/assets/c4Diagram-YG6GDRKO-DrDAvsn7.js +10 -0
  71. package/dist/assets/channel-BFOE7OyC.js +1 -0
  72. package/dist/assets/chunk-4BX2VUAB-CGbhdZcN.js +1 -0
  73. package/dist/assets/chunk-55IACEB6-sGeFSxen.js +1 -0
  74. package/dist/assets/chunk-B4BG7PRW-RxgHAU_P.js +165 -0
  75. package/dist/assets/chunk-DI55MBZ5-CvHUXZrx.js +220 -0
  76. package/dist/assets/chunk-FMBD7UC4-Sn7cXVNU.js +15 -0
  77. package/dist/assets/chunk-QN33PNHL-bDKOHppN.js +1 -0
  78. package/dist/assets/chunk-QZHKN3VN-B9kN5LGm.js +1 -0
  79. package/dist/assets/chunk-TZMSLE5B-CCBe6len.js +1 -0
  80. package/dist/assets/classDiagram-2ON5EDUG-DQoMNYlY.js +1 -0
  81. package/dist/assets/classDiagram-v2-WZHVMYZB-DQoMNYlY.js +1 -0
  82. package/dist/assets/clone-BulQSblH.js +1 -0
  83. package/dist/assets/cose-bilkent-S5V4N54A-r6sLiHBW.js +1 -0
  84. package/dist/assets/cytoscape.esm-5J0xJHOV.js +321 -0
  85. package/dist/assets/dagre-6UL2VRFP-AbsaR1Oa.js +4 -0
  86. package/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
  87. package/dist/assets/diagram-PSM6KHXK-DjCvdUyy.js +24 -0
  88. package/dist/assets/diagram-QEK2KX5R-CqiocXkH.js +43 -0
  89. package/dist/assets/diagram-S2PKOQOG-D5ALeoII.js +24 -0
  90. package/dist/assets/erDiagram-Q2GNP2WA-C1AeOsiz.js +60 -0
  91. package/dist/assets/flowDiagram-NV44I4VS-C98ve_DO.js +162 -0
  92. package/dist/assets/ganttDiagram-JELNMOA3-3zdLvcxD.js +267 -0
  93. package/dist/assets/gitGraphDiagram-V2S2FVAM-nDuDMsHh.js +65 -0
  94. package/dist/assets/graph-Blm-emkR.js +1 -0
  95. package/dist/assets/index-B8Yutsnm.css +1 -0
  96. package/dist/assets/index-Cf6gyhc3.js +654 -0
  97. package/dist/assets/infoDiagram-HS3SLOUP-CmzE3q0R.js +2 -0
  98. package/dist/assets/init-Gi6I4Gst.js +1 -0
  99. package/dist/assets/journeyDiagram-XKPGCS4Q-CYaW7LrP.js +139 -0
  100. package/dist/assets/kanban-definition-3W4ZIXB7-mkmW7p0j.js +89 -0
  101. package/dist/assets/layout-Ct2kFIId.js +1 -0
  102. package/dist/assets/linear-CKG9EwIJ.js +1 -0
  103. package/dist/assets/mermaid.core-CaKcUnnd.js +250 -0
  104. package/dist/assets/mindmap-definition-VGOIOE7T-Bct-pYin.js +68 -0
  105. package/dist/assets/ordinal-Cboi1Yqb.js +1 -0
  106. package/dist/assets/pieDiagram-ADFJNKIX-C8wFD4Jr.js +30 -0
  107. package/dist/assets/quadrantDiagram-AYHSOK5B-CuiE_f46.js +7 -0
  108. package/dist/assets/requirementDiagram-UZGBJVZJ-C4v1CqU8.js +64 -0
  109. package/dist/assets/sankeyDiagram-TZEHDZUN-keAqqs-Y.js +10 -0
  110. package/dist/assets/sequenceDiagram-WL72ISMW-ClNbvKTB.js +145 -0
  111. package/dist/assets/stateDiagram-FKZM4ZOC-D8We-2vq.js +1 -0
  112. package/dist/assets/stateDiagram-v2-4FDKWEC3-BUURfjrE.js +1 -0
  113. package/dist/assets/timeline-definition-IT6M3QCI-DsvEMT5T.js +61 -0
  114. package/dist/assets/treemap-GDKQZRPO-TJrzN7Bj.js +162 -0
  115. package/dist/assets/vendor-codemirror-ansM-eig.js +33 -0
  116. package/dist/assets/vendor-react-BWF6YQQ3.js +8 -0
  117. package/dist/assets/vendor-xterm-DfcmCpbH.js +66 -0
  118. package/dist/assets/xychartDiagram-PRI3JC2R-BdATmY5F.js +7 -0
  119. package/dist/clear-cache.html +85 -0
  120. package/dist/convert-icons.md +53 -0
  121. package/dist/dr-claw.png +0 -0
  122. package/dist/favicon.png +0 -0
  123. package/dist/generate-icons.js +49 -0
  124. package/dist/icons/claude-ai-icon.svg +1 -0
  125. package/dist/icons/codex-white.svg +3 -0
  126. package/dist/icons/codex.svg +3 -0
  127. package/dist/icons/cursor-white.svg +12 -0
  128. package/dist/icons/cursor.svg +1 -0
  129. package/dist/icons/file.svg +16997 -0
  130. package/dist/icons/generate-icons.md +19 -0
  131. package/dist/icons/icon-128x128.png +0 -0
  132. package/dist/icons/icon-144x144.png +0 -0
  133. package/dist/icons/icon-152x152.png +0 -0
  134. package/dist/icons/icon-192x192.png +0 -0
  135. package/dist/icons/icon-384x384.png +0 -0
  136. package/dist/icons/icon-512x512.png +0 -0
  137. package/dist/icons/icon-72x72.png +0 -0
  138. package/dist/icons/icon-96x96.png +0 -0
  139. package/dist/icons/news/arxiv.svg +1 -0
  140. package/dist/icons/news/huggingface.svg +8 -0
  141. package/dist/icons/news/x-black.png +0 -0
  142. package/dist/icons/news/x-white.png +0 -0
  143. package/dist/icons/news/xiaohongshu.png +0 -0
  144. package/dist/index.html +51 -0
  145. package/dist/leveraged-cognition.png +0 -0
  146. package/dist/manifest.json +61 -0
  147. package/dist/screenshots/chat.png +0 -0
  148. package/dist/screenshots/new_claw.png +0 -0
  149. package/dist/screenshots/news_dashboard.png +0 -0
  150. package/dist/screenshots/project_dashboard.png +0 -0
  151. package/dist/screenshots/research_lab.png +0 -0
  152. package/dist/screenshots/skill_library.png +0 -0
  153. package/dist/screenshots/task_list.png +0 -0
  154. package/dist/screenshots/terminal_example.png +0 -0
  155. package/dist/screenshots/terminal_example1.png +0 -0
  156. package/dist/sw.js +58 -0
  157. package/dist/wechat-group-qr.jpg +0 -0
  158. package/package.json +133 -0
  159. package/public/api-docs.html +878 -0
  160. package/public/clear-cache.html +85 -0
  161. package/public/convert-icons.md +53 -0
  162. package/public/dr-claw.png +0 -0
  163. package/public/favicon.png +0 -0
  164. package/public/generate-icons.js +49 -0
  165. package/public/icons/claude-ai-icon.svg +1 -0
  166. package/public/icons/codex-white.svg +3 -0
  167. package/public/icons/codex.svg +3 -0
  168. package/public/icons/cursor-white.svg +12 -0
  169. package/public/icons/cursor.svg +1 -0
  170. package/public/icons/file.svg +16997 -0
  171. package/public/icons/generate-icons.md +19 -0
  172. package/public/icons/icon-128x128.png +0 -0
  173. package/public/icons/icon-144x144.png +0 -0
  174. package/public/icons/icon-152x152.png +0 -0
  175. package/public/icons/icon-192x192.png +0 -0
  176. package/public/icons/icon-384x384.png +0 -0
  177. package/public/icons/icon-512x512.png +0 -0
  178. package/public/icons/icon-72x72.png +0 -0
  179. package/public/icons/icon-96x96.png +0 -0
  180. package/public/icons/news/arxiv.svg +1 -0
  181. package/public/icons/news/huggingface.svg +8 -0
  182. package/public/icons/news/x-black.png +0 -0
  183. package/public/icons/news/x-white.png +0 -0
  184. package/public/icons/news/xiaohongshu.png +0 -0
  185. package/public/leveraged-cognition.png +0 -0
  186. package/public/manifest.json +61 -0
  187. package/public/screenshots/chat.png +0 -0
  188. package/public/screenshots/new_claw.png +0 -0
  189. package/public/screenshots/news_dashboard.png +0 -0
  190. package/public/screenshots/project_dashboard.png +0 -0
  191. package/public/screenshots/research_lab.png +0 -0
  192. package/public/screenshots/skill_library.png +0 -0
  193. package/public/screenshots/task_list.png +0 -0
  194. package/public/screenshots/terminal_example.png +0 -0
  195. package/public/screenshots/terminal_example1.png +0 -0
  196. package/public/sw.js +58 -0
  197. package/public/wechat-group-qr.jpg +0 -0
  198. package/scripts/export-skills-catalog-v2.mjs +746 -0
  199. package/scripts/fix-node-pty.js +45 -0
  200. package/scripts/gemini_image_core.py +470 -0
  201. package/scripts/install-python-tools.js +64 -0
  202. package/server/AGENTS.md +73 -0
  203. package/server/__tests__/gemini-legacy-thought.test.mjs +88 -0
  204. package/server/__tests__/runtimePorts.test.mjs +115 -0
  205. package/server/__tests__/session-delete.test.mjs +166 -0
  206. package/server/bin/compute-node +70 -0
  207. package/server/claude-sdk.js +799 -0
  208. package/server/cli-chat.js +488 -0
  209. package/server/cli.js +354 -0
  210. package/server/compute-node.js +439 -0
  211. package/server/constants/config.js +5 -0
  212. package/server/cursor-cli.js +340 -0
  213. package/server/database/auth.db +0 -0
  214. package/server/database/db.js +1852 -0
  215. package/server/database/init.sql +194 -0
  216. package/server/gemini-cli.js +1352 -0
  217. package/server/index.js +3154 -0
  218. package/server/load-env.js +71 -0
  219. package/server/local-gpu.js +860 -0
  220. package/server/middleware/auth.js +100 -0
  221. package/server/openai-codex.js +724 -0
  222. package/server/openrouter.js +890 -0
  223. package/server/project-token-usage.js +407 -0
  224. package/server/projects.js +3973 -0
  225. package/server/routes/agent.js +1272 -0
  226. package/server/routes/auth.js +128 -0
  227. package/server/routes/auto-research.js +558 -0
  228. package/server/routes/cli-auth.js +958 -0
  229. package/server/routes/codex.js +344 -0
  230. package/server/routes/commands.js +601 -0
  231. package/server/routes/compute.js +358 -0
  232. package/server/routes/cursor.js +795 -0
  233. package/server/routes/git.js +1348 -0
  234. package/server/routes/mcp-utils.js +48 -0
  235. package/server/routes/mcp.js +618 -0
  236. package/server/routes/news.js +711 -0
  237. package/server/routes/projects.js +633 -0
  238. package/server/routes/references.js +338 -0
  239. package/server/routes/settings.js +285 -0
  240. package/server/routes/skills.js +914 -0
  241. package/server/routes/taskmaster.js +2566 -0
  242. package/server/routes/telemetry.js +89 -0
  243. package/server/routes/user.js +155 -0
  244. package/server/scripts/fix_session_names.js +57 -0
  245. package/server/scripts/research-news/common_words.py +19 -0
  246. package/server/scripts/research-news/link_keywords.py +298 -0
  247. package/server/scripts/research-news/scan_existing_notes.py +266 -0
  248. package/server/scripts/research-news/scoring_utils.py +227 -0
  249. package/server/scripts/research-news/search_arxiv.py +842 -0
  250. package/server/scripts/research-news/search_huggingface.py +438 -0
  251. package/server/scripts/research-news/search_x.py +490 -0
  252. package/server/scripts/research-news/search_xiaohongshu.py +610 -0
  253. package/server/taskmaster-templates/README.md +150 -0
  254. package/server/taskmaster-templates/README.zh-CN.md +150 -0
  255. package/server/taskmaster-templates/ai-research-dataset.json +230 -0
  256. package/server/taskmaster-templates/ai-research-method-model.json +230 -0
  257. package/server/taskmaster-templates/ai-research-position-paper.json +230 -0
  258. package/server/telemetry.js +121 -0
  259. package/server/templates/AGENTS.md +103 -0
  260. package/server/templates/CLAUDE.md +104 -0
  261. package/server/templates/GEMINI.md +103 -0
  262. package/server/templates/cursor-project.md +108 -0
  263. package/server/templates/index.js +37 -0
  264. package/server/utils/__tests__/sessionTokenUsage.test.js +82 -0
  265. package/server/utils/auto-research-mailer.js +57 -0
  266. package/server/utils/cliResolution.js +170 -0
  267. package/server/utils/commandParser.js +303 -0
  268. package/server/utils/cursorCommand.js +61 -0
  269. package/server/utils/geminiApiKey.js +28 -0
  270. package/server/utils/gitConfig.js +24 -0
  271. package/server/utils/imageAttachmentFiles.js +35 -0
  272. package/server/utils/mcp-detector.js +198 -0
  273. package/server/utils/parsers/bibtex-parser.js +207 -0
  274. package/server/utils/permissions.js +100 -0
  275. package/server/utils/runtimePorts.js +212 -0
  276. package/server/utils/sessionFormatting.js +68 -0
  277. package/server/utils/sessionIndex.js +100 -0
  278. package/server/utils/sessionMode.js +95 -0
  279. package/server/utils/sessionTokenUsage.js +105 -0
  280. package/server/utils/taskmaster-websocket.js +129 -0
  281. package/server/utils/zotero-client.js +136 -0
  282. package/shared/errorClassifier.js +102 -0
  283. package/shared/geminiThinkingSupport.d.ts +22 -0
  284. package/shared/geminiThinkingSupport.js +96 -0
  285. package/shared/geminiThoughtParser.d.ts +8 -0
  286. package/shared/geminiThoughtParser.js +30 -0
  287. package/shared/modelConstants.js +139 -0
@@ -0,0 +1,1352 @@
1
+ import { spawn } from 'child_process';
2
+ import crossSpawn from 'cross-spawn';
3
+ import { promises as fs } from 'fs';
4
+ import path from 'path';
5
+ import os from 'os';
6
+ import { createRequestId, waitForToolApproval, matchesToolPermission } from './utils/permissions.js';
7
+ import { encodeProjectPath, ensureProjectSkillLinks, reconcileGeminiSessionIndex } from './projects.js';
8
+ import { writeProjectTemplates } from './templates/index.js';
9
+ import { stripInternalContextPrefix } from './utils/sessionFormatting.js';
10
+ import { applyStageTagsToSession, recordIndexedSession } from './utils/sessionIndex.js';
11
+ import { buildTempAttachmentFilename, toPortableAtPath } from './utils/imageAttachmentFiles.js';
12
+ import { splitLegacyGeminiThoughtContent } from '../shared/geminiThoughtParser.js';
13
+ import { classifyError } from '../shared/errorClassifier.js';
14
+ import { buildGeminiThinkingConfig } from '../shared/geminiThinkingSupport.js';
15
+
16
+ // Use cross-spawn on Windows for better command execution
17
+ const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
18
+
19
+ let activeGeminiSessions = new Map(); // Track active sessions: { process, heartbeat, sessionId, options, sessionAllowedTools, sessionDisallowedTools }
20
+ const GEMINI_DEFAULT_CONTEXT_WINDOW = parseInt(process.env.GEMINI_CONTEXT_WINDOW || process.env.CONTEXT_WINDOW || '2000000', 10);
21
+
22
+ const GEMINI_TOOL_NAME_MAP = {
23
+ // Official Gemini CLI tool names
24
+ run_shell_command: 'Bash',
25
+ glob: 'Glob',
26
+ grep_search: 'Grep',
27
+ list_directory: 'LS',
28
+ read_file: 'Read',
29
+ read_many_files: 'Read',
30
+ replace: 'Edit',
31
+ write_file: 'Write',
32
+ ask_user: 'AskUserQuestion',
33
+ write_todos: 'TodoWrite',
34
+ enter_plan_mode: 'enter_plan_mode',
35
+ exit_plan_mode: 'exit_plan_mode',
36
+ google_web_search: 'WebSearch',
37
+ web_fetch: 'WebFetch',
38
+
39
+ // Backward-compatible aliases seen in older wrappers
40
+ insert_content: 'Edit',
41
+ todo_read: 'TodoRead',
42
+ todo_write: 'TodoWrite',
43
+ task_get: 'TaskGet',
44
+ task_list: 'TaskList',
45
+ task_create: 'TaskCreate',
46
+ task_update: 'TaskUpdate',
47
+ ask_user_question: 'AskUserQuestion'
48
+ };
49
+
50
+ const GEMINI_PLAN_MODE_TOOLS = [
51
+ 'read_file',
52
+ 'read_many_files',
53
+ 'list_directory',
54
+ 'glob',
55
+ 'grep_search',
56
+ 'write_todos',
57
+ 'ask_user',
58
+ 'enter_plan_mode',
59
+ 'exit_plan_mode',
60
+ 'google_web_search',
61
+ 'web_fetch',
62
+ 'activate_skill',
63
+ 'save_memory',
64
+ 'get_internal_docs'
65
+ ];
66
+
67
+ const GEMINI_INTERACTIVE_TOOLS = new Set(['ask_user', 'ask_user_question', 'AskUserQuestion']);
68
+ const GEMINI_PLAN_BLOCKED_TOOLS = new Set([
69
+ 'run_shell_command',
70
+ 'write_file',
71
+ 'replace',
72
+ 'insert_content',
73
+ 'undo',
74
+ 'complete_task',
75
+ 'Bash',
76
+ 'Write',
77
+ 'Edit'
78
+ ]);
79
+
80
+ async function persistGeminiSessionMetadata(sessionId, projectPath, sessionMode) {
81
+ if (!sessionId || !projectPath) return;
82
+
83
+ try {
84
+ const { sessionDb } = await import('./database/db.js');
85
+ sessionDb.upsertSession(
86
+ sessionId,
87
+ encodeProjectPath(projectPath),
88
+ 'gemini',
89
+ 'Untitled Session',
90
+ new Date().toISOString(),
91
+ 0,
92
+ { sessionMode: sessionMode || 'research' },
93
+ );
94
+ } catch (error) {
95
+ console.warn('[Gemini] Failed to persist session metadata:', error.message);
96
+ }
97
+ }
98
+
99
+ function normalizeGeminiToolName(name) {
100
+ if (!name || typeof name !== 'string') return name;
101
+ const normalized = name.trim();
102
+ return GEMINI_TOOL_NAME_MAP[normalized] || normalized;
103
+ }
104
+
105
+ function ensurePlanModeAllowedTools(allowedTools = []) {
106
+ const merged = new Set(allowedTools);
107
+ for (const tool of GEMINI_PLAN_MODE_TOOLS) {
108
+ merged.add(tool);
109
+ const alias = GEMINI_TOOL_NAME_MAP[tool];
110
+ if (alias) merged.add(alias);
111
+ }
112
+ return Array.from(merged);
113
+ }
114
+
115
+ function isLikelyMultiStepRequest(command) {
116
+ const text = String(command || '').trim();
117
+ if (!text) return false;
118
+
119
+ const numberedSteps = (text.match(/^\s*\d+\.\s+/gm) || []).length;
120
+ const bulletSteps = (text.match(/^\s*[-*]\s+/gm) || []).length;
121
+ const explicitMultiStep = /\b(multi[- ]step|step by step|several tasks|task list|pipeline)\b/i.test(text);
122
+ const transitionWords = (text.match(/\b(first|then|next|after that|finally)\b/gi) || []).length;
123
+
124
+ return numberedSteps >= 2 || bulletSteps >= 2 || explicitMultiStep || transitionWords >= 2;
125
+ }
126
+
127
+ function isAllowedBeforeTodos(rawToolName) {
128
+ const normalized = String(rawToolName || '').trim().toLowerCase();
129
+ return [
130
+ 'write_todos',
131
+ 'todo_write',
132
+ 'activate_skill',
133
+ 'enter_plan_mode'
134
+ ].includes(normalized);
135
+ }
136
+
137
+ function sanitizePersistedGeminiContent(content) {
138
+ if (typeof content === 'string') {
139
+ return stripInternalContextPrefix(content, false);
140
+ }
141
+
142
+ if (Array.isArray(content)) {
143
+ return content.map((part) => {
144
+ if (part && typeof part === 'object' && typeof part.text === 'string') {
145
+ return { ...part, text: stripInternalContextPrefix(part.text, false) };
146
+ }
147
+ return part;
148
+ });
149
+ }
150
+
151
+ return content;
152
+ }
153
+
154
+ // Exported for testing only
155
+ export function normalizePersistedGeminiAssistantEntries(content) {
156
+ const normalizedContent = typeof content === 'string'
157
+ ? stripInternalContextPrefix(content, false)
158
+ : content;
159
+ const legacySegments = splitLegacyGeminiThoughtContent(normalizedContent);
160
+
161
+ if (!legacySegments) {
162
+ return [{
163
+ role: 'assistant',
164
+ content: normalizedContent,
165
+ type: 'message'
166
+ }];
167
+ }
168
+
169
+ return legacySegments.map((segment) => segment.isThinking
170
+ ? { role: 'assistant', type: 'thinking', content: segment.content }
171
+ : { role: 'assistant', content: segment.content, type: 'message' }
172
+ );
173
+ }
174
+
175
+ function summarizeGeminiErrorOutput(rawErrorOutput, fallbackModel) {
176
+ const text = String(rawErrorOutput || '').trim();
177
+ if (!text) {
178
+ return { summary: 'Gemini request failed.', details: '' };
179
+ }
180
+
181
+ const isCapacity429 =
182
+ /status\s+429|Too Many Requests|RESOURCE_EXHAUSTED|MODEL_CAPACITY_EXHAUSTED|No capacity available/i.test(text);
183
+ if (isCapacity429) {
184
+ const modelMatch =
185
+ text.match(/No capacity available for model ([a-zA-Z0-9._-]+)/i) ||
186
+ text.match(/"model"\s*:\s*"([^"]+)"/i) ||
187
+ text.match(/GeminiCLI\/[^\s/]+\/([a-zA-Z0-9._-]+)/i);
188
+ const modelName = modelMatch?.[1] || fallbackModel || 'current model';
189
+ return {
190
+ summary: `Model capacity exhausted for ${modelName} (HTTP 429). Please retry in a moment or switch to a Flash/Lite model.`,
191
+ details: text.slice(0, 12000)
192
+ };
193
+ }
194
+
195
+ const concise = text.split('\n').map((line) => line.trim()).find(Boolean) || 'Gemini request failed.';
196
+ return {
197
+ summary: concise.slice(0, 400),
198
+ details: text.slice(0, 12000)
199
+ };
200
+ }
201
+
202
+ function inferProjectName(workingDir, projectPath) {
203
+ const candidate = projectPath || workingDir;
204
+ if (!candidate || typeof candidate !== 'string') return null;
205
+ return path.basename(candidate.replace(/[\\/]+$/, '')) || null;
206
+ }
207
+
208
+ function shouldNotifyTaskMasterRefresh(toolName, toolInput, toolResultOutput) {
209
+ const normalizedName = String(toolName || '').toLowerCase();
210
+ if (
211
+ normalizedName.startsWith('task_') ||
212
+ normalizedName === 'todo_write' ||
213
+ normalizedName === 'todo_read' ||
214
+ normalizedName === 'write_todos'
215
+ ) {
216
+ return true;
217
+ }
218
+
219
+ const serializedInput = JSON.stringify(toolInput || {});
220
+ if (normalizedName === 'write_file' || normalizedName === 'replace' || normalizedName === 'insert_content') {
221
+ if (serializedInput.includes('.pipeline/tasks/tasks.json')) return true;
222
+ }
223
+
224
+ if (normalizedName === 'run_shell_command') {
225
+ const commandText = String(toolInput?.command || toolInput?.cmd || '');
226
+ if (/taskmaster|task-master|\.pipeline\/tasks\/tasks\.json/i.test(commandText)) return true;
227
+ const outputText = typeof toolResultOutput === 'string' ? toolResultOutput : '';
228
+ if (/taskmaster|task-master|tasks\.json/i.test(outputText)) return true;
229
+ }
230
+
231
+ return false;
232
+ }
233
+
234
+ function shouldNotifyProjectRefresh(toolName, toolInput, toolResultOutput) {
235
+ const normalizedName = String(toolName || '').toLowerCase();
236
+ const serializedInput = JSON.stringify(toolInput || {});
237
+ const outputText = typeof toolResultOutput === 'string' ? toolResultOutput : '';
238
+
239
+ if (normalizedName === 'write_file' || normalizedName === 'replace' || normalizedName === 'insert_content') {
240
+ if (
241
+ serializedInput.includes('.pipeline/docs/research_brief.json') ||
242
+ serializedInput.includes('instance.json') ||
243
+ serializedInput.includes('.pipeline/config.json')
244
+ ) {
245
+ return true;
246
+ }
247
+ }
248
+
249
+ if (normalizedName === 'run_shell_command') {
250
+ const commandText = String(toolInput?.command || toolInput?.cmd || '');
251
+ if (/research_brief\.json|instance\.json|\.pipeline\/config\.json/i.test(commandText)) return true;
252
+ if (/research_brief\.json|instance\.json|\.pipeline\/config\.json/i.test(outputText)) return true;
253
+ }
254
+
255
+ return false;
256
+ }
257
+
258
+ function resolveCanonicalProjectFilePath(filePath) {
259
+ if (!filePath || typeof filePath !== 'string') return filePath;
260
+ const trimmed = filePath.trim();
261
+ if (!trimmed) return trimmed;
262
+
263
+ // Keep explicit absolute or already-nested paths unchanged.
264
+ if (path.isAbsolute(trimmed) || trimmed.includes('/')) return trimmed;
265
+
266
+ const canonicalCandidates = {
267
+ 'research_brief.json': ['.pipeline/docs/research_brief.json'],
268
+ 'tasks.json': ['.pipeline/tasks/tasks.json'],
269
+ 'pipeline_config.json': ['.pipeline/config.json']
270
+ };
271
+
272
+ const candidates = canonicalCandidates[trimmed];
273
+ if (!candidates || candidates.length === 0) return trimmed;
274
+
275
+ // Prefer canonical pipeline location even when the file is not present yet.
276
+ // This prevents accidental writes to guessed root-level filenames.
277
+ return candidates[0];
278
+ }
279
+
280
+ function normalizeGeminiToolInput(rawToolName, toolInput) {
281
+ if (!toolInput || typeof toolInput !== 'object') return toolInput;
282
+ const normalizedToolName = String(rawToolName || '').toLowerCase();
283
+ const next = { ...toolInput };
284
+
285
+ if (next.file_path && typeof next.file_path === 'string') {
286
+ if (
287
+ normalizedToolName === 'read_file' ||
288
+ normalizedToolName === 'write_file' ||
289
+ normalizedToolName === 'replace'
290
+ ) {
291
+ next.file_path = resolveCanonicalProjectFilePath(next.file_path);
292
+ }
293
+ }
294
+
295
+ return next;
296
+ }
297
+
298
+ function parseTodosFromMarkdown(markdown) {
299
+ if (!markdown || typeof markdown !== 'string') return [];
300
+ const lines = markdown.split('\n');
301
+ const todos = [];
302
+ for (const line of lines) {
303
+ const m = line.match(/^\s*-\s*\[( |x|X|~|>|-)\]\s+(.+?)\s*$/);
304
+ if (!m) continue;
305
+ const marker = m[1];
306
+ const description = m[2].replace(/<!--.*?-->/g, '').trim();
307
+ if (!description) continue;
308
+ let status = 'pending';
309
+ if (marker.toLowerCase() === 'x') status = 'completed';
310
+ else if (marker === '>' || marker === '~') status = 'in_progress';
311
+ else if (marker === '-') status = 'cancelled';
312
+ todos.push({ description, status });
313
+ }
314
+ return todos;
315
+ }
316
+
317
+ function extractTodosFromShellCommand(command) {
318
+ if (!command || typeof command !== 'string') return [];
319
+ if (!/todos\.md/.test(command)) return [];
320
+ const heredocMatch = command.match(/cat\s*<<\s*EOF\s*>\s*[^\n]*todos\.md\s*\n([\s\S]*?)\nEOF/m);
321
+ if (!heredocMatch) return [];
322
+ return parseTodosFromMarkdown(heredocMatch[1]);
323
+ }
324
+
325
+ async function enrichListDirectoryResult(toolContext, outputText, workingDir) {
326
+ if (!toolContext) return outputText;
327
+ const rawName = String(toolContext.rawToolName || '').toLowerCase();
328
+ if (rawName !== 'list_directory') return outputText;
329
+
330
+ const dirInput = String(toolContext.toolInput?.dir_path || toolContext.toolInput?.path || '.');
331
+ const resolvedDir = path.isAbsolute(dirInput)
332
+ ? path.resolve(dirInput)
333
+ : path.resolve(workingDir, dirInput);
334
+ const normalizedRoot = path.resolve(workingDir) + path.sep;
335
+ if (!resolvedDir.startsWith(normalizedRoot) && resolvedDir !== path.resolve(workingDir)) {
336
+ return outputText;
337
+ }
338
+
339
+ try {
340
+ const entries = await fs.readdir(resolvedDir, { withFileTypes: true });
341
+ const sortedEntries = [...entries].sort((a, b) => {
342
+ if (a.isDirectory() !== b.isDirectory()) {
343
+ return a.isDirectory() ? -1 : 1;
344
+ }
345
+ return a.name.localeCompare(b.name);
346
+ });
347
+ const visibleEntries = sortedEntries
348
+ .slice(0, 200)
349
+ .map((entry) => ({
350
+ name: entry.name,
351
+ isDirectory: entry.isDirectory()
352
+ }));
353
+
354
+ const baseRel = path.relative(workingDir, resolvedDir).replace(/\\/g, '/');
355
+ const relDir = baseRel && baseRel !== '' ? baseRel : '.';
356
+ const items = visibleEntries.map((entry) => {
357
+ const relPath = relDir === '.'
358
+ ? entry.name
359
+ : `${relDir}/${entry.name}`;
360
+ return {
361
+ name: entry.name,
362
+ path: `${relPath}${entry.isDirectory ? '/' : ''}`,
363
+ isDirectory: entry.isDirectory
364
+ };
365
+ });
366
+
367
+ return JSON.stringify(
368
+ {
369
+ summary: outputText,
370
+ directory: relDir,
371
+ files: items.map((item) => item.path),
372
+ items,
373
+ total: entries.length,
374
+ truncated: entries.length > visibleEntries.length
375
+ },
376
+ null,
377
+ 2
378
+ );
379
+ } catch {
380
+ return outputText;
381
+ }
382
+ }
383
+
384
+ async function handleGeminiAttachments(command, attachments, workingDir) {
385
+ const tempFilePaths = [];
386
+ let tempDir = null;
387
+ if (!Array.isArray(attachments) || attachments.length === 0) {
388
+ return { modifiedCommand: command, tempFilePaths, tempDir };
389
+ }
390
+
391
+ try {
392
+ tempDir = path.join(workingDir || process.cwd(), '.tmp', 'attachments', Date.now().toString());
393
+ await fs.mkdir(tempDir, { recursive: true });
394
+
395
+ for (const [index, item] of attachments.entries()) {
396
+ const data = String(item?.data || '');
397
+ const matches = data.match(/^data:([^;]+);base64,(.+)$/);
398
+ if (!matches) continue;
399
+ const [, mimeType, base64Data] = matches;
400
+ const filename = buildTempAttachmentFilename(index, item?.name, mimeType);
401
+ const filepath = path.join(tempDir, filename);
402
+ await fs.writeFile(filepath, Buffer.from(base64Data, 'base64'));
403
+ tempFilePaths.push(filepath);
404
+ }
405
+
406
+ if (tempFilePaths.length === 0) {
407
+ return { modifiedCommand: command, tempFilePaths, tempDir };
408
+ }
409
+
410
+ const referencedPaths = tempFilePaths.map((filePath) => {
411
+ return toPortableAtPath(filePath, workingDir || process.cwd());
412
+ });
413
+ const note = `\n\nAttached files:\n${referencedPaths.join('\n')}`;
414
+ return { modifiedCommand: `${command}${note}`, tempFilePaths, tempDir };
415
+ } catch (error) {
416
+ console.error('[Gemini] Failed to process attachments:', error.message);
417
+ return { modifiedCommand: command, tempFilePaths, tempDir };
418
+ }
419
+ }
420
+
421
+ async function cleanupGeminiTempFiles(tempFilePaths, tempDirs) {
422
+ if (!Array.isArray(tempFilePaths) || tempFilePaths.length === 0) return;
423
+ for (const filePath of tempFilePaths) {
424
+ try { await fs.unlink(filePath); } catch {}
425
+ }
426
+
427
+ if (!Array.isArray(tempDirs)) return;
428
+ for (const tempDir of new Set(tempDirs.filter(Boolean))) {
429
+ try { await fs.rm(tempDir, { recursive: true, force: true }); } catch {}
430
+ }
431
+ }
432
+
433
+ function isPlainObject(value) {
434
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
435
+ }
436
+
437
+ function deepMergeObjects(baseValue, overrideValue) {
438
+ if (!isPlainObject(baseValue)) {
439
+ return isPlainObject(overrideValue) ? { ...overrideValue } : overrideValue;
440
+ }
441
+
442
+ if (!isPlainObject(overrideValue)) {
443
+ return overrideValue;
444
+ }
445
+
446
+ const merged = { ...baseValue };
447
+ for (const [key, value] of Object.entries(overrideValue)) {
448
+ if (isPlainObject(value) && isPlainObject(merged[key])) {
449
+ merged[key] = deepMergeObjects(merged[key], value);
450
+ } else {
451
+ merged[key] = value;
452
+ }
453
+ }
454
+ return merged;
455
+ }
456
+
457
+ async function prepareGeminiThinkingSettings(model, thinkingMode, env = {}) {
458
+ const thinkingConfig = buildGeminiThinkingConfig(model, thinkingMode);
459
+ if (!thinkingConfig || !model) {
460
+ return null;
461
+ }
462
+
463
+ const aliasName = '__dr_claw_session_model';
464
+ const overrideSettings = {
465
+ modelConfigs: {
466
+ customAliases: {
467
+ [aliasName]: {
468
+ extends: model,
469
+ modelConfig: {
470
+ model,
471
+ generateContentConfig: {
472
+ thinkingConfig,
473
+ },
474
+ },
475
+ },
476
+ },
477
+ },
478
+ };
479
+
480
+ let mergedSettings = overrideSettings;
481
+ const inheritedSettingsPath = env.GEMINI_CLI_SYSTEM_SETTINGS_PATH;
482
+ if (inheritedSettingsPath) {
483
+ try {
484
+ const inheritedContent = await fs.readFile(inheritedSettingsPath, 'utf8');
485
+ const inheritedSettings = JSON.parse(inheritedContent);
486
+ mergedSettings = deepMergeObjects(inheritedSettings, overrideSettings);
487
+ } catch (error) {
488
+ console.warn(`[Gemini] Failed to read inherited system settings: ${error.message}`);
489
+ }
490
+ }
491
+
492
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'dr-claw-gemini-settings-'));
493
+ const settingsPath = path.join(tempDir, 'settings.json');
494
+ await fs.writeFile(settingsPath, JSON.stringify(mergedSettings, null, 2), 'utf8');
495
+
496
+ return {
497
+ cliModel: aliasName,
498
+ settingsPath,
499
+ tempDir,
500
+ };
501
+ }
502
+
503
+ /**
504
+ * Ensures a session directory exists and creates a basic JSONL metadata file if it doesn't.
505
+ * This helps Dr. Claw discover the session even if the CLI hasn't written to it yet.
506
+ */
507
+ async function syncSessionMetadata(sessionId, projectPath, sessionMode = 'research') {
508
+ if (!sessionId || !projectPath) return;
509
+
510
+ const geminiSessionsDir = path.join(os.homedir(), '.gemini', 'sessions');
511
+ const sessionFile = path.join(geminiSessionsDir, `${sessionId}.jsonl`);
512
+
513
+ try {
514
+ await fs.mkdir(geminiSessionsDir, { recursive: true });
515
+
516
+ // Check if file already exists
517
+ try {
518
+ await fs.access(sessionFile);
519
+ // Already exists, we don't want to overwrite real history
520
+ return;
521
+ } catch (e) {
522
+ // File doesn't exist, create it with metadata
523
+ const timestamp = new Date().toISOString();
524
+ const initialEntry = {
525
+ type: 'session_meta',
526
+ payload: {
527
+ id: sessionId,
528
+ cwd: projectPath,
529
+ timestamp,
530
+ sessionMode: sessionMode || 'research',
531
+ },
532
+ cwd: projectPath, // Compatibility
533
+ timestamp,
534
+ };
535
+
536
+ await fs.writeFile(sessionFile, JSON.stringify(initialEntry) + '\n', 'utf8');
537
+ console.log(`[Gemini] Synced session metadata to ${sessionFile}`);
538
+ }
539
+ } catch (error) {
540
+ console.error(`[Gemini] Failed to sync session metadata: ${error.message}`);
541
+ }
542
+ }
543
+
544
+ /**
545
+ * Executes a Gemini CLI query
546
+ * @param {string} command - User prompt/command
547
+ * @param {Object} options - Query options
548
+ * @param {Object} ws - WebSocket connection
549
+ * @returns {Promise<void>}
550
+ */
551
+ export async function spawnGemini(command, options = {}, ws) {
552
+ return new Promise(async (resolve, reject) => {
553
+ const { sessionId, projectPath, cwd, model, images, attachments, permissionMode, thinkingMode, toolsSettings, sessionMode, stageTagKeys, stageTagSource = 'task_context', env } = options;
554
+ let capturedSessionId = sessionId;
555
+ let sessionCreatedSent = false;
556
+ let messageStartedSent = false;
557
+ let contentBlockStarted = false;
558
+ let currentBlockIndex = 0;
559
+ let messageBuffer = '';
560
+ let tempFilePaths = [];
561
+ const tempDirs = [];
562
+ // Default: do not hard-require native write_todos.
563
+ // Keep compatibility with markdown/shim flows unless explicitly enabled.
564
+ const requireWriteTodos = Boolean(toolsSettings?.enforceNativeTodos) &&
565
+ (permissionMode === 'plan' || isLikelyMultiStepRequest(command));
566
+ let hasNativeTodoWrite = false;
567
+ const cleanedInitialUserCommand = stripInternalContextPrefix(String(command || ''));
568
+ let initialUserCommandSaved = false;
569
+ let lastSentGeminiErrorSummary = '';
570
+ let policyViolationTriggered = false;
571
+
572
+ const workingDir = cwd || projectPath || process.cwd();
573
+
574
+ // Synchronous (better-sqlite3) — no await needed.
575
+ if (sessionId && workingDir) {
576
+ applyStageTagsToSession({
577
+ sessionId,
578
+ projectPath: workingDir,
579
+ stageTagKeys,
580
+ source: stageTagSource,
581
+ });
582
+ }
583
+
584
+ // Keep Gemini session bootstrap parity with Claude sessions:
585
+ // ensure skill links and instruction templates exist in project workspace.
586
+ if (workingDir) {
587
+ try {
588
+ await ensureProjectSkillLinks(workingDir);
589
+ await writeProjectTemplates(workingDir);
590
+ } catch (err) {
591
+ console.warn('[gemini-cli] Failed to initialize project skills/templates:', err.message);
592
+ }
593
+ }
594
+
595
+ // Track allowed/disallowed tools locally for this session
596
+ const sessionAllowedTools = permissionMode === 'plan'
597
+ ? ensurePlanModeAllowedTools([...(toolsSettings?.allowedTools || [])])
598
+ : [...(toolsSettings?.allowedTools || [])];
599
+ const sessionDisallowedTools = [...(toolsSettings?.disallowedTools || [])];
600
+
601
+ // Build Gemini CLI command
602
+ const args = [];
603
+
604
+ if (sessionId && !sessionId.startsWith('new-session-')) {
605
+ args.push('--resume', sessionId);
606
+ }
607
+
608
+ if (command && command.trim()) {
609
+ const effectiveAttachments = attachments || images;
610
+ const attachmentResult = await handleGeminiAttachments(command, effectiveAttachments, workingDir);
611
+ tempFilePaths = attachmentResult.tempFilePaths;
612
+ if (attachmentResult.tempDir) {
613
+ tempDirs.push(attachmentResult.tempDir);
614
+ }
615
+ const effectivePrompt = `${attachmentResult.modifiedCommand}`;
616
+ args.push('--prompt', effectivePrompt);
617
+
618
+ // Keep Gemini CLI in yolo mode internally and enforce policy in our own approval hook.
619
+ // In non-interactive server sessions, Gemini's own policy prompts can deny tools unexpectedly.
620
+ args.push('--approval-mode', 'yolo');
621
+
622
+ const includeDirectories = [
623
+ path.join(process.cwd(), 'skills'),
624
+ path.join(workingDir, '.agents', 'skills'),
625
+ path.join(workingDir, '.claude', 'skills'),
626
+ path.join(workingDir, '.gemini', 'skills')
627
+ ];
628
+ for (const includeDir of includeDirectories) {
629
+ try {
630
+ await fs.access(includeDir);
631
+ args.push('--include-directories', includeDir);
632
+ } catch {
633
+ // Optional include dir, skip if absent.
634
+ }
635
+ }
636
+
637
+ // Request streaming JSON output
638
+ args.push('--output-format', 'stream-json');
639
+ }
640
+
641
+ const geminiCommand = process.env.GEMINI_CLI_PATH || 'gemini';
642
+
643
+ const cleanEnv = { ...(env || process.env) };
644
+ // Non-interactive JSON streaming: avoid terminal renderer hard-wrap artifacts.
645
+ cleanEnv.TERM = 'dumb';
646
+ cleanEnv.COLUMNS = '1000';
647
+ cleanEnv.LINES = '200';
648
+ delete cleanEnv.TERM_PROGRAM;
649
+ delete cleanEnv.TERM_PROGRAM_VERSION;
650
+ delete cleanEnv.ITERM_SESSION_ID;
651
+
652
+ const thinkingSettings = await prepareGeminiThinkingSettings(model, thinkingMode, cleanEnv);
653
+ const effectiveCliModel = thinkingSettings?.cliModel || model;
654
+ if (thinkingSettings?.settingsPath) {
655
+ cleanEnv.GEMINI_CLI_SYSTEM_SETTINGS_PATH = thinkingSettings.settingsPath;
656
+ tempFilePaths.push(thinkingSettings.settingsPath);
657
+ if (thinkingSettings.tempDir) {
658
+ tempDirs.push(thinkingSettings.tempDir);
659
+ }
660
+ }
661
+
662
+ if (effectiveCliModel && command && command.trim()) {
663
+ args.splice(2, 0, '--model', effectiveCliModel);
664
+ }
665
+
666
+ const escapedArgs = args.map(a => a.includes(' ') ? `"${a.replace(/"/g, '\\"')}"` : a);
667
+ console.log(`[Gemini] Spawning (YOLO-Control): ${geminiCommand} ${escapedArgs.join(' ')}`);
668
+ console.log(`[Gemini] Working directory: ${workingDir}`);
669
+
670
+ let geminiProcess;
671
+ try {
672
+ geminiProcess = spawnFunction(geminiCommand, args, {
673
+ cwd: workingDir,
674
+ stdio: ['pipe', 'pipe', 'pipe'],
675
+ env: cleanEnv,
676
+ detached: process.platform !== 'win32'
677
+ });
678
+ } catch (spawnError) {
679
+ console.error('[Gemini] Spawn call failed:', spawnError);
680
+ ws.send({ type: 'gemini-error', error: `Failed to start Gemini CLI: ${spawnError.message}`, sessionId: capturedSessionId || sessionId || null });
681
+ reject(spawnError);
682
+ return;
683
+ }
684
+
685
+ if (!geminiProcess || !geminiProcess.pid) {
686
+ console.error('[Gemini] Process failed to spawn (no PID)');
687
+ ws.send({ type: 'gemini-error', error: 'Failed to start Gemini CLI process', sessionId: capturedSessionId || sessionId || null });
688
+ reject(new Error('Failed to start Gemini CLI process'));
689
+ return;
690
+ }
691
+
692
+ console.log(`[Gemini] Process spawned with PID: ${geminiProcess.pid}`);
693
+
694
+ const initialKey = capturedSessionId || `temp-${Date.now()}`;
695
+ const startTimeValue = Date.now();
696
+
697
+ const sessionData = {
698
+ process: geminiProcess,
699
+ sessionId: capturedSessionId,
700
+ startTime: startTimeValue,
701
+ options,
702
+ sessionAllowedTools,
703
+ sessionDisallowedTools
704
+ };
705
+
706
+ const statusHeartbeat = setInterval(() => {
707
+ ws.send({
708
+ type: 'gemini-status',
709
+ data: { status: 'Working...', can_interrupt: true, startTime: sessionData.startTime },
710
+ sessionId: capturedSessionId || sessionId || null
711
+ });
712
+ }, 2000);
713
+
714
+ sessionData.heartbeat = statusHeartbeat;
715
+ activeGeminiSessions.set(initialKey, sessionData);
716
+
717
+ ws.send({
718
+ type: 'gemini-status',
719
+ data: { status: 'Working...', can_interrupt: true, startTime: startTimeValue },
720
+ sessionId: capturedSessionId || sessionId || null
721
+ });
722
+
723
+ const sendLifecycleStart = (id) => {
724
+ if (!messageStartedSent) {
725
+ messageStartedSent = true;
726
+ ws.send({
727
+ type: 'gemini-response',
728
+ data: {
729
+ type: 'message_start',
730
+ message: { id: `msg_gemini_${Date.now()}`, role: 'assistant', content: [], model: model || 'gemini' },
731
+ startTime: sessionData.startTime
732
+ },
733
+ sessionId: id || capturedSessionId || sessionId || null
734
+ });
735
+ }
736
+ };
737
+
738
+ const sendContentBlockStart = (id, type = 'text', index = 0) => {
739
+ if (type === 'text' && contentBlockStarted) return;
740
+ if (type === 'text') contentBlockStarted = true;
741
+ currentBlockIndex = index;
742
+ ws.send({
743
+ type: 'gemini-response',
744
+ data: {
745
+ type: 'content_block_start',
746
+ index: index,
747
+ content_block: type === 'text' ? { type: 'text', text: '' } : { type: 'tool_use', id: `tool_${Date.now()}`, name: '', input: {} },
748
+ startTime: sessionData.startTime
749
+ },
750
+ sessionId: id || capturedSessionId || sessionId || null
751
+ });
752
+ };
753
+
754
+ const sendContentBlockStop = (index) => {
755
+ ws.send({
756
+ type: 'gemini-response',
757
+ data: {
758
+ type: 'content_block_stop',
759
+ index: index !== undefined ? index : currentBlockIndex
760
+ },
761
+ sessionId: capturedSessionId || sessionId || null
762
+ });
763
+ };
764
+
765
+ const sendGeminiError = (error, details = undefined) => {
766
+ const summary = String(error || '').trim();
767
+ if (!summary || summary === lastSentGeminiErrorSummary) {
768
+ return;
769
+ }
770
+ lastSentGeminiErrorSummary = summary;
771
+
772
+ const { errorType, isRetryable } = classifyError(summary);
773
+
774
+ ws.send({
775
+ type: 'gemini-error',
776
+ error: summary,
777
+ errorType,
778
+ isRetryable,
779
+ ...(typeof details === 'string' && details.trim() ? { details } : {}),
780
+ sessionId: capturedSessionId || sessionId || null
781
+ });
782
+ };
783
+
784
+ const handleToolApproval = async (toolName, input, allowedTools = [], disallowedTools = []) => {
785
+ // Internal bypass if UI mode is actually bypass
786
+ if (permissionMode === 'bypassPermissions' || toolsSettings?.skipPermissions === true) return true;
787
+
788
+ const isDisallowed = disallowedTools.some(entry => matchesToolPermission(entry, toolName, input));
789
+ if (isDisallowed) return false;
790
+
791
+ if (permissionMode === 'plan') {
792
+ const allowedInPlan = ensurePlanModeAllowedTools(allowedTools);
793
+ return allowedInPlan.some(entry => matchesToolPermission(entry, toolName, input));
794
+ }
795
+
796
+ // Auto Edit Mode: Automatically approve editing tools
797
+ if (permissionMode === 'acceptEdits') {
798
+ const editTools = ['write_file', 'replace', 'insert_content', 'undo'];
799
+ if (editTools.includes(toolName)) {
800
+ console.log(`[Gemini] Auto-approving edit tool: ${toolName}`);
801
+ return true;
802
+ }
803
+ }
804
+
805
+ const isAllowed = allowedTools.some(entry => matchesToolPermission(entry, toolName, input));
806
+ if (isAllowed) return true;
807
+
808
+ const requestId = createRequestId();
809
+ ws.send({
810
+ type: 'claude-permission-request',
811
+ requestId,
812
+ toolName,
813
+ input,
814
+ sessionId: capturedSessionId || sessionId || null
815
+ });
816
+
817
+ const requiresInteraction = GEMINI_INTERACTIVE_TOOLS.has(String(toolName || ''));
818
+ const decision = await waitForToolApproval(requestId, {
819
+ timeoutMs: requiresInteraction ? 0 : undefined,
820
+ onCancel: (reason) => {
821
+ ws.send({
822
+ type: 'claude-permission-cancelled',
823
+ requestId,
824
+ reason,
825
+ sessionId: capturedSessionId || sessionId || null
826
+ });
827
+ }
828
+ });
829
+ if (!decision || decision.cancelled || !decision.allow) return false;
830
+
831
+ if (decision.rememberEntry && typeof decision.rememberEntry === 'string') {
832
+ if (!allowedTools.includes(decision.rememberEntry)) allowedTools.push(decision.rememberEntry);
833
+ const idx = disallowedTools.indexOf(decision.rememberEntry);
834
+ if (idx !== -1) disallowedTools.splice(idx, 1);
835
+ }
836
+ return true;
837
+ };
838
+
839
+ let processingQueue = Promise.resolve();
840
+ let leftOver = '';
841
+ let hasParsedStructuredOutput = false;
842
+ const toolCallContext = new Map();
843
+ const inferredProjectName = inferProjectName(workingDir, projectPath);
844
+
845
+ const appendToSessionFile = async (sid, entry) => {
846
+ const targetSid = capturedSessionId || sid || sessionId;
847
+ if (!targetSid || targetSid.startsWith('temp-')) return;
848
+
849
+ // If it's still a new-session placeholder, we can't save yet, but we'll try again later
850
+ // or we just rely on the fact that capturedSessionId will be set very quickly.
851
+ if (targetSid.startsWith('new-session-')) {
852
+ // console.log('[Gemini] Postponing save, still have new-session ID');
853
+ return;
854
+ }
855
+
856
+ const geminiSessionsDir = path.join(os.homedir(), '.gemini', 'sessions');
857
+ const sessionFile = path.join(geminiSessionsDir, `${targetSid}.jsonl`);
858
+ try {
859
+ await fs.mkdir(geminiSessionsDir, { recursive: true });
860
+ const data = JSON.stringify({ ...entry, timestamp: new Date().toISOString() }) + '\n';
861
+ await fs.appendFile(sessionFile, data, 'utf8');
862
+ console.log(`[Gemini] Saved ${entry.role || entry.type} to ${targetSid}`);
863
+ } catch (error) {
864
+ console.error(`[Gemini] Failed to append to session file: ${error.message}`);
865
+ }
866
+ };
867
+
868
+ const persistAssistantMessageBuffer = async (sid, content) => {
869
+ if (!content) return;
870
+ const normalizedEntries = normalizePersistedGeminiAssistantEntries(content);
871
+ for (const entry of normalizedEntries) {
872
+ await appendToSessionFile(sid, entry);
873
+ }
874
+ };
875
+
876
+ const processLine = async (line) => {
877
+ if (!line.trim()) return;
878
+ if (policyViolationTriggered) return;
879
+ try {
880
+ const response = JSON.parse(line);
881
+ hasParsedStructuredOutput = true;
882
+ switch (response.type) {
883
+ case 'init':
884
+ case 'session':
885
+ const sid = response.session_id || response.id;
886
+ if (sid && (!capturedSessionId || capturedSessionId.startsWith('new-session-') || capturedSessionId.startsWith('temp-'))) {
887
+ const oldKey = capturedSessionId || initialKey;
888
+ capturedSessionId = sid;
889
+
890
+ // Persist metadata to filesystem so Dr. Claw can discover it on refresh
891
+ await syncSessionMetadata(capturedSessionId, workingDir, sessionMode);
892
+ await persistGeminiSessionMetadata(capturedSessionId, workingDir, sessionMode);
893
+
894
+ // NEW: If we have an initial command, save it now that we have a real SID
895
+ if (cleanedInitialUserCommand) {
896
+ await appendToSessionFile(capturedSessionId, {
897
+ role: 'user',
898
+ content: cleanedInitialUserCommand,
899
+ type: 'message'
900
+ });
901
+ initialUserCommandSaved = true;
902
+ }
903
+
904
+ if (oldKey !== capturedSessionId) {
905
+ const sessionData = activeGeminiSessions.get(oldKey);
906
+ if (sessionData) {
907
+ activeGeminiSessions.delete(oldKey);
908
+ sessionData.sessionId = capturedSessionId;
909
+ activeGeminiSessions.set(capturedSessionId, sessionData);
910
+ }
911
+ }
912
+ if (ws.setSessionId && typeof ws.setSessionId === 'function') ws.setSessionId(capturedSessionId);
913
+ if (!sessionCreatedSent) {
914
+ sessionCreatedSent = true;
915
+ recordIndexedSession({
916
+ sessionId: capturedSessionId,
917
+ provider: 'gemini',
918
+ projectPath: workingDir,
919
+ sessionMode: sessionMode || 'research',
920
+ stageTagKeys,
921
+ tagSource: stageTagSource,
922
+ });
923
+ ws.send({ type: 'session-created', sessionId: capturedSessionId, provider: 'gemini', mode: sessionMode || 'research' });
924
+ }
925
+ }
926
+ break;
927
+
928
+ case 'message':
929
+ if (response.role && response.content) {
930
+ const contentText = typeof response.content === 'string'
931
+ ? response.content
932
+ : Array.isArray(response.content)
933
+ ? response.content.map((part) => part?.text || (typeof part === 'string' ? part : '')).join('')
934
+ : '';
935
+
936
+ if (response.role === 'assistant' && contentText) {
937
+ sendLifecycleStart();
938
+ sendContentBlockStart(null, 'text', 0);
939
+ messageBuffer += contentText;
940
+ ws.send({
941
+ type: 'gemini-response',
942
+ data: { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: contentText } },
943
+ sessionId: capturedSessionId || sessionId || null
944
+ });
945
+ } else {
946
+ const sanitizedContent = response.role === 'user'
947
+ ? sanitizePersistedGeminiContent(response.content)
948
+ : response.content;
949
+ const sanitizedContentText = typeof sanitizedContent === 'string'
950
+ ? sanitizedContent
951
+ : Array.isArray(sanitizedContent)
952
+ ? sanitizedContent.map((part) => part?.text || (typeof part === 'string' ? part : '')).join('')
953
+ : '';
954
+ if (
955
+ response.role === 'user' &&
956
+ initialUserCommandSaved &&
957
+ cleanedInitialUserCommand &&
958
+ typeof sanitizedContentText === 'string' &&
959
+ sanitizedContentText.trim() === cleanedInitialUserCommand.trim()
960
+ ) {
961
+ break;
962
+ }
963
+ await appendToSessionFile(capturedSessionId || sessionId || initialKey, {
964
+ role: response.role,
965
+ content: sanitizedContent,
966
+ type: 'message'
967
+ });
968
+ }
969
+ }
970
+ break;
971
+
972
+ case 'content':
973
+ case 'chunk':
974
+ const text = response.text || response.content || response.delta;
975
+ if (text && response.role !== 'user') {
976
+ sendLifecycleStart();
977
+ sendContentBlockStart(null, 'text', 0);
978
+ messageBuffer += text;
979
+ ws.send({
980
+ type: 'gemini-response',
981
+ data: { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: text } },
982
+ sessionId: capturedSessionId || sessionId || null
983
+ });
984
+ }
985
+ break;
986
+
987
+ case 'tool_use':
988
+ case 'tool_call':
989
+ case 'call':
990
+ sendLifecycleStart();
991
+ const toolIndex = 1;
992
+ const rawToolName = response.name || response.tool_name;
993
+ const toolName = normalizeGeminiToolName(rawToolName);
994
+ const originalToolInput = response.parameters || response.input || response.arguments;
995
+ const toolInput = normalizeGeminiToolInput(rawToolName, originalToolInput);
996
+ const toolCallId = response.id || `tool_${Date.now()}`;
997
+ const parentToolUseId = response.parent_tool_use_id || response.parentToolUseId || null;
998
+ const rawNameNormalized = String(rawToolName || '').trim();
999
+ const policyTargetName = rawToolName || toolName;
1000
+
1001
+ if (
1002
+ rawNameNormalized.toLowerCase() === 'write_todos' ||
1003
+ rawNameNormalized.toLowerCase() === 'todo_write' ||
1004
+ String(toolName || '').toLowerCase() === 'todowrite'
1005
+ ) {
1006
+ hasNativeTodoWrite = true;
1007
+ }
1008
+
1009
+ if (requireWriteTodos && !hasNativeTodoWrite && !isAllowedBeforeTodos(rawToolName)) {
1010
+ policyViolationTriggered = true;
1011
+ sendGeminiError('Policy violation: multi-step tasks must call write_todos before executing other tools.');
1012
+ geminiProcess.kill('SIGKILL');
1013
+ break;
1014
+ }
1015
+
1016
+ if (permissionMode === 'plan' && GEMINI_PLAN_BLOCKED_TOOLS.has(policyTargetName)) {
1017
+ policyViolationTriggered = true;
1018
+ sendGeminiError(`Tool execution denied by plan policy: ${policyTargetName}`);
1019
+ geminiProcess.kill('SIGKILL');
1020
+ break;
1021
+ }
1022
+ toolCallContext.set(toolCallId, {
1023
+ rawToolName,
1024
+ normalizedToolName: toolName,
1025
+ toolInput,
1026
+ parentToolUseId
1027
+ });
1028
+
1029
+ await appendToSessionFile(capturedSessionId || sessionId || initialKey, {
1030
+ type: 'tool_use',
1031
+ toolName,
1032
+ rawToolName,
1033
+ toolInput,
1034
+ toolCallId,
1035
+ parentToolUseId
1036
+ });
1037
+
1038
+ ws.send({
1039
+ type: 'gemini-response',
1040
+ data: {
1041
+ role: 'assistant',
1042
+ ...(parentToolUseId ? { parentToolUseId } : {}),
1043
+ content: [
1044
+ {
1045
+ type: 'tool_use',
1046
+ id: toolCallId,
1047
+ name: toolName,
1048
+ input: toolInput
1049
+ }
1050
+ ]
1051
+ },
1052
+ sessionId: capturedSessionId || sessionId || null
1053
+ });
1054
+
1055
+ // Compatibility shim: when Gemini manages todo list via markdown file writes
1056
+ // rather than write_todos tool, synthesize TodoWrite card for UI parity.
1057
+ if ((rawToolName === 'write_file' || rawToolName === 'replace') && typeof toolInput?.file_path === 'string' && /(?:^|\/)\.pipeline\/tasks\/todos\.md$/.test(toolInput.file_path)) {
1058
+ const todos = parseTodosFromMarkdown(String(toolInput?.content || toolInput?.new_string || ''));
1059
+ if (todos.length > 0) {
1060
+ const syntheticId = `todo_${Date.now()}`;
1061
+ ws.send({
1062
+ type: 'gemini-response',
1063
+ data: {
1064
+ role: 'assistant',
1065
+ ...(parentToolUseId ? { parentToolUseId } : {}),
1066
+ content: [{ type: 'tool_use', id: syntheticId, name: 'TodoWrite', input: { todos } }]
1067
+ },
1068
+ sessionId: capturedSessionId || sessionId || null
1069
+ });
1070
+ ws.send({
1071
+ type: 'gemini-response',
1072
+ data: {
1073
+ role: 'user',
1074
+ content: [{ type: 'tool_result', tool_use_id: syntheticId, content: 'Todo list updated', is_error: false }]
1075
+ },
1076
+ sessionId: capturedSessionId || sessionId || null
1077
+ });
1078
+ }
1079
+ }
1080
+
1081
+ if (rawToolName === 'run_shell_command') {
1082
+ const todos = extractTodosFromShellCommand(String(toolInput?.command || toolInput?.cmd || ''));
1083
+ if (todos.length > 0) {
1084
+ const syntheticId = `todo_${Date.now()}`;
1085
+ ws.send({
1086
+ type: 'gemini-response',
1087
+ data: {
1088
+ role: 'assistant',
1089
+ ...(parentToolUseId ? { parentToolUseId } : {}),
1090
+ content: [{ type: 'tool_use', id: syntheticId, name: 'TodoWrite', input: { todos } }]
1091
+ },
1092
+ sessionId: capturedSessionId || sessionId || null
1093
+ });
1094
+ ws.send({
1095
+ type: 'gemini-response',
1096
+ data: {
1097
+ role: 'user',
1098
+ content: [{ type: 'tool_result', tool_use_id: syntheticId, content: 'Todo list updated', is_error: false }]
1099
+ },
1100
+ sessionId: capturedSessionId || sessionId || null
1101
+ });
1102
+ }
1103
+ }
1104
+
1105
+ ws.send({
1106
+ type: 'gemini-response',
1107
+ data: {
1108
+ type: 'content_block_start',
1109
+ index: toolIndex,
1110
+ content_block: { id: toolCallId, name: toolName, input: toolInput }
1111
+ },
1112
+ sessionId: capturedSessionId || sessionId || null
1113
+ });
1114
+ currentBlockIndex = toolIndex;
1115
+
1116
+ const currentSessionData = activeGeminiSessions.get(capturedSessionId || initialKey);
1117
+ const approved = await handleToolApproval(
1118
+ rawToolName || toolName,
1119
+ toolInput,
1120
+ currentSessionData?.sessionAllowedTools || sessionAllowedTools,
1121
+ currentSessionData?.sessionDisallowedTools || sessionDisallowedTools
1122
+ );
1123
+
1124
+ if (!approved) {
1125
+ ws.send({
1126
+ type: 'gemini-error',
1127
+ error: `Tool '${toolName}' was denied by user. Aborting session for safety.`,
1128
+ sessionId: capturedSessionId || sessionId || null
1129
+ });
1130
+ geminiProcess.kill('SIGKILL');
1131
+ }
1132
+ break;
1133
+
1134
+ case 'tool_result':
1135
+ if (response.output || response.content) {
1136
+ const rawResult = response.output !== undefined ? response.output : response.content;
1137
+ const baseOutputText = typeof rawResult === 'string' ? rawResult : JSON.stringify(rawResult, null, 2);
1138
+ const resultToolCallId = response.id || response.tool_use_id;
1139
+ const ctx = resultToolCallId ? toolCallContext.get(resultToolCallId) : null;
1140
+ const isError = Boolean(response.is_error || response.error);
1141
+ const outputText = await enrichListDirectoryResult(ctx, baseOutputText, workingDir);
1142
+
1143
+ await appendToSessionFile(capturedSessionId || sessionId || initialKey, {
1144
+ type: 'tool_result',
1145
+ output: outputText,
1146
+ toolCallId: resultToolCallId,
1147
+ parentToolUseId: ctx?.parentToolUseId || null,
1148
+ isError
1149
+ });
1150
+
1151
+ if (resultToolCallId) {
1152
+ ws.send({
1153
+ type: 'gemini-response',
1154
+ data: {
1155
+ role: 'user',
1156
+ ...(ctx?.parentToolUseId ? { parentToolUseId: ctx.parentToolUseId } : {}),
1157
+ content: [
1158
+ {
1159
+ type: 'tool_result',
1160
+ tool_use_id: resultToolCallId,
1161
+ content: outputText,
1162
+ is_error: isError
1163
+ }
1164
+ ]
1165
+ },
1166
+ sessionId: capturedSessionId || sessionId || null
1167
+ });
1168
+ }
1169
+
1170
+ if (shouldNotifyTaskMasterRefresh(ctx?.rawToolName, ctx?.toolInput, outputText) && inferredProjectName) {
1171
+ ws.send({
1172
+ type: 'taskmaster-tasks-updated',
1173
+ projectName: inferredProjectName,
1174
+ provider: 'gemini',
1175
+ source: 'gemini-tool-result',
1176
+ timestamp: new Date().toISOString()
1177
+ });
1178
+ }
1179
+
1180
+ if (shouldNotifyProjectRefresh(ctx?.rawToolName, ctx?.toolInput, outputText) && inferredProjectName) {
1181
+ ws.send({
1182
+ type: 'taskmaster-project-updated',
1183
+ projectName: inferredProjectName,
1184
+ provider: 'gemini',
1185
+ source: 'gemini-tool-result',
1186
+ timestamp: new Date().toISOString()
1187
+ });
1188
+ }
1189
+
1190
+ if (resultToolCallId) {
1191
+ toolCallContext.delete(resultToolCallId);
1192
+ }
1193
+ }
1194
+ sendContentBlockStop(1);
1195
+ currentBlockIndex = 0;
1196
+ break;
1197
+
1198
+ case 'result':
1199
+ if (messageBuffer && (capturedSessionId || sessionId)) {
1200
+ await persistAssistantMessageBuffer(capturedSessionId || sessionId || initialKey, messageBuffer);
1201
+ messageBuffer = '';
1202
+ }
1203
+ break;
1204
+ case 'status':
1205
+ if (response.stats || response.status === 'completed') {
1206
+ if (response.stats) {
1207
+ await appendToSessionFile(capturedSessionId || sessionId || initialKey, {
1208
+ type: 'status',
1209
+ stats: response.stats
1210
+ });
1211
+ }
1212
+ if (response.stats) {
1213
+ ws.send({
1214
+ type: 'token-budget',
1215
+ data: {
1216
+ used: response.stats.total_tokens || response.stats.input_tokens + response.stats.output_tokens,
1217
+ total: GEMINI_DEFAULT_CONTEXT_WINDOW,
1218
+ breakdown: {
1219
+ input: response.stats.input_tokens || 0,
1220
+ output: response.stats.output_tokens || 0,
1221
+ cacheCreation: response.stats.cache_creation_input_tokens || 0,
1222
+ cacheRead: response.stats.cache_read_input_tokens || 0
1223
+ }
1224
+ },
1225
+ sessionId: capturedSessionId || sessionId || null
1226
+ });
1227
+ }
1228
+ sendContentBlockStop();
1229
+ ws.send({ type: 'gemini-response', data: { type: 'message_delta', delta: { stop_reason: 'end_turn' } }, sessionId: capturedSessionId || sessionId || null });
1230
+ ws.send({ type: 'gemini-response', data: { type: 'message_stop' }, sessionId: capturedSessionId || sessionId || null });
1231
+ }
1232
+ break;
1233
+
1234
+ case 'error':
1235
+ sendGeminiError(response.message);
1236
+ break;
1237
+ }
1238
+ } catch (parseError) {
1239
+ // Gemini stream-json should be JSONL. Ignore non-JSON diagnostics to avoid
1240
+ // polluting assistant markdown with hard-wrapped or noisy lines.
1241
+ if (!hasParsedStructuredOutput && line.trim()) {
1242
+ console.log(`[Gemini] Ignoring non-JSON stdout line before structured stream: ${line}`);
1243
+ }
1244
+ }
1245
+ };
1246
+
1247
+ geminiProcess.stdout.on('data', (data) => {
1248
+ const rawOutput = leftOver + data.toString();
1249
+ const lines = rawOutput.split('\n');
1250
+ leftOver = lines.pop() || '';
1251
+ for (const line of lines) {
1252
+ processingQueue = processingQueue.then(() => processLine(line));
1253
+ }
1254
+ });
1255
+
1256
+ geminiProcess.stderr.on('data', (data) => {
1257
+ const errorOutput = data.toString();
1258
+ if (policyViolationTriggered && /Policy violation: multi-step tasks must call write_todos/i.test(errorOutput)) {
1259
+ return;
1260
+ }
1261
+ if (/error|exception|fail|invalid|denied/i.test(errorOutput) && !/cached credentials/i.test(errorOutput)) {
1262
+ console.error(`[Gemini STDERR] ${errorOutput}`);
1263
+ const parsed = summarizeGeminiErrorOutput(errorOutput, model);
1264
+ sendGeminiError(parsed.summary, parsed.details);
1265
+ }
1266
+ });
1267
+
1268
+ geminiProcess.on('close', async (code) => {
1269
+ await processingQueue;
1270
+ if (messageBuffer && (capturedSessionId || sessionId || initialKey)) {
1271
+ await persistAssistantMessageBuffer(capturedSessionId || sessionId || initialKey, messageBuffer);
1272
+ messageBuffer = '';
1273
+ }
1274
+ const finalSessionId = capturedSessionId || sessionId || initialKey;
1275
+ const sessionData = activeGeminiSessions.get(finalSessionId);
1276
+ if (sessionData?.heartbeat) clearInterval(sessionData.heartbeat);
1277
+ await cleanupGeminiTempFiles(tempFilePaths, tempDirs);
1278
+ // Send completion event immediately so the UI can settle
1279
+ ws.send({ type: 'gemini-complete', sessionId: finalSessionId, exitCode: code, isNewSession: (!sessionId || sessionId.startsWith('new-session-')) && !!command });
1280
+ activeGeminiSessions.delete(finalSessionId);
1281
+ // Post-completion housekeeping — runs after the UI receives the completion signal
1282
+ if (workingDir && finalSessionId) {
1283
+ try {
1284
+ await reconcileGeminiSessionIndex(workingDir, {
1285
+ sessionId: finalSessionId,
1286
+ projectName: encodeProjectPath(workingDir),
1287
+ });
1288
+ } catch (error) {
1289
+ console.warn(`[Gemini] Failed to reconcile indexed session ${finalSessionId}:`, error.message);
1290
+ }
1291
+ }
1292
+ if (policyViolationTriggered || code === 0 || code === null) resolve();
1293
+ else reject(new Error(`Gemini CLI exited with code ${code}`));
1294
+ });
1295
+
1296
+ geminiProcess.on('error', (error) => {
1297
+ console.error('[Gemini] Process error:', error);
1298
+ const finalSessionId = capturedSessionId || sessionId || initialKey;
1299
+ const sessionData = activeGeminiSessions.get(finalSessionId);
1300
+ if (sessionData && sessionData.heartbeat) clearInterval(sessionData.heartbeat);
1301
+ activeGeminiSessions.delete(finalSessionId);
1302
+ cleanupGeminiTempFiles(tempFilePaths, tempDirs).catch(() => {});
1303
+ sendGeminiError(error.message);
1304
+ reject(error);
1305
+ });
1306
+ geminiProcess.stdin.end();
1307
+ });
1308
+ }
1309
+
1310
+ export function abortGeminiSession(sessionId) {
1311
+ let sessionData = activeGeminiSessions.get(sessionId);
1312
+ let targetId = sessionId;
1313
+ if (!sessionData) {
1314
+ const activeIds = Array.from(activeGeminiSessions.keys());
1315
+ if (activeIds.length === 1) {
1316
+ targetId = activeIds[0];
1317
+ sessionData = activeGeminiSessions.get(targetId);
1318
+ }
1319
+ }
1320
+ if (sessionData?.process) {
1321
+ try {
1322
+ if (sessionData.heartbeat) { clearInterval(sessionData.heartbeat); sessionData.heartbeat = null; }
1323
+ const proc = sessionData.process;
1324
+ if (process.platform !== 'win32' && proc.pid) {
1325
+ try { process.kill(-proc.pid, 'SIGINT'); } catch (e) { proc.kill('SIGINT'); }
1326
+ } else { proc.kill('SIGINT'); }
1327
+ setTimeout(() => {
1328
+ if (activeGeminiSessions.has(targetId)) {
1329
+ if (process.platform !== 'win32' && proc.pid) { try { process.kill(-proc.pid, 'SIGKILL'); } catch (e) { proc.kill('SIGKILL'); } }
1330
+ else { proc.kill('SIGKILL'); }
1331
+ activeGeminiSessions.delete(targetId);
1332
+ }
1333
+ }, 500);
1334
+ return true;
1335
+ } catch (err) {
1336
+ activeGeminiSessions.delete(targetId);
1337
+ return false;
1338
+ }
1339
+ }
1340
+ return false;
1341
+ }
1342
+
1343
+ export function isGeminiSessionActive(sessionId) { return activeGeminiSessions.has(sessionId); }
1344
+
1345
+ export function getGeminiSessionStartTime(sessionId) {
1346
+ const session = activeGeminiSessions.get(sessionId);
1347
+ return session ? session.startTime : null;
1348
+ }
1349
+
1350
+ export function getActiveGeminiSessions() {
1351
+ return Array.from(activeGeminiSessions.keys());
1352
+ }