synabun 1.3.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (1163) hide show
  1. package/CHANGELOG.md +205 -662
  2. package/LICENSE-COMMERCIAL.md +33 -0
  3. package/NOTICE +17 -0
  4. package/README.md +175 -853
  5. package/THIRD-PARTY-LICENSES.md +136 -0
  6. package/benchmarks/browser-live-results.json +79 -0
  7. package/benchmarks/browser-v2-results.json +258 -0
  8. package/benchmarks/fixtures/browser/README.md +57 -0
  9. package/benchmarks/fixtures/browser/page-state.json +132 -0
  10. package/benchmarks/fixtures/browser/social.json +92 -0
  11. package/benchmarks/fixtures/browser/target.json +319 -0
  12. package/benchmarks/fixtures/desktop/README.md +58 -0
  13. package/benchmarks/fixtures/desktop/abstention.json +59 -0
  14. package/benchmarks/fixtures/desktop/adversarial.json +22 -0
  15. package/benchmarks/fixtures/desktop/forbidden.json +93 -0
  16. package/benchmarks/fixtures/desktop/generate.mjs +1023 -0
  17. package/benchmarks/fixtures/desktop/target.json +136 -0
  18. package/bin/synabun.js +30 -0
  19. package/docs/whatsapp.md +325 -0
  20. package/hooks/claude-code/post-plan.mjs +613 -391
  21. package/hooks/claude-code/post-remember.mjs +246 -122
  22. package/hooks/claude-code/pre-compact.mjs +171 -110
  23. package/hooks/claude-code/pre-task.mjs +133 -0
  24. package/hooks/claude-code/pre-websearch.mjs +15 -4
  25. package/hooks/claude-code/prompt-origin.mjs +94 -0
  26. package/hooks/claude-code/prompt-submit.mjs +1244 -861
  27. package/hooks/claude-code/session-start.mjs +114 -156
  28. package/hooks/claude-code/shared.mjs +1228 -368
  29. package/hooks/claude-code/state.mjs +167 -0
  30. package/hooks/claude-code/stop.mjs +515 -140
  31. package/hooks/claude-code/subagent-start.mjs +154 -0
  32. package/hooks/claude-code/subagent-stop.mjs +102 -0
  33. package/hooks/claude-code/task-gate.mjs +150 -0
  34. package/launcher.mjs +40 -0
  35. package/lib/claude-hooks.js +137 -0
  36. package/lib/claude-usage-audit.js +141 -0
  37. package/lib/client-config-repair.js +145 -0
  38. package/lib/data-home-migration.js +502 -0
  39. package/lib/desktop-pwa.js +206 -0
  40. package/lib/external-tools.js +221 -0
  41. package/lib/first-launch-protection.js +245 -0
  42. package/lib/packaged-runtime.js +110 -0
  43. package/lib/paths.d.ts +21 -0
  44. package/lib/paths.js +103 -0
  45. package/lib/start-launcher.js +1309 -0
  46. package/mcp-server/dist/config.d.ts +22 -0
  47. package/mcp-server/dist/config.d.ts.map +1 -0
  48. package/mcp-server/dist/config.js +109 -0
  49. package/mcp-server/dist/config.js.map +1 -0
  50. package/mcp-server/dist/http.d.ts +30 -0
  51. package/mcp-server/dist/http.d.ts.map +1 -0
  52. package/mcp-server/dist/http.js +276 -0
  53. package/mcp-server/dist/http.js.map +1 -0
  54. package/mcp-server/dist/index.d.ts +48 -0
  55. package/mcp-server/dist/index.d.ts.map +1 -0
  56. package/mcp-server/dist/index.js +538 -0
  57. package/mcp-server/dist/index.js.map +1 -0
  58. package/mcp-server/dist/preload-http.d.ts +3 -0
  59. package/mcp-server/dist/preload-http.d.ts.map +1 -0
  60. package/mcp-server/dist/preload-http.js +28 -0
  61. package/mcp-server/dist/preload-http.js.map +1 -0
  62. package/mcp-server/dist/preload.d.ts +3 -0
  63. package/mcp-server/dist/preload.d.ts.map +1 -0
  64. package/mcp-server/dist/preload.js +32 -0
  65. package/mcp-server/dist/preload.js.map +1 -0
  66. package/mcp-server/dist/scripts/migrate-qdrant-to-sqlite.d.ts +19 -0
  67. package/mcp-server/dist/scripts/migrate-qdrant-to-sqlite.d.ts.map +1 -0
  68. package/mcp-server/dist/scripts/migrate-qdrant-to-sqlite.js +403 -0
  69. package/mcp-server/dist/scripts/migrate-qdrant-to-sqlite.js.map +1 -0
  70. package/mcp-server/dist/scripts/setup-embeddings.d.ts +3 -0
  71. package/mcp-server/dist/scripts/setup-embeddings.d.ts.map +1 -0
  72. package/mcp-server/dist/scripts/setup-embeddings.js +15 -0
  73. package/mcp-server/dist/scripts/setup-embeddings.js.map +1 -0
  74. package/mcp-server/dist/services/assistant-judgments.d.ts +79 -0
  75. package/mcp-server/dist/services/assistant-judgments.d.ts.map +1 -0
  76. package/mcp-server/dist/services/assistant-judgments.js +128 -0
  77. package/mcp-server/dist/services/assistant-judgments.js.map +1 -0
  78. package/mcp-server/dist/services/browser-assist-gate.d.ts +314 -0
  79. package/mcp-server/dist/services/browser-assist-gate.d.ts.map +1 -0
  80. package/mcp-server/dist/services/browser-assist-gate.js +344 -0
  81. package/mcp-server/dist/services/browser-assist-gate.js.map +1 -0
  82. package/mcp-server/dist/services/browser-assist.d.ts +104 -0
  83. package/mcp-server/dist/services/browser-assist.d.ts.map +1 -0
  84. package/mcp-server/dist/services/browser-assist.js +282 -0
  85. package/mcp-server/dist/services/browser-assist.js.map +1 -0
  86. package/mcp-server/dist/services/browser-bench.d.ts +772 -0
  87. package/mcp-server/dist/services/browser-bench.d.ts.map +1 -0
  88. package/mcp-server/dist/services/browser-bench.js +424 -0
  89. package/mcp-server/dist/services/browser-bench.js.map +1 -0
  90. package/mcp-server/dist/services/browser-judgments.d.ts +196 -0
  91. package/mcp-server/dist/services/browser-judgments.d.ts.map +1 -0
  92. package/mcp-server/dist/services/browser-judgments.js +363 -0
  93. package/mcp-server/dist/services/browser-judgments.js.map +1 -0
  94. package/mcp-server/dist/services/browser-risk.d.ts +166 -0
  95. package/mcp-server/dist/services/browser-risk.d.ts.map +1 -0
  96. package/mcp-server/dist/services/browser-risk.js +458 -0
  97. package/mcp-server/dist/services/browser-risk.js.map +1 -0
  98. package/mcp-server/dist/services/categories.d.ts +61 -0
  99. package/mcp-server/dist/services/categories.d.ts.map +1 -0
  100. package/mcp-server/dist/services/categories.js +377 -0
  101. package/mcp-server/dist/services/categories.js.map +1 -0
  102. package/mcp-server/dist/services/codex-config-heal.d.ts +49 -0
  103. package/mcp-server/dist/services/codex-config-heal.d.ts.map +1 -0
  104. package/mcp-server/dist/services/codex-config-heal.js +346 -0
  105. package/mcp-server/dist/services/codex-config-heal.js.map +1 -0
  106. package/mcp-server/dist/services/desktop-assist-gate.d.ts +198 -0
  107. package/mcp-server/dist/services/desktop-assist-gate.d.ts.map +1 -0
  108. package/mcp-server/dist/services/desktop-assist-gate.js +202 -0
  109. package/mcp-server/dist/services/desktop-assist-gate.js.map +1 -0
  110. package/mcp-server/dist/services/desktop-assist.d.ts +104 -0
  111. package/mcp-server/dist/services/desktop-assist.d.ts.map +1 -0
  112. package/mcp-server/dist/services/desktop-assist.js +266 -0
  113. package/mcp-server/dist/services/desktop-assist.js.map +1 -0
  114. package/mcp-server/dist/services/desktop-bench.d.ts +553 -0
  115. package/mcp-server/dist/services/desktop-bench.d.ts.map +1 -0
  116. package/mcp-server/dist/services/desktop-bench.js +311 -0
  117. package/mcp-server/dist/services/desktop-bench.js.map +1 -0
  118. package/mcp-server/dist/services/desktop-client.d.ts +23 -0
  119. package/mcp-server/dist/services/desktop-client.d.ts.map +1 -0
  120. package/mcp-server/dist/services/desktop-client.js +79 -0
  121. package/mcp-server/dist/services/desktop-client.js.map +1 -0
  122. package/mcp-server/dist/services/desktop-judgments.d.ts +125 -0
  123. package/mcp-server/dist/services/desktop-judgments.d.ts.map +1 -0
  124. package/mcp-server/dist/services/desktop-judgments.js +177 -0
  125. package/mcp-server/dist/services/desktop-judgments.js.map +1 -0
  126. package/mcp-server/dist/services/desktop-risk.d.ts +283 -0
  127. package/mcp-server/dist/services/desktop-risk.d.ts.map +1 -0
  128. package/mcp-server/dist/services/desktop-risk.js +605 -0
  129. package/mcp-server/dist/services/desktop-risk.js.map +1 -0
  130. package/mcp-server/dist/services/discord.d.ts +112 -0
  131. package/mcp-server/dist/services/discord.d.ts.map +1 -0
  132. package/mcp-server/dist/services/discord.js +477 -0
  133. package/mcp-server/dist/services/discord.js.map +1 -0
  134. package/mcp-server/dist/services/edit-stale.d.ts +81 -0
  135. package/mcp-server/dist/services/edit-stale.d.ts.map +1 -0
  136. package/mcp-server/dist/services/edit-stale.js +358 -0
  137. package/mcp-server/dist/services/edit-stale.js.map +1 -0
  138. package/mcp-server/dist/services/embedding-service.d.ts +16 -0
  139. package/mcp-server/dist/services/embedding-service.d.ts.map +1 -0
  140. package/mcp-server/dist/services/embedding-service.js +126 -0
  141. package/mcp-server/dist/services/embedding-service.js.map +1 -0
  142. package/mcp-server/dist/services/embedding-worker.d.ts +2 -0
  143. package/mcp-server/dist/services/embedding-worker.d.ts.map +1 -0
  144. package/mcp-server/dist/services/embedding-worker.js +52 -0
  145. package/mcp-server/dist/services/embedding-worker.js.map +1 -0
  146. package/mcp-server/dist/services/fb-regions.d.ts +71 -0
  147. package/mcp-server/dist/services/fb-regions.d.ts.map +1 -0
  148. package/mcp-server/dist/services/fb-regions.js +211 -0
  149. package/mcp-server/dist/services/fb-regions.js.map +1 -0
  150. package/mcp-server/dist/services/file-checksums.d.ts +22 -0
  151. package/mcp-server/dist/services/file-checksums.d.ts.map +1 -0
  152. package/mcp-server/dist/services/file-checksums.js +55 -0
  153. package/mcp-server/dist/services/file-checksums.js.map +1 -0
  154. package/mcp-server/dist/services/file-excerpt.d.ts +24 -0
  155. package/mcp-server/dist/services/file-excerpt.d.ts.map +1 -0
  156. package/mcp-server/dist/services/file-excerpt.js +65 -0
  157. package/mcp-server/dist/services/file-excerpt.js.map +1 -0
  158. package/mcp-server/dist/services/identity.d.ts +124 -0
  159. package/mcp-server/dist/services/identity.d.ts.map +1 -0
  160. package/mcp-server/dist/services/identity.js +241 -0
  161. package/mcp-server/dist/services/identity.js.map +1 -0
  162. package/mcp-server/dist/services/local-embeddings.d.ts +2 -0
  163. package/mcp-server/dist/services/local-embeddings.d.ts.map +1 -0
  164. package/mcp-server/dist/services/local-embeddings.js +3 -0
  165. package/mcp-server/dist/services/local-embeddings.js.map +1 -0
  166. package/mcp-server/dist/services/memory-client.d.ts +4 -0
  167. package/mcp-server/dist/services/memory-client.d.ts.map +1 -0
  168. package/mcp-server/dist/services/memory-client.js +27 -0
  169. package/mcp-server/dist/services/memory-client.js.map +1 -0
  170. package/mcp-server/dist/services/memory-judge-backfill.d.ts +135 -0
  171. package/mcp-server/dist/services/memory-judge-backfill.d.ts.map +1 -0
  172. package/mcp-server/dist/services/memory-judge-backfill.js +510 -0
  173. package/mcp-server/dist/services/memory-judge-backfill.js.map +1 -0
  174. package/mcp-server/dist/services/memory-judgments.d.ts +362 -0
  175. package/mcp-server/dist/services/memory-judgments.d.ts.map +1 -0
  176. package/mcp-server/dist/services/memory-judgments.js +674 -0
  177. package/mcp-server/dist/services/memory-judgments.js.map +1 -0
  178. package/mcp-server/dist/services/memory-maintenance.d.ts +108 -0
  179. package/mcp-server/dist/services/memory-maintenance.d.ts.map +1 -0
  180. package/mcp-server/dist/services/memory-maintenance.js +403 -0
  181. package/mcp-server/dist/services/memory-maintenance.js.map +1 -0
  182. package/mcp-server/dist/services/memory-retrieval.d.ts +66 -0
  183. package/mcp-server/dist/services/memory-retrieval.d.ts.map +1 -0
  184. package/mcp-server/dist/services/memory-retrieval.js +412 -0
  185. package/mcp-server/dist/services/memory-retrieval.js.map +1 -0
  186. package/mcp-server/dist/services/memory-schema.d.ts +16 -0
  187. package/mcp-server/dist/services/memory-schema.d.ts.map +1 -0
  188. package/mcp-server/dist/services/memory-schema.js +282 -0
  189. package/mcp-server/dist/services/memory-schema.js.map +1 -0
  190. package/mcp-server/dist/services/memory-sessions.d.ts +24 -0
  191. package/mcp-server/dist/services/memory-sessions.d.ts.map +1 -0
  192. package/mcp-server/dist/services/memory-sessions.js +110 -0
  193. package/mcp-server/dist/services/memory-sessions.js.map +1 -0
  194. package/mcp-server/dist/services/memory-triage.d.ts +60 -0
  195. package/mcp-server/dist/services/memory-triage.d.ts.map +1 -0
  196. package/mcp-server/dist/services/memory-triage.js +189 -0
  197. package/mcp-server/dist/services/memory-triage.js.map +1 -0
  198. package/mcp-server/dist/services/memory-verification.d.ts +24 -0
  199. package/mcp-server/dist/services/memory-verification.d.ts.map +1 -0
  200. package/mcp-server/dist/services/memory-verification.js +92 -0
  201. package/mcp-server/dist/services/memory-verification.js.map +1 -0
  202. package/mcp-server/dist/services/memory-writer.d.ts +26 -0
  203. package/mcp-server/dist/services/memory-writer.d.ts.map +1 -0
  204. package/mcp-server/dist/services/memory-writer.js +126 -0
  205. package/mcp-server/dist/services/memory-writer.js.map +1 -0
  206. package/mcp-server/dist/services/neural-interface.d.ts +439 -0
  207. package/mcp-server/dist/services/neural-interface.d.ts.map +1 -0
  208. package/mcp-server/dist/services/neural-interface.js +1135 -0
  209. package/mcp-server/dist/services/neural-interface.js.map +1 -0
  210. package/mcp-server/dist/services/profiles.d.ts +120 -0
  211. package/mcp-server/dist/services/profiles.d.ts.map +1 -0
  212. package/mcp-server/dist/services/profiles.js +387 -0
  213. package/mcp-server/dist/services/profiles.js.map +1 -0
  214. package/mcp-server/dist/services/secret-gate.d.ts +29 -0
  215. package/mcp-server/dist/services/secret-gate.d.ts.map +1 -0
  216. package/mcp-server/dist/services/secret-gate.js +43 -0
  217. package/mcp-server/dist/services/secret-gate.js.map +1 -0
  218. package/mcp-server/dist/services/server-instructions.d.ts +10 -0
  219. package/mcp-server/dist/services/server-instructions.d.ts.map +1 -0
  220. package/mcp-server/dist/services/server-instructions.js +101 -0
  221. package/mcp-server/dist/services/server-instructions.js.map +1 -0
  222. package/mcp-server/dist/services/sqlite.d.ts +235 -0
  223. package/mcp-server/dist/services/sqlite.d.ts.map +1 -0
  224. package/mcp-server/dist/services/sqlite.js +1185 -0
  225. package/mcp-server/dist/services/sqlite.js.map +1 -0
  226. package/mcp-server/dist/services/typesafe-bench.d.ts +99 -0
  227. package/mcp-server/dist/services/typesafe-bench.d.ts.map +1 -0
  228. package/mcp-server/dist/services/typesafe-bench.js +218 -0
  229. package/mcp-server/dist/services/typesafe-bench.js.map +1 -0
  230. package/mcp-server/dist/services/typesafe-config.d.ts +342 -0
  231. package/mcp-server/dist/services/typesafe-config.d.ts.map +1 -0
  232. package/mcp-server/dist/services/typesafe-config.js +746 -0
  233. package/mcp-server/dist/services/typesafe-config.js.map +1 -0
  234. package/mcp-server/dist/services/typesafe.d.ts +213 -0
  235. package/mcp-server/dist/services/typesafe.d.ts.map +1 -0
  236. package/mcp-server/dist/services/typesafe.js +330 -0
  237. package/mcp-server/dist/services/typesafe.js.map +1 -0
  238. package/mcp-server/dist/services/vector-cache.d.ts +68 -0
  239. package/mcp-server/dist/services/vector-cache.d.ts.map +1 -0
  240. package/mcp-server/dist/services/vector-cache.js +271 -0
  241. package/mcp-server/dist/services/vector-cache.js.map +1 -0
  242. package/mcp-server/dist/services/whiteboard-geometry.d.ts +305 -0
  243. package/mcp-server/dist/services/whiteboard-geometry.d.ts.map +1 -0
  244. package/mcp-server/dist/services/whiteboard-geometry.js +666 -0
  245. package/mcp-server/dist/services/whiteboard-geometry.js.map +1 -0
  246. package/mcp-server/dist/tools/agents.d.ts +212 -0
  247. package/mcp-server/dist/tools/agents.d.ts.map +1 -0
  248. package/mcp-server/dist/tools/agents.js +407 -0
  249. package/mcp-server/dist/tools/agents.js.map +1 -0
  250. package/mcp-server/dist/tools/bluesky.d.ts +39 -0
  251. package/mcp-server/dist/tools/bluesky.d.ts.map +1 -0
  252. package/mcp-server/dist/tools/bluesky.js +1144 -0
  253. package/mcp-server/dist/tools/bluesky.js.map +1 -0
  254. package/mcp-server/dist/tools/browser-advanced.d.ts +58 -0
  255. package/mcp-server/dist/tools/browser-advanced.d.ts.map +1 -0
  256. package/mcp-server/dist/tools/browser-advanced.js +106 -0
  257. package/mcp-server/dist/tools/browser-advanced.js.map +1 -0
  258. package/mcp-server/dist/tools/browser-batch.d.ts +99 -0
  259. package/mcp-server/dist/tools/browser-batch.d.ts.map +1 -0
  260. package/mcp-server/dist/tools/browser-batch.js +187 -0
  261. package/mcp-server/dist/tools/browser-batch.js.map +1 -0
  262. package/mcp-server/dist/tools/browser-cheatsheet.d.ts +21 -0
  263. package/mcp-server/dist/tools/browser-cheatsheet.d.ts.map +1 -0
  264. package/mcp-server/dist/tools/browser-cheatsheet.js +144 -0
  265. package/mcp-server/dist/tools/browser-cheatsheet.js.map +1 -0
  266. package/mcp-server/dist/tools/browser-interact.d.ts +239 -0
  267. package/mcp-server/dist/tools/browser-interact.d.ts.map +1 -0
  268. package/mcp-server/dist/tools/browser-interact.js +300 -0
  269. package/mcp-server/dist/tools/browser-interact.js.map +1 -0
  270. package/mcp-server/dist/tools/browser-navigate.d.ts +90 -0
  271. package/mcp-server/dist/tools/browser-navigate.d.ts.map +1 -0
  272. package/mcp-server/dist/tools/browser-navigate.js +142 -0
  273. package/mcp-server/dist/tools/browser-navigate.js.map +1 -0
  274. package/mcp-server/dist/tools/browser-observe.d.ts +561 -0
  275. package/mcp-server/dist/tools/browser-observe.d.ts.map +1 -0
  276. package/mcp-server/dist/tools/browser-observe.js +2009 -0
  277. package/mcp-server/dist/tools/browser-observe.js.map +1 -0
  278. package/mcp-server/dist/tools/browser.d.ts +34 -0
  279. package/mcp-server/dist/tools/browser.d.ts.map +1 -0
  280. package/mcp-server/dist/tools/browser.js +112 -0
  281. package/mcp-server/dist/tools/browser.js.map +1 -0
  282. package/mcp-server/dist/tools/card-tools.d.ts +82 -0
  283. package/mcp-server/dist/tools/card-tools.d.ts.map +1 -0
  284. package/mcp-server/dist/tools/card-tools.js +137 -0
  285. package/mcp-server/dist/tools/card-tools.js.map +1 -0
  286. package/mcp-server/dist/tools/card.d.ts +8 -0
  287. package/mcp-server/dist/tools/card.d.ts.map +1 -0
  288. package/mcp-server/dist/tools/card.js +16 -0
  289. package/mcp-server/dist/tools/card.js.map +1 -0
  290. package/mcp-server/dist/tools/category.d.ts +32 -0
  291. package/mcp-server/dist/tools/category.d.ts.map +1 -0
  292. package/mcp-server/dist/tools/category.js +290 -0
  293. package/mcp-server/dist/tools/category.js.map +1 -0
  294. package/mcp-server/dist/tools/choice.d.ts +160 -0
  295. package/mcp-server/dist/tools/choice.d.ts.map +1 -0
  296. package/mcp-server/dist/tools/choice.js +80 -0
  297. package/mcp-server/dist/tools/choice.js.map +1 -0
  298. package/mcp-server/dist/tools/computer.d.ts +71 -0
  299. package/mcp-server/dist/tools/computer.d.ts.map +1 -0
  300. package/mcp-server/dist/tools/computer.js +174 -0
  301. package/mcp-server/dist/tools/computer.js.map +1 -0
  302. package/mcp-server/dist/tools/discord-channel.d.ts +44 -0
  303. package/mcp-server/dist/tools/discord-channel.d.ts.map +1 -0
  304. package/mcp-server/dist/tools/discord-channel.js +236 -0
  305. package/mcp-server/dist/tools/discord-channel.js.map +1 -0
  306. package/mcp-server/dist/tools/discord-guild.d.ts +20 -0
  307. package/mcp-server/dist/tools/discord-guild.d.ts.map +1 -0
  308. package/mcp-server/dist/tools/discord-guild.js +138 -0
  309. package/mcp-server/dist/tools/discord-guild.js.map +1 -0
  310. package/mcp-server/dist/tools/discord-member.d.ts +26 -0
  311. package/mcp-server/dist/tools/discord-member.d.ts.map +1 -0
  312. package/mcp-server/dist/tools/discord-member.js +122 -0
  313. package/mcp-server/dist/tools/discord-member.js.map +1 -0
  314. package/mcp-server/dist/tools/discord-message.d.ts +36 -0
  315. package/mcp-server/dist/tools/discord-message.d.ts.map +1 -0
  316. package/mcp-server/dist/tools/discord-message.js +184 -0
  317. package/mcp-server/dist/tools/discord-message.js.map +1 -0
  318. package/mcp-server/dist/tools/discord-onboarding.d.ts +32 -0
  319. package/mcp-server/dist/tools/discord-onboarding.d.ts.map +1 -0
  320. package/mcp-server/dist/tools/discord-onboarding.js +179 -0
  321. package/mcp-server/dist/tools/discord-onboarding.js.map +1 -0
  322. package/mcp-server/dist/tools/discord-role.d.ts +34 -0
  323. package/mcp-server/dist/tools/discord-role.d.ts.map +1 -0
  324. package/mcp-server/dist/tools/discord-role.js +178 -0
  325. package/mcp-server/dist/tools/discord-role.js.map +1 -0
  326. package/mcp-server/dist/tools/discord-thread.d.ts +28 -0
  327. package/mcp-server/dist/tools/discord-thread.d.ts.map +1 -0
  328. package/mcp-server/dist/tools/discord-thread.js +88 -0
  329. package/mcp-server/dist/tools/discord-thread.js.map +1 -0
  330. package/mcp-server/dist/tools/discord-webhook.d.ts +32 -0
  331. package/mcp-server/dist/tools/discord-webhook.d.ts.map +1 -0
  332. package/mcp-server/dist/tools/discord-webhook.js +154 -0
  333. package/mcp-server/dist/tools/discord-webhook.js.map +1 -0
  334. package/mcp-server/dist/tools/discord.d.ts +7 -0
  335. package/mcp-server/dist/tools/discord.d.ts.map +1 -0
  336. package/mcp-server/dist/tools/discord.js +25 -0
  337. package/mcp-server/dist/tools/discord.js.map +1 -0
  338. package/mcp-server/dist/tools/fb-groups.d.ts +98 -0
  339. package/mcp-server/dist/tools/fb-groups.d.ts.map +1 -0
  340. package/mcp-server/dist/tools/fb-groups.js +369 -0
  341. package/mcp-server/dist/tools/fb-groups.js.map +1 -0
  342. package/mcp-server/dist/tools/forget.d.ts +14 -0
  343. package/mcp-server/dist/tools/forget.d.ts.map +1 -0
  344. package/mcp-server/dist/tools/forget.js +26 -0
  345. package/mcp-server/dist/tools/forget.js.map +1 -0
  346. package/mcp-server/dist/tools/git-tools.d.ts +24 -0
  347. package/mcp-server/dist/tools/git-tools.d.ts.map +1 -0
  348. package/mcp-server/dist/tools/git-tools.js +107 -0
  349. package/mcp-server/dist/tools/git-tools.js.map +1 -0
  350. package/mcp-server/dist/tools/git.d.ts +7 -0
  351. package/mcp-server/dist/tools/git.d.ts.map +1 -0
  352. package/mcp-server/dist/tools/git.js +11 -0
  353. package/mcp-server/dist/tools/git.js.map +1 -0
  354. package/mcp-server/dist/tools/gsc/experience.d.ts +135 -0
  355. package/mcp-server/dist/tools/gsc/experience.d.ts.map +1 -0
  356. package/mcp-server/dist/tools/gsc/experience.js +351 -0
  357. package/mcp-server/dist/tools/gsc/experience.js.map +1 -0
  358. package/mcp-server/dist/tools/gsc/helpers.d.ts +78 -0
  359. package/mcp-server/dist/tools/gsc/helpers.d.ts.map +1 -0
  360. package/mcp-server/dist/tools/gsc/helpers.js +258 -0
  361. package/mcp-server/dist/tools/gsc/helpers.js.map +1 -0
  362. package/mcp-server/dist/tools/gsc/indexing.d.ts +121 -0
  363. package/mcp-server/dist/tools/gsc/indexing.d.ts.map +1 -0
  364. package/mcp-server/dist/tools/gsc/indexing.js +357 -0
  365. package/mcp-server/dist/tools/gsc/indexing.js.map +1 -0
  366. package/mcp-server/dist/tools/gsc/inspect.d.ts +78 -0
  367. package/mcp-server/dist/tools/gsc/inspect.d.ts.map +1 -0
  368. package/mcp-server/dist/tools/gsc/inspect.js +321 -0
  369. package/mcp-server/dist/tools/gsc/inspect.js.map +1 -0
  370. package/mcp-server/dist/tools/gsc/navigate.d.ts +44 -0
  371. package/mcp-server/dist/tools/gsc/navigate.d.ts.map +1 -0
  372. package/mcp-server/dist/tools/gsc/navigate.js +197 -0
  373. package/mcp-server/dist/tools/gsc/navigate.js.map +1 -0
  374. package/mcp-server/dist/tools/gsc/performance.d.ts +84 -0
  375. package/mcp-server/dist/tools/gsc/performance.d.ts.map +1 -0
  376. package/mcp-server/dist/tools/gsc/performance.js +274 -0
  377. package/mcp-server/dist/tools/gsc/performance.js.map +1 -0
  378. package/mcp-server/dist/tools/gsc/settings.d.ts +153 -0
  379. package/mcp-server/dist/tools/gsc/settings.d.ts.map +1 -0
  380. package/mcp-server/dist/tools/gsc/settings.js +425 -0
  381. package/mcp-server/dist/tools/gsc/settings.js.map +1 -0
  382. package/mcp-server/dist/tools/gsc.d.ts +7 -0
  383. package/mcp-server/dist/tools/gsc.d.ts.map +1 -0
  384. package/mcp-server/dist/tools/gsc.js +51 -0
  385. package/mcp-server/dist/tools/gsc.js.map +1 -0
  386. package/mcp-server/dist/tools/image-tools.d.ts +24 -0
  387. package/mcp-server/dist/tools/image-tools.d.ts.map +1 -0
  388. package/mcp-server/dist/tools/image-tools.js +80 -0
  389. package/mcp-server/dist/tools/image-tools.js.map +1 -0
  390. package/mcp-server/dist/tools/image.d.ts +10 -0
  391. package/mcp-server/dist/tools/image.d.ts.map +1 -0
  392. package/mcp-server/dist/tools/image.js +14 -0
  393. package/mcp-server/dist/tools/image.js.map +1 -0
  394. package/mcp-server/dist/tools/leonardo-browser-tools.d.ts +97 -0
  395. package/mcp-server/dist/tools/leonardo-browser-tools.d.ts.map +1 -0
  396. package/mcp-server/dist/tools/leonardo-browser-tools.js +228 -0
  397. package/mcp-server/dist/tools/leonardo-browser-tools.js.map +1 -0
  398. package/mcp-server/dist/tools/leonardo.d.ts +13 -0
  399. package/mcp-server/dist/tools/leonardo.d.ts.map +1 -0
  400. package/mcp-server/dist/tools/leonardo.js +21 -0
  401. package/mcp-server/dist/tools/leonardo.js.map +1 -0
  402. package/mcp-server/dist/tools/loop.d.ts +30 -0
  403. package/mcp-server/dist/tools/loop.d.ts.map +1 -0
  404. package/mcp-server/dist/tools/loop.js +401 -0
  405. package/mcp-server/dist/tools/loop.js.map +1 -0
  406. package/mcp-server/dist/tools/memories.d.ts +56 -0
  407. package/mcp-server/dist/tools/memories.d.ts.map +1 -0
  408. package/mcp-server/dist/tools/memories.js +171 -0
  409. package/mcp-server/dist/tools/memories.js.map +1 -0
  410. package/mcp-server/dist/tools/morelogin.d.ts +24 -0
  411. package/mcp-server/dist/tools/morelogin.d.ts.map +1 -0
  412. package/mcp-server/dist/tools/morelogin.js +79 -0
  413. package/mcp-server/dist/tools/morelogin.js.map +1 -0
  414. package/mcp-server/dist/tools/profile.d.ts +17 -0
  415. package/mcp-server/dist/tools/profile.d.ts.map +1 -0
  416. package/mcp-server/dist/tools/profile.js +114 -0
  417. package/mcp-server/dist/tools/profile.js.map +1 -0
  418. package/mcp-server/dist/tools/recall.d.ts +71 -0
  419. package/mcp-server/dist/tools/recall.d.ts.map +1 -0
  420. package/mcp-server/dist/tools/recall.js +147 -0
  421. package/mcp-server/dist/tools/recall.js.map +1 -0
  422. package/mcp-server/dist/tools/reflect.d.ts +56 -0
  423. package/mcp-server/dist/tools/reflect.d.ts.map +1 -0
  424. package/mcp-server/dist/tools/reflect.js +157 -0
  425. package/mcp-server/dist/tools/reflect.js.map +1 -0
  426. package/mcp-server/dist/tools/remember.d.ts +62 -0
  427. package/mcp-server/dist/tools/remember.d.ts.map +1 -0
  428. package/mcp-server/dist/tools/remember.js +201 -0
  429. package/mcp-server/dist/tools/remember.js.map +1 -0
  430. package/mcp-server/dist/tools/response.d.ts +23 -0
  431. package/mcp-server/dist/tools/response.d.ts.map +1 -0
  432. package/mcp-server/dist/tools/response.js +24 -0
  433. package/mcp-server/dist/tools/response.js.map +1 -0
  434. package/mcp-server/dist/tools/restore.d.ts +14 -0
  435. package/mcp-server/dist/tools/restore.d.ts.map +1 -0
  436. package/mcp-server/dist/tools/restore.js +23 -0
  437. package/mcp-server/dist/tools/restore.js.map +1 -0
  438. package/mcp-server/dist/tools/style-guide-tools.d.ts +51 -0
  439. package/mcp-server/dist/tools/style-guide-tools.d.ts.map +1 -0
  440. package/mcp-server/dist/tools/style-guide-tools.js +231 -0
  441. package/mcp-server/dist/tools/style-guide-tools.js.map +1 -0
  442. package/mcp-server/dist/tools/style-guide.d.ts +8 -0
  443. package/mcp-server/dist/tools/style-guide.d.ts.map +1 -0
  444. package/mcp-server/dist/tools/style-guide.js +12 -0
  445. package/mcp-server/dist/tools/style-guide.js.map +1 -0
  446. package/mcp-server/dist/tools/sync.d.ts +20 -0
  447. package/mcp-server/dist/tools/sync.d.ts.map +1 -0
  448. package/mcp-server/dist/tools/sync.js +156 -0
  449. package/mcp-server/dist/tools/sync.js.map +1 -0
  450. package/mcp-server/dist/tools/tictactoe-tools.d.ts +18 -0
  451. package/mcp-server/dist/tools/tictactoe-tools.d.ts.map +1 -0
  452. package/mcp-server/dist/tools/tictactoe-tools.js +78 -0
  453. package/mcp-server/dist/tools/tictactoe-tools.js.map +1 -0
  454. package/mcp-server/dist/tools/tictactoe.d.ts +7 -0
  455. package/mcp-server/dist/tools/tictactoe.d.ts.map +1 -0
  456. package/mcp-server/dist/tools/tictactoe.js +11 -0
  457. package/mcp-server/dist/tools/tictactoe.js.map +1 -0
  458. package/mcp-server/dist/tools/utils.d.ts +19 -0
  459. package/mcp-server/dist/tools/utils.d.ts.map +1 -0
  460. package/mcp-server/dist/tools/utils.js +54 -0
  461. package/mcp-server/dist/tools/utils.js.map +1 -0
  462. package/mcp-server/dist/tools/whiteboard-format.d.ts +88 -0
  463. package/mcp-server/dist/tools/whiteboard-format.d.ts.map +1 -0
  464. package/mcp-server/dist/tools/whiteboard-format.js +337 -0
  465. package/mcp-server/dist/tools/whiteboard-format.js.map +1 -0
  466. package/mcp-server/dist/tools/whiteboard-tools.d.ts +326 -0
  467. package/mcp-server/dist/tools/whiteboard-tools.d.ts.map +1 -0
  468. package/mcp-server/dist/tools/whiteboard-tools.js +222 -0
  469. package/mcp-server/dist/tools/whiteboard-tools.js.map +1 -0
  470. package/mcp-server/dist/tools/whiteboard.d.ts +8 -0
  471. package/mcp-server/dist/tools/whiteboard.d.ts.map +1 -0
  472. package/mcp-server/dist/tools/whiteboard.js +16 -0
  473. package/mcp-server/dist/tools/whiteboard.js.map +1 -0
  474. package/mcp-server/dist/tools/youtube/analytics.d.ts +46 -0
  475. package/mcp-server/dist/tools/youtube/analytics.d.ts.map +1 -0
  476. package/mcp-server/dist/tools/youtube/analytics.js +83 -0
  477. package/mcp-server/dist/tools/youtube/analytics.js.map +1 -0
  478. package/mcp-server/dist/tools/youtube/dedup.d.ts +35 -0
  479. package/mcp-server/dist/tools/youtube/dedup.d.ts.map +1 -0
  480. package/mcp-server/dist/tools/youtube/dedup.js +56 -0
  481. package/mcp-server/dist/tools/youtube/dedup.js.map +1 -0
  482. package/mcp-server/dist/tools/youtube/download.d.ts +31 -0
  483. package/mcp-server/dist/tools/youtube/download.d.ts.map +1 -0
  484. package/mcp-server/dist/tools/youtube/download.js +30 -0
  485. package/mcp-server/dist/tools/youtube/download.js.map +1 -0
  486. package/mcp-server/dist/tools/youtube/helpers.d.ts +66 -0
  487. package/mcp-server/dist/tools/youtube/helpers.d.ts.map +1 -0
  488. package/mcp-server/dist/tools/youtube/helpers.js +143 -0
  489. package/mcp-server/dist/tools/youtube/helpers.js.map +1 -0
  490. package/mcp-server/dist/tools/youtube/media.d.ts +43 -0
  491. package/mcp-server/dist/tools/youtube/media.d.ts.map +1 -0
  492. package/mcp-server/dist/tools/youtube/media.js +97 -0
  493. package/mcp-server/dist/tools/youtube/media.js.map +1 -0
  494. package/mcp-server/dist/tools/youtube/metadata.d.ts +33 -0
  495. package/mcp-server/dist/tools/youtube/metadata.d.ts.map +1 -0
  496. package/mcp-server/dist/tools/youtube/metadata.js +106 -0
  497. package/mcp-server/dist/tools/youtube/metadata.js.map +1 -0
  498. package/mcp-server/dist/tools/youtube/navigate.d.ts +30 -0
  499. package/mcp-server/dist/tools/youtube/navigate.d.ts.map +1 -0
  500. package/mcp-server/dist/tools/youtube/navigate.js +71 -0
  501. package/mcp-server/dist/tools/youtube/navigate.js.map +1 -0
  502. package/mcp-server/dist/tools/youtube/playlist.d.ts +29 -0
  503. package/mcp-server/dist/tools/youtube/playlist.d.ts.map +1 -0
  504. package/mcp-server/dist/tools/youtube/playlist.js +128 -0
  505. package/mcp-server/dist/tools/youtube/playlist.js.map +1 -0
  506. package/mcp-server/dist/tools/youtube/schedule.d.ts +31 -0
  507. package/mcp-server/dist/tools/youtube/schedule.d.ts.map +1 -0
  508. package/mcp-server/dist/tools/youtube/schedule.js +147 -0
  509. package/mcp-server/dist/tools/youtube/schedule.js.map +1 -0
  510. package/mcp-server/dist/tools/youtube/source.d.ts +36 -0
  511. package/mcp-server/dist/tools/youtube/source.d.ts.map +1 -0
  512. package/mcp-server/dist/tools/youtube/source.js +85 -0
  513. package/mcp-server/dist/tools/youtube/source.js.map +1 -0
  514. package/mcp-server/dist/tools/youtube/upload.d.ts +53 -0
  515. package/mcp-server/dist/tools/youtube/upload.d.ts.map +1 -0
  516. package/mcp-server/dist/tools/youtube/upload.js +255 -0
  517. package/mcp-server/dist/tools/youtube/upload.js.map +1 -0
  518. package/mcp-server/dist/tools/youtube.d.ts +12 -0
  519. package/mcp-server/dist/tools/youtube.d.ts.map +1 -0
  520. package/mcp-server/dist/tools/youtube.js +38 -0
  521. package/mcp-server/dist/tools/youtube.js.map +1 -0
  522. package/mcp-server/dist/tui.d.ts +3 -0
  523. package/mcp-server/dist/tui.d.ts.map +1 -0
  524. package/mcp-server/dist/tui.js +806 -0
  525. package/mcp-server/dist/tui.js.map +1 -0
  526. package/mcp-server/dist/types.d.ts +77 -0
  527. package/mcp-server/dist/types.d.ts.map +1 -0
  528. package/mcp-server/dist/types.js +2 -0
  529. package/mcp-server/dist/types.js.map +1 -0
  530. package/mcp-server/package-lock.json +4798 -0
  531. package/mcp-server/package.json +55 -38
  532. package/mcp-server/run.mjs +12 -2
  533. package/mcp-server/src/config.ts +48 -3
  534. package/mcp-server/src/http.ts +296 -93
  535. package/mcp-server/src/index.ts +546 -203
  536. package/mcp-server/src/preload-http.ts +19 -18
  537. package/mcp-server/src/preload.ts +19 -18
  538. package/mcp-server/src/scripts/setup-embeddings.ts +9 -0
  539. package/mcp-server/src/services/assistant-judgments.ts +157 -0
  540. package/mcp-server/src/services/browser-assist-gate.ts +383 -0
  541. package/mcp-server/src/services/browser-assist.ts +267 -0
  542. package/mcp-server/src/services/browser-bench.ts +477 -0
  543. package/mcp-server/src/services/browser-judgments.ts +414 -0
  544. package/mcp-server/src/services/browser-risk.ts +484 -0
  545. package/mcp-server/src/services/categories.ts +36 -19
  546. package/mcp-server/src/services/codex-config-heal.ts +403 -0
  547. package/mcp-server/src/services/desktop-assist-gate.ts +251 -0
  548. package/mcp-server/src/services/desktop-assist.ts +308 -0
  549. package/mcp-server/src/services/desktop-bench.ts +372 -0
  550. package/mcp-server/src/services/desktop-client.ts +76 -0
  551. package/mcp-server/src/services/desktop-judgments.ts +218 -0
  552. package/mcp-server/src/services/desktop-risk.ts +743 -0
  553. package/mcp-server/src/services/edit-stale.ts +299 -0
  554. package/mcp-server/src/services/embedding-service.ts +108 -0
  555. package/mcp-server/src/services/embedding-worker.ts +46 -0
  556. package/mcp-server/src/services/fb-regions.ts +221 -0
  557. package/mcp-server/src/services/file-checksums.ts +26 -9
  558. package/mcp-server/src/services/file-excerpt.ts +59 -0
  559. package/mcp-server/src/services/identity.ts +291 -0
  560. package/mcp-server/src/services/local-embeddings.ts +2 -77
  561. package/mcp-server/src/services/memory-client.ts +23 -0
  562. package/mcp-server/src/services/memory-judge-backfill.ts +462 -0
  563. package/mcp-server/src/services/memory-judgments.ts +901 -0
  564. package/mcp-server/src/services/memory-maintenance.ts +360 -0
  565. package/mcp-server/src/services/memory-retrieval.ts +349 -0
  566. package/mcp-server/src/services/memory-schema.ts +280 -0
  567. package/mcp-server/src/services/memory-sessions.ts +87 -0
  568. package/mcp-server/src/services/memory-triage.ts +182 -0
  569. package/mcp-server/src/services/memory-verification.ts +68 -0
  570. package/mcp-server/src/services/memory-writer.ts +125 -0
  571. package/mcp-server/src/services/neural-interface.ts +1217 -73
  572. package/mcp-server/src/services/profiles.ts +428 -0
  573. package/mcp-server/src/services/secret-gate.ts +52 -0
  574. package/mcp-server/src/services/server-instructions.ts +122 -0
  575. package/mcp-server/src/services/sqlite.ts +1456 -823
  576. package/mcp-server/src/services/typesafe-bench.ts +247 -0
  577. package/mcp-server/src/services/typesafe-config.ts +790 -0
  578. package/mcp-server/src/services/typesafe.ts +390 -0
  579. package/mcp-server/src/services/vector-cache.ts +255 -0
  580. package/mcp-server/src/services/whiteboard-geometry.ts +711 -0
  581. package/mcp-server/src/tools/agents.ts +539 -0
  582. package/mcp-server/src/tools/bluesky.ts +1186 -0
  583. package/mcp-server/src/tools/browser-advanced.ts +15 -6
  584. package/mcp-server/src/tools/browser-batch.ts +178 -0
  585. package/mcp-server/src/tools/browser-cheatsheet.ts +155 -0
  586. package/mcp-server/src/tools/browser-interact.ts +362 -255
  587. package/mcp-server/src/tools/browser-navigate.ts +164 -77
  588. package/mcp-server/src/tools/browser-observe.ts +2222 -1330
  589. package/mcp-server/src/tools/browser.ts +181 -107
  590. package/mcp-server/src/tools/card.ts +7 -5
  591. package/mcp-server/src/tools/choice.ts +103 -0
  592. package/mcp-server/src/tools/computer.ts +190 -0
  593. package/mcp-server/src/tools/discord.ts +10 -23
  594. package/mcp-server/src/tools/fb-groups.ts +387 -0
  595. package/mcp-server/src/tools/forget.ts +1 -1
  596. package/mcp-server/src/tools/git.ts +3 -1
  597. package/mcp-server/src/tools/gsc/experience.ts +440 -0
  598. package/mcp-server/src/tools/gsc/helpers.ts +288 -0
  599. package/mcp-server/src/tools/gsc/indexing.ts +495 -0
  600. package/mcp-server/src/tools/gsc/inspect.ts +391 -0
  601. package/mcp-server/src/tools/gsc/navigate.ts +236 -0
  602. package/mcp-server/src/tools/gsc/performance.ts +352 -0
  603. package/mcp-server/src/tools/gsc/settings.ts +564 -0
  604. package/mcp-server/src/tools/gsc.ts +95 -0
  605. package/mcp-server/src/tools/image.ts +3 -1
  606. package/mcp-server/src/tools/leonardo-browser-tools.ts +47 -37
  607. package/mcp-server/src/tools/leonardo.ts +7 -5
  608. package/mcp-server/src/tools/loop.ts +118 -25
  609. package/mcp-server/src/tools/memories.ts +65 -5
  610. package/mcp-server/src/tools/morelogin.ts +81 -0
  611. package/mcp-server/src/tools/profile.ts +122 -0
  612. package/mcp-server/src/tools/recall.ts +105 -261
  613. package/mcp-server/src/tools/reflect.ts +171 -145
  614. package/mcp-server/src/tools/remember.ts +223 -115
  615. package/mcp-server/src/tools/style-guide-tools.ts +250 -0
  616. package/mcp-server/src/tools/style-guide.ts +16 -0
  617. package/mcp-server/src/tools/sync.ts +66 -9
  618. package/mcp-server/src/tools/tictactoe.ts +3 -1
  619. package/mcp-server/src/tools/utils.ts +22 -0
  620. package/mcp-server/src/tools/whiteboard-format.ts +394 -0
  621. package/mcp-server/src/tools/whiteboard-tools.ts +191 -183
  622. package/mcp-server/src/tools/whiteboard.ts +7 -5
  623. package/mcp-server/src/tools/youtube/analytics.ts +106 -0
  624. package/mcp-server/src/tools/youtube/dedup.ts +57 -0
  625. package/mcp-server/src/tools/youtube/download.ts +41 -0
  626. package/mcp-server/src/tools/youtube/helpers.ts +183 -0
  627. package/mcp-server/src/tools/youtube/media.ts +109 -0
  628. package/mcp-server/src/tools/youtube/metadata.ts +112 -0
  629. package/mcp-server/src/tools/youtube/navigate.ts +81 -0
  630. package/mcp-server/src/tools/youtube/playlist.ts +134 -0
  631. package/mcp-server/src/tools/youtube/schedule.ts +158 -0
  632. package/mcp-server/src/tools/youtube/source.ts +101 -0
  633. package/mcp-server/src/tools/youtube/upload.ts +296 -0
  634. package/mcp-server/src/tools/youtube.ts +47 -0
  635. package/mcp-server/src/tui.ts +8 -9
  636. package/neural-interface/games/TicTacToe/1.svg +1 -0
  637. package/neural-interface/games/TicTacToe/2.svg +1 -0
  638. package/neural-interface/games/TicTacToe/3.svg +1 -0
  639. package/neural-interface/games/TicTacToe/4.svg +1 -0
  640. package/neural-interface/games/TicTacToe/5.svg +1 -0
  641. package/neural-interface/games/TicTacToe/6.svg +1 -0
  642. package/neural-interface/games/TicTacToe/7.svg +1 -0
  643. package/neural-interface/games/TicTacToe/8.svg +1 -0
  644. package/neural-interface/games/TicTacToe/9.svg +1 -0
  645. package/neural-interface/games/TicTacToe/Board.svg +1 -0
  646. package/neural-interface/games/TicTacToe/Circle.svg +1 -0
  647. package/neural-interface/games/TicTacToe/Cross.svg +1 -0
  648. package/neural-interface/i18n/en.json +7022 -0
  649. package/neural-interface/i18n/pt-BR.json +7026 -0
  650. package/neural-interface/lib/assistant-api.js +1005 -0
  651. package/neural-interface/lib/assistant-attachments.js +121 -0
  652. package/neural-interface/lib/assistant-brains/claude.js +207 -0
  653. package/neural-interface/lib/assistant-brains/codex-browser-policy-hook.mjs +43 -0
  654. package/neural-interface/lib/assistant-brains/codex-route-gate-hook.mjs +56 -0
  655. package/neural-interface/lib/assistant-brains/codex.js +213 -0
  656. package/neural-interface/lib/assistant-brains/opencode-browser-policy.js +47 -0
  657. package/neural-interface/lib/assistant-brains/opencode-route-gate.js +62 -0
  658. package/neural-interface/lib/assistant-brains/opencode.js +320 -0
  659. package/neural-interface/lib/assistant-budget.js +318 -0
  660. package/neural-interface/lib/assistant-catalog.js +731 -0
  661. package/neural-interface/lib/assistant-clarify.js +664 -0
  662. package/neural-interface/lib/assistant-config.js +232 -0
  663. package/neural-interface/lib/assistant-dispatch.js +2744 -0
  664. package/neural-interface/lib/assistant-envelope.js +582 -0
  665. package/neural-interface/lib/assistant-evidence.js +236 -0
  666. package/neural-interface/lib/assistant-hidden-models.js +272 -0
  667. package/neural-interface/lib/assistant-jev.js +117 -0
  668. package/neural-interface/lib/assistant-judgments.js +111 -0
  669. package/neural-interface/lib/assistant-media.js +335 -0
  670. package/neural-interface/lib/assistant-memory.js +398 -0
  671. package/neural-interface/lib/assistant-persona.js +444 -0
  672. package/neural-interface/lib/assistant-plan-permissions.js +448 -0
  673. package/neural-interface/lib/assistant-playbooks/design.md +87 -0
  674. package/neural-interface/lib/assistant-playbooks.js +72 -0
  675. package/neural-interface/lib/assistant-pricing.js +159 -0
  676. package/neural-interface/lib/assistant-route-gate-codex.js +163 -0
  677. package/neural-interface/lib/assistant-route-gate.js +212 -0
  678. package/neural-interface/lib/assistant-router.js +1324 -0
  679. package/neural-interface/lib/assistant-runtime.js +3337 -0
  680. package/neural-interface/lib/assistant-task-prompt.js +472 -0
  681. package/neural-interface/lib/assistant-usage.js +1688 -0
  682. package/neural-interface/lib/augmented-path.js +70 -0
  683. package/neural-interface/lib/automation-storage-migration.js +54 -0
  684. package/neural-interface/lib/backup-service.js +851 -0
  685. package/neural-interface/lib/backup-zip-reader.js +262 -0
  686. package/neural-interface/lib/browser-assist-api.js +41 -0
  687. package/neural-interface/lib/browser-console.js +176 -0
  688. package/neural-interface/lib/browser-execution.js +241 -0
  689. package/neural-interface/lib/browser-extraction.js +191 -0
  690. package/neural-interface/lib/browser-language.js +83 -0
  691. package/neural-interface/lib/browser-screenshot.js +399 -0
  692. package/neural-interface/lib/browser-semantic-context.js +639 -0
  693. package/neural-interface/lib/browser-snapshots.js +192 -0
  694. package/neural-interface/lib/browser-tab-recovery.js +119 -0
  695. package/neural-interface/lib/browser-targets.js +70 -0
  696. package/neural-interface/lib/browser-tool-policy.js +926 -0
  697. package/neural-interface/lib/claude-accounts.js +230 -0
  698. package/neural-interface/lib/claude-agent-bridge.js +3203 -0
  699. package/neural-interface/lib/claude-bypass-policy.js +99 -0
  700. package/neural-interface/lib/claude-executable.js +241 -0
  701. package/neural-interface/lib/claude-history.js +429 -0
  702. package/neural-interface/lib/claude-model-catalog.js +444 -0
  703. package/neural-interface/lib/claude-panel-requests.js +165 -0
  704. package/neural-interface/lib/claude-panel-session.js +272 -0
  705. package/neural-interface/lib/claude-permission-rules.js +153 -0
  706. package/neural-interface/lib/claude-session-cleanup.js +71 -0
  707. package/neural-interface/lib/claude-session-list.js +82 -0
  708. package/neural-interface/lib/claude-session-ops.js +140 -0
  709. package/neural-interface/lib/claude-settings-view.js +51 -0
  710. package/neural-interface/lib/claude-skin-connect.js +79 -0
  711. package/neural-interface/lib/claude-stall-policy.js +7 -0
  712. package/neural-interface/lib/claude-temporary.js +435 -0
  713. package/neural-interface/lib/cli-update-planner.js +312 -0
  714. package/neural-interface/lib/codex-capabilities.d.ts +41 -0
  715. package/neural-interface/lib/codex-capabilities.js +148 -0
  716. package/neural-interface/lib/codex-collaboration-mode.js +17 -0
  717. package/neural-interface/lib/codex-compat-rpc.js +241 -0
  718. package/neural-interface/lib/codex-mcp-approvals.js +56 -0
  719. package/neural-interface/lib/codex-mcp-apps.js +47 -0
  720. package/neural-interface/lib/codex-model-context.js +202 -0
  721. package/neural-interface/lib/codex-plan-store.js +71 -0
  722. package/neural-interface/lib/codex-request-journal.js +242 -0
  723. package/neural-interface/lib/codex-runtime-path.js +213 -0
  724. package/neural-interface/lib/codex-session-catalog.js +231 -0
  725. package/neural-interface/lib/codex-session-settings.js +46 -0
  726. package/neural-interface/lib/codex-sidepanel-permissions.js +110 -0
  727. package/neural-interface/lib/codex-thread-history.js +165 -0
  728. package/neural-interface/lib/codex-user-verification.js +34 -0
  729. package/neural-interface/lib/codex-writer-lifecycle.js +152 -0
  730. package/neural-interface/lib/confined-fs.js +256 -0
  731. package/neural-interface/lib/db.js +922 -0
  732. package/neural-interface/lib/design-md.js +9 -0
  733. package/neural-interface/lib/desktop/api.js +92 -0
  734. package/neural-interface/lib/desktop/audit.js +62 -0
  735. package/neural-interface/lib/desktop/build.js +423 -0
  736. package/neural-interface/lib/desktop/config.js +606 -0
  737. package/neural-interface/lib/desktop/coords.js +76 -0
  738. package/neural-interface/lib/desktop/fake-helper.js +1192 -0
  739. package/neural-interface/lib/desktop/frames.js +77 -0
  740. package/neural-interface/lib/desktop/grants.js +84 -0
  741. package/neural-interface/lib/desktop/guards.js +170 -0
  742. package/neural-interface/lib/desktop/helper/SynabunDesktop.swift +3056 -0
  743. package/neural-interface/lib/desktop/lease.js +51 -0
  744. package/neural-interface/lib/desktop/manager.js +545 -0
  745. package/neural-interface/lib/desktop/press-contexts.js +103 -0
  746. package/neural-interface/lib/desktop/protocol.js +495 -0
  747. package/neural-interface/lib/desktop/service.js +1202 -0
  748. package/neural-interface/lib/effort-levels.js +148 -0
  749. package/neural-interface/lib/event-loop-monitor.js +67 -0
  750. package/neural-interface/lib/exec-loop-command.js +209 -0
  751. package/neural-interface/lib/folder-picker.ps1 +77 -0
  752. package/neural-interface/lib/http-guards.js +287 -0
  753. package/neural-interface/lib/jsonl-lines.js +94 -0
  754. package/neural-interface/lib/loop-logger.js +127 -0
  755. package/neural-interface/lib/mcp-client-config.js +133 -0
  756. package/neural-interface/lib/mcp-installer.js +487 -0
  757. package/neural-interface/lib/mcp-profile-refresh-coordinator.js +78 -0
  758. package/neural-interface/lib/mcp-profile-servers.js +120 -0
  759. package/neural-interface/lib/memo-cache.js +84 -0
  760. package/neural-interface/lib/memory-api.js +758 -0
  761. package/neural-interface/lib/memory-map-api.js +1150 -0
  762. package/neural-interface/lib/memory-map-layout.js +872 -0
  763. package/neural-interface/lib/memory-map-worker.js +165 -0
  764. package/neural-interface/lib/memory-session-capture.js +81 -0
  765. package/neural-interface/lib/memory-timers.js +62 -0
  766. package/neural-interface/lib/morelogin.js +301 -0
  767. package/neural-interface/lib/native-binary-runtime.js +382 -0
  768. package/neural-interface/lib/native-loop-goal.js +33 -0
  769. package/neural-interface/lib/native-loop-providers.js +1002 -0
  770. package/neural-interface/lib/native-loop-runtime.js +747 -0
  771. package/neural-interface/lib/offline-page.js +54 -0
  772. package/neural-interface/lib/opencode-hidden-models.js +113 -0
  773. package/neural-interface/lib/opencode-history.js +253 -0
  774. package/neural-interface/lib/opencode-runtime-config.js +58 -0
  775. package/neural-interface/lib/opencode-v2-client.js +486 -0
  776. package/neural-interface/lib/opencode-v2-ws-requests.js +955 -0
  777. package/neural-interface/lib/path-confine.js +213 -0
  778. package/neural-interface/lib/plugin-installer.js +264 -0
  779. package/neural-interface/lib/project-storage.js +766 -0
  780. package/neural-interface/lib/pty-host/core.js +603 -0
  781. package/neural-interface/lib/pty-host/flow.js +43 -0
  782. package/neural-interface/lib/pty-host/host.js +47 -0
  783. package/neural-interface/lib/pty-host/manager.js +360 -0
  784. package/neural-interface/lib/pty-host/protocol.js +81 -0
  785. package/neural-interface/lib/pty-host/snapshot.js +133 -0
  786. package/neural-interface/lib/remote-policy.js +582 -0
  787. package/neural-interface/lib/rulesets/api.js +175 -0
  788. package/neural-interface/lib/rulesets/fs-safe.js +245 -0
  789. package/neural-interface/lib/rulesets/installer.js +1453 -0
  790. package/neural-interface/lib/rulesets/json-edit.js +186 -0
  791. package/neural-interface/lib/rulesets/lock.js +170 -0
  792. package/neural-interface/lib/rulesets/managed-text.js +314 -0
  793. package/neural-interface/lib/rulesets/normalise.js +24 -0
  794. package/neural-interface/lib/rulesets/render.js +84 -0
  795. package/neural-interface/lib/schedule-launch-intent.js +40 -0
  796. package/neural-interface/lib/session-cache-builder.js +524 -0
  797. package/neural-interface/lib/session-chunker.js +344 -0
  798. package/neural-interface/lib/session-indexer.js +327 -0
  799. package/neural-interface/lib/session-title-generator.js +232 -0
  800. package/neural-interface/lib/skill-prompt-bridge.js +11 -0
  801. package/neural-interface/lib/style-guide/api.js +375 -0
  802. package/neural-interface/lib/style-guide/color.js +352 -0
  803. package/neural-interface/lib/style-guide/export-css.js +99 -0
  804. package/neural-interface/lib/style-guide/export-dtcg.js +209 -0
  805. package/neural-interface/lib/style-guide/export-tailwind.js +102 -0
  806. package/neural-interface/lib/style-guide/fonts.json +227 -0
  807. package/neural-interface/lib/style-guide/import.js +1268 -0
  808. package/neural-interface/lib/style-guide/pointers.js +103 -0
  809. package/neural-interface/lib/style-guide/presets.js +232 -0
  810. package/neural-interface/lib/style-guide/render-design-md.js +319 -0
  811. package/neural-interface/lib/style-guide/schema.js +848 -0
  812. package/neural-interface/lib/style-guide/security.js +43 -0
  813. package/neural-interface/lib/style-guide/store.js +491 -0
  814. package/neural-interface/lib/style-guide/summary.js +106 -0
  815. package/neural-interface/lib/style-guide/tokens.js +170 -0
  816. package/neural-interface/lib/synabun-update-plan.js +324 -0
  817. package/neural-interface/lib/synabun-version.js +179 -0
  818. package/neural-interface/lib/system-restore.js +99 -0
  819. package/neural-interface/lib/terminal-env.js +86 -0
  820. package/neural-interface/lib/ui-blob-store.js +106 -0
  821. package/neural-interface/lib/vector-cache.js +2 -0
  822. package/neural-interface/lib/virtual-socket.js +60 -0
  823. package/neural-interface/lib/whatsapp/api.js +146 -0
  824. package/neural-interface/lib/whatsapp/auth-store.js +389 -0
  825. package/neural-interface/lib/whatsapp/baileys-adapter.js +457 -0
  826. package/neural-interface/lib/whatsapp/brain.js +128 -0
  827. package/neural-interface/lib/whatsapp/bridge.js +1499 -0
  828. package/neural-interface/lib/whatsapp/cards.js +519 -0
  829. package/neural-interface/lib/whatsapp/commands.js +92 -0
  830. package/neural-interface/lib/whatsapp/config.js +291 -0
  831. package/neural-interface/lib/whatsapp/connector/entry.mjs +25 -0
  832. package/neural-interface/lib/whatsapp/connector/manifest.json +6 -0
  833. package/neural-interface/lib/whatsapp/connector/npm-shrinkwrap.json +717 -0
  834. package/neural-interface/lib/whatsapp/connector/package.json +8 -0
  835. package/neural-interface/lib/whatsapp/fake-baileys.js +444 -0
  836. package/neural-interface/lib/whatsapp/format.js +552 -0
  837. package/neural-interface/lib/whatsapp/host-core.js +1225 -0
  838. package/neural-interface/lib/whatsapp/host.js +119 -0
  839. package/neural-interface/lib/whatsapp/identity.js +288 -0
  840. package/neural-interface/lib/whatsapp/inbound.js +149 -0
  841. package/neural-interface/lib/whatsapp/installer.js +562 -0
  842. package/neural-interface/lib/whatsapp/manager.js +638 -0
  843. package/neural-interface/lib/whatsapp/paths.js +81 -0
  844. package/neural-interface/lib/whatsapp/protocol.js +327 -0
  845. package/neural-interface/lib/whatsapp/qr-svg.js +57 -0
  846. package/neural-interface/lib/whatsapp/redact.js +130 -0
  847. package/neural-interface/lib/whatsapp/service.js +1305 -0
  848. package/neural-interface/lib/whatsapp/vendor/qrcodegen.js +848 -0
  849. package/neural-interface/package-lock.json +4431 -0
  850. package/neural-interface/package.json +58 -27
  851. package/neural-interface/public/apple-touch-icon.png +0 -0
  852. package/neural-interface/public/category-logos/synabun.png +0 -0
  853. package/neural-interface/public/claude.svg +1 -0
  854. package/neural-interface/public/favicon-16x16.png +0 -0
  855. package/neural-interface/public/favicon-32x32.png +0 -0
  856. package/neural-interface/public/icon-192.png +0 -0
  857. package/neural-interface/public/icon-512.png +0 -0
  858. package/neural-interface/public/index.html +32 -182
  859. package/neural-interface/public/index2d.html +2 -2
  860. package/neural-interface/public/install-app.html +31 -0
  861. package/neural-interface/public/logo-synabun.png +0 -0
  862. package/neural-interface/public/logoHD.png +0 -0
  863. package/neural-interface/public/manifest.json +2 -0
  864. package/neural-interface/public/mcp.svg +4 -0
  865. package/neural-interface/public/offline.html +670 -299
  866. package/neural-interface/public/onboarding.html +804 -161
  867. package/neural-interface/public/opencode-logo-dark-square.svg +1 -0
  868. package/neural-interface/public/shared/agent-runtime-options.js +596 -0
  869. package/neural-interface/public/shared/api.js +339 -28
  870. package/neural-interface/public/shared/assistant/asst-accounts.js +318 -0
  871. package/neural-interface/public/shared/assistant/asst-agents-dock.js +296 -0
  872. package/neural-interface/public/shared/assistant/asst-api.js +270 -0
  873. package/neural-interface/public/shared/assistant/asst-brain-picker.js +744 -0
  874. package/neural-interface/public/shared/assistant/asst-budget.js +333 -0
  875. package/neural-interface/public/shared/assistant/asst-composer.js +656 -0
  876. package/neural-interface/public/shared/assistant/asst-computer.js +803 -0
  877. package/neural-interface/public/shared/assistant/asst-control.js +1018 -0
  878. package/neural-interface/public/shared/assistant/asst-hosts.js +262 -0
  879. package/neural-interface/public/shared/assistant/asst-mascot.js +313 -0
  880. package/neural-interface/public/shared/assistant/asst-menu.js +375 -0
  881. package/neural-interface/public/shared/assistant/asst-panel.js +1845 -0
  882. package/neural-interface/public/shared/assistant/asst-rack-styles.js +586 -0
  883. package/neural-interface/public/shared/assistant/asst-render.js +3305 -0
  884. package/neural-interface/public/shared/assistant/asst-route.js +1653 -0
  885. package/neural-interface/public/shared/assistant/asst-roving.js +75 -0
  886. package/neural-interface/public/shared/assistant/asst-sanitize.js +117 -0
  887. package/neural-interface/public/shared/assistant/asst-sidepanel-styles.js +488 -0
  888. package/neural-interface/public/shared/assistant/asst-sidepanel.js +675 -0
  889. package/neural-interface/public/shared/assistant/asst-slash.js +258 -0
  890. package/neural-interface/public/shared/assistant/asst-state.js +727 -0
  891. package/neural-interface/public/shared/assistant/asst-styles.js +1686 -0
  892. package/neural-interface/public/shared/assistant/asst-tool-kinds.js +1017 -0
  893. package/neural-interface/public/shared/assistant/asst-usage.js +444 -0
  894. package/neural-interface/public/shared/assistant/asst-ws.js +175 -0
  895. package/neural-interface/public/shared/automation-context.js +75 -0
  896. package/neural-interface/public/shared/blob-store.js +99 -0
  897. package/neural-interface/public/shared/cdx/cdx-capabilities.js +118 -0
  898. package/neural-interface/public/shared/cdx/cdx-cards.js +37 -0
  899. package/neural-interface/public/shared/cdx/cdx-commands.js +171 -0
  900. package/neural-interface/public/shared/cdx/cdx-context-menu.js +402 -0
  901. package/neural-interface/public/shared/cdx/cdx-context-model.js +220 -0
  902. package/neural-interface/public/shared/cdx/cdx-features.js +408 -0
  903. package/neural-interface/public/shared/cdx/cdx-icons.js +89 -0
  904. package/neural-interface/public/shared/cdx/cdx-mcp-app-host.js +414 -0
  905. package/neural-interface/public/shared/cdx/cdx-mcp-app-proxy.html +6 -0
  906. package/neural-interface/public/shared/cdx/cdx-mcp-app-proxy.js +56 -0
  907. package/neural-interface/public/shared/cdx/cdx-mcp-app.js +178 -0
  908. package/neural-interface/public/shared/cdx/cdx-model-catalog.js +120 -0
  909. package/neural-interface/public/shared/cdx/cdx-output-model.js +261 -0
  910. package/neural-interface/public/shared/cdx/cdx-output.js +382 -0
  911. package/neural-interface/public/shared/cdx/cdx-panel.js +1372 -0
  912. package/neural-interface/public/shared/cdx/cdx-plan.js +266 -0
  913. package/neural-interface/public/shared/cdx/cdx-protocol.js +682 -0
  914. package/neural-interface/public/shared/cdx/cdx-render.js +3381 -0
  915. package/neural-interface/public/shared/cdx/cdx-requests.js +1136 -0
  916. package/neural-interface/public/shared/cdx/cdx-styles.js +3251 -0
  917. package/neural-interface/public/shared/cdx/cdx-tabs.js +7992 -0
  918. package/neural-interface/public/shared/cdx/cdx-ws.js +363 -0
  919. package/neural-interface/public/shared/cli-status.js +169 -0
  920. package/neural-interface/public/shared/command-runner-model.js +30 -0
  921. package/neural-interface/public/shared/constants.js +22 -5
  922. package/neural-interface/public/shared/cp/cp-agents.js +186 -0
  923. package/neural-interface/public/shared/cp/cp-bash.js +252 -0
  924. package/neural-interface/public/shared/cp/cp-compose.js +159 -0
  925. package/neural-interface/public/shared/cp/cp-context-menu.js +309 -0
  926. package/neural-interface/public/shared/cp/cp-context-model.js +157 -0
  927. package/neural-interface/public/shared/cp/cp-context-window.js +80 -0
  928. package/neural-interface/public/shared/cp/cp-ctx.js +11 -0
  929. package/neural-interface/public/shared/cp/cp-diff.js +255 -0
  930. package/neural-interface/public/shared/cp/cp-event-rows.js +637 -0
  931. package/neural-interface/public/shared/cp/cp-events.js +846 -0
  932. package/neural-interface/public/shared/cp/cp-markdown.js +104 -0
  933. package/neural-interface/public/shared/cp/cp-permission-model.js +905 -0
  934. package/neural-interface/public/shared/cp/cp-permissions.js +626 -0
  935. package/neural-interface/public/shared/cp/cp-rehydrate.js +113 -0
  936. package/neural-interface/public/shared/cp/cp-restore.js +224 -0
  937. package/neural-interface/public/shared/cp/cp-session-model.js +338 -0
  938. package/neural-interface/public/shared/cp/cp-session-settings.js +254 -0
  939. package/neural-interface/public/shared/cp/cp-sessions.js +227 -0
  940. package/neural-interface/public/shared/cp/cp-statusline.js +153 -0
  941. package/neural-interface/public/shared/cp/cp-styles.js +349 -0
  942. package/neural-interface/public/shared/cp/cp-tasks-model.js +282 -0
  943. package/neural-interface/public/shared/cp/cp-tasks.js +107 -0
  944. package/neural-interface/public/shared/cp/cp-temporary-view.js +56 -0
  945. package/neural-interface/public/shared/cp/cp-temporary.js +231 -0
  946. package/neural-interface/public/shared/cp/cp-tool-cards.js +326 -0
  947. package/neural-interface/public/shared/cp/cp-tool-results.js +618 -0
  948. package/neural-interface/public/shared/cp/cp-usage-model.js +124 -0
  949. package/neural-interface/public/shared/graph-activity.js +68 -0
  950. package/neural-interface/public/shared/html-shell.js +116 -17
  951. package/neural-interface/public/shared/i18n.js +132 -34
  952. package/neural-interface/public/shared/node-positions-store.js +44 -0
  953. package/neural-interface/public/shared/ocp-hidden-models.js +116 -0
  954. package/neural-interface/public/shared/ocp-v2/ocp-v2-approvals.js +355 -0
  955. package/neural-interface/public/shared/ocp-v2/ocp-v2-autoaccept-button.js +35 -0
  956. package/neural-interface/public/shared/ocp-v2/ocp-v2-automation-ownership.js +72 -0
  957. package/neural-interface/public/shared/ocp-v2/ocp-v2-binding.js +280 -0
  958. package/neural-interface/public/shared/ocp-v2/ocp-v2-caps.js +67 -0
  959. package/neural-interface/public/shared/ocp-v2/ocp-v2-changes-logic.js +179 -0
  960. package/neural-interface/public/shared/ocp-v2/ocp-v2-changes.js +164 -0
  961. package/neural-interface/public/shared/ocp-v2/ocp-v2-childpanel.js +725 -0
  962. package/neural-interface/public/shared/ocp-v2/ocp-v2-compact-button.js +75 -0
  963. package/neural-interface/public/shared/ocp-v2/ocp-v2-composer-logic.js +1204 -0
  964. package/neural-interface/public/shared/ocp-v2/ocp-v2-confirm-logic.js +117 -0
  965. package/neural-interface/public/shared/ocp-v2/ocp-v2-confirm.js +59 -0
  966. package/neural-interface/public/shared/ocp-v2/ocp-v2-context-gauge.js +252 -0
  967. package/neural-interface/public/shared/ocp-v2/ocp-v2-context-menu.js +478 -0
  968. package/neural-interface/public/shared/ocp-v2/ocp-v2-context-model.js +179 -0
  969. package/neural-interface/public/shared/ocp-v2/ocp-v2-events.js +233 -0
  970. package/neural-interface/public/shared/ocp-v2/ocp-v2-icons.js +30 -0
  971. package/neural-interface/public/shared/ocp-v2/ocp-v2-manager.js +1065 -0
  972. package/neural-interface/public/shared/ocp-v2/ocp-v2-mention-hints.js +182 -0
  973. package/neural-interface/public/shared/ocp-v2/ocp-v2-modelpicker.js +655 -0
  974. package/neural-interface/public/shared/ocp-v2/ocp-v2-panel.js +2636 -0
  975. package/neural-interface/public/shared/ocp-v2/ocp-v2-plan.js +601 -0
  976. package/neural-interface/public/shared/ocp-v2/ocp-v2-profile-inheritance.js +78 -0
  977. package/neural-interface/public/shared/ocp-v2/ocp-v2-projectbar.js +649 -0
  978. package/neural-interface/public/shared/ocp-v2/ocp-v2-providers.js +35 -0
  979. package/neural-interface/public/shared/ocp-v2/ocp-v2-rehydrate.js +357 -0
  980. package/neural-interface/public/shared/ocp-v2/ocp-v2-render-logic.js +189 -0
  981. package/neural-interface/public/shared/ocp-v2/ocp-v2-render.js +1869 -0
  982. package/neural-interface/public/shared/ocp-v2/ocp-v2-select.js +42 -0
  983. package/neural-interface/public/shared/ocp-v2/ocp-v2-send-logic.js +117 -0
  984. package/neural-interface/public/shared/ocp-v2/ocp-v2-send.js +1714 -0
  985. package/neural-interface/public/shared/ocp-v2/ocp-v2-session-actions.js +468 -0
  986. package/neural-interface/public/shared/ocp-v2/ocp-v2-session-widgets.js +63 -0
  987. package/neural-interface/public/shared/ocp-v2/ocp-v2-sessions-logic.js +366 -0
  988. package/neural-interface/public/shared/ocp-v2/ocp-v2-settings-link.js +19 -0
  989. package/neural-interface/public/shared/ocp-v2/ocp-v2-slash-hints.js +343 -0
  990. package/neural-interface/public/shared/ocp-v2/ocp-v2-state.js +746 -0
  991. package/neural-interface/public/shared/ocp-v2/ocp-v2-status-logic.js +347 -0
  992. package/neural-interface/public/shared/ocp-v2/ocp-v2-status.js +338 -0
  993. package/neural-interface/public/shared/ocp-v2/ocp-v2-styles.js +3543 -0
  994. package/neural-interface/public/shared/ocp-v2/ocp-v2-subagents-logic.js +482 -0
  995. package/neural-interface/public/shared/ocp-v2/ocp-v2-tools-logic.js +351 -0
  996. package/neural-interface/public/shared/ocp-v2/ocp-v2-tools.js +168 -0
  997. package/neural-interface/public/shared/ocp-v2/ocp-v2-variantpicker.js +315 -0
  998. package/neural-interface/public/shared/ocp-v2/ocp-v2-ws.js +545 -0
  999. package/neural-interface/public/shared/provider-icons.js +30 -0
  1000. package/neural-interface/public/shared/pwa-install-page.js +34 -0
  1001. package/neural-interface/public/shared/pwa-install.css +12 -0
  1002. package/neural-interface/public/shared/pwa-install.js +28 -0
  1003. package/neural-interface/public/shared/session-title.js +100 -0
  1004. package/neural-interface/public/shared/settings/settings-ia.js +389 -0
  1005. package/neural-interface/public/shared/settings/settings-kit.js +234 -0
  1006. package/neural-interface/public/shared/settings/settings-rules.js +160 -0
  1007. package/neural-interface/public/shared/settings/settings-search.js +463 -0
  1008. package/neural-interface/public/shared/settings/settings-shell.js +570 -0
  1009. package/neural-interface/public/shared/start-bridge.js +583 -0
  1010. package/neural-interface/public/shared/state.js +5 -0
  1011. package/neural-interface/public/shared/storage.js +50 -7
  1012. package/neural-interface/public/shared/styleguide/sg-agents.js +42 -0
  1013. package/neural-interface/public/shared/styleguide/sg-brand.js +4 -0
  1014. package/neural-interface/public/shared/styleguide/sg-color-client.js +388 -0
  1015. package/neural-interface/public/shared/styleguide/sg-colors.js +151 -0
  1016. package/neural-interface/public/shared/styleguide/sg-components.js +35 -0
  1017. package/neural-interface/public/shared/styleguide/sg-history.js +41 -0
  1018. package/neural-interface/public/shared/styleguide/sg-icons-logo.js +38 -0
  1019. package/neural-interface/public/shared/styleguide/sg-imagery.js +4 -0
  1020. package/neural-interface/public/shared/styleguide/sg-import-export.js +48 -0
  1021. package/neural-interface/public/shared/styleguide/sg-kit.js +284 -0
  1022. package/neural-interface/public/shared/styleguide/sg-labels.js +318 -0
  1023. package/neural-interface/public/shared/styleguide/sg-layout.js +4 -0
  1024. package/neural-interface/public/shared/styleguide/sg-motion.js +5 -0
  1025. package/neural-interface/public/shared/styleguide/sg-panel.js +237 -0
  1026. package/neural-interface/public/shared/styleguide/sg-preview.js +138 -0
  1027. package/neural-interface/public/shared/styleguide/sg-rules.js +9 -0
  1028. package/neural-interface/public/shared/styleguide/sg-shape.js +24 -0
  1029. package/neural-interface/public/shared/styleguide/sg-state.js +132 -0
  1030. package/neural-interface/public/shared/styleguide/sg-typography.js +125 -0
  1031. package/neural-interface/public/shared/styles.css +8614 -1477
  1032. package/neural-interface/public/shared/synabun-mascot.js +1625 -0
  1033. package/neural-interface/public/shared/synabun-ticker.js +376 -0
  1034. package/neural-interface/public/shared/term-ready.js +307 -0
  1035. package/neural-interface/public/shared/term-renderer.js +343 -0
  1036. package/neural-interface/public/shared/ui-assistant-panel.js +10 -0
  1037. package/neural-interface/public/shared/ui-assistant.js +77 -0
  1038. package/neural-interface/public/shared/ui-automation-studio.js +5105 -3573
  1039. package/neural-interface/public/shared/ui-claude-panel.js +11685 -6333
  1040. package/neural-interface/public/shared/ui-codex-panel.js +12 -0
  1041. package/neural-interface/public/shared/ui-command-runner.js +251 -468
  1042. package/neural-interface/public/shared/ui-cost-widget.js +55 -34
  1043. package/neural-interface/public/shared/ui-detail.js +77 -14
  1044. package/neural-interface/public/shared/ui-explorer.js +360 -181
  1045. package/neural-interface/public/shared/ui-file-explorer.js +485 -107
  1046. package/neural-interface/public/shared/ui-help.js +2 -2
  1047. package/neural-interface/public/shared/ui-image-gallery.js +45 -21
  1048. package/neural-interface/public/shared/ui-judgments.js +1346 -0
  1049. package/neural-interface/public/shared/ui-keybinds.js +3 -2
  1050. package/neural-interface/public/shared/ui-link.js +105 -51
  1051. package/neural-interface/public/shared/ui-loading.js +222 -11
  1052. package/neural-interface/public/shared/ui-memory-maintenance.js +111 -0
  1053. package/neural-interface/public/shared/ui-menubar.js +100 -31
  1054. package/neural-interface/public/shared/ui-native-loop-router-state.js +94 -0
  1055. package/neural-interface/public/shared/ui-native-loop-router.js +493 -0
  1056. package/neural-interface/public/shared/ui-native-window-id.js +7 -0
  1057. package/neural-interface/public/shared/ui-navbar.js +357 -222
  1058. package/neural-interface/public/shared/ui-notifications.js +58 -12
  1059. package/neural-interface/public/shared/ui-opencode-panel-v2.js +17 -0
  1060. package/neural-interface/public/shared/ui-panels.js +15 -7
  1061. package/neural-interface/public/shared/ui-resume.js +823 -653
  1062. package/neural-interface/public/shared/ui-schedule-queue.js +172 -0
  1063. package/neural-interface/public/shared/ui-schedules-studio.js +1882 -0
  1064. package/neural-interface/public/shared/ui-scroll-follow.js +194 -0
  1065. package/neural-interface/public/shared/ui-sessions.js +789 -34
  1066. package/neural-interface/public/shared/ui-settings.js +5774 -2194
  1067. package/neural-interface/public/shared/ui-sidepanel-layout.js +109 -0
  1068. package/neural-interface/public/shared/ui-sidepanel-runtime.js +19 -0
  1069. package/neural-interface/public/shared/ui-sidepanel-session-entry.js +40 -0
  1070. package/neural-interface/public/shared/ui-sidepanel-sessions.js +221 -0
  1071. package/neural-interface/public/shared/ui-sidepanel-state.js +131 -0
  1072. package/neural-interface/public/shared/ui-sidepanel-tray.js +547 -0
  1073. package/neural-interface/public/shared/ui-sidepanel-windows.js +387 -0
  1074. package/neural-interface/public/shared/ui-skills.js +18 -32
  1075. package/neural-interface/public/shared/ui-styleguide.js +35 -0
  1076. package/neural-interface/public/shared/ui-sync.js +87 -0
  1077. package/neural-interface/public/shared/ui-terminal.js +7863 -6385
  1078. package/neural-interface/public/shared/ui-tool-updates.js +292 -0
  1079. package/neural-interface/public/shared/ui-tooltip.js +156 -15
  1080. package/neural-interface/public/shared/ui-tutorial-draw.js +119 -683
  1081. package/neural-interface/public/shared/ui-tutorial-layout.js +135 -0
  1082. package/neural-interface/public/shared/ui-tutorial-steps.js +59 -2209
  1083. package/neural-interface/public/shared/ui-tutorial.css +102 -0
  1084. package/neural-interface/public/shared/ui-tutorial.js +557 -1076
  1085. package/neural-interface/public/shared/ui-update.js +420 -68
  1086. package/neural-interface/public/shared/ui-whatsapp.js +941 -0
  1087. package/neural-interface/public/shared/ui-whiteboard.js +1076 -233
  1088. package/neural-interface/public/shared/ui-workspaces.js +2 -1
  1089. package/neural-interface/public/shared/utils.js +58 -0
  1090. package/neural-interface/public/shared/vterm-buffer.js +15 -5
  1091. package/neural-interface/public/shared/whatsapp/wa-phone.js +65 -0
  1092. package/neural-interface/public/shared/whatsapp/wa-view.js +411 -0
  1093. package/neural-interface/public/sidepanel-session.html +18 -0
  1094. package/neural-interface/public/sw.js +10 -2
  1095. package/neural-interface/public/synabun.png +0 -0
  1096. package/neural-interface/public/variant/2d/graph.js +1707 -1635
  1097. package/neural-interface/public/variant/2d/lasso.js +2 -1
  1098. package/neural-interface/public/variant/2d/layout.js +595 -594
  1099. package/neural-interface/public/variant/2d/main.js +557 -521
  1100. package/neural-interface/public/variant/2d/settings-gfx.js +18 -24
  1101. package/neural-interface/public/variant/3d/main.js +517 -500
  1102. package/neural-interface/public/variant/3d/map-controls.js +144 -0
  1103. package/neural-interface/public/variant/3d/map-data.js +251 -0
  1104. package/neural-interface/public/variant/3d/map-labels.js +241 -0
  1105. package/neural-interface/public/variant/3d/map-pick.js +90 -0
  1106. package/neural-interface/public/variant/3d/map-renderer.js +521 -0
  1107. package/neural-interface/public/variant/3d/map-shaders.js +158 -0
  1108. package/neural-interface/public/variant/3d/map-view.js +618 -0
  1109. package/neural-interface/public/vendor/three/manifest.json +3 -0
  1110. package/neural-interface/public/vendor/three/three-0.169.0.min.js +3844 -0
  1111. package/neural-interface/public/vendor/xterm/manifest.json +3 -0
  1112. package/neural-interface/public/vendor/xterm/xterm-6.0.0.css +285 -0
  1113. package/neural-interface/public/vendor/xterm/xterm-6.0.0.min.js +192 -0
  1114. package/neural-interface/scripts/build-vendor.mjs +64 -0
  1115. package/neural-interface/scripts/codex-protocol-fixture.mjs +83 -0
  1116. package/neural-interface/scripts/rebuild-pty.js +39 -22
  1117. package/neural-interface/scripts/vendor-three-entry.js +24 -0
  1118. package/neural-interface/scripts/vendor-xterm-entry.js +8 -0
  1119. package/neural-interface/server.js +33983 -15000
  1120. package/neural-interface/skins/default/skin.css +90 -0
  1121. package/neural-interface/skins/default/skin.json +8 -0
  1122. package/neural-interface/templates/rulesets/README.md +9 -0
  1123. package/neural-interface/templates/rulesets/claude.md +3 -0
  1124. package/neural-interface/templates/rulesets/codex.md +2 -0
  1125. package/neural-interface/templates/rulesets/coexistence.md +6 -0
  1126. package/neural-interface/templates/rulesets/core.md +26 -0
  1127. package/neural-interface/templates/rulesets/cursor.md +2 -0
  1128. package/neural-interface/templates/rulesets/gemini.md +2 -0
  1129. package/neural-interface/templates/rulesets/legacy-hashes.json +227 -0
  1130. package/neural-interface/templates/rulesets/manifest.json +12 -0
  1131. package/neural-interface/templates/rulesets/opencode.md +2 -0
  1132. package/package.json +131 -53
  1133. package/postinstall.js +55 -26
  1134. package/preuninstall.js +187 -0
  1135. package/scripts/restore-data-home-from-backup.mjs +239 -0
  1136. package/setup.js +872 -260
  1137. package/skills/leonardo/SKILL.md +19 -10
  1138. package/skills/leonardo/modules/image-prompter.md +18 -12
  1139. package/skills/leonardo/modules/video-prompter.md +19 -13
  1140. package/skills/synabun/SKILL.md +209 -185
  1141. package/skills/synabun/icon.png +0 -0
  1142. package/skills/synabun/modules/audit.md +380 -374
  1143. package/skills/synabun/modules/changelog.md +15 -6
  1144. package/skills/synabun/modules/idea.md +148 -144
  1145. package/skills/synabun/modules/memorize.md +13 -4
  1146. package/skills/synabun/modules/schedule.md +198 -0
  1147. package/skills/synabun/synabunicon.png +0 -0
  1148. package/updater.cmd +31 -0
  1149. package/updater.mjs +382 -0
  1150. package/updater.sh +29 -0
  1151. package/hooks/claude-code/test-user-learning.mjs +0 -531
  1152. package/neural-interface/public/claude-chat.css +0 -878
  1153. package/neural-interface/public/claude-chat.html +0 -92
  1154. package/neural-interface/public/claude-chat.js +0 -1168
  1155. package/neural-interface/public/index-legacy.html +0 -14677
  1156. package/neural-interface/public/index2d-legacy.html +0 -8193
  1157. package/neural-interface/public/shared/html-term-renderer.js +0 -1022
  1158. package/neural-interface/public/variant/3d/background.js +0 -884
  1159. package/neural-interface/public/variant/3d/camera.js +0 -661
  1160. package/neural-interface/public/variant/3d/gfx.js +0 -116
  1161. package/neural-interface/public/variant/3d/graph.js +0 -2399
  1162. package/neural-interface/public/variant/3d/settings-gfx.js +0 -228
  1163. package/neural-interface/templates/CLAUDE-template.md +0 -211
