synabun 1.3.0 → 2.0.0-beta

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