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,890 @@
1
+ /**
2
+ * OpenRouter Integration — Full Agentic Provider
3
+ * ================================================
4
+ *
5
+ * Routes prompts to any model on OpenRouter with the same agentic capabilities
6
+ * as Claude, Gemini, and Codex: tool use (file I/O, shell, search), pipeline
7
+ * initialization, conversation persistence, and auto-research support.
8
+ *
9
+ * Uses the OpenAI-compatible chat completions API with function calling.
10
+ */
11
+
12
+ import crypto from 'crypto';
13
+ import { promises as fs } from 'fs';
14
+ import path from 'path';
15
+ import os from 'os';
16
+ import { exec } from 'child_process';
17
+ import { promisify } from 'util';
18
+ import { encodeProjectPath, ensureProjectSkillLinks, reconcileOpenRouterSessionIndex } from './projects.js';
19
+ import { writeProjectTemplates } from './templates/index.js';
20
+ import { classifyError } from '../shared/errorClassifier.js';
21
+ import { applyStageTagsToSession, recordIndexedSession } from './utils/sessionIndex.js';
22
+ import { createRequestId, waitForToolApproval, matchesToolPermission } from './utils/permissions.js';
23
+
24
+ const execAsync = promisify(exec);
25
+
26
+ const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1';
27
+ const MAX_AGENT_TURNS = 30;
28
+ const BASH_TIMEOUT_MS = 120_000;
29
+ const MAX_OUTPUT_CHARS = 100_000;
30
+ const SESSIONS_DIR_NAME = 'openrouter-sessions';
31
+
32
+ const activeOpenRouterSessions = new Map();
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Tool schemas (OpenAI function-calling format)
36
+ // ---------------------------------------------------------------------------
37
+
38
+ const TOOL_SCHEMAS = [
39
+ {
40
+ type: 'function',
41
+ function: {
42
+ name: 'Read',
43
+ description: 'Read the contents of a file at the given path. Returns numbered lines.',
44
+ parameters: {
45
+ type: 'object',
46
+ properties: {
47
+ path: { type: 'string', description: 'Absolute or project-relative file path' },
48
+ offset: { type: 'integer', description: 'Start line (1-indexed). Omit to read from the beginning.' },
49
+ limit: { type: 'integer', description: 'Max lines to return. Omit to read the whole file.' },
50
+ },
51
+ required: ['path'],
52
+ },
53
+ },
54
+ },
55
+ {
56
+ type: 'function',
57
+ function: {
58
+ name: 'Write',
59
+ description: 'Create or overwrite a file with the given content.',
60
+ parameters: {
61
+ type: 'object',
62
+ properties: {
63
+ path: { type: 'string', description: 'File path' },
64
+ content: { type: 'string', description: 'Full file content to write' },
65
+ },
66
+ required: ['path', 'content'],
67
+ },
68
+ },
69
+ },
70
+ {
71
+ type: 'function',
72
+ function: {
73
+ name: 'Edit',
74
+ description: 'Replace an exact substring in a file. old_str must appear exactly once.',
75
+ parameters: {
76
+ type: 'object',
77
+ properties: {
78
+ path: { type: 'string', description: 'File path' },
79
+ old_str: { type: 'string', description: 'Exact text to find (including whitespace)' },
80
+ new_str: { type: 'string', description: 'Replacement text' },
81
+ },
82
+ required: ['path', 'old_str', 'new_str'],
83
+ },
84
+ },
85
+ },
86
+ {
87
+ type: 'function',
88
+ function: {
89
+ name: 'Bash',
90
+ description: 'Execute a shell command. Use for git, npm, python, curl, etc.',
91
+ parameters: {
92
+ type: 'object',
93
+ properties: {
94
+ command: { type: 'string', description: 'Shell command to run' },
95
+ },
96
+ required: ['command'],
97
+ },
98
+ },
99
+ },
100
+ {
101
+ type: 'function',
102
+ function: {
103
+ name: 'Glob',
104
+ description: 'Find files matching a glob pattern.',
105
+ parameters: {
106
+ type: 'object',
107
+ properties: {
108
+ pattern: { type: 'string', description: 'Glob pattern, e.g. "**/*.py" or "src/**/*.ts"' },
109
+ path: { type: 'string', description: 'Directory to search (default: project root)' },
110
+ },
111
+ required: ['pattern'],
112
+ },
113
+ },
114
+ },
115
+ {
116
+ type: 'function',
117
+ function: {
118
+ name: 'Grep',
119
+ description: 'Search file contents for a regex pattern. Returns matching lines.',
120
+ parameters: {
121
+ type: 'object',
122
+ properties: {
123
+ pattern: { type: 'string', description: 'Regex pattern' },
124
+ path: { type: 'string', description: 'File or directory to search (default: project root)' },
125
+ include: { type: 'string', description: 'File glob filter, e.g. "*.py"' },
126
+ },
127
+ required: ['pattern'],
128
+ },
129
+ },
130
+ },
131
+ {
132
+ type: 'function',
133
+ function: {
134
+ name: 'LS',
135
+ description: 'List directory contents with file types.',
136
+ parameters: {
137
+ type: 'object',
138
+ properties: {
139
+ path: { type: 'string', description: 'Directory path (default: project root)' },
140
+ },
141
+ },
142
+ },
143
+ },
144
+ {
145
+ type: 'function',
146
+ function: {
147
+ name: 'WebFetch',
148
+ description: 'Fetch the text content of a URL.',
149
+ parameters: {
150
+ type: 'object',
151
+ properties: {
152
+ url: { type: 'string', description: 'URL to fetch' },
153
+ },
154
+ required: ['url'],
155
+ },
156
+ },
157
+ },
158
+ {
159
+ type: 'function',
160
+ function: {
161
+ name: 'WebSearch',
162
+ description: 'Search the web using a query. Returns summarized results.',
163
+ parameters: {
164
+ type: 'object',
165
+ properties: {
166
+ query: { type: 'string', description: 'Search query' },
167
+ },
168
+ required: ['query'],
169
+ },
170
+ },
171
+ },
172
+ {
173
+ type: 'function',
174
+ function: {
175
+ name: 'TodoWrite',
176
+ description: 'Create or update a structured task list for tracking progress.',
177
+ parameters: {
178
+ type: 'object',
179
+ properties: {
180
+ todos: {
181
+ type: 'array',
182
+ items: {
183
+ type: 'object',
184
+ properties: {
185
+ id: { type: 'string' },
186
+ content: { type: 'string' },
187
+ status: { type: 'string', enum: ['pending', 'in_progress', 'completed'] },
188
+ },
189
+ required: ['id', 'content', 'status'],
190
+ },
191
+ description: 'Array of TODO items',
192
+ },
193
+ },
194
+ required: ['todos'],
195
+ },
196
+ },
197
+ },
198
+ ];
199
+
200
+ const READ_ONLY_TOOLS = new Set(['Read', 'Glob', 'Grep', 'LS', 'WebFetch', 'WebSearch', 'TodoWrite']);
201
+
202
+ // ---------------------------------------------------------------------------
203
+ // WebSocket helper — matches Codex pattern (no double-serialization)
204
+ // ---------------------------------------------------------------------------
205
+
206
+ function sendMessage(ws, data) {
207
+ try {
208
+ if (ws.isSSEStreamWriter || ws.isWebSocketWriter) {
209
+ ws.send(data);
210
+ } else if (typeof ws.send === 'function') {
211
+ ws.send(JSON.stringify(data));
212
+ }
213
+ } catch (error) {
214
+ console.error('[OpenRouter] Error sending message:', error.message);
215
+ }
216
+ }
217
+
218
+ // ---------------------------------------------------------------------------
219
+ // Tool execution
220
+ // ---------------------------------------------------------------------------
221
+
222
+ async function executeTool(name, args, workingDir) {
223
+ const resolve = (p) => {
224
+ if (!p) return workingDir;
225
+ return path.isAbsolute(p) ? p : path.resolve(workingDir, p);
226
+ };
227
+ const trunc = (s) =>
228
+ s.length <= MAX_OUTPUT_CHARS ? s : s.slice(0, MAX_OUTPUT_CHARS) + `\n…(truncated, ${s.length} total chars)`;
229
+
230
+ try {
231
+ switch (name) {
232
+ case 'Read': {
233
+ const content = await fs.readFile(resolve(args.path), 'utf-8');
234
+ const lines = content.split('\n');
235
+ const start = Math.max(0, (args.offset || 1) - 1);
236
+ const end = args.limit ? start + args.limit : lines.length;
237
+ return trunc(
238
+ lines.slice(start, end).map((l, i) => `${String(start + i + 1).padStart(6)}|${l}`).join('\n'),
239
+ );
240
+ }
241
+
242
+ case 'Write': {
243
+ const fp = resolve(args.path);
244
+ await fs.mkdir(path.dirname(fp), { recursive: true });
245
+ await fs.writeFile(fp, args.content, 'utf-8');
246
+ return `Wrote ${args.content.length} chars → ${args.path}`;
247
+ }
248
+
249
+ case 'Edit': {
250
+ const fp = resolve(args.path);
251
+ const src = await fs.readFile(fp, 'utf-8');
252
+ const count = src.split(args.old_str).length - 1;
253
+ if (count === 0) return `Error: old_str not found in ${args.path}`;
254
+ if (count > 1) return `Error: old_str matches ${count} times — add more context to make it unique`;
255
+ await fs.writeFile(fp, src.replace(args.old_str, args.new_str), 'utf-8');
256
+ return `Edited ${args.path}`;
257
+ }
258
+
259
+ case 'Bash': {
260
+ try {
261
+ const { stdout, stderr } = await execAsync(args.command, {
262
+ cwd: workingDir,
263
+ timeout: BASH_TIMEOUT_MS,
264
+ maxBuffer: 5 * 1024 * 1024,
265
+ env: { ...process.env, HOME: os.homedir() },
266
+ });
267
+ let out = stdout || '';
268
+ if (stderr) out += (out ? '\n' : '') + `STDERR:\n${stderr}`;
269
+ return trunc(out || '(no output)');
270
+ } catch (err) {
271
+ return trunc(
272
+ `Exit code ${err.code ?? 1}\n${err.stdout || ''}${err.stderr ? '\nSTDERR:\n' + err.stderr : ''}\n${err.message}`,
273
+ );
274
+ }
275
+ }
276
+
277
+ case 'Glob': {
278
+ const dir = resolve(args.path);
279
+ const { stdout } = await execAsync(
280
+ `rg --files --glob '${args.pattern}' 2>/dev/null | head -300`,
281
+ { cwd: dir, timeout: 30_000, maxBuffer: 1024 * 1024 },
282
+ ).catch(() =>
283
+ execAsync(`find . -name '${args.pattern}' -type f 2>/dev/null | head -300`, {
284
+ cwd: dir, timeout: 30_000, maxBuffer: 1024 * 1024,
285
+ }),
286
+ );
287
+ return trunc(stdout || '(no matches)');
288
+ }
289
+
290
+ case 'Grep': {
291
+ const target = resolve(args.path);
292
+ let cmd = `rg --line-number --max-count 100 --max-columns 200`;
293
+ if (args.include) cmd += ` --glob '${args.include}'`;
294
+ cmd += ` '${args.pattern.replace(/'/g, "'\\''")}' '${target}'`;
295
+ try {
296
+ const { stdout } = await execAsync(cmd, {
297
+ cwd: workingDir, timeout: 30_000, maxBuffer: 2 * 1024 * 1024,
298
+ });
299
+ return trunc(stdout || '(no matches)');
300
+ } catch (err) {
301
+ if (err.code === 1) return '(no matches)';
302
+ return `Error: ${err.message}`;
303
+ }
304
+ }
305
+
306
+ case 'LS': {
307
+ const entries = await fs.readdir(resolve(args.path), { withFileTypes: true });
308
+ return entries.map((e) => `${e.isDirectory() ? 'd' : 'f'} ${e.name}`).join('\n') || '(empty)';
309
+ }
310
+
311
+ case 'WebFetch': {
312
+ const resp = await fetch(args.url, {
313
+ headers: { 'User-Agent': 'Dr. Claw Research Agent' },
314
+ signal: AbortSignal.timeout(30_000),
315
+ });
316
+ return trunc(await resp.text());
317
+ }
318
+
319
+ case 'WebSearch': {
320
+ const encoded = encodeURIComponent(args.query);
321
+ try {
322
+ const resp = await fetch(
323
+ `https://html.duckduckgo.com/html/?q=${encoded}`,
324
+ { headers: { 'User-Agent': 'Dr. Claw Research Agent' }, signal: AbortSignal.timeout(15_000) },
325
+ );
326
+ const html = await resp.text();
327
+ const results = [];
328
+ const regex = /<a rel="nofollow" class="result__a" href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/g;
329
+ let m;
330
+ while ((m = regex.exec(html)) !== null && results.length < 8) {
331
+ results.push({ url: m[1], title: m[2].replace(/<[^>]+>/g, '').trim() });
332
+ }
333
+ const snippetRe = /<a class="result__snippet"[^>]*>([\s\S]*?)<\/a>/g;
334
+ let idx = 0;
335
+ while ((m = snippetRe.exec(html)) !== null && idx < results.length) {
336
+ results[idx].snippet = m[1].replace(/<[^>]+>/g, '').trim();
337
+ idx++;
338
+ }
339
+ return results.length
340
+ ? results.map((r, i) => `${i + 1}. ${r.title}\n ${r.url}\n ${r.snippet || ''}`).join('\n\n')
341
+ : '(no results found)';
342
+ } catch (err) {
343
+ return `Search error: ${err.message}`;
344
+ }
345
+ }
346
+
347
+ case 'TodoWrite': {
348
+ return `TODO list updated:\n${(args.todos || []).map((t) => `- [${t.status}] ${t.content}`).join('\n')}`;
349
+ }
350
+
351
+ default:
352
+ return `Unknown tool: ${name}`;
353
+ }
354
+ } catch (error) {
355
+ return `Error executing ${name}: ${error.message}`;
356
+ }
357
+ }
358
+
359
+ // ---------------------------------------------------------------------------
360
+ // Tool permission checking (mirrors Gemini/Claude pattern)
361
+ // ---------------------------------------------------------------------------
362
+
363
+ async function checkToolPermission(toolName, toolArgs, permissionMode, allowedTools, disallowedTools, ws, sessionId, skipPermissions = false) {
364
+ if (permissionMode === 'bypassPermissions' || skipPermissions) return true;
365
+
366
+ if (disallowedTools.some((e) => matchesToolPermission(e, toolName, toolArgs))) return false;
367
+
368
+ if (permissionMode === 'plan') return READ_ONLY_TOOLS.has(toolName);
369
+
370
+ if (permissionMode === 'acceptEdits' && ['Write', 'Edit'].includes(toolName)) return true;
371
+
372
+ if (allowedTools.some((e) => matchesToolPermission(e, toolName, toolArgs))) return true;
373
+
374
+ if (READ_ONLY_TOOLS.has(toolName)) return true;
375
+
376
+ const requestId = createRequestId();
377
+ sendMessage(ws, {
378
+ type: 'claude-permission-request',
379
+ requestId,
380
+ toolName,
381
+ input: toolArgs,
382
+ sessionId,
383
+ });
384
+
385
+ const decision = await waitForToolApproval(requestId);
386
+ if (!decision || decision.cancelled || !decision.allow) return false;
387
+ if (decision.rememberEntry) allowedTools.push(decision.rememberEntry);
388
+ return true;
389
+ }
390
+
391
+ // ---------------------------------------------------------------------------
392
+ // System prompt builder
393
+ // ---------------------------------------------------------------------------
394
+
395
+ async function buildSystemPrompt(workingDir) {
396
+ const parts = [
397
+ `You are a powerful agentic AI research assistant. You operate in the project directory: ${workingDir}`,
398
+ '',
399
+ 'You have tools to read/write files, run shell commands, search code, and browse the web.',
400
+ 'Use them proactively to explore the project, gather information, and produce high-quality output.',
401
+ 'Always prefer using tools over guessing about file contents or project structure.',
402
+ '',
403
+ ];
404
+
405
+ try {
406
+ const agentsMd = await fs.readFile(path.join(workingDir, 'AGENTS.md'), 'utf-8');
407
+ if (agentsMd.trim()) parts.push('# Project Instructions (AGENTS.md)\n', agentsMd, '');
408
+ } catch {}
409
+
410
+ try {
411
+ const raw = await fs.readFile(path.join(workingDir, 'instance.json'), 'utf-8');
412
+ const inst = JSON.parse(raw);
413
+ if (inst.research_topic || inst.stages) {
414
+ parts.push('# Project Metadata (instance.json)\n', JSON.stringify(inst, null, 2), '');
415
+ }
416
+ } catch {}
417
+
418
+ try {
419
+ const skillsDir = path.join(workingDir, '.agents', 'skills');
420
+ const entries = await fs.readdir(skillsDir).catch(() => []);
421
+ if (entries.length > 0) {
422
+ parts.push(`# Available Skills (${entries.length})\n`);
423
+ for (const entry of entries.slice(0, 10)) {
424
+ try {
425
+ const md = await fs.readFile(path.join(skillsDir, entry, 'SKILL.md'), 'utf-8');
426
+ if (md.trim()) parts.push(`## Skill: ${entry}\n${md.slice(0, 3000)}\n`);
427
+ } catch {
428
+ parts.push(`- ${entry}`);
429
+ }
430
+ }
431
+ parts.push('');
432
+ }
433
+ } catch {}
434
+
435
+ return parts.join('\n');
436
+ }
437
+
438
+ // ---------------------------------------------------------------------------
439
+ // Session persistence (JSONL — mirrors Gemini pattern)
440
+ // ---------------------------------------------------------------------------
441
+
442
+ async function sessionsDir() {
443
+ const dir = path.join(os.homedir(), '.dr-claw', SESSIONS_DIR_NAME);
444
+ await fs.mkdir(dir, { recursive: true });
445
+ return dir;
446
+ }
447
+
448
+ async function appendSession(sessionId, entry) {
449
+ if (!sessionId || sessionId.startsWith('new-session-') || sessionId.startsWith('temp-')) return;
450
+ const dir = await sessionsDir();
451
+ await fs.appendFile(
452
+ path.join(dir, `${sessionId}.jsonl`),
453
+ JSON.stringify({ ...entry, ts: new Date().toISOString() }) + '\n',
454
+ 'utf-8',
455
+ );
456
+ }
457
+
458
+ async function loadHistory(sessionId) {
459
+ if (!sessionId) return [];
460
+ try {
461
+ const dir = await sessionsDir();
462
+ const raw = await fs.readFile(path.join(dir, `${sessionId}.jsonl`), 'utf-8');
463
+ const msgs = [];
464
+ for (const line of raw.trim().split('\n').filter(Boolean)) {
465
+ const e = JSON.parse(line);
466
+ if (e.role === 'user') msgs.push({ role: 'user', content: e.content });
467
+ else if (e.role === 'assistant') {
468
+ const msg = { role: 'assistant', content: e.content || null };
469
+ if (e.tool_calls) msg.tool_calls = e.tool_calls;
470
+ msgs.push(msg);
471
+ } else if (e.role === 'tool') {
472
+ msgs.push({ role: 'tool', tool_call_id: e.tool_call_id, content: e.content });
473
+ }
474
+ }
475
+ return msgs;
476
+ } catch {
477
+ return [];
478
+ }
479
+ }
480
+
481
+ // ---------------------------------------------------------------------------
482
+ // Streaming API call + response parser
483
+ // ---------------------------------------------------------------------------
484
+
485
+ async function streamApiCall(apiKey, model, messages, tools, signal) {
486
+ const body = { model, messages, stream: true, stream_options: { include_usage: true } };
487
+ if (tools?.length) {
488
+ body.tools = tools;
489
+ body.tool_choice = 'auto';
490
+ }
491
+ return fetch(`${OPENROUTER_BASE_URL}/chat/completions`, {
492
+ method: 'POST',
493
+ headers: {
494
+ Authorization: `Bearer ${apiKey}`,
495
+ 'Content-Type': 'application/json',
496
+ 'HTTP-Referer': 'https://github.com/OpenLAIR/dr-claw',
497
+ 'X-Title': 'Dr. Claw',
498
+ },
499
+ body: JSON.stringify(body),
500
+ signal,
501
+ });
502
+ }
503
+
504
+ async function consumeStream(response, { onText, onAbortCheck }) {
505
+ const reader = response.body.getReader();
506
+ const decoder = new TextDecoder();
507
+ let buffer = '';
508
+ let content = '';
509
+ const toolCalls = {};
510
+ let finishReason = null;
511
+ let usage = null;
512
+
513
+ while (true) {
514
+ const { done, value } = await reader.read();
515
+ if (done) break;
516
+ if (onAbortCheck?.()) { reader.cancel(); break; }
517
+
518
+ buffer += decoder.decode(value, { stream: true });
519
+ const lines = buffer.split('\n');
520
+ buffer = lines.pop() || '';
521
+
522
+ for (const line of lines) {
523
+ const trimmed = line.trim();
524
+ if (!trimmed || !trimmed.startsWith('data: ')) continue;
525
+ const payload = trimmed.slice(6);
526
+ if (payload === '[DONE]') continue;
527
+
528
+ try {
529
+ const parsed = JSON.parse(payload);
530
+ if (parsed.usage) usage = parsed.usage;
531
+ const choice = parsed.choices?.[0];
532
+ if (!choice) continue;
533
+ if (choice.finish_reason) finishReason = choice.finish_reason;
534
+
535
+ const delta = choice.delta;
536
+ if (delta?.content) {
537
+ content += delta.content;
538
+ onText?.(delta.content);
539
+ }
540
+ if (delta?.tool_calls) {
541
+ for (const tc of delta.tool_calls) {
542
+ const idx = tc.index;
543
+ if (!toolCalls[idx]) toolCalls[idx] = { id: '', name: '', arguments: '' };
544
+ if (tc.id) toolCalls[idx].id = tc.id;
545
+ if (tc.function?.name) toolCalls[idx].name = tc.function.name;
546
+ if (tc.function?.arguments) toolCalls[idx].arguments += tc.function.arguments;
547
+ }
548
+ }
549
+ } catch { /* skip malformed chunks */ }
550
+ }
551
+ }
552
+
553
+ return {
554
+ content,
555
+ toolCalls: Object.values(toolCalls).filter((tc) => tc.id && tc.name),
556
+ finishReason,
557
+ usage,
558
+ };
559
+ }
560
+
561
+ // ---------------------------------------------------------------------------
562
+ // Main entry point
563
+ // ---------------------------------------------------------------------------
564
+
565
+ export async function queryOpenRouter(command, options = {}, ws) {
566
+ const {
567
+ sessionId,
568
+ cwd,
569
+ projectPath,
570
+ model = 'anthropic/claude-sonnet-4',
571
+ env,
572
+ sessionMode,
573
+ stageTagKeys,
574
+ stageTagSource = 'task_context',
575
+ systemPrompt: customSystemPrompt,
576
+ permissionMode = 'bypassPermissions',
577
+ toolsSettings,
578
+ } = options;
579
+
580
+ const workingDirectory = cwd || projectPath || process.cwd();
581
+ const apiKey = env?.OPENROUTER_API_KEY || process.env.OPENROUTER_API_KEY;
582
+
583
+ if (!apiKey) {
584
+ sendMessage(ws, {
585
+ type: 'openrouter-error',
586
+ error: 'OPENROUTER_API_KEY is not set. Add it to your .env file or configure it in Settings.',
587
+ errorType: 'auth',
588
+ isRetryable: false,
589
+ sessionId,
590
+ });
591
+ return;
592
+ }
593
+
594
+ const currentSessionId = sessionId || `openrouter-${crypto.randomUUID()}`;
595
+ const abortController = new AbortController();
596
+ const allowedTools = [...(toolsSettings?.allowedTools || [])];
597
+ const disallowedTools = [...(toolsSettings?.disallowedTools || [])];
598
+
599
+ try {
600
+ // ── Pipeline initialization (same as Claude / Gemini) ──────────────
601
+ if (workingDirectory) {
602
+ try {
603
+ await ensureProjectSkillLinks(workingDirectory);
604
+ await writeProjectTemplates(workingDirectory);
605
+ } catch (err) {
606
+ console.warn('[OpenRouter] Project template init warning:', err.message);
607
+ }
608
+ }
609
+
610
+ // ── Session tracking ───────────────────────────────────────────────
611
+ if (sessionId && workingDirectory) {
612
+ applyStageTagsToSession({ sessionId, projectPath: workingDirectory, stageTagKeys, source: stageTagSource });
613
+ }
614
+
615
+ activeOpenRouterSessions.set(currentSessionId, {
616
+ status: 'running',
617
+ abortController,
618
+ startTime: Date.now(),
619
+ });
620
+
621
+ // Strip [Context: ...] prefixes to extract the user's actual text for the display name
622
+ const userText = (command || '').replace(/\s*\[Context:[^\]]*\]\s*/gi, '').trim();
623
+ const sessionDisplayName = userText.slice(0, 100) || null;
624
+
625
+ if (workingDirectory) {
626
+ recordIndexedSession({
627
+ sessionId: currentSessionId,
628
+ provider: 'openrouter',
629
+ projectPath: workingDirectory,
630
+ sessionMode: sessionMode || 'research',
631
+ displayName: sessionDisplayName,
632
+ stageTagKeys,
633
+ tagSource: stageTagSource,
634
+ });
635
+ }
636
+
637
+ // ── Notify frontend ────────────────────────────────────────────────
638
+ sendMessage(ws, {
639
+ type: 'session-created',
640
+ sessionId: currentSessionId,
641
+ provider: 'openrouter',
642
+ mode: sessionMode || 'research',
643
+ startTime: activeOpenRouterSessions.get(currentSessionId).startTime,
644
+ displayName: sessionDisplayName || 'OpenRouter Session',
645
+ projectName: workingDirectory ? encodeProjectPath(workingDirectory) : undefined,
646
+ });
647
+
648
+ // ── Build conversation ─────────────────────────────────────────────
649
+ const systemContent = customSystemPrompt || await buildSystemPrompt(workingDirectory);
650
+ const messages = [{ role: 'system', content: systemContent }];
651
+
652
+ if (sessionId) {
653
+ const history = await loadHistory(sessionId);
654
+ if (history.length) {
655
+ console.log(`[OpenRouter] Resumed session ${sessionId} with ${history.length} history messages`);
656
+ messages.push(...history);
657
+ }
658
+ }
659
+
660
+ messages.push({ role: 'user', content: command });
661
+ await appendSession(currentSessionId, { role: 'user', content: command }).catch(() => {});
662
+
663
+ const tools = permissionMode === 'plan'
664
+ ? TOOL_SCHEMAS.filter((t) => READ_ONLY_TOOLS.has(t.function.name))
665
+ : TOOL_SCHEMAS;
666
+
667
+ // ── Agent loop ─────────────────────────────────────────────────────
668
+ let turn = 0;
669
+ let noToolFallback = false;
670
+ let cumulativeTokens = 0;
671
+ const contextWindow = 200000;
672
+
673
+ while (turn < MAX_AGENT_TURNS) {
674
+ turn++;
675
+ const session = activeOpenRouterSessions.get(currentSessionId);
676
+ if (!session || session.status === 'aborted') break;
677
+
678
+ console.log(`[OpenRouter] Turn ${turn}/${MAX_AGENT_TURNS} · model=${model} · msgs=${messages.length}`);
679
+
680
+ const response = await streamApiCall(
681
+ apiKey, model, messages,
682
+ noToolFallback ? [] : tools,
683
+ abortController.signal,
684
+ );
685
+
686
+ if (!response.ok) {
687
+ const body = await response.text();
688
+ let msg = `OpenRouter API error (${response.status})`;
689
+ try { msg = JSON.parse(body).error?.message || msg; } catch {}
690
+
691
+ if (!noToolFallback && response.status === 400 && /tool|function/i.test(msg)) {
692
+ console.log('[OpenRouter] Model may not support tools — retrying without tools');
693
+ noToolFallback = true;
694
+ continue;
695
+ }
696
+
697
+ console.error(`[OpenRouter] API error: ${msg}`);
698
+ sendMessage(ws, {
699
+ type: 'openrouter-error',
700
+ error: msg,
701
+ errorType: response.status === 401 ? 'auth' : 'api',
702
+ isRetryable: response.status >= 500,
703
+ sessionId: currentSessionId,
704
+ });
705
+ return;
706
+ }
707
+
708
+ const result = await consumeStream(response, {
709
+ onText(delta) {
710
+ sendMessage(ws, {
711
+ type: 'openrouter-response',
712
+ sessionId: currentSessionId,
713
+ data: {
714
+ type: 'assistant_message',
715
+ message: { role: 'assistant', content: delta },
716
+ startTime: activeOpenRouterSessions.get(currentSessionId)?.startTime,
717
+ },
718
+ });
719
+ },
720
+ onAbortCheck() {
721
+ const s = activeOpenRouterSessions.get(currentSessionId);
722
+ return !s || s.status === 'aborted';
723
+ },
724
+ });
725
+
726
+ // ── Update token budget ───────────────────────────────────────────
727
+ if (result.usage) {
728
+ cumulativeTokens = (result.usage.prompt_tokens || 0) + (result.usage.completion_tokens || 0);
729
+ } else {
730
+ cumulativeTokens += (result.content?.length || 0) / 4;
731
+ for (const tc of result.toolCalls) {
732
+ cumulativeTokens += (tc.arguments?.length || 0) / 4;
733
+ }
734
+ }
735
+ sendMessage(ws, {
736
+ type: 'token-budget',
737
+ data: { used: Math.round(cumulativeTokens), total: contextWindow },
738
+ sessionId: currentSessionId,
739
+ });
740
+
741
+ // ── No tool calls → final answer, done ─────────────────────────
742
+ if (result.toolCalls.length === 0) {
743
+ if (result.content) {
744
+ await appendSession(currentSessionId, { role: 'assistant', content: result.content }).catch(() => {});
745
+ }
746
+ break;
747
+ }
748
+
749
+ // ── Tool calls → execute, add results, continue loop ───────────
750
+ console.log(`[OpenRouter] ${result.toolCalls.length} tool call(s) in turn ${turn}`);
751
+
752
+ const assistantMsg = {
753
+ role: 'assistant',
754
+ content: result.content || null,
755
+ tool_calls: result.toolCalls.map((tc) => ({
756
+ id: tc.id,
757
+ type: 'function',
758
+ function: { name: tc.name, arguments: tc.arguments },
759
+ })),
760
+ };
761
+ messages.push(assistantMsg);
762
+ await appendSession(currentSessionId, assistantMsg).catch(() => {});
763
+
764
+ // Send ALL tool calls in a single structured message (Claude-compatible format)
765
+ // so the frontend can render them using the same proven code path as Claude.
766
+ const structuredToolUseContent = result.toolCalls.map((tc) => {
767
+ let input;
768
+ try { input = JSON.parse(tc.arguments); } catch { input = {}; }
769
+ return { type: 'tool_use', id: tc.id, name: tc.name, input };
770
+ });
771
+ sendMessage(ws, {
772
+ type: 'openrouter-response',
773
+ sessionId: currentSessionId,
774
+ data: {
775
+ type: 'structured_turn',
776
+ message: { role: 'assistant', content: structuredToolUseContent },
777
+ },
778
+ });
779
+
780
+ // Execute each tool and send results as structured tool_result messages
781
+ for (const tc of result.toolCalls) {
782
+ let args;
783
+ try { args = JSON.parse(tc.arguments); } catch { args = {}; }
784
+
785
+ console.log(`[OpenRouter] → ${tc.name}(${JSON.stringify(args).slice(0, 120)})`);
786
+
787
+ const allowed = await checkToolPermission(
788
+ tc.name, args, permissionMode, allowedTools, disallowedTools, ws, currentSessionId,
789
+ toolsSettings?.skipPermissions === true,
790
+ );
791
+
792
+ const output = allowed
793
+ ? await executeTool(tc.name, args, workingDirectory)
794
+ : `Permission denied for tool: ${tc.name}`;
795
+
796
+ sendMessage(ws, {
797
+ type: 'openrouter-response',
798
+ sessionId: currentSessionId,
799
+ data: {
800
+ type: 'structured_result',
801
+ message: {
802
+ role: 'user',
803
+ content: [{
804
+ type: 'tool_result',
805
+ tool_use_id: tc.id,
806
+ content: output.slice(0, 2000),
807
+ is_error: output.startsWith('Error'),
808
+ }],
809
+ },
810
+ },
811
+ });
812
+
813
+ const toolMsg = { role: 'tool', tool_call_id: tc.id, content: output };
814
+ messages.push(toolMsg);
815
+ await appendSession(currentSessionId, toolMsg).catch(() => {});
816
+ }
817
+ }
818
+
819
+ if (turn >= MAX_AGENT_TURNS) {
820
+ console.warn(`[OpenRouter] Reached max agent turns (${MAX_AGENT_TURNS})`);
821
+ }
822
+
823
+ if (workingDirectory) {
824
+ try {
825
+ await reconcileOpenRouterSessionIndex(workingDirectory, {
826
+ sessionId: currentSessionId,
827
+ projectName: encodeProjectPath(workingDirectory),
828
+ });
829
+ } catch (err) {
830
+ console.warn('[OpenRouter] Session reconciliation warning:', err.message);
831
+ }
832
+ }
833
+
834
+ sendMessage(ws, { type: 'openrouter-complete', sessionId: currentSessionId, exitCode: 0 });
835
+ } catch (error) {
836
+ console.error('[OpenRouter] Error:', error.name, error.message);
837
+ if (error.name === 'AbortError') {
838
+ sendMessage(ws, { type: 'openrouter-complete', sessionId: currentSessionId, exitCode: 1, aborted: true });
839
+ } else {
840
+ const { errorType, isRetryable } = classifyError(error.message);
841
+ sendMessage(ws, {
842
+ type: 'openrouter-error',
843
+ error: error.message,
844
+ errorType,
845
+ isRetryable,
846
+ sessionId: currentSessionId,
847
+ });
848
+ }
849
+ } finally {
850
+ const session = activeOpenRouterSessions.get(currentSessionId);
851
+ if (session) session.status = 'completed';
852
+ }
853
+ }
854
+
855
+ // ---------------------------------------------------------------------------
856
+ // Session management exports
857
+ // ---------------------------------------------------------------------------
858
+
859
+ export function abortOpenRouterSession(sessionId) {
860
+ const session = activeOpenRouterSessions.get(sessionId);
861
+ if (!session) return false;
862
+ session.status = 'aborted';
863
+ session.abortController?.abort();
864
+ activeOpenRouterSessions.delete(sessionId);
865
+ return true;
866
+ }
867
+
868
+ export function isOpenRouterSessionActive(sessionId) {
869
+ return activeOpenRouterSessions.get(sessionId)?.status === 'running';
870
+ }
871
+
872
+ export function getOpenRouterSessionStartTime(sessionId) {
873
+ return activeOpenRouterSessions.get(sessionId)?.startTime || null;
874
+ }
875
+
876
+ export function getActiveOpenRouterSessions() {
877
+ return Array.from(activeOpenRouterSessions.entries())
878
+ .filter(([, s]) => s.status === 'running')
879
+ .map(([id, s]) => ({ sessionId: id, startTime: s.startTime }));
880
+ }
881
+
882
+ // Periodic cleanup (mirrors Codex pattern)
883
+ setInterval(() => {
884
+ const now = Date.now();
885
+ for (const [id, session] of activeOpenRouterSessions.entries()) {
886
+ if (session.status !== 'running' && now - (session.startTime || 0) > 30 * 60 * 1000) {
887
+ activeOpenRouterSessions.delete(id);
888
+ }
889
+ }
890
+ }, 5 * 60 * 1000);