@pixelbyte-software/pixcode 1.34.0 → 1.35.1

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 (247) hide show
  1. package/LICENSE +718 -718
  2. package/README.de.md +248 -248
  3. package/README.ja.md +240 -240
  4. package/README.ko.md +240 -240
  5. package/README.md +303 -303
  6. package/README.ru.md +248 -248
  7. package/README.tr.md +250 -250
  8. package/README.zh-CN.md +240 -240
  9. package/dist/api-docs.html +548 -395
  10. package/dist/assets/index-B8w57E1r.css +32 -0
  11. package/dist/assets/index-CBdsvGSR.js +854 -0
  12. package/dist/clear-cache.html +85 -85
  13. package/dist/convert-icons.md +52 -52
  14. package/dist/favicon.svg +8 -8
  15. package/dist/generate-icons.js +48 -48
  16. package/dist/icons/codex-white.svg +3 -3
  17. package/dist/icons/codex.svg +3 -3
  18. package/dist/icons/cursor-white.svg +11 -11
  19. package/dist/icons/icon-128x128.svg +9 -9
  20. package/dist/icons/icon-144x144.svg +9 -9
  21. package/dist/icons/icon-152x152.svg +9 -9
  22. package/dist/icons/icon-192x192.svg +9 -9
  23. package/dist/icons/icon-384x384.svg +9 -9
  24. package/dist/icons/icon-512x512.svg +9 -9
  25. package/dist/icons/icon-72x72.svg +9 -9
  26. package/dist/icons/icon-96x96.svg +9 -9
  27. package/dist/icons/icon-template.svg +9 -9
  28. package/dist/icons/qwen-logo.svg +14 -14
  29. package/dist/index.html +59 -59
  30. package/dist/logo.svg +12 -12
  31. package/dist/manifest.json +60 -60
  32. package/dist/openapi.yaml +1693 -1311
  33. package/dist/sw.js +124 -124
  34. package/dist-server/server/claude-sdk.js +38 -7
  35. package/dist-server/server/claude-sdk.js.map +1 -1
  36. package/dist-server/server/cli.js +107 -112
  37. package/dist-server/server/cli.js.map +1 -1
  38. package/dist-server/server/daemon/manager.js +33 -33
  39. package/dist-server/server/daemon-manager.js +159 -112
  40. package/dist-server/server/daemon-manager.js.map +1 -1
  41. package/dist-server/server/database/json-store.js +8 -5
  42. package/dist-server/server/database/json-store.js.map +1 -1
  43. package/dist-server/server/index.js +31 -10
  44. package/dist-server/server/index.js.map +1 -1
  45. package/dist-server/server/modules/orchestration/a2a/adapter-registry.js +45 -19
  46. package/dist-server/server/modules/orchestration/a2a/adapter-registry.js.map +1 -1
  47. package/dist-server/server/modules/orchestration/a2a/adapters/abstract-a2a.adapter.js.map +1 -1
  48. package/dist-server/server/modules/orchestration/a2a/adapters/claude-code.adapter.js +1 -0
  49. package/dist-server/server/modules/orchestration/a2a/adapters/claude-code.adapter.js.map +1 -1
  50. package/dist-server/server/modules/orchestration/a2a/adapters/codex.adapter.js +202 -0
  51. package/dist-server/server/modules/orchestration/a2a/adapters/codex.adapter.js.map +1 -0
  52. package/dist-server/server/modules/orchestration/a2a/adapters/cursor.adapter.js +205 -0
  53. package/dist-server/server/modules/orchestration/a2a/adapters/cursor.adapter.js.map +1 -0
  54. package/dist-server/server/modules/orchestration/a2a/adapters/gemini.adapter.js +205 -0
  55. package/dist-server/server/modules/orchestration/a2a/adapters/gemini.adapter.js.map +1 -0
  56. package/dist-server/server/modules/orchestration/a2a/adapters/opencode.adapter.js +205 -0
  57. package/dist-server/server/modules/orchestration/a2a/adapters/opencode.adapter.js.map +1 -0
  58. package/dist-server/server/modules/orchestration/a2a/adapters/qwen.adapter.js +205 -0
  59. package/dist-server/server/modules/orchestration/a2a/adapters/qwen.adapter.js.map +1 -0
  60. package/dist-server/server/modules/orchestration/a2a/routes.js +298 -34
  61. package/dist-server/server/modules/orchestration/a2a/routes.js.map +1 -1
  62. package/dist-server/server/modules/orchestration/a2a/task-store.js +144 -0
  63. package/dist-server/server/modules/orchestration/a2a/task-store.js.map +1 -0
  64. package/dist-server/server/modules/orchestration/a2a/validator.js +16 -0
  65. package/dist-server/server/modules/orchestration/a2a/validator.js.map +1 -1
  66. package/dist-server/server/modules/orchestration/index.js +14 -0
  67. package/dist-server/server/modules/orchestration/index.js.map +1 -1
  68. package/dist-server/server/modules/orchestration/preview/port-watcher.js +90 -0
  69. package/dist-server/server/modules/orchestration/preview/port-watcher.js.map +1 -0
  70. package/dist-server/server/modules/orchestration/preview/preview-proxy.js +58 -0
  71. package/dist-server/server/modules/orchestration/preview/preview-proxy.js.map +1 -0
  72. package/dist-server/server/modules/orchestration/preview/types.js +2 -0
  73. package/dist-server/server/modules/orchestration/preview/types.js.map +1 -0
  74. package/dist-server/server/modules/orchestration/tasks/orchestration-task-store.js +37 -0
  75. package/dist-server/server/modules/orchestration/tasks/orchestration-task-store.js.map +1 -0
  76. package/dist-server/server/modules/orchestration/tasks/orchestration-task.routes.js +68 -0
  77. package/dist-server/server/modules/orchestration/tasks/orchestration-task.routes.js.map +1 -0
  78. package/dist-server/server/modules/orchestration/tasks/orchestration-task.service.js +128 -0
  79. package/dist-server/server/modules/orchestration/tasks/orchestration-task.service.js.map +1 -0
  80. package/dist-server/server/modules/orchestration/tasks/orchestration-task.types.js +2 -0
  81. package/dist-server/server/modules/orchestration/tasks/orchestration-task.types.js.map +1 -0
  82. package/dist-server/server/modules/orchestration/workflows/built-in-workflows.js +126 -0
  83. package/dist-server/server/modules/orchestration/workflows/built-in-workflows.js.map +1 -0
  84. package/dist-server/server/modules/orchestration/workflows/workflow-runner.js +1047 -0
  85. package/dist-server/server/modules/orchestration/workflows/workflow-runner.js.map +1 -0
  86. package/dist-server/server/modules/orchestration/workflows/workflow-store.js +76 -0
  87. package/dist-server/server/modules/orchestration/workflows/workflow-store.js.map +1 -0
  88. package/dist-server/server/modules/orchestration/workflows/workflow.routes.js +151 -0
  89. package/dist-server/server/modules/orchestration/workflows/workflow.routes.js.map +1 -0
  90. package/dist-server/server/modules/orchestration/workflows/workflow.types.js +2 -0
  91. package/dist-server/server/modules/orchestration/workflows/workflow.types.js.map +1 -0
  92. package/dist-server/server/modules/orchestration/workflows/workspace-target.js +98 -0
  93. package/dist-server/server/modules/orchestration/workflows/workspace-target.js.map +1 -0
  94. package/dist-server/server/modules/orchestration/workspace/docker-workspace.js +122 -0
  95. package/dist-server/server/modules/orchestration/workspace/docker-workspace.js.map +1 -0
  96. package/dist-server/server/modules/orchestration/workspace/path-safety.js +48 -0
  97. package/dist-server/server/modules/orchestration/workspace/path-safety.js.map +1 -0
  98. package/dist-server/server/modules/orchestration/workspace/types.js +11 -0
  99. package/dist-server/server/modules/orchestration/workspace/types.js.map +1 -0
  100. package/dist-server/server/modules/orchestration/workspace/workspace-manager.js +80 -0
  101. package/dist-server/server/modules/orchestration/workspace/workspace-manager.js.map +1 -0
  102. package/dist-server/server/modules/orchestration/workspace/worktree-workspace.js +96 -0
  103. package/dist-server/server/modules/orchestration/workspace/worktree-workspace.js.map +1 -0
  104. package/dist-server/server/modules/providers/index.js +3 -0
  105. package/dist-server/server/modules/providers/index.js.map +1 -0
  106. package/dist-server/server/openai-codex.js +35 -4
  107. package/dist-server/server/openai-codex.js.map +1 -1
  108. package/dist-server/server/routes/commands.js +25 -25
  109. package/dist-server/server/routes/git.js +17 -17
  110. package/dist-server/server/routes/taskmaster.js +525 -508
  111. package/dist-server/server/routes/taskmaster.js.map +1 -1
  112. package/package.json +180 -178
  113. package/scripts/fix-node-pty.js +67 -67
  114. package/scripts/smoke/a2a-roundtrip.mjs +86 -17
  115. package/scripts/smoke/orchestration-api.mjs +172 -0
  116. package/scripts/smoke/orchestration-live-run.mjs +176 -0
  117. package/server/claude-sdk.js +898 -857
  118. package/server/cli.js +935 -940
  119. package/server/constants/config.js +4 -4
  120. package/server/cursor-cli.js +342 -342
  121. package/server/daemon/manager.js +564 -564
  122. package/server/daemon-manager.js +959 -920
  123. package/server/database/db.js +794 -794
  124. package/server/database/json-store.js +197 -194
  125. package/server/gemini-cli.js +535 -535
  126. package/server/gemini-response-handler.js +79 -79
  127. package/server/index.js +3135 -3104
  128. package/server/load-env.js +34 -34
  129. package/server/middleware/auth.js +173 -173
  130. package/server/modules/orchestration/a2a/adapter-registry.ts +72 -22
  131. package/server/modules/orchestration/a2a/adapters/abstract-a2a.adapter.ts +9 -3
  132. package/server/modules/orchestration/a2a/adapters/claude-code.adapter.ts +1 -0
  133. package/server/modules/orchestration/a2a/adapters/codex.adapter.ts +244 -0
  134. package/server/modules/orchestration/a2a/adapters/cursor.adapter.ts +249 -0
  135. package/server/modules/orchestration/a2a/adapters/gemini.adapter.ts +248 -0
  136. package/server/modules/orchestration/a2a/adapters/opencode.adapter.ts +248 -0
  137. package/server/modules/orchestration/a2a/adapters/qwen.adapter.ts +248 -0
  138. package/server/modules/orchestration/a2a/routes.ts +349 -36
  139. package/server/modules/orchestration/a2a/task-store.ts +178 -0
  140. package/server/modules/orchestration/a2a/types.ts +14 -0
  141. package/server/modules/orchestration/a2a/validator.ts +25 -2
  142. package/server/modules/orchestration/index.ts +40 -0
  143. package/server/modules/orchestration/preview/port-watcher.ts +112 -0
  144. package/server/modules/orchestration/preview/preview-proxy.ts +60 -0
  145. package/server/modules/orchestration/preview/types.ts +19 -0
  146. package/server/modules/orchestration/tasks/orchestration-task-store.ts +45 -0
  147. package/server/modules/orchestration/tasks/orchestration-task.routes.ts +73 -0
  148. package/server/modules/orchestration/tasks/orchestration-task.service.ts +145 -0
  149. package/server/modules/orchestration/tasks/orchestration-task.types.ts +29 -0
  150. package/server/modules/orchestration/workflows/built-in-workflows.ts +127 -0
  151. package/server/modules/orchestration/workflows/workflow-runner.ts +1206 -0
  152. package/server/modules/orchestration/workflows/workflow-store.ts +97 -0
  153. package/server/modules/orchestration/workflows/workflow.routes.ts +169 -0
  154. package/server/modules/orchestration/workflows/workflow.types.ts +70 -0
  155. package/server/modules/orchestration/workflows/workspace-target.ts +120 -0
  156. package/server/modules/orchestration/workspace/docker-workspace.ts +135 -0
  157. package/server/modules/orchestration/workspace/path-safety.ts +55 -0
  158. package/server/modules/orchestration/workspace/types.ts +52 -0
  159. package/server/modules/orchestration/workspace/workspace-manager.ts +97 -0
  160. package/server/modules/orchestration/workspace/worktree-workspace.ts +125 -0
  161. package/server/modules/providers/index.ts +2 -0
  162. package/server/modules/providers/list/claude/claude-auth.provider.ts +145 -145
  163. package/server/modules/providers/list/claude/claude-mcp.provider.ts +135 -135
  164. package/server/modules/providers/list/claude/claude-sessions.provider.ts +306 -306
  165. package/server/modules/providers/list/claude/claude.provider.ts +15 -15
  166. package/server/modules/providers/list/codex/codex-auth.provider.ts +115 -115
  167. package/server/modules/providers/list/codex/codex-mcp.provider.ts +135 -135
  168. package/server/modules/providers/list/codex/codex-sessions.provider.ts +319 -319
  169. package/server/modules/providers/list/codex/codex.provider.ts +15 -15
  170. package/server/modules/providers/list/cursor/cursor-auth.provider.ts +143 -143
  171. package/server/modules/providers/list/cursor/cursor-mcp.provider.ts +108 -108
  172. package/server/modules/providers/list/cursor/cursor-sessions.provider.ts +421 -421
  173. package/server/modules/providers/list/cursor/cursor.provider.ts +15 -15
  174. package/server/modules/providers/list/gemini/gemini-auth.provider.ts +163 -163
  175. package/server/modules/providers/list/gemini/gemini-mcp.provider.ts +110 -110
  176. package/server/modules/providers/list/gemini/gemini-sessions.provider.ts +227 -227
  177. package/server/modules/providers/list/gemini/gemini.provider.ts +15 -15
  178. package/server/modules/providers/list/opencode/opencode-auth.provider.ts +130 -130
  179. package/server/modules/providers/list/opencode/opencode-mcp.provider.ts +126 -126
  180. package/server/modules/providers/list/opencode/opencode-sessions.provider.ts +232 -232
  181. package/server/modules/providers/list/opencode/opencode.provider.ts +29 -29
  182. package/server/modules/providers/list/qwen/qwen-auth.provider.ts +145 -145
  183. package/server/modules/providers/list/qwen/qwen-mcp.provider.ts +114 -114
  184. package/server/modules/providers/list/qwen/qwen-sessions.provider.ts +265 -265
  185. package/server/modules/providers/list/qwen/qwen.provider.ts +21 -21
  186. package/server/modules/providers/provider.registry.ts +40 -40
  187. package/server/modules/providers/provider.routes.ts +819 -819
  188. package/server/modules/providers/services/mcp.service.ts +86 -86
  189. package/server/modules/providers/services/provider-auth.service.ts +26 -26
  190. package/server/modules/providers/services/sessions.service.ts +45 -45
  191. package/server/modules/providers/shared/base/abstract.provider.ts +20 -20
  192. package/server/modules/providers/shared/mcp/mcp.provider.ts +151 -151
  193. package/server/modules/providers/shared/provider-configs.ts +142 -142
  194. package/server/modules/providers/tests/mcp.test.ts +293 -293
  195. package/server/openai-codex.js +462 -426
  196. package/server/opencode-cli.js +459 -459
  197. package/server/opencode-response-handler.js +107 -107
  198. package/server/projects.js +3105 -3105
  199. package/server/qwen-code-cli.js +395 -395
  200. package/server/qwen-response-handler.js +73 -73
  201. package/server/routes/agent.js +1365 -1365
  202. package/server/routes/auth.js +138 -138
  203. package/server/routes/codex.js +19 -19
  204. package/server/routes/commands.js +554 -554
  205. package/server/routes/cursor.js +52 -52
  206. package/server/routes/gemini.js +24 -24
  207. package/server/routes/git.js +1488 -1488
  208. package/server/routes/mcp-utils.js +31 -31
  209. package/server/routes/messages.js +61 -61
  210. package/server/routes/network.js +120 -120
  211. package/server/routes/plugins.js +318 -318
  212. package/server/routes/projects.js +915 -915
  213. package/server/routes/qwen.js +27 -27
  214. package/server/routes/settings.js +286 -286
  215. package/server/routes/taskmaster.js +1496 -1471
  216. package/server/routes/telegram.js +125 -125
  217. package/server/routes/user.js +123 -123
  218. package/server/services/external-access.js +171 -171
  219. package/server/services/install-jobs.js +571 -571
  220. package/server/services/notification-orchestrator.js +242 -242
  221. package/server/services/provider-credentials.js +189 -189
  222. package/server/services/provider-models.js +381 -381
  223. package/server/services/telegram/bot.js +279 -279
  224. package/server/services/telegram/telegram-http-client.js +130 -130
  225. package/server/services/telegram/translations.js +170 -170
  226. package/server/services/vapid-keys.js +36 -36
  227. package/server/sessionManager.js +225 -225
  228. package/server/shared/interfaces.ts +54 -54
  229. package/server/shared/types.ts +172 -172
  230. package/server/shared/utils.ts +193 -193
  231. package/server/tsconfig.json +36 -36
  232. package/server/utils/colors.js +21 -21
  233. package/server/utils/commandParser.js +303 -303
  234. package/server/utils/frontmatter.js +18 -18
  235. package/server/utils/gitConfig.js +34 -34
  236. package/server/utils/mcp-detector.js +147 -147
  237. package/server/utils/plugin-loader.js +457 -457
  238. package/server/utils/plugin-process-manager.js +184 -184
  239. package/server/utils/port-access.js +209 -209
  240. package/server/utils/runtime-paths.js +37 -37
  241. package/server/utils/taskmaster-websocket.js +128 -128
  242. package/server/utils/url-detection.js +71 -71
  243. package/server/vite-daemon.js +78 -78
  244. package/shared/modelConstants.js +162 -162
  245. package/shared/networkHosts.js +22 -22
  246. package/dist/assets/index-B1ghfb4w.css +0 -32
  247. package/dist/assets/index-BvClqlMf.js +0 -852