@@ -0,0 +1,3203 @@
1
+ import { claudeStallSeconds } from './claude-stall-policy.js';
2
+ import { detectProject as memoryProject } from '../../mcp-server/dist/config.js';
3
+ // ── Claude Agent SDK bridge for the Claude sidepanel (claude-skin) ──
4
+ //
5
+ // Replaces the per-turn `claude --print` spawn architecture with one long-lived
6
+ // interactive SDK session per tab (streaming-input mode). What this buys:
7
+ // - Real pre-execution permission prompts via canUseTool (no kill/respawn)
8
+ // - AskUserQuestion answered in-place via updatedInput (process stays alive)
9
+ // - ExitPlanMode → genuine plan approval; same turn continues on approve
10
+ // - Live token streaming (includePartialMessages → stream_event deltas)
11
+ // - Subagent visibility (parent_tool_use_id on every message)
12
+ // - Native interrupt, permission modes, rewindFiles, supportedCommands
13
+ //
14
+ // Wire contract: the frontend receives `{type:'event', event:{…stream-json…}}`,
15
+ // `control_request`, `done`, `aborted`, `reattach_result`, `engine`,
16
+ // `mode_changed`, `rewind_result`, `control_cancelled`, `turn_started` (the CLI
17
+ // began a turn on its own — a background agent finished, a scheduled wakeup
18
+ // fired). Synthetic subagent events are `subagent` start / background / stop.
19
+ // Inbound: `query`, `control_response`, `compact`, `abort`, `set_permission_mode`,
20
+ // `rewind`, `mcp_status`, `dispose` (the tab was closed — end the session now).
21
+ //
22
+ // Ownership model: the session object owns the Query, the input queue, the
23
+ // event buffer, pending permission resolvers, cost baseline and watchdog. The
24
+ // WebSocket merely binds/unbinds to it — an "orphan" is just a session with no
25
+ // WS bound, so page-refresh reattach is the same object.
26
+
27
+ import { query, startup } from '@anthropic-ai/claude-agent-sdk';
28
+ import { CLAUDE_EFFORT_LEVELS } from './claude-model-catalog.js';
29
+ import { CLAUDE_NOT_INSTALLED, resolveClaudeSdkExecutable } from './claude-executable.js';
30
+ import { diagnoseLaunchFailure, isNativeBinaryLaunchFailure } from './native-binary-runtime.js';
31
+ import { normalizePanelSession, startSignature, applyPanelSessionOptions, liveSettingsPatch, normalizeMcpServers, mergeTabMcpServers } from './claude-panel-session.js';
32
+ import { SESSION_REQUESTS, runSessionRequest, slimRewind, slimMcpStatus } from './claude-panel-requests.js';
33
+ import { applyTemporaryOptions, plansDirsOf, planFileOfToolCall, removeTemporaryPlanFiles } from './claude-temporary.js';
34
+ import { rootBypassBlock } from './claude-bypass-policy.js';
35
+ import { randomUUID } from 'node:crypto';
36
+ import { dirname, join } from 'node:path';
37
+ import { existsSync, readFileSync, mkdirSync } from 'node:fs';
38
+ import { createRequire } from 'node:module';
39
+
40
+ const _require = createRequire(import.meta.url);
41
+ export const SDK_VERSION = (() => {
42
+ try { return _require('@anthropic-ai/claude-agent-sdk/package.json').version; }
43
+ catch { /* package.json not in exports map — walk up from the entry file */ }
44
+ try {
45
+ let dir = dirname(_require.resolve('@anthropic-ai/claude-agent-sdk'));
46
+ for (let i = 0; i < 5; i++) {
47
+ const pj = `${dir}/package.json`;
48
+ if (existsSync(pj)) {
49
+ const v = JSON.parse(readFileSync(pj, 'utf-8'));
50
+ if (v.name === '@anthropic-ai/claude-agent-sdk') return v.version;
51
+ }
52
+ dir = dirname(dir);
53
+ }
54
+ } catch {}
55
+ return 'unknown';
56
+ })();
57
+
58
+ // Bookkeeping tools that must never hit the user with a permission card.
59
+ // Read-only tools (Read/Glob/Grep/…) are allowed by the SDK's own default-mode
60
+ // evaluation before canUseTool is consulted, so they need no entry here.
61
+ const AUTO_ALLOW = new Set(['TodoWrite', 'TodoRead', 'Task', 'Agent', 'ToolSearch', 'AskUserQuestion', 'ExitPlanMode']);
62
+ // (AskUserQuestion/ExitPlanMode are listed because they take dedicated paths below,
63
+ // never the generic prompt path.)
64
+
65
+ const ORPHAN_GRACE_MS = 30 * 60 * 1000; // survive sleep / refresh / tab throttling
66
+ const IDLE_REAP_MS = 15 * 60 * 1000; // end idle Queries to cap resident CLI processes
67
+ const ORPHAN_BUFFER_CAP = 2000; // entries; stream_event never buffered while detached
68
+ const DELTA_FLUSH_MS = 40; // coalescing window for text/thinking deltas
69
+ const WS_BACKPRESSURE_BYTES = 1024 * 1024;
70
+
71
+ // Stall-recovery herd control: when stalls are correlated (an MCP server or
72
+ // the API went down), every session's watchdog fires in the same window and
73
+ // each retry spawns a fresh CLI subprocess. Cap concurrent recreates; user-
74
+ // initiated prompts are never limited.
75
+ const MAX_CONCURRENT_RECREATES = 2;
76
+ let _recreatesInFlight = 0;
77
+ const _recreateWaiters = [];
78
+ function _acquireRecreateSlot() {
79
+ if (_recreatesInFlight < MAX_CONCURRENT_RECREATES) { _recreatesInFlight++; return Promise.resolve(); }
80
+ return new Promise(r => _recreateWaiters.push(r));
81
+ }
82
+ function _releaseRecreateSlot() {
83
+ const next = _recreateWaiters.shift();
84
+ if (next) next();
85
+ else _recreatesInFlight = Math.max(0, _recreatesInFlight - 1);
86
+ }
87
+ const STALL_WARN_SEC = 30;
88
+ const BOOT_TIMEOUT_SEC = 120;
89
+ const MAX_RETRIES = 2;
90
+
91
+ // What the sidepanel may rely on from this bridge, sent in the engine hello. The
92
+ // panel is served statically and reloads at any time, while this module only
93
+ // changes when the server restarts: a panel feature that needs bridge support
94
+ // shows only when its name is listed here.
95
+ export const PANEL_CAPABILITIES = Object.freeze([
96
+ 'capabilities',
97
+ 'session_info', // system/session_info after init: account, output styles, models, agents
98
+ 'reload_skills', // inbound `reload_skills` → `reload_result` + a fresh commands_list
99
+ 'permission_context', // control_request carries the CLI's title, reason, path, MCP server, flags
100
+ 'permission_modes_v2', // dontAsk and auto are accepted; `modeFromSettings` leaves the mode to the user's settings
101
+ 'deny_interrupt', // a deny response may carry `interrupt: true` (deny and stop the turn)
102
+ 'ask_annotations', // AskUserQuestion answers keep their `annotations`
103
+ 'tool_policy', // query `toolPolicy`: 'full' | 'read-only' | 'no-web'
104
+ 'elicitation', // MCP elicitation as control_request subtype `elicitation` (for clients that declare the feature)
105
+ 'session_settings', // query `session`: fallback model, limits, directories, plugins, strict MCP, instructions, sandbox, thinking, fast mode, output style, agent
106
+ 'reload_plugins', // inbound `reload_plugins` → `reload_result` + fresh commands_list / mcp_status / plugins_list
107
+ 'message_flags', // session.hookEvents / promptSuggestions / subagentText / agentSummaries → the matching SDK options
108
+ 'task_control', // inbound `stop_task` / `background_tasks` → `task_control_result`; system/session_crons events
109
+ 'origin_human', // a query marked `typed: true` is sent with origin {kind:'human'}
110
+ 'session_requests', // inbound `session_request {id, what, args}` → `session_response`: context_usage, usage, mcp_status, mcp_reconnect, mcp_toggle, rewind_preview
111
+ 'rewind_stats', // `rewind_result` carries filesChanged / insertions / deletions
112
+ 'session_title', // query `title` names a new session where the CLI reads it
113
+ 'conversation_rewind', // inbound `rewind_conversation {messageUuid, userMessageUuid?}` → `rewind_conversation_result`
114
+ 'cache_cost', // system/cache_cost when resuming re-caches an expired context, and (source 'model_switch') after a model change
115
+ 'warm_start', // inbound `warm` (a query without a prompt): start the CLI process before the first message
116
+ 'permission_rules', // session_request `permission_rules` / `forget_session_rules`; reattach_result carries `grantedRules`
117
+ 'mcp_dynamic', // session_request `mcp_set_servers {servers}` (a tab's remote servers, live) and `mcp_permission_mode {serverName, mode}`
118
+ 'session_state', // the CLI's session_state_changed events reach the panel (running / idle / requires_action)
119
+ 'session_settings_v2', // query `session` also reads: allowedTools, disallowedTools, tools, planModeInstructions, mcpServers, synabunAlwaysLoad, agents, skills, overlay, debug
120
+ 'bypass_mode', // Bypass is the SDK's bypassPermissions: a tab's process can take it (query `permissionMode`, `set_permission_mode`, the plan card's `planDecision`); `mode_changed` carries `reason` when it was refused or undone
121
+ 'mode_statements', // the page numbers its statements of the tab's mode (`modeSeq`) and every mode report (init, status, mode_changed, reattach_result) returns the number applied; `set_permission_mode {fromSettings}` and a plan answer's `planExit` go back to the mode the user's settings name
122
+ 'temporary_chat', // query / warm / `config` `temporary: true`: a conversation that leaves nothing behind (lib/claude-temporary.js); `temporary_ended` when its process is gone
123
+ ]);
124
+
125
+ const WARM_TTL_MS = 3 * 60 * 1000; // an unused warm process is closed after this
126
+
127
+ const ACCOUNT_UNAVAILABLE = 'The Claude account this tab uses is no longer set up in SynaBun. Pick another one with /account, or add it again in Settings.';
128
+ // A panel session nothing configured yet was asked to start (see ensureQuery).
129
+ const CONFIG_REQUIRED = 'This tab\'s session was not started: its account and restrictions did not come with the request. Reload the page, or send a message first.';
130
+ // Panel sessions, a temporary chat (lib/claude-temporary.js): what it is told when it cannot go on, or cannot do something.
131
+ const TEMPORARY_ENDED = 'This temporary chat is over: its session is gone and nothing of it was kept. Start a new chat to go on.';
132
+ const TEMPORARY_REFUSED = 'A conversation that has started cannot be made temporary. Start a new chat and choose Temporary before its first message.';
133
+ const TEMPORARY_NO_REWIND = 'A temporary chat keeps no file checkpoints and no transcript, so there is nothing to rewind to.';
134
+ const TEMPORARY_NO_RESTART = 'In a temporary chat the session cannot be restarted: its conversation would be lost.';
135
+
136
+ // Permission modes. The two newer ones are offered to panel sessions only;
137
+ // every other caller keeps the four it had.
138
+ const CLASSIC_PERMISSION_MODES = ['default', 'acceptEdits', 'plan', 'bypassPermissions'];
139
+ const PANEL_PERMISSION_MODES = [...CLASSIC_PERMISSION_MODES, 'dontAsk', 'auto'];
140
+
141
+ // Panel sessions and Bypass. A tab's process is launched able to take
142
+ // bypassPermissions (the SDK's allowDangerouslySkipPermissions), and only what
143
+ // the user chose puts it there: the mode a query or a starting control states
144
+ // (the mode control, a restored tab), `set_permission_mode`, the plan card's
145
+ // own choice, or the user's settings when the tab never picked a mode
146
+ // (permissions.defaultMode, which the CLI reads itself). Whether the user's
147
+ // settings or a policy turn Bypass off is the CLI's decision: it starts in
148
+ // another mode, or refuses the switch, and the tab is told.
149
+ const BYPASS = 'bypassPermissions';
150
+ const BYPASS_NOT_TAKEN = 'Claude Code did not start this session in Bypass: your Claude Code settings or a policy turn it off.';
151
+ // A Bypass the session reports that is not its mode. The sentence says whose it
152
+ // was: nobody's the user knows of, their own earlier pick or settings (the
153
+ // session was in Bypass before, by their choice), or the mode their settings
154
+ // started this process in before they picked another one.
155
+ const BYPASS_FOREIGN = 'Something other than you switched this session to Bypass. It was switched back.';
156
+ const BYPASS_LEFT = 'This session reported Bypass again after it had left it. It was switched back.';
157
+ const BYPASS_FROM_SETTINGS = 'Your Claude Code settings started this session in Bypass. It was switched to the mode this tab is in.';
158
+ const SETTINGS_MODE_UNKNOWN = 'This session did not start in the mode your Claude Code settings name, so it cannot go back to it: it runs in Default until it restarts.';
159
+ const bypassRefusalText = (why) => {
160
+ const text = String(why || '');
161
+ if (/disabled by settings/i.test(text)) return 'Bypass is turned off by your Claude Code settings or a policy (permissions.disableBypassPermissionsMode).';
162
+ if (/not launched/i.test(text)) return 'Bypass is not available in this session: its process was started without it.';
163
+ return `Claude Code refused Bypass${text ? `: ${text.slice(0, 200)}` : '.'}`;
164
+ };
165
+ // Why a process cannot be launched able to take Bypass ('' when it can). Claude
166
+ // Code exits at start when asked for it as root outside a sandbox, so there the
167
+ // option is left out and the mode is refused here instead
168
+ // (lib/claude-bypass-policy.js; `deps.bypassBlock` stands in for it in tests).
169
+ function panelBypassBlock() {
170
+ if (typeof deps?.bypassBlock === 'function') { try { return String(deps.bypassBlock() || ''); } catch { return ''; } }
171
+ return rootBypassBlock();
172
+ }
173
+
174
+ // Tool policies a panel tab can pick: tools removed from the session.
175
+ const TOOL_POLICY_DISALLOW = {
176
+ 'read-only': ['Edit', 'Write', 'NotebookEdit', 'Bash'],
177
+ 'no-web': ['WebFetch', 'WebSearch'],
178
+ };
179
+
180
+ // The list for this server: capabilities that depend on what the host wired in.
181
+ export function panelCapabilities() {
182
+ const caps = [...PANEL_CAPABILITIES];
183
+ if (typeof deps?.claudeAccountEnv === 'function') caps.push('accounts'); // query `accountId`: run the tab under another Claude account
184
+ if (deps?.sessionOps === true) caps.push('session_ops'); // the host serves /api/claude-code/sessions/:id/{title,tag,fork,subagents} and DELETE
185
+ if (deps?.permissionRulesEdit === true) caps.push('permission_rules_edit'); // the host serves DELETE /api/claude-code/permission-rules
186
+ if (deps?.settingsView === true) caps.push('settings_view'); // the host serves GET /api/claude-code/settings/resolved
187
+ if (deps?.accountSettings === true) caps.push('account_settings'); // those settings routes take `account`: a named-account tab sees its own files
188
+ if (deps?.bypassPolicy === true) caps.push('bypass_policy'); // the host serves GET /api/claude-code/bypass-policy (is Bypass turned off, and by what)
189
+ return caps;
190
+ }
191
+
192
+ // Per-session switch for everything the sidepanel adds on top of the shared
193
+ // session class. Only createClaudeBridge() passes it: the Assistant brain and
194
+ // every other ClaudeSession caller keep the options, events and defaults they
195
+ // had before.
196
+ const PANEL_SESSION_OPTS = Object.freeze({ panel: true });
197
+ // Panel sessions: the messages, besides a query and a warm start, that can start
198
+ // a process. Only these apply the `config` they carry (see handleMessage). Every
199
+ // other message looks at the session or needs a live one: what it carries is
200
+ // not applied, so a request that only looks (permission rules, usage, MCP
201
+ // status) binds no account, takes no lock and leaves a cold session cold.
202
+ const STARTING_CONTROLS = new Set(['compact', 'rewind', 'rewind_conversation']);
203
+ const controlCanStart = (msg) => STARTING_CONTROLS.has(msg?.type) || (msg?.type === 'session_request' && msg.what === 'rewind_preview');
204
+ // Panel sessions: the messages that wait in line while the session is between
205
+ // two processes (see _newTurn): the ones that can start a process, the one
206
+ // request that ends it, and the two whose effect depends on what came before
207
+ // them (a stop, a permission-mode switch: handled ahead of a prompt that is
208
+ // still waiting, the stop would stop nothing and the prompt would undo the
209
+ // switch). Everything else (a permission answer, a request that only looks) is
210
+ // handled when it arrives, as it always was.
211
+ const takesTurn = (msg) => msg?.type === 'query' || msg?.type === 'warm' || controlCanStart(msg)
212
+ || (msg?.type === 'session_request' && msg.what === 'forget_session_rules')
213
+ || msg?.type === 'abort' || msg?.type === 'set_permission_mode';
214
+
215
+ let deps = null; // injected once from server.js — see configureClaudeBridge()
216
+ export function configureClaudeBridge(d) { deps = d; }
217
+
218
+ // windowId:sessionId → session (only while detached, for reattach)
219
+ const _detached = new Map();
220
+ // every live session, for shutdown + sweeps
221
+ const _allSessions = new Set();
222
+
223
+ function _orphanKey(wid, sid) { return sid ? `${wid}:${sid}` : wid; }
224
+
225
+ function log(...args) { console.log('[claude-sdk]', ...args); }
226
+
227
+ // Panel sessions, a temporary chat (lib/claude-temporary.js): the plan files
228
+ // Claude Code wrote for one that still have to be removed. path → { plansDirs,
229
+ // since, ended, last }. One is removed once the pump of the process that wrote
230
+ // it has settled (`ended`), and stays owed after that removal, whether it
231
+ // removed the file or found none: the pump settles when the SDK's own cleanup
232
+ // stops waiting for the process, which can be before the process is gone, and
233
+ // a write that was slow puts the file back. The path is forgotten by the sweep
234
+ // that runs once the process can no longer be alive (`last`), when that sweep
235
+ // did not fail. A removal that fails keeps the path: every later sweep tries it
236
+ // again, and so does the shutdown, which waits for no process and forgets what
237
+ // it removed. Empty unless a temporary chat wrote a plan.
238
+ const _temporaryPlansOwed = new Map();
239
+
240
+ // How long the SDK lets a session's process live once it ends it
241
+ // (ProcessTransport.close() in sdk.mjs, SDK 0.3.288): two seconds to exit by
242
+ // itself, then SIGTERM, then five more before SIGKILL. Its cleanup, which the
243
+ // pump waits for, gives up after the first two.
244
+ const SDK_PROCESS_EXIT_MS = 2000 + 5000;
245
+ // The last sweep of a temporary chat's plan files, counted from the moment its
246
+ // pump settled (the SDK ended the process before that): a second later than
247
+ // the process can live.
248
+ const TEMPORARY_PLANS_LAST_SWEEP_MS = SDK_PROCESS_EXIT_MS + 1000;
249
+
250
+ function _removeOwedTemporaryPlans({ all = false } = {}) {
251
+ if (!_temporaryPlansOwed.size) return;
252
+ let removed = 0;
253
+ let failed = 0;
254
+ for (const [path, owed] of [..._temporaryPlansOwed]) {
255
+ if (!all && !owed.ended) continue;
256
+ const result = removeTemporaryPlanFiles([path], owed);
257
+ removed += result.removed.length;
258
+ if (result.failed.length) { failed++; continue; }
259
+ if (all || owed.last) _temporaryPlansOwed.delete(path);
260
+ }
261
+ if (removed) log(`temporary chat: removed ${removed} plan file${removed === 1 ? '' : 's'} it wrote`);
262
+ if (failed) log(`temporary chat: ${failed} plan file${failed === 1 ? '' : 's'} could not be removed and will be tried again`);
263
+ }
264
+
265
+ function cleanEnv() {
266
+ const env = Object.fromEntries(
267
+ Object.entries(process.env).filter(([k]) =>
268
+ k !== 'CLAUDECODE' &&
269
+ !k.startsWith('VSCODE_') &&
270
+ k !== 'TERM_PROGRAM' &&
271
+ k !== 'TERM_PROGRAM_VERSION'
272
+ )
273
+ );
274
+ env.ENABLE_TOOL_SEARCH = 'true';
275
+ return env;
276
+ }
277
+
278
+ function validateWorkDir(cwd) {
279
+ if (!cwd) return null;
280
+ if (process.platform !== 'win32' && /^[A-Za-z]:[\\\/]/.test(cwd)) return null;
281
+ if (process.platform === 'win32' && cwd.startsWith('/')) return null;
282
+ if (!existsSync(cwd)) return null;
283
+ return cwd;
284
+ }
285
+
286
+ // Push-based AsyncIterable feeding the SDK's streaming-input mode.
287
+ function createInputQueue() {
288
+ const buf = [];
289
+ let wake = null;
290
+ let closed = false;
291
+ return {
292
+ push(m) { if (closed) return false; buf.push(m); if (wake) { const w = wake; wake = null; w(); } return true; },
293
+ close() { closed = true; if (wake) { const w = wake; wake = null; w(); } },
294
+ get closed() { return closed; },
295
+ async *[Symbol.asyncIterator]() {
296
+ for (;;) {
297
+ while (buf.length) yield buf.shift();
298
+ if (closed) return;
299
+ await new Promise(r => { wake = r; });
300
+ }
301
+ },
302
+ };
303
+ }
304
+
305
+ // Optional per-session overrides (used by the assistant brain; the sidepanel
306
+ // passes none): { ownerKey, systemPromptAppend, settingSources, hooks,
307
+ // mcpHeaders, extraMcpServers, maxBudgetUsd, env, disallowedTools,
308
+ // allowedTools, permissionMode, model, effort, cwd, windowId, sessionId,
309
+ // planPermission, planHook, planDenyMessage, planExitMode, planModeInstructions }.
310
+ function mergeHookMatchers(base = {}, extra = {}) {
311
+ const merged = { ...base };
312
+ for (const [event, matchers] of Object.entries(extra || {})) {
313
+ if (!Array.isArray(matchers) || !matchers.length) continue;
314
+ merged[event] = [...(merged[event] || []), ...matchers];
315
+ }
316
+ return merged;
317
+ }
318
+
319
+ export class ClaudeSession {
320
+ constructor(ws, opts = {}) {
321
+ this.ws = ws;
322
+ this.opts = opts && typeof opts === 'object' ? opts : {};
323
+ this.panel = this.opts.panel === true; // a sidepanel tab (see PANEL_SESSION_OPTS)
324
+ this.windowId = this.opts.windowId || null;
325
+ this.sessionId = this.opts.sessionId || null; // claude session id (from init/result events)
326
+ this.model = this.opts.model || null;
327
+ this.cwd = this.opts.cwd || null;
328
+ this.effort = this.opts.effort || null;
329
+ this.permissionMode = this.opts.permissionMode || 'default';
330
+
331
+ this.q = null; // live Query or null
332
+ this.input = null; // input queue feeding the Query
333
+ this.abortController = null;
334
+ this.pumpPromise = null;
335
+
336
+ this.inTurn = false;
337
+ this.pendingTurns = 0; // user messages pushed minus results received
338
+ this.swallowResults = 0; // results to swallow (produced by interrupt()) — no done, no accounting
339
+ this.swallowDeadline = 0; // staleness guard: swallow entries expire after 15s
340
+ this.bootComplete = false;
341
+ this.lastEventTime = Date.now();
342
+ this.lastActivity = Date.now();
343
+ this.lastPrompt = null;
344
+ this.stallRetries = 0;
345
+
346
+ // The Claude Code executable the current Query was started with (see
347
+ // ensureQuery): what a launch failure is diagnosed against.
348
+ this._claudeExecutable = null;
349
+
350
+ this.pendingPerms = new Map(); // requestId → { toolName, resolve, input, kind, wire }
351
+ this.alwaysAllowed = new Set();
352
+
353
+ // Work the CLI comes back to without a new prompt, after the turn that started
354
+ // it has ended: background tasks (agents, shells, monitors — the CLI's
355
+ // background_tasks_changed level signal) and session crons (ScheduleWakeup,
356
+ // CronCreate, /loop — reported to the Stop hook). Both live inside one CLI
357
+ // process, so ending the Query kills them; _onProcessGone() resets them.
358
+ this._bgTasks = new Map(); // task_id → { task_id, task_type, description, ambient }
359
+ this._sessionCrons = [];
360
+ // Panel sessions: the permission updates granted from cards ("Always"), as
361
+ // the /permissions view lists them. Session-scoped ones live in the CLI
362
+ // process and are dropped with it; the others are in the settings files.
363
+ this._grantedRules = [];
364
+ this._bgAgentIds = new Set(); // Task/Agent tool_use ids that went to the background
365
+ this._queryGen = 0; // per Query, so a replaced one's hook callbacks are ignored
366
+
367
+ // Turn-accounting cross-checks (see _handleResult and _checkStall).
368
+ this._lastPushAt = 0; // last user message pushed into the Query
369
+ this._lastResultAt = 0; // last result the CLI delivered
370
+ this._resultOwesTurn = false; // that result said another queued turn follows
371
+ this._outputSinceResult = false; // main-thread output since that result
372
+
373
+ this.costBySession = new Map(); // sessionId → last cumulative total_cost_usd baseline
374
+
375
+ this.orphanBuffer = null; // non-null while detached
376
+ this.orphanResynced = false;
377
+ this.orphanKillTimer = null;
378
+ this.destroyed = false;
379
+
380
+ // Stable SynaBun browser-routing identity for this chat session. Sent as
381
+ // X-Synabun-Terminal on the SynaBun MCP entry so the Neural Interface
382
+ // routes this session's tab-less browser calls to its OWN tab — and keeps
383
+ // the same tab across ensureQuery() re-creations (stall recovery), which
384
+ // would otherwise mint a fresh Mcp-Session-Id and acquire a new tab.
385
+ this.synabunOwnerKey = this.opts.ownerKey || `sidepanel-${randomUUID()}`;
386
+
387
+ // Delta coalescing state: blockKey → { event, text, timer }
388
+ this._deltaBuf = new Map();
389
+
390
+ // Random start offset de-phases the 15s watchdogs of sessions created in
391
+ // the same tick (page reload restores N tabs at once) so their stall
392
+ // checks — and any resulting recoveries — don't align.
393
+ this._watchdogStartTimer = setTimeout(() => {
394
+ this._watchdogStartTimer = null;
395
+ if (this.destroyed) return;
396
+ this._watchdog = setInterval(() => this._checkStall(), 15_000);
397
+ this._watchdog.unref?.();
398
+ }, Math.floor(Math.random() * 15_000));
399
+ this._watchdogStartTimer.unref?.();
400
+ // Self-heartbeat the session lock while a live Query exists — covers the
401
+ // detached window where client heartbeats stop but the CLI still writes JSONL.
402
+ this._lockBeat = setInterval(() => {
403
+ if (this.q && this.sessionId && this.windowId && !this.temporary) {
404
+ try { deps.heartbeatLock(this.sessionId, this.windowId); } catch {}
405
+ }
406
+ }, 30_000);
407
+ this._lockBeat.unref?.();
408
+ this._idleReaper = setInterval(() => this._maybeReapIdle(), 60_000);
409
+ this._idleReaper.unref?.();
410
+
411
+ _allSessions.add(this);
412
+ }
413
+
414
+ // ── wire helpers ──────────────────────────────────────────────────────────
415
+
416
+ send(data) {
417
+ if (this.destroyed) return;
418
+ if (this.orphanBuffer) {
419
+ // High-volume deltas are not buffered while detached — full assistant
420
+ // messages still arrive and carry the final content.
421
+ if (data?.type === 'event' && data.event?.type === 'stream_event') return;
422
+ this.orphanBuffer.push(data);
423
+ if (this.orphanBuffer.length > ORPHAN_BUFFER_CAP) {
424
+ this.orphanBuffer.shift();
425
+ this.orphanResynced = true;
426
+ }
427
+ return;
428
+ }
429
+ if (this._replaying) {
430
+ // Mid-replay: queue behind the buffered events to preserve ordering
431
+ // (same delta-drop policy as detachment — the window is a few ticks).
432
+ if (data?.type === 'event' && data.event?.type === 'stream_event') return;
433
+ this._replayQueue?.push(data);
434
+ return;
435
+ }
436
+ if (this.ws && this.ws.readyState === 1) {
437
+ try { this.ws.send(JSON.stringify(data)); } catch {}
438
+ }
439
+ }
440
+
441
+ sendEvent(event) { this.send({ type: 'event', event }); }
442
+
443
+ sendDone(code) {
444
+ this.send({ type: 'done', code });
445
+ }
446
+
447
+ // ── query lifecycle ──────────────────────────────────────────────────────
448
+
449
+ // `warm: true` (panel sessions, see the `warm` message) starts the CLI process
450
+ // without a prompt: the first real ensureQuery() adopts it if the session's
451
+ // configuration is still the one it was started with.
452
+ ensureQuery({ warm = false } = {}) {
453
+ if (this.q) return this.q;
454
+ // Panel sessions under a named Claude account: every process start checks
455
+ // the account again (a message, a recreation after a stall, a lost-session
456
+ // recovery, a rewind, a warm start). One that is gone refuses the start:
457
+ // the process would otherwise run under the ambient default identity while
458
+ // the tab says another. The environment validated here is the one used.
459
+ this._startRefused = null;
460
+ // Panel sessions: a process starts only for a session that was given its
461
+ // tab's configuration, by a query or by the starting control that reached
462
+ // it last (_establishFromControl). After a server restart, or when an idle tab's
463
+ // socket was replaced, the session object holds nothing but the id: started
464
+ // as it is, it would run under the default identity with every tool and
465
+ // default permissions while the tab says otherwise. Every start comes
466
+ // through here, so this is the one place that refuses.
467
+ if (this.panel && !this._established) {
468
+ this._startRefused = this._coldRefusal || { code: 'config_required', message: CONFIG_REQUIRED };
469
+ log(`session create refused: ${this._startRefused.code} (resume=${this.sessionId || 'none'})`);
470
+ return null;
471
+ }
472
+ // Panel sessions, a temporary chat whose process is gone: the conversation
473
+ // lived in that process and nowhere else. A new one would answer without
474
+ // it, so none is started (every start comes through here).
475
+ if (this.temporary && this._tempOver) {
476
+ this._closeWarm();
477
+ this._startRefused = { code: 'temporary_ended', message: TEMPORARY_ENDED };
478
+ log('session create refused: the temporary chat has ended');
479
+ return null;
480
+ }
481
+ let accountEnv = null;
482
+ if (this.panel && this.accountId) {
483
+ accountEnv = this._accountEnvOf(this.accountId);
484
+ if (!accountEnv) {
485
+ this._closeWarm();
486
+ this._startRefused = { code: 'account_unavailable', message: ACCOUNT_UNAVAILABLE };
487
+ log(`session create refused: the account "${this.accountId}" is no longer set up`);
488
+ return null;
489
+ }
490
+ }
491
+ // Which Claude Code runs this session: the user's own installation, always.
492
+ // SynaBun carries none (lib/external-tools.js at the package root) and the
493
+ // SDK is never left to look for one. The host answers (deps.claudeExecutable,
494
+ // lib/claude-executable.js): an explicit override from cli-config.json
495
+ // ("claude-skin": { "sdkExecutable": "…" }), else the installed Claude Code.
496
+ // The model picker lists that same installation's models, so an alias means
497
+ // in the session what it meant in the picker.
498
+ //
499
+ // Nothing installed is an ordinary state: the start is refused with the
500
+ // sentence the panel shows its install help on, and nothing was started.
501
+ // The SDK itself is never called without an executable; only a host that
502
+ // brings its own query factory and no resolver (a test) starts without one.
503
+ let executable = null;
504
+ if (typeof deps.claudeExecutable === 'function') {
505
+ try { executable = deps.claudeExecutable() || null; } catch { executable = null; }
506
+ } else if (deps.sdkExecutable) {
507
+ executable = resolveClaudeSdkExecutable({ sdkExecutable: deps.sdkExecutable });
508
+ }
509
+ const ownFactory = typeof (warm ? deps.startupFactory : deps.queryFactory) === 'function';
510
+ if (!executable?.path && (typeof deps.claudeExecutable === 'function' || !ownFactory)) {
511
+ this._closeWarm();
512
+ this._startRefused = { code: 'claude_not_installed', message: executable?.reason || CLAUDE_NOT_INSTALLED };
513
+ log(`session create refused: no Claude Code to run (${executable?.reason || 'not installed'})`);
514
+ return null;
515
+ }
516
+ if (this._warm) {
517
+ const w = this._warm;
518
+ this._warm = null;
519
+ clearTimeout(w.timer);
520
+ if (!warm && w.ready && !w.closed && w.sig === this._warmSignature()) {
521
+ this.input = w.input;
522
+ this.abortController = w.abortController;
523
+ this.bootComplete = false;
524
+ this.lastEventTime = Date.now();
525
+ this._lastResultAt = 0;
526
+ this._resultOwesTurn = false;
527
+ this._outputSinceResult = false;
528
+ this._deltaBuf.clear();
529
+ log(`session create: adopted the warm process (resume=${this.sessionId || 'none'})`);
530
+ this._startModeSeen = false;
531
+ this.q = w.handle.query(this.input);
532
+ if (this.panel) this._noteProcessStarted();
533
+ this.pumpPromise = this._pump(this.q);
534
+ return this.q;
535
+ }
536
+ this._closeWarm(w);
537
+ }
538
+ // A new CLI process starts with no background work of its own; anything the
539
+ // previous one still reported died with it.
540
+ this._onProcessGone();
541
+ const gen = ++this._queryGen;
542
+ this.input = createInputQueue();
543
+ this.abortController = new AbortController();
544
+ this.bootComplete = false;
545
+ this.lastEventTime = Date.now();
546
+ this._lastResultAt = 0;
547
+ this._resultOwesTurn = false;
548
+ this._outputSinceResult = false;
549
+ this._deltaBuf.clear();
550
+
551
+ const workDir = validateWorkDir(this.cwd) || deps.PACKAGE_ROOT;
552
+ const sessionOpts = this.opts || {};
553
+ const options = {
554
+ cwd: workDir,
555
+ permissionMode: this.permissionMode || 'default',
556
+ // Opt-in (the assistant brain): lets plan mode or a later switch reach bypassPermissions.
557
+ ...(sessionOpts.allowDangerouslySkipPermissions === true ? { allowDangerouslySkipPermissions: true } : {}),
558
+ includePartialMessages: deps.includePartialMessages !== false,
559
+ enableFileCheckpointing: true, // required for Query.rewindFiles()
560
+ // CRITICAL: without these two the SDK loads neither the Claude Code system
561
+ // prompt nor any ~/.claude settings/hooks/CLAUDE.md/MCP servers.
562
+ systemPrompt: sessionOpts.systemPromptAppend
563
+ ? { type: 'preset', preset: 'claude_code', append: String(sessionOpts.systemPromptAppend) }
564
+ : { type: 'preset', preset: 'claude_code' },
565
+ settingSources: Array.isArray(sessionOpts.settingSources) ? sessionOpts.settingSources : ['user', 'project', 'local'],
566
+ canUseTool: (name, input, opts) => this._onCanUseTool(name, input, opts || {}),
567
+ hooks: mergeHookMatchers({
568
+ PreCompact: [{
569
+ hooks: [async () => {
570
+ this.sendEvent({ type: 'system', subtype: 'compact_started', message: 'Auto-compacting context…' });
571
+ return {};
572
+ }],
573
+ }],
574
+ // Stop fires at the end of every main-thread turn, and its input lists the
575
+ // session crons (ScheduleWakeup, CronCreate, /loop) that will wake this
576
+ // session later. The idle reaper must not end a session waiting on one.
577
+ Stop: [{
578
+ hooks: [async (input) => {
579
+ if (gen === this._queryGen) {
580
+ const before = JSON.stringify(this._sessionCrons);
581
+ this._sessionCrons = Array.isArray(input?.session_crons) ? input.session_crons : [];
582
+ // Panel sessions: the scheduled wakeups of this session, when they change.
583
+ if (this.panel && JSON.stringify(this._sessionCrons) !== before) this._announceCrons();
584
+ }
585
+ return {};
586
+ }],
587
+ }],
588
+ // Opt-in (the assistant brain): plan mode refuses before permission rules
589
+ // run, so a user allow rule (Edit(*), mcp__fs__*) cannot turn a code
590
+ // change back on. Subagents too.
591
+ ...(typeof sessionOpts.planHook === 'function' ? {
592
+ PreToolUse: [{
593
+ hooks: [async (input) => {
594
+ if (this.permissionMode !== 'plan' || sessionOpts.planHook(input?.tool_name, input?.tool_input || {}) !== 'deny') return {};
595
+ this.send({ type: 'stderr', text: `Plan mode: declined ${input?.tool_name}` });
596
+ const reason = sessionOpts.planDenyMessage?.(input?.tool_name) || `Plan mode: ${input?.tool_name} was not run.`;
597
+ return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: reason } };
598
+ }],
599
+ }],
600
+ } : {}),
601
+ }, sessionOpts.hooks),
602
+ // `typesafe:'off'` on a query (the live skin test) keeps this CLI's hooks
603
+ // from paying for judgments: they read the kill switch from their env.
604
+ // (Panel sessions: the CLI reports running / idle / requires_action only when
605
+ // asked to; `idle` is its own word that a turn is over. See _noteSessionState.)
606
+ env: { ...cleanEnv(), ...(this.typesafeOff ? { SYNABUN_TYPESAFE: 'off' } : {}), ...(sessionOpts.env || {}), ...(accountEnv || {}), ...(this.panel ? { CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS: '1' } : {}) },
607
+ abortController: this.abortController,
608
+ stderr: (text) => {
609
+ const t = String(text || '').trim();
610
+ if (t) this.send({ type: 'stderr', text: t });
611
+ },
612
+ };
613
+ if (Array.isArray(sessionOpts.disallowedTools) && sessionOpts.disallowedTools.length) options.disallowedTools = [...sessionOpts.disallowedTools];
614
+ if (this.panel) {
615
+ // The process is launched able to take Bypass, so the tab's mode control
616
+ // can switch to it and back on the live session (see BYPASS above). Where
617
+ // it cannot be (root), a tab that asks for Bypass runs in Default.
618
+ const bypassBlock = panelBypassBlock();
619
+ this._bypassLaunched = !bypassBlock;
620
+ if (this._bypassLaunched) options.allowDangerouslySkipPermissions = true;
621
+ else if (options.permissionMode === BYPASS) {
622
+ options.permissionMode = this.permissionMode = 'default';
623
+ this.sendEvent(this._numbered({ type: 'mode_changed', mode: 'default', reason: bypassBlock }));
624
+ }
625
+ // A tab that never picked a mode starts in the one the user's settings
626
+ // name (permissions.defaultMode): omitted, the CLI decides.
627
+ if (this._modeFromSettings) delete options.permissionMode;
628
+ this._startModeSeen = false; // the first init of this process says which mode it started in
629
+ this._startAskedBypass = options.permissionMode === BYPASS;
630
+ this._startedFromSettings = !('permissionMode' in options); // the CLI chose the starting mode (permissions.defaultMode)
631
+ this._launchMode = this._startedFromSettings ? '' : options.permissionMode; // what the process is in until it says otherwise (see _noteProcessStarted)
632
+ if (this._startAskedBypass) this._bypassHeld = true;
633
+ const removed = TOOL_POLICY_DISALLOW[this.toolPolicy];
634
+ if (removed) options.disallowedTools = [...new Set([...(options.disallowedTools || []), ...removed])];
635
+ // MCP servers asking the user for input or a sign-in. Only for a client
636
+ // that said it can show the card: left unset, the CLI declines for us.
637
+ if (this.clientFeatures?.has('elicitation')) {
638
+ options.onElicitation = (request, opts) => this._promptElicitation(request || {}, opts?.signal);
639
+ }
640
+ // The panel has a stop control per background task, so an interrupt of the
641
+ // turn leaves background agents and shells running.
642
+ if (this.clientFeatures?.has('task_stop')) options.perTaskStopAffordance = true;
643
+ // Resuming a session whose prompt cache has expired pays to write it
644
+ // again: the CLI says how much at SessionStart.
645
+ options.hooks = mergeHookMatchers(options.hooks, {
646
+ SessionStart: [{
647
+ hooks: [async (input) => {
648
+ if (gen === this._queryGen && input?.prompt_cache_likely_expired === true && Number(input?.estimated_cache_write_usd) > 0) {
649
+ this.sendEvent({
650
+ type: 'system', subtype: 'cache_cost', session_id: this.sessionId,
651
+ source: String(input.source || ''),
652
+ seconds_since_last_response: Number(input.seconds_since_last_response) || 0,
653
+ estimated_cache_write_usd: Number(input.estimated_cache_write_usd),
654
+ });
655
+ }
656
+ return {};
657
+ }],
658
+ }],
659
+ // Changing the model mid-conversation sends the whole context to the new
660
+ // model's cache with the next reply: the CLI says what that costs once
661
+ // the switch is done (a headless set_model fires this with source 'sdk').
662
+ PostModelSwitch: [{
663
+ hooks: [async (input) => {
664
+ if (gen === this._queryGen && input?.source !== 'resume' && Number(input?.estimated_cache_write_usd) > 0) {
665
+ this.sendEvent({
666
+ type: 'system', subtype: 'cache_cost', session_id: this.sessionId,
667
+ source: 'model_switch',
668
+ from_model: String(input.from_model || ''),
669
+ to_model: String(input.to_model || ''),
670
+ context_tokens: Number(input.context_tokens) || 0,
671
+ prompt_cache_warm: input.prompt_cache_warm === true,
672
+ estimated_cache_write_usd: Number(input.estimated_cache_write_usd),
673
+ });
674
+ }
675
+ return {};
676
+ }],
677
+ }],
678
+ });
679
+ }
680
+ if (Array.isArray(sessionOpts.allowedTools) && sessionOpts.allowedTools.length) options.allowedTools = [...sessionOpts.allowedTools];
681
+ // The plan workflow the CLI puts inside its own plan-mode reminder (it keeps its preamble and the ExitPlanMode footer).
682
+ if (typeof sessionOpts.planModeInstructions === 'string' && sessionOpts.planModeInstructions.trim()) options.planModeInstructions = sessionOpts.planModeInstructions;
683
+ if (Number(sessionOpts.maxBudgetUsd) > 0) options.maxBudgetUsd = Number(sessionOpts.maxBudgetUsd);
684
+ // Per-chat-session SynaBun identity: programmatic mcpServers shadows the
685
+ // same-named user-scope entry from settingSources, so this session's
686
+ // browser calls carry a stable X-Synabun-Terminal and get their own tab
687
+ // (instead of colliding with other sidepanel chats / CLI windows on the
688
+ // shared HTTP transport identity).
689
+ if (deps.mcpUrl) {
690
+ options.mcpServers = {
691
+ SynaBun: {
692
+ type: 'http',
693
+ url: deps.mcpUrl,
694
+ headers: {
695
+ 'X-Synabun-Terminal': this.synabunOwnerKey,
696
+ 'X-Synabun-Project': memoryProject(workDir),
697
+ // (A temporary chat is never named to the memory server by a conversation id.)
698
+ 'X-Synabun-Memory-Session': (this.temporary ? null : this.sessionId) || this.synabunOwnerKey,
699
+ ...(sessionOpts.mcpHeaders || {}),
700
+ },
701
+ },
702
+ };
703
+ }
704
+ if (sessionOpts.extraMcpServers && typeof sessionOpts.extraMcpServers === 'object') {
705
+ options.mcpServers = { ...(options.mcpServers || {}), ...sessionOpts.extraMcpServers };
706
+ }
707
+ // (A temporary chat has no transcript to resume from.)
708
+ if (this.sessionId && !this.temporary) options.resume = this.sessionId;
709
+ if (this.panel) {
710
+ // A new session takes the name the user gave the tab; a conversation
711
+ // rewind resumes at the chosen entry, once.
712
+ if (!this.sessionId && this._title) options.title = this._title;
713
+ if (this.sessionId && this._resumeAt) { options.resumeSessionAt = this._resumeAt; this._resumeAt = null; }
714
+ }
715
+ if (this.model) options.model = this.model;
716
+ if (this.effort && CLAUDE_EFFORT_LEVELS.includes(this.effort)) {
717
+ // Panel sessions use the typed option; every other caller keeps the flag it had.
718
+ if (this.panel) options.effort = this.effort;
719
+ else options.extraArgs = { effort: this.effort };
720
+ }
721
+ // Sibling-project access, same guard as legacy --add-dir
722
+ const parentDir = dirname(workDir);
723
+ if (parentDir && parentDir !== workDir && dirname(parentDir) !== parentDir) {
724
+ options.additionalDirectories = [parentDir];
725
+ }
726
+ // The tab's own session settings (fallback model, limits, directories,
727
+ // plugins, instructions, sandbox, thinking, fast mode, output style, agent).
728
+ // The host's own MCP entries, before a tab adds its servers: setMcpServers
729
+ // replaces the whole dynamic set, so these go back in with every change.
730
+ this._hostMcpServers = { ...(options.mcpServers || {}) };
731
+ if (this.panel && this.panelSession) {
732
+ // (A temporary chat writes no debug log: the log holds what the session said.)
733
+ const wantsDebug = this.temporary && this.panelSession.start?.debug;
734
+ const cfg = wantsDebug ? { ...this.panelSession, start: { ...this.panelSession.start, debug: false } } : this.panelSession;
735
+ const notes = applyPanelSessionOptions(options, cfg, { model: this.model, validateDir: validateWorkDir, debugFile: this.temporary ? '' : this._debugFilePath() });
736
+ if (wantsDebug) notes.push('A temporary chat writes no debug log.');
737
+ for (const note of notes) this.sendEvent({ type: 'system', subtype: 'runtime_notice', level: 'warn', message: note });
738
+ if (options.debugFile) this.sendEvent({ type: 'system', subtype: 'runtime_notice', level: 'info', message: `Debug log of this session: ${options.debugFile}` });
739
+ this._liveApplied = { ...this.panelSession.live };
740
+ }
741
+ // Panel sessions, a temporary chat: nothing saved, memory readable. Applied
742
+ // last, over everything above (lib/claude-temporary.js).
743
+ if (this.panel && this.temporary) {
744
+ // (Which of the session's MCP connections is SynaBun's is asked of the live process when a tool of one is called.)
745
+ applyTemporaryOptions(options, { mcpUrl: deps.mcpUrl || '', mcpStatus: () => this.q?.mcpServerStatus?.() });
746
+ this._tempPlansDirs = plansDirsOf(options.env, { cwd: workDir, settings: options.settings });
747
+ if (!this._tempSince) this._tempSince = Date.now();
748
+ }
749
+ // The executable resolved at the top of this call. The SDK uses
750
+ // pathToClaudeCodeExecutable verbatim with zero validation, so it was vetted
751
+ // there — notably a bare command name, which would ENOENT under the SDK's
752
+ // spawn(shell:false).
753
+ let runtimeLabel = 'unset';
754
+ this._claudeExecutable = null;
755
+ if (executable?.ignored) {
756
+ log('ignoring sdkExecutable override:', executable.ignored);
757
+ this.send({ type: 'stderr', text: `Ignoring sdkExecutable override — ${executable.ignored}` });
758
+ }
759
+ if (executable?.path) {
760
+ options.pathToClaudeCodeExecutable = executable.path;
761
+ this._claudeExecutable = executable.path;
762
+ runtimeLabel = executable.source === 'override' ? 'override' : executable.path;
763
+ }
764
+
765
+ if (warm) {
766
+ // Start the process now; hand it the input queue when the first prompt comes.
767
+ // (`bypass`: started in Bypass, as the tab's stated mode or as the mode this session saw the user's
768
+ // settings name. A process kept ready is a process of this session's all the same: see _mayBypass.)
769
+ const w = { sig: this._warmSignature(), input: this.input, abortController: this.abortController, handle: null, ready: false, closed: false, timer: null, bypass: this._startAskedBypass === true || (this._startedFromSettings === true && this.permissionMode === BYPASS) };
770
+ this._warm = w;
771
+ log(`warm start: resume=${this.sessionId || 'none'} model=${this.model || 'default'} cwd=${workDir} cli=${runtimeLabel}`);
772
+ const startupFactory = typeof deps.startupFactory === 'function' ? deps.startupFactory : startup;
773
+ Promise.resolve().then(() => startupFactory({ options })).then((handle) => {
774
+ if (w.closed) { try { handle.close(); } catch {} return; }
775
+ w.handle = handle;
776
+ w.ready = true;
777
+ }).catch((err) => {
778
+ log('warm start failed:', err?.message || err);
779
+ if (this._warm === w) this._warm = null;
780
+ w.closed = true;
781
+ });
782
+ w.timer = setTimeout(() => { if (this._warm === w) { this._warm = null; this._closeWarm(w); } }, WARM_TTL_MS);
783
+ w.timer.unref?.();
784
+ return null;
785
+ }
786
+
787
+ log(`session create: resume=${this.sessionId || 'none'} model=${this.model || 'default'} effort=${this.effort || 'default'} mode=${this.permissionMode} cwd=${workDir} sdk=${SDK_VERSION} cli=${runtimeLabel}${sessionOpts.ownerKey ? ` owner=${sessionOpts.ownerKey}` : ''}`);
788
+ const queryFactory = typeof deps.queryFactory === 'function' ? deps.queryFactory : query;
789
+ this.q = queryFactory({ prompt: this.input, options });
790
+ if (this.panel) this._noteProcessStarted();
791
+ this.pumpPromise = this._pump(this.q);
792
+ return this.q;
793
+ }
794
+
795
+ // Everything a process is started with: a warm one is only adopted when
796
+ // none of it changed between the first keystroke and the first message.
797
+ _warmSignature() {
798
+ return JSON.stringify([
799
+ this.cwd || '', this.model || '', this.effort || '', this.permissionMode || '', !!this._modeFromSettings,
800
+ this.toolPolicy || '', startSignature(this.panelSession), this.panelSession?.live || null,
801
+ this.sessionId || '', this.accountId || '', this._title || '', [...(this.clientFeatures || [])].sort(),
802
+ !!this.typesafeOff, this._resumeAt || '', !!this.temporary,
803
+ ]);
804
+ }
805
+
806
+ _closeWarm(w = this._warm) {
807
+ if (!w) return;
808
+ if (this._warm === w) this._warm = null;
809
+ clearTimeout(w.timer);
810
+ w.closed = true;
811
+ try { w.handle?.close(); } catch {}
812
+ try { w.abortController?.abort(); } catch {}
813
+ // (A process that was started in Bypass ended: the page is told when that is news, as for any other. See _mayBypass.)
814
+ if (w.bypass) this._factsSettled();
815
+ }
816
+
817
+ // Panel sessions: the tab stated its mode (or its configuration) again while a
818
+ // process is kept ready. One that was started with something else is ended
819
+ // now, not at the next message: it would not be adopted anyway
820
+ // (_warmSignature), so the turn starts on a process launched in the stated
821
+ // mode, and nothing of this session's stays behind in a mode the tab left.
822
+ _warmStillFits() {
823
+ const w = this._warm;
824
+ if (w && w.sig !== this._warmSignature()) this._closeWarm(w);
825
+ }
826
+
827
+ async _pump(q) {
828
+ try {
829
+ for await (const m of q) {
830
+ if (this.destroyed) break;
831
+ // A replaced query (stall recreate, config change) must stop feeding the
832
+ // client — its late results would corrupt the new query's turn accounting.
833
+ if (this.q !== q) break;
834
+ this.lastEventTime = Date.now();
835
+ try { this._translate(m); } catch (err) { log('translate error:', err.message); }
836
+ }
837
+ // Generator ended (input closed / process exit). If a turn was in flight
838
+ // and this wasn't a deliberate teardown, surface it.
839
+ if (!this.destroyed && this.q === q && this.inTurn) {
840
+ log('query ended mid-turn');
841
+ this._finishTurnWithError('Claude session ended unexpectedly.');
842
+ }
843
+ } catch (err) {
844
+ if (this.destroyed || this.q !== q) return;
845
+ const msg = err?.message || String(err);
846
+ log('pump error:', msg);
847
+ if (/No conversation found/i.test(msg)) { this._recoverLostSession(); return; }
848
+ // The SDK cannot tell a permission problem from a libc mismatch: it buckets
849
+ // every spawn error together and always blames musl/glibc. Diagnose it
850
+ // properly, repair what we can, and fall back to the user's CLI.
851
+ if (isNativeBinaryLaunchFailure(err)) { this._recoverNativeRuntime(err); return; }
852
+ this._finishTurnWithError(msg);
853
+ } finally {
854
+ // Still current means the process went away on its own (exit, crash) rather
855
+ // than through _endQuery, which already cleaned up.
856
+ if (this.q === q) {
857
+ this.q = null;
858
+ this._onProcessGone();
859
+ }
860
+ }
861
+ }
862
+
863
+ // The CLI process behind this session is gone — ended, replaced or crashed. Its
864
+ // background tasks and crons died with it: tell the panel, so it stops showing
865
+ // agents that will never report back. Idempotent.
866
+ _onProcessGone() {
867
+ // Panel sessions, a temporary chat: its conversation lived in the process
868
+ // that just ended and nowhere else. It is over, and the tab is told once.
869
+ if (this.temporary && this._tempLive && !this._tempOver) {
870
+ this._tempOver = true;
871
+ this._removeTemporaryPlans();
872
+ this.send({ type: 'temporary_ended' });
873
+ }
874
+ // Session-scoped permission rules lived in the process that just ended.
875
+ this._grantedRules = this._grantedRules.filter(u => u.destination && u.destination !== 'session');
876
+ const hadTasks = this._bgTasks.size > 0;
877
+ this._bgTasks.clear();
878
+ const hadCrons = this._sessionCrons.length > 0;
879
+ this._sessionCrons = [];
880
+ if (this.panel && hadCrons) this._announceCrons();
881
+ // (Panel sessions: with the process gone nothing is in Bypass any more, and the page is told when that is news.)
882
+ if (this.panel) this._factsSettled();
883
+ for (const id of this._bgAgentIds) {
884
+ this.sendEvent({
885
+ type: 'subagent', subtype: 'stop', tool_use_id: id,
886
+ is_error: true, status: 'stopped', session_id: this.sessionId,
887
+ });
888
+ }
889
+ this._bgAgentIds.clear();
890
+ if (hadTasks) {
891
+ this.sendEvent({ type: 'system', subtype: 'background_tasks_changed', tasks: [], session_id: this.sessionId });
892
+ }
893
+ }
894
+
895
+ // The environment of a named Claude account (its own config directory), or
896
+ // null when that account cannot be used: an id that is not a plain name, a
897
+ // profile removed in Settings (it resolves to no config directory), a registry
898
+ // that throws. The one lookup behind every check: there is no fallback to an
899
+ // empty environment, which would be the ambient default identity.
900
+ _accountEnvOf(account) {
901
+ if (typeof account !== 'string' || !/^[A-Za-z0-9_-]{1,64}$/.test(account) || typeof deps.claudeAccountEnv !== 'function') return null;
902
+ let env = null;
903
+ try { env = deps.claudeAccountEnv(account); } catch { env = null; }
904
+ if (!env || typeof env.CLAUDE_CONFIG_DIR !== 'string' || !env.CLAUDE_CONFIG_DIR) return null;
905
+ return env;
906
+ }
907
+
908
+ // A start ensureQuery() refused (see there): tell the tab why and close the
909
+ // turn it is waiting on. False when the last start was not refused.
910
+ _announceRefusedStart() {
911
+ const refused = this._startRefused;
912
+ if (!refused) return false;
913
+ this._denyAllPending('Session error');
914
+ this.inTurn = false;
915
+ this.pendingTurns = 0;
916
+ this.send({ type: 'error', code: refused.code, message: refused.message });
917
+ this.sendDone(-1);
918
+ return true;
919
+ }
920
+
921
+ // The account a panel query names, checked before the query touches anything.
922
+ // Returns { account } ('' is the default account) or { code, message } when the
923
+ // query must be refused: running it anyway would use the ambient default
924
+ // identity while the tab says another one.
925
+ _resolveQueryAccount(accountId, sessionId) {
926
+ if (accountId === undefined) return { account: this.accountId || '' };
927
+ const account = accountId === 'default' || accountId === '' || accountId === null ? '' : accountId;
928
+ if (account && !this._accountEnvOf(account)) return { code: 'account_unavailable', message: ACCOUNT_UNAVAILABLE };
929
+ // A conversation belongs to the account it started under: its transcript
930
+ // lives in that account's config directory and cannot be resumed from another.
931
+ // "Started under" is known once a message ran here (_accountBound). A session
932
+ // object made for a cold resume (the server restarted, the page reattached
933
+ // to nothing) only has the id: its first message says which account owns it.
934
+ if (this._accountBound && this.sessionId && sessionId && sessionId === this.sessionId && account !== (this.accountId || '')) {
935
+ return { code: 'account_change_refused', message: 'This conversation belongs to the account it started under. Start a new chat to use another account.' };
936
+ }
937
+ return { account };
938
+ }
939
+
940
+ // Panel sessions: a control that can start a process reached a session no
941
+ // process has started for yet, and brings the tab's configuration (`config`:
942
+ // the fields of a query, without a prompt). It is applied by the code a query
943
+ // runs (_handleQuery: the account is validated, the lock taken, the tool
944
+ // policy, permission mode and session settings set), and nothing is started
945
+ // here: the message's own handling starts the process if it needs one.
946
+ // Until a process has started, the latest configuration is the one that
947
+ // counts: the tab may have changed (another account, other restrictions)
948
+ // since an earlier control or a warm start brought one. So the earlier one is
949
+ // dropped first, and a configuration that is refused leaves the session cold,
950
+ // with the reason for the reply, never startable under what it replaced. A
951
+ // control that brings none keeps what the session has.
952
+ _establishFromControl(msg) {
953
+ this._coldRefusal = null;
954
+ const config = msg?.config;
955
+ if (!config || typeof config !== 'object' || Array.isArray(config)) return;
956
+ this._established = false;
957
+ this._handleQuery({ ...config, type: 'warm' }, { warmOnly: true, establishOnly: true });
958
+ }
959
+
960
+ // Panel sessions: a starting control whose configuration names another
961
+ // conversation than the one this session has. New chat and the session menu
962
+ // keep the tab's socket and tell the bridge nothing, so the tab can be on a
963
+ // conversation the session's process was not started for. Judged exactly as
964
+ // a prompt is (`sessionChanged` / `newChat` in _handleQuery): another session
965
+ // id, or none where the session has one. A control without a configuration
966
+ // says nothing about where the tab is, and changes nothing.
967
+ _controlLeftConversation(msg) {
968
+ const config = msg?.config;
969
+ if (!config || typeof config !== 'object' || Array.isArray(config)) return false;
970
+ const sessionId = typeof config.sessionId === 'string' ? config.sessionId : '';
971
+ return sessionId ? (!!this.sessionId && sessionId !== this.sessionId) : !!this.sessionId;
972
+ }
973
+
974
+ // Panel sessions: a process was started for this tab (not a warm one, which
975
+ // is only a process kept ready). From here a control's configuration no
976
+ // longer replaces the session's while the tab stays on this conversation,
977
+ // and the conversation belongs to the account it started under, whether a
978
+ // message or a control started it.
979
+ _noteProcessStarted() {
980
+ this._everStarted = true;
981
+ this._accountBound = true;
982
+ // What this process is in, and whether it may be in Bypass (see "what is" below):
983
+ // a process launched in Bypass may be in it from its launch.
984
+ this._cli = { q: this.q, mode: this._launchMode || '', may: this._startAskedBypass === true, at: 0, sends: 0 };
985
+ }
986
+
987
+ // Panel sessions: a turn at the session's process. Socket messages are
988
+ // dispatched as they arrive and not awaited, so a message that ends the
989
+ // process and waits for it to close (a control after New chat or a session
990
+ // switch, a prompt that changes the conversation, a conversation rewind) used
991
+ // to leave a window in which the next message started a replacement, which
992
+ // the first one then took for its own. Now the message that waits on a
993
+ // closing process holds the turn until what it does next is done, and the
994
+ // messages of `takesTurn` that arrive meanwhile wait in line, first come
995
+ // first served (handleMessage). While nobody holds it, nothing waits: a
996
+ // message is handled in the tick it arrives, exactly as before there were turns.
997
+ // `take()` returns null when the turn is the caller's at once (it was free, or
998
+ // the caller holds it already) and a promise to wait on when it is not.
999
+ // `release()` hands the turn to the next waiter and may be called twice.
1000
+ // A turn is held only across waits that end by themselves (a closing process
1001
+ // is given 5 s): a handler gives it up before it waits on the CLI or on the
1002
+ // disk and takes it again afterwards, so a call that never returns cannot
1003
+ // keep the tab's later messages waiting.
1004
+ _newTurn() {
1005
+ const turn = {
1006
+ held: false,
1007
+ take: () => {
1008
+ if (turn.held) return null;
1009
+ turn.held = true;
1010
+ if (!this._turnBusy) { this._turnBusy = true; return null; }
1011
+ return new Promise((resolve) => { (this._turnWaiters ||= []).push(resolve); });
1012
+ },
1013
+ release: () => {
1014
+ if (!turn.held) return;
1015
+ turn.held = false;
1016
+ const next = this._turnWaiters?.shift();
1017
+ if (next) next(); else this._turnBusy = false;
1018
+ },
1019
+ };
1020
+ return turn;
1021
+ }
1022
+
1023
+ // Where a panel tab's debug log goes when it asked for one: a file in the
1024
+ // directory the host names. Without that directory there is no debug log
1025
+ // (the stream would otherwise land in stderr, which the panel prints).
1026
+ _debugFilePath() {
1027
+ if (!this.panel || !this.panelSession?.start?.debug) return '';
1028
+ const dir = typeof deps.debugDir === 'string' ? deps.debugDir : '';
1029
+ if (!dir) return '';
1030
+ try { mkdirSync(dir, { recursive: true }); } catch { return ''; }
1031
+ const key = String(this.sessionId || this.synabunOwnerKey || 'tab').replace(/[^\w-]/g, '_').slice(0, 80);
1032
+ return join(dir, `claude-${key}.log`);
1033
+ }
1034
+
1035
+ // A permission update as the /permissions view needs it: its verb, where it
1036
+ // was written, the rules. Nothing else of the object is kept.
1037
+ _noteGranted(updates) {
1038
+ for (const u of Array.isArray(updates) ? updates : []) {
1039
+ if (!u || typeof u !== 'object' || typeof u.type !== 'string') continue;
1040
+ const slim = { type: u.type.slice(0, 40) };
1041
+ if (typeof u.behavior === 'string') slim.behavior = u.behavior.slice(0, 20);
1042
+ slim.destination = typeof u.destination === 'string' ? u.destination.slice(0, 40) : 'session';
1043
+ if (Array.isArray(u.rules)) slim.rules = u.rules.slice(0, 20).map(r => ({ toolName: String(r?.toolName || '').slice(0, 200), ...(r?.ruleContent != null ? { ruleContent: String(r.ruleContent).slice(0, 500) } : {}) }));
1044
+ if (typeof u.mode === 'string') slim.mode = u.mode.slice(0, 40);
1045
+ if (Array.isArray(u.directories)) slim.directories = u.directories.slice(0, 20).map(d => String(d).slice(0, 500));
1046
+ this._grantedRules.push(slim);
1047
+ }
1048
+ if (this._grantedRules.length > 100) this._grantedRules = this._grantedRules.slice(-100);
1049
+ }
1050
+
1051
+ _cronList() {
1052
+ return this._sessionCrons.map(c => ({ id: String(c?.id || ''), schedule: String(c?.schedule || ''), recurring: c?.recurring === true, prompt: String(c?.prompt || '').slice(0, 400) }));
1053
+ }
1054
+
1055
+ _announceCrons() {
1056
+ this.sendEvent({ type: 'system', subtype: 'session_crons', session_id: this.sessionId, crons: this._cronList() });
1057
+ }
1058
+
1059
+ // ── temporary chat (panel sessions; lib/claude-temporary.js) ────────────────
1060
+
1061
+ // Claude Code keeps a plan in a plans folder whatever the session's options
1062
+ // say (its own, or the one the settings name). The ones this session's tool
1063
+ // calls name are removed when the chat ends.
1064
+ _noteTemporaryPlans(m) {
1065
+ const content = m.message?.content;
1066
+ if (!Array.isArray(content)) return;
1067
+ for (const block of content) {
1068
+ const file = planFileOfToolCall(block, this._tempPlansDirs);
1069
+ if (file) (this._tempPlans ||= new Set()).add(file);
1070
+ }
1071
+ }
1072
+
1073
+ // Called when the chat ends, by whichever way. The files are removed once the
1074
+ // pump of the process that wrote them has settled, not before: a write that
1075
+ // was under way would put the file back. `this.pumpPromise` is that process's
1076
+ // pump at every call (the one being ended, or the one that just ended by
1077
+ // itself). The pump settling is not the process being gone (the SDK's cleanup
1078
+ // waits two seconds for it, and the process may live five more), so the paths
1079
+ // stay owed and are swept once more when it can no longer be alive.
1080
+ _removeTemporaryPlans() {
1081
+ if (!this._tempPlans?.size) return;
1082
+ const owed = { plansDirs: this._tempPlansDirs, since: this._tempSince, ended: false, last: false };
1083
+ for (const path of this._tempPlans) _temporaryPlansOwed.set(path, owed);
1084
+ this._tempPlans.clear();
1085
+ Promise.resolve(this.pumpPromise).catch(() => {}).then(() => {
1086
+ owed.ended = true;
1087
+ _removeOwedTemporaryPlans();
1088
+ // (Not kept alive for it: a server that shuts down meanwhile sweeps everything owed, shutdownAllBridges().)
1089
+ setTimeout(() => { owed.last = true; _removeOwedTemporaryPlans(); }, TEMPORARY_PLANS_LAST_SWEEP_MS).unref?.();
1090
+ });
1091
+ }
1092
+
1093
+ // The tab left its temporary conversation (New chat): what is left of it goes,
1094
+ // and the session is an ordinary one until a temporary chat is chosen again.
1095
+ _leaveTemporary() {
1096
+ this._removeTemporaryPlans();
1097
+ this.temporary = false;
1098
+ this._tempLive = false;
1099
+ this._tempOver = false;
1100
+ this._tempSince = 0;
1101
+ }
1102
+
1103
+ // Whether this message makes, keeps or may not make the conversation a
1104
+ // temporary one. Chosen with the first message of a conversation (`temporary:
1105
+ // true`, and no session id: there is nothing to resume) and fixed from there.
1106
+ // Returns null, or { code, message } when the message must be refused.
1107
+ _takeTemporary(msg, sessionId) {
1108
+ const wants = msg.temporary === true;
1109
+ const leaves = sessionId ? (!!this.sessionId && sessionId !== this.sessionId) : !!this.sessionId;
1110
+ if (leaves) {
1111
+ // Another conversation. One that names a session and says it is temporary
1112
+ // is a temporary chat this session does not hold: it ended with its page.
1113
+ if (wants && sessionId) return { code: 'temporary_ended', message: TEMPORARY_ENDED };
1114
+ if (this.temporary) this._leaveTemporary();
1115
+ this.temporary = wants;
1116
+ return null;
1117
+ }
1118
+ // The same conversation. Nothing has started for it yet (at most a process
1119
+ // kept ready by a warm start): the latest word decides.
1120
+ if (!this._everStarted && !this.sessionId) {
1121
+ if (this.temporary && !wants) this._leaveTemporary();
1122
+ this.temporary = wants;
1123
+ return null;
1124
+ }
1125
+ // It has started: what it is does not change.
1126
+ if (this.temporary) return this._tempOver ? { code: 'temporary_ended', message: TEMPORARY_ENDED } : null;
1127
+ if (!wants) return null;
1128
+ if (this._everStarted) return { code: 'temporary_refused', message: TEMPORARY_REFUSED };
1129
+ // A session id this session never ran, said to be temporary: a temporary
1130
+ // chat from before the server restarted or the page came back. Over.
1131
+ return { code: 'temporary_ended', message: TEMPORARY_ENDED };
1132
+ }
1133
+
1134
+ // What in this message would restart a live temporary chat's process (the
1135
+ // conditions of _handleQuery's restart, judged before anything is applied).
1136
+ _temporaryRestartReason(msg, { cwd, effort, account }) {
1137
+ if (cwd && this.cwd && cwd !== this.cwd) return 'the project folder';
1138
+ if (effort && effort !== this.effort && typeof this.q?.applyFlagSettings !== 'function') return 'the effort level';
1139
+ if (msg.typesafe !== undefined && (msg.typesafe === 'off') !== !!this.typesafeOff) return 'the judgment setting';
1140
+ if (msg.accountId !== undefined && account !== (this.accountId || '')) return 'the Claude account';
1141
+ if (msg.toolPolicy !== undefined && (TOOL_POLICY_DISALLOW[msg.toolPolicy] ? msg.toolPolicy : null) !== (this.toolPolicy || null)) return 'the tool policy';
1142
+ if (msg.session !== undefined && startSignature(normalizePanelSession(msg.session)) !== startSignature(this.panelSession || normalizePanelSession(null))) return 'the session settings';
1143
+ return '';
1144
+ }
1145
+
1146
+ // A temporary chat cannot be resumed, so a stalled stream is not answered
1147
+ // with a new process: the turn is stopped and the tab is told. A process
1148
+ // that does not even take the stop is gone, and the chat with it.
1149
+ _stallTemporary() {
1150
+ log('stall in a temporary chat: stopping the turn, no restart');
1151
+ this.lastEventTime = Date.now();
1152
+ this.swallowResults++;
1153
+ this.swallowDeadline = Date.now() + 15_000;
1154
+ const q = this.q;
1155
+ let timer = null;
1156
+ const deadline = new Promise(r => { timer = setTimeout(() => r('timeout'), 10_000); timer.unref?.(); });
1157
+ Promise.race([q.interrupt().then(() => 'ok', () => 'failed'), deadline]).then((how) => {
1158
+ clearTimeout(timer);
1159
+ if (this.destroyed || this.q !== q) return;
1160
+ this.inTurn = false;
1161
+ this.pendingTurns = 0;
1162
+ this.lastActivity = Date.now();
1163
+ if (how === 'ok') {
1164
+ this.send({ type: 'error', message: 'The stream stalled, so the turn was stopped. A temporary chat cannot be restarted: send the message again.' });
1165
+ } else {
1166
+ this._endQuery({ graceful: false });
1167
+ this.send({ type: 'error', code: 'temporary_ended', message: TEMPORARY_ENDED });
1168
+ }
1169
+ this.sendDone(-1);
1170
+ });
1171
+ }
1172
+
1173
+ // True while the CLI has work it will come back to without a new prompt.
1174
+ _hasPendingWork() {
1175
+ return this._bgTasks.size > 0 || this._sessionCrons.length > 0;
1176
+ }
1177
+
1178
+ _finishTurnWithError(message) {
1179
+ this._denyAllPending('Session error');
1180
+ this.inTurn = false;
1181
+ this.pendingTurns = 0;
1182
+ this.send({ type: 'error', message });
1183
+ this.sendDone(-1);
1184
+ }
1185
+
1186
+ async _endQuery({ graceful = true } = {}) {
1187
+ const q = this.q;
1188
+ this._closeWarm();
1189
+ if (!q) return;
1190
+ // Panel sessions: the process this call ends is the one it found, held here
1191
+ // before the wait below (its input, its pump, its abort controller). A
1192
+ // process started while this one is closing (a recovery, a prompt after an
1193
+ // idle reap) belongs to whoever started it and is never aborted from here.
1194
+ // Any other session reads its own fields at each step, as it always did.
1195
+ const own = this.panel ? { input: this.input, pumpPromise: this.pumpPromise, abortController: this.abortController } : this;
1196
+ // (Panel sessions: a process that may be in Bypass may be until it has really
1197
+ // ended, which is the end of the wait below, not this call. See _mayBypass.)
1198
+ const closing = this.panel && this._cli?.q === q && this._cli.may === true;
1199
+ if (closing) this._closingBypass = (this._closingBypass || 0) + 1;
1200
+ this.q = null;
1201
+ this._onProcessGone();
1202
+ this._denyAllPending('Session ending');
1203
+ // The dying query's in-flight turn can never deliver a result (pump guard
1204
+ // drops late ones) — reset accounting so recreation paths start clean.
1205
+ this.inTurn = false;
1206
+ this.pendingTurns = 0;
1207
+ this.swallowResults = 0;
1208
+ try { own.input?.close(); } catch {}
1209
+ if (graceful && own.pumpPromise) {
1210
+ await Promise.race([own.pumpPromise, new Promise(r => setTimeout(r, 5000))]);
1211
+ }
1212
+ try { own.abortController?.abort(); } catch {}
1213
+ if (closing) { this._closingBypass--; this._factsSettled(); }
1214
+ }
1215
+
1216
+ _recreateQuery(continuationPrompt) {
1217
+ const sid = this.sessionId;
1218
+ return this._endQuery({ graceful: false }).then(() => {
1219
+ if (this.destroyed) return;
1220
+ this.sessionId = sid;
1221
+ this.ensureQuery();
1222
+ if (this._announceRefusedStart()) return;
1223
+ if (continuationPrompt) this._pushUserText(continuationPrompt);
1224
+ });
1225
+ }
1226
+
1227
+ _recoverLostSession() {
1228
+ log('session not found — retrying as new conversation');
1229
+ const prompt = this.lastPrompt;
1230
+ this.sessionId = null;
1231
+ this.sendEvent({ type: 'system', subtype: 'session_reset', message: 'Previous session was deleted. Starting fresh conversation.' });
1232
+ this._endQuery({ graceful: false }).then(() => {
1233
+ if (this.destroyed) return;
1234
+ this.sessionId = null;
1235
+ this.ensureQuery();
1236
+ if (this._announceRefusedStart()) return;
1237
+ if (prompt) this._pushUserText(prompt);
1238
+ });
1239
+ }
1240
+
1241
+ /**
1242
+ * The user's Claude Code would not start.
1243
+ *
1244
+ * The SDK's own message for this always blames a musl/glibc mismatch, which is
1245
+ * only ever right on Linux — elsewhere the cause is a missing execute bit, a
1246
+ * quarantined download, a build for another processor or a file that is gone.
1247
+ * There is no other runtime to move to (SynaBun carries none), so the turn
1248
+ * ends with what is wrong with that installation and how to fix it.
1249
+ */
1250
+ _recoverNativeRuntime(err) {
1251
+ const dx = diagnoseLaunchFailure({ err, executable: this._claudeExecutable });
1252
+ log(`Claude Code failed to launch: kind=${dx.kind} executable=${this._claudeExecutable || 'unset'}`);
1253
+ this._finishTurnWithError(dx.message);
1254
+ }
1255
+
1256
+ _pushUserText(text, images, { human = false } = {}) {
1257
+ const content = [];
1258
+ if (images?.length) {
1259
+ for (const img of images) {
1260
+ if (!img?.base64) continue;
1261
+ content.push({
1262
+ type: 'image',
1263
+ source: { type: 'base64', media_type: img.mediaType || 'image/png', data: img.base64 },
1264
+ });
1265
+ }
1266
+ }
1267
+ if (text) content.push({ type: 'text', text });
1268
+ if (!content.length) return false;
1269
+ this.ensureQuery();
1270
+ if (this._announceRefusedStart()) return false;
1271
+ const ok = this.input.push({
1272
+ type: 'user',
1273
+ message: { role: 'user', content },
1274
+ parent_tool_use_id: null,
1275
+ session_id: this.sessionId || '',
1276
+ // Only a prompt the user typed in the panel is stamped as human input
1277
+ // (never a queued, replayed or generated one).
1278
+ ...(human ? { origin: { kind: 'human' } } : {}),
1279
+ });
1280
+ if (ok) {
1281
+ this.inTurn = true;
1282
+ this.pendingTurns++;
1283
+ this.lastEventTime = Date.now();
1284
+ this.lastActivity = Date.now();
1285
+ this._lastPushAt = Date.now();
1286
+ }
1287
+ return ok;
1288
+ }
1289
+
1290
+ // ── translation: SDKMessage → wire events ─────────────────────────────────
1291
+
1292
+ _translate(m) {
1293
+ switch (m.type) {
1294
+ case 'system': {
1295
+ if (m.subtype === 'init') {
1296
+ this.bootComplete = true;
1297
+ if (m.session_id) {
1298
+ const prev = this.sessionId;
1299
+ this.sessionId = m.session_id;
1300
+ if (prev !== this.sessionId) log('sessionId from init:', this.sessionId);
1301
+ }
1302
+ // (A temporary chat: from here its conversation exists, in this process only.)
1303
+ if (this.temporary) this._tempLive = true;
1304
+ // Panel sessions: the mode the CLI says it is in (see _reportedMode).
1305
+ const told = this.panel ? this._reportedMode(m) : null;
1306
+ this.sendEvent(told ? told.event : m);
1307
+ if (told?.then) this.sendEvent(told.then);
1308
+ this._announceCapabilities();
1309
+ return;
1310
+ }
1311
+ // Panel sessions: a mode change the CLI reports between two inits.
1312
+ if (m.subtype === 'status' && this.panel && m.permissionMode) {
1313
+ const told = this._reportedMode(m);
1314
+ this.sendEvent(told.event);
1315
+ if (told.then) this.sendEvent(told.then);
1316
+ return;
1317
+ }
1318
+ if (m.subtype === 'background_tasks_changed') this._setBackgroundTasks(m.tasks);
1319
+ else if (m.subtype === 'task_notification') this._settleBackgroundAgent(m);
1320
+ else if (m.subtype === 'session_state_changed' && this.panel) this._noteSessionState(m.state);
1321
+ // Background work reporting in is activity: without this, a reaper tick
1322
+ // between "last task finished" and the turn the CLI starts to handle it
1323
+ // would end the session under that turn.
1324
+ if (m.subtype === 'background_tasks_changed' || String(m.subtype || '').startsWith('task_')) {
1325
+ this.lastActivity = Date.now();
1326
+ }
1327
+ // compact_boundary and any future system subtypes pass through
1328
+ this.sendEvent(m);
1329
+ return;
1330
+ }
1331
+ case 'stream_event': {
1332
+ this._noteMainThreadOutput(m);
1333
+ this._forwardStreamEvent(m);
1334
+ return;
1335
+ }
1336
+ case 'assistant': {
1337
+ this.bootComplete = true;
1338
+ this._noteMainThreadOutput(m);
1339
+ this._flushDeltas();
1340
+ this._synthesizeSubagentStarts(m);
1341
+ if (this.temporary) this._noteTemporaryPlans(m);
1342
+ this.sendEvent({ ...m });
1343
+ return;
1344
+ }
1345
+ case 'user': {
1346
+ if (!m.parent_tool_use_id) this._outputSinceResult = true;
1347
+ this._flushDeltas();
1348
+ this._synthesizeSubagentStops(m);
1349
+ this.sendEvent({ ...m });
1350
+ // The frontend renders tool results from synthetic `tool_result` events
1351
+ // (it has no handler for tool_result blocks inside user messages) — emit
1352
+ // one per block so existing updateToolResult() keeps working.
1353
+ const content = m.message?.content;
1354
+ if (Array.isArray(content)) {
1355
+ for (const block of content) {
1356
+ if (block?.type === 'tool_result') {
1357
+ this.sendEvent({
1358
+ type: 'tool_result',
1359
+ tool_use_id: block.tool_use_id,
1360
+ content: block.content,
1361
+ is_error: !!block.is_error,
1362
+ parent_tool_use_id: m.parent_tool_use_id ?? null,
1363
+ session_id: m.session_id,
1364
+ });
1365
+ }
1366
+ }
1367
+ }
1368
+ return;
1369
+ }
1370
+ case 'result': {
1371
+ this._flushDeltas();
1372
+ this._handleResult(m);
1373
+ return;
1374
+ }
1375
+ default:
1376
+ this.sendEvent(m);
1377
+ }
1378
+ }
1379
+
1380
+ // Main-thread (not subagent) output means the CLI is in a turn. Most turns start
1381
+ // with a prompt we pushed; the rest the CLI starts itself — a background agent
1382
+ // finished, a scheduled wakeup fired. Those have to be opened here, or the
1383
+ // reaper, detach and the stall watchdog treat a working session as idle and the
1384
+ // panel shows nothing running.
1385
+ _noteMainThreadOutput(m) {
1386
+ if (m.parent_tool_use_id) return;
1387
+ this._outputSinceResult = true;
1388
+ this.lastActivity = Date.now();
1389
+ if (this.inTurn) return;
1390
+ // The tail of a turn we just interrupted is still draining.
1391
+ if (this.swallowResults > 0 && Date.now() < this.swallowDeadline) return;
1392
+ this.inTurn = true;
1393
+ this.pendingTurns = Math.max(this.pendingTurns, 1);
1394
+ this.lastEventTime = Date.now();
1395
+ log(`turn started by the CLI for session ${this.sessionId || '(new)'}`);
1396
+ this.send({ type: 'turn_started' });
1397
+ }
1398
+
1399
+ // The CLI's own word on whether it is in a turn. `idle` comes after a turn's
1400
+ // result: if this session still counts a turn open then, its count is off (the
1401
+ // same drift the stall watchdog finds much later). Close it after a short
1402
+ // wait, under the watchdog's own conditions.
1403
+ _noteSessionState(state) {
1404
+ if (state !== 'idle' || !this.inTurn) return;
1405
+ clearTimeout(this._idleCheck);
1406
+ this._idleCheck = setTimeout(() => {
1407
+ this._idleCheck = null;
1408
+ if (this.destroyed || !this.q || !this.inTurn || this.pendingPerms.size > 0) return;
1409
+ if (this._lastResultAt > this._lastPushAt && !this._outputSinceResult && !this._resultOwesTurn) {
1410
+ log('session reported idle with a turn still counted open; closing it');
1411
+ this.inTurn = false;
1412
+ this.pendingTurns = 0;
1413
+ this.lastActivity = Date.now();
1414
+ this.sendDone(0);
1415
+ }
1416
+ }, 2000);
1417
+ this._idleCheck.unref?.();
1418
+ }
1419
+
1420
+ // background_tasks_changed carries the full set (replace, never merge). Ambient
1421
+ // tasks count: a Monitor the user asked for is ambient, and ending the Query
1422
+ // would kill it. The panel leaves them out of its indicator.
1423
+ _setBackgroundTasks(tasks) {
1424
+ this._bgTasks.clear();
1425
+ for (const t of Array.isArray(tasks) ? tasks : []) {
1426
+ if (!t?.task_id) continue;
1427
+ this._bgTasks.set(t.task_id, {
1428
+ task_id: t.task_id,
1429
+ task_type: t.task_type || '',
1430
+ description: t.description || '',
1431
+ ambient: !!t.ambient,
1432
+ });
1433
+ }
1434
+ }
1435
+
1436
+ // A background agent reported back: close its card with the real outcome.
1437
+ _settleBackgroundAgent(m) {
1438
+ const id = m.tool_use_id;
1439
+ if (!id || !this._bgAgentIds.delete(id)) return;
1440
+ this.sendEvent({
1441
+ type: 'subagent', subtype: 'stop', tool_use_id: id,
1442
+ is_error: m.status !== 'completed', status: m.status || '',
1443
+ summary: typeof m.summary === 'string' ? m.summary : '',
1444
+ session_id: this.sessionId,
1445
+ });
1446
+ }
1447
+
1448
+ _recordCost(m) {
1449
+ // Cost: total_cost_usd is cumulative for the process; on resumed sessions it
1450
+ // may or may not include pre-resume history. Self-calibrating baseline:
1451
+ if (typeof m.total_cost_usd === 'number' && m.total_cost_usd > 0) {
1452
+ const sid = m.session_id || this.sessionId;
1453
+ if (sid) {
1454
+ if (!this.costBySession.has(sid)) {
1455
+ // (A temporary chat has no stored cost: no record names it.)
1456
+ const stored = this.temporary ? 0 : (() => { try { return deps.getSessionCost(sid) || 0; } catch { return 0; } })();
1457
+ this.costBySession.set(sid, stored);
1458
+ }
1459
+ let base = this.costBySession.get(sid) || 0;
1460
+ let delta = m.total_cost_usd - base;
1461
+ if (delta < 0) {
1462
+ // Process-lifetime semantics (doesn't include resumed history) — recalibrate.
1463
+ log(`cost-semantics=process sid=${sid} base=${base} reported=${m.total_cost_usd}`);
1464
+ base = 0;
1465
+ delta = m.total_cost_usd;
1466
+ }
1467
+ this.costBySession.set(sid, m.total_cost_usd);
1468
+ // (A temporary chat: the money is counted, the session is not named.)
1469
+ if (delta > 0) { try { deps.addCost(delta, this.temporary ? null : sid); } catch {} }
1470
+ }
1471
+ }
1472
+ }
1473
+
1474
+ _handleResult(m) {
1475
+ // Interrupt-produced result (abort / stall recovery): the client already got
1476
+ // its terminal signal — forward the event + cost, but skip turn accounting.
1477
+ if (this.swallowResults > 0 && Date.now() < this.swallowDeadline) {
1478
+ this.swallowResults--;
1479
+ if (m.session_id) this.sessionId = m.session_id;
1480
+ this._recordCost(m);
1481
+ this.sendEvent({ ...m });
1482
+ return;
1483
+ }
1484
+ this.swallowResults = 0; // expired entries never linger past a real result
1485
+
1486
+ // queued_turn_count is how many sends the CLI still holds. 0 means no more
1487
+ // results are coming: the CLI merges prompts sent close together into one
1488
+ // turn, so counting one result per push could leave a turn open forever.
1489
+ this.pendingTurns = m.queued_turn_count === 0 ? 0 : Math.max(0, this.pendingTurns - 1);
1490
+ this.inTurn = this.pendingTurns > 0;
1491
+ this._lastResultAt = Date.now();
1492
+ this._resultOwesTurn = m.queued_turn_count > 0;
1493
+ this._outputSinceResult = false;
1494
+ this.stallRetries = 0;
1495
+ this.lastActivity = Date.now();
1496
+
1497
+ if (m.subtype === 'error_during_execution' || m.subtype === 'error') {
1498
+ const errMsg = (m.errors && m.errors[0]) || m.error || m.result || 'unknown';
1499
+ log('CLI error result:', String(errMsg).slice(0, 200));
1500
+ if (typeof errMsg === 'string' && errMsg.includes('No conversation found')) {
1501
+ this._recoverLostSession();
1502
+ return;
1503
+ }
1504
+ }
1505
+ if (m.session_id) this.sessionId = m.session_id;
1506
+ this._recordCost(m);
1507
+ this.sendEvent({ ...m });
1508
+ this.sendDone(m.subtype === 'success' ? 0 : 1);
1509
+ }
1510
+
1511
+ // Coalesce text/thinking deltas per content block; everything else forwards raw.
1512
+ _forwardStreamEvent(m) {
1513
+ const ev = m.event;
1514
+ const isTextDelta = ev?.type === 'content_block_delta' &&
1515
+ (ev.delta?.type === 'text_delta' || ev.delta?.type === 'thinking_delta');
1516
+ if (!isTextDelta) {
1517
+ this._flushDeltas();
1518
+ this._sendStreamEvent(m);
1519
+ return;
1520
+ }
1521
+ const key = `${m.parent_tool_use_id || ''}:${ev.index}`;
1522
+ let slot = this._deltaBuf.get(key);
1523
+ if (!slot) {
1524
+ slot = { m, text: '', kind: ev.delta.type, timer: null };
1525
+ this._deltaBuf.set(key, slot);
1526
+ slot.timer = setTimeout(() => this._flushDelta(key), DELTA_FLUSH_MS);
1527
+ }
1528
+ slot.text += ev.delta.type === 'text_delta' ? (ev.delta.text || '') : (ev.delta.thinking || '');
1529
+ }
1530
+
1531
+ _flushDelta(key) {
1532
+ const slot = this._deltaBuf.get(key);
1533
+ if (!slot) return;
1534
+ this._deltaBuf.delete(key);
1535
+ clearTimeout(slot.timer);
1536
+ if (!slot.text) return;
1537
+ const ev = slot.m.event;
1538
+ const merged = {
1539
+ ...slot.m,
1540
+ event: {
1541
+ ...ev,
1542
+ delta: slot.kind === 'text_delta'
1543
+ ? { ...ev.delta, text: slot.text }
1544
+ : { ...ev.delta, thinking: slot.text },
1545
+ },
1546
+ };
1547
+ this._sendStreamEvent(merged);
1548
+ }
1549
+
1550
+ _flushDeltas() {
1551
+ for (const key of [...this._deltaBuf.keys()]) this._flushDelta(key);
1552
+ }
1553
+
1554
+ _sendStreamEvent(m) {
1555
+ // Drop live deltas under WS backpressure — final assistant events carry the content.
1556
+ if (!this.orphanBuffer && this.ws && this.ws.bufferedAmount > WS_BACKPRESSURE_BYTES) return;
1557
+ this.sendEvent({ ...m });
1558
+ }
1559
+
1560
+ // Synthetic subagent lifecycle events: dumb-simple signal for the frontend
1561
+ // (which also gets the full nested feed via parent_tool_use_id passthrough).
1562
+ _synthesizeSubagentStarts(m) {
1563
+ const content = m.message?.content;
1564
+ if (!Array.isArray(content)) return;
1565
+ for (const block of content) {
1566
+ if (block?.type === 'tool_use' && (block.name === 'Task' || block.name === 'Agent')) {
1567
+ this._taskIds = this._taskIds || new Set();
1568
+ this._taskIds.add(block.id);
1569
+ this.sendEvent({
1570
+ type: 'subagent', subtype: 'start', tool_use_id: block.id,
1571
+ description: block.input?.description || '',
1572
+ subagent_type: block.input?.subagent_type || '',
1573
+ session_id: this.sessionId,
1574
+ });
1575
+ }
1576
+ }
1577
+ }
1578
+
1579
+ _synthesizeSubagentStops(m) {
1580
+ if (!this._taskIds?.size) return;
1581
+ const content = m.message?.content;
1582
+ if (!Array.isArray(content)) return;
1583
+ // A Task/Agent call that went to the background returns at once with a
1584
+ // placeholder result; the agent keeps running and reports through
1585
+ // task_notification (_settleBackgroundAgent) — or dies with the process.
1586
+ const launchStatus = m.tool_use_result?.status;
1587
+ const launched = launchStatus === 'async_launched' || launchStatus === 'remote_launched';
1588
+ for (const block of content) {
1589
+ if (block?.type === 'tool_result' && this._taskIds.has(block.tool_use_id)) {
1590
+ this._taskIds.delete(block.tool_use_id);
1591
+ if (launched && !block.is_error) {
1592
+ this._bgAgentIds.add(block.tool_use_id);
1593
+ this.sendEvent({
1594
+ type: 'subagent', subtype: 'background', tool_use_id: block.tool_use_id,
1595
+ session_id: this.sessionId,
1596
+ });
1597
+ continue;
1598
+ }
1599
+ this.sendEvent({
1600
+ type: 'subagent', subtype: 'stop', tool_use_id: block.tool_use_id,
1601
+ is_error: !!block.is_error, session_id: this.sessionId,
1602
+ });
1603
+ }
1604
+ }
1605
+ }
1606
+
1607
+ async _announceCapabilities() {
1608
+ const q = this.q;
1609
+ if (!q) return;
1610
+ try {
1611
+ const commands = await q.supportedCommands();
1612
+ if (this.q === q && Array.isArray(commands)) {
1613
+ this.sendEvent({ type: 'system', subtype: 'commands_list', commands });
1614
+ }
1615
+ } catch (err) { log('supportedCommands failed:', err.message); }
1616
+ try {
1617
+ const status = await q.mcpServerStatus();
1618
+ if (this.q === q && Array.isArray(status)) {
1619
+ this.sendEvent({ type: 'system', subtype: 'mcp_status', servers: status });
1620
+ }
1621
+ } catch { /* optional */ }
1622
+ // Panel sessions: what the CLI answered at initialize (account, output styles,
1623
+ // models, agents), once per Query. init repeats every turn; this does not change.
1624
+ if (this.panel && this._infoAnnouncedFor !== q && typeof q.initializationResult === 'function') {
1625
+ this._infoAnnouncedFor = q;
1626
+ try {
1627
+ const r = await q.initializationResult();
1628
+ if (this.q === q && r && typeof r === 'object') {
1629
+ this.sendEvent({
1630
+ type: 'system', subtype: 'session_info',
1631
+ info: {
1632
+ account: r.account || null,
1633
+ outputStyle: r.output_style || '',
1634
+ availableOutputStyles: Array.isArray(r.available_output_styles) ? r.available_output_styles : [],
1635
+ models: Array.isArray(r.models) ? r.models : [],
1636
+ agents: Array.isArray(r.agents) ? r.agents : [],
1637
+ fastModeState: r.fast_mode_state || '',
1638
+ },
1639
+ });
1640
+ }
1641
+ } catch (err) { log('initializationResult failed:', err.message); }
1642
+ }
1643
+ }
1644
+
1645
+ // ── the latest statement of the mode wins ────────────────────────────────────
1646
+
1647
+ // The mode is stated by a `set_permission_mode`, by a query that names one (or
1648
+ // leaves it to the user's settings) and by the answer to a plan card. A switch
1649
+ // that is still on its way when the next statement is made (the plan
1650
+ // approval's own, 50 ms later; a call the CLI has not answered yet) is void:
1651
+ // it is not sent, it assigns nothing, it announces nothing and its failure
1652
+ // puts no earlier mode back. So is one whose process was ended or replaced
1653
+ // meanwhile: the next process starts in the mode the session holds then.
1654
+ // _stateMode() counts a statement and returns its number; _modeSwitchStands()
1655
+ // says whether a switch made for statement `stamp` on process `q` still holds.
1656
+ _stateMode() {
1657
+ this._modeStatements = (this._modeStatements || 0) + 1;
1658
+ return this._modeStatements;
1659
+ }
1660
+
1661
+ _modeSwitchStands(q, stamp) {
1662
+ return !this.destroyed && !!q && this.q === q && this._modeStatements === stamp;
1663
+ }
1664
+
1665
+ // ── numbered statements (panel sessions) ─────────────────────────────────────
1666
+
1667
+ // The page owns the tab's chosen mode and this session owns the mode it is
1668
+ // really in. The page numbers every statement it makes of the tab's mode
1669
+ // (`modeSeq`, growing per tab: a pick, a message that names a mode, a plan
1670
+ // card answer, a restatement). The number of the latest one applied goes back
1671
+ // with every report of the mode (init, status, mode_changed, the answer to
1672
+ // reattach), so the page can tell a report that is older than its own latest
1673
+ // statement and ignore it. Messages are not all handled in the order sent
1674
+ // (some wait their turn, a card's answer does not): a statement older than
1675
+ // the one applied is not applied. Returns false for such a statement. A
1676
+ // message without a number (a page that was not reloaded, any other host) is
1677
+ // applied as it always was.
1678
+ _takeStatement(seq) {
1679
+ if (!this.panel || !Number.isSafeInteger(seq) || seq < 1) return true;
1680
+ if (this._pageSeq !== undefined && seq < this._pageSeq) return false;
1681
+ this._pageSeq = seq;
1682
+ return true;
1683
+ }
1684
+
1685
+ /**
1686
+ * A mode report as it goes to the page, once the page numbers its statements:
1687
+ * the mode the CLI is in (not the one the event was built for, when the switch
1688
+ * to that one is still under way: then `switching` names it), the number of
1689
+ * the latest statement applied, and whether the session may be in Bypass.
1690
+ */
1691
+ _numbered(ev) {
1692
+ return this.panel && this._pageSeq !== undefined ? { ...ev, ...this._modeFacts(ev.mode) } : ev;
1693
+ }
1694
+
1695
+ /** A numbered statement older than the one applied was not applied: the page is told the number this session holds, takes it and says its mode again. */
1696
+ _statementDropped() {
1697
+ if (this.panel && this._pageSeq !== undefined) this.sendEvent({ type: 'mode_behind', modeSeq: this._pageSeq });
1698
+ }
1699
+
1700
+ // ── what is, and whether it may be Bypass (panel sessions) ───────────────────
1701
+
1702
+ // Two records, never one for the other. `this.permissionMode` is the mode the
1703
+ // tab stated: what the next process starts in, and what a switch is sent for.
1704
+ // `this._cli` is what the current process is in: the mode the CLI last
1705
+ // acknowledged (a setPermissionMode call that resolved) or reported, never one
1706
+ // that was only asked for. Reports to the page carry the second; a switch that
1707
+ // is under way is named beside it (`switching`), and one that failed or was
1708
+ // refused leaves it where it is and is told (`failed`).
1709
+ // "May be in Bypass" is a fact of its own that errs toward warning. A process
1710
+ // may be in Bypass from the moment a switch into it is sent, or it is launched
1711
+ // in it, or it says so itself. Only three things end that:
1712
+ // - the CLI acknowledged a switch to another mode that was sent after it
1713
+ // (`at`: the count of switches sent when the session last entered, or may
1714
+ // have entered, Bypass);
1715
+ // - the process has ended;
1716
+ // - the process says it is in another mode and this session never sent it
1717
+ // into Bypass itself (`sends`): then whatever Bypass it was in it entered
1718
+ // at its launch or by itself, and its own messages are in order.
1719
+ // Nothing else does: not a switch away that was asked for and not answered,
1720
+ // not one that failed, and not a report of the CLI's after a switch into
1721
+ // Bypass of this session's own, which may have been written before it landed
1722
+ // (an acknowledgement and a message do not arrive in the order written).
1723
+ _cliOf(q) {
1724
+ if (this._cli?.q !== q) this._cli = { q, mode: '', may: false, at: 0, sends: 0 };
1725
+ return this._cli;
1726
+ }
1727
+
1728
+ // (Also while the mode the tab stated is Bypass: a switch into it may still be
1729
+ // to send, and the next process starts in it. And while a process kept ready
1730
+ // for the next message lives that was started in Bypass: every process this
1731
+ // session owns counts, the live one, a closing one and the warm one.)
1732
+ _mayBypass() {
1733
+ const c = this._cli;
1734
+ return (!!this.q && c?.q === this.q && c.may === true) || this._warm?.bypass === true || (this._closingBypass || 0) > 0 || this.permissionMode === BYPASS;
1735
+ }
1736
+
1737
+ // The facts every numbered mode report carries. `named`: the mode the report
1738
+ // is about (the tab's stated mode by default). Whenever the CLI is not in that
1739
+ // mode the report says both: `mode` is where the CLI is, `switching` the mode
1740
+ // the session holds for the tab (the switch to it is under way, still to be
1741
+ // sent, or failed and is sent again with the next message). The page never
1742
+ // has to take the first for the second.
1743
+ // The statement number orders what the page says; these facts need an order
1744
+ // of their own, because several reports go out between two statements and a
1745
+ // report can reach the page long after it was written (it waits in the buffer
1746
+ // of a detached session and is replayed after the answer to reattach, which
1747
+ // is written later and sent first). `modeRev` only grows for this session and
1748
+ // is given when the report is created, so a buffered message keeps the one it
1749
+ // was created with; `modeRevOf` names this session object, whose count it is
1750
+ // (a session created anew, after a server restart or for a tab that had
1751
+ // none, counts from 1 again under another id). The page lets a report with a
1752
+ // lower revision of the same session set the fact and nothing else.
1753
+ _modeFacts(named = this.permissionMode || 'default') {
1754
+ const c = this._cli;
1755
+ const mode = (this.q && c?.q === this.q && c.mode) || named;
1756
+ this._modeRev = (this._modeRev || 0) + 1;
1757
+ const facts = { mode, modeSeq: this._pageSeq, mayBypass: this._mayBypass(), modeRev: this._modeRev, modeRevOf: (this._modeRevOf ||= randomUUID()) };
1758
+ if (named && mode !== named) facts.switching = named;
1759
+ this._toldFacts = `${mode}|${facts.mayBypass}|${facts.switching || ''}`;
1760
+ return facts;
1761
+ }
1762
+
1763
+ // The facts changed and nothing says so by itself (an acknowledgement nobody
1764
+ // announces, a switch that failed, the process ending): the page is told with
1765
+ // a `mode_state`, which is never a line in its transcript. `extra`: `failed`.
1766
+ _tellFacts(extra) {
1767
+ if (this.destroyed || !this.panel || this._pageSeq === undefined) return;
1768
+ const told = this._toldFacts;
1769
+ const facts = this._modeFacts();
1770
+ // (A page that was told nothing yet is told nothing calm: its first report says it.)
1771
+ if (!extra && (this._toldFacts === told || (told === undefined && !facts.mayBypass && !facts.switching))) return;
1772
+ this.sendEvent({ type: 'mode_state', ...facts, ...(extra || {}) });
1773
+ }
1774
+
1775
+ /** Checked once whoever is handling the change had its say (the handler that awaited the switch announces it itself). */
1776
+ _factsSettled() {
1777
+ if (!this.panel || this._factsTimer || this.destroyed) return;
1778
+ this._factsTimer = setTimeout(() => { this._factsTimer = null; this._tellFacts(); }, 0);
1779
+ this._factsTimer.unref?.();
1780
+ }
1781
+
1782
+ // A switch of this session's own on the CLI, counted while the CLI has not
1783
+ // answered it. What the CLI reports in that time was written before the
1784
+ // switch landed: it is that switch's echo or older news, never a decision of
1785
+ // the CLI's that came after it (see _reportedMode). This replaces judging by
1786
+ // how recent the switch was.
1787
+ _switchMode(q, mode) {
1788
+ if (this._modeCalls?.q !== q) this._modeCalls = { q, open: 0, sent: 0 };
1789
+ const calls = this._modeCalls;
1790
+ calls.open++;
1791
+ calls.sent++;
1792
+ // From the moment a switch into Bypass is sent the process may be in it; the
1793
+ // CLI acknowledging a switch to another mode that was sent later ends that.
1794
+ const cli = this._cliOf(q);
1795
+ const n = calls.sent;
1796
+ if (mode === BYPASS) { cli.may = true; cli.at = n; cli.sends++; }
1797
+ // (The caller gets the CLI's own promise: counting adds no step to its answer.)
1798
+ const call = q.setPermissionMode(mode);
1799
+ call.then(() => {
1800
+ calls.open--;
1801
+ cli.mode = mode;
1802
+ if (mode !== BYPASS && n > cli.at) cli.may = false;
1803
+ this._factsSettled();
1804
+ }, () => { calls.open--; this._factsSettled(); });
1805
+ return call;
1806
+ }
1807
+
1808
+ _modeSwitchUnderWay() {
1809
+ if (!this.q) return false;
1810
+ if (this._modeCalls?.q === this.q && this._modeCalls.open > 0) return true;
1811
+ // A plan approval's switch that waits for its 50 ms (see _handleExitPlan).
1812
+ const due = this._planSwitchDue;
1813
+ return !!due && due.q === this.q && due.stamp === this._modeStatements;
1814
+ }
1815
+
1816
+ /** Whether this session switched the mode of its current process since it started. */
1817
+ _switchedSinceStart() {
1818
+ return this._modeCalls?.q === this.q && this._modeCalls.sent > 0;
1819
+ }
1820
+
1821
+ // The tab goes back to following the user's settings: it never picked a mode
1822
+ // and left plan mode. The next process starts in the mode they name
1823
+ // (permissions.defaultMode, which only the CLI reads). A live one is switched
1824
+ // to that mode when this process showed it: the first init of a process that
1825
+ // started from the settings. A process that started in a stated mode never
1826
+ // said what the settings name, so it runs in Default until it is restarted,
1827
+ // and the tab is told. Returns { mode, reason } ('' when there is no process).
1828
+ _returnToSettings() {
1829
+ this._modeFromSettings = true;
1830
+ const q = this.q;
1831
+ if (!q) return { mode: '', reason: '' };
1832
+ const known = this._settingsModeOf?.q === q ? this._settingsModeOf.mode : '';
1833
+ const mode = known || 'default';
1834
+ this.permissionMode = mode;
1835
+ if (mode === BYPASS) this._bypassHeld = true;
1836
+ return { mode, reason: known ? '' : SETTINGS_MODE_UNKNOWN };
1837
+ }
1838
+
1839
+ // ── permission mode (panel sessions) ─────────────────────────────────────────
1840
+
1841
+ // The CLI's own word on the permission mode: every turn's init, and a status
1842
+ // message when it changes in between. `this.permissionMode` is the mode the
1843
+ // user put the tab in; the CLI reporting Bypass is accepted only when that is
1844
+ // Bypass, or when this is the first init of a process that left the choice to
1845
+ // the user's settings (permissions.defaultMode; the CLI itself drops one that a
1846
+ // repository committed) and that this session has not switched since. Any
1847
+ // other Bypass is not the session's mode: it is switched back at once, and the
1848
+ // event goes on with the tab's own mode, so the page never takes it for a
1849
+ // choice. Returns { event, then? }.
1850
+ // Once the page numbers its statements, what goes on to it is this session's
1851
+ // record of the mode with the number of the latest statement applied, and the
1852
+ // record follows the CLI where the CLI decided: a status with nothing of this
1853
+ // session's under way is the CLI changing its mode itself (the model entered
1854
+ // plan mode, a permission card's rule switched it), and that is the mode now.
1855
+ // An init that differs from a stated mode is a snapshot written before the
1856
+ // switch landed (the CLI says every change with a status): it is not taken.
1857
+ _reportedMode(m) {
1858
+ const reported = m.permissionMode;
1859
+ const init = m.subtype === 'init';
1860
+ const first = init && !this._startModeSeen;
1861
+ if (init) this._startModeSeen = true;
1862
+ if (!PANEL_PERMISSION_MODES.includes(reported)) return { event: m };
1863
+ const q = this.q;
1864
+ const numbered = this._pageSeq !== undefined;
1865
+ const underWay = this._modeSwitchUnderWay();
1866
+ const untouched = !this._switchedSinceStart(); // the process is still in the mode it started in, as far as this session goes
1867
+ const cli = this._cliOf(q);
1868
+ // (Numbered) the event as it goes on: what the CLI is in, the number, and whether it may be in Bypass (_modeFacts).
1869
+ const own = () => {
1870
+ if (!numbered) return m;
1871
+ const { mode, ...facts } = this._modeFacts();
1872
+ return { ...m, permissionMode: mode, ...facts };
1873
+ };
1874
+ if (reported !== BYPASS) {
1875
+ // (Numbered) written before a switch of this session's own landed.
1876
+ if (numbered && underWay) return { event: own() };
1877
+ // Whether this report says where the process is now. While the process may
1878
+ // be in a Bypass this session sent it into, it does not: it may have been
1879
+ // written before that switch landed. A process this session never sent
1880
+ // into Bypass says its own mode in order, and that ends a Bypass it
1881
+ // started in or entered by itself.
1882
+ const ordered = cli.sends === 0;
1883
+ const trusted = cli.may !== true || ordered;
1884
+ const taken = () => { cli.mode = reported; if (ordered) cli.may = false; };
1885
+ // The CLI picked the mode from the user's settings: that is the mode now.
1886
+ if (init && this._modeFromSettings && (!numbered || untouched)) {
1887
+ this.permissionMode = reported;
1888
+ if (trusted) taken();
1889
+ if (first && untouched) this._settingsModeOf = { q, mode: reported };
1890
+ return { event: own() };
1891
+ }
1892
+ // The process was started in Bypass for the tab and did not take it. (A
1893
+ // switch to Bypass sent before this init is not that: it is still on its way.)
1894
+ if (first && this._startAskedBypass && this.permissionMode === BYPASS) {
1895
+ this.permissionMode = reported;
1896
+ if (trusted) taken();
1897
+ return { event: own(), then: this._numbered({ type: 'mode_changed', mode: reported, reason: BYPASS_NOT_TAKEN }) };
1898
+ }
1899
+ // (Numbered) the CLI changed its mode itself: this session's record follows.
1900
+ // Not while the process may be in a Bypass of this session's own sending:
1901
+ // then the tab's Bypass stays the tab's, with one exception. Plan mode is
1902
+ // something the session enters by itself (the model's EnterPlanMode) and
1903
+ // leaves back to the tab's own mode, so it is followed; the session may
1904
+ // still be in Bypass all the same, and is said to be.
1905
+ if (numbered && !init) {
1906
+ if (trusted) { this.permissionMode = reported; taken(); }
1907
+ else if (reported === 'plan') { this.permissionMode = reported; cli.mode = reported; }
1908
+ }
1909
+ return { event: own() };
1910
+ }
1911
+ // The CLI says it is in Bypass: from here it may be, whatever was sent to it
1912
+ // before (only a switch away that is sent after this ends it).
1913
+ cli.may = true;
1914
+ cli.at = this._modeCalls?.q === q ? this._modeCalls.sent : 0;
1915
+ cli.mode = BYPASS;
1916
+ if (this.permissionMode === BYPASS) return { event: own() };
1917
+ if (first && this._modeFromSettings && untouched) {
1918
+ this.permissionMode = BYPASS;
1919
+ this._bypassHeld = true;
1920
+ this._settingsModeOf = { q, mode: BYPASS };
1921
+ return { event: own() };
1922
+ }
1923
+ const mode = this.permissionMode || 'default';
1924
+ // Its own switch can still be on its way to the CLI (the user left Bypass, a
1925
+ // plan was approved into another mode): then this report is older than that
1926
+ // switch, and the mode is only stated again.
1927
+ const echo = underWay;
1928
+ // Whose Bypass it was decides the sentence: the mode the user's settings
1929
+ // started this process in, one the session was in before by their choice,
1930
+ // or nobody's they know of.
1931
+ const reason = echo ? '' : (first && this._startedFromSettings) ? BYPASS_FROM_SETTINGS : this._bypassHeld ? BYPASS_LEFT : BYPASS_FOREIGN;
1932
+ log(`permission mode: the CLI reported ${BYPASS}, the tab is in ${mode}${echo ? ' (a switch is under way)' : ''}; switching back`);
1933
+ if (q) {
1934
+ this._switchMode(q, mode).catch((err) => {
1935
+ // A session that cannot be taken out of a Bypass that is not its mode does not go on.
1936
+ log('setPermissionMode (leaving a Bypass the tab did not choose) failed:', err?.message || err);
1937
+ if (this.q !== q || this.destroyed) return;
1938
+ this._endQuery({ graceful: false });
1939
+ this.send({ type: 'error', message: 'This session was switched to Bypass by something other than you and could not be switched back, so it was stopped. Your next message resumes the conversation.' });
1940
+ this.sendDone(-1);
1941
+ });
1942
+ }
1943
+ return { event: numbered ? own() : { ...m, permissionMode: mode }, then: this._numbered({ type: 'mode_changed', mode, ...(reason ? { reason } : {}) }) };
1944
+ }
1945
+
1946
+ // The CLI refused Bypass on the live session (the user's settings or a policy
1947
+ // turn it off): the tab goes back to `fallback`, on the CLI too, and is told why.
1948
+ _bypassRefused(q, why, fallback = 'default') {
1949
+ if (this.destroyed || this.permissionMode !== BYPASS) return;
1950
+ this.permissionMode = fallback === BYPASS ? 'default' : fallback;
1951
+ if (q && this.q === q) this._switchMode(q, this.permissionMode).catch(() => {});
1952
+ this.sendEvent(this._numbered({ type: 'mode_changed', mode: this.permissionMode, reason: bypassRefusalText(why) }));
1953
+ }
1954
+
1955
+ // ── permissions ────────────────────────────────────────────────────────────
1956
+
1957
+ async _onCanUseTool(toolName, input, context) {
1958
+ const { signal, suggestions } = context;
1959
+ this.lastActivity = Date.now();
1960
+ if (toolName === 'AskUserQuestion') return this._promptUser(toolName, input, signal, suggestions, 'ask');
1961
+ if (toolName === 'ExitPlanMode') return this._handleExitPlan(input, signal);
1962
+ if (AUTO_ALLOW.has(toolName)) return { behavior: 'allow', updatedInput: input };
1963
+ // Opt-in (the assistant brain): the plan policy answers first — before session
1964
+ // "always allow", so an always-allowed edit is still refused. A null answer is
1965
+ // asked as outside plan mode, under the approval mode an approved plan continues in.
1966
+ if (this.permissionMode === 'plan' && typeof this.opts?.planPermission === 'function') {
1967
+ const decision = this.opts.planPermission(toolName, input, { approvalMode: this._planExitMode() });
1968
+ if (decision === 'allow') return { behavior: 'allow', updatedInput: input };
1969
+ if (decision === 'deny') {
1970
+ this.send({ type: 'stderr', text: `Plan mode: declined ${toolName}` });
1971
+ return { behavior: 'deny', message: this.opts.planDenyMessage?.(toolName) || `Plan mode: ${toolName} was not run.` };
1972
+ }
1973
+ }
1974
+ if (this.alwaysAllowed.has(toolName)) return { behavior: 'allow', updatedInput: input };
1975
+ return this._promptUser(toolName, input, signal, suggestions, 'perm', context);
1976
+ }
1977
+
1978
+ /** The approval mode an approved plan continues in (opts.planExitMode; bypass only when the session allows it). */
1979
+ _planExitMode() {
1980
+ const configured = ['acceptEdits', 'bypassPermissions'].includes(this.opts?.planExitMode) ? this.opts.planExitMode : 'default';
1981
+ return configured === 'bypassPermissions' && this.opts?.allowDangerouslySkipPermissions !== true ? 'default' : configured;
1982
+ }
1983
+
1984
+ _promptUser(toolName, input, signal, suggestions, kind, context = null) {
1985
+ const requestId = `perm-${randomUUID()}`;
1986
+ const request = { tool_name: toolName, subtype: 'can_use_tool', input: input || {} };
1987
+ if (suggestions?.length) request.suggestions = suggestions;
1988
+ // Panel sessions: what the CLI itself says about this ask (its sentence, the
1989
+ // reason, the path or MCP server involved) and how it wants it presented.
1990
+ if (this.panel && context) {
1991
+ const c = context;
1992
+ if (c.title) request.title = String(c.title);
1993
+ if (c.displayName) request.display_name = String(c.displayName);
1994
+ if (c.description) request.description = String(c.description);
1995
+ if (c.decisionReason) request.decision_reason = String(c.decisionReason);
1996
+ if (c.blockedPath) request.blocked_path = String(c.blockedPath);
1997
+ if (c.mcpServer?.name) request.mcp_server = { name: String(c.mcpServer.name), source: String(c.mcpServer.source || '') };
1998
+ if (c.matchedAskRule) request.matched_ask_rule = c.matchedAskRule;
1999
+ if (c.agentID) request.agent_id = String(c.agentID);
2000
+ if (c.toolUseID) request.tool_use_id = String(c.toolUseID);
2001
+ if (c.defaultToNo === true) request.default_to_no = true;
2002
+ if (c.suppressAlwaysAllowRule === true) request.suppress_always = true;
2003
+ }
2004
+ // Kept on the entry so reattach() can re-send it to a reloaded page.
2005
+ const wire = { type: 'control_request', request_id: requestId, request };
2006
+ this.send(wire);
2007
+ log(`permission request ${requestId} tool=${toolName} kind=${kind}`);
2008
+ return new Promise((resolve) => {
2009
+ const entry = { toolName, resolve, input, kind, createdAt: Date.now(), wire, toolUseId: this.panel ? (context?.toolUseID || '') : '' };
2010
+ this.pendingPerms.set(requestId, entry);
2011
+ if (signal) {
2012
+ signal.addEventListener('abort', () => {
2013
+ if (this.pendingPerms.delete(requestId)) {
2014
+ this.send({ type: 'control_cancelled', request_id: requestId });
2015
+ resolve({ behavior: 'deny', message: 'Turn was interrupted' });
2016
+ }
2017
+ }, { once: true });
2018
+ }
2019
+ });
2020
+ }
2021
+
2022
+ // An MCP server asks the user for input (a form) or sends them to a URL. Same
2023
+ // pending map as permission prompts, so the card survives a page reload and a
2024
+ // closed turn cancels it. Resolves with an MCP ElicitResult.
2025
+ _promptElicitation(request, signal) {
2026
+ const requestId = `elicit-${randomUUID()}`;
2027
+ const wire = {
2028
+ type: 'control_request',
2029
+ request_id: requestId,
2030
+ request: {
2031
+ subtype: 'elicitation',
2032
+ server_name: String(request.serverName || ''),
2033
+ message: String(request.message || ''),
2034
+ mode: request.mode === 'url' ? 'url' : 'form',
2035
+ ...(request.url ? { url: String(request.url) } : {}),
2036
+ ...(request.elicitationId ? { elicitation_id: String(request.elicitationId) } : {}),
2037
+ ...(request.requestedSchema && typeof request.requestedSchema === 'object' ? { requested_schema: request.requestedSchema } : {}),
2038
+ ...(request.title ? { title: String(request.title) } : {}),
2039
+ ...(request.displayName ? { display_name: String(request.displayName) } : {}),
2040
+ ...(request.description ? { description: String(request.description) } : {}),
2041
+ },
2042
+ };
2043
+ this.send(wire);
2044
+ log(`elicitation request ${requestId} server=${request.serverName || '?'} mode=${wire.request.mode}`);
2045
+ return new Promise((resolve) => {
2046
+ this.pendingPerms.set(requestId, { toolName: 'elicitation', resolve, input: null, kind: 'elicit', createdAt: Date.now(), wire });
2047
+ if (signal) {
2048
+ signal.addEventListener('abort', () => {
2049
+ if (this.pendingPerms.delete(requestId)) {
2050
+ this.send({ type: 'control_cancelled', request_id: requestId });
2051
+ resolve({ action: 'cancel' });
2052
+ }
2053
+ }, { once: true });
2054
+ }
2055
+ });
2056
+ }
2057
+
2058
+ async _handleExitPlan(input, signal) {
2059
+ // Author the plan file BEFORE the approval card (ordering contract: the
2060
+ // frontend's eager-capture path expects plan_file_written first).
2061
+ const planBody = (input?.plan || '').trim();
2062
+ // (A temporary chat: SynaBun keeps no copy of the plan; the card shows it.)
2063
+ if (planBody && !this.temporary) {
2064
+ try {
2065
+ const workDir = validateWorkDir(this.cwd) || deps.PACKAGE_ROOT;
2066
+ const result = deps.writePlanFile(planBody, { cwd: workDir, projectPath: workDir });
2067
+ if (result?.ok) {
2068
+ this.sendEvent({ type: 'system', subtype: 'plan_file_written', path: result.path, name: result.name });
2069
+ log('plan authored:', result.path);
2070
+ }
2071
+ } catch (err) { log('plan author error:', err.message); }
2072
+ }
2073
+ const res = await this._promptUser('ExitPlanMode', input, signal, null, 'plan');
2074
+ if (res.behavior !== 'allow') return res;
2075
+ // planDecision is attached by _resolvePermission from the client response.
2076
+ const decision = res._planDecision || 'default';
2077
+ // A plain approval continues in the approval mode the session was set to
2078
+ // before planning (opts.planExitMode: the assistant's plan + bypass/accept).
2079
+ // (Panel sessions: the plan card's own Bypass choice, on a process launched able to take it.)
2080
+ const bypassOk = this.opts?.allowDangerouslySkipPermissions === true || (this.panel && this._bypassLaunched === true);
2081
+ let targetMode = decision === 'acceptEdits' ? 'acceptEdits'
2082
+ : (decision === 'bypassPermissions' && bypassOk) ? 'bypassPermissions'
2083
+ : this._planExitMode();
2084
+ // (Panel sessions: the answer is a numbered statement of the tab's mode. One
2085
+ // older than the statement applied approves the plan and states no mode. A
2086
+ // page that numbers them may also name where the plan leaves to more exactly
2087
+ // than the three decisions (`planExit`: the mode the tab was in before the
2088
+ // plan, or 'settings' for a tab that never picked one). Read here only, for
2089
+ // a plan request.)
2090
+ let toSettings = null;
2091
+ if (this.panel) {
2092
+ if (!this._takeStatement(res._planSeq)) { this._statementDropped(); return { behavior: 'allow', updatedInput: res.updatedInput || input }; }
2093
+ const exit = this._pageSeq !== undefined ? res._planExit : undefined;
2094
+ if (exit === 'settings') { toSettings = this._returnToSettings(); targetMode = toSettings.mode || 'default'; }
2095
+ else if (PANEL_PERMISSION_MODES.includes(exit) && exit !== 'plan' && (exit !== BYPASS || bypassOk)) targetMode = exit;
2096
+ }
2097
+ // Approved: plan-mode permission handling ends now, not when the switch below lands.
2098
+ this.permissionMode = targetMode;
2099
+ const stamp = this._stateMode();
2100
+ const q = this.q;
2101
+ // (Panel sessions: the CLI goes back to the mode it was in before the plan
2102
+ // by itself, and may say so before the switch below lands: until then that
2103
+ // switch is under way (_modeSwitchUnderWay). And the answer to the card is
2104
+ // the tab's own pick: the user's settings no longer choose the mode of a
2105
+ // process started after it, unless the answer went back to them.)
2106
+ if (this.panel) {
2107
+ this._modeFromSettings = !!toSettings;
2108
+ if (targetMode === BYPASS) this._bypassHeld = true;
2109
+ this._planSwitchDue = { q, stamp };
2110
+ }
2111
+ setTimeout(() => {
2112
+ if (this.panel && this._planSwitchDue?.stamp === stamp) this._planSwitchDue = null;
2113
+ // Void once the mode was stated again or the process is gone (see _stateMode).
2114
+ if (!this._modeSwitchStands(q, stamp)) return;
2115
+ (this.panel ? this._switchMode(q, targetMode) : q.setPermissionMode(targetMode))
2116
+ .then(() => {
2117
+ if (!this._modeSwitchStands(q, stamp)) return;
2118
+ this.permissionMode = targetMode;
2119
+ this.sendEvent(this._numbered({ type: 'mode_changed', mode: targetMode, ...(toSettings ? { fromSettings: true, ...(toSettings.reason ? { reason: toSettings.reason } : {}) } : {}) }));
2120
+ })
2121
+ .catch(err => {
2122
+ log('setPermissionMode after plan approve failed:', err.message);
2123
+ if (this.panel && targetMode === BYPASS && this._modeSwitchStands(q, stamp)) this._bypassRefused(q, err.message);
2124
+ });
2125
+ }, 50);
2126
+ return { behavior: 'allow', updatedInput: res.updatedInput || input };
2127
+ }
2128
+
2129
+ // AskUserQuestion answers arrive as updatedInput {questions, answers} from the
2130
+ // existing frontend card. The CLI's AskUserQuestion tool reads ONLY the
2131
+ // top-level `answers` map, looked up by each question's exact `question` text
2132
+ // (verified against the CLI itself: missing/empty map → "The user did not
2133
+ // answer the questions."). So: keep the original questions, pass the answers
2134
+ // map through, and re-key any answer the panel stored under a fallback key
2135
+ // (text/header) back onto the question text.
2136
+ _normalizeAskInput(originalInput, updatedInput) {
2137
+ if (!updatedInput) return originalInput;
2138
+ const rawAnswers = updatedInput.answers;
2139
+ if (!rawAnswers || typeof rawAnswers !== 'object') {
2140
+ return { ...originalInput, ...updatedInput };
2141
+ }
2142
+ const readAnswer = (v) => {
2143
+ if (v == null) return '';
2144
+ if (Array.isArray(v?.answers)) return v.answers.join(', ');
2145
+ if (typeof v === 'string') return v;
2146
+ if (typeof v === 'object') { try { return JSON.stringify(v); } catch { return String(v); } }
2147
+ return String(v);
2148
+ };
2149
+ const questions = Array.isArray(originalInput?.questions) ? originalInput.questions : [];
2150
+ const answerKeys = Object.keys(rawAnswers);
2151
+ // Keep every answer under its original key (the panel already keys by question text)
2152
+ const answers = {};
2153
+ for (const k of answerKeys) {
2154
+ const a = readAnswer(rawAnswers[k]);
2155
+ if (a) answers[k] = a;
2156
+ }
2157
+ // Ensure each question's EXACT text has an entry — re-key fallback-keyed answers
2158
+ questions.forEach((q, i) => {
2159
+ const qText = q?.question || '';
2160
+ if (!qText || answers[qText] != null) return;
2161
+ for (const key of [q?.text, q?.header, q?.id, String(i), answerKeys[i]].filter(Boolean)) {
2162
+ if (answers[key] != null) { answers[qText] = answers[key]; break; }
2163
+ }
2164
+ });
2165
+ // Panel sessions: what the user added to a choice (notes, the preview they
2166
+ // picked) travels with the answers.
2167
+ if (this.panel && updatedInput.annotations && typeof updatedInput.annotations === 'object') {
2168
+ return { ...originalInput, answers, annotations: updatedInput.annotations };
2169
+ }
2170
+ return { ...originalInput, answers };
2171
+ }
2172
+
2173
+ _resolvePermission(requestId, innerResponse) {
2174
+ const entry = this.pendingPerms.get(requestId);
2175
+ if (!entry) { log('control_response for unknown/expired request:', requestId); return; }
2176
+ this.pendingPerms.delete(requestId);
2177
+ const behavior = innerResponse?.behavior;
2178
+ const latencyMs = Date.now() - entry.createdAt;
2179
+ if (entry.kind === 'elicit') {
2180
+ const action = ['accept', 'decline', 'cancel'].includes(innerResponse?.action) ? innerResponse.action : 'decline';
2181
+ log(`elicitation resolved ${requestId} action=${action} latency=${latencyMs}ms`);
2182
+ const content = innerResponse?.content;
2183
+ entry.resolve(action === 'accept' && content && typeof content === 'object' ? { action, content } : { action });
2184
+ return;
2185
+ }
2186
+ log(`permission resolved ${requestId} tool=${entry.toolName} behavior=${behavior} latency=${latencyMs}ms`);
2187
+
2188
+ if (behavior === 'allow') {
2189
+ if (innerResponse.always && entry.kind === 'perm') this.alwaysAllowed.add(entry.toolName);
2190
+ let updatedInput = innerResponse.updatedInput || entry.input;
2191
+ if (entry.kind === 'ask') updatedInput = this._normalizeAskInput(entry.input, innerResponse.updatedInput);
2192
+ const result = { behavior: 'allow', updatedInput };
2193
+ if (Array.isArray(innerResponse.updatedPermissions) && innerResponse.updatedPermissions.length) {
2194
+ result.updatedPermissions = innerResponse.updatedPermissions;
2195
+ // Panel sessions: a permission card is not a way into Bypass (the mode
2196
+ // control and the plan card are). A mode switch to it is taken out.
2197
+ if (this.panel) {
2198
+ const kept = result.updatedPermissions.filter(u => !(u && typeof u === 'object' && u.type === 'setMode' && u.mode === BYPASS));
2199
+ if (kept.length !== result.updatedPermissions.length) log(`permission answer ${requestId}: dropped a switch to ${BYPASS}`);
2200
+ if (kept.length) result.updatedPermissions = kept; else delete result.updatedPermissions;
2201
+ }
2202
+ }
2203
+ if (entry.kind === 'plan') {
2204
+ result._planDecision = innerResponse.planDecision || 'default';
2205
+ // Panel sessions: the answer's statement number, and where the plan leaves to (see _handleExitPlan).
2206
+ if (this.panel) { result._planSeq = innerResponse.modeSeq; result._planExit = innerResponse.planExit; }
2207
+ }
2208
+ // Panel sessions tell the CLI which call this answers and whether the
2209
+ // approval was for this once or came with rules.
2210
+ if (this.panel && entry.kind === 'perm') {
2211
+ if (entry.toolUseId) result.toolUseID = entry.toolUseId;
2212
+ result.decisionClassification = result.updatedPermissions?.length ? 'user_permanent' : 'user_temporary';
2213
+ if (result.updatedPermissions?.length) this._noteGranted(result.updatedPermissions);
2214
+ }
2215
+ entry.resolve(result);
2216
+ } else {
2217
+ const message = innerResponse?.message
2218
+ || (entry.kind === 'plan'
2219
+ ? 'User wants to keep planning. Revise the plan based on their feedback.'
2220
+ : `User denied permission for ${entry.toolName}`);
2221
+ const result = { behavior: 'deny', message };
2222
+ if (this.panel && entry.kind === 'perm') {
2223
+ // "Deny and stop": the turn ends here instead of the model trying another way.
2224
+ if (innerResponse?.interrupt === true) result.interrupt = true;
2225
+ if (entry.toolUseId) result.toolUseID = entry.toolUseId;
2226
+ result.decisionClassification = 'user_reject';
2227
+ }
2228
+ entry.resolve(result);
2229
+ }
2230
+ }
2231
+
2232
+ _denyAllPending(reason) {
2233
+ for (const [rid, entry] of this.pendingPerms) {
2234
+ entry.resolve(entry.kind === 'elicit' ? { action: 'cancel' } : { behavior: 'deny', message: reason });
2235
+ this.send({ type: 'control_cancelled', request_id: rid });
2236
+ }
2237
+ this.pendingPerms.clear();
2238
+ }
2239
+
2240
+ // ── public API for other hosts of a session (the Assistant brain) ────────────
2241
+ // What a host may change on a session without reaching into its fields.
2242
+
2243
+ /** The approval mode an approved plan continues in ('default' | 'acceptEdits' | 'bypassPermissions'). */
2244
+ setPlanExitMode(mode) {
2245
+ // A copy: the options object may be shared or frozen (panel sessions share one).
2246
+ this.opts = { ...this.opts, planExitMode: mode };
2247
+ return this._planExitMode();
2248
+ }
2249
+
2250
+ /**
2251
+ * Switch the model before a turn: on the live query when there is one, and
2252
+ * for the next query either way. Never throws.
2253
+ * @returns {{ ok: boolean, changed: boolean, error?: string }}
2254
+ */
2255
+ async setModel(model) {
2256
+ const want = model || null;
2257
+ if ((this.model || null) === want) return { ok: true, changed: false };
2258
+ if (typeof this.q?.setModel === 'function') {
2259
+ try { await this.q.setModel(want || undefined); }
2260
+ catch (error) { return { ok: false, changed: false, error: error?.message || String(error) }; }
2261
+ }
2262
+ this.model = want; // ensureQuery() reads it when the next query is created
2263
+ return { ok: true, changed: true };
2264
+ }
2265
+
2266
+ // ── watchdog / reapers ─────────────────────────────────────────────────────
2267
+
2268
+ _checkStall() {
2269
+ if (this.destroyed || !this.q || !this.inTurn) return;
2270
+ if (this.pendingPerms.size > 0) { this.lastEventTime = Date.now(); return; }
2271
+ const silentSec = Math.round((Date.now() - this.lastEventTime) / 1000);
2272
+ if (!this.bootComplete) {
2273
+ if (silentSec >= BOOT_TIMEOUT_SEC) {
2274
+ log(`boot timeout — ${silentSec}s without init`);
2275
+ this._denyAllPending('Boot timeout');
2276
+ this._endQuery({ graceful: false });
2277
+ this.inTurn = false; this.pendingTurns = 0;
2278
+ this.send({ type: 'error', message: `Session failed to start within ${BOOT_TIMEOUT_SEC}s. MCP servers or hooks may be hanging.` });
2279
+ this.sendDone(-1);
2280
+ }
2281
+ return;
2282
+ }
2283
+ const killSec = claudeStallSeconds(this.effort, 90);
2284
+ if (silentSec >= STALL_WARN_SEC && silentSec < killSec) {
2285
+ log(`stall warning: silent ${silentSec}s (kill at ${killSec}s) retries=${this.stallRetries}`);
2286
+ return;
2287
+ }
2288
+ if (silentSec < killSec) return;
2289
+ // No main-thread output since the last result, nothing pushed since, and that
2290
+ // result said nothing was queued: the CLI is not in a turn — our count is off.
2291
+ // Close it here. A stall retry would restart the CLI, killing its background
2292
+ // work and injecting a "continue" prompt nobody sent.
2293
+ if (this._lastResultAt > this._lastPushAt && !this._outputSinceResult && !this._resultOwesTurn) {
2294
+ log('turn accounting drift — the CLI has no turn in flight; closing it');
2295
+ this.inTurn = false;
2296
+ this.pendingTurns = 0;
2297
+ this.lastActivity = Date.now();
2298
+ this.sendDone(0);
2299
+ return;
2300
+ }
2301
+ if (this.temporary) { this._stallTemporary(); return; }
2302
+ if (this.stallRetries < MAX_RETRIES) {
2303
+ this.stallRetries++;
2304
+ log(`stall retry ${this.stallRetries}/${MAX_RETRIES} — interrupt → recreate with resume`);
2305
+ this.sendEvent({ type: 'system', subtype: 'retry', message: `Stream stalled — retrying (${this.stallRetries}/${MAX_RETRIES})...` });
2306
+ // The stalled turn is in flight by definition — swallow its interrupt-result
2307
+ // so the client doesn't get a premature done mid-recovery.
2308
+ this.swallowResults++;
2309
+ this.swallowDeadline = Date.now() + 15_000;
2310
+ const q = this.q;
2311
+ const interruptDeadline = new Promise(r => setTimeout(() => r('timeout'), 10_000));
2312
+ Promise.race([q.interrupt().then(() => 'ok').catch(() => 'failed'), interruptDeadline])
2313
+ .then(async () => {
2314
+ if (this.destroyed) return;
2315
+ // Jitter + global slot: de-phase correlated stall recoveries so N
2316
+ // sessions don't respawn N CLIs in the same instant.
2317
+ await new Promise(r => setTimeout(r, Math.random() * 3000));
2318
+ if (this.destroyed) return;
2319
+ await _acquireRecreateSlot();
2320
+ try {
2321
+ if (this.destroyed) return;
2322
+ await this._recreateQuery('The previous turn was interrupted by an API stream drop. Please continue from where you left off.');
2323
+ } finally {
2324
+ _releaseRecreateSlot();
2325
+ }
2326
+ });
2327
+ } else {
2328
+ log('stall timeout — max retries exhausted');
2329
+ this._endQuery({ graceful: false });
2330
+ this.inTurn = false; this.pendingTurns = 0;
2331
+ this.send({ type: 'error', message: `Session stalled after ${MAX_RETRIES} retries. The API stream keeps dropping. Try a simpler message or switch to a faster model.` });
2332
+ this.sendDone(-1);
2333
+ }
2334
+ }
2335
+
2336
+ _maybeReapIdle() {
2337
+ if (this.destroyed || !this.q || this.inTurn || this.pendingPerms.size > 0) return;
2338
+ // Background agents, shells and scheduled wakeups run inside this CLI process
2339
+ // after the turn that started them has ended. Ending the Query kills them.
2340
+ if (this._hasPendingWork()) return;
2341
+ // A temporary chat cannot be resumed: it lives as long as its tab does.
2342
+ if (this.temporary) return;
2343
+ if (Date.now() - this.lastActivity < IDLE_REAP_MS) return;
2344
+ log(`idle reap: ending query for session ${this.sessionId || '(new)'} — next prompt resumes transparently`);
2345
+ this._endQuery({ graceful: true });
2346
+ }
2347
+
2348
+ // ── inbound WS messages ────────────────────────────────────────────────────
2349
+
2350
+ async handleMessage(msg, turn = null) {
2351
+ // Panel sessions: while another message is between ending a process and
2352
+ // starting the next, a message that can start or end one waits in line
2353
+ // (see _newTurn). It is handled with a turn of its own, which it takes
2354
+ // before it waits on a closing process itself.
2355
+ if (this.panel && !turn && takesTurn(msg)) {
2356
+ const mine = this._newTurn();
2357
+ try {
2358
+ if (this._turnBusy) {
2359
+ await mine.take();
2360
+ // The tab went away while the message waited: there is no one left to answer.
2361
+ if (this.destroyed) { log(`${msg.type} dropped: the session was closed while it waited its turn`); return; }
2362
+ }
2363
+ return await this.handleMessage(msg, mine);
2364
+ } finally { mine.release(); }
2365
+ }
2366
+ if (this.panel && controlCanStart(msg)) {
2367
+ if (!this._everStarted) this._establishFromControl(msg);
2368
+ else if (this._controlLeftConversation(msg)) {
2369
+ // The tab is on another conversation than the one this session's
2370
+ // process was started for: that process is not what the control runs
2371
+ // on. It ends, as it does for a prompt, and the control's configuration
2372
+ // is the one of a conversation no process has started for yet.
2373
+ log(`control ${msg.type} for another conversation (was ${this.sessionId || 'none'}): ending the query`);
2374
+ // The turn is this message's from here (it is free, or already its own).
2375
+ { const waiting = turn?.take(); if (waiting) await waiting; }
2376
+ await this._endQuery({ graceful: true });
2377
+ if (this.destroyed) return;
2378
+ // No message started a process during that wait: the ones that can are
2379
+ // waiting their turn behind this one. A recovery that was already under
2380
+ // way (a stall retry) is not a message and may have: what it restarted
2381
+ // is the conversation the tab left, so it ends too, at once (nothing
2382
+ // is awaited when the end is not graceful).
2383
+ if (this.q) this._endQuery({ graceful: false });
2384
+ this._everStarted = false;
2385
+ this._accountBound = false;
2386
+ this._establishFromControl(msg);
2387
+ }
2388
+ }
2389
+ switch (msg.type) {
2390
+ case 'query': {
2391
+ // (Panel sessions: a prompt that restarts the process holds the turn until the new one exists.)
2392
+ const restarting = this._handleQuery(msg);
2393
+ if (restarting) { const waiting = turn?.take(); if (waiting) await waiting; }
2394
+ return restarting;
2395
+ }
2396
+ case 'warm':
2397
+ // Panel sessions only: the user started typing. Start the CLI process
2398
+ // now (same configuration as the message that follows), unless one is
2399
+ // already there.
2400
+ if (!this.panel || this.q || this._warm) return;
2401
+ return this._handleQuery(msg, { warmOnly: true });
2402
+ case 'control_response': {
2403
+ const rid = msg.request_id || msg.response?.request_id;
2404
+ const inner = msg.response?.response || msg.response || {};
2405
+ if (rid) this._resolvePermission(rid, inner);
2406
+ return;
2407
+ }
2408
+ case 'compact': {
2409
+ // Panel sessions: a start that is refused is said before anything is announced.
2410
+ if (this.panel && !this.q) { this.ensureQuery(); if (this._announceRefusedStart()) return; }
2411
+ this.sendEvent({ type: 'system', subtype: 'compact_started', message: 'Compacting context...' });
2412
+ this._pushUserText('/compact');
2413
+ return;
2414
+ }
2415
+ case 'abort': {
2416
+ // Swallow the result interrupt() will produce — but only when a turn is
2417
+ // actually in flight, and with an expiry so a no-result interrupt can't
2418
+ // permanently eat the next real turn's done.
2419
+ if (this.inTurn) { this.swallowResults++; this.swallowDeadline = Date.now() + 15_000; }
2420
+ this._denyAllPending('Turn was interrupted');
2421
+ this.inTurn = false;
2422
+ this.pendingTurns = 0;
2423
+ const q = this.q;
2424
+ turn?.release(); // (panel sessions: a stop that waited in line holds nothing while the CLI answers)
2425
+ if (q) { try { await q.interrupt(); } catch (err) { log('interrupt failed:', err.message); } }
2426
+ this.send({ type: 'aborted' });
2427
+ return;
2428
+ }
2429
+ case 'set_permission_mode': {
2430
+ const mode = msg.mode;
2431
+ // Panel sessions, a page that numbers its statements: back to the mode
2432
+ // the user's settings name (a tab that never picked a mode left plan mode).
2433
+ const toSettings = this.panel && msg.fromSettings === true && Number.isSafeInteger(msg.modeSeq);
2434
+ if (!toSettings && !(this.panel ? PANEL_PERMISSION_MODES : CLASSIC_PERMISSION_MODES).includes(mode)) return;
2435
+ // (Panel sessions: a numbered statement older than the one applied is not applied.)
2436
+ if (!this._takeStatement(msg.modeSeq)) { this._statementDropped(); return; }
2437
+ if (toSettings) {
2438
+ const back = this._returnToSettings();
2439
+ this._warmStillFits();
2440
+ const stamp = this._stateMode();
2441
+ turn?.release();
2442
+ const q = this.q;
2443
+ if (q) {
2444
+ try {
2445
+ await this._switchMode(q, back.mode);
2446
+ } catch (err) {
2447
+ log('setPermissionMode (back to the settings\' mode) failed:', err.message);
2448
+ if (back.mode === BYPASS) { if (this._modeSwitchStands(q, stamp)) this._bypassRefused(q, err.message); return; }
2449
+ // The session is where it was: the page is told so, with what it may still be in.
2450
+ if (this._modeSwitchStands(q, stamp)) this._tellFacts({ failed: back.mode });
2451
+ this.send({ type: 'error', message: `Could not switch permission mode: ${err.message}` });
2452
+ return;
2453
+ }
2454
+ }
2455
+ if (this._modeStatements !== stamp) return;
2456
+ this.sendEvent(this._numbered({ type: 'mode_changed', mode: back.mode, fromSettings: true, ...(back.reason ? { reason: back.reason } : {}) }));
2457
+ return;
2458
+ }
2459
+ // Panel sessions: Bypass where no process can be launched able to take
2460
+ // it (see panelBypassBlock) is refused here, and the tab keeps its mode.
2461
+ const previous = this.permissionMode;
2462
+ if (this.panel && mode === BYPASS) {
2463
+ const block = panelBypassBlock() || (this.q && !this._bypassLaunched ? bypassRefusalText('not launched') : '');
2464
+ if (block) {
2465
+ // (A numbered statement that is refused still decides the tab's mode: the one it is in instead, away from the settings.)
2466
+ const kept = previous === BYPASS ? 'default' : (previous || 'default');
2467
+ if (this._pageSeq !== undefined) { this._modeFromSettings = false; this.permissionMode = kept; this._stateMode(); this._warmStillFits(); }
2468
+ this.sendEvent(this._numbered({ type: 'mode_changed', mode: kept, reason: block }));
2469
+ return;
2470
+ }
2471
+ }
2472
+ this._modeFromSettings = false;
2473
+ this.permissionMode = mode;
2474
+ if (this.panel && mode === BYPASS) this._bypassHeld = true;
2475
+ // (Panel sessions: a process kept ready that was started in another mode is ended; see _warmStillFits.)
2476
+ if (this.panel) this._warmStillFits();
2477
+ const stamp = this._stateMode();
2478
+ turn?.release(); // (panel sessions: as for a stop)
2479
+ if (this.q) {
2480
+ const q = this.q;
2481
+ try {
2482
+ await (this.panel ? this._switchMode(q, mode) : q.setPermissionMode(mode));
2483
+ } catch (err) {
2484
+ log('setPermissionMode failed:', err.message);
2485
+ // Panel sessions: a Bypass the CLI refused leaves the tab in the mode it had
2486
+ // (not when the mode was stated again meanwhile, or the process is
2487
+ // another one: then the refusal is about neither; see _stateMode).
2488
+ if (this.panel && mode === BYPASS) { if (this._modeSwitchStands(q, stamp)) this._bypassRefused(q, err.message, previous); return; }
2489
+ // (Panel sessions: a switch that failed or was refused leaves the session
2490
+ // in the mode the CLI is in, a Bypass it could not leave included: the
2491
+ // page is told that, not only that there was an error.)
2492
+ if (this.panel && this._modeSwitchStands(q, stamp)) this._tellFacts({ failed: mode });
2493
+ this.send({ type: 'error', message: `Could not switch permission mode: ${err.message}` });
2494
+ return;
2495
+ }
2496
+ }
2497
+ // (Panel sessions: a switch the CLI answered after the mode was stated
2498
+ // again announces nothing: the tab is not in it.)
2499
+ if (this.panel && this._modeStatements !== stamp) return;
2500
+ this.sendEvent(this._numbered({ type: 'mode_changed', mode }));
2501
+ return;
2502
+ }
2503
+ case 'rewind': {
2504
+ // (A temporary chat has no checkpoints.)
2505
+ if (this.temporary) { this.send({ type: 'rewind_result', ok: false, code: 'temporary', error: TEMPORARY_NO_REWIND }); return; }
2506
+ const uuid = msg.userMessageUuid;
2507
+ // The idle reaper may have ended the Query — resume transparently
2508
+ if (!this.q && this.sessionId && uuid) this.ensureQuery();
2509
+ if (!uuid || !this.q) {
2510
+ this.send({ type: 'rewind_result', ok: false, error: this.q ? 'Missing userMessageUuid' : (this._startRefused?.message || 'No active session'), ...(!this.q && this._startRefused?.code ? { code: this._startRefused.code } : {}) });
2511
+ return;
2512
+ }
2513
+ // (Panel sessions: the turn is given up for the CLI's answer, see _newTurn.)
2514
+ turn?.release();
2515
+ try {
2516
+ const r = await this.q.rewindFiles(uuid);
2517
+ if (r && r.canRewind === false) {
2518
+ this.send({ type: 'rewind_result', ok: false, error: r.error || 'Rewind not available for this checkpoint' });
2519
+ } else if (this.panel) {
2520
+ // What the rewind changed, for the panel's result line.
2521
+ const stats = slimRewind(r);
2522
+ this.send({ type: 'rewind_result', ok: true, userMessageUuid: uuid, fileCount: stats.fileCount, insertions: stats.insertions, deletions: stats.deletions, skippedLinks: stats.skippedLinks });
2523
+ } else {
2524
+ this.send({ type: 'rewind_result', ok: true, userMessageUuid: uuid });
2525
+ }
2526
+ } catch (err) {
2527
+ this.send({ type: 'rewind_result', ok: false, error: err.message });
2528
+ }
2529
+ return;
2530
+ }
2531
+ case 'mcp_status': {
2532
+ if (!this.q) { this.sendEvent({ type: 'system', subtype: 'mcp_status', servers: [] }); return; }
2533
+ try {
2534
+ const servers = await this.q.mcpServerStatus();
2535
+ this.sendEvent({ type: 'system', subtype: 'mcp_status', servers: servers || [] });
2536
+ } catch (err) { log('mcpServerStatus failed:', err.message); }
2537
+ return;
2538
+ }
2539
+ case 'reload_skills': {
2540
+ // Panel sessions only: re-read skills from disk and refresh the slash menu.
2541
+ if (!this.panel) return;
2542
+ if (!this.q || typeof this.q.reloadSkills !== 'function') {
2543
+ this.send({ type: 'reload_result', what: 'skills', ok: false, error: 'No active session yet. Skills load fresh when the session starts.' });
2544
+ return;
2545
+ }
2546
+ const q = this.q;
2547
+ try {
2548
+ await q.reloadSkills();
2549
+ const commands = await q.supportedCommands();
2550
+ if (this.q === q && Array.isArray(commands)) {
2551
+ this.sendEvent({ type: 'system', subtype: 'commands_list', commands });
2552
+ this.send({ type: 'reload_result', what: 'skills', ok: true, count: commands.length });
2553
+ }
2554
+ } catch (err) {
2555
+ this.send({ type: 'reload_result', what: 'skills', ok: false, error: err.message });
2556
+ }
2557
+ return;
2558
+ }
2559
+ case 'rewind_conversation': {
2560
+ // Panel sessions only: drop the conversation back to one entry (the
2561
+ // model forgets what came after), optionally restoring the files to
2562
+ // the prompt that followed it. The session restarts, resumed at that entry.
2563
+ if (!this.panel) return;
2564
+ let at = typeof msg.messageUuid === 'string' ? msg.messageUuid : '';
2565
+ const fail = (error, code) => this.send({ type: 'rewind_conversation_result', ok: false, error, ...(code ? { code } : {}) });
2566
+ if (this.temporary) { fail(TEMPORARY_NO_REWIND, 'temporary'); return; }
2567
+ if (!at || !this.sessionId) { fail(at ? 'No session to rewind' : 'Missing message id'); return; }
2568
+ if (this.inTurn) { fail('Claude is still working. Stop the turn first.'); return; }
2569
+ // The turn (see _newTurn) is given up while this waits on the disk and
2570
+ // on the CLI, and other messages are handled meanwhile. One that moved
2571
+ // the tab to another conversation, or started a turn, stops the rewind:
2572
+ // going on would rewind the wrong conversation, or end that turn.
2573
+ const moves = this._conversationMoves || 0;
2574
+ const pushedAt = this._lastPushAt;
2575
+ const MOVED_ON = 'The tab moved to another conversation while this rewind was under way, so the conversation was not rewound.';
2576
+ // Where to resume is the entry the prompt follows in the transcript. The
2577
+ // panel's own answer comes from the rows it rendered, and a turn can end
2578
+ // on an entry it has no row for (a tool-result carrier, an attachment):
2579
+ // resuming before that would drop the kept turn's last output. So when
2580
+ // the host can read the transcript, only its answer is used: a lookup
2581
+ // that fails or does not find the prompt refuses the rewind before a
2582
+ // file is touched or the session restarted. The panel's value stands
2583
+ // only for a host that wired no lookup.
2584
+ if (typeof deps.transcriptParentOf === 'function') {
2585
+ if (typeof msg.userMessageUuid !== 'string' || !msg.userMessageUuid) { fail('The rewind did not say which prompt to go back to.'); return; }
2586
+ let parent = null;
2587
+ turn?.release();
2588
+ try { parent = await deps.transcriptParentOf({ sessionId: this.sessionId, uuid: msg.userMessageUuid, cwd: this.cwd, accountId: this.accountId || '' }); } catch { parent = null; }
2589
+ await turn?.take();
2590
+ if (parent === '') { fail('This is the first message of the conversation: there is nothing before it to go back to.'); return; }
2591
+ if (typeof parent !== 'string' || !parent) { fail('The transcript of this session could not be read to find what comes before that message, so nothing was rewound. Try again once the turn is written, or check that the tab\'s project is the one the session belongs to.'); return; }
2592
+ at = parent;
2593
+ if (this.inTurn || this.destroyed) { fail('Claude is still working. Stop the turn first.'); return; }
2594
+ if ((this._conversationMoves || 0) !== moves) { fail(MOVED_ON); return; }
2595
+ }
2596
+ let files = null;
2597
+ if (typeof msg.userMessageUuid === 'string' && msg.userMessageUuid) {
2598
+ if (!this.q) this.ensureQuery();
2599
+ if (!this.q) { fail(this._startRefused?.message || 'No active session', this._startRefused?.code); return; }
2600
+ turn?.release();
2601
+ try {
2602
+ const r = await this.q.rewindFiles(msg.userMessageUuid);
2603
+ if (r && r.canRewind === false) { fail(r.error || 'Files cannot be rewound to this message'); return; }
2604
+ files = slimRewind(r);
2605
+ } catch (err) { fail(err.message); return; }
2606
+ await turn?.take();
2607
+ if (this.destroyed) return;
2608
+ // The files are restored; the conversation is rewound only when
2609
+ // nothing else happened to the session meanwhile (see `moves` above).
2610
+ if ((this._conversationMoves || 0) !== moves) { fail(`${MOVED_ON} The files were restored.`); return; }
2611
+ // (A turn the CLI started by itself is ended by the restart, as it always was: only one a message started stops the rewind.)
2612
+ if (this.inTurn && this._lastPushAt !== pushedAt) { fail('Claude started working while the files were being restored, so the conversation was not rewound. The files were restored: stop the turn, then rewind again.'); return; }
2613
+ }
2614
+ { const waiting = turn?.take(); if (waiting) await waiting; }
2615
+ await this._endQuery({ graceful: true });
2616
+ if (this.destroyed) return;
2617
+ this._resumeAt = at;
2618
+ this.ensureQuery();
2619
+ if (this._startRefused) { fail(this._startRefused.message, this._startRefused.code); return; }
2620
+ this.send({ type: 'rewind_conversation_result', ok: true, messageUuid: at, userMessageUuid: msg.userMessageUuid || '', ...(files ? { fileCount: files.fileCount, insertions: files.insertions, deletions: files.deletions } : {}) });
2621
+ return;
2622
+ }
2623
+ case 'session_request': {
2624
+ // Panel sessions only: one of the Query's control calls, answered as a card's data.
2625
+ if (!this.panel) return;
2626
+ const id = typeof msg.id === 'string' ? msg.id.slice(0, 80) : '';
2627
+ const what = SESSION_REQUESTS.includes(msg.what) ? msg.what : '';
2628
+ if (!id || !what) return;
2629
+ // The session's own record of granted permission rules, and dropping the
2630
+ // session-scoped ones. The SDK has no call that removes a live rule: they
2631
+ // live in the CLI process, so forgetting them ends it (the next message
2632
+ // resumes the conversation in a new one).
2633
+ if (what === 'permission_rules') { this.send({ type: 'session_response', id, what, ok: true, data: { granted: [...this._grantedRules] } }); return; }
2634
+ // A temporary chat: forgetting the session's rules ends its process, and a rewind needs checkpoints it does not keep.
2635
+ if (this.temporary && (what === 'forget_session_rules' || what === 'rewind_preview')) {
2636
+ this.send({ type: 'session_response', id, what, ok: false, code: 'temporary', error: what === 'rewind_preview' ? TEMPORARY_NO_REWIND : `${TEMPORARY_NO_RESTART} The rules granted here end with it.` });
2637
+ return;
2638
+ }
2639
+ if (what === 'forget_session_rules') {
2640
+ if (this.inTurn || this.pendingPerms.size) { this.send({ type: 'session_response', id, what, ok: false, error: 'Claude is still working. Stop the turn first.' }); return; }
2641
+ // Ending the process also ends what runs in it without a turn:
2642
+ // background tasks and scheduled wake-ups. That is said, and done only
2643
+ // when the request confirms it.
2644
+ if (this.q && this._hasPendingWork() && msg.args?.confirm !== true) {
2645
+ const backgroundTasks = [...this._bgTasks.values()].slice(0, 50).map(t => ({ task_id: String(t?.task_id || ''), task_type: String(t?.task_type || ''), description: String(t?.description || '').slice(0, 300) }));
2646
+ const wakeups = this._cronList().slice(0, 50);
2647
+ const parts = [];
2648
+ if (backgroundTasks.length) parts.push(`${backgroundTasks.length} background task${backgroundTasks.length === 1 ? '' : 's'}`);
2649
+ if (wakeups.length) parts.push(`${wakeups.length} scheduled wake-up${wakeups.length === 1 ? '' : 's'}`);
2650
+ this.send({ type: 'session_response', id, what, ok: false, code: 'confirm_required', error: `Forgetting the rules restarts the session, which ends ${parts.join(' and ')}. Confirm to go on.`, data: { backgroundTasks, wakeups } });
2651
+ return;
2652
+ }
2653
+ const restarted = !!this.q;
2654
+ { const waiting = turn?.take(); if (waiting) await waiting; }
2655
+ if (this.q) await this._endQuery({ graceful: true });
2656
+ this._grantedRules = this._grantedRules.filter(u => u.destination && u.destination !== 'session');
2657
+ this.send({ type: 'session_response', id, what, ok: true, data: { granted: [...this._grantedRules], restarted } });
2658
+ return;
2659
+ }
2660
+ // The tab's own remote MCP servers, changed on the live session. The call
2661
+ // replaces the dynamic set, so the host's entries (SynaBun) are always
2662
+ // part of it; the tab's settings are updated to match, so the next
2663
+ // message does not restart the session over a change that is already live.
2664
+ if (what === 'mcp_set_servers') {
2665
+ const args = msg.args && typeof msg.args === 'object' ? msg.args : {};
2666
+ const q = this.q;
2667
+ try {
2668
+ if (!q) throw new Error('No active session. The servers start with the session.');
2669
+ if (typeof q.setMcpServers !== 'function') throw new Error('Not supported by this Claude Code version');
2670
+ const current = this.panelSession || normalizePanelSession(null);
2671
+ const start = { ...current.start, mcpServers: normalizeMcpServers(args.servers) };
2672
+ const merged = mergeTabMcpServers(this._hostMcpServers, start);
2673
+ const r = await q.setMcpServers(merged.servers);
2674
+ if (this.q === q) this.panelSession = { ...current, start };
2675
+ const errors = r?.errors && typeof r.errors === 'object' ? Object.fromEntries(Object.entries(r.errors).slice(0, 20).map(([k, v]) => [String(k).slice(0, 80), String(v).slice(0, 300)])) : {};
2676
+ const servers = typeof q.mcpServerStatus === 'function' ? slimMcpStatus(await q.mcpServerStatus()) : [];
2677
+ this.send({ type: 'session_response', id, what, ok: true, data: { added: Array.isArray(r?.added) ? r.added : [], removed: Array.isArray(r?.removed) ? r.removed : [], errors, notes: merged.notes, applied: start.mcpServers, servers } });
2678
+ if (servers.length && this.q === q) this.sendEvent({ type: 'system', subtype: 'mcp_status', servers });
2679
+ } catch (err) {
2680
+ this.send({ type: 'session_response', id, what, ok: false, error: err?.message || String(err) });
2681
+ }
2682
+ return;
2683
+ }
2684
+ // A rewind preview on a session the idle reaper ended: resume it, as rewind itself does.
2685
+ if (!this.q && what === 'rewind_preview' && this.sessionId) {
2686
+ this.ensureQuery();
2687
+ if (!this.q && this._startRefused) { this.send({ type: 'session_response', id, what, ok: false, code: this._startRefused.code, error: this._startRefused.message }); return; }
2688
+ }
2689
+ const q = this.q;
2690
+ // (A rewind preview holds a turn until here: given up for the CLI's answer, see _newTurn.)
2691
+ turn?.release();
2692
+ try {
2693
+ const data = await runSessionRequest(q, what, msg.args && typeof msg.args === 'object' ? msg.args : {});
2694
+ this.send({ type: 'session_response', id, what, ok: true, data });
2695
+ // The statusline's dots follow a reconnect or a toggle.
2696
+ if (data?.servers && this.q === q) this.sendEvent({ type: 'system', subtype: 'mcp_status', servers: data.servers });
2697
+ } catch (err) {
2698
+ this.send({ type: 'session_response', id, what, ok: false, error: err?.message || String(err) });
2699
+ }
2700
+ return;
2701
+ }
2702
+ case 'stop_task':
2703
+ case 'background_tasks': {
2704
+ // Panel sessions only: stop one background task, or send running
2705
+ // foreground work (one tool call, or all of it) to the background.
2706
+ if (!this.panel) return;
2707
+ const action = msg.type;
2708
+ const q = this.q;
2709
+ if (!q) { this.send({ type: 'task_control_result', action, ok: false, error: 'No active session' }); return; }
2710
+ try {
2711
+ if (action === 'stop_task') {
2712
+ const taskId = typeof msg.taskId === 'string' ? msg.taskId : '';
2713
+ if (!taskId || typeof q.stopTask !== 'function') throw new Error(taskId ? 'Not supported by this Claude Code version' : 'Missing task id');
2714
+ await q.stopTask(taskId);
2715
+ this.send({ type: 'task_control_result', action, ok: true, taskId });
2716
+ } else {
2717
+ if (typeof q.backgroundTasks !== 'function') throw new Error('Not supported by this Claude Code version');
2718
+ const toolUseId = typeof msg.toolUseId === 'string' && msg.toolUseId ? msg.toolUseId : undefined;
2719
+ const moved = await q.backgroundTasks(toolUseId);
2720
+ this.send({ type: 'task_control_result', action, ok: moved !== false, ...(moved === false ? { error: 'Nothing running in the foreground matched' } : {}) });
2721
+ }
2722
+ } catch (err) {
2723
+ this.send({ type: 'task_control_result', action, ok: false, error: err.message });
2724
+ }
2725
+ return;
2726
+ }
2727
+ case 'reload_plugins': {
2728
+ // Panel sessions only: pick up plugins installed or changed since the session started.
2729
+ if (!this.panel) return;
2730
+ if (!this.q || typeof this.q.reloadPlugins !== 'function') {
2731
+ this.send({ type: 'reload_result', what: 'plugins', ok: false, error: 'No active session yet. Plugins load fresh when the session starts.' });
2732
+ return;
2733
+ }
2734
+ const q = this.q;
2735
+ try {
2736
+ const r = await q.reloadPlugins();
2737
+ if (this.q !== q) return;
2738
+ if (Array.isArray(r?.commands)) this.sendEvent({ type: 'system', subtype: 'commands_list', commands: r.commands });
2739
+ if (Array.isArray(r?.mcpServers)) this.sendEvent({ type: 'system', subtype: 'mcp_status', servers: r.mcpServers });
2740
+ const plugins = Array.isArray(r?.plugins) ? r.plugins.map(p => ({ name: p?.name || '', version: p?.version || '', source: p?.source || '' })) : [];
2741
+ this.sendEvent({ type: 'system', subtype: 'plugins_list', plugins, agents: Array.isArray(r?.agents) ? r.agents : [], errorCount: Number(r?.error_count) || 0 });
2742
+ this.send({ type: 'reload_result', what: 'plugins', ok: true, count: plugins.length, errorCount: Number(r?.error_count) || 0 });
2743
+ } catch (err) {
2744
+ this.send({ type: 'reload_result', what: 'plugins', ok: false, error: err.message });
2745
+ }
2746
+ return;
2747
+ }
2748
+ case 'heartbeat': {
2749
+ if (msg.windowId) this.windowId = msg.windowId;
2750
+ if (msg.sessionId && this.windowId && !this.temporary) {
2751
+ try { deps.heartbeatLock(msg.sessionId, this.windowId); } catch {}
2752
+ }
2753
+ return;
2754
+ }
2755
+ default:
2756
+ // 'reattach' handled at the connection layer
2757
+ return;
2758
+ }
2759
+ }
2760
+
2761
+ // `establishOnly` (with `warmOnly`; panel sessions, see _establishFromControl):
2762
+ // apply the configuration of a query that has no prompt, and start nothing.
2763
+ _handleQuery(msg, { warmOnly = false, establishOnly = false } = {}) {
2764
+ let { prompt, cwd, sessionId, model, effort, images, windowId, permissionMode } = msg;
2765
+ if (warmOnly) { prompt = ''; images = null; }
2766
+ if (windowId) this.windowId = windowId;
2767
+ if (model && model.includes(':')) model = deps.toCliModelName(model);
2768
+ if (!warmOnly && !prompt && !images?.length) {
2769
+ this.send({ type: 'error', message: 'No prompt provided' });
2770
+ return;
2771
+ }
2772
+ // Panel sessions: the account comes first. An account that is no longer set
2773
+ // up, or another account in the middle of a conversation, refuses the query
2774
+ // before a lock is taken or a process is started.
2775
+ let queryAccount = null;
2776
+ if (this.panel) {
2777
+ queryAccount = this._resolveQueryAccount(msg.accountId, sessionId);
2778
+ if (queryAccount.code) {
2779
+ if (!warmOnly) this.send({ type: 'error', code: queryAccount.code, message: queryAccount.message });
2780
+ if (establishOnly) this._coldRefusal = { code: queryAccount.code, message: queryAccount.message };
2781
+ return;
2782
+ }
2783
+ }
2784
+
2785
+ // Panel sessions: a temporary chat is chosen with the first message of a
2786
+ // conversation and fixed from there (see _takeTemporary). A message that
2787
+ // cannot be answered as one is refused before anything is applied.
2788
+ if (this.panel) {
2789
+ let refused = this._takeTemporary(msg, sessionId);
2790
+ if (!refused && this.temporary && this.q && !warmOnly) {
2791
+ const why = this._temporaryRestartReason(msg, { cwd, effort, account: queryAccount.account });
2792
+ if (why) refused = { code: 'temporary_restart', message: `${TEMPORARY_NO_RESTART} So ${why} cannot change here: put it back, or start a new chat with the new setting.` };
2793
+ }
2794
+ if (refused) {
2795
+ if (!warmOnly) { this.send({ type: 'error', code: refused.code, message: refused.message }); this.sendDone(-1); }
2796
+ if (establishOnly) this._coldRefusal = refused;
2797
+ return;
2798
+ }
2799
+ }
2800
+
2801
+ // Session lock — identical to legacy (a warm start takes none: nothing is sent yet)
2802
+ // (A temporary chat takes none: no other window can open it.)
2803
+ if (sessionId && this.windowId && (!warmOnly || establishOnly) && !this.temporary) {
2804
+ try {
2805
+ const lockResult = deps.acquireSessionLock(sessionId, this.windowId, null);
2806
+ if (!lockResult.ok) {
2807
+ if (establishOnly) { this._coldRefusal = { code: 'session_locked', message: 'Session locked by another window' }; return; }
2808
+ this.send({ type: 'error', message: 'Session locked by another window' });
2809
+ return;
2810
+ }
2811
+ } catch {}
2812
+ }
2813
+
2814
+ // SynaBun skill injection runs FIRST; unrecognized /commands pass through to the CLI
2815
+ if (prompt && prompt.startsWith('/')) {
2816
+ try {
2817
+ const injected = deps.maybeInjectSkillPrompt(prompt);
2818
+ if (injected.command === 'clear' && !injected.skill) return; // client-only
2819
+ if (injected.skill) {
2820
+ log('skill injection: /' + injected.command);
2821
+ prompt = injected.prompt;
2822
+ }
2823
+ } catch {}
2824
+ }
2825
+
2826
+ // Config changes that require a fresh process (cwd/effort map to spawn flags)
2827
+ const cwdChanged = cwd && this.cwd && cwd !== this.cwd;
2828
+ // A session that started without an effort still switches when one is picked.
2829
+ let effortChanged = !!effort && effort !== this.effort;
2830
+ const sessionChanged = sessionId && this.sessionId && sessionId !== this.sessionId;
2831
+ // No sessionId from a tab whose session has one: the tab started a new chat
2832
+ // (New chat, project switch). Continuing here would answer it with the old
2833
+ // conversation's context — or resume the old session after an idle reap.
2834
+ const newChat = !sessionId && !!this.sessionId;
2835
+ // (A panel tab that started on the default model can still pick one later.)
2836
+ const modelChanged = model && (this.model || this.panel) && model !== this.model;
2837
+ // The env is fixed at spawn, so flipping judgments on a live query needs a fresh process.
2838
+ const typesafeOff = msg.typesafe === undefined ? !!this.typesafeOff : msg.typesafe === 'off';
2839
+ const typesafeChanged = !!this.q && typesafeOff !== !!this.typesafeOff;
2840
+ this.typesafeOff = typesafeOff;
2841
+ // Panel sessions: what the client can render, and the tab's tool policy.
2842
+ // Removing tools is a start-up option, so a change needs a fresh process.
2843
+ let policyChanged = false;
2844
+ if (this.panel) {
2845
+ if (Array.isArray(msg.features)) this.clientFeatures = new Set(msg.features.filter(f => typeof f === 'string'));
2846
+ if (typeof msg.title === 'string') this._title = msg.title.trim().slice(0, 200);
2847
+ // The account is part of the process environment: a change restarts the
2848
+ // session (a conversation of one account cannot be resumed under another;
2849
+ // the lost-session recovery starts a fresh one and says so).
2850
+ // (Validated above, in _resolveQueryAccount.) Each reason to restart is
2851
+ // added to the others: a later unchanged setting must not cancel it.
2852
+ if (msg.accountId !== undefined) {
2853
+ const account = queryAccount.account;
2854
+ if (this.q && account !== (this.accountId || '')) policyChanged = true;
2855
+ this.accountId = account;
2856
+ }
2857
+ // A message (not a warm start) is what makes the conversation this account's.
2858
+ // (A control's configuration binds nothing by itself: the process it starts does, in ensureQuery.)
2859
+ if (!warmOnly) this._accountBound = true;
2860
+ // From here the session has its tab's configuration: it may start a process.
2861
+ this._established = true;
2862
+ if (msg.toolPolicy !== undefined) {
2863
+ const policy = TOOL_POLICY_DISALLOW[msg.toolPolicy] ? msg.toolPolicy : null;
2864
+ if (this.q && policy !== (this.toolPolicy || null)) policyChanged = true;
2865
+ this.toolPolicy = policy;
2866
+ }
2867
+ if (msg.session !== undefined) {
2868
+ const next = normalizePanelSession(msg.session);
2869
+ // What is fixed at start changed: the session restarts with it.
2870
+ if (this.q && startSignature(next) !== startSignature(this.panelSession || normalizePanelSession(null))) policyChanged = true;
2871
+ this.panelSession = next;
2872
+ }
2873
+ }
2874
+
2875
+ // Panel sessions: counted, for a control that waits and must know the tab
2876
+ // stayed where it was (the conversation rewind).
2877
+ if (this.panel && (newChat || sessionChanged)) this._conversationMoves = (this._conversationMoves || 0) + 1;
2878
+ if (newChat) this.sessionId = null;
2879
+ if (sessionId) this.sessionId = sessionId;
2880
+ if (cwd) this.cwd = cwd;
2881
+ // A panel tab that sends no effort means "the model's default": a level that
2882
+ // was set goes back to it, live when a session exists.
2883
+ const effortCleared = this.panel && !effort && !!this.effort;
2884
+ if (effort) this.effort = effort;
2885
+ else if (effortCleared) this.effort = null;
2886
+ if (model) this.model = model;
2887
+ // Panel sessions: a tab in Bypass where no process can be launched able to
2888
+ // take it runs in Default, and is told (see panelBypassBlock).
2889
+ // (Panel sessions: the mode a message states is a numbered statement like
2890
+ // any other. One older than the statement applied states no mode; the rest
2891
+ // of the message stands.)
2892
+ const statesMode = !this.panel || this._takeStatement(msg.modeSeq);
2893
+ if (!statesMode) { permissionMode = ''; this._statementDropped(); }
2894
+ if (this.panel && permissionMode === BYPASS) {
2895
+ const block = panelBypassBlock();
2896
+ if (block) { permissionMode = 'default'; this.sendEvent(this._numbered({ type: 'mode_changed', mode: 'default', reason: block })); }
2897
+ }
2898
+ let backToSettings = null;
2899
+ if (permissionMode && (this.panel ? PANEL_PERMISSION_MODES : CLASSIC_PERMISSION_MODES).includes(permissionMode)) {
2900
+ this.permissionMode = permissionMode;
2901
+ this._modeFromSettings = false;
2902
+ if (this.panel && permissionMode === BYPASS) this._bypassHeld = true;
2903
+ this._stateMode();
2904
+ } else if (this.panel && statesMode && !permissionMode && msg.modeFromSettings === true) {
2905
+ // The tab never picked a mode: the user's settings decide (permissions.defaultMode).
2906
+ // A live process that was put in a stated mode meanwhile (the tab was in
2907
+ // plan mode and left it) goes back to the settings' mode now.
2908
+ if (this.q && !warmOnly && this._modeFromSettings !== true) backToSettings = this._returnToSettings();
2909
+ this._modeFromSettings = true;
2910
+ this._stateMode();
2911
+ }
2912
+ if (warmOnly) {
2913
+ // Configuration only: no prompt, no turn. (No live query here: see the `warm` case.)
2914
+ // (A starting control's configuration starts nothing; a process kept ready for another one is ended.)
2915
+ if (establishOnly) this._warmStillFits();
2916
+ if (!establishOnly && !this.q && !this.destroyed) this.ensureQuery({ warm: true });
2917
+ return;
2918
+ }
2919
+ this.lastPrompt = prompt;
2920
+
2921
+ // Effort alone switches on the live CLI. A restart would cold-start the
2922
+ // session and kill any background agents it is running.
2923
+ if (this.q && effortChanged && !cwdChanged && !sessionChanged && !newChat
2924
+ && typeof this.q.applyFlagSettings === 'function') {
2925
+ const effortLevel = CLAUDE_EFFORT_LEVELS.includes(effort) ? effort : null;
2926
+ this.q.applyFlagSettings({ effortLevel }).catch(err => log('applyFlagSettings(effort) failed:', err.message));
2927
+ effortChanged = false;
2928
+ }
2929
+ if (this.q && this.panel && !cwdChanged && !sessionChanged && !newChat && !policyChanged
2930
+ && typeof this.q.applyFlagSettings === 'function') {
2931
+ // Fast mode, output style and the main-thread agent switch on the live
2932
+ // session; so does a return to the default effort.
2933
+ const patch = liveSettingsPatch(this._liveApplied, this.panelSession?.live) || {};
2934
+ if (effortCleared) patch.effortLevel = null;
2935
+ if (Object.keys(patch).length) {
2936
+ this._liveApplied = { ...(this.panelSession?.live || {}) };
2937
+ this.q.applyFlagSettings(patch).catch(err => {
2938
+ log('applyFlagSettings(session) failed:', err.message);
2939
+ this.send({ type: 'stderr', text: `Could not apply the session settings: ${err.message}` });
2940
+ });
2941
+ }
2942
+ }
2943
+
2944
+ if (this.q && (cwdChanged || effortChanged || sessionChanged || newChat || typesafeChanged || policyChanged)) {
2945
+ log(`config change (cwd=${!!cwdChanged} effort=${!!effortChanged} session=${!!sessionChanged} newChat=${newChat} typesafe=${typesafeChanged}${policyChanged ? ' toolPolicy=true' : ''}) — recreating query`);
2946
+ const restarted = this._endQuery({ graceful: true }).then(() => {
2947
+ if (this.destroyed) return;
2948
+ this.ensureQuery();
2949
+ this._pushUserText(prompt, images, { human: this.panel && msg.typed === true });
2950
+ });
2951
+ // Panel sessions: the message holds its turn until the restart is done
2952
+ // (handleMessage waits on what this returns), so the next message meets
2953
+ // the new process and not the gap before it.
2954
+ return this.panel ? restarted : undefined;
2955
+ }
2956
+
2957
+ const hadQuery = !!this.q;
2958
+ this._pushUserText(prompt, images, { human: this.panel && msg.typed === true });
2959
+
2960
+ if (hadQuery && modelChanged && this.q) {
2961
+ this.q.setModel(model).catch(err => log('setModel failed:', err.message));
2962
+ }
2963
+ if (hadQuery && permissionMode && this.q) {
2964
+ const q = this.q;
2965
+ const stated = this.permissionMode;
2966
+ const stamp = this._modeStatements;
2967
+ // (Panel sessions: a Bypass the CLI refuses leaves the tab in Default, and it is told,
2968
+ // unless the mode was stated again or the process replaced meanwhile.)
2969
+ (this.panel ? this._switchMode(q, stated) : q.setPermissionMode(stated)).catch((err) => { if (this.panel && stated === BYPASS && this._modeSwitchStands(q, stamp)) this._bypassRefused(q, err?.message); });
2970
+ }
2971
+ if (hadQuery && backToSettings?.mode && this.q) {
2972
+ const q = this.q;
2973
+ const stamp = this._modeStatements;
2974
+ this._switchMode(q, backToSettings.mode).catch((err) => { if (backToSettings.mode === BYPASS && this._modeSwitchStands(q, stamp)) this._bypassRefused(q, err?.message); });
2975
+ if (backToSettings.reason) this.sendEvent(this._numbered({ type: 'mode_changed', mode: backToSettings.mode, fromSettings: true, reason: backToSettings.reason }));
2976
+ }
2977
+ }
2978
+
2979
+ // ── detach / reattach / destroy ───────────────────────────────────────────
2980
+
2981
+ detach() {
2982
+ if (this.destroyed) return false;
2983
+ // A temporary chat whose page is gone is over: it is never kept for a reattach.
2984
+ if (this.temporary) return false;
2985
+ // Background work counts: a page reload must not kill agents that are still
2986
+ // running after their turn ended — the reloaded tab reattaches to them.
2987
+ const busy = this.inTurn || this.pendingPerms.size > 0 || this._hasPendingWork();
2988
+ if (!this.windowId || !busy || !this.q) return false;
2989
+ this.orphanBuffer = [];
2990
+ this.orphanResynced = false;
2991
+ this.ws = null;
2992
+ const key = _orphanKey(this.windowId, this.sessionId);
2993
+ _detached.set(key, this);
2994
+ this.orphanKillTimer = setTimeout(() => {
2995
+ log(`orphan grace expired for ${key} — destroying session`);
2996
+ _detached.delete(key);
2997
+ this.destroy();
2998
+ }, ORPHAN_GRACE_MS);
2999
+ log(`detached session ${key} (turn in flight: ${this.inTurn}, pending perms: ${this.pendingPerms.size}, background tasks: ${this._bgTasks.size}, crons: ${this._sessionCrons.length})`);
3000
+ return true;
3001
+ }
3002
+
3003
+ reattach(ws) {
3004
+ clearTimeout(this.orphanKillTimer);
3005
+ this.orphanKillTimer = null;
3006
+ this.ws = ws;
3007
+ const buffer = this.orphanBuffer || [];
3008
+ this.orphanBuffer = null;
3009
+ // reattach_result FIRST: the client must restore running/session state before
3010
+ // any replayed control_request re-arms its permission buffering.
3011
+ const result = {
3012
+ type: 'reattach_result',
3013
+ ok: true,
3014
+ sessionId: this.sessionId,
3015
+ running: this.inTurn || this.pendingPerms.size > 0,
3016
+ backgroundTasks: [...this._bgTasks.values()],
3017
+ };
3018
+ // A reloaded page lost its task and wakeup lists: hand both back in full
3019
+ // (backgroundTasks above; the wakeups with their schedule and prompt).
3020
+ if (this.panel) { result.sessionCrons = this._sessionCrons.length; result.sessionCronList = this._cronList(); result.grantedRules = [...this._grantedRules]; }
3021
+ // (A page that numbers its statements: the mode this session is in, and the
3022
+ // number of the latest statement it applied. The page says its own again
3023
+ // when that is behind.
3024
+ // What it is in is the mode the CLI acknowledged or reported, with a switch
3025
+ // under way named beside it and whether the session may be in Bypass.)
3026
+ if (this.panel && this._pageSeq !== undefined) { Object.assign(result, this._modeFacts()); if (this._modeFromSettings) result.fromSettings = true; }
3027
+ if (this.orphanResynced) result.resynced = true;
3028
+ this.orphanResynced = false;
3029
+ if (ws.readyState === 1) { try { ws.send(JSON.stringify(result)); } catch {} }
3030
+ // A reloaded page lost the cards for requests issued before the socket
3031
+ // dropped, and the turn would wait on them forever. Re-send every pending one;
3032
+ // the panel ignores a request_id it already shows.
3033
+ for (const entry of this.pendingPerms.values()) {
3034
+ if (entry.wire && ws.readyState === 1) { try { ws.send(JSON.stringify(entry.wire)); } catch {} }
3035
+ }
3036
+ // Chunked replay — serializing + sending up to ORPHAN_BUFFER_CAP events in
3037
+ // one tick stalls every other session on this single-threaded server.
3038
+ // ~50 events per slice, pausing while the socket buffer is saturated.
3039
+ // Live events generated mid-replay queue behind the buffered ones
3040
+ // (this._replaying in send()) so ordering is preserved.
3041
+ this._replaying = true;
3042
+ this._replayQueue = [];
3043
+ const REPLAY_SLICE = 50;
3044
+ let idx = 0;
3045
+ const pump = () => {
3046
+ if (this.destroyed || this.ws !== ws || ws.readyState !== 1) {
3047
+ this._replaying = false;
3048
+ this._replayQueue = null;
3049
+ return;
3050
+ }
3051
+ if (ws.bufferedAmount > WS_BACKPRESSURE_BYTES) {
3052
+ setTimeout(pump, 50);
3053
+ return;
3054
+ }
3055
+ let n = 0;
3056
+ while (idx < buffer.length && n++ < REPLAY_SLICE) {
3057
+ try { ws.send(JSON.stringify(buffer[idx])); } catch {}
3058
+ idx++;
3059
+ }
3060
+ if (idx < buffer.length) { setImmediate(pump); return; }
3061
+ // Flush events that arrived mid-replay, then resume direct sends
3062
+ const queued = this._replayQueue || [];
3063
+ this._replaying = false;
3064
+ this._replayQueue = null;
3065
+ for (const evt of queued) {
3066
+ if (ws.readyState === 1) { try { ws.send(JSON.stringify(evt)); } catch {} }
3067
+ }
3068
+ log(`reattached session ${_orphanKey(this.windowId, this.sessionId)}, replayed ${buffer.length} events (+${queued.length} live)`);
3069
+ };
3070
+ pump();
3071
+ }
3072
+
3073
+ destroy() {
3074
+ if (this.destroyed) return;
3075
+ this.destroyed = true;
3076
+ this._closeWarm();
3077
+ clearTimeout(this._watchdogStartTimer);
3078
+ clearInterval(this._watchdog);
3079
+ clearInterval(this._lockBeat);
3080
+ clearInterval(this._idleReaper);
3081
+ clearTimeout(this.orphanKillTimer);
3082
+ clearTimeout(this._idleCheck);
3083
+ this._replaying = false;
3084
+ this._replayQueue = null;
3085
+ for (const slot of this._deltaBuf.values()) clearTimeout(slot.timer);
3086
+ this._deltaBuf.clear();
3087
+ this._denyAllPending('Session closed');
3088
+ try { this.input?.close(); } catch {}
3089
+ try { this.abortController?.abort(); } catch {}
3090
+ this.q = null;
3091
+ if (this.temporary) this._removeTemporaryPlans();
3092
+ if (this.windowId) {
3093
+ try { deps.releaseAllLocks(this.windowId); } catch {}
3094
+ const key = _orphanKey(this.windowId, this.sessionId);
3095
+ if (_detached.get(key) === this) _detached.delete(key);
3096
+ }
3097
+ // Release this chat session's owned browser tab so the shared browser can
3098
+ // grace-reap once no owners remain. Best-effort, in-process on the NI side.
3099
+ try { deps.releaseBrowserOwner?.(this.synabunOwnerKey); } catch {}
3100
+ _allSessions.delete(this);
3101
+ }
3102
+ }
3103
+
3104
+ // ── connection layer ─────────────────────────────────────────────────────────
3105
+
3106
+ export function createClaudeBridge(ws) {
3107
+ if (!deps) throw new Error('claude-agent-bridge not configured — call configureClaudeBridge(deps) first');
3108
+
3109
+ let session = null;
3110
+
3111
+ // Engine hello — lets the frontend gate SDK-only UI
3112
+ try {
3113
+ ws.send(JSON.stringify({ type: 'engine', engine: 'sdk', sdkVersion: SDK_VERSION, capabilities: panelCapabilities() }));
3114
+ } catch {}
3115
+
3116
+ // Ping/pong keepalive (same cadence as legacy)
3117
+ let alive = true;
3118
+ ws.on('pong', () => { alive = true; });
3119
+ const pingInterval = setInterval(() => {
3120
+ if (!alive) { try { ws.terminate(); } catch {} clearInterval(pingInterval); return; }
3121
+ alive = false;
3122
+ if (ws.readyState === 1) ws.ping();
3123
+ }, 30_000);
3124
+
3125
+ ws.on('message', (raw) => {
3126
+ let msg;
3127
+ try { msg = JSON.parse(raw.toString()); } catch { return; }
3128
+ try {
3129
+ if (msg.type === 'dispose') {
3130
+ // The tab was closed. End its session now instead of orphaning it for a
3131
+ // reattach that no tab will ever send.
3132
+ if (session) { session.destroy(); session = null; }
3133
+ return;
3134
+ }
3135
+ if (msg.type === 'reattach') {
3136
+ const wid = msg.windowId;
3137
+ if (!wid) return;
3138
+ const key = _orphanKey(wid, msg.sessionId);
3139
+ const orphan = _detached.get(key);
3140
+ if (!orphan || orphan.destroyed) {
3141
+ if (orphan) _detached.delete(key);
3142
+ try { ws.send(JSON.stringify({ type: 'reattach_result', ok: false })) } catch {}
3143
+ // No orphan: fall through to a fresh session bound to this windowId
3144
+ if (!session) { session = new ClaudeSession(ws, PANEL_SESSION_OPTS); }
3145
+ session.windowId = wid;
3146
+ if (msg.sessionId) session.sessionId = msg.sessionId;
3147
+ return;
3148
+ }
3149
+ _detached.delete(key);
3150
+ if (session && session !== orphan) session.destroy();
3151
+ session = orphan;
3152
+ session.reattach(ws);
3153
+ return;
3154
+ }
3155
+ if (!session) {
3156
+ session = new ClaudeSession(ws, PANEL_SESSION_OPTS);
3157
+ }
3158
+ session.handleMessage(msg);
3159
+ } catch (err) {
3160
+ log('message handling error:', err.message);
3161
+ }
3162
+ });
3163
+
3164
+ ws.on('close', () => {
3165
+ clearInterval(pingInterval);
3166
+ if (!session) return;
3167
+ if (session.detach()) return; // turn in flight → orphaned for reattach
3168
+ session.destroy();
3169
+ });
3170
+ }
3171
+
3172
+ // End one sidepanel session by window + Claude session id (tray ×, or a closed
3173
+ // tab whose socket was already gone). Never touches a session that is still
3174
+ // bound to an open socket — that one belongs to a live tab.
3175
+ export function killSession(windowId, sessionId) {
3176
+ if (!windowId || !sessionId) return false;
3177
+ const target = _detached.get(_orphanKey(windowId, sessionId))
3178
+ || [..._allSessions].find(s => !s.destroyed && s.windowId === windowId
3179
+ && s.sessionId === sessionId && !(s.ws && s.ws.readyState === 1));
3180
+ if (!target) return false;
3181
+ target.destroy();
3182
+ return true;
3183
+ }
3184
+
3185
+ // Whether a session has a live CLI process behind it (a tab is using it, or it
3186
+ // is running detached). The server asks before deleting a transcript.
3187
+ export function isSessionLive(sessionId) {
3188
+ if (!sessionId) return false;
3189
+ return [..._allSessions].some(s => !s.destroyed && s.sessionId === sessionId && !!s.q);
3190
+ }
3191
+
3192
+ export function shutdownAllBridges() {
3193
+ for (const session of [..._allSessions]) {
3194
+ try { session.destroy(); } catch {}
3195
+ }
3196
+ _detached.clear();
3197
+ // (A temporary chat's plan files that are still owed: the last chance, with no process waited for.)
3198
+ _removeOwedTemporaryPlans({ all: true });
3199
+ }
3200
+
3201
+ export function bridgeStats() {
3202
+ return { sessions: _allSessions.size, detached: _detached.size, sdkVersion: SDK_VERSION };
3203
+ }