@thaut/claude-ide 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (559) hide show
  1. package/bin/claude-ide.js +23 -0
  2. package/package.json +75 -4
  3. package/server/add-dirs.ts +126 -0
  4. package/server/auth.test.ts +148 -0
  5. package/server/auth.ts +235 -0
  6. package/server/bang.ts +54 -0
  7. package/server/btw-instructions.ts +19 -0
  8. package/server/btw-policy.ts +17 -0
  9. package/server/btw.ts +143 -0
  10. package/server/child-env.ts +29 -0
  11. package/server/cli-control.ts +255 -0
  12. package/server/cli-files.ts +55 -0
  13. package/server/cli-info.ts +73 -0
  14. package/server/config/account.ts +65 -0
  15. package/server/config/agents.ts +186 -0
  16. package/server/config/mcp.ts +86 -0
  17. package/server/config/memory.ts +193 -0
  18. package/server/config/paths.ts +156 -0
  19. package/server/config/plugins.ts +204 -0
  20. package/server/config/routes.ts +95 -0
  21. package/server/config/settings.ts +246 -0
  22. package/server/config.ts +46 -0
  23. package/server/db.ts +121 -0
  24. package/server/directories.test.ts +180 -0
  25. package/server/directories.ts +87 -0
  26. package/server/dist-version.ts +69 -0
  27. package/server/durable-tasks.ts +61 -0
  28. package/server/file-transfers.ts +79 -0
  29. package/server/files.test.ts +127 -0
  30. package/server/files.ts +386 -0
  31. package/server/fs-queue.ts +14 -0
  32. package/server/fsutil.ts +144 -0
  33. package/server/git.ts +86 -0
  34. package/server/goal-state.test.ts +64 -0
  35. package/server/goal-state.ts +78 -0
  36. package/server/history-pages.test.ts +619 -0
  37. package/server/history-pages.ts +513 -0
  38. package/server/http-channel-manager.ts +468 -0
  39. package/server/http-channel-pull.test.ts +314 -0
  40. package/server/http-channel-push.test.ts +364 -0
  41. package/server/http-channel-registration.test.ts +291 -0
  42. package/server/http-channel-routes.ts +73 -0
  43. package/server/http-channel-store.ts +142 -0
  44. package/server/http-channel-test-fixture.ts +103 -0
  45. package/server/ide-messages-mcp.test.ts +82 -0
  46. package/server/ide-messages-mcp.ts +47 -0
  47. package/server/idecron-mcp.ts +65 -0
  48. package/server/idecron.test.ts +79 -0
  49. package/server/idecron.ts +271 -0
  50. package/server/index.ts +336 -0
  51. package/server/live-session.test.ts +1126 -0
  52. package/server/live-session.ts +1165 -0
  53. package/server/project-routes.ts +46 -0
  54. package/server/projects.test.ts +28 -0
  55. package/server/push.ts +315 -0
  56. package/server/report-cache.ts +28 -0
  57. package/server/session-aliases.test.ts +57 -0
  58. package/server/session-aliases.ts +50 -0
  59. package/server/session-branch.ts +71 -0
  60. package/server/session-dirs.ts +189 -0
  61. package/server/session-file.test.ts +134 -0
  62. package/server/session-file.ts +113 -0
  63. package/server/session-messaging.test.ts +297 -0
  64. package/server/session-messaging.ts +165 -0
  65. package/server/sessions.ts +638 -0
  66. package/server/subagents.test.ts +43 -0
  67. package/server/subagents.ts +58 -0
  68. package/server/tool-images.test.ts +76 -0
  69. package/server/tool-images.ts +45 -0
  70. package/server/turn-changes.ts +308 -0
  71. package/server/worktree-git.ts +87 -0
  72. package/server/worktrees.ts +138 -0
  73. package/server/ws.test.ts +195 -0
  74. package/server/ws.ts +197 -0
  75. package/shared/agent-model.ts +15 -0
  76. package/shared/agent-tasks.ts +12 -0
  77. package/shared/config.ts +175 -0
  78. package/shared/directories.ts +12 -0
  79. package/shared/files.ts +51 -0
  80. package/shared/http-channels.ts +63 -0
  81. package/shared/message-window.ts +189 -0
  82. package/shared/project.ts +77 -0
  83. package/shared/protocol.ts +203 -0
  84. package/shared/task-types.ts +4 -0
  85. package/shared/todos.ts +40 -0
  86. package/shared/write-policy.ts +9 -0
  87. package/web/dist/assets/AgentView-wrTKgcJ3.js +1 -0
  88. package/web/dist/assets/AnnotationSelection-BZmNmwVW.js +7 -0
  89. package/web/dist/assets/ChangesView-G1WTzh4q.js +2 -0
  90. package/web/dist/assets/FilesView-DPgAbYt_.js +6 -0
  91. package/web/dist/assets/Form-BKTwiYyw.js +1 -0
  92. package/web/dist/assets/HomePage-Dq2apjBe.js +1 -0
  93. package/web/dist/assets/Markdown-IwP-xF29.js +67 -0
  94. package/web/dist/assets/MessageList-DD9GypSy.js +15 -0
  95. package/web/dist/assets/Modal-g2rv8I_n.js +1 -0
  96. package/web/dist/assets/NotificationSettings-DpuLO_Bv.js +1 -0
  97. package/web/dist/assets/OverviewPage-Dx6miabE.js +1 -0
  98. package/web/dist/assets/ProjectHome-CousyTLL.js +1 -0
  99. package/web/dist/assets/SessionView-CPVL7_HO.js +2 -0
  100. package/web/dist/assets/SettingsPage-D_oYSnW2.js +7 -0
  101. package/web/dist/assets/WorktreesView-C7e0HV__.js +1 -0
  102. package/web/dist/assets/abap-CLvhMVsD.js +1 -0
  103. package/web/dist/assets/actionscript-3-Cs6JJXT5.js +1 -0
  104. package/web/dist/assets/ada-X3Y2mlq2.js +1 -0
  105. package/web/dist/assets/ahk-DwADkxyd.js +1 -0
  106. package/web/dist/assets/ahk2-_6rzW9BS.js +1 -0
  107. package/web/dist/assets/andromeeda-vGVdxbeo.js +1 -0
  108. package/web/dist/assets/angular-html-Cw29lun8.js +1 -0
  109. package/web/dist/assets/angular-ts-BXz-O3SJ.js +1 -0
  110. package/web/dist/assets/apache-U0d_L8uA.js +1 -0
  111. package/web/dist/assets/apex-KFGFyKwG.js +1 -0
  112. package/web/dist/assets/apl-xBchdRhy.js +1 -0
  113. package/web/dist/assets/applescript-CCn79oCD.js +1 -0
  114. package/web/dist/assets/ara-4CJ0cIlV.js +1 -0
  115. package/web/dist/assets/asciidoc-SCjQUq34.js +1 -0
  116. package/web/dist/assets/asm-Cmm7eHzH.js +1 -0
  117. package/web/dist/assets/astro-1q6DGuN3.js +1 -0
  118. package/web/dist/assets/aurora-x-CDeNXAV0.js +1 -0
  119. package/web/dist/assets/awk-BWXHIvNe.js +1 -0
  120. package/web/dist/assets/ayu-dark-DluEY0Gj.js +1 -0
  121. package/web/dist/assets/ayu-light-C3h-C4tm.js +1 -0
  122. package/web/dist/assets/ayu-mirage-Bqwy1Gya.js +1 -0
  123. package/web/dist/assets/ballerina-B7ZEbQpA.js +1 -0
  124. package/web/dist/assets/bat-qdxAdBWQ.js +1 -0
  125. package/web/dist/assets/beancount-C2IN4Vk8.js +1 -0
  126. package/web/dist/assets/berry-DKpUyyne.js +1 -0
  127. package/web/dist/assets/bibtex-Ci_nEsc7.js +1 -0
  128. package/web/dist/assets/bicep-CUHmPFLl.js +1 -0
  129. package/web/dist/assets/bird2-CYIUgYqB.js +1 -0
  130. package/web/dist/assets/blade-Cqdv4oZN.js +1 -0
  131. package/web/dist/assets/bsl-CVED6lJ1.js +1 -0
  132. package/web/dist/assets/c-D9gIxSfx.js +1 -0
  133. package/web/dist/assets/c3-D0apG41V.js +1 -0
  134. package/web/dist/assets/cadence-CQ2zXKGN.js +1 -0
  135. package/web/dist/assets/cairo-DRAZz75c.js +1 -0
  136. package/web/dist/assets/catppuccin-frappe-C9Q5zVZY.js +1 -0
  137. package/web/dist/assets/catppuccin-latte-CtsyrtPq.js +1 -0
  138. package/web/dist/assets/catppuccin-macchiato-R8nAf1vR.js +1 -0
  139. package/web/dist/assets/catppuccin-mocha-DjuaAJKi.js +1 -0
  140. package/web/dist/assets/chapel-CuseGurG.js +1 -0
  141. package/web/dist/assets/clarity-CtfBBspg.js +1 -0
  142. package/web/dist/assets/clojure-DqKBuwfJ.js +1 -0
  143. package/web/dist/assets/cmake-Bj61d0ZC.js +1 -0
  144. package/web/dist/assets/cobol-Bdpj3Qoh.js +1 -0
  145. package/web/dist/assets/codeowners-C8r90Shi.js +1 -0
  146. package/web/dist/assets/codeql-oeQT6MSM.js +1 -0
  147. package/web/dist/assets/coffee-Sk8pHFjs.js +1 -0
  148. package/web/dist/assets/common-lisp-Cv5bFMCO.js +1 -0
  149. package/web/dist/assets/coq-CGaSfhwR.js +1 -0
  150. package/web/dist/assets/core-BxI4V4-T.js +12 -0
  151. package/web/dist/assets/cpp-BQfBYKx3.js +1 -0
  152. package/web/dist/assets/crystal-B3j7DoOZ.js +1 -0
  153. package/web/dist/assets/csharp-dmMxLVLW.js +1 -0
  154. package/web/dist/assets/css-DpFbYmRh.js +1 -0
  155. package/web/dist/assets/csv-Dx-8-gkx.js +1 -0
  156. package/web/dist/assets/cue-CE9AQfxI.js +1 -0
  157. package/web/dist/assets/cypher-ClKdZ_lG.js +1 -0
  158. package/web/dist/assets/d-qD-0Kul2.js +1 -0
  159. package/web/dist/assets/dark-plus-Cs2F2srj.js +1 -0
  160. package/web/dist/assets/dart-CHZY9Xhz.js +1 -0
  161. package/web/dist/assets/dax-BkyTk9wS.js +1 -0
  162. package/web/dist/assets/desktop-Dlh5hvp9.js +1 -0
  163. package/web/dist/assets/diff-woXpYk--.js +1 -0
  164. package/web/dist/assets/docker-IyjqRm3v.js +1 -0
  165. package/web/dist/assets/dotenv-CuH5my32.js +1 -0
  166. package/web/dist/assets/dracula-BHWKrbxM.js +1 -0
  167. package/web/dist/assets/dracula-soft-5eyTD99u.js +1 -0
  168. package/web/dist/assets/dream-maker-DW3nJb8Q.js +1 -0
  169. package/web/dist/assets/edge-DXQWmXTB.js +1 -0
  170. package/web/dist/assets/elixir-CrDIY2Vo.js +1 -0
  171. package/web/dist/assets/elm-BslFEg8K.js +1 -0
  172. package/web/dist/assets/emacs-lisp-DR2vtWaj.js +1 -0
  173. package/web/dist/assets/engine-javascript-DJg3-Vu2.js +141 -0
  174. package/web/dist/assets/erb-CUYhg0Ws.js +1 -0
  175. package/web/dist/assets/erlang-BXdgHVfi.js +1 -0
  176. package/web/dist/assets/everforest-dark-sB-x3p7T.js +1 -0
  177. package/web/dist/assets/everforest-light-Df2xbC6M.js +1 -0
  178. package/web/dist/assets/fennel-DQxkIbk2.js +1 -0
  179. package/web/dist/assets/fish-BJitypiv.js +1 -0
  180. package/web/dist/assets/fluent-C03EYrpw.js +1 -0
  181. package/web/dist/assets/format-DrBC7w0A.js +1 -0
  182. package/web/dist/assets/fortran-fixed-form-D6mN0kjy.js +1 -0
  183. package/web/dist/assets/fortran-free-form-CYNrtFtB.js +1 -0
  184. package/web/dist/assets/fsharp-CwoEF39o.js +1 -0
  185. package/web/dist/assets/gdresource-CZkFGo3B.js +1 -0
  186. package/web/dist/assets/gdscript-DYvRD3hg.js +1 -0
  187. package/web/dist/assets/gdshader-CBce3t8t.js +1 -0
  188. package/web/dist/assets/genie-CV2tkWYe.js +1 -0
  189. package/web/dist/assets/gherkin-DExj1W_8.js +1 -0
  190. package/web/dist/assets/git-commit-dUGrvm6Q.js +1 -0
  191. package/web/dist/assets/git-rebase-4zCRqF28.js +1 -0
  192. package/web/dist/assets/github-dark-C-LZuMrd.js +1 -0
  193. package/web/dist/assets/github-dark-default-DXG-b-1a.js +1 -0
  194. package/web/dist/assets/github-dark-dimmed-Bx1FflLF.js +1 -0
  195. package/web/dist/assets/github-dark-high-contrast-B_tTalzw.js +1 -0
  196. package/web/dist/assets/github-light-EUqPIrTm.js +1 -0
  197. package/web/dist/assets/github-light-default-BXViO-2h.js +1 -0
  198. package/web/dist/assets/github-light-high-contrast-B68TUdTA.js +1 -0
  199. package/web/dist/assets/gleam-CSRkHgEL.js +1 -0
  200. package/web/dist/assets/glimmer-js-7adTRvH4.js +1 -0
  201. package/web/dist/assets/glimmer-ts-IdSskDYw.js +1 -0
  202. package/web/dist/assets/glsl-Qzy53KDt.js +1 -0
  203. package/web/dist/assets/gn-CAXYAgV-.js +1 -0
  204. package/web/dist/assets/gnuplot-7GGW24-e.js +1 -0
  205. package/web/dist/assets/go-rLFTqkRN.js +1 -0
  206. package/web/dist/assets/graphql-CV_puxbB.js +1 -0
  207. package/web/dist/assets/groovy-CacY0gHj.js +1 -0
  208. package/web/dist/assets/gruvbox-dark-hard-C820rvS2.js +1 -0
  209. package/web/dist/assets/gruvbox-dark-medium-BPjhmG05.js +1 -0
  210. package/web/dist/assets/gruvbox-dark-soft-MrdJrrXF.js +1 -0
  211. package/web/dist/assets/gruvbox-light-hard-BC_s9l72.js +1 -0
  212. package/web/dist/assets/gruvbox-light-medium-BAWPOn9u.js +1 -0
  213. package/web/dist/assets/gruvbox-light-soft-BSMLrYjP.js +1 -0
  214. package/web/dist/assets/hack-C5GPjiLV.js +1 -0
  215. package/web/dist/assets/haml-BQ5YR1DQ.js +1 -0
  216. package/web/dist/assets/handlebars-DnhRK9A1.js +1 -0
  217. package/web/dist/assets/haskell-D8IpX4py.js +1 -0
  218. package/web/dist/assets/haxe-C8oCnwJJ.js +1 -0
  219. package/web/dist/assets/hcl-Dh228itO.js +1 -0
  220. package/web/dist/assets/highlight.worker-CY-ZeDAl.js +1 -0
  221. package/web/dist/assets/hjson-CxZEssPk.js +1 -0
  222. package/web/dist/assets/hlsl-Cvrh5tZx.js +1 -0
  223. package/web/dist/assets/horizon-CE9ld1lL.js +1 -0
  224. package/web/dist/assets/horizon-bright-DSNQnXHK.js +1 -0
  225. package/web/dist/assets/houston-CsvMBhTu.js +1 -0
  226. package/web/dist/assets/html-CmHiEKV9.js +1 -0
  227. package/web/dist/assets/html-derivative-BXE-rYIK.js +1 -0
  228. package/web/dist/assets/http-DPjjqtcL.js +1 -0
  229. package/web/dist/assets/hurl-AMBpSloO.js +1 -0
  230. package/web/dist/assets/hxml-D7Y24wKF.js +1 -0
  231. package/web/dist/assets/hy-CZbG8q4J.js +1 -0
  232. package/web/dist/assets/imba-DsUTQ-LC.js +1 -0
  233. package/web/dist/assets/index-DNPnl4sh.css +2 -0
  234. package/web/dist/assets/index-DQh72lEx.js +14 -0
  235. package/web/dist/assets/ini-B5eOa1yu.js +1 -0
  236. package/web/dist/assets/java-CEM7Tdw_.js +1 -0
  237. package/web/dist/assets/javascript-CU3PLowd.js +1 -0
  238. package/web/dist/assets/jinja-fR0x_9Xy.js +1 -0
  239. package/web/dist/assets/jison-BoKsxs5M.js +1 -0
  240. package/web/dist/assets/json-BM2FWsRB.js +1 -0
  241. package/web/dist/assets/json5-BR5RXkoi.js +1 -0
  242. package/web/dist/assets/jsonc-CYpm1nAK.js +1 -0
  243. package/web/dist/assets/jsonl-CmCQp5Yx.js +1 -0
  244. package/web/dist/assets/jsonnet-CJTPZ8u_.js +1 -0
  245. package/web/dist/assets/jssm-DXw9l8Rf.js +1 -0
  246. package/web/dist/assets/jsx-B5fTecQP.js +1 -0
  247. package/web/dist/assets/julia-DGyDG39_.js +1 -0
  248. package/web/dist/assets/just-B-RyLcSR.js +1 -0
  249. package/web/dist/assets/kanagawa-dragon-Bozd7klA.js +1 -0
  250. package/web/dist/assets/kanagawa-lotus-BmuOG9ZW.js +1 -0
  251. package/web/dist/assets/kanagawa-wave-BML5mF2J.js +1 -0
  252. package/web/dist/assets/kdl-CsD5j6eV.js +1 -0
  253. package/web/dist/assets/kotlin-DhhofPvG.js +1 -0
  254. package/web/dist/assets/kusto-C7mF5XQf.js +1 -0
  255. package/web/dist/assets/langs-CciPKqaF.js +1 -0
  256. package/web/dist/assets/laserwave-C_8bwKvT.js +1 -0
  257. package/web/dist/assets/latex-CV1pQelm.js +1 -0
  258. package/web/dist/assets/lean-CewbzKMR.js +1 -0
  259. package/web/dist/assets/less-DVTAwKKz.js +1 -0
  260. package/web/dist/assets/lib-DlcVMUqT.js +1 -0
  261. package/web/dist/assets/light-plus-DVQuIRkW.js +1 -0
  262. package/web/dist/assets/liquid-9p05RSzQ.js +1 -0
  263. package/web/dist/assets/llvm-DqHOMS-s.js +1 -0
  264. package/web/dist/assets/log-BNLmms1o.js +1 -0
  265. package/web/dist/assets/logo-Cluzi2Zq.js +1 -0
  266. package/web/dist/assets/lua-CIPVh0S-.js +1 -0
  267. package/web/dist/assets/luau-VGWVLoqX.js +1 -0
  268. package/web/dist/assets/make-Dixweg8N.js +1 -0
  269. package/web/dist/assets/markdown-BYOwaDjH.js +1 -0
  270. package/web/dist/assets/marko-h2h6YUMS.js +1 -0
  271. package/web/dist/assets/material-theme-Bm3Qr25_.js +1 -0
  272. package/web/dist/assets/material-theme-darker-2IIEA8gg.js +1 -0
  273. package/web/dist/assets/material-theme-lighter-uhdI0v04.js +1 -0
  274. package/web/dist/assets/material-theme-ocean-CHQ94UKr.js +1 -0
  275. package/web/dist/assets/material-theme-palenight-B5W6OYN7.js +1 -0
  276. package/web/dist/assets/matlab-D7qyCx1q.js +1 -0
  277. package/web/dist/assets/mdc-yAyZMdoC.js +1 -0
  278. package/web/dist/assets/mdx-DQZ5AkYe.js +1 -0
  279. package/web/dist/assets/mermaid-BxpfTIzt.js +1 -0
  280. package/web/dist/assets/message-font-size-DoaZfwkT.js +1 -0
  281. package/web/dist/assets/min-dark-BSWPekZh.js +1 -0
  282. package/web/dist/assets/min-light-DDpmG2fV.js +1 -0
  283. package/web/dist/assets/mipsasm-BMqwQI7S.js +1 -0
  284. package/web/dist/assets/mojo-fg40_ntQ.js +1 -0
  285. package/web/dist/assets/monokai-BscAjiQZ.js +1 -0
  286. package/web/dist/assets/moonbit-D9W784E_.js +1 -0
  287. package/web/dist/assets/move-BYnGhZoM.js +1 -0
  288. package/web/dist/assets/narrat-_X_XdTYD.js +1 -0
  289. package/web/dist/assets/nextflow-BoTnSBQ_.js +1 -0
  290. package/web/dist/assets/nextflow-groovy-D7IRt6Pp.js +1 -0
  291. package/web/dist/assets/nginx-jWINucNT.js +1 -0
  292. package/web/dist/assets/night-owl-DhmEMT88.js +1 -0
  293. package/web/dist/assets/night-owl-light-eJ-hLW7d.js +1 -0
  294. package/web/dist/assets/nim-Dtt9DZ0t.js +1 -0
  295. package/web/dist/assets/nix-Cl_7024t.js +1 -0
  296. package/web/dist/assets/nord-Cb4Vim4T.js +1 -0
  297. package/web/dist/assets/nsis-CjNLYCLg.js +1 -0
  298. package/web/dist/assets/nushell--0fT3PI1.js +1 -0
  299. package/web/dist/assets/objective-c-D1A_Heim.js +1 -0
  300. package/web/dist/assets/objective-cpp-BsSzOQcm.js +1 -0
  301. package/web/dist/assets/ocaml-O90oeIOV.js +1 -0
  302. package/web/dist/assets/odin-B1RWQWA5.js +1 -0
  303. package/web/dist/assets/one-dark-pro-C6YyR8OD.js +1 -0
  304. package/web/dist/assets/one-light-D7Lr4KcI.js +1 -0
  305. package/web/dist/assets/openscad-BUDT5pXO.js +1 -0
  306. package/web/dist/assets/org-ClD5huOa.js +1 -0
  307. package/web/dist/assets/pascal-4ZHwLPI5.js +1 -0
  308. package/web/dist/assets/perl-BFegxyeJ.js +1 -0
  309. package/web/dist/assets/php-B-qOA3Y4.js +1 -0
  310. package/web/dist/assets/pkl-ot-7Btpt.js +1 -0
  311. package/web/dist/assets/plastic-DQwYfKfQ.js +1 -0
  312. package/web/dist/assets/plsql-DGHpHOYJ.js +1 -0
  313. package/web/dist/assets/po-BiJDBrnU.js +1 -0
  314. package/web/dist/assets/poimandres-DRFjx7u4.js +1 -0
  315. package/web/dist/assets/polar-C7UOKdEL.js +1 -0
  316. package/web/dist/assets/postcss-BXeXVLqQ.js +1 -0
  317. package/web/dist/assets/powerquery-DNMTfnFr.js +1 -0
  318. package/web/dist/assets/powershell-QtSnmXcu.js +1 -0
  319. package/web/dist/assets/prisma-DMgNhnYS.js +1 -0
  320. package/web/dist/assets/prolog-iXnhIJG7.js +1 -0
  321. package/web/dist/assets/proto-DB4EqR-F.js +1 -0
  322. package/web/dist/assets/pug-Cq0NBPu-.js +1 -0
  323. package/web/dist/assets/puppet-CDv2pdJW.js +1 -0
  324. package/web/dist/assets/purescript-9MfHhQsQ.js +1 -0
  325. package/web/dist/assets/python-gzcpVVnB.js +1 -0
  326. package/web/dist/assets/qml-CYUsa87k.js +1 -0
  327. package/web/dist/assets/qmldir-DCQb3MpD.js +1 -0
  328. package/web/dist/assets/qss-Fe1Jh2GI.js +1 -0
  329. package/web/dist/assets/r-4xJhgbOf.js +1 -0
  330. package/web/dist/assets/racket-DcIDlBhZ.js +1 -0
  331. package/web/dist/assets/raku-B3gFvitq.js +1 -0
  332. package/web/dist/assets/razor-BlHaOHx8.js +1 -0
  333. package/web/dist/assets/rbs-C0MpOrj4.js +1 -0
  334. package/web/dist/assets/react-B4UGpcWC.js +1 -0
  335. package/web/dist/assets/red-CYlAq2b0.js +1 -0
  336. package/web/dist/assets/reg-CRGYupPL.js +1 -0
  337. package/web/dist/assets/regexp-BUSxjv2o.js +1 -0
  338. package/web/dist/assets/rel-BtDbiS_P.js +1 -0
  339. package/web/dist/assets/riscv-Ckw8ddFX.js +1 -0
  340. package/web/dist/assets/ron-VUp2lXgN.js +1 -0
  341. package/web/dist/assets/rose-pine-aMsaMD1D.js +1 -0
  342. package/web/dist/assets/rose-pine-dawn-8PwUyIlP.js +1 -0
  343. package/web/dist/assets/rose-pine-moon-DO-qXg-I.js +1 -0
  344. package/web/dist/assets/rosmsg-CAekHB0j.js +1 -0
  345. package/web/dist/assets/rst-zjfguEh7.js +1 -0
  346. package/web/dist/assets/ruby-4oR9waBK.js +1 -0
  347. package/web/dist/assets/rust-DpiIf5JO.js +1 -0
  348. package/web/dist/assets/sas-Do4V_2i9.js +1 -0
  349. package/web/dist/assets/sass-DXrisJhu.js +1 -0
  350. package/web/dist/assets/scala-BiFt_7mu.js +1 -0
  351. package/web/dist/assets/scheme-DQCgrYNe.js +1 -0
  352. package/web/dist/assets/scss-CW6JPtag.js +1 -0
  353. package/web/dist/assets/sdbl-bTVj8UrX.js +1 -0
  354. package/web/dist/assets/shaderlab-C4rnI7x0.js +1 -0
  355. package/web/dist/assets/shellscript-zBmOMFJh.js +1 -0
  356. package/web/dist/assets/shellsession-DxGUTWaf.js +1 -0
  357. package/web/dist/assets/slack-dark-DnToyrRv.js +1 -0
  358. package/web/dist/assets/slack-ochin-B2OO5cIa.js +1 -0
  359. package/web/dist/assets/smalltalk-BHpQb5nM.js +1 -0
  360. package/web/dist/assets/smithy-Dgaz7aur.js +1 -0
  361. package/web/dist/assets/snazzy-light-4G7pJPwS.js +1 -0
  362. package/web/dist/assets/solarized-dark-b97czJOk.js +1 -0
  363. package/web/dist/assets/solarized-light-DFPxpIYQ.js +1 -0
  364. package/web/dist/assets/solidity-Bj7E1mRn.js +1 -0
  365. package/web/dist/assets/soy-DVtw20Yn.js +1 -0
  366. package/web/dist/assets/sparql-C0ia5K60.js +1 -0
  367. package/web/dist/assets/splunk-BC2Px7Mm.js +1 -0
  368. package/web/dist/assets/sql-DGCM-jag.js +1 -0
  369. package/web/dist/assets/ssh-config-BgfXC-Er.js +1 -0
  370. package/web/dist/assets/stata-DfuMOXna.js +1 -0
  371. package/web/dist/assets/stylus-B6D30XZt.js +1 -0
  372. package/web/dist/assets/surrealql-CBoRWxfR.js +1 -0
  373. package/web/dist/assets/svelte-g_uCUem5.js +1 -0
  374. package/web/dist/assets/swift-CyEgAFGc.js +1 -0
  375. package/web/dist/assets/synthwave-84-nFMaYfgc.js +1 -0
  376. package/web/dist/assets/system-verilog-Dh5ihWbf.js +1 -0
  377. package/web/dist/assets/systemd-BxMlprV5.js +1 -0
  378. package/web/dist/assets/talonscript-CohzipZa.js +1 -0
  379. package/web/dist/assets/tasl-DMoTqEGO.js +1 -0
  380. package/web/dist/assets/tcl-CZd0xW_V.js +1 -0
  381. package/web/dist/assets/templ-N5C1hJ4Y.js +1 -0
  382. package/web/dist/assets/terraform-DswuEJGm.js +1 -0
  383. package/web/dist/assets/tex-mx6xDwuK.js +1 -0
  384. package/web/dist/assets/themes-CNElGaXw.js +1 -0
  385. package/web/dist/assets/tokyo-night-oM2G3aXe.js +1 -0
  386. package/web/dist/assets/toml-CcmNWLt0.js +1 -0
  387. package/web/dist/assets/ts-tags-C-j71FRb.js +1 -0
  388. package/web/dist/assets/tsv-sltzmVWM.js +1 -0
  389. package/web/dist/assets/tsx-z04y7qR9.js +1 -0
  390. package/web/dist/assets/turtle-ByJddavk.js +1 -0
  391. package/web/dist/assets/twig-Bz57G_nl.js +1 -0
  392. package/web/dist/assets/typescript-RMqVTmzR.js +1 -0
  393. package/web/dist/assets/typespec-BGhR2SqG.js +1 -0
  394. package/web/dist/assets/typst-BgkqA2Pg.js +1 -0
  395. package/web/dist/assets/v-DBj4K9d_.js +1 -0
  396. package/web/dist/assets/vala-zf12oZj6.js +1 -0
  397. package/web/dist/assets/vb-DOwzaHZ0.js +1 -0
  398. package/web/dist/assets/verilog-B2gwz-sJ.js +1 -0
  399. package/web/dist/assets/vesper-DdrHHSXu.js +1 -0
  400. package/web/dist/assets/vhdl-BroJfC0k.js +1 -0
  401. package/web/dist/assets/viml-DvXPmvsu.js +1 -0
  402. package/web/dist/assets/vitesse-black-fwtXNY1n.js +1 -0
  403. package/web/dist/assets/vitesse-dark-BZCL-v6S.js +1 -0
  404. package/web/dist/assets/vitesse-light-VbXTXTou.js +1 -0
  405. package/web/dist/assets/vue-CQgvQytb.js +1 -0
  406. package/web/dist/assets/vue-html-Dhl6fh9l.js +1 -0
  407. package/web/dist/assets/vue-vine-Dw_uU59K.js +1 -0
  408. package/web/dist/assets/vyper-CgoNMtux.js +1 -0
  409. package/web/dist/assets/wasm-ByWQv1Qj.js +1 -0
  410. package/web/dist/assets/wenyan-C8pVoKbM.js +1 -0
  411. package/web/dist/assets/wgsl-BsKzXJz4.js +1 -0
  412. package/web/dist/assets/wikitext-ClFFjSW2.js +1 -0
  413. package/web/dist/assets/wit-DdvCle-K.js +1 -0
  414. package/web/dist/assets/wolfram-DLL8P-h_.js +1 -0
  415. package/web/dist/assets/xml-B49klYQ3.js +1 -0
  416. package/web/dist/assets/xsl-D7tnD3qq.js +1 -0
  417. package/web/dist/assets/yaml-DNQ5_xkL.js +1 -0
  418. package/web/dist/assets/zenscript-BnlCZFoB.js +1 -0
  419. package/web/dist/assets/zig-CMLA9XwU.js +1 -0
  420. package/web/dist/icons/apple-touch-icon.png +0 -0
  421. package/web/dist/icons/badge-96.png +0 -0
  422. package/web/dist/icons/icon-192.png +0 -0
  423. package/web/dist/icons/icon-512.png +0 -0
  424. package/web/dist/icons/icon.svg +4 -0
  425. package/web/dist/icons/maskable-192.png +0 -0
  426. package/web/dist/icons/maskable-512.png +0 -0
  427. package/web/dist/index.html +30 -0
  428. package/web/dist/manifest.webmanifest +20 -0
  429. package/web/dist/sw.js +176 -0
  430. package/web/dist/version.json +1 -0
  431. package/web/src/App.tsx +97 -0
  432. package/web/src/agent-history-flow.test.ts +191 -0
  433. package/web/src/agent-history-flow.ts +58 -0
  434. package/web/src/agent-owners.ts +30 -0
  435. package/web/src/annotation-selection.test.ts +36 -0
  436. package/web/src/annotation-selection.ts +123 -0
  437. package/web/src/annotations.test.ts +279 -0
  438. package/web/src/annotations.ts +191 -0
  439. package/web/src/api.ts +27 -0
  440. package/web/src/auth-events.ts +11 -0
  441. package/web/src/auth-lifecycle.test.ts +105 -0
  442. package/web/src/clipboard.ts +32 -0
  443. package/web/src/components/ActivityNotifier.tsx +22 -0
  444. package/web/src/components/AnnotationEditor.tsx +135 -0
  445. package/web/src/components/AnnotationSelection.tsx +122 -0
  446. package/web/src/components/Automation.tsx +390 -0
  447. package/web/src/components/CodeView.tsx +121 -0
  448. package/web/src/components/Composer.tsx +552 -0
  449. package/web/src/components/ComposerAnnotations.tsx +45 -0
  450. package/web/src/components/Confirmation.tsx +91 -0
  451. package/web/src/components/CopyButton.tsx +23 -0
  452. package/web/src/components/DiffView.tsx +94 -0
  453. package/web/src/components/DirectoryPicker.tsx +230 -0
  454. package/web/src/components/FileCopyMenu.tsx +58 -0
  455. package/web/src/components/FileEntryActions.tsx +95 -0
  456. package/web/src/components/ImagePreview.tsx +68 -0
  457. package/web/src/components/Items.tsx +608 -0
  458. package/web/src/components/Markdown.tsx +73 -0
  459. package/web/src/components/MessageList.tsx +458 -0
  460. package/web/src/components/Modal.tsx +72 -0
  461. package/web/src/components/NotificationEntry.tsx +31 -0
  462. package/web/src/components/NotificationSettings.tsx +226 -0
  463. package/web/src/components/NotificationToasts.tsx +59 -0
  464. package/web/src/components/OpenTabs.tsx +112 -0
  465. package/web/src/components/Pending.tsx +175 -0
  466. package/web/src/components/ProjectExtras.tsx +299 -0
  467. package/web/src/components/ProjectNav.tsx +47 -0
  468. package/web/src/components/SessionActions.tsx +271 -0
  469. package/web/src/components/SessionChannels.tsx +288 -0
  470. package/web/src/components/SessionExtras.tsx +263 -0
  471. package/web/src/components/SettingsLink.tsx +21 -0
  472. package/web/src/components/Shell.tsx +320 -0
  473. package/web/src/components/SvgPreview.tsx +18 -0
  474. package/web/src/components/TodoPanel.tsx +37 -0
  475. package/web/src/components/VersionNotice.tsx +40 -0
  476. package/web/src/components/icons.tsx +43 -0
  477. package/web/src/diff.ts +62 -0
  478. package/web/src/drafts.test.ts +80 -0
  479. package/web/src/drafts.ts +64 -0
  480. package/web/src/format.ts +5 -0
  481. package/web/src/highlight.worker.ts +49 -0
  482. package/web/src/hooks.ts +59 -0
  483. package/web/src/item-view-state.test.ts +51 -0
  484. package/web/src/item-view-state.ts +43 -0
  485. package/web/src/live-history-flow.test.ts +77 -0
  486. package/web/src/live-merge.test.ts +64 -0
  487. package/web/src/live-merge.ts +50 -0
  488. package/web/src/live-message-hydration.test.ts +253 -0
  489. package/web/src/live.ts +404 -0
  490. package/web/src/main.tsx +27 -0
  491. package/web/src/markdown-assets.test.ts +70 -0
  492. package/web/src/markdown-assets.ts +78 -0
  493. package/web/src/markdown-html.test.ts +84 -0
  494. package/web/src/markdown-html.ts +60 -0
  495. package/web/src/markdown-svg.test.ts +37 -0
  496. package/web/src/markdown-svg.ts +42 -0
  497. package/web/src/markdown-tables.test.ts +34 -0
  498. package/web/src/markdown-tables.ts +17 -0
  499. package/web/src/message-bodies.ts +117 -0
  500. package/web/src/message-cache.test.ts +208 -0
  501. package/web/src/message-cache.ts +158 -0
  502. package/web/src/message-font-size.test.ts +41 -0
  503. package/web/src/message-font-size.ts +57 -0
  504. package/web/src/message-time.test.ts +32 -0
  505. package/web/src/message-time.ts +26 -0
  506. package/web/src/notify.ts +132 -0
  507. package/web/src/open-tabs.test.ts +69 -0
  508. package/web/src/open-tabs.ts +110 -0
  509. package/web/src/pages/AgentView.tsx +138 -0
  510. package/web/src/pages/ChangesView.tsx +193 -0
  511. package/web/src/pages/FilesView.tsx +406 -0
  512. package/web/src/pages/HomePage.tsx +96 -0
  513. package/web/src/pages/LoginPage.tsx +56 -0
  514. package/web/src/pages/OverviewPage.tsx +124 -0
  515. package/web/src/pages/ProjectHome.tsx +136 -0
  516. package/web/src/pages/SessionView.tsx +238 -0
  517. package/web/src/pages/WorktreesView.tsx +197 -0
  518. package/web/src/pages/settings/AccountPage.tsx +149 -0
  519. package/web/src/pages/settings/AgentsPage.tsx +182 -0
  520. package/web/src/pages/settings/ConfigPage.tsx +181 -0
  521. package/web/src/pages/settings/GeneralPage.tsx +27 -0
  522. package/web/src/pages/settings/HooksPage.tsx +244 -0
  523. package/web/src/pages/settings/McpPage.tsx +310 -0
  524. package/web/src/pages/settings/MemoryPage.tsx +309 -0
  525. package/web/src/pages/settings/PermissionsPage.tsx +160 -0
  526. package/web/src/pages/settings/PluginsPage.tsx +251 -0
  527. package/web/src/pages/settings/SettingsPage.tsx +141 -0
  528. package/web/src/pages/settings/SkillsPage.tsx +142 -0
  529. package/web/src/pages/settings/ui.tsx +165 -0
  530. package/web/src/preference-document.ts +133 -0
  531. package/web/src/project-actions.ts +18 -0
  532. package/web/src/project-files-state.test.ts +49 -0
  533. package/web/src/project-files-state.ts +64 -0
  534. package/web/src/project-images.ts +13 -0
  535. package/web/src/pwa.ts +33 -0
  536. package/web/src/queryClient.ts +5 -0
  537. package/web/src/reasoning-preference.test.ts +39 -0
  538. package/web/src/reasoning-preference.ts +42 -0
  539. package/web/src/session-confirmations.ts +36 -0
  540. package/web/src/stream-replay.test.ts +163 -0
  541. package/web/src/styles.css +231 -0
  542. package/web/src/tool-result-body.test.ts +115 -0
  543. package/web/src/transcript.test.ts +186 -0
  544. package/web/src/transcript.ts +583 -0
  545. package/web/src/turns.test.ts +338 -0
  546. package/web/src/turns.ts +178 -0
  547. package/web/src/ui/Button.tsx +60 -0
  548. package/web/src/ui/Controls.tsx +227 -0
  549. package/web/src/ui/Form.tsx +203 -0
  550. package/web/src/ui/MenuPopover.tsx +144 -0
  551. package/web/src/ui/Popover.tsx +147 -0
  552. package/web/src/ui/ResizablePane.tsx +110 -0
  553. package/web/src/ui/Select.tsx +150 -0
  554. package/web/src/ui/icons.tsx +270 -0
  555. package/web/src/unsaved.tsx +99 -0
  556. package/web/src/user-text.test.ts +60 -0
  557. package/web/src/user-text.ts +35 -0
  558. package/web/src/uuid.ts +12 -0
  559. package/README.md +0 -3