@@ -1,857 +1,898 @@
1
- /**
2
- * Claude SDK Integration
3
- *
4
- * This module provides SDK-based integration with Claude using the @anthropic-ai/claude-agent-sdk.
5
- * It mirrors the interface of claude-cli.js but uses the SDK internally for better performance
6
- * and maintainability.
7
- *
8
- * Key features:
9
- * - Direct SDK integration without child processes
10
- * - Session management with abort capability
11
- * - Options mapping between CLI and SDK formats
12
- * - WebSocket message streaming
13
- */
14
-
15
- import { query } from '@anthropic-ai/claude-agent-sdk';
16
- import crypto from 'crypto';
17
- import { promises as fs } from 'fs';
18
- import path from 'path';
19
- import os from 'os';
20
- import { CLAUDE_MODELS } from '../shared/modelConstants.js';
21
- import {
22
- createNotificationEvent,
23
- notifyRunFailed,
24
- notifyRunStopped,
25
- notifyUserIfEnabled
26
- } from './services/notification-orchestrator.js';
27
- import { sessionsService } from './modules/providers/services/sessions.service.js';
28
- import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
29
- import { resolveClaudeExecutable, resolveGitBashPath } from './services/install-jobs.js';
30
- import { createNormalizedMessage } from './shared/utils.js';
31
-
32
- const activeSessions = new Map();
33
- const pendingToolApprovals = new Map();
34
-
35
- const TOOL_APPROVAL_TIMEOUT_MS = parseInt(process.env.CLAUDE_TOOL_APPROVAL_TIMEOUT_MS, 10) || 55000;
36
-
37
- const TOOLS_REQUIRING_INTERACTION = new Set(['AskUserQuestion', 'ExitPlanMode']);
38
-
39
- function createRequestId() {
40
- if (typeof crypto.randomUUID === 'function') {
41
- return crypto.randomUUID();
42
- }
43
- return crypto.randomBytes(16).toString('hex');
44
- }
45
-
46
- function waitForToolApproval(requestId, options = {}) {
47
- const { timeoutMs = TOOL_APPROVAL_TIMEOUT_MS, signal, onCancel, metadata } = options;
48
-
49
- return new Promise(resolve => {
50
- let settled = false;
51
-
52
- const finalize = (decision) => {
53
- if (settled) return;
54
- settled = true;
55
- cleanup();
56
- resolve(decision);
57
- };
58
-
59
- let timeout;
60
-
61
- const cleanup = () => {
62
- pendingToolApprovals.delete(requestId);
63
- if (timeout) clearTimeout(timeout);
64
- if (signal && abortHandler) {
65
- signal.removeEventListener('abort', abortHandler);
66
- }
67
- };
68
-
69
- // timeoutMs 0 = wait indefinitely (interactive tools)
70
- if (timeoutMs > 0) {
71
- timeout = setTimeout(() => {
72
- onCancel?.('timeout');
73
- finalize(null);
74
- }, timeoutMs);
75
- }
76
-
77
- const abortHandler = () => {
78
- onCancel?.('cancelled');
79
- finalize({ cancelled: true });
80
- };
81
-
82
- if (signal) {
83
- if (signal.aborted) {
84
- onCancel?.('cancelled');
85
- finalize({ cancelled: true });
86
- return;
87
- }
88
- signal.addEventListener('abort', abortHandler, { once: true });
89
- }
90
-
91
- const resolver = (decision) => {
92
- finalize(decision);
93
- };
94
- // Attach metadata for getPendingApprovalsForSession lookup
95
- if (metadata) {
96
- Object.assign(resolver, metadata);
97
- }
98
- pendingToolApprovals.set(requestId, resolver);
99
- });
100
- }
101
-
102
- function resolveToolApproval(requestId, decision) {
103
- const resolver = pendingToolApprovals.get(requestId);
104
- if (resolver) {
105
- resolver(decision);
106
- }
107
- }
108
-
109
- // Match stored permission entries against a tool + input combo.
110
- // This only supports exact tool names and the Bash(command:*) shorthand
111
- // used by the UI; it intentionally does not implement full glob semantics,
112
- // introduced to stay consistent with the UI's "Allow rule" format.
113
- function matchesToolPermission(entry, toolName, input) {
114
- if (!entry || !toolName) {
115
- return false;
116
- }
117
-
118
- if (entry === toolName) {
119
- return true;
120
- }
121
-
122
- const bashMatch = entry.match(/^Bash\((.+):\*\)$/);
123
- if (toolName === 'Bash' && bashMatch) {
124
- const allowedPrefix = bashMatch[1];
125
- let command = '';
126
-
127
- if (typeof input === 'string') {
128
- command = input.trim();
129
- } else if (input && typeof input === 'object' && typeof input.command === 'string') {
130
- command = input.command.trim();
131
- }
132
-
133
- if (!command) {
134
- return false;
135
- }
136
-
137
- return command.startsWith(allowedPrefix);
138
- }
139
-
140
- return false;
141
- }
142
-
143
- /**
144
- * Maps CLI options to SDK-compatible options format
145
- * @param {Object} options - CLI options
146
- * @returns {Object} SDK-compatible options
147
- */
148
- function mapCliOptionsToSDK(options = {}) {
149
- const { sessionId, cwd, toolsSettings, permissionMode } = options;
150
-
151
- const sdkOptions = {};
152
-
153
- // Forward all host env vars (e.g. ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN) to the subprocess.
154
- // Since claude-agent-sdk 0.2.113+, options.env REPLACES process.env in the subprocess
155
- // instead of overlaying it. Without spreading process.env here, users who rely on
156
- // ANTHROPIC_BASE_URL, HTTP(S)_PROXY, etc. would silently lose those settings.
157
- sdkOptions.env = { ...process.env };
158
-
159
- // Claude Code on Windows hard-requires a POSIX bash (typically from Git
160
- // for Windows) and reads its path from CLAUDE_CODE_GIT_BASH_PATH. If the
161
- // user has git-bash installed but hasn't exported that var, the CLI
162
- // exits with code 1 + a guidance message and the pixcode UI just sees
163
- // an opaque "Claude Code process exited with code 1" error. Auto-probing
164
- // a handful of known install locations lets the CLI boot transparently.
165
- if (!sdkOptions.env.CLAUDE_CODE_GIT_BASH_PATH) {
166
- const bashPath = resolveGitBashPath();
167
- if (bashPath) sdkOptions.env.CLAUDE_CODE_GIT_BASH_PATH = bashPath;
168
- }
169
-
170
- // Resolve the Claude Code CLI path cross-platform. The SDK uses plain
171
- // `child_process.spawn(command, args)` with no shell — and its own
172
- // `nb()` helper treats anything not ending in .js/.mjs/.ts as a native
173
- // executable. That means:
174
- // - Unix: bare `"claude"` works (kernel PATH + shebang handle it).
175
- // - Windows: bare `"claude"` fails ("native binary not found"). A
176
- // `.cmd` shim fails with EINVAL (post-CVE-2024 Node refuses .cmd
177
- // without shell:true). We need the underlying `.exe` instead.
178
- // `resolveClaudeExecutable()` does a `where`/`which` lookup and, on
179
- // Windows, peeks inside npm .cmd shims to recover the real .exe target.
180
- // If nothing is found we leave the option unset so the SDK falls through
181
- // to its own bundled-native-binary resolver.
182
- const resolvedClaudePath = process.env.CLAUDE_CLI_PATH || resolveClaudeExecutable();
183
- if (resolvedClaudePath) {
184
- sdkOptions.pathToClaudeCodeExecutable = resolvedClaudePath;
185
- }
186
-
187
- // Map working directory
188
- if (cwd) {
189
- sdkOptions.cwd = cwd;
190
- }
191
-
192
- // Map permission mode
193
- if (permissionMode && permissionMode !== 'default') {
194
- sdkOptions.permissionMode = permissionMode;
195
- }
196
-
197
- // Map tool settings
198
- const settings = toolsSettings || {
199
- allowedTools: [],
200
- disallowedTools: [],
201
- skipPermissions: false
202
- };
203
-
204
- // Handle tool permissions
205
- if (settings.skipPermissions && permissionMode !== 'plan') {
206
- // When skipping permissions, use bypassPermissions mode
207
- sdkOptions.permissionMode = 'bypassPermissions';
208
- }
209
-
210
- let allowedTools = [...(settings.allowedTools || [])];
211
-
212
- // Add plan mode default tools
213
- if (permissionMode === 'plan') {
214
- const planModeTools = ['Read', 'Task', 'exit_plan_mode', 'TodoRead', 'TodoWrite', 'WebFetch', 'WebSearch'];
215
- for (const tool of planModeTools) {
216
- if (!allowedTools.includes(tool)) {
217
- allowedTools.push(tool);
218
- }
219
- }
220
- }
221
-
222
- sdkOptions.allowedTools = allowedTools;
223
-
224
- // Use the tools preset to make all default built-in tools available (including AskUserQuestion).
225
- // This was introduced in SDK 0.1.57. Omitting this preserves existing behavior (all tools available),
226
- // but being explicit ensures forward compatibility and clarity.
227
- sdkOptions.tools = { type: 'preset', preset: 'claude_code' };
228
-
229
- sdkOptions.disallowedTools = settings.disallowedTools || [];
230
-
231
- // Map model (default to sonnet)
232
- // Valid models: sonnet, opus, haiku, opusplan, sonnet[1m]
233
- sdkOptions.model = options.model || CLAUDE_MODELS.DEFAULT;
234
- // Model logged at query start below
235
-
236
- // Map system prompt configuration
237
- sdkOptions.systemPrompt = {
238
- type: 'preset',
239
- preset: 'claude_code' // Required to use CLAUDE.md
240
- };
241
-
242
- // Map setting sources for CLAUDE.md loading
243
- // This loads CLAUDE.md from project, user (~/.config/claude/CLAUDE.md), and local directories
244
- sdkOptions.settingSources = ['project', 'user', 'local'];
245
-
246
- // Map resume session
247
- if (sessionId) {
248
- sdkOptions.resume = sessionId;
249
- }
250
-
251
- return sdkOptions;
252
- }
253
-
254
- /**
255
- * Adds a session to the active sessions map
256
- * @param {string} sessionId - Session identifier
257
- * @param {Object} queryInstance - SDK query instance
258
- * @param {Array<string>} tempImagePaths - Temp image file paths for cleanup
259
- * @param {string} tempDir - Temp directory for cleanup
260
- */
261
- function addSession(sessionId, queryInstance, tempImagePaths = [], tempDir = null, writer = null) {
262
- activeSessions.set(sessionId, {
263
- instance: queryInstance,
264
- startTime: Date.now(),
265
- status: 'active',
266
- tempImagePaths,
267
- tempDir,
268
- writer
269
- });
270
- }
271
-
272
- /**
273
- * Removes a session from the active sessions map
274
- * @param {string} sessionId - Session identifier
275
- */
276
- function removeSession(sessionId) {
277
- activeSessions.delete(sessionId);
278
- }
279
-
280
- /**
281
- * Gets a session from the active sessions map
282
- * @param {string} sessionId - Session identifier
283
- * @returns {Object|undefined} Session data or undefined
284
- */
285
- function getSession(sessionId) {
286
- return activeSessions.get(sessionId);
287
- }
288
-
289
- /**
290
- * Gets all active session IDs
291
- * @returns {Array<string>} Array of active session IDs
292
- */
293
- function getAllSessions() {
294
- return Array.from(activeSessions.keys());
295
- }
296
-
297
- /**
298
- * Transforms SDK messages to WebSocket format expected by frontend
299
- * @param {Object} sdkMessage - SDK message object
300
- * @returns {Object} Transformed message ready for WebSocket
301
- */
302
- function transformMessage(sdkMessage) {
303
- // Extract parent_tool_use_id for subagent tool grouping
304
- if (sdkMessage.parent_tool_use_id) {
305
- return {
306
- ...sdkMessage,
307
- parentToolUseId: sdkMessage.parent_tool_use_id
308
- };
309
- }
310
- return sdkMessage;
311
- }
312
-
313
- /**
314
- * Extracts token usage from SDK result messages
315
- * @param {Object} resultMessage - SDK result message
316
- * @returns {Object|null} Token budget object or null
317
- */
318
- function extractTokenBudget(resultMessage) {
319
- if (resultMessage.type !== 'result' || !resultMessage.modelUsage) {
320
- return null;
321
- }
322
-
323
- // Get the first model's usage data
324
- const modelKey = Object.keys(resultMessage.modelUsage)[0];
325
- const modelData = resultMessage.modelUsage[modelKey];
326
-
327
- if (!modelData) {
328
- return null;
329
- }
330
-
331
- // Use cumulative tokens if available (tracks total for the session)
332
- // Otherwise fall back to per-request tokens
333
- const inputTokens = modelData.cumulativeInputTokens || modelData.inputTokens || 0;
334
- const outputTokens = modelData.cumulativeOutputTokens || modelData.outputTokens || 0;
335
- const cacheReadTokens = modelData.cumulativeCacheReadInputTokens || modelData.cacheReadInputTokens || 0;
336
- const cacheCreationTokens = modelData.cumulativeCacheCreationInputTokens || modelData.cacheCreationInputTokens || 0;
337
-
338
- // Total used = input + output + cache tokens
339
- const totalUsed = inputTokens + outputTokens + cacheReadTokens + cacheCreationTokens;
340
-
341
- // Use configured context window budget from environment (default 160000)
342
- // This is the user's budget limit, not the model's context window
343
- const contextWindow = parseInt(process.env.CONTEXT_WINDOW) || 160000;
344
-
345
- // Token calc logged via token-budget WS event
346
-
347
- return {
348
- used: totalUsed,
349
- total: contextWindow
350
- };
351
- }
352
-
353
- /**
354
- * Handles image processing for SDK queries
355
- * Saves base64 images to temporary files and returns modified prompt with file paths
356
- * @param {string} command - Original user prompt
357
- * @param {Array} images - Array of image objects with base64 data
358
- * @param {string} cwd - Working directory for temp file creation
359
- * @returns {Promise<Object>} {modifiedCommand, tempImagePaths, tempDir}
360
- */
361
- async function handleImages(command, images, cwd) {
362
- const tempImagePaths = [];
363
- let tempDir = null;
364
-
365
- if (!images || images.length === 0) {
366
- return { modifiedCommand: command, tempImagePaths, tempDir };
367
- }
368
-
369
- try {
370
- // Create temp directory in the project directory
371
- const workingDir = cwd || process.cwd();
372
- tempDir = path.join(workingDir, '.tmp', 'images', Date.now().toString());
373
- await fs.mkdir(tempDir, { recursive: true });
374
-
375
- // Save each image to a temp file
376
- for (const [index, image] of images.entries()) {
377
- // Extract base64 data and mime type
378
- const matches = image.data.match(/^data:([^;]+);base64,(.+)$/);
379
- if (!matches) {
380
- console.error('Invalid image data format');
381
- continue;
382
- }
383
-
384
- const [, mimeType, base64Data] = matches;
385
- const extension = mimeType.split('/')[1] || 'png';
386
- const filename = `image_${index}.${extension}`;
387
- const filepath = path.join(tempDir, filename);
388
-
389
- // Write base64 data to file
390
- await fs.writeFile(filepath, Buffer.from(base64Data, 'base64'));
391
- tempImagePaths.push(filepath);
392
- }
393
-
394
- // Include the full image paths in the prompt
395
- let modifiedCommand = command;
396
- if (tempImagePaths.length > 0 && command && command.trim()) {
397
- const imageNote = `\n\n[Images provided at the following paths:]\n${tempImagePaths.map((p, i) => `${i + 1}. ${p}`).join('\n')}`;
398
- modifiedCommand = command + imageNote;
399
- }
400
-
401
- // Images processed
402
- return { modifiedCommand, tempImagePaths, tempDir };
403
- } catch (error) {
404
- console.error('Error processing images for SDK:', error);
405
- return { modifiedCommand: command, tempImagePaths, tempDir };
406
- }
407
- }
408
-
409
- /**
410
- * Cleans up temporary image files
411
- * @param {Array<string>} tempImagePaths - Array of temp file paths to delete
412
- * @param {string} tempDir - Temp directory to remove
413
- */
414
- async function cleanupTempFiles(tempImagePaths, tempDir) {
415
- if (!tempImagePaths || tempImagePaths.length === 0) {
416
- return;
417
- }
418
-
419
- try {
420
- // Delete individual temp files
421
- for (const imagePath of tempImagePaths) {
422
- await fs.unlink(imagePath).catch(err =>
423
- console.error(`Failed to delete temp image ${imagePath}:`, err)
424
- );
425
- }
426
-
427
- // Delete temp directory
428
- if (tempDir) {
429
- await fs.rm(tempDir, { recursive: true, force: true }).catch(err =>
430
- console.error(`Failed to delete temp directory ${tempDir}:`, err)
431
- );
432
- }
433
-
434
- // Temp files cleaned
435
- } catch (error) {
436
- console.error('Error during temp file cleanup:', error);
437
- }
438
- }
439
-
440
- /**
441
- * Loads MCP server configurations from ~/.claude.json
442
- * @param {string} cwd - Current working directory for project-specific configs
443
- * @returns {Object|null} MCP servers object or null if none found
444
- */
445
- async function loadMcpConfig(cwd) {
446
- try {
447
- const claudeConfigPath = path.join(os.homedir(), '.claude.json');
448
-
449
- // Check if config file exists
450
- try {
451
- await fs.access(claudeConfigPath);
452
- } catch (error) {
453
- // File doesn't exist, return null
454
- // No config file
455
- return null;
456
- }
457
-
458
- // Read and parse config file
459
- let claudeConfig;
460
- try {
461
- const configContent = await fs.readFile(claudeConfigPath, 'utf8');
462
- claudeConfig = JSON.parse(configContent);
463
- } catch (error) {
464
- console.error('Failed to parse ~/.claude.json:', error.message);
465
- return null;
466
- }
467
-
468
- // Extract MCP servers (merge global and project-specific)
469
- let mcpServers = {};
470
-
471
- // Add global MCP servers
472
- if (claudeConfig.mcpServers && typeof claudeConfig.mcpServers === 'object') {
473
- mcpServers = { ...claudeConfig.mcpServers };
474
- // Global MCP servers loaded
475
- }
476
-
477
- // Add/override with project-specific MCP servers
478
- if (claudeConfig.claudeProjects && cwd) {
479
- const projectConfig = claudeConfig.claudeProjects[cwd];
480
- if (projectConfig && projectConfig.mcpServers && typeof projectConfig.mcpServers === 'object') {
481
- mcpServers = { ...mcpServers, ...projectConfig.mcpServers };
482
- // Project MCP servers merged
483
- }
484
- }
485
-
486
- // Return null if no servers found
487
- if (Object.keys(mcpServers).length === 0) {
488
- return null;
489
- }
490
- return mcpServers;
491
- } catch (error) {
492
- console.error('Error loading MCP config:', error.message);
493
- return null;
494
- }
495
- }
496
-
497
- /**
498
- * Executes a Claude query using the SDK
499
- * @param {string} command - User prompt/command
500
- * @param {Object} options - Query options
501
- * @param {Object} ws - WebSocket connection
502
- * @returns {Promise<void>}
503
- */
504
- async function queryClaudeSDK(command, options = {}, ws) {
505
- const { sessionId, sessionSummary } = options;
506
- let capturedSessionId = sessionId;
507
- let sessionCreatedSent = false;
508
- let tempImagePaths = [];
509
- let tempDir = null;
510
-
511
- const emitNotification = (event) => {
512
- notifyUserIfEnabled({
513
- userId: ws?.userId || null,
514
- writer: ws,
515
- event
516
- });
517
- };
518
-
519
- try {
520
- // Map CLI options to SDK format
521
- const sdkOptions = mapCliOptionsToSDK(options);
522
-
523
- // Load MCP configuration
524
- const mcpServers = await loadMcpConfig(options.cwd);
525
- if (mcpServers) {
526
- sdkOptions.mcpServers = mcpServers;
527
- }
528
-
529
- // Handle images - save to temp files and modify prompt
530
- const imageResult = await handleImages(command, options.images, options.cwd);
531
- const finalCommand = imageResult.modifiedCommand;
532
- tempImagePaths = imageResult.tempImagePaths;
533
- tempDir = imageResult.tempDir;
534
-
535
- sdkOptions.hooks = {
536
- Notification: [{
537
- matcher: '',
538
- hooks: [async (input) => {
539
- const message = typeof input?.message === 'string' ? input.message : 'Claude requires your attention.';
540
- emitNotification(createNotificationEvent({
541
- provider: 'claude',
542
- sessionId: capturedSessionId || sessionId || null,
543
- kind: 'action_required',
544
- code: 'agent.notification',
545
- meta: { message, sessionName: sessionSummary },
546
- severity: 'warning',
547
- requiresUserAction: true,
548
- dedupeKey: `claude:hook:notification:${capturedSessionId || sessionId || 'none'}:${message}`
549
- }));
550
- return {};
551
- }]
552
- }]
553
- };
554
-
555
- sdkOptions.canUseTool = async (toolName, input, context) => {
556
- const requiresInteraction = TOOLS_REQUIRING_INTERACTION.has(toolName);
557
-
558
- if (!requiresInteraction) {
559
- if (sdkOptions.permissionMode === 'bypassPermissions') {
560
- return { behavior: 'allow', updatedInput: input };
561
- }
562
-
563
- const isDisallowed = (sdkOptions.disallowedTools || []).some(entry =>
564
- matchesToolPermission(entry, toolName, input)
565
- );
566
- if (isDisallowed) {
567
- return { behavior: 'deny', message: 'Tool disallowed by settings' };
568
- }
569
-
570
- const isAllowed = (sdkOptions.allowedTools || []).some(entry =>
571
- matchesToolPermission(entry, toolName, input)
572
- );
573
- if (isAllowed) {
574
- return { behavior: 'allow', updatedInput: input };
575
- }
576
- }
577
-
578
- const requestId = createRequestId();
579
- ws.send(createNormalizedMessage({ kind: 'permission_request', requestId, toolName, input, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));
580
- emitNotification(createNotificationEvent({
581
- provider: 'claude',
582
- sessionId: capturedSessionId || sessionId || null,
583
- kind: 'action_required',
584
- code: 'permission.required',
585
- meta: { toolName, sessionName: sessionSummary },
586
- severity: 'warning',
587
- requiresUserAction: true,
588
- dedupeKey: `claude:permission:${capturedSessionId || sessionId || 'none'}:${requestId}`
589
- }));
590
-
591
- const decision = await waitForToolApproval(requestId, {
592
- timeoutMs: requiresInteraction ? 0 : undefined,
593
- signal: context?.signal,
594
- metadata: {
595
- _sessionId: capturedSessionId || sessionId || null,
596
- _toolName: toolName,
597
- _input: input,
598
- _receivedAt: new Date(),
599
- },
600
- onCancel: (reason) => {
601
- ws.send(createNormalizedMessage({ kind: 'permission_cancelled', requestId, reason, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));
602
- }
603
- });
604
- if (!decision) {
605
- return { behavior: 'deny', message: 'Permission request timed out' };
606
- }
607
-
608
- if (decision.cancelled) {
609
- return { behavior: 'deny', message: 'Permission request cancelled' };
610
- }
611
-
612
- if (decision.allow) {
613
- if (decision.rememberEntry && typeof decision.rememberEntry === 'string') {
614
- if (!sdkOptions.allowedTools.includes(decision.rememberEntry)) {
615
- sdkOptions.allowedTools.push(decision.rememberEntry);
616
- }
617
- if (Array.isArray(sdkOptions.disallowedTools)) {
618
- sdkOptions.disallowedTools = sdkOptions.disallowedTools.filter(entry => entry !== decision.rememberEntry);
619
- }
620
- }
621
- return { behavior: 'allow', updatedInput: decision.updatedInput ?? input };
622
- }
623
-
624
- return { behavior: 'deny', message: decision.message ?? 'User denied tool use' };
625
- };
626
-
627
- // Set stream-close timeout for interactive tools (Query constructor reads it synchronously). Claude Agent SDK has a default of 5s and this overrides it
628
- const prevStreamTimeout = process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT;
629
- process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT = '300000';
630
-
631
- let queryInstance;
632
- try {
633
- queryInstance = query({
634
- prompt: finalCommand,
635
- options: sdkOptions
636
- });
637
- } catch (hookError) {
638
- // Older/newer SDK versions may not accept hook shapes yet.
639
- // Keep notification behavior operational via runtime events even if hook registration fails.
640
- console.warn('Failed to initialize Claude query with hooks, retrying without hooks:', hookError?.message || hookError);
641
- delete sdkOptions.hooks;
642
- queryInstance = query({
643
- prompt: finalCommand,
644
- options: sdkOptions
645
- });
646
- }
647
-
648
- // Restore immediately — Query constructor already captured the value
649
- if (prevStreamTimeout !== undefined) {
650
- process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT = prevStreamTimeout;
651
- } else {
652
- delete process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT;
653
- }
654
-
655
- // Track the query instance for abort capability
656
- if (capturedSessionId) {
657
- addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir, ws);
658
- }
659
-
660
- // Process streaming messages
661
- console.log('Starting async generator loop for session:', capturedSessionId || 'NEW');
662
- for await (const message of queryInstance) {
663
- // Capture session ID from first message
664
- if (message.session_id && !capturedSessionId) {
665
-
666
- capturedSessionId = message.session_id;
667
- addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir, ws);
668
-
669
- // Set session ID on writer
670
- if (ws.setSessionId && typeof ws.setSessionId === 'function') {
671
- ws.setSessionId(capturedSessionId);
672
- }
673
-
674
- // Send session-created event only once for new sessions
675
- if (!sessionId && !sessionCreatedSent) {
676
- sessionCreatedSent = true;
677
- ws.send(createNormalizedMessage({ kind: 'session_created', newSessionId: capturedSessionId, sessionId: capturedSessionId, provider: 'claude' }));
678
- }
679
- } else {
680
- // session_id already captured
681
- }
682
-
683
- // Transform and normalize message via adapter
684
- const transformedMessage = transformMessage(message);
685
- const sid = capturedSessionId || sessionId || null;
686
-
687
- // Use adapter to normalize SDK events into NormalizedMessage[]
688
- const normalized = sessionsService.normalizeMessage('claude', transformedMessage, sid);
689
- for (const msg of normalized) {
690
- // Preserve parentToolUseId from SDK wrapper for subagent tool grouping
691
- if (transformedMessage.parentToolUseId && !msg.parentToolUseId) {
692
- msg.parentToolUseId = transformedMessage.parentToolUseId;
693
- }
694
- ws.send(msg);
695
- }
696
-
697
- // Extract and send token budget updates from result messages
698
- if (message.type === 'result') {
699
- const models = Object.keys(message.modelUsage || {});
700
- if (models.length > 0) {
701
- // Model info available in result message
702
- }
703
- const tokenBudgetData = extractTokenBudget(message);
704
- if (tokenBudgetData) {
705
- ws.send(createNormalizedMessage({ kind: 'status', text: 'token_budget', tokenBudget: tokenBudgetData, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));
706
- }
707
- }
708
- }
709
-
710
- // Clean up session on completion
711
- if (capturedSessionId) {
712
- removeSession(capturedSessionId);
713
- }
714
-
715
- // Clean up temporary image files
716
- await cleanupTempFiles(tempImagePaths, tempDir);
717
-
718
- // Send completion event
719
- ws.send(createNormalizedMessage({ kind: 'complete', exitCode: 0, isNewSession: !sessionId && !!command, sessionId: capturedSessionId, provider: 'claude' }));
720
- notifyRunStopped({
721
- userId: ws?.userId || null,
722
- provider: 'claude',
723
- sessionId: capturedSessionId || sessionId || null,
724
- sessionName: sessionSummary,
725
- stopReason: 'completed'
726
- });
727
- // Complete
728
-
729
- } catch (error) {
730
- console.error('SDK query error:', error);
731
-
732
- // Clean up session on error
733
- if (capturedSessionId) {
734
- removeSession(capturedSessionId);
735
- }
736
-
737
- // Clean up temporary image files on error
738
- await cleanupTempFiles(tempImagePaths, tempDir);
739
-
740
- // Check if Claude CLI is installed for a clearer error message
741
- const installed = await providerAuthService.isProviderInstalled('claude');
742
- const errorContent = !installed
743
- ? 'Claude Code is not installed. Please install it first: https://docs.anthropic.com/en/docs/claude-code'
744
- : error.message;
745
-
746
- // Send error to WebSocket
747
- ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));
748
- notifyRunFailed({
749
- userId: ws?.userId || null,
750
- provider: 'claude',
751
- sessionId: capturedSessionId || sessionId || null,
752
- sessionName: sessionSummary,
753
- error
754
- });
755
- }
756
- }
757
-
758
- /**
759
- * Aborts an active SDK session
760
- * @param {string} sessionId - Session identifier
761
- * @returns {boolean} True if session was aborted, false if not found
762
- */
763
- async function abortClaudeSDKSession(sessionId) {
764
- const session = getSession(sessionId);
765
-
766
- if (!session) {
767
- console.log(`Session ${sessionId} not found`);
768
- return false;
769
- }
770
-
771
- try {
772
- console.log(`Aborting SDK session: ${sessionId}`);
773
-
774
- // Call interrupt() on the query instance
775
- await session.instance.interrupt();
776
-
777
- // Update session status
778
- session.status = 'aborted';
779
-
780
- // Clean up temporary image files
781
- await cleanupTempFiles(session.tempImagePaths, session.tempDir);
782
-
783
- // Clean up session
784
- removeSession(sessionId);
785
-
786
- return true;
787
- } catch (error) {
788
- console.error(`Error aborting session ${sessionId}:`, error);
789
- return false;
790
- }
791
- }
792
-
793
- /**
794
- * Checks if an SDK session is currently active
795
- * @param {string} sessionId - Session identifier
796
- * @returns {boolean} True if session is active
797
- */
798
- function isClaudeSDKSessionActive(sessionId) {
799
- const session = getSession(sessionId);
800
- return session && session.status === 'active';
801
- }
802
-
803
- /**
804
- * Gets all active SDK session IDs
805
- * @returns {Array<string>} Array of active session IDs
806
- */
807
- function getActiveClaudeSDKSessions() {
808
- return getAllSessions();
809
- }
810
-
811
- /**
812
- * Get pending tool approvals for a specific session.
813
- * @param {string} sessionId - The session ID
814
- * @returns {Array} Array of pending permission request objects
815
- */
816
- function getPendingApprovalsForSession(sessionId) {
817
- const pending = [];
818
- for (const [requestId, resolver] of pendingToolApprovals.entries()) {
819
- if (resolver._sessionId === sessionId) {
820
- pending.push({
821
- requestId,
822
- toolName: resolver._toolName || 'UnknownTool',
823
- input: resolver._input,
824
- context: resolver._context,
825
- sessionId,
826
- receivedAt: resolver._receivedAt || new Date(),
827
- });
828
- }
829
- }
830
- return pending;
831
- }
832
-
833
- /**
834
- * Reconnect a session's WebSocketWriter to a new raw WebSocket.
835
- * Called when client reconnects (e.g. page refresh) while SDK is still running.
836
- * @param {string} sessionId - The session ID
837
- * @param {Object} newRawWs - The new raw WebSocket connection
838
- * @returns {boolean} True if writer was successfully reconnected
839
- */
840
- function reconnectSessionWriter(sessionId, newRawWs) {
841
- const session = getSession(sessionId);
842
- if (!session?.writer?.updateWebSocket) return false;
843
- session.writer.updateWebSocket(newRawWs);
844
- console.log(`[RECONNECT] Writer swapped for session ${sessionId}`);
845
- return true;
846
- }
847
-
848
- // Export public API
849
- export {
850
- queryClaudeSDK,
851
- abortClaudeSDKSession,
852
- isClaudeSDKSessionActive,
853
- getActiveClaudeSDKSessions,
854
- resolveToolApproval,
855
- getPendingApprovalsForSession,
856
- reconnectSessionWriter
857
- };
1
+ /**
2
+ * Claude SDK Integration
3
+ *
4
+ * This module provides SDK-based integration with Claude using the @anthropic-ai/claude-agent-sdk.
5
+ * It mirrors the interface of claude-cli.js but uses the SDK internally for better performance
6
+ * and maintainability.
7
+ *
8
+ * Key features:
9
+ * - Direct SDK integration without child processes
10
+ * - Session management with abort capability
11
+ * - Options mapping between CLI and SDK formats
12
+ * - WebSocket message streaming
13
+ */
14
+
15
+ import crypto from 'crypto';
16
+ import { existsSync, readFileSync, promises as fs } from 'fs';
17
+ import path from 'path';
18
+ import os from 'os';
19
+
20
+ import { query } from '@anthropic-ai/claude-agent-sdk';
21
+
22
+ import {
23
+ createNotificationEvent,
24
+ notifyRunFailed,
25
+ notifyRunStopped,
26
+ notifyUserIfEnabled
27
+ } from './services/notification-orchestrator.js';
28
+ import { sessionsService } from './modules/providers/services/sessions.service.js';
29
+ import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
30
+ import { resolveClaudeExecutable, resolveGitBashPath } from './services/install-jobs.js';
31
+ import { createNormalizedMessage } from './shared/utils.js';
32
+
33
+ const activeSessions = new Map();
34
+ const pendingToolApprovals = new Map();
35
+
36
+ const TOOL_APPROVAL_TIMEOUT_MS = parseInt(process.env.CLAUDE_TOOL_APPROVAL_TIMEOUT_MS, 10) || 55000;
37
+
38
+ const TOOLS_REQUIRING_INTERACTION = new Set(['AskUserQuestion', 'ExitPlanMode']);
39
+
40
+ function createRequestId() {
41
+ if (typeof crypto.randomUUID === 'function') {
42
+ return crypto.randomUUID();
43
+ }
44
+ return crypto.randomBytes(16).toString('hex');
45
+ }
46
+
47
+ function waitForToolApproval(requestId, options = {}) {
48
+ const { timeoutMs = TOOL_APPROVAL_TIMEOUT_MS, signal, onCancel, metadata } = options;
49
+
50
+ return new Promise(resolve => {
51
+ let settled = false;
52
+
53
+ const finalize = (decision) => {
54
+ if (settled) return;
55
+ settled = true;
56
+ cleanup();
57
+ resolve(decision);
58
+ };
59
+
60
+ let timeout;
61
+
62
+ const cleanup = () => {
63
+ pendingToolApprovals.delete(requestId);
64
+ if (timeout) clearTimeout(timeout);
65
+ if (signal && abortHandler) {
66
+ signal.removeEventListener('abort', abortHandler);
67
+ }
68
+ };
69
+
70
+ // timeoutMs 0 = wait indefinitely (interactive tools)
71
+ if (timeoutMs > 0) {
72
+ timeout = setTimeout(() => {
73
+ onCancel?.('timeout');
74
+ finalize(null);
75
+ }, timeoutMs);
76
+ }
77
+
78
+ const abortHandler = () => {
79
+ onCancel?.('cancelled');
80
+ finalize({ cancelled: true });
81
+ };
82
+
83
+ if (signal) {
84
+ if (signal.aborted) {
85
+ onCancel?.('cancelled');
86
+ finalize({ cancelled: true });
87
+ return;
88
+ }
89
+ signal.addEventListener('abort', abortHandler, { once: true });
90
+ }
91
+
92
+ const resolver = (decision) => {
93
+ finalize(decision);
94
+ };
95
+ // Attach metadata for getPendingApprovalsForSession lookup
96
+ if (metadata) {
97
+ Object.assign(resolver, metadata);
98
+ }
99
+ pendingToolApprovals.set(requestId, resolver);
100
+ });
101
+ }
102
+
103
+ function resolveToolApproval(requestId, decision) {
104
+ const resolver = pendingToolApprovals.get(requestId);
105
+ if (resolver) {
106
+ resolver(decision);
107
+ }
108
+ }
109
+
110
+ // Match stored permission entries against a tool + input combo.
111
+ // This only supports exact tool names and the Bash(command:*) shorthand
112
+ // used by the UI; it intentionally does not implement full glob semantics,
113
+ // introduced to stay consistent with the UI's "Allow rule" format.
114
+ function matchesToolPermission(entry, toolName, input) {
115
+ if (!entry || !toolName) {
116
+ return false;
117
+ }
118
+
119
+ if (entry === toolName) {
120
+ return true;
121
+ }
122
+
123
+ const bashMatch = entry.match(/^Bash\((.+):\*\)$/);
124
+ if (toolName === 'Bash' && bashMatch) {
125
+ const allowedPrefix = bashMatch[1];
126
+ let command = '';
127
+
128
+ if (typeof input === 'string') {
129
+ command = input.trim();
130
+ } else if (input && typeof input === 'object' && typeof input.command === 'string') {
131
+ command = input.command.trim();
132
+ }
133
+
134
+ if (!command) {
135
+ return false;
136
+ }
137
+
138
+ return command.startsWith(allowedPrefix);
139
+ }
140
+
141
+ return false;
142
+ }
143
+
144
+ function readClaudeSettingsEnv(filePath) {
145
+ try {
146
+ if (!existsSync(filePath)) return {};
147
+ const parsed = JSON.parse(readFileSync(filePath, 'utf8'));
148
+ const env = parsed?.env;
149
+ if (!env || typeof env !== 'object') return {};
150
+
151
+ return Object.fromEntries(
152
+ Object.entries(env)
153
+ .filter(([key, value]) =>
154
+ typeof value === 'string' &&
155
+ value.trim() &&
156
+ !key.startsWith('//'),
157
+ ),
158
+ );
159
+ } catch {
160
+ return {};
161
+ }
162
+ }
163
+
164
+ function loadClaudeSettingsEnv(cwd) {
165
+ const files = [
166
+ path.join(os.homedir(), '.claude', 'settings.json'),
167
+ ];
168
+ if (cwd) {
169
+ files.push(
170
+ path.join(cwd, '.claude', 'settings.json'),
171
+ path.join(cwd, '.claude', 'settings.local.json'),
172
+ );
173
+ }
174
+
175
+ return files.reduce((env, filePath) => ({
176
+ ...env,
177
+ ...readClaudeSettingsEnv(filePath),
178
+ }), {});
179
+ }
180
+
181
+ /**
182
+ * Maps CLI options to SDK-compatible options format
183
+ * @param {Object} options - CLI options
184
+ * @returns {Object} SDK-compatible options
185
+ */
186
+ function mapCliOptionsToSDK(options = {}) {
187
+ const { sessionId, cwd, toolsSettings, permissionMode } = options;
188
+
189
+ const sdkOptions = {};
190
+
191
+ // Forward all host env vars (e.g. ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN) to the subprocess.
192
+ // Since claude-agent-sdk 0.2.113+, options.env REPLACES process.env in the subprocess
193
+ // instead of overlaying it. Without spreading process.env here, users who rely on
194
+ // ANTHROPIC_BASE_URL, HTTP(S)_PROXY, etc. would silently lose those settings.
195
+ sdkOptions.env = { ...process.env, ...loadClaudeSettingsEnv(cwd) };
196
+
197
+ // Claude Code on Windows hard-requires a POSIX bash (typically from Git
198
+ // for Windows) and reads its path from CLAUDE_CODE_GIT_BASH_PATH. If the
199
+ // user has git-bash installed but hasn't exported that var, the CLI
200
+ // exits with code 1 + a guidance message and the pixcode UI just sees
201
+ // an opaque "Claude Code process exited with code 1" error. Auto-probing
202
+ // a handful of known install locations lets the CLI boot transparently.
203
+ if (!sdkOptions.env.CLAUDE_CODE_GIT_BASH_PATH) {
204
+ const bashPath = resolveGitBashPath();
205
+ if (bashPath) sdkOptions.env.CLAUDE_CODE_GIT_BASH_PATH = bashPath;
206
+ }
207
+
208
+ // Resolve the Claude Code CLI path cross-platform. The SDK uses plain
209
+ // `child_process.spawn(command, args)` with no shell — and its own
210
+ // `nb()` helper treats anything not ending in .js/.mjs/.ts as a native
211
+ // executable. That means:
212
+ // - Unix: bare `"claude"` works (kernel PATH + shebang handle it).
213
+ // - Windows: bare `"claude"` fails ("native binary not found"). A
214
+ // `.cmd` shim fails with EINVAL (post-CVE-2024 Node refuses .cmd
215
+ // without shell:true). We need the underlying `.exe` instead.
216
+ // `resolveClaudeExecutable()` does a `where`/`which` lookup and, on
217
+ // Windows, peeks inside npm .cmd shims to recover the real .exe target.
218
+ // If nothing is found we leave the option unset so the SDK falls through
219
+ // to its own bundled-native-binary resolver.
220
+ const resolvedClaudePath = process.env.CLAUDE_CLI_PATH || resolveClaudeExecutable();
221
+ if (resolvedClaudePath) {
222
+ sdkOptions.pathToClaudeCodeExecutable = resolvedClaudePath;
223
+ }
224
+
225
+ // Map working directory
226
+ if (cwd) {
227
+ sdkOptions.cwd = cwd;
228
+ }
229
+
230
+ // Map permission mode
231
+ if (permissionMode && permissionMode !== 'default') {
232
+ sdkOptions.permissionMode = permissionMode;
233
+ }
234
+
235
+ // Map tool settings
236
+ const settings = toolsSettings || {
237
+ allowedTools: [],
238
+ disallowedTools: [],
239
+ skipPermissions: false
240
+ };
241
+
242
+ // Handle tool permissions
243
+ if (settings.skipPermissions && permissionMode !== 'plan') {
244
+ // When skipping permissions, use bypassPermissions mode
245
+ sdkOptions.permissionMode = 'bypassPermissions';
246
+ }
247
+
248
+ let allowedTools = [...(settings.allowedTools || [])];
249
+
250
+ // Add plan mode default tools
251
+ if (permissionMode === 'plan') {
252
+ const planModeTools = ['Read', 'Task', 'exit_plan_mode', 'TodoRead', 'TodoWrite', 'WebFetch', 'WebSearch'];
253
+ for (const tool of planModeTools) {
254
+ if (!allowedTools.includes(tool)) {
255
+ allowedTools.push(tool);
256
+ }
257
+ }
258
+ }
259
+
260
+ sdkOptions.allowedTools = allowedTools;
261
+
262
+ // Use the tools preset to make all default built-in tools available (including AskUserQuestion).
263
+ // This was introduced in SDK 0.1.57. Omitting this preserves existing behavior (all tools available),
264
+ // but being explicit ensures forward compatibility and clarity.
265
+ sdkOptions.tools = { type: 'preset', preset: 'claude_code' };
266
+
267
+ sdkOptions.disallowedTools = settings.disallowedTools || [];
268
+
269
+ // Map model only when Pixcode explicitly passes one. If omitted, let
270
+ // Claude Code load the user's own project/user/local settings, including
271
+ // ~/.claude/settings.json "model".
272
+ if (typeof options.model === 'string' && options.model.trim()) {
273
+ sdkOptions.model = options.model.trim();
274
+ }
275
+ // Model logged at query start below
276
+
277
+ // Map system prompt configuration
278
+ sdkOptions.systemPrompt = {
279
+ type: 'preset',
280
+ preset: 'claude_code' // Required to use CLAUDE.md
281
+ };
282
+
283
+ // Map setting sources for CLAUDE.md loading
284
+ // This loads CLAUDE.md from project, user (~/.config/claude/CLAUDE.md), and local directories
285
+ sdkOptions.settingSources = ['project', 'user', 'local'];
286
+
287
+ // Map resume session
288
+ if (sessionId) {
289
+ sdkOptions.resume = sessionId;
290
+ }
291
+
292
+ return sdkOptions;
293
+ }
294
+
295
+ /**
296
+ * Adds a session to the active sessions map
297
+ * @param {string} sessionId - Session identifier
298
+ * @param {Object} queryInstance - SDK query instance
299
+ * @param {Array<string>} tempImagePaths - Temp image file paths for cleanup
300
+ * @param {string} tempDir - Temp directory for cleanup
301
+ */
302
+ function addSession(sessionId, queryInstance, tempImagePaths = [], tempDir = null, writer = null) {
303
+ activeSessions.set(sessionId, {
304
+ instance: queryInstance,
305
+ startTime: Date.now(),
306
+ status: 'active',
307
+ tempImagePaths,
308
+ tempDir,
309
+ writer
310
+ });
311
+ }
312
+
313
+ /**
314
+ * Removes a session from the active sessions map
315
+ * @param {string} sessionId - Session identifier
316
+ */
317
+ function removeSession(sessionId) {
318
+ activeSessions.delete(sessionId);
319
+ }
320
+
321
+ /**
322
+ * Gets a session from the active sessions map
323
+ * @param {string} sessionId - Session identifier
324
+ * @returns {Object|undefined} Session data or undefined
325
+ */
326
+ function getSession(sessionId) {
327
+ return activeSessions.get(sessionId);
328
+ }
329
+
330
+ /**
331
+ * Gets all active session IDs
332
+ * @returns {Array<string>} Array of active session IDs
333
+ */
334
+ function getAllSessions() {
335
+ return Array.from(activeSessions.keys());
336
+ }
337
+
338
+ /**
339
+ * Transforms SDK messages to WebSocket format expected by frontend
340
+ * @param {Object} sdkMessage - SDK message object
341
+ * @returns {Object} Transformed message ready for WebSocket
342
+ */
343
+ function transformMessage(sdkMessage) {
344
+ // Extract parent_tool_use_id for subagent tool grouping
345
+ if (sdkMessage.parent_tool_use_id) {
346
+ return {
347
+ ...sdkMessage,
348
+ parentToolUseId: sdkMessage.parent_tool_use_id
349
+ };
350
+ }
351
+ return sdkMessage;
352
+ }
353
+
354
+ /**
355
+ * Extracts token usage from SDK result messages
356
+ * @param {Object} resultMessage - SDK result message
357
+ * @returns {Object|null} Token budget object or null
358
+ */
359
+ function extractTokenBudget(resultMessage) {
360
+ if (resultMessage.type !== 'result' || !resultMessage.modelUsage) {
361
+ return null;
362
+ }
363
+
364
+ // Get the first model's usage data
365
+ const modelKey = Object.keys(resultMessage.modelUsage)[0];
366
+ const modelData = resultMessage.modelUsage[modelKey];
367
+
368
+ if (!modelData) {
369
+ return null;
370
+ }
371
+
372
+ // Use cumulative tokens if available (tracks total for the session)
373
+ // Otherwise fall back to per-request tokens
374
+ const inputTokens = modelData.cumulativeInputTokens || modelData.inputTokens || 0;
375
+ const outputTokens = modelData.cumulativeOutputTokens || modelData.outputTokens || 0;
376
+ const cacheReadTokens = modelData.cumulativeCacheReadInputTokens || modelData.cacheReadInputTokens || 0;
377
+ const cacheCreationTokens = modelData.cumulativeCacheCreationInputTokens || modelData.cacheCreationInputTokens || 0;
378
+
379
+ // Total used = input + output + cache tokens
380
+ const totalUsed = inputTokens + outputTokens + cacheReadTokens + cacheCreationTokens;
381
+
382
+ // Use configured context window budget from environment (default 160000)
383
+ // This is the user's budget limit, not the model's context window
384
+ const contextWindow = parseInt(process.env.CONTEXT_WINDOW) || 160000;
385
+
386
+ // Token calc logged via token-budget WS event
387
+
388
+ return {
389
+ used: totalUsed,
390
+ total: contextWindow
391
+ };
392
+ }
393
+
394
+ /**
395
+ * Handles image processing for SDK queries
396
+ * Saves base64 images to temporary files and returns modified prompt with file paths
397
+ * @param {string} command - Original user prompt
398
+ * @param {Array} images - Array of image objects with base64 data
399
+ * @param {string} cwd - Working directory for temp file creation
400
+ * @returns {Promise<Object>} {modifiedCommand, tempImagePaths, tempDir}
401
+ */
402
+ async function handleImages(command, images, cwd) {
403
+ const tempImagePaths = [];
404
+ let tempDir = null;
405
+
406
+ if (!images || images.length === 0) {
407
+ return { modifiedCommand: command, tempImagePaths, tempDir };
408
+ }
409
+
410
+ try {
411
+ // Create temp directory in the project directory
412
+ const workingDir = cwd || process.cwd();
413
+ tempDir = path.join(workingDir, '.tmp', 'images', Date.now().toString());
414
+ await fs.mkdir(tempDir, { recursive: true });
415
+
416
+ // Save each image to a temp file
417
+ for (const [index, image] of images.entries()) {
418
+ // Extract base64 data and mime type
419
+ const matches = image.data.match(/^data:([^;]+);base64,(.+)$/);
420
+ if (!matches) {
421
+ console.error('Invalid image data format');
422
+ continue;
423
+ }
424
+
425
+ const [, mimeType, base64Data] = matches;
426
+ const extension = mimeType.split('/')[1] || 'png';
427
+ const filename = `image_${index}.${extension}`;
428
+ const filepath = path.join(tempDir, filename);
429
+
430
+ // Write base64 data to file
431
+ await fs.writeFile(filepath, Buffer.from(base64Data, 'base64'));
432
+ tempImagePaths.push(filepath);
433
+ }
434
+
435
+ // Include the full image paths in the prompt
436
+ let modifiedCommand = command;
437
+ if (tempImagePaths.length > 0 && command && command.trim()) {
438
+ const imageNote = `\n\n[Images provided at the following paths:]\n${tempImagePaths.map((p, i) => `${i + 1}. ${p}`).join('\n')}`;
439
+ modifiedCommand = command + imageNote;
440
+ }
441
+
442
+ // Images processed
443
+ return { modifiedCommand, tempImagePaths, tempDir };
444
+ } catch (error) {
445
+ console.error('Error processing images for SDK:', error);
446
+ return { modifiedCommand: command, tempImagePaths, tempDir };
447
+ }
448
+ }
449
+
450
+ /**
451
+ * Cleans up temporary image files
452
+ * @param {Array<string>} tempImagePaths - Array of temp file paths to delete
453
+ * @param {string} tempDir - Temp directory to remove
454
+ */
455
+ async function cleanupTempFiles(tempImagePaths, tempDir) {
456
+ if (!tempImagePaths || tempImagePaths.length === 0) {
457
+ return;
458
+ }
459
+
460
+ try {
461
+ // Delete individual temp files
462
+ for (const imagePath of tempImagePaths) {
463
+ await fs.unlink(imagePath).catch(err =>
464
+ console.error(`Failed to delete temp image ${imagePath}:`, err)
465
+ );
466
+ }
467
+
468
+ // Delete temp directory
469
+ if (tempDir) {
470
+ await fs.rm(tempDir, { recursive: true, force: true }).catch(err =>
471
+ console.error(`Failed to delete temp directory ${tempDir}:`, err)
472
+ );
473
+ }
474
+
475
+ // Temp files cleaned
476
+ } catch (error) {
477
+ console.error('Error during temp file cleanup:', error);
478
+ }
479
+ }
480
+
481
+ /**
482
+ * Loads MCP server configurations from ~/.claude.json
483
+ * @param {string} cwd - Current working directory for project-specific configs
484
+ * @returns {Object|null} MCP servers object or null if none found
485
+ */
486
+ async function loadMcpConfig(cwd) {
487
+ try {
488
+ const claudeConfigPath = path.join(os.homedir(), '.claude.json');
489
+
490
+ // Check if config file exists
491
+ try {
492
+ await fs.access(claudeConfigPath);
493
+ } catch (error) {
494
+ // File doesn't exist, return null
495
+ // No config file
496
+ return null;
497
+ }
498
+
499
+ // Read and parse config file
500
+ let claudeConfig;
501
+ try {
502
+ const configContent = await fs.readFile(claudeConfigPath, 'utf8');
503
+ claudeConfig = JSON.parse(configContent);
504
+ } catch (error) {
505
+ console.error('Failed to parse ~/.claude.json:', error.message);
506
+ return null;
507
+ }
508
+
509
+ // Extract MCP servers (merge global and project-specific)
510
+ let mcpServers = {};
511
+
512
+ // Add global MCP servers
513
+ if (claudeConfig.mcpServers && typeof claudeConfig.mcpServers === 'object') {
514
+ mcpServers = { ...claudeConfig.mcpServers };
515
+ // Global MCP servers loaded
516
+ }
517
+
518
+ // Add/override with project-specific MCP servers
519
+ if (claudeConfig.claudeProjects && cwd) {
520
+ const projectConfig = claudeConfig.claudeProjects[cwd];
521
+ if (projectConfig && projectConfig.mcpServers && typeof projectConfig.mcpServers === 'object') {
522
+ mcpServers = { ...mcpServers, ...projectConfig.mcpServers };
523
+ // Project MCP servers merged
524
+ }
525
+ }
526
+
527
+ // Return null if no servers found
528
+ if (Object.keys(mcpServers).length === 0) {
529
+ return null;
530
+ }
531
+ return mcpServers;
532
+ } catch (error) {
533
+ console.error('Error loading MCP config:', error.message);
534
+ return null;
535
+ }
536
+ }
537
+
538
+ /**
539
+ * Executes a Claude query using the SDK
540
+ * @param {string} command - User prompt/command
541
+ * @param {Object} options - Query options
542
+ * @param {Object} ws - WebSocket connection
543
+ * @returns {Promise<void>}
544
+ */
545
+ async function queryClaudeSDK(command, options = {}, ws) {
546
+ const { sessionId, sessionSummary } = options;
547
+ let capturedSessionId = sessionId;
548
+ let sessionCreatedSent = false;
549
+ let tempImagePaths = [];
550
+ let tempDir = null;
551
+
552
+ const emitNotification = (event) => {
553
+ notifyUserIfEnabled({
554
+ userId: ws?.userId || null,
555
+ writer: ws,
556
+ event
557
+ });
558
+ };
559
+
560
+ try {
561
+ // Map CLI options to SDK format
562
+ const sdkOptions = mapCliOptionsToSDK(options);
563
+
564
+ // Load MCP configuration
565
+ const mcpServers = await loadMcpConfig(options.cwd);
566
+ if (mcpServers) {
567
+ sdkOptions.mcpServers = mcpServers;
568
+ }
569
+
570
+ // Handle images - save to temp files and modify prompt
571
+ const imageResult = await handleImages(command, options.images, options.cwd);
572
+ const finalCommand = imageResult.modifiedCommand;
573
+ tempImagePaths = imageResult.tempImagePaths;
574
+ tempDir = imageResult.tempDir;
575
+
576
+ sdkOptions.hooks = {
577
+ Notification: [{
578
+ matcher: '',
579
+ hooks: [async (input) => {
580
+ const message = typeof input?.message === 'string' ? input.message : 'Claude requires your attention.';
581
+ emitNotification(createNotificationEvent({
582
+ provider: 'claude',
583
+ sessionId: capturedSessionId || sessionId || null,
584
+ kind: 'action_required',
585
+ code: 'agent.notification',
586
+ meta: { message, sessionName: sessionSummary },
587
+ severity: 'warning',
588
+ requiresUserAction: true,
589
+ dedupeKey: `claude:hook:notification:${capturedSessionId || sessionId || 'none'}:${message}`
590
+ }));
591
+ return {};
592
+ }]
593
+ }]
594
+ };
595
+
596
+ sdkOptions.canUseTool = async (toolName, input, context) => {
597
+ const requiresInteraction = TOOLS_REQUIRING_INTERACTION.has(toolName);
598
+
599
+ if (!requiresInteraction) {
600
+ if (sdkOptions.permissionMode === 'bypassPermissions') {
601
+ return { behavior: 'allow', updatedInput: input };
602
+ }
603
+
604
+ const isDisallowed = (sdkOptions.disallowedTools || []).some(entry =>
605
+ matchesToolPermission(entry, toolName, input)
606
+ );
607
+ if (isDisallowed) {
608
+ return { behavior: 'deny', message: 'Tool disallowed by settings' };
609
+ }
610
+
611
+ const isAllowed = (sdkOptions.allowedTools || []).some(entry =>
612
+ matchesToolPermission(entry, toolName, input)
613
+ );
614
+ if (isAllowed) {
615
+ return { behavior: 'allow', updatedInput: input };
616
+ }
617
+ }
618
+
619
+ const requestId = createRequestId();
620
+ ws.send(createNormalizedMessage({ kind: 'permission_request', requestId, toolName, input, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));
621
+ emitNotification(createNotificationEvent({
622
+ provider: 'claude',
623
+ sessionId: capturedSessionId || sessionId || null,
624
+ kind: 'action_required',
625
+ code: 'permission.required',
626
+ meta: { toolName, sessionName: sessionSummary },
627
+ severity: 'warning',
628
+ requiresUserAction: true,
629
+ dedupeKey: `claude:permission:${capturedSessionId || sessionId || 'none'}:${requestId}`
630
+ }));
631
+
632
+ const decision = await waitForToolApproval(requestId, {
633
+ timeoutMs: requiresInteraction ? 0 : undefined,
634
+ signal: context?.signal,
635
+ metadata: {
636
+ _sessionId: capturedSessionId || sessionId || null,
637
+ _toolName: toolName,
638
+ _input: input,
639
+ _receivedAt: new Date(),
640
+ },
641
+ onCancel: (reason) => {
642
+ ws.send(createNormalizedMessage({ kind: 'permission_cancelled', requestId, reason, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));
643
+ }
644
+ });
645
+ if (!decision) {
646
+ return { behavior: 'deny', message: 'Permission request timed out' };
647
+ }
648
+
649
+ if (decision.cancelled) {
650
+ return { behavior: 'deny', message: 'Permission request cancelled' };
651
+ }
652
+
653
+ if (decision.allow) {
654
+ if (decision.rememberEntry && typeof decision.rememberEntry === 'string') {
655
+ if (!sdkOptions.allowedTools.includes(decision.rememberEntry)) {
656
+ sdkOptions.allowedTools.push(decision.rememberEntry);
657
+ }
658
+ if (Array.isArray(sdkOptions.disallowedTools)) {
659
+ sdkOptions.disallowedTools = sdkOptions.disallowedTools.filter(entry => entry !== decision.rememberEntry);
660
+ }
661
+ }
662
+ return { behavior: 'allow', updatedInput: decision.updatedInput ?? input };
663
+ }
664
+
665
+ return { behavior: 'deny', message: decision.message ?? 'User denied tool use' };
666
+ };
667
+
668
+ // Set stream-close timeout for interactive tools (Query constructor reads it synchronously). Claude Agent SDK has a default of 5s and this overrides it
669
+ const prevStreamTimeout = process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT;
670
+ process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT = '300000';
671
+
672
+ let queryInstance;
673
+ try {
674
+ queryInstance = query({
675
+ prompt: finalCommand,
676
+ options: sdkOptions
677
+ });
678
+ } catch (hookError) {
679
+ // Older/newer SDK versions may not accept hook shapes yet.
680
+ // Keep notification behavior operational via runtime events even if hook registration fails.
681
+ console.warn('Failed to initialize Claude query with hooks, retrying without hooks:', hookError?.message || hookError);
682
+ delete sdkOptions.hooks;
683
+ queryInstance = query({
684
+ prompt: finalCommand,
685
+ options: sdkOptions
686
+ });
687
+ }
688
+
689
+ // Restore immediately Query constructor already captured the value
690
+ if (prevStreamTimeout !== undefined) {
691
+ process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT = prevStreamTimeout;
692
+ } else {
693
+ delete process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT;
694
+ }
695
+
696
+ // Track the query instance for abort capability
697
+ if (capturedSessionId) {
698
+ addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir, ws);
699
+ }
700
+
701
+ // Process streaming messages
702
+ console.log('Starting async generator loop for session:', capturedSessionId || 'NEW');
703
+ for await (const message of queryInstance) {
704
+ // Capture session ID from first message
705
+ if (message.session_id && !capturedSessionId) {
706
+
707
+ capturedSessionId = message.session_id;
708
+ addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir, ws);
709
+
710
+ // Set session ID on writer
711
+ if (ws.setSessionId && typeof ws.setSessionId === 'function') {
712
+ ws.setSessionId(capturedSessionId);
713
+ }
714
+
715
+ // Send session-created event only once for new sessions
716
+ if (!sessionId && !sessionCreatedSent) {
717
+ sessionCreatedSent = true;
718
+ ws.send(createNormalizedMessage({ kind: 'session_created', newSessionId: capturedSessionId, sessionId: capturedSessionId, provider: 'claude' }));
719
+ }
720
+ } else {
721
+ // session_id already captured
722
+ }
723
+
724
+ // Transform and normalize message via adapter
725
+ const transformedMessage = transformMessage(message);
726
+ const sid = capturedSessionId || sessionId || null;
727
+
728
+ // Use adapter to normalize SDK events into NormalizedMessage[]
729
+ const normalized = sessionsService.normalizeMessage('claude', transformedMessage, sid);
730
+ for (const msg of normalized) {
731
+ // Preserve parentToolUseId from SDK wrapper for subagent tool grouping
732
+ if (transformedMessage.parentToolUseId && !msg.parentToolUseId) {
733
+ msg.parentToolUseId = transformedMessage.parentToolUseId;
734
+ }
735
+ ws.send(msg);
736
+ }
737
+
738
+ // Extract and send token budget updates from result messages
739
+ if (message.type === 'result') {
740
+ const models = Object.keys(message.modelUsage || {});
741
+ if (models.length > 0) {
742
+ // Model info available in result message
743
+ }
744
+ const tokenBudgetData = extractTokenBudget(message);
745
+ if (tokenBudgetData) {
746
+ ws.send(createNormalizedMessage({ kind: 'status', text: 'token_budget', tokenBudget: tokenBudgetData, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));
747
+ }
748
+ }
749
+ }
750
+
751
+ // Clean up session on completion
752
+ if (capturedSessionId) {
753
+ removeSession(capturedSessionId);
754
+ }
755
+
756
+ // Clean up temporary image files
757
+ await cleanupTempFiles(tempImagePaths, tempDir);
758
+
759
+ // Send completion event
760
+ ws.send(createNormalizedMessage({ kind: 'complete', exitCode: 0, isNewSession: !sessionId && !!command, sessionId: capturedSessionId, provider: 'claude' }));
761
+ notifyRunStopped({
762
+ userId: ws?.userId || null,
763
+ provider: 'claude',
764
+ sessionId: capturedSessionId || sessionId || null,
765
+ sessionName: sessionSummary,
766
+ stopReason: 'completed'
767
+ });
768
+ // Complete
769
+
770
+ } catch (error) {
771
+ console.error('SDK query error:', error);
772
+
773
+ // Clean up session on error
774
+ if (capturedSessionId) {
775
+ removeSession(capturedSessionId);
776
+ }
777
+
778
+ // Clean up temporary image files on error
779
+ await cleanupTempFiles(tempImagePaths, tempDir);
780
+
781
+ // Check if Claude CLI is installed for a clearer error message
782
+ const installed = await providerAuthService.isProviderInstalled('claude');
783
+ const errorContent = !installed
784
+ ? 'Claude Code is not installed. Please install it first: https://docs.anthropic.com/en/docs/claude-code'
785
+ : error.message;
786
+
787
+ // Send error to WebSocket
788
+ ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));
789
+ notifyRunFailed({
790
+ userId: ws?.userId || null,
791
+ provider: 'claude',
792
+ sessionId: capturedSessionId || sessionId || null,
793
+ sessionName: sessionSummary,
794
+ error
795
+ });
796
+ }
797
+ }
798
+
799
+ /**
800
+ * Aborts an active SDK session
801
+ * @param {string} sessionId - Session identifier
802
+ * @returns {boolean} True if session was aborted, false if not found
803
+ */
804
+ async function abortClaudeSDKSession(sessionId) {
805
+ const session = getSession(sessionId);
806
+
807
+ if (!session) {
808
+ console.log(`Session ${sessionId} not found`);
809
+ return false;
810
+ }
811
+
812
+ try {
813
+ console.log(`Aborting SDK session: ${sessionId}`);
814
+
815
+ // Call interrupt() on the query instance
816
+ await session.instance.interrupt();
817
+
818
+ // Update session status
819
+ session.status = 'aborted';
820
+
821
+ // Clean up temporary image files
822
+ await cleanupTempFiles(session.tempImagePaths, session.tempDir);
823
+
824
+ // Clean up session
825
+ removeSession(sessionId);
826
+
827
+ return true;
828
+ } catch (error) {
829
+ console.error(`Error aborting session ${sessionId}:`, error);
830
+ return false;
831
+ }
832
+ }
833
+
834
+ /**
835
+ * Checks if an SDK session is currently active
836
+ * @param {string} sessionId - Session identifier
837
+ * @returns {boolean} True if session is active
838
+ */
839
+ function isClaudeSDKSessionActive(sessionId) {
840
+ const session = getSession(sessionId);
841
+ return session && session.status === 'active';
842
+ }
843
+
844
+ /**
845
+ * Gets all active SDK session IDs
846
+ * @returns {Array<string>} Array of active session IDs
847
+ */
848
+ function getActiveClaudeSDKSessions() {
849
+ return getAllSessions();
850
+ }
851
+
852
+ /**
853
+ * Get pending tool approvals for a specific session.
854
+ * @param {string} sessionId - The session ID
855
+ * @returns {Array} Array of pending permission request objects
856
+ */
857
+ function getPendingApprovalsForSession(sessionId) {
858
+ const pending = [];
859
+ for (const [requestId, resolver] of pendingToolApprovals.entries()) {
860
+ if (resolver._sessionId === sessionId) {
861
+ pending.push({
862
+ requestId,
863
+ toolName: resolver._toolName || 'UnknownTool',
864
+ input: resolver._input,
865
+ context: resolver._context,
866
+ sessionId,
867
+ receivedAt: resolver._receivedAt || new Date(),
868
+ });
869
+ }
870
+ }
871
+ return pending;
872
+ }
873
+
874
+ /**
875
+ * Reconnect a session's WebSocketWriter to a new raw WebSocket.
876
+ * Called when client reconnects (e.g. page refresh) while SDK is still running.
877
+ * @param {string} sessionId - The session ID
878
+ * @param {Object} newRawWs - The new raw WebSocket connection
879
+ * @returns {boolean} True if writer was successfully reconnected
880
+ */
881
+ function reconnectSessionWriter(sessionId, newRawWs) {
882
+ const session = getSession(sessionId);
883
+ if (!session?.writer?.updateWebSocket) return false;
884
+ session.writer.updateWebSocket(newRawWs);
885
+ console.log(`[RECONNECT] Writer swapped for session ${sessionId}`);
886
+ return true;
887
+ }
888
+
889
+ // Export public API
890
+ export {
891
+ queryClaudeSDK,
892
+ abortClaudeSDKSession,
893
+ isClaudeSDKSessionActive,
894
+ getActiveClaudeSDKSessions,
895
+ resolveToolApproval,
896
+ getPendingApprovalsForSession,
897
+ reconnectSessionWriter
898
+ };