@cuongtran001/kanna 0.39.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (473) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +355 -0
  3. package/bin/kanna +9 -0
  4. package/dist/client/apple-touch-icon.png +0 -0
  5. package/dist/client/assets/abap-BdImnpbu.js +1 -0
  6. package/dist/client/assets/actionscript-3-CoDkCxhg.js +1 -0
  7. package/dist/client/assets/ada-bCR0ucgS.js +1 -0
  8. package/dist/client/assets/andromeeda-C4gqWexZ.js +1 -0
  9. package/dist/client/assets/angular-html-CU67Zn6k.js +1 -0
  10. package/dist/client/assets/angular-ts-BwZT4LLn.js +1 -0
  11. package/dist/client/assets/apache-Pmp26Uib.js +1 -0
  12. package/dist/client/assets/apex-D8_7TLub.js +1 -0
  13. package/dist/client/assets/apl-dKokRX4l.js +1 -0
  14. package/dist/client/assets/applescript-Co6uUVPk.js +1 -0
  15. package/dist/client/assets/ara-BRHolxvo.js +1 -0
  16. package/dist/client/assets/asciidoc-Ve4PFQV2.js +1 -0
  17. package/dist/client/assets/asm-D_Q5rh1f.js +1 -0
  18. package/dist/client/assets/astro-CbQHKStN.js +1 -0
  19. package/dist/client/assets/aurora-x-D-2ljcwZ.js +1 -0
  20. package/dist/client/assets/awk-DMzUqQB5.js +1 -0
  21. package/dist/client/assets/ayu-dark-DYE7WIF3.js +1 -0
  22. package/dist/client/assets/ayu-light-BA47KaF1.js +1 -0
  23. package/dist/client/assets/ayu-mirage-32ctXXKs.js +1 -0
  24. package/dist/client/assets/ballerina-BFfxhgS-.js +1 -0
  25. package/dist/client/assets/bat-BkioyH1T.js +1 -0
  26. package/dist/client/assets/beancount-k_qm7-4y.js +1 -0
  27. package/dist/client/assets/berry-uYugtg8r.js +1 -0
  28. package/dist/client/assets/bibtex-CHM0blh-.js +1 -0
  29. package/dist/client/assets/bicep-Bmn6On1c.js +1 -0
  30. package/dist/client/assets/bird2-DPOp833l.js +1 -0
  31. package/dist/client/assets/blade-D4QpJJKB.js +1 -0
  32. package/dist/client/assets/bricolage-grotesque-latin-ext-wght-normal-CcLUaPy7.woff2 +0 -0
  33. package/dist/client/assets/bricolage-grotesque-latin-wght-normal-DLoelf7F.woff2 +0 -0
  34. package/dist/client/assets/bricolage-grotesque-vietnamese-wght-normal-BUzh504Q.woff2 +0 -0
  35. package/dist/client/assets/bsl-BO_Y6i37.js +1 -0
  36. package/dist/client/assets/c-BIGW1oBm.js +1 -0
  37. package/dist/client/assets/c3-eo99z4R2.js +1 -0
  38. package/dist/client/assets/cadence-Bv_4Rxtq.js +1 -0
  39. package/dist/client/assets/cairo-KRGpt6FW.js +1 -0
  40. package/dist/client/assets/catppuccin-frappe-DFWUc33u.js +1 -0
  41. package/dist/client/assets/catppuccin-latte-C9dUb6Cb.js +1 -0
  42. package/dist/client/assets/catppuccin-macchiato-DQyhUUbL.js +1 -0
  43. package/dist/client/assets/catppuccin-mocha-D87Tk5Gz.js +1 -0
  44. package/dist/client/assets/clarity-D53aC0YG.js +1 -0
  45. package/dist/client/assets/clojure-P80f7IUj.js +1 -0
  46. package/dist/client/assets/cmake-D1j8_8rp.js +1 -0
  47. package/dist/client/assets/cobol-nwyudZeR.js +1 -0
  48. package/dist/client/assets/codeowners-Bp6g37R7.js +1 -0
  49. package/dist/client/assets/codeql-DsOJ9woJ.js +1 -0
  50. package/dist/client/assets/coffee-Ch7k5sss.js +1 -0
  51. package/dist/client/assets/common-lisp-Cg-RD9OK.js +1 -0
  52. package/dist/client/assets/coq-DkFqJrB1.js +1 -0
  53. package/dist/client/assets/cpp-CofmeUqb.js +1 -0
  54. package/dist/client/assets/crystal-tKQVLTB8.js +1 -0
  55. package/dist/client/assets/csharp-COcwbKMJ.js +1 -0
  56. package/dist/client/assets/css-DPfMkruS.js +1 -0
  57. package/dist/client/assets/csv-fuZLfV_i.js +1 -0
  58. package/dist/client/assets/cue-D82EKSYY.js +1 -0
  59. package/dist/client/assets/cypher-COkxafJQ.js +1 -0
  60. package/dist/client/assets/d-85-TOEBH.js +1 -0
  61. package/dist/client/assets/dark-plus-C3mMm8J8.js +1 -0
  62. package/dist/client/assets/dart-CF10PKvl.js +1 -0
  63. package/dist/client/assets/dax-CEL-wOlO.js +1 -0
  64. package/dist/client/assets/desktop-BmXAJ9_W.js +1 -0
  65. package/dist/client/assets/diff-D97Zzqfu.js +1 -0
  66. package/dist/client/assets/docker-BcOcwvcX.js +1 -0
  67. package/dist/client/assets/dotenv-Da5cRb03.js +1 -0
  68. package/dist/client/assets/dracula-BzJJZx-M.js +1 -0
  69. package/dist/client/assets/dracula-soft-BXkSAIEj.js +1 -0
  70. package/dist/client/assets/dream-maker-BtqSS_iP.js +1 -0
  71. package/dist/client/assets/edge-BkV0erSs.js +1 -0
  72. package/dist/client/assets/elixir-CDX3lj18.js +1 -0
  73. package/dist/client/assets/elm-DbKCFpqz.js +1 -0
  74. package/dist/client/assets/emacs-lisp-C9XAeP06.js +1 -0
  75. package/dist/client/assets/erb-B12qg9BL.js +1 -0
  76. package/dist/client/assets/erlang-DsQrWhSR.js +1 -0
  77. package/dist/client/assets/everforest-dark-BgDCqdQA.js +1 -0
  78. package/dist/client/assets/everforest-light-C8M2exoo.js +1 -0
  79. package/dist/client/assets/fennel-BYunw83y.js +1 -0
  80. package/dist/client/assets/fish-BvzEVeQv.js +1 -0
  81. package/dist/client/assets/fluent-C4IJs8-o.js +1 -0
  82. package/dist/client/assets/fortran-fixed-form-CkoXwp7k.js +1 -0
  83. package/dist/client/assets/fortran-free-form-BxgE0vQu.js +1 -0
  84. package/dist/client/assets/fsharp-CXgrBDvD.js +1 -0
  85. package/dist/client/assets/gdresource-BOOCDP_w.js +1 -0
  86. package/dist/client/assets/gdscript-C5YyOfLZ.js +1 -0
  87. package/dist/client/assets/gdshader-DkwncUOv.js +1 -0
  88. package/dist/client/assets/genie-D0YGMca9.js +1 -0
  89. package/dist/client/assets/gherkin-DyxjwDmM.js +1 -0
  90. package/dist/client/assets/git-commit-F4YmCXRG.js +1 -0
  91. package/dist/client/assets/git-rebase-r7XF79zn.js +1 -0
  92. package/dist/client/assets/github-dark-DHJKELXO.js +1 -0
  93. package/dist/client/assets/github-dark-default-Cuk6v7N8.js +1 -0
  94. package/dist/client/assets/github-dark-dimmed-DH5Ifo-i.js +1 -0
  95. package/dist/client/assets/github-dark-high-contrast-E3gJ1_iC.js +1 -0
  96. package/dist/client/assets/github-light-DAi9KRSo.js +1 -0
  97. package/dist/client/assets/github-light-default-D7oLnXFd.js +1 -0
  98. package/dist/client/assets/github-light-high-contrast-BfjtVDDH.js +1 -0
  99. package/dist/client/assets/gleam-BspZqrRM.js +1 -0
  100. package/dist/client/assets/glimmer-js-Rg0-pVw9.js +1 -0
  101. package/dist/client/assets/glimmer-ts-U6CK756n.js +1 -0
  102. package/dist/client/assets/glsl-DplSGwfg.js +1 -0
  103. package/dist/client/assets/gn-n2N0HUVH.js +1 -0
  104. package/dist/client/assets/gnuplot-DdkO51Og.js +1 -0
  105. package/dist/client/assets/go-CxLEBnE3.js +1 -0
  106. package/dist/client/assets/graphql-ChdNCCLP.js +1 -0
  107. package/dist/client/assets/groovy-gcz8RCvz.js +1 -0
  108. package/dist/client/assets/gruvbox-dark-hard-CFHQjOhq.js +1 -0
  109. package/dist/client/assets/gruvbox-dark-medium-GsRaNv29.js +1 -0
  110. package/dist/client/assets/gruvbox-dark-soft-CVdnzihN.js +1 -0
  111. package/dist/client/assets/gruvbox-light-hard-CH1njM8p.js +1 -0
  112. package/dist/client/assets/gruvbox-light-medium-DRw_LuNl.js +1 -0
  113. package/dist/client/assets/gruvbox-light-soft-hJgmCMqR.js +1 -0
  114. package/dist/client/assets/hack-CaT9iCJl.js +1 -0
  115. package/dist/client/assets/haml-B8DHNrY2.js +1 -0
  116. package/dist/client/assets/handlebars-BL8al0AC.js +1 -0
  117. package/dist/client/assets/haskell-Df6bDoY_.js +1 -0
  118. package/dist/client/assets/haxe-CzTSHFRz.js +1 -0
  119. package/dist/client/assets/hcl-BWvSN4gD.js +1 -0
  120. package/dist/client/assets/hjson-D5-asLiD.js +1 -0
  121. package/dist/client/assets/hlsl-D3lLCCz7.js +1 -0
  122. package/dist/client/assets/horizon-BUw7H-hv.js +1 -0
  123. package/dist/client/assets/horizon-bright-Cn-bp-IR.js +1 -0
  124. package/dist/client/assets/houston-DnULxvSX.js +1 -0
  125. package/dist/client/assets/html-GMplVEZG.js +1 -0
  126. package/dist/client/assets/html-derivative-BFtXZ54Q.js +1 -0
  127. package/dist/client/assets/http-jrhK8wxY.js +1 -0
  128. package/dist/client/assets/hurl-irOxFIW8.js +1 -0
  129. package/dist/client/assets/hxml-Bvhsp5Yf.js +1 -0
  130. package/dist/client/assets/hy-DFXneXwc.js +1 -0
  131. package/dist/client/assets/imba-DGztddWO.js +1 -0
  132. package/dist/client/assets/index-Do7324M0.css +32 -0
  133. package/dist/client/assets/index-ktE9DLCD.js +2620 -0
  134. package/dist/client/assets/ini-BEwlwnbL.js +1 -0
  135. package/dist/client/assets/java-CylS5w8V.js +1 -0
  136. package/dist/client/assets/javascript-wDzz0qaB.js +1 -0
  137. package/dist/client/assets/jinja-4LBKfQ-Z.js +1 -0
  138. package/dist/client/assets/jison-wvAkD_A8.js +1 -0
  139. package/dist/client/assets/json-Cp-IABpG.js +1 -0
  140. package/dist/client/assets/json5-C9tS-k6U.js +1 -0
  141. package/dist/client/assets/jsonc-Des-eS-w.js +1 -0
  142. package/dist/client/assets/jsonl-DcaNXYhu.js +1 -0
  143. package/dist/client/assets/jsonnet-DFQXde-d.js +1 -0
  144. package/dist/client/assets/jssm-C2t-YnRu.js +1 -0
  145. package/dist/client/assets/jsx-g9-lgVsj.js +1 -0
  146. package/dist/client/assets/julia-CxzCAyBv.js +1 -0
  147. package/dist/client/assets/just-Cw27pwNe.js +1 -0
  148. package/dist/client/assets/kanagawa-dragon-CkXjmgJE.js +1 -0
  149. package/dist/client/assets/kanagawa-lotus-CfQXZHmo.js +1 -0
  150. package/dist/client/assets/kanagawa-wave-DWedfzmr.js +1 -0
  151. package/dist/client/assets/kdl-DV7GczEv.js +1 -0
  152. package/dist/client/assets/kotlin-BdnUsdx6.js +1 -0
  153. package/dist/client/assets/kusto-DZf3V79B.js +1 -0
  154. package/dist/client/assets/laserwave-DUszq2jm.js +1 -0
  155. package/dist/client/assets/latex-CWtU0Tv5.js +1 -0
  156. package/dist/client/assets/lean-BZvkOJ9d.js +1 -0
  157. package/dist/client/assets/less-B1dDrJ26.js +1 -0
  158. package/dist/client/assets/light-plus-B7mTdjB0.js +1 -0
  159. package/dist/client/assets/liquid-DYVedYrR.js +1 -0
  160. package/dist/client/assets/llvm-DjAJT7YJ.js +1 -0
  161. package/dist/client/assets/log-2UxHyX5q.js +1 -0
  162. package/dist/client/assets/logo-BtOb2qkB.js +1 -0
  163. package/dist/client/assets/lua-BaeVxFsk.js +1 -0
  164. package/dist/client/assets/luau-C-HG3fhB.js +1 -0
  165. package/dist/client/assets/make-CHLpvVh8.js +1 -0
  166. package/dist/client/assets/markdown-Cvjx9yec.js +1 -0
  167. package/dist/client/assets/marko-CnJfTvn9.js +1 -0
  168. package/dist/client/assets/material-theme-D5KoaKCx.js +1 -0
  169. package/dist/client/assets/material-theme-darker-BfHTSMKl.js +1 -0
  170. package/dist/client/assets/material-theme-lighter-B0m2ddpp.js +1 -0
  171. package/dist/client/assets/material-theme-ocean-CyktbL80.js +1 -0
  172. package/dist/client/assets/material-theme-palenight-Csfq5Kiy.js +1 -0
  173. package/dist/client/assets/matlab-D7o27uSR.js +1 -0
  174. package/dist/client/assets/mdc-BMNejdWA.js +1 -0
  175. package/dist/client/assets/mdx-Cmh6b_Ma.js +1 -0
  176. package/dist/client/assets/mermaid-mWjccvbQ.js +1 -0
  177. package/dist/client/assets/min-dark-CafNBF8u.js +1 -0
  178. package/dist/client/assets/min-light-CTRr51gU.js +1 -0
  179. package/dist/client/assets/mipsasm-CKIfxQSi.js +1 -0
  180. package/dist/client/assets/mojo-rZm6bMo-.js +1 -0
  181. package/dist/client/assets/monokai-D4h5O-jR.js +1 -0
  182. package/dist/client/assets/moonbit-_H4v1dQx.js +1 -0
  183. package/dist/client/assets/move-IF9eRakj.js +1 -0
  184. package/dist/client/assets/narrat-DRg8JJMk.js +1 -0
  185. package/dist/client/assets/nextflow-Zz6hmt5N.js +1 -0
  186. package/dist/client/assets/nextflow-groovy-BeH2EWoN.js +1 -0
  187. package/dist/client/assets/nginx-BpAMiNFr.js +1 -0
  188. package/dist/client/assets/night-owl-C39BiMTA.js +1 -0
  189. package/dist/client/assets/night-owl-light-CMTm3GFP.js +1 -0
  190. package/dist/client/assets/nim-CVrawwO9.js +1 -0
  191. package/dist/client/assets/nix-CwoSXNpI.js +1 -0
  192. package/dist/client/assets/nord-Ddv68eIx.js +1 -0
  193. package/dist/client/assets/nushell-Cz2AlsmD.js +1 -0
  194. package/dist/client/assets/objective-c-DXmwc3jG.js +1 -0
  195. package/dist/client/assets/objective-cpp-CLxacb5B.js +1 -0
  196. package/dist/client/assets/ocaml-C0hk2d4L.js +1 -0
  197. package/dist/client/assets/odin-BBf5iR-q.js +1 -0
  198. package/dist/client/assets/one-dark-pro-DVMEJ2y_.js +1 -0
  199. package/dist/client/assets/one-light-C3Wv6jpd.js +1 -0
  200. package/dist/client/assets/openscad-C4EeE6gA.js +1 -0
  201. package/dist/client/assets/pascal-D93ZcfNL.js +1 -0
  202. package/dist/client/assets/perl-C0TMdlhV.js +1 -0
  203. package/dist/client/assets/php-Dhbhpdrm.js +1 -0
  204. package/dist/client/assets/pierre-dark-DF2SEV7i.js +1 -0
  205. package/dist/client/assets/pierre-light-DOlZxES8.js +1 -0
  206. package/dist/client/assets/pkl-u5AG7uiY.js +1 -0
  207. package/dist/client/assets/plastic-3e1v2bzS.js +1 -0
  208. package/dist/client/assets/plsql-ChMvpjG-.js +1 -0
  209. package/dist/client/assets/po-BTJTHyun.js +1 -0
  210. package/dist/client/assets/poimandres-CS3Unz2-.js +1 -0
  211. package/dist/client/assets/polar-C0HS_06l.js +1 -0
  212. package/dist/client/assets/postcss-CXtECtnM.js +1 -0
  213. package/dist/client/assets/powerquery-CEu0bR-o.js +1 -0
  214. package/dist/client/assets/powershell-Dpen1YoG.js +1 -0
  215. package/dist/client/assets/prisma-Dd19v3D-.js +1 -0
  216. package/dist/client/assets/prolog-CbFg5uaA.js +1 -0
  217. package/dist/client/assets/proto-C7zT0LnQ.js +1 -0
  218. package/dist/client/assets/pug-CGlum2m_.js +1 -0
  219. package/dist/client/assets/puppet-BMWR74SV.js +1 -0
  220. package/dist/client/assets/purescript-CklMAg4u.js +1 -0
  221. package/dist/client/assets/python-B6aJPvgy.js +1 -0
  222. package/dist/client/assets/qml-3beO22l8.js +1 -0
  223. package/dist/client/assets/qmldir-C8lEn-DE.js +1 -0
  224. package/dist/client/assets/qss-IeuSbFQv.js +1 -0
  225. package/dist/client/assets/r-Dspwwk_N.js +1 -0
  226. package/dist/client/assets/racket-BqYA7rlc.js +1 -0
  227. package/dist/client/assets/raku-DXvB9xmW.js +1 -0
  228. package/dist/client/assets/razor-Uh8Bk_45.js +1 -0
  229. package/dist/client/assets/red-bN70gL4F.js +1 -0
  230. package/dist/client/assets/reg-C-SQnVFl.js +1 -0
  231. package/dist/client/assets/regexp-CDVJQ6XC.js +1 -0
  232. package/dist/client/assets/rel-C3B-1QV4.js +1 -0
  233. package/dist/client/assets/riscv-BM1_JUlF.js +1 -0
  234. package/dist/client/assets/ron-D8l8udqQ.js +1 -0
  235. package/dist/client/assets/rose-pine-dawn-DHQR4-dF.js +1 -0
  236. package/dist/client/assets/rose-pine-moon-D4_iv3hh.js +1 -0
  237. package/dist/client/assets/rose-pine-qdsjHGoJ.js +1 -0
  238. package/dist/client/assets/rosmsg-BJDFO7_C.js +1 -0
  239. package/dist/client/assets/rst-BrH8l1NY.js +1 -0
  240. package/dist/client/assets/ruby-Dw2BHqvy.js +1 -0
  241. package/dist/client/assets/rust-B1yitclQ.js +1 -0
  242. package/dist/client/assets/sas-cz2c8ADy.js +1 -0
  243. package/dist/client/assets/sass-Cj5Yp3dK.js +1 -0
  244. package/dist/client/assets/scala-C151Ov-r.js +1 -0
  245. package/dist/client/assets/scheme-C98Dy4si.js +1 -0
  246. package/dist/client/assets/scss-OYdSNvt2.js +1 -0
  247. package/dist/client/assets/sdbl-DVxCFoDh.js +1 -0
  248. package/dist/client/assets/shaderlab-Dg9Lc6iA.js +1 -0
  249. package/dist/client/assets/shellscript-Yzrsuije.js +1 -0
  250. package/dist/client/assets/shellsession-BADoaaVG.js +1 -0
  251. package/dist/client/assets/slack-dark-BthQWCQV.js +1 -0
  252. package/dist/client/assets/slack-ochin-DqwNpetd.js +1 -0
  253. package/dist/client/assets/smalltalk-BERRCDM3.js +1 -0
  254. package/dist/client/assets/snazzy-light-Bw305WKR.js +1 -0
  255. package/dist/client/assets/solarized-dark-DXbdFlpD.js +1 -0
  256. package/dist/client/assets/solarized-light-L9t79GZl.js +1 -0
  257. package/dist/client/assets/solidity-rGO070M0.js +1 -0
  258. package/dist/client/assets/soy-Brmx7dQM.js +1 -0
  259. package/dist/client/assets/sparql-rVzFXLq3.js +1 -0
  260. package/dist/client/assets/splunk-BtCnVYZw.js +1 -0
  261. package/dist/client/assets/sql-BLtJtn59.js +1 -0
  262. package/dist/client/assets/ssh-config-_ykCGR6B.js +1 -0
  263. package/dist/client/assets/stata-BH5u7GGu.js +1 -0
  264. package/dist/client/assets/stylus-BEDo0Tqx.js +1 -0
  265. package/dist/client/assets/surrealql-Bq5Q-fJD.js +1 -0
  266. package/dist/client/assets/svelte-C_ipcX3V.js +1 -0
  267. package/dist/client/assets/swift-D82vCrfD.js +1 -0
  268. package/dist/client/assets/synthwave-84-CbfX1IO0.js +1 -0
  269. package/dist/client/assets/system-verilog-CnnmHF94.js +1 -0
  270. package/dist/client/assets/systemd-4A_iFExJ.js +1 -0
  271. package/dist/client/assets/talonscript-CkByrt1z.js +1 -0
  272. package/dist/client/assets/tasl-QIJgUcNo.js +1 -0
  273. package/dist/client/assets/tcl-dwOrl1Do.js +1 -0
  274. package/dist/client/assets/templ-P3uqSqPl.js +1 -0
  275. package/dist/client/assets/terraform-BETggiCN.js +1 -0
  276. package/dist/client/assets/tex-idrVyKtj.js +1 -0
  277. package/dist/client/assets/tokyo-night-hegEt444.js +1 -0
  278. package/dist/client/assets/toml-vGWfd6FD.js +1 -0
  279. package/dist/client/assets/ts-tags-zn1MmPIZ.js +1 -0
  280. package/dist/client/assets/tsv-B_m7g4N7.js +1 -0
  281. package/dist/client/assets/tsx-COt5Ahok.js +1 -0
  282. package/dist/client/assets/turtle-BsS91CYL.js +1 -0
  283. package/dist/client/assets/twig-DNn4PbVi.js +1 -0
  284. package/dist/client/assets/typescript-BPQ3VLAy.js +1 -0
  285. package/dist/client/assets/typespec-BGHnOYBU.js +1 -0
  286. package/dist/client/assets/typst-DHCkPAjA.js +1 -0
  287. package/dist/client/assets/v-BcVCzyr7.js +1 -0
  288. package/dist/client/assets/vala-CsfeWuGM.js +1 -0
  289. package/dist/client/assets/vb-D17OF-Vu.js +1 -0
  290. package/dist/client/assets/verilog-BQ8w6xss.js +1 -0
  291. package/dist/client/assets/vesper-DU1UobuO.js +1 -0
  292. package/dist/client/assets/vhdl-CeAyd5Ju.js +1 -0
  293. package/dist/client/assets/viml-CJc9bBzg.js +1 -0
  294. package/dist/client/assets/vitesse-black-Bkuqu6BP.js +1 -0
  295. package/dist/client/assets/vitesse-dark-D0r3Knsf.js +1 -0
  296. package/dist/client/assets/vitesse-light-CVO1_9PV.js +1 -0
  297. package/dist/client/assets/vue-DN_0RTcg.js +1 -0
  298. package/dist/client/assets/vue-html-AaS7Mt5G.js +1 -0
  299. package/dist/client/assets/vue-vine-CQOfvN7w.js +1 -0
  300. package/dist/client/assets/vyper-CDx5xZoG.js +1 -0
  301. package/dist/client/assets/wasm-CG6Dc4jp.js +1 -0
  302. package/dist/client/assets/wasm-MzD3tlZU.js +1 -0
  303. package/dist/client/assets/wenyan-BV7otONQ.js +1 -0
  304. package/dist/client/assets/wgsl-Dx-B1_4e.js +1 -0
  305. package/dist/client/assets/wikitext-BhOHFoWU.js +1 -0
  306. package/dist/client/assets/wit-5i3qLPDT.js +1 -0
  307. package/dist/client/assets/wolfram-lXgVvXCa.js +1 -0
  308. package/dist/client/assets/xml-sdJ4AIDG.js +1 -0
  309. package/dist/client/assets/xsl-CtQFsRM5.js +1 -0
  310. package/dist/client/assets/yaml-Buea-lGh.js +1 -0
  311. package/dist/client/assets/zenscript-DVFEvuxE.js +1 -0
  312. package/dist/client/assets/zig-VOosw3JB.js +1 -0
  313. package/dist/client/chat-sounds/Blow.mp3 +0 -0
  314. package/dist/client/chat-sounds/Bottle.mp3 +0 -0
  315. package/dist/client/chat-sounds/Frog.mp3 +0 -0
  316. package/dist/client/chat-sounds/Funk.mp3 +0 -0
  317. package/dist/client/chat-sounds/Glass.mp3 +0 -0
  318. package/dist/client/chat-sounds/Ping.mp3 +0 -0
  319. package/dist/client/chat-sounds/Pop.mp3 +0 -0
  320. package/dist/client/chat-sounds/Purr.mp3 +0 -0
  321. package/dist/client/chat-sounds/Tink.mp3 +0 -0
  322. package/dist/client/editor-icons/cursor.png +0 -0
  323. package/dist/client/editor-icons/custom.png +0 -0
  324. package/dist/client/editor-icons/default-app.png +0 -0
  325. package/dist/client/editor-icons/finder.png +0 -0
  326. package/dist/client/editor-icons/preview.png +0 -0
  327. package/dist/client/editor-icons/terminal.png +0 -0
  328. package/dist/client/editor-icons/windsurf.png +0 -0
  329. package/dist/client/editor-icons/xcode.png +0 -0
  330. package/dist/client/favicon.png +0 -0
  331. package/dist/client/fonts/body-medium.woff2 +0 -0
  332. package/dist/client/fonts/body-regular-italic.woff2 +0 -0
  333. package/dist/client/fonts/body-regular.woff2 +0 -0
  334. package/dist/client/fonts/body-semibold.woff2 +0 -0
  335. package/dist/client/icon-192.png +0 -0
  336. package/dist/client/icon-512.png +0 -0
  337. package/dist/client/icon-maskable-512.png +0 -0
  338. package/dist/client/icon.svg +4 -0
  339. package/dist/client/index.html +34 -0
  340. package/dist/client/manifest.webmanifest +46 -0
  341. package/dist/client/screenshot-light.png +0 -0
  342. package/dist/client/screenshot.png +0 -0
  343. package/dist/export-viewer/assets/bricolage-grotesque-latin-ext-wght-normal-CcLUaPy7.woff2 +0 -0
  344. package/dist/export-viewer/assets/bricolage-grotesque-latin-wght-normal-DLoelf7F.woff2 +0 -0
  345. package/dist/export-viewer/assets/bricolage-grotesque-vietnamese-wght-normal-BUzh504Q.woff2 +0 -0
  346. package/dist/export-viewer/assets/index-D1qUumZR.js +410 -0
  347. package/dist/export-viewer/assets/index-gG2nMW51.css +1 -0
  348. package/dist/export-viewer/editor-icons/cursor.png +0 -0
  349. package/dist/export-viewer/editor-icons/custom.png +0 -0
  350. package/dist/export-viewer/editor-icons/default-app.png +0 -0
  351. package/dist/export-viewer/editor-icons/finder.png +0 -0
  352. package/dist/export-viewer/editor-icons/preview.png +0 -0
  353. package/dist/export-viewer/editor-icons/terminal.png +0 -0
  354. package/dist/export-viewer/editor-icons/windsurf.png +0 -0
  355. package/dist/export-viewer/editor-icons/xcode.png +0 -0
  356. package/dist/export-viewer/fonts/body-medium.woff2 +0 -0
  357. package/dist/export-viewer/fonts/body-regular-italic.woff2 +0 -0
  358. package/dist/export-viewer/fonts/body-regular.woff2 +0 -0
  359. package/dist/export-viewer/fonts/body-semibold.woff2 +0 -0
  360. package/dist/export-viewer/index.html +14 -0
  361. package/package.json +99 -0
  362. package/src/server/__fixtures__/claude-session-empty.jsonl +0 -0
  363. package/src/server/__fixtures__/claude-session-malformed.jsonl +3 -0
  364. package/src/server/__fixtures__/claude-session-valid.jsonl +6 -0
  365. package/src/server/agent.test.ts +2369 -0
  366. package/src/server/agent.ts +1927 -0
  367. package/src/server/analytics.test.ts +313 -0
  368. package/src/server/analytics.ts +131 -0
  369. package/src/server/app-settings.test.ts +233 -0
  370. package/src/server/app-settings.ts +548 -0
  371. package/src/server/auth.test.ts +329 -0
  372. package/src/server/auth.ts +204 -0
  373. package/src/server/auto-continue/e2e.test.ts +215 -0
  374. package/src/server/auto-continue/events.test.ts +30 -0
  375. package/src/server/auto-continue/events.ts +35 -0
  376. package/src/server/auto-continue/limit-detector.test.ts +153 -0
  377. package/src/server/auto-continue/limit-detector.ts +159 -0
  378. package/src/server/auto-continue/read-model.test.ts +109 -0
  379. package/src/server/auto-continue/read-model.ts +83 -0
  380. package/src/server/auto-continue/schedule-manager.test.ts +155 -0
  381. package/src/server/auto-continue/schedule-manager.ts +116 -0
  382. package/src/server/claude-session-importer.test.ts +214 -0
  383. package/src/server/claude-session-importer.ts +187 -0
  384. package/src/server/claude-session-mapper.test.ts +88 -0
  385. package/src/server/claude-session-mapper.ts +106 -0
  386. package/src/server/claude-session-parser.test.ts +38 -0
  387. package/src/server/claude-session-parser.ts +67 -0
  388. package/src/server/claude-session-scanner.test.ts +49 -0
  389. package/src/server/claude-session-scanner.ts +24 -0
  390. package/src/server/claude-session-types.ts +61 -0
  391. package/src/server/cli-runtime.test.ts +523 -0
  392. package/src/server/cli-runtime.ts +405 -0
  393. package/src/server/cli-supervisor.ts +102 -0
  394. package/src/server/cli.ts +64 -0
  395. package/src/server/cloudflare-tunnel/agent-integration.test.ts +76 -0
  396. package/src/server/cloudflare-tunnel/agent-integration.ts +55 -0
  397. package/src/server/cloudflare-tunnel/detector.test.ts +72 -0
  398. package/src/server/cloudflare-tunnel/detector.ts +44 -0
  399. package/src/server/cloudflare-tunnel/e2e.test.ts +194 -0
  400. package/src/server/cloudflare-tunnel/events.test.ts +43 -0
  401. package/src/server/cloudflare-tunnel/events.ts +31 -0
  402. package/src/server/cloudflare-tunnel/gateway.ts +143 -0
  403. package/src/server/cloudflare-tunnel/lifecycle.test.ts +48 -0
  404. package/src/server/cloudflare-tunnel/lifecycle.ts +62 -0
  405. package/src/server/cloudflare-tunnel/read-model.test.ts +69 -0
  406. package/src/server/cloudflare-tunnel/read-model.ts +80 -0
  407. package/src/server/cloudflare-tunnel/tunnel-manager.test.ts +116 -0
  408. package/src/server/cloudflare-tunnel/tunnel-manager.ts +165 -0
  409. package/src/server/codex-app-server-protocol.ts +487 -0
  410. package/src/server/codex-app-server.test.ts +1816 -0
  411. package/src/server/codex-app-server.ts +1475 -0
  412. package/src/server/diff-store.test.ts +737 -0
  413. package/src/server/diff-store.ts +2199 -0
  414. package/src/server/discovery.test.ts +211 -0
  415. package/src/server/discovery.ts +301 -0
  416. package/src/server/event-store.test.ts +797 -0
  417. package/src/server/event-store.ts +1421 -0
  418. package/src/server/events.ts +217 -0
  419. package/src/server/external-open.test.ts +112 -0
  420. package/src/server/external-open.ts +345 -0
  421. package/src/server/generate-commit-message.test.ts +79 -0
  422. package/src/server/generate-commit-message.ts +126 -0
  423. package/src/server/generate-title.ts +76 -0
  424. package/src/server/harness-types.ts +19 -0
  425. package/src/server/keybindings.test.ts +144 -0
  426. package/src/server/keybindings.ts +178 -0
  427. package/src/server/llm-provider.test.ts +134 -0
  428. package/src/server/llm-provider.ts +207 -0
  429. package/src/server/machine-name.ts +22 -0
  430. package/src/server/paths-route.test.ts +64 -0
  431. package/src/server/paths.ts +35 -0
  432. package/src/server/process-utils.test.ts +12 -0
  433. package/src/server/process-utils.ts +47 -0
  434. package/src/server/project-paths.test.ts +95 -0
  435. package/src/server/project-paths.ts +191 -0
  436. package/src/server/provider-catalog.test.ts +69 -0
  437. package/src/server/provider-catalog.ts +87 -0
  438. package/src/server/quick-response.test.ts +440 -0
  439. package/src/server/quick-response.ts +300 -0
  440. package/src/server/read-models.test.ts +509 -0
  441. package/src/server/read-models.ts +230 -0
  442. package/src/server/restart.test.ts +27 -0
  443. package/src/server/restart.ts +30 -0
  444. package/src/server/server.ts +616 -0
  445. package/src/server/share.test.ts +180 -0
  446. package/src/server/share.ts +150 -0
  447. package/src/server/standalone-export.test.ts +224 -0
  448. package/src/server/standalone-export.ts +419 -0
  449. package/src/server/terminal-manager.test.ts +315 -0
  450. package/src/server/terminal-manager.ts +350 -0
  451. package/src/server/test-helpers/async-event-queue.ts +52 -0
  452. package/src/server/test-helpers/wait-for.ts +14 -0
  453. package/src/server/title-generation.live.test.ts +44 -0
  454. package/src/server/update-manager.test.ts +158 -0
  455. package/src/server/update-manager.ts +222 -0
  456. package/src/server/update-strategy.test.ts +237 -0
  457. package/src/server/update-strategy.ts +241 -0
  458. package/src/server/uploads.test.ts +292 -0
  459. package/src/server/uploads.ts +131 -0
  460. package/src/server/ws-router.test.ts +2292 -0
  461. package/src/server/ws-router.ts +1465 -0
  462. package/src/shared/analytics.ts +30 -0
  463. package/src/shared/branding.test.ts +31 -0
  464. package/src/shared/branding.ts +77 -0
  465. package/src/shared/dev-ports.test.ts +113 -0
  466. package/src/shared/dev-ports.ts +134 -0
  467. package/src/shared/ports.ts +2 -0
  468. package/src/shared/protocol.ts +257 -0
  469. package/src/shared/share.ts +27 -0
  470. package/src/shared/tools.test.ts +164 -0
  471. package/src/shared/tools.ts +327 -0
  472. package/src/shared/types.test.ts +25 -0
  473. package/src/shared/types.ts +1088 -0