@@ -0,0 +1,1126 @@
1
+ /**
2
+ * Unit tests for ordering and concurrency in LiveSession / SessionManager, with a scripted
3
+ * fake SDK query (real timing can't be forced reliably end to end).
4
+ * Run: npm run test:unit
5
+ */
6
+ import { test, before } from "node:test";
7
+ import assert from "node:assert/strict";
8
+ import fs from "node:fs";
9
+ import os from "node:os";
10
+ import path from "node:path";
11
+ import crypto from "node:crypto";
12
+
13
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "cide-unit-"));
14
+ process.env.CLAUDE_IDE_DATA_DIR = path.join(tmp, "data");
15
+ process.env.CLAUDE_IDE_PASSWORD = "unit";
16
+ process.env.CLAUDE_CONFIG_DIR = path.join(tmp, "claude"); // no real transcripts are touched
17
+
18
+ type Msg = Record<string, unknown>;
19
+
20
+ /** A fake Query: the test pushes SDK messages; close() ends the stream. */
21
+ class FakeQuery {
22
+ closed = false;
23
+ backgroundCalls: (string | undefined)[] = [];
24
+ interruptCalls = 0;
25
+ constructor(readonly args: { prompt: AsyncIterable<Msg>; options: Record<string, any> }) {}
26
+ /** The next message our server delivered to this "CLI process". */
27
+ async received(): Promise<Msg> {
28
+ return (await this.args.prompt[Symbol.asyncIterator]().next()).value;
29
+ }
30
+ private queue: Msg[] = [];
31
+ private waiter?: (r: IteratorResult<Msg>) => void;
32
+ push(m: Msg) {
33
+ if (this.waiter) {
34
+ const w = this.waiter;
35
+ this.waiter = undefined;
36
+ w({ value: m, done: false });
37
+ } else this.queue.push(m);
38
+ }
39
+ close() {
40
+ this.closed = true;
41
+ this.waiter?.({ value: undefined, done: true });
42
+ }
43
+ async interrupt() { this.interruptCalls++; }
44
+ async backgroundTasks(toolUseId?: string) { this.backgroundCalls.push(toolUseId); return true; }
45
+ async applyFlagSettings() {
46
+ await new Promise((r) => setTimeout(r, 10)); // a real control round-trip takes a moment
47
+ }
48
+ async setPermissionMode() {}
49
+ async setModel() {}
50
+ async setCwd(dir: string) { this.args.options.cwd = dir; return { status: "success" }; }
51
+ [Symbol.asyncIterator]() {
52
+ return {
53
+ next: (): Promise<IteratorResult<Msg>> => {
54
+ const m = this.queue.shift();
55
+ if (m) return Promise.resolve({ value: m, done: false });
56
+ if (this.closed) return Promise.resolve({ value: undefined, done: true });
57
+ return new Promise((r) => (this.waiter = r));
58
+ },
59
+ };
60
+ }
61
+ }
62
+
63
+ let created: FakeQuery[] = [];
64
+ let LiveSession: typeof import("./live-session.ts").LiveSession;
65
+ let sessions: typeof import("./sessions.ts").sessions;
66
+
67
+ before(async () => {
68
+ const mod = await import("./live-session.ts");
69
+ mod.setQueryImplForTests(((args: FakeQuery["args"]) => {
70
+ const q = new FakeQuery(args);
71
+ created.push(q);
72
+ return q;
73
+ }) as never);
74
+ LiveSession = mod.LiveSession;
75
+ sessions = (await import("./sessions.ts")).sessions;
76
+ });
77
+
78
+ const tick = () => new Promise((r) => setTimeout(r, 20));
79
+ const newDir = () => fs.mkdtempSync(path.join(tmp, "proj-"));
80
+
81
+ function writeTranscript(cwd: string, sessionId: string, records: Msg[]) {
82
+ cwd = fs.realpathSync(cwd); // SDK directory lookup canonicalizes macOS /var.
83
+ const dir = path.join(process.env.CLAUDE_CONFIG_DIR!, "projects", cwd.replace(/[^a-zA-Z0-9]/g, "-"));
84
+ fs.mkdirSync(dir, { recursive: true });
85
+ let parent: string | null = null;
86
+ const rows = records.map((r) => {
87
+ const uuid = String(r.uuid ?? crypto.randomUUID());
88
+ const row = { sessionId, cwd, timestamp: new Date().toISOString(), isSidechain: false, ...r, uuid, parentUuid: r.parentUuid === undefined ? parent : r.parentUuid };
89
+ parent = uuid;
90
+ return JSON.stringify(row);
91
+ });
92
+ fs.writeFileSync(path.join(dir, `${sessionId}.jsonl`), rows.join("\n") + "\n");
93
+ }
94
+
95
+ test("a stop during start does not leave a CLI process behind", async () => {
96
+ created = [];
97
+ const s = new LiveSession(crypto.randomUUID(), newDir(), () => {});
98
+ const sending = s.send(crypto.randomUUID(), "hi"); // start is now in flight (awaiting session info)
99
+ await s.shutdown();
100
+ await sending;
101
+ await tick();
102
+ assert.ok(created.every((q) => q.closed), `open queries: ${created.filter((q) => !q.closed).length}`);
103
+ });
104
+
105
+ test("concurrent first sends create one query", async () => {
106
+ created = [];
107
+ const s = new LiveSession(crypto.randomUUID(), newDir(), () => {});
108
+ await Promise.all([s.send(crypto.randomUUID(), "a"), s.send(crypto.randomUUID(), "b")]);
109
+ assert.equal(created.length, 1);
110
+ await s.shutdown();
111
+ });
112
+
113
+ test("peer delivery is bounded under concurrent sends and stays composed text", async () => {
114
+ created = [];
115
+ const s = new LiveSession(crypto.randomUUID(), newDir(), () => {});
116
+ try {
117
+ const outcomes = await Promise.allSettled(Array.from({ length: 33 }, () => s.sendPeerText(crypto.randomUUID(), "/btw ordinary peer text", () => true)));
118
+ assert.equal(outcomes.filter((r) => r.status === "fulfilled" && r.value).length, 32);
119
+ assert.equal(outcomes.filter((r) => r.status === "rejected" && /队列已满/.test(String(r.reason))).length, 1);
120
+ assert.equal(s.queuedMessageCount, 32);
121
+ assert.equal(created.length, 1);
122
+ const delivered = await created[0]!.received();
123
+ assert.equal(delivered.client_composed, true);
124
+ assert.equal(delivered.priority, "next");
125
+ assert.deepEqual(delivered.message, { role: "user", content: [{ type: "text", text: "/btw ordinary peer text" }] });
126
+ } finally { await s.shutdown(); }
127
+ });
128
+
129
+ test("peer delivery refuses pending rewinds and rechecks after asynchronous startup", async () => {
130
+ const { db } = await import("./db.ts");
131
+ const id = crypto.randomUUID(), dir = newDir();
132
+ db.prepare("INSERT INTO session_view (session_id,project_path,rewind_anchor) VALUES (?,?,?)").run(id, dir, "anchor");
133
+ const s = new LiveSession(id, dir, () => {});
134
+ created = [];
135
+ try {
136
+ await assert.rejects(s.sendPeerText(crypto.randomUUID(), "peer", () => true), /回退/);
137
+ assert.equal(created.length, 0);
138
+ db.prepare("UPDATE session_view SET rewind_anchor=NULL WHERE session_id=?").run(id);
139
+ let checks = 0;
140
+ await assert.rejects(s.sendPeerText(crypto.randomUUID(), "peer", () => {
141
+ if (++checks === 2) db.prepare("UPDATE session_view SET rewind_anchor='new-anchor' WHERE session_id=?").run(id);
142
+ return true;
143
+ }), /回退/);
144
+ assert.equal(checks, 2);
145
+ assert.equal(s.queuedMessageCount, 0);
146
+ assert.equal(s.events.some(({ ev }) => ev.k === "user" && ev.text === "peer"), false);
147
+ assert.equal(s.rewindAnchor, "new-anchor");
148
+ db.prepare("UPDATE session_view SET rewind_anchor=NULL WHERE session_id=?").run(id);
149
+ let activeChecks = 0;
150
+ assert.equal(await s.sendPeerText(crypto.randomUUID(), "cancelled", () => ++activeChecks < 2), false);
151
+ assert.equal(s.queuedMessageCount, 0);
152
+ } finally { await s.shutdown(); }
153
+ });
154
+
155
+ test("IDEMessages injection permits listing but guards mutations and btw delegation", async () => {
156
+ const { db } = await import("./db.ts");
157
+ const { updateSession } = await import("./sessions.ts");
158
+ const id = crypto.randomUUID(), dir = newDir();
159
+ await updateSession(id, dir, { writeEnabled: false });
160
+ const s = await sessions.getOrCreate(id, dir);
161
+ created = [];
162
+ try {
163
+ await s.wake();
164
+ const q = created[0]!;
165
+ assert.ok(q.args.options.mcpServers.IDEMessages);
166
+ const hook = q.args.options.hooks.PreToolUse[0].hooks[0];
167
+ const check = (tool_name: string) => hook({ hook_event_name: "PreToolUse", tool_name });
168
+ assert.deepEqual(await check("mcp__IDEMessages__ide_list_alias"), {});
169
+ const options = { signal: new AbortController().signal, toolUseID: "peer" };
170
+ for (const name of ["ide_send_message", "ide_set_alias", "ide_remove_alias"]) {
171
+ const tool = `mcp__IDEMessages__${name}`;
172
+ assert.equal((await check(tool)).hookSpecificOutput.permissionDecision, "deny");
173
+ assert.equal((await q.args.options.canUseTool(tool, {}, options)).behavior, "deny");
174
+ }
175
+ await updateSession(id, dir, { writeEnabled: true });
176
+ assert.deepEqual(await check("mcp__IDEMessages__ide_send_message"), {});
177
+ db.prepare("UPDATE session_view SET btw_parent='parent' WHERE session_id=?").run(id);
178
+ assert.equal((await check("mcp__IDEMessages__ide_send_message")).hookSpecificOutput.permissionDecision, "deny");
179
+ assert.equal((await q.args.options.canUseTool("mcp__IDEMessages__ide_send_message", {}, options)).behavior, "deny");
180
+ assert.deepEqual(await check("mcp__IDEMessages__ide_list_alias"), {});
181
+ } finally { await sessions.drop(id); }
182
+ });
183
+
184
+ test("backgrounding targets one live tool without restarting or interrupting the query", async () => {
185
+ created = [];
186
+ const s = new LiveSession(crypto.randomUUID(), newDir(), () => {});
187
+ try {
188
+ await s.send(crypto.randomUUID(), "run a long command");
189
+ const q = created[0]!;
190
+ assert.equal(await s.backgroundTask("tool-bash-1"), true);
191
+ assert.deepEqual(q.backgroundCalls, ["tool-bash-1"]);
192
+ assert.equal(q.interruptCalls, 0);
193
+ assert.equal(q.closed, false);
194
+ assert.equal(created.length, 1);
195
+ q.backgroundTasks = async () => false;
196
+ assert.equal(await s.backgroundTask("finished-tool"), false);
197
+ q.backgroundTasks = async () => { throw new Error("background tasks disabled"); };
198
+ await assert.rejects(s.backgroundTask("tool-bash-2"), /background tasks disabled/);
199
+ await assert.rejects(s.backgroundTask(" "), /缺少工具调用 ID/);
200
+ } finally {
201
+ await s.shutdown();
202
+ }
203
+ });
204
+
205
+ test("backgrounding an inactive session does not start a query", async () => {
206
+ created = [];
207
+ const s = new LiveSession(crypto.randomUUID(), newDir(), () => {});
208
+ assert.equal(await s.backgroundTask("old-tool"), false);
209
+ assert.equal(created.length, 0);
210
+ await s.shutdown();
211
+ });
212
+
213
+ test("stream and Stop-hook task type aliases retain subagent trace identity", async () => {
214
+ created = [];
215
+ const s = new LiveSession(crypto.randomUUID(), newDir(), () => {});
216
+ try {
217
+ await s.send(crypto.randomUUID(), "start background work");
218
+ const q = created[0]!;
219
+ for (const type of ["local_agent", "subagent"]) {
220
+ (s as any).handle({ type: "system", subtype: "task_started", task_id: `task-${type}`, tool_use_id: `tool-${type}`, task_type: type, description: "Plan multilingual project site", is_backgrounded: true });
221
+ }
222
+ await q.args.options.hooks.Stop[0].hooks[0]({ background_tasks: [
223
+ { id: "task-local_agent", type: "subagent", status: "running", description: "Plan multilingual project site" },
224
+ { id: "task-subagent", type: "subagent", status: "completed", description: "Other agent" },
225
+ ] });
226
+ const snapshot = s.snapshot();
227
+ assert.equal(snapshot.tasks[0]?.type, "subagent", "hook summaries carry friendly type names");
228
+ for (const task of snapshot.tasks) {
229
+ assert.equal(snapshot.agentTasks?.find((agent) => agent.taskId === task.id)?.toolUseId, task.id.replace("task-", "tool-"));
230
+ }
231
+ } finally { await s.shutdown(); }
232
+ });
233
+
234
+ test("AskUserQuestion can be disabled live without restarting and leaves plan approval pending", async () => {
235
+ created = [];
236
+ const { updateSession } = await import("./sessions.ts");
237
+ const id = crypto.randomUUID();
238
+ const dir = newDir();
239
+ const s = await sessions.getOrCreate(id, dir);
240
+ try {
241
+ assert.equal(s.snapshot().askQuestionsEnabled, true);
242
+ await s.send(crypto.randomUUID(), "hi");
243
+ const q = created[0]!;
244
+ const options = { signal: new AbortController().signal, toolUseID: "question-tool" };
245
+ const input = { questions: [{ question: "Which one?", options: [{ label: "A", description: "A" }] }] };
246
+ const asking = q.args.options.canUseTool("AskUserQuestion", input, options);
247
+ const plan = q.args.options.canUseTool("ExitPlanMode", { plan: "the plan" }, { ...options, toolUseID: "plan-tool" });
248
+ assert.equal(s.snapshot().pending.length, 2);
249
+ await updateSession(id, dir, { askQuestionsEnabled: false });
250
+ assert.match((await asking).message, /已禁用 AskUserQuestion/);
251
+ assert.deepEqual(s.snapshot().pending.map((request) => request.kind), ["plan"]);
252
+ assert.equal(s.snapshot().askQuestionsEnabled, false);
253
+ assert.equal((await q.args.options.canUseTool("AskUserQuestion", input, options)).behavior, "deny");
254
+ assert.equal(s.snapshot().pending.length, 1);
255
+ assert.equal(created.length, 1);
256
+ assert.equal(q.interruptCalls, 0);
257
+ assert.equal(q.closed, false);
258
+ assert.ok(s.events.some(({ ev }) => ev.k === "settings" && ev.askQuestionsEnabled === false));
259
+ const restored = new LiveSession(id, dir, () => {});
260
+ assert.equal(restored.snapshot().askQuestionsEnabled, false);
261
+ await restored.shutdown();
262
+ await updateSession(id, dir, { askQuestionsEnabled: true });
263
+ const askingAgain = q.args.options.canUseTool("AskUserQuestion", input, options);
264
+ const ask = s.snapshot().pending.find((request) => request.kind === "ask")!;
265
+ assert.ok(ask);
266
+ s.resolvePending(ask.id, { kind: "ask", answers: { "Which one?": "A" } });
267
+ assert.equal((await askingAgain).behavior, "allow");
268
+ s.resolvePending(s.snapshot().pending[0]!.id, { kind: "cancel" });
269
+ await plan;
270
+ } finally {
271
+ await s.shutdown();
272
+ }
273
+ });
274
+
275
+ test("AskUserQuestion preference can be saved before a session starts", async () => {
276
+ created = [];
277
+ const { updateSession } = await import("./sessions.ts");
278
+ const id = crypto.randomUUID();
279
+ const dir = newDir();
280
+ await updateSession(id, dir, { askQuestionsEnabled: false });
281
+ const restored = new LiveSession(id, dir, () => {});
282
+ assert.equal(restored.askQuestionsEnabled, false);
283
+ assert.equal(created.length, 0);
284
+ await restored.shutdown();
285
+ });
286
+
287
+ test("write toggle takes effect during a live query and also guards subsequent subagent calls", async () => {
288
+ const { updateSession } = await import("./sessions.ts");
289
+ const { db } = await import("./db.ts");
290
+ created = [];
291
+ const dir = newDir(), id = crypto.randomUUID();
292
+ const s = await sessions.getOrCreate(id, dir);
293
+ const firstClient: import("../shared/protocol.ts").LiveEvent[] = [], secondClient: typeof firstClient = [];
294
+ const offA = s.subscribe((_seq, event) => firstClient.push(event));
295
+ const offB = s.subscribe((_seq, event) => secondClient.push(event));
296
+ try {
297
+ assert.equal(s.snapshot().writeEnabled, true);
298
+ await s.send(crypto.randomUUID(), "working");
299
+ const query = created[0]!;
300
+ const pre = query.args.options.hooks.PreToolUse[0].hooks[0];
301
+ const check = (tool_name: string, agent_id?: string) => pre({ hook_event_name: "PreToolUse", tool_name, agent_id });
302
+ assert.deepEqual(await check("Write"), {});
303
+ await updateSession(id, dir, { writeEnabled: false });
304
+ assert.equal(db.prepare("SELECT write_enabled FROM session_view WHERE session_id=?").get(id)?.write_enabled, 0);
305
+ assert.equal(s.state, "running");
306
+ assert.equal(created.length, 1);
307
+ assert.equal(query.closed, false);
308
+ for (const tool of ["Bash", "Write", "Edit", "NotebookEdit", "EnterWorktree"]) {
309
+ for (const agent of [undefined, "child", "nested-child"]) {
310
+ assert.equal((await check(tool, agent)).hookSpecificOutput.permissionDecision, "deny");
311
+ }
312
+ }
313
+ for (const tool of ["Read", "Grep", "Glob", "Agent", "AskUserQuestion", "ExitPlanMode"]) assert.deepEqual(await check(tool, "child"), {});
314
+ assert.deepEqual(firstClient.filter((event) => event.k === "settings"), secondClient.filter((event) => event.k === "settings"));
315
+ assert.ok(firstClient.some((event) => event.k === "settings" && event.writeEnabled === false));
316
+ assert.equal(s.snapshot().writeEnabled, false, "reconnecting devices receive the current authoritative value");
317
+ await assert.rejects(s.runBash(crypto.randomUUID(), "echo should-not-run"), /只读/);
318
+ await s.setPlanMode(true);
319
+ const plan = (s as any).canUseTool("ExitPlanMode", { plan: "plan" }, { signal: new AbortController().signal, toolUseID: "exit-plan" });
320
+ const pending = s.snapshot().pending.find((item) => item.kind === "plan")!;
321
+ assert.equal(s.resolvePending(pending.id, { kind: "plan", approve: true }), true);
322
+ assert.equal((await plan).behavior, "allow");
323
+ assert.equal(s.planMode, false);
324
+ assert.equal((await check("Write", "child")).hookSpecificOutput.permissionDecision, "deny", "plan approval cannot override the write switch");
325
+ await s.setPlanMode(true);
326
+ await updateSession(id, dir, { writeEnabled: true });
327
+ assert.equal(s.planMode, true, "enabling writes does not exit plan mode");
328
+ assert.deepEqual(await check("Write", "child"), {}, "enabled returns no allow override: ordinary permission checks still apply");
329
+ assert.equal(created.length, 1);
330
+ assert.equal(query.closed, false);
331
+ assert.ok(secondClient.some((event) => event.k === "settings" && event.writeEnabled === true));
332
+ } finally { offA(); offB(); await sessions.drop(id); }
333
+ });
334
+
335
+ test("write preference survives unloaded sessions, query restarts and btw defaults", async () => {
336
+ const { updateSession } = await import("./sessions.ts");
337
+ const { db } = await import("./db.ts");
338
+ const { askBtw, promoteBtw } = await import("./btw.ts");
339
+ const dir = newDir(), id = crypto.randomUUID();
340
+ created = [];
341
+ await updateSession(id, dir, { writeEnabled: false });
342
+ const s = await sessions.getOrCreate(id, dir);
343
+ try {
344
+ assert.equal(s.snapshot().writeEnabled, false);
345
+ await s.wake();
346
+ const first = created.at(-1)!;
347
+ assert.equal(first.args.options.permissionMode, "bypassPermissions", "the write flag is independent of permission mode");
348
+ assert.equal(first.args.options.disallowedTools, undefined, "the tool set must remain available for runtime re-enabling");
349
+ await s.stop();
350
+ await s.wake();
351
+ assert.equal(s.snapshot().writeEnabled, false);
352
+ assert.equal(created.length, 2);
353
+ const { id: childId } = await askBtw(dir, id, "side question");
354
+ const child = sessions.get(childId)!;
355
+ try {
356
+ assert.equal(child.snapshot().writeEnabled, false);
357
+ assert.equal(db.prepare("SELECT write_enabled FROM session_view WHERE session_id=?").get(childId)?.write_enabled, 0);
358
+ const q = created.at(-1)!;
359
+ const clear = await q.received(), boundary = await q.received(), question = await q.received();
360
+ assert.equal((clear.message as { content: unknown }).content, "/goal clear");
361
+ assert.equal(clear.shouldQuery, false);
362
+ const { BTW_BOUNDARY: boundaryText } = await import("./btw-instructions.ts");
363
+ assert.deepEqual(boundary.message, { role: "user", content: boundaryText });
364
+ assert.equal(boundary.shouldQuery, false, "the boundary must not trigger its own model turn");
365
+ assert.notEqual(boundary.uuid, question.uuid);
366
+ assert.notEqual(boundary.isSynthetic, true, "the boundary is a normal persisted user message");
367
+ assert.deepEqual(child.events.flatMap(({ ev }) => ev.k === "user" ? [ev.text] : []), [boundaryText, "side question"]);
368
+ assert.equal(s.events.some(({ ev }) => ev.k === "user" && ev.text === boundaryText), false, "the parent receives no boundary message");
369
+ assert.deepEqual((question.message as { content: unknown }).content, [{ type: "text", text: "side question" }]);
370
+ (child as any).handle({ type: "system", subtype: "session_state_changed", state: "idle" });
371
+ (child as any).handle({ type: "result", uuid: "initialization", subtype: "success", num_turns: 0, user_message_uuids: [clear.uuid], total_cost_usd: 0 });
372
+ assert.equal(child.state, "running", "clearing the inherited goal cannot finish the queued side question");
373
+ assert.equal(child.queuedMessageCount, 2);
374
+ assert.equal(child.events.some(({ ev }) => ev.k === "sdk" && ev.m.uuid === "initialization"), false);
375
+ assert.equal(db.prepare("SELECT btw_goal_isolated FROM session_view WHERE session_id=?").get(childId)?.btw_goal_isolated, 1);
376
+ (child as any).handle({ type: "system", subtype: "session_state_changed", state: "idle" });
377
+ (child as any).handle({ type: "result", uuid: "boundary-append", subtype: "success", num_turns: 0, user_message_uuids: [boundary.uuid], total_cost_usd: 0 });
378
+ assert.equal(child.state, "running", "appending the boundary cannot finish the queued side question");
379
+ assert.equal(child.queuedMessageCount, 1);
380
+ assert.equal(child.events.some(({ ev }) => ev.k === "sdk" && ev.m.uuid === "boundary-append"), false, "the append must not create an empty cost row");
381
+ (child as any).handle({ type: "result", uuid: "side-answer", subtype: "success", num_turns: 1, user_message_uuids: [question.uuid], total_cost_usd: 0.01 });
382
+ assert.equal(child.queuedMessageCount, 0);
383
+ assert.ok(child.events.some(({ ev }) => ev.k === "sdk" && ev.m.uuid === "side-answer"), "the real answer's result must remain visible");
384
+ writeTranscript(dir, childId, [
385
+ { type: "user", uuid: clear.uuid, message: { role: "user", content: "<command-name>/goal</command-name><command-args>clear</command-args>" } },
386
+ { type: "user", uuid: boundary.uuid, message: boundary.message },
387
+ { type: "user", uuid: question.uuid, message: question.message },
388
+ ]);
389
+ await child.stop();
390
+ await child.wake();
391
+ assert.equal((child as any).inheritedGoalCommand, undefined, "completed isolation is not repeated on resume");
392
+ await child.send(crypto.randomUUID(), "follow-up question");
393
+ assert.deepEqual((await created.at(-1)!.received()).message, { role: "user", content: [{ type: "text", text: "follow-up question" }] }, "resuming does not inject another boundary");
394
+ await updateSession(childId, dir, { writeEnabled: true });
395
+ assert.equal(child.snapshot().writeEnabled, true, "btw defaults can be changed explicitly");
396
+ promoteBtw(dir, childId);
397
+ assert.equal(child.snapshot().writeEnabled, true, "promotion does not silently change the saved switch");
398
+ } finally { await sessions.drop(childId); }
399
+ const restored = new LiveSession(id, dir, () => {});
400
+ assert.equal(restored.snapshot().writeEnabled, false, "a new server-side object restores the persisted switch");
401
+ restored.close();
402
+ } finally { await sessions.drop(id); }
403
+ });
404
+
405
+ test("btw keeps inherited history and appends its boundary only in the child", async () => {
406
+ const { getSessionMessages } = await import("@anthropic-ai/claude-agent-sdk");
407
+ const { askBtw } = await import("./btw.ts");
408
+ const dir = newDir(), parentId = crypto.randomUUID();
409
+ writeTranscript(dir, parentId, [
410
+ { type: "user", message: { role: "user", content: "Continue the main task." } },
411
+ { type: "assistant", message: { role: "assistant", content: [{ type: "text", text: "Main task context." }] } },
412
+ ]);
413
+ const before = await getSessionMessages(parentId, { dir });
414
+ assert.equal(before.length, 2);
415
+ created = [];
416
+ const { id } = await askBtw(dir, parentId, "Explain one detail.");
417
+ try {
418
+ const inherited = await getSessionMessages(id, { dir });
419
+ assert.deepEqual(inherited.map(({ type, message }) => ({ type, message })), before.map(({ type, message }) => ({ type, message })), "the native fork retains the parent context with its own message identities");
420
+ assert.deepEqual(await getSessionMessages(parentId, { dir }), before, "the parent transcript stays unchanged");
421
+ const q = created.at(-1)!;
422
+ await q.received(); // inherited goal cleanup
423
+ const boundary = await q.received(), question = await q.received();
424
+ assert.match((boundary.message as { content: string }).content, /Everything before this boundary/);
425
+ assert.equal(boundary.shouldQuery, false);
426
+ assert.deepEqual((question.message as { content: unknown }).content, [{ type: "text", text: "Explain one detail." }]);
427
+ } finally { await sessions.drop(id); }
428
+ });
429
+
430
+ test("concurrent /btw requests share one branch until promotion and never broadcast to another session", async () => {
431
+ const { askBtw, promoteBtw } = await import("./btw.ts");
432
+ const { db } = await import("./db.ts");
433
+ const dir = newDir(), parentId = crypto.randomUUID();
434
+ const parent = await sessions.getOrCreate(parentId, dir);
435
+ created = [];
436
+ const ids = new Set<string>();
437
+ try {
438
+ const results = await Promise.all(["first", "second", "third"].map((question) => askBtw(dir, parentId, question)));
439
+ results.forEach(({ id }) => ids.add(id));
440
+ assert.equal(ids.size, 1);
441
+ assert.equal(created.length, 1);
442
+ const id = results[0]!.id, child = sessions.get(id)!;
443
+ assert.deepEqual(child.events.flatMap(({ ev }) => ev.k === "user" && ["first", "second", "third"].includes(ev.text) ? [ev.text] : []), ["first", "second", "third"]);
444
+ assert.equal(parent.events.length, 0, "none of the side questions reach the parent");
445
+ assert.equal((await askBtw(dir, parentId, "")).id, id, "bare /btw only opens the existing branch");
446
+ assert.equal((await askBtw(dir, id, "follow-up from inside")).id, id, "inside /btw cannot fork another branch");
447
+ assert.equal(db.prepare("SELECT COUNT(*) AS n FROM session_view WHERE btw_parent=?").get(id)?.n, 0);
448
+ assert.equal(db.prepare("SELECT COUNT(*) AS n FROM session_view WHERE btw_parent=?").get(parentId)?.n, 1);
449
+ const before = child.events.length;
450
+ promoteBtw(dir, id);
451
+ const next = await askBtw(dir, parentId, "new side question after promotion");
452
+ ids.add(next.id);
453
+ assert.notEqual(next.id, id);
454
+ assert.equal(db.prepare("SELECT COUNT(*) AS n FROM session_view WHERE btw_parent=?").get(parentId)?.n, 1);
455
+ assert.equal(child.events.length, before, "the promoted branch does not receive the new branch's prompt");
456
+ } finally {
457
+ for (const id of ids) await sessions.drop(id);
458
+ await sessions.drop(parentId);
459
+ }
460
+ });
461
+
462
+ test("btw identity and read-only context are injected on every prompt and change live on promotion", async () => {
463
+ const { askBtw, promoteBtw } = await import("./btw.ts");
464
+ const { updateSession } = await import("./sessions.ts");
465
+ const dir = newDir(), parentId = crypto.randomUUID();
466
+ created = [];
467
+ const { id } = await askBtw(dir, parentId, "explain only");
468
+ try {
469
+ const q = created[0]!, child = sessions.get(id)!;
470
+ const prompt = q.args.options.hooks.UserPromptSubmit[0].hooks[0];
471
+ const pre = q.args.options.hooks.PreToolUse[0].hooks[0];
472
+ const context = async () => (await prompt({ hook_event_name: "UserPromptSubmit" })).hookSpecificOutput?.additionalContext;
473
+ assert.match(await context(), /btw side conversation/);
474
+ assert.match(await context(), /disabled writes/);
475
+ assert.match(await context(), /Inherited history is reference-only/);
476
+ for (const tool of ["Write", "Bash", "mcp__external__change_files", "UnknownMutation", "Agent", "SendMessage"]) {
477
+ assert.equal((await pre({ hook_event_name: "PreToolUse", tool_name: tool })).hookSpecificOutput.permissionDecision, "deny", tool);
478
+ const permission = await (child as any).canUseTool(tool, {}, { signal: new AbortController().signal, toolUseID: tool });
479
+ assert.equal(permission.behavior, "deny", "permission callbacks also enforce the policy");
480
+ }
481
+ for (const tool of ["Read", "Grep", "Glob", "WebFetch"]) assert.deepEqual(await pre({ hook_event_name: "PreToolUse", tool_name: tool }), {});
482
+ await updateSession(id, dir, { writeEnabled: true });
483
+ assert.match(await context(), /Writes are enabled/);
484
+ assert.equal((await pre({ hook_event_name: "PreToolUse", tool_name: "Agent" })).hookSpecificOutput.permissionDecision, "deny", "side chats cannot delegate even with writes enabled");
485
+ promoteBtw(dir, id);
486
+ assert.match(await context(), /promoted.*ordinary conversation/);
487
+ assert.deepEqual(await pre({ hook_event_name: "PreToolUse", tool_name: "Agent" }), {});
488
+ assert.equal(created.length, 1, "changing the role requires no CLI restart");
489
+ assert.equal(child.writeEnabled, true, "promotion preserves the user's explicit permission choice");
490
+ } finally { await sessions.drop(id); }
491
+ });
492
+
493
+ test("btw previews are bounded, exclude inherited answers and respect rewind branches", async () => {
494
+ const { btwInfo } = await import("./btw.ts");
495
+ const { db } = await import("./db.ts");
496
+ const dir = newDir(), parentId = crypto.randomUUID(), id = crypto.randomUUID(), prompt = crypto.randomUUID(), inherited = crypto.randomUUID();
497
+ db.prepare("INSERT INTO session_view(session_id,project_path,btw_parent,btw_prompt_uuid,btw_question,write_enabled) VALUES(?,?,?,?,?,0)").run(id, dir, parentId, prompt, "side question");
498
+ const prefix = [
499
+ { type: "assistant", uuid: inherited, message: { role: "assistant", content: [{ type: "text", text: "inherited parent answer" }] } },
500
+ { type: "user", uuid: prompt, message: { role: "user", content: "side question" } },
501
+ ];
502
+ try {
503
+ writeTranscript(dir, id, prefix.slice(0, 1));
504
+ assert.equal((await btwInfo(dir, parentId)).children[0]?.answer, undefined);
505
+ writeTranscript(dir, id, [...prefix, { type: "assistant", message: { role: "assistant", content: [{ type: "text", text: "own answer ".repeat(1000) }] } }]);
506
+ const answer = (await btwInfo(dir, parentId)).children[0]?.answer;
507
+ assert.equal(answer?.length, 2000);
508
+ assert.ok(answer?.startsWith("own answer"));
509
+ db.prepare("UPDATE session_view SET rewind_anchor=? WHERE session_id=?").run(inherited, id);
510
+ assert.equal((await btwInfo(dir, parentId)).children[0]?.answer, undefined, "rewinding before the question cannot expose the parent's answer as the side response");
511
+ } finally { await sessions.drop(id); }
512
+ });
513
+
514
+ test("a failed context append stops the query and reports the error without an empty result row", async () => {
515
+ created = [];
516
+ const s = new LiveSession(crypto.randomUUID(), newDir(), () => {});
517
+ try {
518
+ await s.send(crypto.randomUUID(), "side question", [], "Side conversation boundary.");
519
+ const q = created.at(-1)!;
520
+ const boundary = await q.received();
521
+ (s as any).handle({ type: "result", uuid: "append-failed", subtype: "error_during_execution", is_error: true, num_turns: 0, user_message_uuids: [boundary.uuid] });
522
+ assert.equal(q.closed, true);
523
+ assert.ok(s.events.some(({ ev }) => ev.k === "error" && ev.message.includes("历史边界未能写入")));
524
+ assert.equal(s.events.some(({ ev }) => ev.k === "sdk" && ev.m.uuid === "append-failed"), false);
525
+ await tick();
526
+ assert.equal(s.queuedMessageCount, 0);
527
+ assert.equal(s.snapshot().state, "idle");
528
+ } finally { await s.shutdown(); }
529
+ });
530
+
531
+ test("cold promoted btw retries interrupted goal cleanup and preserves a recorded own goal", async () => {
532
+ const { db } = await import("./db.ts");
533
+ const { promoteBtw } = await import("./btw.ts");
534
+ const dir = newDir(), id = crypto.randomUUID(), prompt = crypto.randomUUID();
535
+ db.prepare("INSERT INTO session_view (session_id, project_path, btw_parent, btw_prompt_uuid) VALUES (?, ?, ?, ?)").run(id, dir, crypto.randomUUID(), prompt);
536
+ promoteBtw(dir, id);
537
+ const s = new LiveSession(id, dir, () => {});
538
+ try {
539
+ await s.wake();
540
+ assert.equal((await created.at(-1)!.received()).shouldQuery, false);
541
+ await s.stop();
542
+ assert.equal(db.prepare("SELECT btw_goal_isolated FROM session_view WHERE session_id=?").get(id)?.btw_goal_isolated, 0, "interrupted cleanup is not marked complete");
543
+ await s.wake();
544
+ const clear = await created.at(-1)!.received();
545
+ (s as any).handle({ type: "result", subtype: "error_during_execution", is_error: true, num_turns: 0, user_message_uuids: [clear.uuid] });
546
+ assert.equal(db.prepare("SELECT btw_goal_isolated FROM session_view WHERE session_id=?").get(id)?.btw_goal_isolated, 0, "failed cleanup is retried too");
547
+ await s.stop();
548
+ const files = path.join(process.env.CLAUDE_CONFIG_DIR!, "projects", dir.replace(/[^a-zA-Z0-9]/g, "-"));
549
+ fs.mkdirSync(files, { recursive: true });
550
+ const records = [
551
+ { type: "attachment", attachment: { type: "goal_status", sentinel: true, condition: "parent" } },
552
+ { type: "user", uuid: prompt, message: { content: "side question" } },
553
+ { type: "attachment", attachment: { type: "goal_status", sentinel: true, condition: "child goal" } },
554
+ { type: "user", message: { content: "<command-name>/goal</command-name><command-args>child goal</command-args>" } },
555
+ ];
556
+ writeTranscript(dir, id, records);
557
+ await s.wake();
558
+ assert.equal((s as any).inheritedGoalCommand, undefined, "native attachment-before-command owns the goal, even on cold legacy promotion");
559
+ assert.equal(db.prepare("SELECT btw_goal_isolated FROM session_view WHERE session_id=?").get(id)?.btw_goal_isolated, 0, "a read-only projection does not mutate a permanent isolation flag");
560
+ } finally { await s.shutdown(); }
561
+ });
562
+
563
+ test("rewind validates btw isolation in the resumed branch and preserves retained own goals", async () => {
564
+ const { db } = await import("./db.ts");
565
+ const { goalState } = await import("./session-file.ts");
566
+ const dir = newDir(), id = crypto.randomUUID(), prompt = crypto.randomUUID(), clearId = crypto.randomUUID();
567
+ const parentAnchor = crypto.randomUUID(), ownAnchor = crypto.randomUUID();
568
+ const command = (args: string) => ({ role: "user", content: `<command-name>/goal</command-name><command-args>${args}</command-args>` });
569
+ const effect = (condition: string, met = false) => ({ type: "attachment", attachment: { type: "goal_status", condition, sentinel: true, met } });
570
+ const original: Msg[] = [
571
+ { ...effect("parent"), forkedFrom: { sessionId: "parent" } },
572
+ { type: "user", uuid: parentAnchor, message: command("parent"), forkedFrom: { sessionId: "parent" } },
573
+ effect("parent", true), { type: "user", uuid: clearId, message: command("clear") },
574
+ { type: "user", uuid: prompt, message: { role: "user", content: "side question" } },
575
+ effect("own"), { type: "user", uuid: ownAnchor, message: command("own") },
576
+ ];
577
+ writeTranscript(dir, id, original);
578
+ db.prepare("INSERT INTO session_view (session_id, project_path, btw_prompt_uuid, btw_goal_isolated, btw_goal_isolation_uuid) VALUES (?, ?, ?, 1, ?)").run(id, dir, prompt, clearId);
579
+ const s = new LiveSession(id, dir, () => {});
580
+ try {
581
+ await s.rewindConversation(dir, parentAnchor, "rewritten parent input");
582
+ assert.equal(await goalState(dir, id), null, "a discarded own goal cannot establish isolation in the pending branch");
583
+ await s.send(crypto.randomUUID(), "rewritten parent input");
584
+ const q = created.at(-1)!;
585
+ assert.equal(q.args.options.resumeSessionAt, parentAnchor);
586
+ const clear = await q.received(), user = await q.received();
587
+ assert.equal((clear.message as Msg).content, "/goal clear", "a permanent old flag cannot skip branch-local isolation");
588
+ assert.equal(clear.shouldQuery, false);
589
+ const clearedStatus = crypto.randomUUID();
590
+ writeTranscript(dir, id, [...original,
591
+ { ...effect("parent", true), uuid: clearedStatus, parentUuid: parentAnchor },
592
+ { type: "user", uuid: clear.uuid, parentUuid: clearedStatus, message: command("clear") },
593
+ { type: "user", uuid: user.uuid, parentUuid: clear.uuid, message: user.message },
594
+ ]);
595
+ (s as any).handle({ type: "result", subtype: "success", num_turns: 0, user_message_uuids: [clear.uuid] });
596
+ assert.equal((await goalState(dir, id))?.status, "cleared");
597
+ await s.stop(); await s.wake();
598
+ assert.equal((s as any).inheritedGoalCommand, undefined, "this branch retains its own successful cleanup");
599
+ await s.rewindConversation(dir, ownAnchor, "continue own goal");
600
+ assert.equal((await goalState(dir, id))?.condition, "own");
601
+ await s.send(crypto.randomUUID(), "continue own goal");
602
+ const preserved = created.at(-1)!;
603
+ assert.equal(preserved.args.options.resumeSessionAt, ownAnchor);
604
+ assert.equal((s as any).inheritedGoalCommand, undefined, "rewinding within a valid own goal does not clear it");
605
+ assert.notEqual((await preserved.received()).shouldQuery, false);
606
+ } finally { await s.shutdown(); }
607
+ });
608
+
609
+ for (const order of ["result-then-idle", "idle-then-result"] as const) {
610
+ test(`a failed turn notifies as failed (${order})`, async () => {
611
+ created = [];
612
+ const transitions: { to: string; failed: boolean }[] = [];
613
+ const s = new LiveSession(crypto.randomUUID(), newDir(), () => {}, (_from, to, turn) => {
614
+ if (!turn.silent) transitions.push({ to, failed: turn.failed });
615
+ });
616
+ await s.send(crypto.randomUUID(), "hi");
617
+ const q = created[0]!;
618
+ const idle = { type: "system", subtype: "session_state_changed", state: "idle", uuid: crypto.randomUUID(), session_id: "x" };
619
+ const result = { type: "result", subtype: "success", is_error: true, total_cost_usd: 0, uuid: crypto.randomUUID(), session_id: "x" };
620
+ for (const m of order === "result-then-idle" ? [result, idle] : [idle, result]) q.push(m);
621
+ await tick();
622
+ const ended = transitions.filter((t) => t.to === "idle");
623
+ assert.equal(ended.length, 1, JSON.stringify(transitions));
624
+ assert.equal(ended[0]!.failed, true);
625
+ await s.shutdown();
626
+ });
627
+ }
628
+
629
+ test("deleting a session can't race with a client creating it again", async () => {
630
+ created = [];
631
+ const dir = newDir();
632
+ const id = crypto.randomUUID();
633
+ const s = await sessions.getOrCreate(id, dir);
634
+ await s.send(crypto.randomUUID(), "hi");
635
+ const dropping = sessions.drop(id);
636
+ // Another client subscribes while the delete is in progress: it must get the closed instance…
637
+ const during = await sessions.getOrCreate(id, dir);
638
+ assert.equal(during, s);
639
+ await during.send(crypto.randomUUID(), "again"); // …which refuses to start a new CLI process
640
+ await dropping;
641
+ await tick();
642
+ assert.equal(created.length, 1);
643
+ assert.ok(created[0]!.closed);
644
+ });
645
+
646
+ test("send and stop are serialized through delivery", async () => {
647
+ created = [];
648
+ const s = new LiveSession(crypto.randomUUID(), newDir(), () => {});
649
+ await s.wake();
650
+ const received = created[0]!.received();
651
+ const sending = s.send(crypto.randomUUID(), "must arrive");
652
+ let stopping: Promise<void> | undefined;
653
+ queueMicrotask(() => { stopping = s.stop(); });
654
+ await sending;
655
+ assert.match(JSON.stringify(await received), /must arrive/);
656
+ await stopping;
657
+ assert.equal(created.length, 1);
658
+ assert.equal(created[0]!.closed, true);
659
+ });
660
+
661
+ test("a config reload during start does not stop the starting process", async () => {
662
+ created = [];
663
+ const id = crypto.randomUUID();
664
+ // With an extra directory the start has a second step (applying it) after the process exists.
665
+ const { insertSessionDir } = await import("./session-dirs.ts");
666
+ insertSessionDir(id, newDir());
667
+ const s = new LiveSession(id, newDir(), () => {});
668
+ const sending = s.send(crypto.randomUUID(), "hello");
669
+ while (created.length === 0) await new Promise((r) => setImmediate(r)); // the process exists, start not finished
670
+ await s.reloadConfig();
671
+ await sending;
672
+ await tick();
673
+ assert.equal(created.length, 1);
674
+ assert.equal(created[0]!.closed, false);
675
+ await s.shutdown();
676
+ });
677
+
678
+ test("sessions with scheduled tasks are resumed after a restart", async () => {
679
+ created = [];
680
+ const dir = newDir();
681
+ const id = crypto.randomUUID();
682
+ const s = await sessions.getOrCreate(id, dir);
683
+ await s.send(crypto.randomUUID(), "/loop 1m ping");
684
+ // The CLI reports its scheduled tasks through the Stop hook.
685
+ const stop = created[0]!.args.options.hooks.Stop[0].hooks[0];
686
+ await stop({ session_crons: [{ id: "c1", schedule: "* * * * *", recurring: true, prompt: "ping" }] });
687
+ // "Restart": the in-memory session is gone, the process too.
688
+ await s.stop();
689
+ (sessions as unknown as { live: Map<string, unknown> }).live.delete(id);
690
+ const before = created.length;
691
+ await sessions.resumeScheduled();
692
+ assert.equal(created.length, before + 1, "the session's CLI process was brought back");
693
+ assert.equal(created.at(-1)!.args.options.resume ?? created.at(-1)!.args.options.sessionId, id);
694
+ // Once its tasks are gone it is no longer resumed.
695
+ await created.at(-1)!.args.options.hooks.Stop[0].hooks[0]({ session_crons: [] });
696
+ await sessions.drop(id);
697
+ const n = created.length;
698
+ await sessions.resumeScheduled();
699
+ assert.equal(created.length, n);
700
+ });
701
+
702
+ test("a restarted session shows its persisted task list before the next Stop", async () => {
703
+ created = [];
704
+ const dir = newDir();
705
+ const id = crypto.randomUUID();
706
+ const s = await sessions.getOrCreate(id, dir);
707
+ await s.wake();
708
+ const stop = created[0]!.args.options.hooks.Stop[0].hooks[0];
709
+ await stop({ session_crons: [{ id: "c1", schedule: "*/5 * * * *", recurring: true, prompt: "ping" }] });
710
+ await s.stop();
711
+ (sessions as unknown as { live: Map<string, unknown> }).live.delete(id);
712
+
713
+ await sessions.resumeScheduled();
714
+ const restored = await sessions.getOrCreate(id, dir);
715
+ assert.deepEqual(restored.snapshot().crons.map((c) => c.id), ["c1"], "the cached list is visible immediately");
716
+ assert.equal(restored.snapshot().cronsStale, false, "a non-empty cached list needs no refresh prompt");
717
+ assert.equal(restored.reapIfIdle(0), false, "a cached task list prevents idle reaping");
718
+ await sessions.drop(id);
719
+ });
720
+
721
+ test("durable tasks are visible, keep the session scheduled, and persist across a restart", async () => {
722
+ created = [];
723
+ const dir = newDir();
724
+ const id = crypto.randomUUID();
725
+ fs.mkdirSync(path.join(dir, ".claude"), { recursive: true });
726
+ fs.writeFileSync(path.join(dir, ".claude", "scheduled_tasks.json"), JSON.stringify({
727
+ tasks: [
728
+ { id: "d1", cron: "0 9 * * *", prompt: "daily", recurring: true, createdBySessionId: id },
729
+ { id: "d2", cron: "0 10 * * *", prompt: "elsewhere", recurring: true, createdBySessionId: crypto.randomUUID() },
730
+ ],
731
+ }));
732
+ const s = await sessions.getOrCreate(id, dir);
733
+ await s.wake();
734
+ try {
735
+ assert.deepEqual(s.snapshot().crons.map((c) => c.id), ["d1", "d2"], "every session shows all project durable tasks");
736
+ assert.equal(s.snapshot().crons[0]!.durable, true);
737
+ assert.equal(s.reapIfIdle(0), false, "durable work prevents idle reaping");
738
+ await s.stop();
739
+ (sessions as unknown as { live: Map<string, unknown> }).live.delete(id);
740
+ const before = created.length;
741
+ await sessions.resumeScheduled();
742
+ assert.equal(created.length, before + 1, "the durable task's owner is brought back");
743
+ await sessions.getOrCreate(id, dir);
744
+ } finally {
745
+ await sessions.drop(id);
746
+ }
747
+ });
748
+
749
+ test("a non-owner session shows project durable tasks without claiming them", async () => {
750
+ created = [];
751
+ const dir = newDir();
752
+ const id = crypto.randomUUID();
753
+ fs.mkdirSync(path.join(dir, ".claude"), { recursive: true });
754
+ fs.writeFileSync(path.join(dir, ".claude", "scheduled_tasks.json"), JSON.stringify({
755
+ tasks: [{ id: "d1", cron: "0 9 * * *", prompt: "daily", recurring: true, createdBySessionId: crypto.randomUUID() }],
756
+ }));
757
+ const s = await sessions.getOrCreate(id, dir);
758
+ await s.wake();
759
+ try {
760
+ assert.deepEqual(s.snapshot().crons.map((c) => c.id), ["d1"]);
761
+ assert.equal(s.hasScheduledWork(), false, "another session's durable task does not keep this process alive");
762
+ const { db } = await import("./db.ts");
763
+ assert.equal(db.prepare("SELECT 1 FROM scheduled_sessions WHERE session_id = ?").get(id), undefined);
764
+ } finally {
765
+ await s.shutdown();
766
+ await sessions.drop(id);
767
+ }
768
+ });
769
+
770
+ test("IDECron tasks appear in the snapshot and schedule delivery respects the mode", async () => {
771
+ created = [];
772
+ const { idecron } = await import("./idecron.ts");
773
+ const id = crypto.randomUUID();
774
+ const task = idecron.create({ sessionId: id, projectPath: "/tmp", when: "10m", prompt: "server tick" });
775
+ const s = new LiveSession(id, newDir(), () => {});
776
+ try {
777
+ assert.deepEqual(s.snapshot().crons.map((c) => c.id), [task.id]);
778
+ assert.equal(s.snapshot().crons[0]!.server, true);
779
+ assert.equal(await s.sendScheduled("steer-1", "do the server tick", "steer"), true);
780
+ const q = created[0]!;
781
+ const msg = await q.received();
782
+ assert.equal(msg.priority, undefined, "an idle steer starts a normal turn");
783
+ assert.equal(msg.client_composed, true);
784
+ assert.equal(await s.sendScheduled("next-1", "not yet", "next_turn"), false, "a running session waits for the next turn");
785
+ assert.equal(await s.sendScheduled("steer-2", "steer now", "steer"), true);
786
+ const queued = await q.received();
787
+ assert.equal(queued.priority, "next", "a running steer is queued at the next safe point");
788
+ q.push({ type: "command_lifecycle", command_uuid: "steer-1", state: "completed" });
789
+ q.push({ type: "command_lifecycle", command_uuid: "steer-2", state: "completed" });
790
+ q.push({ type: "result", subtype: "success", is_error: false, total_cost_usd: 0, uuid: "steer-1", session_id: "x" });
791
+ q.push({ type: "system", subtype: "session_state_changed", state: "idle" });
792
+ await tick();
793
+ assert.equal(await s.sendScheduled("next-1", "now", "next_turn"), true);
794
+ assert.equal(idecron.cancel(task.id, id), true);
795
+ assert.deepEqual(s.snapshot().crons, []);
796
+ } finally {
797
+ await s.shutdown();
798
+ }
799
+ });
800
+
801
+ test("deleting the durable file clears a durable-only stale row", async () => {
802
+ created = [];
803
+ const dir = newDir();
804
+ const id = crypto.randomUUID();
805
+ fs.mkdirSync(path.join(dir, ".claude"), { recursive: true });
806
+ fs.writeFileSync(path.join(dir, ".claude", "scheduled_tasks.json"), JSON.stringify({
807
+ tasks: [{ id: "d1", cron: "0 9 * * *", prompt: "daily", recurring: true, createdBySessionId: id }],
808
+ }));
809
+ const s = await sessions.getOrCreate(id, dir);
810
+ await s.wake();
811
+ try {
812
+ assert.equal(s.snapshot().crons.length, 1);
813
+ fs.rmSync(path.join(dir, ".claude", "scheduled_tasks.json"));
814
+ assert.equal(s.hasScheduledWork(), false, "the deleted file no longer protects the process");
815
+ assert.equal(s.snapshot().crons.length, 0);
816
+ const { db } = await import("./db.ts");
817
+ assert.equal(db.prepare("SELECT 1 FROM scheduled_sessions WHERE session_id = ?").get(id), undefined, "the stale row is removed");
818
+ } finally {
819
+ await s.shutdown();
820
+ await sessions.drop(id);
821
+ }
822
+ });
823
+
824
+ test("cron refresh runs one turn, lists tasks through the Stop hook, and does not notify", async () => {
825
+ created = [];
826
+ const transitions: { to: string; silent: boolean }[] = [];
827
+ const s = new LiveSession(crypto.randomUUID(), newDir(), () => {}, (_from, to, turn) => {
828
+ transitions.push({ to, silent: turn.silent });
829
+ });
830
+ try {
831
+ s.hydrateCrons([], true);
832
+ await s.refreshCrons();
833
+ const q = created[0]!;
834
+ const msg = await q.received();
835
+ const prompt = JSON.stringify(msg.message);
836
+ assert.match(prompt, /系统恢复/);
837
+ assert.match(prompt, /CronList/);
838
+ assert.doesNotMatch(prompt, /claude-ide/);
839
+ await q.args.options.hooks.Stop[0].hooks[0]({
840
+ session_crons: [{ id: "c1", schedule: "*/5 * * * *", recurring: true, prompt: "ping" }],
841
+ });
842
+ assert.equal(s.snapshot().cronsStale, false);
843
+ assert.deepEqual(s.snapshot().crons.map((c) => c.id), ["c1"]);
844
+ q.push({ type: "result", subtype: "success", is_error: false, total_cost_usd: 0, uuid: "refresh", session_id: "x" });
845
+ q.push({ type: "system", subtype: "session_state_changed", state: "idle" });
846
+ await tick();
847
+ assert.equal(transitions.find((t) => t.to === "idle")?.silent, true, "the refresh turn does not notify");
848
+ } finally {
849
+ await s.shutdown();
850
+ }
851
+ });
852
+
853
+ test("reload landing during delivery preserves the message", async () => {
854
+ created = [];
855
+ const s = new LiveSession(crypto.randomUUID(), newDir(), () => {});
856
+ await s.wake();
857
+ const received = created[0]!.received();
858
+ const sending = s.send("delivery", "keep me");
859
+ let reload: Promise<void> | undefined;
860
+ queueMicrotask(() => { reload = s.reloadConfig(); });
861
+ await sending;
862
+ await reload;
863
+ assert.match(JSON.stringify(await received), /keep me/);
864
+ assert.equal(created[0]!.closed, false);
865
+ await s.shutdown();
866
+ });
867
+
868
+ for (const order of ["result-first", "idle-first"] as const) {
869
+ test(`reload waits for queued B after A ends (${order})`, async () => {
870
+ created = [];
871
+ const s = new LiveSession(crypto.randomUUID(), newDir(), () => {});
872
+ await s.send("A", "first");
873
+ const q = created[0]!;
874
+ await q.received();
875
+ await s.reloadConfig();
876
+ await s.send("B", "second");
877
+ const idle = { type: "system", subtype: "session_state_changed", state: "idle" };
878
+ const result = (uuid: string) => ({ type: "result", subtype: "success", is_error: false, user_message_uuids: [uuid] });
879
+ for (const m of order === "result-first" ? [result("A"), idle] : [idle, result("A")]) q.push(m);
880
+ await tick();
881
+ assert.equal(q.closed, false, "B still needs this process");
882
+ assert.equal((await q.received()).uuid, "B");
883
+ for (const m of order === "result-first" ? [result("B"), idle] : [idle, result("B")]) q.push(m);
884
+ await tick();
885
+ assert.equal(q.closed, true, "reload happens after both messages finish");
886
+ await s.shutdown();
887
+ });
888
+ }
889
+
890
+ test("no-model commands finish through lifecycle events before config reload", async () => {
891
+ created = [];
892
+ const s = new LiveSession(crypto.randomUUID(), newDir(), () => {});
893
+ await s.runBash("bang", "printf test");
894
+ await s.reloadConfig();
895
+ assert.equal(created[0]!.closed, false);
896
+ created[0]!.push({ type: "command_lifecycle", command_uuid: "bang", state: "completed" });
897
+ await tick();
898
+ assert.equal(created[0]!.closed, true);
899
+ await s.shutdown();
900
+ });
901
+
902
+ test("/cd moves imported session callbacks, persistence and scheduled ownership", async (t) => {
903
+ created = [];
904
+ const { changeSessionDir, pendingRewindDraft } = await import("./sessions.ts");
905
+ const { db } = await import("./db.ts");
906
+ const { encodeProjectId } = await import("../shared/protocol.ts");
907
+ const dir = newDir(), target = newDir(), id = crypto.randomUUID();
908
+ const s = await sessions.getOrCreate(id, dir); // no session_view row, like a CLI import
909
+ await s.wake();
910
+ const hook = created[0]!.args.options.hooks.Stop[0].hooks[0];
911
+ await hook({ session_crons: [{ id: "c", schedule: "* * * * *", recurring: true, prompt: "ping" }] });
912
+ await changeSessionDir(id, dir, target);
913
+ assert.equal(s.cwd, target);
914
+ const notices: string[] = [];
915
+ // The manager broadcasts changes for every session. Other tests' deferred
916
+ // notifications may arrive while this test awaits SDK metadata; only these two
917
+ // project IDs can show whether this session's callback kept the old owner.
918
+ const watched = new Set([encodeProjectId(dir), encodeProjectId(target)]);
919
+ const unsubscribe = sessions.onSessionsChanged((pid) => { if (watched.has(pid)) notices.push(pid); });
920
+ const activity: string[] = [];
921
+ let activityArrived!: () => void;
922
+ const activityReceived = new Promise<void>((resolve) => { activityArrived = resolve; });
923
+ const unactivity = sessions.onActivity((a) => {
924
+ if (a.sessionId !== id) return;
925
+ activity.push(a.projectId);
926
+ activityArrived();
927
+ });
928
+ let activityTimer: ReturnType<typeof setTimeout> | undefined;
929
+ t.after(() => { clearTimeout(activityTimer); unsubscribe(); unactivity(); });
930
+ await s.send("cd-message", "hello");
931
+ created[0]!.push({ type: "result", subtype: "success", is_error: false, user_message_uuids: ["cd-message"] });
932
+ created[0]!.push({ type: "system", subtype: "session_state_changed", state: "idle" });
933
+ await Promise.race([activityReceived, new Promise<never>((_resolve, reject) => {
934
+ activityTimer = setTimeout(() => reject(new Error("Timed out waiting for the moved session's activity notification")), 3000);
935
+ })]);
936
+ clearTimeout(activityTimer);
937
+ assert.ok(notices.length > 0 && notices.every((pid) => pid === encodeProjectId(target)), `moved session notices: ${JSON.stringify(notices)}`);
938
+ assert.deepEqual(activity, [encodeProjectId(target)]);
939
+ assert.equal((db.prepare("SELECT project_path FROM session_view WHERE session_id=?").get(id) as any).project_path, target);
940
+ assert.equal((db.prepare("SELECT cwd FROM session_cwd WHERE session_id=?").get(id) as any).cwd, target);
941
+ assert.equal((db.prepare("SELECT project_path FROM scheduled_sessions WHERE session_id=?").get(id) as any).project_path, target);
942
+ await hook({ session_crons: [{ id: "c", schedule: "* * * * *", recurring: true, prompt: "ping" }] });
943
+ assert.equal((db.prepare("SELECT project_path FROM scheduled_sessions WHERE session_id=?").get(id) as any).project_path, target);
944
+ const rewindId = await s.rewindConversation(target, "anchor", "original");
945
+ assert.deepEqual(pendingRewindDraft(id), { rewindText: "original", rewindId });
946
+ const nextRewindId = await s.rewindConversation(target, "anchor", "original");
947
+ assert.notEqual(nextRewindId, rewindId, "repeating a rewind has a new identity");
948
+ unsubscribe(); unactivity();
949
+ await sessions.drop(id);
950
+ assert.equal((await sessions.getOrCreate(id, dir)).cwd, target, "old URL cannot reclaim ownership");
951
+ assert.equal(sessions.projectOf.get(id), target);
952
+ await sessions.drop(id);
953
+ });
954
+
955
+ test("/cd does not persist a directory rejected by the CLI trust response", async () => {
956
+ created = [];
957
+ const { changeSessionDir } = await import("./sessions.ts");
958
+ const { db } = await import("./db.ts");
959
+ const dir = newDir(), target = newDir(), id = crypto.randomUUID();
960
+ const s = await sessions.getOrCreate(id, dir);
961
+ await s.wake();
962
+ let calls = 0;
963
+ created[0]!.setCwd = async () => ++calls === 1
964
+ ? { status: "needs_trust", trusted_directory: target }
965
+ : { status: "error", error: "trust denied" };
966
+ await assert.rejects(changeSessionDir(id, dir, target), /trust denied/);
967
+ assert.equal(s.cwd, dir);
968
+ assert.equal(sessions.projectOf.get(id), dir);
969
+ assert.equal(db.prepare("SELECT cwd FROM session_cwd WHERE session_id=?").get(id), undefined);
970
+ assert.equal(db.prepare("SELECT project_path FROM session_view WHERE session_id=?").get(id), undefined);
971
+ await sessions.drop(id);
972
+ });
973
+
974
+ test("CACHE-002 active stream checkpoint survives the server replay ring", () => {
975
+ const s = new LiveSession(crypto.randomUUID(), newDir(), () => {});
976
+ const emit = (event: import("../shared/protocol.ts").LiveEvent) => (s as any).emit(event);
977
+ for (let i = 0; i < 6000; i++) emit({ k: "delta", msgId: "stream", index: 0, kind: "text", text: `${i},`, parent: null });
978
+ assert.equal(s.events.length, 5000);
979
+ assert.equal((s.streamingCheckpoints[0]?.ev as { text: string }).text, Array.from({ length: 6000 }, (_, i) => `${i},`).join(""));
980
+ emit({ k: "sdk", m: { type: "assistant", uuid: "done", message: { id: "stream", content: [{ type: "text", text: "done" }] } } });
981
+ assert.equal(s.streamingCheckpoints.length, 0);
982
+ s.close();
983
+ });
984
+
985
+ test("server publishes exact thinking stream identities alongside completed display messages", () => {
986
+ const s = new LiveSession(crypto.randomUUID(), newDir(), () => {});
987
+ const emit = (event: import("../shared/protocol.ts").LiveEvent) => (s as any).emit(event);
988
+ try {
989
+ emit({ k: "delta", msgId: "api", index: 7, kind: "thinking", text: "thought", parent: "agent" });
990
+ emit({ k: "sdk", m: { type: "assistant", uuid: "frame", parent_tool_use_id: "agent", message: { id: "api", content: [
991
+ { type: "text", text: "unstreamed" }, { type: "thinking", thinking: "thought" },
992
+ ] } } });
993
+ const event = s.events.at(-1)?.ev;
994
+ assert.ok(event?.k === "sdk");
995
+ assert.deepEqual(event.m._streamBlocks, [7]);
996
+ assert.deepEqual(event.m._streamThinking, { 1: 7 });
997
+ assert.equal(s.streamingCheckpoints.length, 0);
998
+ } finally { s.close(); }
999
+ });
1000
+
1001
+ test("CACHE-004 agent lifecycle survives transcript eviction and is recoverable in snapshots", () => {
1002
+ const s = new LiveSession(crypto.randomUUID(), newDir(), () => {});
1003
+ const handle = (message: Msg) => (s as any).handle(message);
1004
+ handle({ type: "assistant", uuid: "agent-call", message: { content: [{ type: "tool_use", id: "agent-tool", name: "Agent", input: { description: "independent review", run_in_background: true } }] } });
1005
+ handle({ type: "system", subtype: "task_started", uuid: "started", task_id: "agent-task", tool_use_id: "agent-tool", task_type: "local_agent", is_backgrounded: true, run_id: "run-2" });
1006
+ for (let i = 0; i < 5100; i++) (s as any).emit({ k: "state", state: "running" });
1007
+ assert.equal(s.events.some(({ ev }) => ev.k === "sdk" && ev.m.uuid === "agent-call"), false);
1008
+ assert.equal(s.snapshot().agentTasks?.find((task) => task.toolUseId === "agent-tool")?.status, "running");
1009
+ handle({ type: "user", uuid: "backgrounded", message: { content: [{ type: "tool_result", tool_use_id: "agent-tool", content: "running in background" }] } });
1010
+ assert.equal(s.snapshot().agentTasks?.[0]?.status, "running");
1011
+ handle({ type: "system", subtype: "task_notification", uuid: "finished", task_id: "agent-task", status: "completed", run_id: "run-2" });
1012
+ assert.equal(s.snapshot().agentTasks?.[0]?.status, "completed");
1013
+ assert.ok(s.events.some(({ ev }) => ev.k === "agent_tasks" && ev.tasks[0]?.status === "completed"));
1014
+ handle({ type: "system", subtype: "task_started", uuid: "late", task_id: "agent-task", tool_use_id: "agent-tool", run_id: "run-1" });
1015
+ assert.equal(s.snapshot().agentTasks?.[0]?.status, "completed");
1016
+ s.close();
1017
+ });
1018
+
1019
+ test("replayed agent messages retain their file identity after the task status map evicts their run", () => {
1020
+ const s = new LiveSession(crypto.randomUUID(), newDir(), () => {});
1021
+ const handle = (message: Msg) => (s as any).handle(message);
1022
+ try {
1023
+ handle({ type: "system", subtype: "task_started", task_id: "stable", tool_use_id: "resume-1", task_type: "local_agent" });
1024
+ handle({ type: "assistant", uuid: "old-text", parent_tool_use_id: "resume-1", message: { model: "model", content: [{ type: "text", text: "old live" }] } });
1025
+ handle({ type: "system", subtype: "task_notification", task_id: "stable", tool_use_id: "resume-1", status: "completed" });
1026
+ for (let i = 0; i < 128; i++) handle({ type: "system", subtype: "task_started", task_id: `task-${i}`, tool_use_id: `tool-${i}`, task_type: "local_agent" });
1027
+ assert.equal(s.snapshot().agentTasks?.some((task) => task.toolUseId === "resume-1"), false);
1028
+ const replay = s.events.find(({ ev }) => ev.k === "sdk" && ev.m.uuid === "old-text")?.ev;
1029
+ assert.ok(replay?.k === "sdk");
1030
+ assert.equal(replay.agentId, "stable", "newly connected clients do not need an evicted task-start event");
1031
+ } finally { s.close(); }
1032
+ });
1033
+
1034
+ test("actual subagent models are captured at stream start and survive replay eviction", () => {
1035
+ const s = new LiveSession(crypto.randomUUID(), newDir(), () => {});
1036
+ const handle = (message: Msg) => (s as any).handle(message);
1037
+ const model = (id: string) => s.snapshot().agentTasks?.find((task) => task.toolUseId === id)?.model;
1038
+ try {
1039
+ handle({ type: "assistant", message: { model: "main-model", content: ["agent-a", "agent-b"].map((id) => ({ type: "tool_use", id, name: "Agent", input: { model: "configured-hint" } })) } });
1040
+ assert.equal(model("agent-a"), undefined, "a requested model is not an observed response");
1041
+ handle({ type: "stream_event", parent_tool_use_id: "agent-a", event: { type: "message_start", message: { id: "response-a", model: "actual-a" } } });
1042
+ assert.equal(model("agent-a"), "actual-a");
1043
+ assert.equal(model("agent-b"), undefined);
1044
+ assert.equal(s.events.some(({ ev }) => ev.k === "sdk" && ev.m.type === "stream_event"), false, "raw starts are not sent to clients");
1045
+ assert.ok(s.events.some(({ ev }) => ev.k === "agent_tasks" && ev.tasks.some((task) => task.toolUseId === "agent-a" && task.model === "actual-a")));
1046
+ handle({ type: "assistant", parent_tool_use_id: "agent-a", message: { model: "fallback-a", content: [{ type: "tool_use", id: "nested", name: "Agent", input: {} }] } });
1047
+ handle({ type: "assistant", parent_tool_use_id: "nested", message: { model: "nested-model", content: [] } });
1048
+ handle({ type: "assistant", parent_tool_use_id: "agent-b", message: { model: "actual-b", content: [] } });
1049
+ handle({ type: "assistant", parent_tool_use_id: null, message: { model: "new-main-model", content: [] } });
1050
+ handle({ type: "assistant", parent_tool_use_id: "agent-a", message: { model: "<synthetic>", content: [] } });
1051
+ handle({ type: "assistant", parent_tool_use_id: "agent-a", error: "failed", message: { model: "error-model", content: [] } });
1052
+ handle({ type: "assistant", parent_tool_use_id: "unknown", message: { model: "orphan-model", content: [] } });
1053
+ assert.equal(s.snapshot().agentTasks?.length, 3, "response metadata must not create incomplete agent tasks");
1054
+ for (let i = 0; i < 5100; i++) (s as any).emit({ k: "state", state: "running" });
1055
+ assert.equal(s.events.some(({ ev }) => ev.k === "agent_tasks"), false);
1056
+ assert.equal(model("agent-a"), "fallback-a");
1057
+ assert.equal(model("agent-b"), "actual-b");
1058
+ assert.equal(model("nested"), "nested-model");
1059
+ } finally { s.close(); }
1060
+ });
1061
+
1062
+ test("late metadata cannot make an older invocation receive current task updates", async () => {
1063
+ const { currentAgentTask } = await import("../shared/agent-tasks.ts");
1064
+ const s = new LiveSession(crypto.randomUUID(), newDir(), () => {});
1065
+ const handle = (message: Msg) => (s as any).handle(message);
1066
+ const current = () => currentAgentTask(s.snapshot().agentTasks ?? [], "stable");
1067
+ try {
1068
+ handle({ type: "system", subtype: "task_started", task_id: "stable", tool_use_id: "old-tool", task_type: "local_agent", run_id: "run-1" });
1069
+ handle({ type: "system", subtype: "task_notification", task_id: "stable", tool_use_id: "old-tool", run_id: "run-1", status: "completed" });
1070
+ handle({ type: "system", subtype: "task_started", task_id: "stable", tool_use_id: "resumed-tool", task_type: "local_agent", run_id: "run-2" });
1071
+ handle({ type: "system", subtype: "task_progress", task_id: "stable", tool_use_id: "old-tool", run_id: "run-1", description: "late old progress" });
1072
+ handle({ type: "assistant", parent_tool_use_id: "old-tool", message: { model: "old-model", content: [] } });
1073
+ assert.equal(s.snapshot().agentTasks?.at(-1)?.toolUseId, "old-tool", "the probe really reorders map entries");
1074
+ assert.equal(current()?.toolUseId, "resumed-tool");
1075
+ handle({ type: "system", subtype: "task_updated", task_id: "stable", run_id: "run-2", patch: { status: "paused" } });
1076
+ assert.equal(current()?.status, "paused", "an event without a tool ID targets the latest run");
1077
+ handle({ type: "system", subtype: "task_notification", task_id: "stable", run_id: "run-1", status: "failed" });
1078
+ assert.equal(current()?.status, "paused", "a late notification cannot terminate the newer run");
1079
+ handle({ type: "system", subtype: "task_notification", task_id: "stable", run_id: "run-2", status: "completed" });
1080
+ assert.equal(current()?.status, "completed");
1081
+ assert.equal(s.snapshot().agentTasks?.find((task) => task.toolUseId === "old-tool")?.status, "completed");
1082
+ } finally { s.close(); }
1083
+ });
1084
+
1085
+ test("CACHE-004 foreground results finish only their agent and shutdown stops unfinished agents", () => {
1086
+ const s = new LiveSession(crypto.randomUUID(), newDir(), () => {});
1087
+ const handle = (message: Msg) => (s as any).handle(message);
1088
+ handle({ type: "assistant", uuid: "calls", message: { content: ["first", "second"].map((id) => ({ type: "tool_use", id, name: "Agent", input: {} })) } });
1089
+ handle({ type: "user", uuid: "r", message: { content: [{ type: "tool_result", tool_use_id: "first" }] } });
1090
+ const statuses = new Map(s.snapshot().agentTasks?.map((task) => [task.toolUseId, task.status]));
1091
+ assert.equal(statuses.get("first"), "completed");
1092
+ assert.equal(statuses.get("second"), "running");
1093
+ s.close();
1094
+ assert.equal(s.snapshot().agentTasks?.find((task) => task.toolUseId === "second")?.status, "stopped");
1095
+ });
1096
+
1097
+
1098
+ test("CACHE-018 results settle only foreground tasks in their parent scope", () => {
1099
+ const s = new LiveSession(crypto.randomUUID(), newDir(), () => {});
1100
+ const track = (message: Msg) => (s as any).trackAgentTasks(message);
1101
+ const call = (id: string, parent: string | null, background = false) => track({ type: "assistant", parent_tool_use_id: parent,
1102
+ message: { content: [{ type: "tool_use", id, name: "Agent", input: { run_in_background: background } }] } });
1103
+ call("background", null, true);
1104
+ track({ type: "system", subtype: "task_started", tool_use_id: "background", task_id: "parent-task", task_type: "local_agent" });
1105
+ call("child", "background");
1106
+ track({ type: "system", subtype: "task_started", tool_use_id: "child", task_id: "child-task", parent_task_id: "parent-task", spawn_depth: 2 });
1107
+ call("direct", null);
1108
+ // task_started can supply ancestry even when no assistant tool-call frame is retained.
1109
+ track({ type: "system", subtype: "task_started", tool_use_id: "metadata-child", task_id: "metadata-task", parent_task_id: "parent-task", task_type: "local_agent" });
1110
+ track({ type: "result", subtype: "success", parent_tool_use_id: null });
1111
+ const status = (id: string) => s.snapshot().agentTasks?.find((task) => task.toolUseId === id)?.status;
1112
+ assert.equal(status("direct"), "completed");
1113
+ assert.equal(status("background"), "running");
1114
+ assert.equal(status("child"), "running");
1115
+ assert.equal(status("metadata-child"), "running");
1116
+ track({ type: "result", subtype: "success", parent_tool_use_id: "background" });
1117
+ assert.equal(status("child"), "completed");
1118
+ assert.equal(status("metadata-child"), "completed");
1119
+ call("terminal-child", "background");
1120
+ track({ type: "system", subtype: "task_notification", tool_use_id: "terminal-child", status: "failed" });
1121
+ assert.equal(status("terminal-child"), "failed");
1122
+ call("shutdown-child", "background");
1123
+ s.close();
1124
+ assert.equal(status("background"), "stopped");
1125
+ assert.equal(status("shutdown-child"), "stopped");
1126
+ });