@@ -0,0 +1,1927 @@
1
+ import { query, type CanUseTool, type PermissionResult, type Query, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk"
2
+ import type {
3
+ AgentProvider,
4
+ ChatAttachment,
5
+ ContextWindowUsageSnapshot,
6
+ ModelOptions,
7
+ NormalizedToolCall,
8
+ PendingToolSnapshot,
9
+ KannaStatus,
10
+ QueuedChatMessage,
11
+ SlashCommand,
12
+ TranscriptEntry,
13
+ } from "../shared/types"
14
+ import { normalizeToolCall } from "../shared/tools"
15
+ import type { ClientCommand } from "../shared/protocol"
16
+ import { EventStore } from "./event-store"
17
+ import type { AnalyticsReporter } from "./analytics"
18
+ import { NoopAnalyticsReporter } from "./analytics"
19
+ import { CodexAppServerManager } from "./codex-app-server"
20
+ import { type GenerateChatTitleResult, generateTitleForChatDetailed } from "./generate-title"
21
+ import type { HarnessEvent, HarnessToolRequest, HarnessTurn } from "./harness-types"
22
+ import {
23
+ codexServiceTierFromModelOptions,
24
+ getServerProviderCatalog,
25
+ normalizeClaudeModelOptions,
26
+ normalizeCodexModelOptions,
27
+ normalizeServerModel,
28
+ } from "./provider-catalog"
29
+ import { resolveClaudeApiModelId } from "../shared/types"
30
+ import { fallbackTitleFromMessage } from "./generate-title"
31
+ import { AUTO_CONTINUE_EVENT_VERSION, type AutoContinueEvent } from "./auto-continue/events"
32
+ import { ClaudeLimitDetector, CodexLimitDetector, type LimitDetection, type LimitDetector } from "./auto-continue/limit-detector"
33
+ import type { ScheduleManager } from "./auto-continue/schedule-manager"
34
+ import { deriveChatSchedules } from "./auto-continue/read-model"
35
+ import type { TunnelGateway } from "./cloudflare-tunnel/gateway"
36
+
37
+ const CLAUDE_TOOLSET = [
38
+ "Skill",
39
+ "WebFetch",
40
+ "WebSearch",
41
+ "Task",
42
+ "TaskOutput",
43
+ "Bash",
44
+ "Glob",
45
+ "Grep",
46
+ "Read",
47
+ "Edit",
48
+ "Write",
49
+ "TodoWrite",
50
+ "KillShell",
51
+ "AskUserQuestion",
52
+ "EnterPlanMode",
53
+ "ExitPlanMode",
54
+ ] as const
55
+
56
+ interface PendingToolRequest {
57
+ toolUseId: string
58
+ tool: NormalizedToolCall & { toolKind: "ask_user_question" | "exit_plan_mode" }
59
+ resolve: (result: unknown) => void
60
+ }
61
+
62
+ interface ActiveTurn {
63
+ chatId: string
64
+ provider: AgentProvider
65
+ turn: HarnessTurn
66
+ claudePromptSeq?: number
67
+ model: string
68
+ effort?: string
69
+ serviceTier?: "fast"
70
+ planMode: boolean
71
+ status: KannaStatus
72
+ pendingTool: PendingToolRequest | null
73
+ postToolFollowUp: { content: string; planMode: boolean } | null
74
+ hasFinalResult: boolean
75
+ cancelRequested: boolean
76
+ cancelRecorded: boolean
77
+ clientTraceId?: string
78
+ profilingStartedAt?: number
79
+ }
80
+
81
+ interface ClaudeSessionHandle {
82
+ provider: "claude"
83
+ stream: AsyncIterable<HarnessEvent>
84
+ getAccountInfo?: () => Promise<any>
85
+ interrupt: () => Promise<void>
86
+ close: () => void
87
+ sendPrompt: (content: string) => Promise<void>
88
+ setModel: (model: string) => Promise<void>
89
+ setPermissionMode: (planMode: boolean) => Promise<void>
90
+ getSupportedCommands: () => Promise<SlashCommand[]>
91
+ }
92
+
93
+ interface ClaudeSessionState {
94
+ id: string
95
+ chatId: string
96
+ session: ClaudeSessionHandle
97
+ localPath: string
98
+ model: string
99
+ effort?: string
100
+ planMode: boolean
101
+ sessionToken: string | null
102
+ accountInfoLoaded: boolean
103
+ nextPromptSeq: number
104
+ pendingPromptSeqs: number[]
105
+ }
106
+
107
+ interface AgentCoordinatorArgs {
108
+ store: EventStore
109
+ onStateChange: (chatId?: string, options?: { immediate?: boolean }) => void
110
+ analytics?: AnalyticsReporter
111
+ codexManager?: CodexAppServerManager
112
+ generateTitle?: (messageContent: string, cwd: string) => Promise<GenerateChatTitleResult>
113
+ tunnelGateway?: TunnelGateway
114
+ startClaudeSession?: (args: {
115
+ localPath: string
116
+ model: string
117
+ effort?: string
118
+ planMode: boolean
119
+ sessionToken: string | null
120
+ forkSession: boolean
121
+ onToolRequest: (request: HarnessToolRequest) => Promise<unknown>
122
+ }) => Promise<ClaudeSessionHandle>
123
+ claudeLimitDetector?: LimitDetector
124
+ codexLimitDetector?: LimitDetector
125
+ scheduleManager?: ScheduleManager
126
+ getAutoResumePreference?: () => boolean
127
+ throwOnClaudeSessionStart?: boolean
128
+ }
129
+
130
+ interface SendToStartingProfile {
131
+ traceId: string
132
+ startedAt: number
133
+ }
134
+
135
+ function isClaudeSteerLoggingEnabled() {
136
+ return process.env.KANNA_LOG_CLAUDE_STEER === "1"
137
+ }
138
+
139
+ function logClaudeSteer(stage: string, details?: Record<string, unknown>) {
140
+ if (!isClaudeSteerLoggingEnabled()) return
141
+ console.log("[kanna/claude-steer]", JSON.stringify({
142
+ stage,
143
+ ...details,
144
+ }))
145
+ }
146
+
147
+ const STEERED_MESSAGE_PREFIX = `<system-message>
148
+ The user would like to inform you of something while you continue to work. Acknowledge receipt immediately with a text response, then continue with the task at hand, incorporating the user's feedback if needed.
149
+ </system-message>`
150
+
151
+ interface SendMessageOptions {
152
+ provider?: AgentProvider
153
+ model?: string
154
+ modelOptions?: ModelOptions
155
+ effort?: string
156
+ planMode?: boolean
157
+ autoContinue?: { scheduleId: string }
158
+ }
159
+
160
+ function timestamped<T extends Omit<TranscriptEntry, "_id" | "createdAt">>(
161
+ entry: T,
162
+ createdAt = Date.now()
163
+ ): TranscriptEntry {
164
+ return {
165
+ _id: crypto.randomUUID(),
166
+ createdAt,
167
+ ...entry,
168
+ } as TranscriptEntry
169
+ }
170
+
171
+ function stringFromUnknown(value: unknown) {
172
+ if (typeof value === "string") return value
173
+ try {
174
+ return JSON.stringify(value, null, 2)
175
+ } catch {
176
+ return String(value)
177
+ }
178
+ }
179
+
180
+ function buildSteeredMessageContent(content: string) {
181
+ return content.trim().length > 0
182
+ ? `${STEERED_MESSAGE_PREFIX}\n\n${content}`
183
+ : STEERED_MESSAGE_PREFIX
184
+ }
185
+
186
+ function asRecord(value: unknown): Record<string, unknown> | null {
187
+ return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null
188
+ }
189
+
190
+ function stringifyToolResultContent(content: unknown): string {
191
+ if (typeof content === "string") return content
192
+ if (Array.isArray(content)) {
193
+ return content
194
+ .map((item) => {
195
+ if (item && typeof item === "object") {
196
+ const r = item as Record<string, unknown>
197
+ return typeof r.text === "string" ? r.text : ""
198
+ }
199
+ return ""
200
+ })
201
+ .join("")
202
+ }
203
+ return ""
204
+ }
205
+
206
+ function asNumber(value: unknown): number | undefined {
207
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined
208
+ }
209
+
210
+ function escapeXmlAttribute(value: string) {
211
+ return value
212
+ .replaceAll("&", "&amp;")
213
+ .replaceAll("\"", "&quot;")
214
+ .replaceAll("<", "&lt;")
215
+ .replaceAll(">", "&gt;")
216
+ }
217
+
218
+ function isSendToStartingProfilingEnabled() {
219
+ return process.env.KANNA_PROFILE_SEND_TO_STARTING === "1"
220
+ }
221
+
222
+ function elapsedProfileMs(startedAt: number) {
223
+ return Number((performance.now() - startedAt).toFixed(1))
224
+ }
225
+
226
+ function logSendToStartingProfile(
227
+ profile: SendToStartingProfile | null | undefined,
228
+ stage: string,
229
+ details?: Record<string, unknown>
230
+ ) {
231
+ if (!profile || !isSendToStartingProfilingEnabled()) {
232
+ return
233
+ }
234
+
235
+ console.log("[kanna/send->starting][server]", JSON.stringify({
236
+ traceId: profile.traceId,
237
+ stage,
238
+ elapsedMs: elapsedProfileMs(profile.startedAt),
239
+ ...details,
240
+ }))
241
+ }
242
+
243
+ export function buildAttachmentHintText(attachments: ChatAttachment[]) {
244
+ if (attachments.length === 0) return ""
245
+
246
+ const lines = attachments.map((attachment) => (
247
+ `<attachment kind="${escapeXmlAttribute(attachment.kind)}" mime_type="${escapeXmlAttribute(attachment.mimeType)}" path="${escapeXmlAttribute(attachment.absolutePath)}" project_path="${escapeXmlAttribute(attachment.relativePath)}" size_bytes="${attachment.size}" display_name="${escapeXmlAttribute(attachment.displayName)}" />`
248
+ ))
249
+
250
+ return [
251
+ "<kanna-attachments>",
252
+ ...lines,
253
+ "</kanna-attachments>",
254
+ ].join("\n")
255
+ }
256
+
257
+ export function buildPromptText(content: string, attachments: ChatAttachment[]) {
258
+ const attachmentHint = buildAttachmentHintText(attachments)
259
+ if (!attachmentHint) {
260
+ return content.trim()
261
+ }
262
+
263
+ const trimmed = content.trim()
264
+ return [
265
+ trimmed || "Please inspect the attached files.",
266
+ attachmentHint,
267
+ ].join("\n\n").trim()
268
+ }
269
+
270
+ function discardedToolResult(
271
+ tool: NormalizedToolCall & { toolKind: "ask_user_question" | "exit_plan_mode" }
272
+ ) {
273
+ if (tool.toolKind === "ask_user_question") {
274
+ return {
275
+ discarded: true,
276
+ answers: {},
277
+ }
278
+ }
279
+
280
+ return {
281
+ discarded: true,
282
+ }
283
+ }
284
+
285
+ export function normalizeClaudeUsageSnapshot(
286
+ value: unknown,
287
+ maxTokens?: number,
288
+ ): ContextWindowUsageSnapshot | null {
289
+ const usage = asRecord(value)
290
+ if (!usage) return null
291
+
292
+ const directInputTokens = asNumber(usage.input_tokens) ?? asNumber(usage.inputTokens) ?? 0
293
+ const cacheCreationInputTokens =
294
+ asNumber(usage.cache_creation_input_tokens) ?? asNumber(usage.cacheCreationInputTokens) ?? 0
295
+ const cacheReadInputTokens =
296
+ asNumber(usage.cache_read_input_tokens) ?? asNumber(usage.cacheReadInputTokens) ?? 0
297
+ const outputTokens = asNumber(usage.output_tokens) ?? asNumber(usage.outputTokens) ?? 0
298
+ const reasoningOutputTokens =
299
+ asNumber(usage.reasoning_output_tokens) ?? asNumber(usage.reasoningOutputTokens)
300
+ const toolUses = asNumber(usage.tool_uses) ?? asNumber(usage.toolUses)
301
+ const durationMs = asNumber(usage.duration_ms) ?? asNumber(usage.durationMs)
302
+
303
+ const inputTokens = directInputTokens + cacheCreationInputTokens + cacheReadInputTokens
304
+ const usedTokens = inputTokens + outputTokens
305
+ if (usedTokens <= 0) {
306
+ return null
307
+ }
308
+
309
+ return {
310
+ usedTokens,
311
+ inputTokens,
312
+ ...(cacheReadInputTokens > 0 ? { cachedInputTokens: cacheReadInputTokens } : {}),
313
+ ...(outputTokens > 0 ? { outputTokens } : {}),
314
+ ...(reasoningOutputTokens !== undefined ? { reasoningOutputTokens } : {}),
315
+ lastUsedTokens: usedTokens,
316
+ lastInputTokens: inputTokens,
317
+ ...(cacheReadInputTokens > 0 ? { lastCachedInputTokens: cacheReadInputTokens } : {}),
318
+ ...(outputTokens > 0 ? { lastOutputTokens: outputTokens } : {}),
319
+ ...(reasoningOutputTokens !== undefined ? { lastReasoningOutputTokens: reasoningOutputTokens } : {}),
320
+ ...(toolUses !== undefined ? { toolUses } : {}),
321
+ ...(durationMs !== undefined ? { durationMs } : {}),
322
+ ...(typeof maxTokens === "number" && maxTokens > 0 ? { maxTokens } : {}),
323
+ compactsAutomatically: false,
324
+ }
325
+ }
326
+
327
+ export function maxClaudeContextWindowFromModelUsage(modelUsage: unknown): number | undefined {
328
+ const record = asRecord(modelUsage)
329
+ if (!record) return undefined
330
+
331
+ let maxContextWindow: number | undefined
332
+ for (const value of Object.values(record)) {
333
+ const usage = asRecord(value)
334
+ const contextWindow = asNumber(usage?.contextWindow) ?? asNumber(usage?.context_window)
335
+ if (contextWindow === undefined) continue
336
+ maxContextWindow = Math.max(maxContextWindow ?? 0, contextWindow)
337
+ }
338
+ return maxContextWindow
339
+ }
340
+
341
+ function getClaudeAssistantMessageUsageId(message: any): string | null {
342
+ if (typeof message?.message?.id === "string" && message.message.id) {
343
+ return message.message.id
344
+ }
345
+ if (typeof message?.uuid === "string" && message.uuid) {
346
+ return message.uuid
347
+ }
348
+ return null
349
+ }
350
+
351
+ export function normalizeClaudeStreamMessage(message: any): TranscriptEntry[] {
352
+ const debugRaw = JSON.stringify(message)
353
+ const messageId = typeof message.uuid === "string" ? message.uuid : undefined
354
+
355
+ if (message.type === "system" && message.subtype === "init") {
356
+ return [
357
+ timestamped({
358
+ kind: "system_init",
359
+ messageId,
360
+ provider: "claude",
361
+ model: typeof message.model === "string" ? message.model : "unknown",
362
+ tools: Array.isArray(message.tools) ? message.tools : [],
363
+ agents: Array.isArray(message.agents) ? message.agents : [],
364
+ slashCommands: Array.isArray(message.slash_commands)
365
+ ? message.slash_commands.filter((entry: string) => !entry.startsWith("._"))
366
+ : [],
367
+ mcpServers: Array.isArray(message.mcp_servers) ? message.mcp_servers : [],
368
+ debugRaw,
369
+ }),
370
+ ]
371
+ }
372
+
373
+ if (message.type === "assistant" && Array.isArray(message.message?.content)) {
374
+ const entries: TranscriptEntry[] = []
375
+ for (const content of message.message.content) {
376
+ if (content.type === "text" && typeof content.text === "string") {
377
+ entries.push(timestamped({
378
+ kind: "assistant_text",
379
+ messageId,
380
+ text: content.text,
381
+ debugRaw,
382
+ }))
383
+ }
384
+ if (content.type === "tool_use" && typeof content.name === "string" && typeof content.id === "string") {
385
+ entries.push(timestamped({
386
+ kind: "tool_call",
387
+ messageId,
388
+ tool: normalizeToolCall({
389
+ toolName: content.name,
390
+ toolId: content.id,
391
+ input: (content.input ?? {}) as Record<string, unknown>,
392
+ }),
393
+ debugRaw,
394
+ }))
395
+ }
396
+ }
397
+ return entries
398
+ }
399
+
400
+ if (message.type === "user" && Array.isArray(message.message?.content)) {
401
+ const entries: TranscriptEntry[] = []
402
+ for (const content of message.message.content) {
403
+ if (content.type === "tool_result" && typeof content.tool_use_id === "string") {
404
+ entries.push(timestamped({
405
+ kind: "tool_result",
406
+ messageId,
407
+ toolId: content.tool_use_id,
408
+ content: content.content,
409
+ isError: Boolean(content.is_error),
410
+ debugRaw,
411
+ }))
412
+ }
413
+ if (message.message.role === "user" && typeof message.message.content === "string") {
414
+ entries.push(timestamped({
415
+ kind: "compact_summary",
416
+ messageId,
417
+ summary: message.message.content,
418
+ debugRaw,
419
+ }))
420
+ }
421
+ }
422
+ return entries
423
+ }
424
+
425
+ if (message.type === "result") {
426
+ if (message.subtype === "cancelled") {
427
+ return [timestamped({ kind: "interrupted", messageId, debugRaw })]
428
+ }
429
+ return [
430
+ timestamped({
431
+ kind: "result",
432
+ messageId,
433
+ subtype: message.is_error ? "error" : "success",
434
+ isError: Boolean(message.is_error),
435
+ durationMs: typeof message.duration_ms === "number" ? message.duration_ms : 0,
436
+ result: typeof message.result === "string" ? message.result : stringFromUnknown(message.result),
437
+ costUsd: typeof message.total_cost_usd === "number" ? message.total_cost_usd : undefined,
438
+ debugRaw,
439
+ }),
440
+ ]
441
+ }
442
+
443
+ if (message.type === "system" && message.subtype === "status" && typeof message.status === "string") {
444
+ return [timestamped({ kind: "status", messageId, status: message.status, debugRaw })]
445
+ }
446
+
447
+ if (message.type === "system" && message.subtype === "compact_boundary") {
448
+ return [timestamped({ kind: "compact_boundary", messageId, debugRaw })]
449
+ }
450
+
451
+ if (message.type === "system" && message.subtype === "context_cleared") {
452
+ return [timestamped({ kind: "context_cleared", messageId, debugRaw })]
453
+ }
454
+
455
+ if (
456
+ message.type === "user" &&
457
+ message.message?.role === "user" &&
458
+ typeof message.message.content === "string" &&
459
+ message.message.content.startsWith("This session is being continued")
460
+ ) {
461
+ return [timestamped({ kind: "compact_summary", messageId, summary: message.message.content, debugRaw })]
462
+ }
463
+
464
+ return []
465
+ }
466
+
467
+ async function* createClaudeHarnessStream(q: Query): AsyncGenerator<HarnessEvent> {
468
+ let seenAssistantUsageIds = new Set<string>()
469
+ let latestUsageSnapshot: ContextWindowUsageSnapshot | null = null
470
+ let lastKnownContextWindow: number | undefined
471
+
472
+ for await (const sdkMessage of q as AsyncIterable<any>) {
473
+ const sessionToken = typeof sdkMessage.session_id === "string" ? sdkMessage.session_id : null
474
+ if (sessionToken) {
475
+ yield { type: "session_token", sessionToken }
476
+ }
477
+
478
+ if (sdkMessage?.type === "assistant") {
479
+ const usageId = getClaudeAssistantMessageUsageId(sdkMessage)
480
+ const usageSnapshot = normalizeClaudeUsageSnapshot(sdkMessage.usage, lastKnownContextWindow)
481
+ if (usageId && usageSnapshot && !seenAssistantUsageIds.has(usageId)) {
482
+ seenAssistantUsageIds.add(usageId)
483
+ latestUsageSnapshot = usageSnapshot
484
+ yield {
485
+ type: "transcript",
486
+ entry: timestamped({
487
+ kind: "context_window_updated",
488
+ usage: usageSnapshot,
489
+ }),
490
+ }
491
+ }
492
+ }
493
+
494
+ if (sdkMessage?.type === "result") {
495
+ const resultContextWindow = maxClaudeContextWindowFromModelUsage(sdkMessage.modelUsage)
496
+ if (resultContextWindow !== undefined) {
497
+ lastKnownContextWindow = resultContextWindow
498
+ }
499
+
500
+ const accumulatedUsage = normalizeClaudeUsageSnapshot(
501
+ sdkMessage.usage,
502
+ resultContextWindow ?? lastKnownContextWindow,
503
+ )
504
+ const finalUsage = latestUsageSnapshot
505
+ ? {
506
+ ...latestUsageSnapshot,
507
+ ...(typeof (resultContextWindow ?? lastKnownContextWindow) === "number"
508
+ ? { maxTokens: resultContextWindow ?? lastKnownContextWindow }
509
+ : {}),
510
+ ...(accumulatedUsage && accumulatedUsage.usedTokens > latestUsageSnapshot.usedTokens
511
+ ? { totalProcessedTokens: accumulatedUsage.usedTokens }
512
+ : {}),
513
+ }
514
+ : accumulatedUsage
515
+
516
+ if (finalUsage) {
517
+ yield {
518
+ type: "transcript",
519
+ entry: timestamped({
520
+ kind: "context_window_updated",
521
+ usage: finalUsage,
522
+ }),
523
+ }
524
+ }
525
+
526
+ seenAssistantUsageIds = new Set<string>()
527
+ latestUsageSnapshot = null
528
+ }
529
+
530
+ for (const entry of normalizeClaudeStreamMessage(sdkMessage)) {
531
+ yield { type: "transcript", entry }
532
+ }
533
+ }
534
+ }
535
+
536
+ class AsyncMessageQueue<T> implements AsyncIterable<T> {
537
+ private readonly values: T[] = []
538
+ private readonly waiters: Array<(result: IteratorResult<T>) => void> = []
539
+ private closed = false
540
+
541
+ push(value: T) {
542
+ if (this.closed) {
543
+ throw new Error("Cannot push to a closed queue")
544
+ }
545
+
546
+ const waiter = this.waiters.shift()
547
+ if (waiter) {
548
+ waiter({ done: false, value })
549
+ return
550
+ }
551
+
552
+ this.values.push(value)
553
+ }
554
+
555
+ close() {
556
+ if (this.closed) return
557
+ this.closed = true
558
+ while (this.waiters.length > 0) {
559
+ const waiter = this.waiters.shift()
560
+ waiter?.({ done: true, value: undefined as never })
561
+ }
562
+ }
563
+
564
+ [Symbol.asyncIterator](): AsyncIterator<T> {
565
+ return {
566
+ next: async () => {
567
+ if (this.values.length > 0) {
568
+ return { done: false, value: this.values.shift() as T }
569
+ }
570
+
571
+ if (this.closed) {
572
+ return { done: true, value: undefined as never }
573
+ }
574
+
575
+ return await new Promise<IteratorResult<T>>((resolve) => {
576
+ this.waiters.push(resolve)
577
+ })
578
+ },
579
+ }
580
+ }
581
+ }
582
+
583
+ async function startClaudeSession(args: {
584
+ localPath: string
585
+ model: string
586
+ effort?: string
587
+ planMode: boolean
588
+ sessionToken: string | null
589
+ forkSession: boolean
590
+ onToolRequest: (request: HarnessToolRequest) => Promise<unknown>
591
+ }): Promise<ClaudeSessionHandle> {
592
+ const canUseTool: CanUseTool = async (toolName, input, options) => {
593
+ if (toolName !== "AskUserQuestion" && toolName !== "ExitPlanMode") {
594
+ return {
595
+ behavior: "allow",
596
+ updatedInput: input,
597
+ }
598
+ }
599
+
600
+ const tool = normalizeToolCall({
601
+ toolName,
602
+ toolId: options.toolUseID,
603
+ input: (input ?? {}) as Record<string, unknown>,
604
+ })
605
+
606
+ if (tool.toolKind !== "ask_user_question" && tool.toolKind !== "exit_plan_mode") {
607
+ return {
608
+ behavior: "deny",
609
+ message: "Unsupported tool request",
610
+ }
611
+ }
612
+
613
+ const result = await args.onToolRequest({ tool })
614
+
615
+ if (tool.toolKind === "ask_user_question") {
616
+ const record = result && typeof result === "object" ? result as Record<string, unknown> : {}
617
+ return {
618
+ behavior: "allow",
619
+ updatedInput: {
620
+ ...(tool.rawInput ?? {}),
621
+ questions: record.questions ?? tool.input.questions,
622
+ answers: record.answers ?? result,
623
+ },
624
+ } satisfies PermissionResult
625
+ }
626
+
627
+ const record = result && typeof result === "object" ? result as Record<string, unknown> : {}
628
+ const confirmed = Boolean(record.confirmed)
629
+ if (confirmed) {
630
+ return {
631
+ behavior: "allow",
632
+ updatedInput: {
633
+ ...(tool.rawInput ?? {}),
634
+ ...record,
635
+ },
636
+ } satisfies PermissionResult
637
+ }
638
+
639
+ return {
640
+ behavior: "deny",
641
+ message: typeof record.message === "string"
642
+ ? `User wants to suggest edits to the plan: ${record.message}`
643
+ : "User wants to suggest edits to the plan before approving.",
644
+ } satisfies PermissionResult
645
+ }
646
+
647
+ const promptQueue = new AsyncMessageQueue<SDKUserMessage>()
648
+
649
+ const q = query({
650
+ prompt: promptQueue,
651
+ options: {
652
+ cwd: args.localPath,
653
+ model: args.model,
654
+ effort: args.effort as "low" | "medium" | "high" | "max" | undefined,
655
+ resume: args.sessionToken ?? undefined,
656
+ forkSession: args.forkSession,
657
+ permissionMode: args.planMode ? "plan" : "acceptEdits",
658
+ canUseTool,
659
+ tools: [...CLAUDE_TOOLSET],
660
+ settingSources: ["user", "project", "local"],
661
+ env: (() => { const { CLAUDECODE: _, ...env } = process.env; return env })(),
662
+ },
663
+ })
664
+
665
+ return {
666
+ provider: "claude",
667
+ stream: createClaudeHarnessStream(q),
668
+ getAccountInfo: async () => {
669
+ try {
670
+ return await q.accountInfo()
671
+ } catch {
672
+ return null
673
+ }
674
+ },
675
+ interrupt: async () => {
676
+ await q.interrupt()
677
+ },
678
+ sendPrompt: async (content: string) => {
679
+ promptQueue.push({
680
+ type: "user",
681
+ message: {
682
+ role: "user",
683
+ content,
684
+ },
685
+ parent_tool_use_id: null,
686
+ session_id: args.sessionToken ?? "",
687
+ })
688
+ },
689
+ setModel: async (model: string) => {
690
+ await q.setModel(model)
691
+ },
692
+ setPermissionMode: async (planMode: boolean) => {
693
+ await q.setPermissionMode(planMode ? "plan" : "acceptEdits")
694
+ },
695
+ getSupportedCommands: async () => {
696
+ try {
697
+ return await q.supportedCommands()
698
+ } catch (error) {
699
+ console.warn("[kanna/claude] supportedCommands failed", error)
700
+ return []
701
+ }
702
+ },
703
+ close: () => {
704
+ promptQueue.close()
705
+ q.close()
706
+ },
707
+ }
708
+ }
709
+
710
+ export class AgentCoordinator {
711
+ private readonly store: EventStore
712
+ private readonly onStateChange: (chatId?: string, options?: { immediate?: boolean }) => void
713
+ private readonly analytics: AnalyticsReporter
714
+ private readonly codexManager: CodexAppServerManager
715
+ private readonly generateTitle: (messageContent: string, cwd: string) => Promise<GenerateChatTitleResult>
716
+ private readonly startClaudeSessionFn: NonNullable<AgentCoordinatorArgs["startClaudeSession"]>
717
+ private reportBackgroundError: ((message: string) => void) | null = null
718
+ readonly activeTurns = new Map<string, ActiveTurn>()
719
+ readonly drainingStreams = new Map<string, { turn: HarnessTurn }>()
720
+ readonly claudeSessions = new Map<string, ClaudeSessionState>()
721
+ private readonly slashCommandsInFlight = new Set<string>()
722
+ private readonly claudeLimitDetector: LimitDetector
723
+ private readonly codexLimitDetector: LimitDetector
724
+ private readonly scheduleManager: ScheduleManager | null
725
+ private readonly getAutoResumePreference: () => boolean
726
+ private readonly throwOnClaudeSessionStart: boolean
727
+ private readonly autoResumeByChat = new Map<string, boolean>()
728
+ private readonly tunnelGateway: TunnelGateway | null
729
+ private readonly pendingBashCalls = new Map<string, { command: string; chatId: string }>()
730
+
731
+ constructor(args: AgentCoordinatorArgs) {
732
+ this.store = args.store
733
+ this.onStateChange = args.onStateChange
734
+ this.analytics = args.analytics ?? NoopAnalyticsReporter
735
+ this.codexManager = args.codexManager ?? new CodexAppServerManager()
736
+ this.generateTitle = args.generateTitle ?? generateTitleForChatDetailed
737
+ this.startClaudeSessionFn = args.startClaudeSession ?? startClaudeSession
738
+ this.claudeLimitDetector = args.claudeLimitDetector ?? new ClaudeLimitDetector()
739
+ this.codexLimitDetector = args.codexLimitDetector ?? new CodexLimitDetector()
740
+ this.scheduleManager = args.scheduleManager ?? null
741
+ this.getAutoResumePreference = args.getAutoResumePreference ?? (() => false)
742
+ this.throwOnClaudeSessionStart = args.throwOnClaudeSessionStart ?? false
743
+ this.tunnelGateway = args.tunnelGateway ?? null
744
+ }
745
+
746
+ setBackgroundErrorReporter(report: ((message: string) => void) | null) {
747
+ this.reportBackgroundError = report
748
+ }
749
+
750
+ getActiveStatuses() {
751
+ const statuses = new Map<string, KannaStatus>()
752
+ for (const [chatId, turn] of this.activeTurns.entries()) {
753
+ statuses.set(chatId, turn.status)
754
+ }
755
+ return statuses
756
+ }
757
+
758
+ getPendingTool(chatId: string): PendingToolSnapshot | null {
759
+ const pending = this.activeTurns.get(chatId)?.pendingTool
760
+ if (!pending) return null
761
+ return { toolUseId: pending.toolUseId, toolKind: pending.tool.toolKind }
762
+ }
763
+
764
+ getDrainingChatIds(): Set<string> {
765
+ return new Set(this.drainingStreams.keys())
766
+ }
767
+
768
+ getSlashCommandsLoadingChatIds(): Set<string> {
769
+ return new Set(this.slashCommandsInFlight)
770
+ }
771
+
772
+ private emitStateChange(chatId?: string, options?: { immediate?: boolean }) {
773
+ this.onStateChange(chatId, options)
774
+ }
775
+
776
+ private trackBashToolEntry(chatId: string, entry: TranscriptEntry): void {
777
+ if (!this.tunnelGateway) return
778
+
779
+ if (entry.kind === "tool_call" && entry.tool.toolKind === "bash") {
780
+ const command = entry.tool.input.command ?? ""
781
+ this.pendingBashCalls.set(entry.tool.toolId, { command, chatId })
782
+ return
783
+ }
784
+
785
+ if (entry.kind === "tool_result") {
786
+ const pending = this.pendingBashCalls.get(entry.toolId)
787
+ if (!pending) return
788
+ this.pendingBashCalls.delete(entry.toolId)
789
+ const stdout = stringifyToolResultContent(entry.content)
790
+ void this.tunnelGateway.handleBashResult({
791
+ command: pending.command,
792
+ stdout,
793
+ chatId: pending.chatId,
794
+ sourcePid: null,
795
+ })
796
+ }
797
+ }
798
+
799
+ getActiveTurnProfile(chatId: string): SendToStartingProfile | null {
800
+ const active = this.activeTurns.get(chatId)
801
+ if (!active?.clientTraceId || active.profilingStartedAt === undefined) {
802
+ return null
803
+ }
804
+
805
+ return {
806
+ traceId: active.clientTraceId,
807
+ startedAt: active.profilingStartedAt,
808
+ }
809
+ }
810
+
811
+ async stopDraining(chatId: string) {
812
+ const draining = this.drainingStreams.get(chatId)
813
+ if (!draining) return
814
+ draining.turn.close()
815
+ this.drainingStreams.delete(chatId)
816
+ this.emitStateChange(chatId)
817
+ }
818
+
819
+ async ensureSlashCommandsLoaded(chatId: string): Promise<void> {
820
+ const chat = this.store.getChat(chatId)
821
+ if (!chat) return
822
+ if (chat.provider === "codex") return
823
+ if (chat.slashCommands && chat.slashCommands.length > 0) return
824
+ if (this.slashCommandsInFlight.has(chatId)) return
825
+
826
+ const project = this.store.getProject(chat.projectId)
827
+ if (!project) return
828
+
829
+ this.slashCommandsInFlight.add(chatId)
830
+ this.emitStateChange(chatId)
831
+ try {
832
+ let commands: SlashCommand[]
833
+ const existing = this.claudeSessions.get(chatId)
834
+ if (existing) {
835
+ commands = await existing.session.getSupportedCommands()
836
+ } else {
837
+ const defaultModel = normalizeServerModel("claude")
838
+ const defaultOptions = normalizeClaudeModelOptions(defaultModel)
839
+ const ephemeral = await this.startClaudeSessionFn({
840
+ localPath: project.localPath,
841
+ model: resolveClaudeApiModelId(defaultModel, defaultOptions.contextWindow),
842
+ effort: defaultOptions.reasoningEffort,
843
+ planMode: chat.planMode ?? false,
844
+ sessionToken: chat.sessionToken ?? null,
845
+ forkSession: false,
846
+ onToolRequest: async () => null,
847
+ })
848
+ try {
849
+ commands = await ephemeral.getSupportedCommands()
850
+ } finally {
851
+ ephemeral.close()
852
+ }
853
+ }
854
+ await this.store.recordSessionCommandsLoaded(chatId, commands)
855
+ this.emitStateChange(chatId)
856
+ } catch (error) {
857
+ console.warn("[kanna/agent] ensureSlashCommandsLoaded failed", error)
858
+ } finally {
859
+ this.slashCommandsInFlight.delete(chatId)
860
+ this.emitStateChange(chatId)
861
+ }
862
+ }
863
+
864
+ async closeChat(chatId: string) {
865
+ await this.stopDraining(chatId)
866
+ const claudeSession = this.claudeSessions.get(chatId)
867
+ if (claudeSession) {
868
+ claudeSession.session.close()
869
+ this.claudeSessions.delete(chatId)
870
+ }
871
+ this.autoResumeByChat.delete(chatId)
872
+ this.emitStateChange(chatId)
873
+ }
874
+
875
+ private resolveProvider(options: SendMessageOptions, currentProvider: AgentProvider | null) {
876
+ if (currentProvider) return currentProvider
877
+ return options.provider ?? "claude"
878
+ }
879
+
880
+ private getProviderSettings(provider: AgentProvider, options: SendMessageOptions) {
881
+ const catalog = getServerProviderCatalog(provider)
882
+ if (provider === "claude") {
883
+ const model = normalizeServerModel(provider, options.model)
884
+ const modelOptions = normalizeClaudeModelOptions(model, options.modelOptions, options.effort)
885
+ return {
886
+ model: resolveClaudeApiModelId(model, modelOptions.contextWindow),
887
+ effort: modelOptions.reasoningEffort,
888
+ serviceTier: undefined,
889
+ planMode: catalog.supportsPlanMode ? Boolean(options.planMode) : false,
890
+ }
891
+ }
892
+
893
+ const modelOptions = normalizeCodexModelOptions(options.modelOptions, options.effort)
894
+ return {
895
+ model: normalizeServerModel(provider, options.model),
896
+ effort: modelOptions.reasoningEffort,
897
+ serviceTier: codexServiceTierFromModelOptions(modelOptions),
898
+ planMode: catalog.supportsPlanMode ? Boolean(options.planMode) : false,
899
+ }
900
+ }
901
+
902
+ private async enqueueMessage(chatId: string, content: string, attachments: ChatAttachment[], options?: SendMessageOptions) {
903
+ const queued = await this.store.enqueueMessage(chatId, {
904
+ content,
905
+ attachments,
906
+ provider: options?.provider,
907
+ model: options?.model,
908
+ modelOptions: options?.modelOptions,
909
+ planMode: options?.planMode,
910
+ autoContinue: options?.autoContinue,
911
+ })
912
+ this.emitStateChange(chatId)
913
+ return queued
914
+ }
915
+
916
+ private async dequeueAndStartQueuedMessage(chatId: string, queuedMessage: QueuedChatMessage, options?: { steered?: boolean }) {
917
+ await this.store.removeQueuedMessage(chatId, queuedMessage.id)
918
+ const chat = this.store.requireChat(chatId)
919
+ const provider = this.resolveProvider(queuedMessage, chat.provider)
920
+ const settings = this.getProviderSettings(provider, queuedMessage)
921
+ await this.startTurnForChat({
922
+ chatId,
923
+ provider,
924
+ content: options?.steered ? buildSteeredMessageContent(queuedMessage.content) : queuedMessage.content,
925
+ attachments: queuedMessage.attachments,
926
+ model: settings.model,
927
+ effort: settings.effort,
928
+ serviceTier: settings.serviceTier,
929
+ planMode: settings.planMode,
930
+ appendUserPrompt: true,
931
+ steered: options?.steered,
932
+ autoContinue: queuedMessage.autoContinue,
933
+ })
934
+ }
935
+
936
+ private async maybeStartNextQueuedMessage(chatId: string) {
937
+ if (this.activeTurns.has(chatId)) return false
938
+ const nextQueuedMessage = typeof this.store.getQueuedMessages === "function"
939
+ ? this.store.getQueuedMessages(chatId)[0]
940
+ : undefined
941
+ if (!nextQueuedMessage) return false
942
+ await this.dequeueAndStartQueuedMessage(chatId, nextQueuedMessage)
943
+ return true
944
+ }
945
+
946
+ private async startTurnForChat(args: {
947
+ chatId: string
948
+ provider: AgentProvider
949
+ content: string
950
+ attachments: ChatAttachment[]
951
+ model: string
952
+ effort?: string
953
+ serviceTier?: "fast"
954
+ planMode: boolean
955
+ appendUserPrompt: boolean
956
+ steered?: boolean
957
+ autoContinue?: { scheduleId: string }
958
+ profile?: SendToStartingProfile | null
959
+ }) {
960
+ logSendToStartingProfile(args.profile, "start_turn.begin", {
961
+ chatId: args.chatId,
962
+ provider: args.provider,
963
+ appendUserPrompt: args.appendUserPrompt,
964
+ planMode: args.planMode,
965
+ })
966
+
967
+ // Close any lingering draining stream before starting a new turn.
968
+ const draining = this.drainingStreams.get(args.chatId)
969
+ if (draining) {
970
+ draining.turn.close()
971
+ this.drainingStreams.delete(args.chatId)
972
+ }
973
+
974
+ const chat = this.store.requireChat(args.chatId)
975
+ if (this.activeTurns.has(args.chatId)) {
976
+ throw new Error("Chat is already running")
977
+ }
978
+
979
+ if (!chat.provider) {
980
+ await this.store.setChatProvider(args.chatId, args.provider)
981
+ logSendToStartingProfile(args.profile, "start_turn.provider_set", {
982
+ chatId: args.chatId,
983
+ provider: args.provider,
984
+ })
985
+ }
986
+ await this.store.setPlanMode(args.chatId, args.planMode)
987
+ logSendToStartingProfile(args.profile, "start_turn.plan_mode_set", {
988
+ chatId: args.chatId,
989
+ planMode: args.planMode,
990
+ })
991
+
992
+ const existingMessages = this.store.getMessages(args.chatId)
993
+ const shouldGenerateTitle = args.appendUserPrompt && chat.title === "New Chat" && existingMessages.length === 0
994
+ const optimisticTitle = shouldGenerateTitle ? fallbackTitleFromMessage(args.content) : null
995
+
996
+ if (optimisticTitle) {
997
+ await this.store.renameChat(args.chatId, optimisticTitle)
998
+ logSendToStartingProfile(args.profile, "start_turn.optimistic_title_set", {
999
+ chatId: args.chatId,
1000
+ title: optimisticTitle,
1001
+ })
1002
+ }
1003
+
1004
+ const project = this.store.getProject(chat.projectId)
1005
+ if (!project) {
1006
+ throw new Error("Project not found")
1007
+ }
1008
+
1009
+ if (args.appendUserPrompt) {
1010
+ const userPromptEntry = timestamped(
1011
+ { kind: "user_prompt", content: args.content, attachments: args.attachments, steered: args.steered, autoContinue: args.autoContinue },
1012
+ Date.now()
1013
+ )
1014
+ await this.store.appendMessage(args.chatId, userPromptEntry)
1015
+ logSendToStartingProfile(args.profile, "start_turn.user_prompt_appended", {
1016
+ chatId: args.chatId,
1017
+ entryId: userPromptEntry._id,
1018
+ })
1019
+ }
1020
+ await this.store.recordTurnStarted(args.chatId)
1021
+ logSendToStartingProfile(args.profile, "start_turn.turn_started_recorded", {
1022
+ chatId: args.chatId,
1023
+ })
1024
+
1025
+ if (shouldGenerateTitle) {
1026
+ void this.generateTitleInBackground(args.chatId, args.content, project.localPath, optimisticTitle ?? "New Chat")
1027
+ }
1028
+
1029
+ const onToolRequest = async (request: HarnessToolRequest): Promise<unknown> => {
1030
+ const active = this.activeTurns.get(args.chatId)
1031
+ if (!active) {
1032
+ throw new Error("Chat turn ended unexpectedly")
1033
+ }
1034
+
1035
+ active.status = "waiting_for_user"
1036
+ this.emitStateChange(args.chatId)
1037
+
1038
+ return await new Promise<unknown>((resolve) => {
1039
+ active.pendingTool = {
1040
+ toolUseId: request.tool.toolId,
1041
+ tool: request.tool,
1042
+ resolve,
1043
+ }
1044
+ })
1045
+ }
1046
+
1047
+ let turn: HarnessTurn
1048
+ if (args.provider === "claude") {
1049
+ logSendToStartingProfile(args.profile, "start_turn.provider_boot.begin", {
1050
+ chatId: args.chatId,
1051
+ provider: args.provider,
1052
+ model: args.model,
1053
+ })
1054
+ turn = await this.startClaudeTurn({
1055
+ chatId: args.chatId,
1056
+ localPath: project.localPath,
1057
+ model: args.model,
1058
+ effort: args.effort,
1059
+ planMode: args.planMode,
1060
+ sessionToken: chat.pendingForkSessionToken ?? chat.sessionToken,
1061
+ forkSession: Boolean(chat.pendingForkSessionToken),
1062
+ onToolRequest,
1063
+ })
1064
+ logSendToStartingProfile(args.profile, "start_turn.provider_boot.ready", {
1065
+ chatId: args.chatId,
1066
+ provider: args.provider,
1067
+ model: args.model,
1068
+ })
1069
+ } else {
1070
+ logSendToStartingProfile(args.profile, "start_turn.provider_boot.begin", {
1071
+ chatId: args.chatId,
1072
+ provider: args.provider,
1073
+ model: args.model,
1074
+ })
1075
+ const sessionToken = await this.codexManager.startSession({
1076
+ chatId: args.chatId,
1077
+ cwd: project.localPath,
1078
+ model: args.model,
1079
+ serviceTier: args.serviceTier,
1080
+ sessionToken: chat.sessionToken,
1081
+ pendingForkSessionToken: chat.pendingForkSessionToken,
1082
+ })
1083
+ if (chat.pendingForkSessionToken && sessionToken) {
1084
+ await this.store.setPendingForkSessionToken(args.chatId, null)
1085
+ }
1086
+ logSendToStartingProfile(args.profile, "start_turn.session_ready", {
1087
+ chatId: args.chatId,
1088
+ provider: args.provider,
1089
+ model: args.model,
1090
+ })
1091
+ turn = await this.codexManager.startTurn({
1092
+ chatId: args.chatId,
1093
+ content: buildPromptText(args.content, args.attachments),
1094
+ model: args.model,
1095
+ effort: args.effort as any,
1096
+ serviceTier: args.serviceTier,
1097
+ planMode: args.planMode,
1098
+ onToolRequest,
1099
+ })
1100
+ logSendToStartingProfile(args.profile, "start_turn.provider_boot.ready", {
1101
+ chatId: args.chatId,
1102
+ provider: args.provider,
1103
+ model: args.model,
1104
+ })
1105
+ }
1106
+
1107
+ const active: ActiveTurn = {
1108
+ chatId: args.chatId,
1109
+ provider: args.provider,
1110
+ turn,
1111
+ model: args.model,
1112
+ effort: args.effort,
1113
+ serviceTier: args.serviceTier,
1114
+ planMode: args.planMode,
1115
+ status: args.provider === "claude" ? "running" : "starting",
1116
+ pendingTool: null,
1117
+ postToolFollowUp: null,
1118
+ hasFinalResult: false,
1119
+ cancelRequested: false,
1120
+ cancelRecorded: false,
1121
+ clientTraceId: args.profile?.traceId,
1122
+ profilingStartedAt: args.profile?.startedAt,
1123
+ }
1124
+ this.activeTurns.set(args.chatId, active)
1125
+ logSendToStartingProfile(args.profile, "start_turn.active_turn_registered", {
1126
+ chatId: args.chatId,
1127
+ status: active.status,
1128
+ })
1129
+ this.emitStateChange(args.chatId, { immediate: active.status === "starting" })
1130
+ logSendToStartingProfile(args.profile, "start_turn.state_change_emitted", {
1131
+ chatId: args.chatId,
1132
+ status: active.status,
1133
+ })
1134
+
1135
+ if (turn.getAccountInfo) {
1136
+ void turn.getAccountInfo()
1137
+ .then(async (accountInfo) => {
1138
+ if (!accountInfo) return
1139
+ if (args.provider === "claude") {
1140
+ const session = this.claudeSessions.get(args.chatId)
1141
+ if (session) {
1142
+ if (session.accountInfoLoaded) return
1143
+ session.accountInfoLoaded = true
1144
+ } else {
1145
+ return
1146
+ }
1147
+ }
1148
+ await this.store.appendMessage(args.chatId, timestamped({ kind: "account_info", accountInfo }))
1149
+ this.emitStateChange(args.chatId)
1150
+ })
1151
+ .catch(() => undefined)
1152
+ }
1153
+
1154
+ if (args.provider === "claude") {
1155
+ const session = this.claudeSessions.get(args.chatId)
1156
+ if (!session) {
1157
+ throw new Error("Claude session was not initialized")
1158
+ }
1159
+ const promptSeq = session.nextPromptSeq + 1
1160
+ session.nextPromptSeq = promptSeq
1161
+ session.pendingPromptSeqs.push(promptSeq)
1162
+ active.claudePromptSeq = promptSeq
1163
+ logClaudeSteer("claude_prompt_sent", {
1164
+ chatId: args.chatId,
1165
+ sessionId: session.id,
1166
+ promptSeq,
1167
+ activeStatus: active.status,
1168
+ contentPreview: args.content.slice(0, 160),
1169
+ pendingPromptSeqs: [...session.pendingPromptSeqs],
1170
+ })
1171
+ await session.session.sendPrompt(buildPromptText(args.content, args.attachments))
1172
+ logSendToStartingProfile(args.profile, "start_turn.claude_prompt_sent", {
1173
+ chatId: args.chatId,
1174
+ })
1175
+ return
1176
+ }
1177
+
1178
+ void this.runTurn(active)
1179
+ }
1180
+
1181
+ private async startClaudeTurn(args: {
1182
+ chatId: string
1183
+ localPath: string
1184
+ model: string
1185
+ effort?: string
1186
+ planMode: boolean
1187
+ sessionToken: string | null
1188
+ forkSession: boolean
1189
+ onToolRequest: (request: HarnessToolRequest) => Promise<unknown>
1190
+ }): Promise<HarnessTurn> {
1191
+ let session = this.claudeSessions.get(args.chatId)
1192
+
1193
+ if (!session || session.localPath !== args.localPath || session.effort !== args.effort || args.forkSession) {
1194
+ if (session) {
1195
+ session.session.close()
1196
+ this.claudeSessions.delete(args.chatId)
1197
+ }
1198
+
1199
+ const started = await this.startClaudeSessionFn({
1200
+ localPath: args.localPath,
1201
+ model: args.model,
1202
+ effort: args.effort,
1203
+ planMode: args.planMode,
1204
+ sessionToken: args.sessionToken,
1205
+ forkSession: args.forkSession,
1206
+ onToolRequest: args.onToolRequest,
1207
+ })
1208
+
1209
+ session = {
1210
+ id: crypto.randomUUID(),
1211
+ chatId: args.chatId,
1212
+ session: started,
1213
+ localPath: args.localPath,
1214
+ model: args.model,
1215
+ effort: args.effort,
1216
+ planMode: args.planMode,
1217
+ sessionToken: args.sessionToken,
1218
+ accountInfoLoaded: false,
1219
+ nextPromptSeq: 0,
1220
+ pendingPromptSeqs: [],
1221
+ }
1222
+ this.claudeSessions.set(args.chatId, session)
1223
+ void this.runClaudeSession(session)
1224
+ void (async () => {
1225
+ try {
1226
+ const commands = await started.getSupportedCommands()
1227
+ await this.store.recordSessionCommandsLoaded(args.chatId, commands)
1228
+ this.emitStateChange(args.chatId)
1229
+ } catch (error) {
1230
+ console.warn("[kanna/agent] failed to load slash commands", error)
1231
+ }
1232
+ })()
1233
+ } else {
1234
+ if (session.model !== args.model) {
1235
+ await session.session.setModel(args.model)
1236
+ session.model = args.model
1237
+ }
1238
+ if (session.planMode !== args.planMode) {
1239
+ await session.session.setPermissionMode(args.planMode)
1240
+ session.planMode = args.planMode
1241
+ }
1242
+ }
1243
+
1244
+ return {
1245
+ provider: "claude",
1246
+ stream: {
1247
+ async *[Symbol.asyncIterator]() {},
1248
+ },
1249
+ getAccountInfo: session.session.getAccountInfo,
1250
+ interrupt: session.session.interrupt,
1251
+ close: () => {},
1252
+ }
1253
+ }
1254
+
1255
+ async send(command: Extract<ClientCommand, { type: "chat.send" }>) {
1256
+ const profile = command.clientTraceId
1257
+ ? { traceId: command.clientTraceId, startedAt: performance.now() }
1258
+ : null
1259
+ let chatId = command.chatId
1260
+
1261
+ logSendToStartingProfile(profile, "chat_send.received", {
1262
+ existingChatId: command.chatId ?? null,
1263
+ projectId: command.projectId ?? null,
1264
+ })
1265
+
1266
+ if (!chatId) {
1267
+ if (!command.projectId) {
1268
+ throw new Error("Missing projectId for new chat")
1269
+ }
1270
+ const created = await this.store.createChat(command.projectId)
1271
+ chatId = created.id
1272
+ this.analytics.track("chat_created")
1273
+ logSendToStartingProfile(profile, "chat_send.chat_created", {
1274
+ chatId,
1275
+ projectId: command.projectId,
1276
+ })
1277
+ }
1278
+
1279
+ if (typeof command.autoResumeOnRateLimit === "boolean" && chatId) {
1280
+ this.autoResumeByChat.set(chatId, command.autoResumeOnRateLimit)
1281
+ }
1282
+
1283
+ const chat = this.store.requireChat(chatId)
1284
+ if (this.activeTurns.has(chatId)) {
1285
+ this.analytics.track("message_sent")
1286
+ const queuedMessage = await this.enqueueMessage(chatId, command.content, command.attachments ?? [], {
1287
+ provider: command.provider,
1288
+ model: command.model,
1289
+ modelOptions: command.modelOptions,
1290
+ effort: command.effort,
1291
+ planMode: command.planMode,
1292
+ })
1293
+ return { chatId, queuedMessageId: queuedMessage.id, queued: true as const }
1294
+ }
1295
+
1296
+ const provider = this.resolveProvider(command, chat.provider)
1297
+ const settings = this.getProviderSettings(provider, command)
1298
+ this.analytics.track("message_sent")
1299
+ await this.startTurnForChat({
1300
+ chatId,
1301
+ provider,
1302
+ content: command.content,
1303
+ attachments: command.attachments ?? [],
1304
+ model: settings.model,
1305
+ effort: settings.effort,
1306
+ serviceTier: settings.serviceTier,
1307
+ planMode: settings.planMode,
1308
+ appendUserPrompt: true,
1309
+ profile,
1310
+ })
1311
+
1312
+ logSendToStartingProfile(profile, "chat_send.ready_for_ack", {
1313
+ chatId,
1314
+ provider,
1315
+ model: settings.model,
1316
+ })
1317
+
1318
+ return { chatId }
1319
+ }
1320
+
1321
+ async enqueue(command: Extract<ClientCommand, { type: "message.enqueue" }>) {
1322
+ if (typeof command.autoResumeOnRateLimit === "boolean") {
1323
+ this.autoResumeByChat.set(command.chatId, command.autoResumeOnRateLimit)
1324
+ }
1325
+ this.analytics.track("message_sent")
1326
+ const queuedMessage = await this.enqueueMessage(command.chatId, command.content, command.attachments ?? [], {
1327
+ provider: command.provider,
1328
+ model: command.model,
1329
+ modelOptions: command.modelOptions,
1330
+ planMode: command.planMode,
1331
+ })
1332
+ return { queuedMessageId: queuedMessage.id }
1333
+ }
1334
+
1335
+ async steer(command: Extract<ClientCommand, { type: "message.steer" }>) {
1336
+ const queuedMessage = this.store.getQueuedMessage(command.chatId, command.queuedMessageId)
1337
+ if (!queuedMessage) {
1338
+ throw new Error("Queued message not found")
1339
+ }
1340
+
1341
+ logClaudeSteer("steer_requested", {
1342
+ chatId: command.chatId,
1343
+ queuedMessageId: command.queuedMessageId,
1344
+ activeTurn: this.activeTurns.has(command.chatId),
1345
+ queuedMessagePreview: queuedMessage.content.slice(0, 160),
1346
+ })
1347
+
1348
+ if (this.activeTurns.has(command.chatId)) {
1349
+ await this.cancel(command.chatId, { hideInterrupted: true })
1350
+ }
1351
+
1352
+ logClaudeSteer("steer_after_cancel", {
1353
+ chatId: command.chatId,
1354
+ stillActive: this.activeTurns.has(command.chatId),
1355
+ })
1356
+
1357
+ if (this.activeTurns.has(command.chatId)) {
1358
+ throw new Error("Chat is still running")
1359
+ }
1360
+
1361
+ await this.dequeueAndStartQueuedMessage(command.chatId, queuedMessage, { steered: true })
1362
+ }
1363
+
1364
+ async dequeue(command: Extract<ClientCommand, { type: "message.dequeue" }>) {
1365
+ const queuedMessage = this.store.getQueuedMessage(command.chatId, command.queuedMessageId)
1366
+ if (!queuedMessage) {
1367
+ throw new Error("Queued message not found")
1368
+ }
1369
+
1370
+ await this.store.removeQueuedMessage(command.chatId, command.queuedMessageId)
1371
+ }
1372
+
1373
+ async forkChat(chatId: string) {
1374
+ const chat = this.store.requireChat(chatId)
1375
+ if (this.activeTurns.has(chatId) || this.drainingStreams.has(chatId)) {
1376
+ throw new Error("Chat must be idle before forking")
1377
+ }
1378
+ if (!chat.provider) {
1379
+ throw new Error("Chat must have a provider before forking")
1380
+ }
1381
+ if (!chat.sessionToken && !chat.pendingForkSessionToken) {
1382
+ throw new Error("Chat has no session to fork")
1383
+ }
1384
+
1385
+ const forked = await this.store.forkChat(chatId)
1386
+ this.analytics.track("chat_created")
1387
+ return { chatId: forked.id }
1388
+ }
1389
+
1390
+ private async runClaudeSession(session: ClaudeSessionState) {
1391
+ try {
1392
+ let simulateLimit = this.throwOnClaudeSessionStart
1393
+ for await (const event of session.session.stream) {
1394
+ if (simulateLimit) {
1395
+ simulateLimit = false
1396
+ throw new Error("simulated rate limit")
1397
+ }
1398
+ if (event.type === "session_token" && event.sessionToken) {
1399
+ session.sessionToken = event.sessionToken
1400
+ await this.store.setSessionToken(session.chatId, event.sessionToken)
1401
+ this.emitStateChange(session.chatId)
1402
+ continue
1403
+ }
1404
+
1405
+ if (!event.entry) continue
1406
+ await this.store.appendMessage(session.chatId, event.entry)
1407
+ this.trackBashToolEntry(session.chatId, event.entry)
1408
+ const active = this.activeTurns.get(session.chatId)
1409
+ if (event.entry.kind === "system_init" && active) {
1410
+ active.status = "running"
1411
+ const chat = this.store.getChat(session.chatId)
1412
+ if (
1413
+ chat?.pendingForkSessionToken
1414
+ && session.sessionToken
1415
+ && session.sessionToken !== chat.pendingForkSessionToken
1416
+ ) {
1417
+ await this.store.setPendingForkSessionToken(session.chatId, null)
1418
+ }
1419
+ logClaudeSteer("claude_event_system_init", {
1420
+ chatId: session.chatId,
1421
+ sessionId: session.id,
1422
+ activePromptSeq: active.claudePromptSeq ?? null,
1423
+ pendingPromptSeqs: [...session.pendingPromptSeqs],
1424
+ })
1425
+ }
1426
+
1427
+ const completedClaudePromptSeq = event.entry.kind === "result" || event.entry.kind === "interrupted"
1428
+ ? (session.pendingPromptSeqs.shift() ?? null)
1429
+ : null
1430
+
1431
+ logClaudeSteer("claude_event", {
1432
+ chatId: session.chatId,
1433
+ sessionId: session.id,
1434
+ entryKind: event.entry.kind,
1435
+ activePromptSeq: active?.claudePromptSeq ?? null,
1436
+ completedPromptSeq: completedClaudePromptSeq,
1437
+ activeStatus: active?.status ?? null,
1438
+ pendingPromptSeqs: [...session.pendingPromptSeqs],
1439
+ })
1440
+
1441
+ if (event.entry.kind === "result" && active && completedClaudePromptSeq === (active.claudePromptSeq ?? null)) {
1442
+ active.hasFinalResult = true
1443
+ if (event.entry.isError) {
1444
+ const resultText = event.entry.result || "Turn failed"
1445
+ const detection = this.claudeLimitDetector.detectFromResultText?.(session.chatId, resultText) ?? null
1446
+ let handled = false
1447
+ if (detection) {
1448
+ handled = await this.handleLimitDetection(session.chatId, detection)
1449
+ }
1450
+ if (handled) {
1451
+ await this.store.recordTurnFailed(session.chatId, "rate_limit")
1452
+ } else {
1453
+ await this.store.recordTurnFailed(session.chatId, resultText)
1454
+ }
1455
+ } else if (!active.cancelRequested) {
1456
+ await this.store.recordTurnFinished(session.chatId)
1457
+ }
1458
+ this.activeTurns.delete(session.chatId)
1459
+ if (!active.cancelRequested) {
1460
+ await this.maybeStartNextQueuedMessage(session.chatId)
1461
+ }
1462
+ }
1463
+
1464
+ this.emitStateChange(session.chatId)
1465
+ }
1466
+ } catch (error) {
1467
+ const active = this.activeTurns.get(session.chatId)
1468
+ if (active && !active.cancelRequested) {
1469
+ const handled = await this.handleLimitError(session.chatId, this.claudeLimitDetector, error)
1470
+ if (!handled) {
1471
+ const message = error instanceof Error ? error.message : String(error)
1472
+ await this.store.appendMessage(
1473
+ session.chatId,
1474
+ timestamped({
1475
+ kind: "result",
1476
+ subtype: "error",
1477
+ isError: true,
1478
+ durationMs: 0,
1479
+ result: message,
1480
+ })
1481
+ )
1482
+ await this.store.recordTurnFailed(session.chatId, message)
1483
+ } else {
1484
+ await this.store.recordTurnFailed(session.chatId, "rate_limit")
1485
+ }
1486
+ }
1487
+ } finally {
1488
+ this.claudeSessions.delete(session.chatId)
1489
+ const active = this.activeTurns.get(session.chatId)
1490
+ if (active?.provider === "claude") {
1491
+ if (active.cancelRequested && !active.cancelRecorded) {
1492
+ await this.store.recordTurnCancelled(session.chatId)
1493
+ }
1494
+ this.activeTurns.delete(session.chatId)
1495
+ }
1496
+ session.session.close()
1497
+ this.emitStateChange(session.chatId)
1498
+ }
1499
+ }
1500
+
1501
+ private async generateTitleInBackground(chatId: string, messageContent: string, cwd: string, expectedCurrentTitle: string) {
1502
+ try {
1503
+ const result = await this.generateTitle(messageContent, cwd)
1504
+ if (result.failureMessage) {
1505
+ this.reportBackgroundError?.(
1506
+ `[title-generation] chat ${chatId} failed provider title generation: ${result.failureMessage}`
1507
+ )
1508
+ }
1509
+ if (!result.title || result.usedFallback) return
1510
+
1511
+ const chat = this.store.requireChat(chatId)
1512
+ if (chat.title !== expectedCurrentTitle) return
1513
+
1514
+ await this.store.renameChat(chatId, result.title)
1515
+ this.emitStateChange(chatId)
1516
+ } catch (error) {
1517
+ const message = error instanceof Error ? error.message : String(error)
1518
+ this.reportBackgroundError?.(
1519
+ `[title-generation] chat ${chatId} failed background title generation: ${message}`
1520
+ )
1521
+ }
1522
+ }
1523
+
1524
+ private async runTurn(active: ActiveTurn) {
1525
+ try {
1526
+ for await (const event of active.turn.stream) {
1527
+ // Once cancelled, stop processing further stream events.
1528
+ // cancel() already removed us from activeTurns and notified the UI.
1529
+ if (active.cancelRequested) break
1530
+
1531
+ if (event.type === "session_token" && event.sessionToken) {
1532
+ await this.store.setSessionToken(active.chatId, event.sessionToken)
1533
+ const chat = this.store.getChat(active.chatId)
1534
+ if (
1535
+ chat?.pendingForkSessionToken
1536
+ && event.sessionToken !== chat.pendingForkSessionToken
1537
+ ) {
1538
+ await this.store.setPendingForkSessionToken(active.chatId, null)
1539
+ }
1540
+ this.emitStateChange(active.chatId)
1541
+ continue
1542
+ }
1543
+
1544
+ if (!event.entry) continue
1545
+ await this.store.appendMessage(active.chatId, event.entry)
1546
+ this.trackBashToolEntry(active.chatId, event.entry)
1547
+
1548
+ if (event.entry.kind === "system_init") {
1549
+ active.status = "running"
1550
+ }
1551
+
1552
+ if (event.entry.kind === "result") {
1553
+ active.hasFinalResult = true
1554
+ if (event.entry.isError) {
1555
+ await this.store.recordTurnFailed(active.chatId, event.entry.result || "Turn failed")
1556
+ } else if (!active.cancelRequested) {
1557
+ await this.store.recordTurnFinished(active.chatId)
1558
+ }
1559
+ // Remove from activeTurns as soon as the result arrives so the UI
1560
+ // transitions to idle immediately. The stream may still be open
1561
+ // (e.g. background tasks), but the user should be able to send
1562
+ // new messages without having to hit stop first.
1563
+ this.activeTurns.delete(active.chatId)
1564
+ // Track the still-open stream so the UI can show a draining
1565
+ // indicator and the user can stop background tasks.
1566
+ this.drainingStreams.set(active.chatId, { turn: active.turn })
1567
+ }
1568
+
1569
+ this.emitStateChange(active.chatId)
1570
+ }
1571
+ } catch (error) {
1572
+ if (!active.cancelRequested) {
1573
+ const handled = await this.handleLimitError(active.chatId, this.codexLimitDetector, error)
1574
+ if (!handled) {
1575
+ const message = error instanceof Error ? error.message : String(error)
1576
+ await this.store.appendMessage(
1577
+ active.chatId,
1578
+ timestamped({
1579
+ kind: "result",
1580
+ subtype: "error",
1581
+ isError: true,
1582
+ durationMs: 0,
1583
+ result: message,
1584
+ })
1585
+ )
1586
+ await this.store.recordTurnFailed(active.chatId, message)
1587
+ } else {
1588
+ await this.store.recordTurnFailed(active.chatId, "rate_limit")
1589
+ }
1590
+ }
1591
+ } finally {
1592
+ if (active.cancelRequested && !active.cancelRecorded) {
1593
+ await this.store.recordTurnCancelled(active.chatId)
1594
+ }
1595
+ active.turn.close()
1596
+ // Only remove if we're still the active turn for this chat.
1597
+ // We may have already been removed by result handling or cancel(),
1598
+ // and a new turn may have started for the same chatId.
1599
+ if (this.activeTurns.get(active.chatId) === active) {
1600
+ this.activeTurns.delete(active.chatId)
1601
+ }
1602
+ // Stream has fully ended — no longer draining.
1603
+ this.drainingStreams.delete(active.chatId)
1604
+ this.emitStateChange(active.chatId)
1605
+
1606
+ if (active.postToolFollowUp && !active.cancelRequested) {
1607
+ try {
1608
+ await this.startTurnForChat({
1609
+ chatId: active.chatId,
1610
+ provider: active.provider,
1611
+ content: active.postToolFollowUp.content,
1612
+ attachments: [],
1613
+ model: active.model,
1614
+ effort: active.effort,
1615
+ serviceTier: active.serviceTier,
1616
+ planMode: active.postToolFollowUp.planMode,
1617
+ appendUserPrompt: false,
1618
+ })
1619
+ } catch (error) {
1620
+ const message = error instanceof Error ? error.message : String(error)
1621
+ await this.store.appendMessage(
1622
+ active.chatId,
1623
+ timestamped({
1624
+ kind: "result",
1625
+ subtype: "error",
1626
+ isError: true,
1627
+ durationMs: 0,
1628
+ result: message,
1629
+ })
1630
+ )
1631
+ await this.store.recordTurnFailed(active.chatId, message)
1632
+ this.emitStateChange(active.chatId)
1633
+ }
1634
+ } else if (!active.cancelRequested) {
1635
+ try {
1636
+ await this.maybeStartNextQueuedMessage(active.chatId)
1637
+ } catch (error) {
1638
+ const message = error instanceof Error ? error.message : String(error)
1639
+ await this.store.appendMessage(
1640
+ active.chatId,
1641
+ timestamped({
1642
+ kind: "result",
1643
+ subtype: "error",
1644
+ isError: true,
1645
+ durationMs: 0,
1646
+ result: message,
1647
+ })
1648
+ )
1649
+ await this.store.recordTurnFailed(active.chatId, message)
1650
+ this.emitStateChange(active.chatId)
1651
+ }
1652
+ }
1653
+ }
1654
+ }
1655
+
1656
+ private resolveAutoResumeFor(chatId: string): boolean {
1657
+ const cached = this.autoResumeByChat.get(chatId)
1658
+ if (typeof cached === "boolean") return cached
1659
+ return this.getAutoResumePreference()
1660
+ }
1661
+
1662
+ private async emitAutoContinueEvent(event: AutoContinueEvent): Promise<void> {
1663
+ await this.store.appendAutoContinueEvent(event)
1664
+ this.scheduleManager?.onEvent(event)
1665
+ this.emitStateChange(event.chatId)
1666
+ }
1667
+
1668
+ private getChatSchedule(chatId: string, scheduleId: string) {
1669
+ const events = this.store.getAutoContinueEvents(chatId)
1670
+ return deriveChatSchedules(events, chatId).schedules[scheduleId]
1671
+ }
1672
+
1673
+ private requireFuture(scheduledAt: number): void {
1674
+ if (scheduledAt <= Date.now()) throw new Error("scheduledAt must be in the future")
1675
+ }
1676
+
1677
+ private async handleLimitError(chatId: string, detector: LimitDetector, error: unknown): Promise<boolean> {
1678
+ const detection = detector.detect(chatId, error)
1679
+ if (!detection) return false
1680
+ return this.handleLimitDetection(chatId, detection)
1681
+ }
1682
+
1683
+ private async handleLimitDetection(chatId: string, detection: LimitDetection): Promise<boolean> {
1684
+ const live = deriveChatSchedules(this.store.getAutoContinueEvents(chatId), chatId).liveScheduleId
1685
+ if (live !== null) return true
1686
+
1687
+ const now = Date.now()
1688
+ const scheduleId = crypto.randomUUID()
1689
+ const base = { v: AUTO_CONTINUE_EVENT_VERSION, timestamp: now, chatId, scheduleId }
1690
+
1691
+ const event: AutoContinueEvent = this.resolveAutoResumeFor(chatId)
1692
+ ? {
1693
+ ...base,
1694
+ kind: "auto_continue_accepted",
1695
+ scheduledAt: detection.resetAt,
1696
+ tz: detection.tz,
1697
+ source: "auto_setting",
1698
+ resetAt: detection.resetAt,
1699
+ detectedAt: now,
1700
+ }
1701
+ : {
1702
+ ...base,
1703
+ kind: "auto_continue_proposed",
1704
+ detectedAt: now,
1705
+ resetAt: detection.resetAt,
1706
+ tz: detection.tz,
1707
+ }
1708
+
1709
+ await this.emitAutoContinueEvent(event)
1710
+ await this.store.appendMessage(chatId, timestamped({
1711
+ kind: "auto_continue_prompt",
1712
+ scheduleId,
1713
+ }))
1714
+
1715
+ return true
1716
+ }
1717
+
1718
+ async fireAutoContinue(chatId: string, scheduleId: string) {
1719
+ if (!this.store.getChat(chatId)) return
1720
+
1721
+ const event: AutoContinueEvent = {
1722
+ v: AUTO_CONTINUE_EVENT_VERSION,
1723
+ kind: "auto_continue_fired",
1724
+ timestamp: Date.now(),
1725
+ chatId,
1726
+ scheduleId,
1727
+ }
1728
+ try {
1729
+ await this.store.appendAutoContinueEvent(event)
1730
+ await this.enqueueMessage(chatId, "continue", [], { autoContinue: { scheduleId } })
1731
+ await this.maybeStartNextQueuedMessage(chatId)
1732
+ } catch (error) {
1733
+ const message = error instanceof Error ? error.message : String(error)
1734
+ await this.store.appendMessage(chatId, timestamped({
1735
+ kind: "result",
1736
+ subtype: "error",
1737
+ isError: true,
1738
+ durationMs: 0,
1739
+ result: `Auto-continue failed: ${message}`,
1740
+ }))
1741
+ }
1742
+
1743
+ this.emitStateChange(chatId)
1744
+ }
1745
+
1746
+ async acceptAutoContinue(chatId: string, scheduleId: string, scheduledAt: number): Promise<void> {
1747
+ const schedule = this.getChatSchedule(chatId, scheduleId)
1748
+ if (!schedule) throw new Error("Schedule not found")
1749
+ if (schedule.state !== "proposed") throw new Error("Schedule not pending")
1750
+ this.requireFuture(scheduledAt)
1751
+
1752
+ await this.emitAutoContinueEvent({
1753
+ v: AUTO_CONTINUE_EVENT_VERSION,
1754
+ kind: "auto_continue_accepted",
1755
+ timestamp: Date.now(),
1756
+ chatId,
1757
+ scheduleId,
1758
+ scheduledAt,
1759
+ tz: schedule.tz,
1760
+ source: "user",
1761
+ resetAt: schedule.resetAt,
1762
+ detectedAt: schedule.detectedAt,
1763
+ })
1764
+ }
1765
+
1766
+ async rescheduleAutoContinue(chatId: string, scheduleId: string, scheduledAt: number): Promise<void> {
1767
+ const schedule = this.getChatSchedule(chatId, scheduleId)
1768
+ if (!schedule || schedule.state !== "scheduled") throw new Error("Schedule not active")
1769
+ this.requireFuture(scheduledAt)
1770
+
1771
+ await this.emitAutoContinueEvent({
1772
+ v: AUTO_CONTINUE_EVENT_VERSION,
1773
+ kind: "auto_continue_rescheduled",
1774
+ timestamp: Date.now(),
1775
+ chatId,
1776
+ scheduleId,
1777
+ scheduledAt,
1778
+ })
1779
+ }
1780
+
1781
+ async cancelAutoContinue(chatId: string, scheduleId: string, reason: "user" | "chat_deleted"): Promise<void> {
1782
+ const schedule = this.getChatSchedule(chatId, scheduleId)
1783
+ if (!schedule) return
1784
+ if (schedule.state !== "proposed" && schedule.state !== "scheduled") return
1785
+
1786
+ await this.emitAutoContinueEvent({
1787
+ v: AUTO_CONTINUE_EVENT_VERSION,
1788
+ kind: "auto_continue_cancelled",
1789
+ timestamp: Date.now(),
1790
+ chatId,
1791
+ scheduleId,
1792
+ reason,
1793
+ })
1794
+ }
1795
+
1796
+ listLiveSchedules(chatId: string): string[] {
1797
+ const { schedules } = deriveChatSchedules(this.store.getAutoContinueEvents(chatId), chatId)
1798
+ return Object.values(schedules)
1799
+ .filter((s) => s.state === "proposed" || s.state === "scheduled")
1800
+ .map((s) => s.scheduleId)
1801
+ .sort()
1802
+ }
1803
+
1804
+ async cancel(chatId: string, options?: { hideInterrupted?: boolean }) {
1805
+ // Also clean up any draining stream for this chat.
1806
+ const draining = this.drainingStreams.get(chatId)
1807
+ if (draining) {
1808
+ draining.turn.close()
1809
+ this.drainingStreams.delete(chatId)
1810
+ }
1811
+
1812
+ const active = this.activeTurns.get(chatId)
1813
+ if (!active) return
1814
+
1815
+ logClaudeSteer("cancel_requested", {
1816
+ chatId,
1817
+ provider: active.provider,
1818
+ activePromptSeq: active.claudePromptSeq ?? null,
1819
+ })
1820
+
1821
+ // Guard against concurrent cancel() calls — only the first one does work.
1822
+ if (active.cancelRequested) return
1823
+ active.cancelRequested = true
1824
+
1825
+ const pendingTool = active.pendingTool
1826
+ active.pendingTool = null
1827
+
1828
+ if (pendingTool) {
1829
+ const result = discardedToolResult(pendingTool.tool)
1830
+ await this.store.appendMessage(
1831
+ chatId,
1832
+ timestamped({
1833
+ kind: "tool_result",
1834
+ toolId: pendingTool.toolUseId,
1835
+ content: result,
1836
+ })
1837
+ )
1838
+ if (active.provider === "codex" && pendingTool.tool.toolKind === "exit_plan_mode") {
1839
+ pendingTool.resolve(result)
1840
+ }
1841
+ }
1842
+
1843
+ await this.store.appendMessage(chatId, timestamped({ kind: "interrupted", hidden: options?.hideInterrupted }))
1844
+ await this.store.recordTurnCancelled(chatId)
1845
+ active.cancelRecorded = true
1846
+ active.hasFinalResult = true
1847
+
1848
+ // Remove from activeTurns immediately so the UI reflects the cancellation
1849
+ // right away, rather than waiting for interrupt() which may hang.
1850
+ this.activeTurns.delete(chatId)
1851
+ this.emitStateChange(chatId)
1852
+ logClaudeSteer("cancel_active_turn_deleted", {
1853
+ chatId,
1854
+ provider: active.provider,
1855
+ activePromptSeq: active.claudePromptSeq ?? null,
1856
+ })
1857
+
1858
+ // Now attempt to interrupt/close the underlying stream in the background.
1859
+ // This is best-effort — the turn is already removed from active state above,
1860
+ // and runTurn()'s finally block will also call close().
1861
+ try {
1862
+ await Promise.race([
1863
+ active.turn.interrupt(),
1864
+ new Promise((resolve) => setTimeout(resolve, 5_000)),
1865
+ ])
1866
+ } catch {
1867
+ // interrupt() failed — force close
1868
+ }
1869
+ active.turn.close()
1870
+ }
1871
+
1872
+ async respondTool(command: Extract<ClientCommand, { type: "chat.respondTool" }>) {
1873
+ const active = this.activeTurns.get(command.chatId)
1874
+ if (!active || !active.pendingTool) {
1875
+ throw new Error("No pending tool request")
1876
+ }
1877
+
1878
+ const pending = active.pendingTool
1879
+ if (pending.toolUseId !== command.toolUseId) {
1880
+ throw new Error("Tool response does not match active request")
1881
+ }
1882
+
1883
+ await this.store.appendMessage(
1884
+ command.chatId,
1885
+ timestamped({
1886
+ kind: "tool_result",
1887
+ toolId: command.toolUseId,
1888
+ content: command.result,
1889
+ })
1890
+ )
1891
+
1892
+ active.pendingTool = null
1893
+ active.status = "running"
1894
+
1895
+ if (pending.tool.toolKind === "exit_plan_mode") {
1896
+ const result = (command.result ?? {}) as {
1897
+ confirmed?: boolean
1898
+ clearContext?: boolean
1899
+ message?: string
1900
+ }
1901
+ if (result.confirmed && result.clearContext) {
1902
+ await this.store.setSessionToken(command.chatId, null)
1903
+ await this.store.appendMessage(command.chatId, timestamped({ kind: "context_cleared" }))
1904
+ }
1905
+
1906
+ if (active.provider === "codex") {
1907
+ active.postToolFollowUp = result.confirmed
1908
+ ? {
1909
+ content: result.message
1910
+ ? `Proceed with the approved plan. Additional guidance: ${result.message}`
1911
+ : "Proceed with the approved plan.",
1912
+ planMode: false,
1913
+ }
1914
+ : {
1915
+ content: result.message
1916
+ ? `Revise the plan using this feedback: ${result.message}`
1917
+ : "Revise the plan using this feedback.",
1918
+ planMode: true,
1919
+ }
1920
+ }
1921
+ }
1922
+
1923
+ pending.resolve(command.result)
1924
+
1925
+ this.emitStateChange(command.chatId)
1926
+ }
1927
+ }