@prismshadow/penguin-server 0.2.2 → 0.2.3

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 (299) hide show
  1. package/README.md +1 -0
  2. package/dist/api/types.d.ts +311 -22
  3. package/dist/chunk-3QINBW3W.js +565 -0
  4. package/dist/chunk-3QINBW3W.js.map +1 -0
  5. package/dist/hmr/manifest.d.ts +44 -0
  6. package/dist/hmr/manifest.js +27 -0
  7. package/dist/hmr/manifest.js.map +1 -0
  8. package/dist/index.js +1995 -796
  9. package/dist/index.js.map +1 -1
  10. package/dist/reset-admin-password.d.ts +28 -0
  11. package/dist/reset-admin-password.js +39 -0
  12. package/dist/reset-admin-password.js.map +1 -0
  13. package/package.json +11 -3
  14. package/web-dist/assets/cpp-DIPi6g--.js +1 -0
  15. package/web-dist/assets/highlighter-Nvj8c0Yw.js +152 -0
  16. package/web-dist/assets/index--uKtXcEB.js +183 -0
  17. package/web-dist/assets/index-BqF51KT4.css +1 -0
  18. package/web-dist/assets/ruby-B13GW9FM.js +1 -0
  19. package/web-dist/index.html +2 -2
  20. package/web-dist/assets/abap-BdImnpbu.js +0 -1
  21. package/web-dist/assets/actionscript-3-CoDkCxhg.js +0 -1
  22. package/web-dist/assets/ada-bCR0ucgS.js +0 -1
  23. package/web-dist/assets/andromeeda-C4gqWexZ.js +0 -1
  24. package/web-dist/assets/angular-html-DA-rfuFy.js +0 -1
  25. package/web-dist/assets/angular-ts-BrjP3tb8.js +0 -1
  26. package/web-dist/assets/apache-Pmp26Uib.js +0 -1
  27. package/web-dist/assets/apex-Dqspr-GT.js +0 -1
  28. package/web-dist/assets/apl-CORt7UWP.js +0 -1
  29. package/web-dist/assets/applescript-Co6uUVPk.js +0 -1
  30. package/web-dist/assets/ara-BRHolxvo.js +0 -1
  31. package/web-dist/assets/asciidoc-Ve4PFQV2.js +0 -1
  32. package/web-dist/assets/asm-D_Q5rh1f.js +0 -1
  33. package/web-dist/assets/astro-HNnZUWAn.js +0 -1
  34. package/web-dist/assets/aurora-x-D-2ljcwZ.js +0 -1
  35. package/web-dist/assets/awk-DMzUqQB5.js +0 -1
  36. package/web-dist/assets/ayu-dark-DYE7WIF3.js +0 -1
  37. package/web-dist/assets/ayu-light-BA47KaF1.js +0 -1
  38. package/web-dist/assets/ayu-mirage-32ctXXKs.js +0 -1
  39. package/web-dist/assets/ballerina-BFfxhgS-.js +0 -1
  40. package/web-dist/assets/bat-BkioyH1T.js +0 -1
  41. package/web-dist/assets/beancount-k_qm7-4y.js +0 -1
  42. package/web-dist/assets/berry-uYugtg8r.js +0 -1
  43. package/web-dist/assets/bibtex-CHM0blh-.js +0 -1
  44. package/web-dist/assets/bicep-Bmn6On1c.js +0 -1
  45. package/web-dist/assets/bird2-BIv1doCn.js +0 -1
  46. package/web-dist/assets/blade-2xfisSek.js +0 -1
  47. package/web-dist/assets/bsl-BO_Y6i37.js +0 -1
  48. package/web-dist/assets/c3-MRO5bC_T.js +0 -1
  49. package/web-dist/assets/cadence-Bv_4Rxtq.js +0 -1
  50. package/web-dist/assets/cairo-KRGpt6FW.js +0 -1
  51. package/web-dist/assets/catppuccin-frappe-DFWUc33u.js +0 -1
  52. package/web-dist/assets/catppuccin-latte-C9dUb6Cb.js +0 -1
  53. package/web-dist/assets/catppuccin-macchiato-DQyhUUbL.js +0 -1
  54. package/web-dist/assets/catppuccin-mocha-D87Tk5Gz.js +0 -1
  55. package/web-dist/assets/clarity-D53aC0YG.js +0 -1
  56. package/web-dist/assets/clojure-P80f7IUj.js +0 -1
  57. package/web-dist/assets/cmake-D1j8_8rp.js +0 -1
  58. package/web-dist/assets/cobol-nBiQ_Alo.js +0 -1
  59. package/web-dist/assets/codeowners-Bp6g37R7.js +0 -1
  60. package/web-dist/assets/codeql-DsOJ9woJ.js +0 -1
  61. package/web-dist/assets/coffee-Ch7k5sss.js +0 -1
  62. package/web-dist/assets/common-lisp-Cg-RD9OK.js +0 -1
  63. package/web-dist/assets/coq-DkFqJrB1.js +0 -1
  64. package/web-dist/assets/cpp-UfJy6YNI.js +0 -1
  65. package/web-dist/assets/crystal-DGywbUpC.js +0 -1
  66. package/web-dist/assets/csharp-DSvCPggb.js +0 -1
  67. package/web-dist/assets/csv-fuZLfV_i.js +0 -1
  68. package/web-dist/assets/cue-D82EKSYY.js +0 -1
  69. package/web-dist/assets/cypher-COkxafJQ.js +0 -1
  70. package/web-dist/assets/d-85-TOEBH.js +0 -1
  71. package/web-dist/assets/dark-plus-C3mMm8J8.js +0 -1
  72. package/web-dist/assets/dart-bE4Kk8sk.js +0 -1
  73. package/web-dist/assets/dax-CEL-wOlO.js +0 -1
  74. package/web-dist/assets/desktop-BmXAJ9_W.js +0 -1
  75. package/web-dist/assets/docker-BcOcwvcX.js +0 -1
  76. package/web-dist/assets/dotenv-Da5cRb03.js +0 -1
  77. package/web-dist/assets/dracula-BzJJZx-M.js +0 -1
  78. package/web-dist/assets/dracula-soft-BXkSAIEj.js +0 -1
  79. package/web-dist/assets/dream-maker-BtqSS_iP.js +0 -1
  80. package/web-dist/assets/edge-FbVlp4U3.js +0 -1
  81. package/web-dist/assets/elixir-CkH2-t6x.js +0 -1
  82. package/web-dist/assets/elm-DbKCFpqz.js +0 -1
  83. package/web-dist/assets/emacs-lisp-CXvaQtF9.js +0 -1
  84. package/web-dist/assets/erb-Dm6A9KJ5.js +0 -1
  85. package/web-dist/assets/erlang-DsQrWhSR.js +0 -1
  86. package/web-dist/assets/everforest-dark-BgDCqdQA.js +0 -1
  87. package/web-dist/assets/everforest-light-C8M2exoo.js +0 -1
  88. package/web-dist/assets/fennel-BYunw83y.js +0 -1
  89. package/web-dist/assets/fish-BvzEVeQv.js +0 -1
  90. package/web-dist/assets/fluent-C4IJs8-o.js +0 -1
  91. package/web-dist/assets/fortran-fixed-form-CkoXwp7k.js +0 -1
  92. package/web-dist/assets/fortran-free-form-BxgE0vQu.js +0 -1
  93. package/web-dist/assets/fsharp-CXgrBDvD.js +0 -1
  94. package/web-dist/assets/gdresource-BOOCDP_w.js +0 -1
  95. package/web-dist/assets/gdscript-C5YyOfLZ.js +0 -1
  96. package/web-dist/assets/gdshader-DkwncUOv.js +0 -1
  97. package/web-dist/assets/genie-D0YGMca9.js +0 -1
  98. package/web-dist/assets/gherkin-DyxjwDmM.js +0 -1
  99. package/web-dist/assets/git-commit-F4YmCXRG.js +0 -1
  100. package/web-dist/assets/git-rebase-r7XF79zn.js +0 -1
  101. package/web-dist/assets/github-dark-default-Cuk6v7N8.js +0 -1
  102. package/web-dist/assets/github-dark-dimmed-DH5Ifo-i.js +0 -1
  103. package/web-dist/assets/github-dark-high-contrast-E3gJ1_iC.js +0 -1
  104. package/web-dist/assets/github-light-default-D7oLnXFd.js +0 -1
  105. package/web-dist/assets/github-light-high-contrast-BfjtVDDH.js +0 -1
  106. package/web-dist/assets/gleam-BspZqrRM.js +0 -1
  107. package/web-dist/assets/glimmer-js-ByusRIyA.js +0 -1
  108. package/web-dist/assets/glimmer-ts-BfAWNZQY.js +0 -1
  109. package/web-dist/assets/glsl-DplSGwfg.js +0 -1
  110. package/web-dist/assets/gn-n2N0HUVH.js +0 -1
  111. package/web-dist/assets/gnuplot-DdkO51Og.js +0 -1
  112. package/web-dist/assets/graphql-ChdNCCLP.js +0 -1
  113. package/web-dist/assets/groovy-gcz8RCvz.js +0 -1
  114. package/web-dist/assets/gruvbox-dark-hard-CFHQjOhq.js +0 -1
  115. package/web-dist/assets/gruvbox-dark-medium-GsRaNv29.js +0 -1
  116. package/web-dist/assets/gruvbox-dark-soft-CVdnzihN.js +0 -1
  117. package/web-dist/assets/gruvbox-light-hard-CH1njM8p.js +0 -1
  118. package/web-dist/assets/gruvbox-light-medium-DRw_LuNl.js +0 -1
  119. package/web-dist/assets/gruvbox-light-soft-hJgmCMqR.js +0 -1
  120. package/web-dist/assets/hack-DbPARsA_.js +0 -1
  121. package/web-dist/assets/haml-D5jkg6IW.js +0 -1
  122. package/web-dist/assets/handlebars-BpdQsYii.js +0 -1
  123. package/web-dist/assets/haskell-Df6bDoY_.js +0 -1
  124. package/web-dist/assets/haxe-CzTSHFRz.js +0 -1
  125. package/web-dist/assets/hcl-BWvSN4gD.js +0 -1
  126. package/web-dist/assets/hjson-D5-asLiD.js +0 -1
  127. package/web-dist/assets/hlsl-D3lLCCz7.js +0 -1
  128. package/web-dist/assets/horizon-BUw7H-hv.js +0 -1
  129. package/web-dist/assets/horizon-bright-CUuTKBJd.js +0 -1
  130. package/web-dist/assets/houston-DnULxvSX.js +0 -1
  131. package/web-dist/assets/html-derivative-DlHx6ybY.js +0 -1
  132. package/web-dist/assets/http-jrhK8wxY.js +0 -1
  133. package/web-dist/assets/hurl-irOxFIW8.js +0 -1
  134. package/web-dist/assets/hxml-Bvhsp5Yf.js +0 -1
  135. package/web-dist/assets/hy-DFXneXwc.js +0 -1
  136. package/web-dist/assets/imba-DGztddWO.js +0 -1
  137. package/web-dist/assets/index-B3puu-Hr.js +0 -153
  138. package/web-dist/assets/index-CaDuyrN0.css +0 -1
  139. package/web-dist/assets/index-rgHg3m6g.js +0 -181
  140. package/web-dist/assets/jinja-f2NsQr07.js +0 -1
  141. package/web-dist/assets/jison-wvAkD_A8.js +0 -1
  142. package/web-dist/assets/json5-C9tS-k6U.js +0 -1
  143. package/web-dist/assets/jsonc-Des-eS-w.js +0 -1
  144. package/web-dist/assets/jsonl-DcaNXYhu.js +0 -1
  145. package/web-dist/assets/jsonnet-DFQXde-d.js +0 -1
  146. package/web-dist/assets/jssm-C2t-YnRu.js +0 -1
  147. package/web-dist/assets/julia-D7OTSIA_.js +0 -1
  148. package/web-dist/assets/just-CUsbIsdP.js +0 -1
  149. package/web-dist/assets/kanagawa-dragon-CkXjmgJE.js +0 -1
  150. package/web-dist/assets/kanagawa-lotus-CfQXZHmo.js +0 -1
  151. package/web-dist/assets/kanagawa-wave-DWedfzmr.js +0 -1
  152. package/web-dist/assets/kdl-DV7GczEv.js +0 -1
  153. package/web-dist/assets/kotlin-BdnUsdx6.js +0 -1
  154. package/web-dist/assets/kusto-wEQ09or8.js +0 -1
  155. package/web-dist/assets/laserwave-DUszq2jm.js +0 -1
  156. package/web-dist/assets/latex-CaSxy8MP.js +0 -1
  157. package/web-dist/assets/lean-BZvkOJ9d.js +0 -1
  158. package/web-dist/assets/less-B1dDrJ26.js +0 -1
  159. package/web-dist/assets/light-plus-B7mTdjB0.js +0 -1
  160. package/web-dist/assets/liquid-C0sCDyMI.js +0 -1
  161. package/web-dist/assets/llvm-DjAJT7YJ.js +0 -1
  162. package/web-dist/assets/logo-BtOb2qkB.js +0 -1
  163. package/web-dist/assets/lua-BaeVxFsk.js +0 -1
  164. package/web-dist/assets/luau-KW6xsasC.js +0 -1
  165. package/web-dist/assets/make-CHLpvVh8.js +0 -1
  166. package/web-dist/assets/marko-DjSrsDqO.js +0 -1
  167. package/web-dist/assets/material-theme-D5KoaKCx.js +0 -1
  168. package/web-dist/assets/material-theme-darker-BfHTSMKl.js +0 -1
  169. package/web-dist/assets/material-theme-lighter-B0m2ddpp.js +0 -1
  170. package/web-dist/assets/material-theme-ocean-CyktbL80.js +0 -1
  171. package/web-dist/assets/material-theme-palenight-Csfq5Kiy.js +0 -1
  172. package/web-dist/assets/matlab-D7o27uSR.js +0 -1
  173. package/web-dist/assets/mdc-DTYItulj.js +0 -1
  174. package/web-dist/assets/mdx-Cmh6b_Ma.js +0 -1
  175. package/web-dist/assets/mermaid-mWjccvbQ.js +0 -1
  176. package/web-dist/assets/min-dark-CafNBF8u.js +0 -1
  177. package/web-dist/assets/min-light-CTRr51gU.js +0 -1
  178. package/web-dist/assets/mipsasm-CKIfxQSi.js +0 -1
  179. package/web-dist/assets/mojo-rZm6bMo-.js +0 -1
  180. package/web-dist/assets/monokai-D4h5O-jR.js +0 -1
  181. package/web-dist/assets/moonbit-_H4v1dQx.js +0 -1
  182. package/web-dist/assets/move-IF9eRakj.js +0 -1
  183. package/web-dist/assets/narrat-DRg8JJMk.js +0 -1
  184. package/web-dist/assets/nextflow-C-mBbutL.js +0 -1
  185. package/web-dist/assets/nextflow-groovy-vE_lwT2v.js +0 -1
  186. package/web-dist/assets/nginx-BpAMiNFr.js +0 -1
  187. package/web-dist/assets/night-owl-C39BiMTA.js +0 -1
  188. package/web-dist/assets/night-owl-light-CMTm3GFP.js +0 -1
  189. package/web-dist/assets/nim-BIad80T-.js +0 -1
  190. package/web-dist/assets/nix-CwoSXNpI.js +0 -1
  191. package/web-dist/assets/nord-Ddv68eIx.js +0 -1
  192. package/web-dist/assets/nushell-Cz2AlsmD.js +0 -1
  193. package/web-dist/assets/objective-c-DXmwc3jG.js +0 -1
  194. package/web-dist/assets/objective-cpp-CLxacb5B.js +0 -1
  195. package/web-dist/assets/ocaml-C0hk2d4L.js +0 -1
  196. package/web-dist/assets/odin-BBf5iR-q.js +0 -1
  197. package/web-dist/assets/one-dark-pro-DVMEJ2y_.js +0 -1
  198. package/web-dist/assets/one-light-C3Wv6jpd.js +0 -1
  199. package/web-dist/assets/openscad-C4EeE6gA.js +0 -1
  200. package/web-dist/assets/pascal-D93ZcfNL.js +0 -1
  201. package/web-dist/assets/perl-B9cMNwum.js +0 -1
  202. package/web-dist/assets/pkl-u5AG7uiY.js +0 -1
  203. package/web-dist/assets/plastic-3e1v2bzS.js +0 -1
  204. package/web-dist/assets/plsql-ChMvpjG-.js +0 -1
  205. package/web-dist/assets/po-BTJTHyun.js +0 -1
  206. package/web-dist/assets/poimandres-CS3Unz2-.js +0 -1
  207. package/web-dist/assets/polar-C0HS_06l.js +0 -1
  208. package/web-dist/assets/postcss-CXtECtnM.js +0 -1
  209. package/web-dist/assets/powerquery-CEu0bR-o.js +0 -1
  210. package/web-dist/assets/powershell-Dpen1YoG.js +0 -1
  211. package/web-dist/assets/prisma-Dd19v3D-.js +0 -1
  212. package/web-dist/assets/prolog-CbFg5uaA.js +0 -1
  213. package/web-dist/assets/proto-C7zT0LnQ.js +0 -1
  214. package/web-dist/assets/pug-DKIMFp6K.js +0 -1
  215. package/web-dist/assets/puppet-BMWR74SV.js +0 -1
  216. package/web-dist/assets/purescript-CklMAg4u.js +0 -1
  217. package/web-dist/assets/qml-3beO22l8.js +0 -1
  218. package/web-dist/assets/qmldir-C8lEn-DE.js +0 -1
  219. package/web-dist/assets/qss-IeuSbFQv.js +0 -1
  220. package/web-dist/assets/r-Dspwwk_N.js +0 -1
  221. package/web-dist/assets/racket-BqYA7rlc.js +0 -1
  222. package/web-dist/assets/raku-DXvB9xmW.js +0 -1
  223. package/web-dist/assets/razor-BjBPvh-w.js +0 -1
  224. package/web-dist/assets/red-bN70gL4F.js +0 -1
  225. package/web-dist/assets/reg-C-SQnVFl.js +0 -1
  226. package/web-dist/assets/regexp-CDVJQ6XC.js +0 -1
  227. package/web-dist/assets/rel-C3B-1QV4.js +0 -1
  228. package/web-dist/assets/riscv-BM1_JUlF.js +0 -1
  229. package/web-dist/assets/ron-D8l8udqQ.js +0 -1
  230. package/web-dist/assets/rose-pine-dawn-DHQR4-dF.js +0 -1
  231. package/web-dist/assets/rose-pine-moon-D4_iv3hh.js +0 -1
  232. package/web-dist/assets/rose-pine-qdsjHGoJ.js +0 -1
  233. package/web-dist/assets/rosmsg-BJDFO7_C.js +0 -1
  234. package/web-dist/assets/rst-CpCqk9r5.js +0 -1
  235. package/web-dist/assets/ruby-DyJCeAvU.js +0 -1
  236. package/web-dist/assets/sas-DEy46yEz.js +0 -1
  237. package/web-dist/assets/sass-Cj5Yp3dK.js +0 -1
  238. package/web-dist/assets/scala-C151Ov-r.js +0 -1
  239. package/web-dist/assets/scheme-C98Dy4si.js +0 -1
  240. package/web-dist/assets/scss-D5BDwBP9.js +0 -1
  241. package/web-dist/assets/sdbl-DVxCFoDh.js +0 -1
  242. package/web-dist/assets/shaderlab-Dg9Lc6iA.js +0 -1
  243. package/web-dist/assets/shellsession-BADoaaVG.js +0 -1
  244. package/web-dist/assets/slack-dark-BthQWCQV.js +0 -1
  245. package/web-dist/assets/slack-ochin-DqwNpetd.js +0 -1
  246. package/web-dist/assets/smalltalk-BERRCDM3.js +0 -1
  247. package/web-dist/assets/snazzy-light-Bw305WKR.js +0 -1
  248. package/web-dist/assets/solarized-dark-DXbdFlpD.js +0 -1
  249. package/web-dist/assets/solarized-light-L9t79GZl.js +0 -1
  250. package/web-dist/assets/solidity-rGO070M0.js +0 -1
  251. package/web-dist/assets/soy-8wufbnw4.js +0 -1
  252. package/web-dist/assets/sparql-rVzFXLq3.js +0 -1
  253. package/web-dist/assets/splunk-BtCnVYZw.js +0 -1
  254. package/web-dist/assets/ssh-config-_ykCGR6B.js +0 -1
  255. package/web-dist/assets/stata-DI20mbqo.js +0 -1
  256. package/web-dist/assets/stylus-BEDo0Tqx.js +0 -1
  257. package/web-dist/assets/surrealql-Bq5Q-fJD.js +0 -1
  258. package/web-dist/assets/svelte-Cy7k_4gC.js +0 -1
  259. package/web-dist/assets/swift-D82vCrfD.js +0 -1
  260. package/web-dist/assets/synthwave-84-CbfX1IO0.js +0 -1
  261. package/web-dist/assets/system-verilog-CnnmHF94.js +0 -1
  262. package/web-dist/assets/systemd-4A_iFExJ.js +0 -1
  263. package/web-dist/assets/talonscript-CkByrt1z.js +0 -1
  264. package/web-dist/assets/tasl-QIJgUcNo.js +0 -1
  265. package/web-dist/assets/tcl-dwOrl1Do.js +0 -1
  266. package/web-dist/assets/templ-DhtptRzy.js +0 -1
  267. package/web-dist/assets/terraform-BETggiCN.js +0 -1
  268. package/web-dist/assets/tex-idrVyKtj.js +0 -1
  269. package/web-dist/assets/tokyo-night-hegEt444.js +0 -1
  270. package/web-dist/assets/ts-tags-D351s5mN.js +0 -1
  271. package/web-dist/assets/tsv-B_m7g4N7.js +0 -1
  272. package/web-dist/assets/turtle-BsS91CYL.js +0 -1
  273. package/web-dist/assets/twig-CW1WmMYd.js +0 -1
  274. package/web-dist/assets/typespec-CAFt9gP4.js +0 -1
  275. package/web-dist/assets/typst-DHCkPAjA.js +0 -1
  276. package/web-dist/assets/v-BcVCzyr7.js +0 -1
  277. package/web-dist/assets/vala-CsfeWuGM.js +0 -1
  278. package/web-dist/assets/vb-D17OF-Vu.js +0 -1
  279. package/web-dist/assets/verilog-BQ8w6xss.js +0 -1
  280. package/web-dist/assets/vesper-DRje8inN.js +0 -1
  281. package/web-dist/assets/vhdl-CeAyd5Ju.js +0 -1
  282. package/web-dist/assets/viml-CJc9bBzg.js +0 -1
  283. package/web-dist/assets/vitesse-black-Bkuqu6BP.js +0 -1
  284. package/web-dist/assets/vitesse-dark-D0r3Knsf.js +0 -1
  285. package/web-dist/assets/vitesse-light-CVO1_9PV.js +0 -1
  286. package/web-dist/assets/vue-D2xRrEX4.js +0 -1
  287. package/web-dist/assets/vue-html-AaS7Mt5G.js +0 -1
  288. package/web-dist/assets/vue-vine-BoDAl6tE.js +0 -1
  289. package/web-dist/assets/vyper-CDx5xZoG.js +0 -1
  290. package/web-dist/assets/wasm-CG6Dc4jp.js +0 -1
  291. package/web-dist/assets/wasm-MzD3tlZU.js +0 -1
  292. package/web-dist/assets/wenyan-BV7otONQ.js +0 -1
  293. package/web-dist/assets/wgsl-Dx-B1_4e.js +0 -1
  294. package/web-dist/assets/wikitext-BhOHFoWU.js +0 -1
  295. package/web-dist/assets/wit-5i3qLPDT.js +0 -1
  296. package/web-dist/assets/wolfram-lXgVvXCa.js +0 -1
  297. package/web-dist/assets/xsl-CtQFsRM5.js +0 -1
  298. package/web-dist/assets/zenscript-DVFEvuxE.js +0 -1
  299. package/web-dist/assets/zig-VOosw3JB.js +0 -1
package/dist/index.js CHANGED
@@ -1,3 +1,18 @@
1
+ import {
2
+ ADMIN_USER_ID,
3
+ AuthService,
4
+ AuthSessionsRepo,
5
+ HttpError,
6
+ MIN_PASSWORD_LENGTH,
7
+ UsersRepo,
8
+ errorBody,
9
+ handleError,
10
+ hashPassword,
11
+ isMissingCredential,
12
+ modelCredentialMissing,
13
+ openDatabase,
14
+ toUserInfo
15
+ } from "./chunk-3QINBW3W.js";
1
16
  import {
2
17
  acquireServerLock,
3
18
  liveServerLock,
@@ -11,20 +26,26 @@ import {
11
26
  } from "./chunk-UUJCF46V.js";
12
27
 
13
28
  // src/index.ts
14
- import fs23 from "fs";
29
+ import fs24 from "fs";
15
30
  import path19 from "path";
16
31
  import { config as loadDotenv } from "dotenv";
17
32
  import { serve } from "@hono/node-server";
18
33
 
19
34
  // src/app.ts
20
- import fs21 from "fs";
21
- import fsp from "fs/promises";
35
+ import fs22 from "fs";
36
+ import fsp2 from "fs/promises";
22
37
  import path17 from "path";
23
- import { Hono as Hono25 } from "hono";
38
+ import { Hono as Hono26 } from "hono";
24
39
  import { bodyLimit } from "hono/body-limit";
25
40
 
26
41
  // src/net/proxy.ts
27
- import { Agent, EnvHttpProxyAgent, fetch as undiciFetch, setGlobalDispatcher } from "undici";
42
+ import {
43
+ Agent,
44
+ EnvHttpProxyAgent,
45
+ ProxyAgent,
46
+ fetch as undiciFetch,
47
+ setGlobalDispatcher
48
+ } from "undici";
28
49
  var LOOPBACK_NO_PROXY = ["localhost", "127.0.0.1", "::1"];
29
50
  function mergedNoProxy(env = process.env) {
30
51
  const raw = env.no_proxy ?? env.NO_PROXY ?? "";
@@ -45,12 +66,14 @@ function normalizeProxyUrl(raw) {
45
66
  } catch {
46
67
  return null;
47
68
  }
48
- if (url.protocol !== "http:" && url.protocol !== "https:") return null;
49
- if (url.hostname === "" || url.username !== "" || url.password !== "") return null;
50
- if (url.pathname !== "/" && url.pathname !== "" || url.search !== "" || url.hash !== "") {
69
+ if (url.hostname === "") return null;
70
+ const normalized = url.pathname === "/" && url.search === "" && url.hash === "" ? url.href.slice(0, -1) : url.href;
71
+ try {
72
+ void new ProxyAgent({ uri: normalized }).close();
73
+ } catch {
51
74
  return null;
52
75
  }
53
- return url.origin;
76
+ return normalized;
54
77
  }
55
78
  function envProxyAgentOptions(proxyUrl, env) {
56
79
  const noProxy = mergedNoProxy(env);
@@ -75,175 +98,6 @@ function applyProxySettings(settings) {
75
98
  if (installed) setGlobalDispatcher(buildProxyDispatcher(current));
76
99
  }
77
100
 
78
- // src/db/database.ts
79
- import { mkdirSync } from "fs";
80
- import path from "path";
81
-
82
- // src/db/schema.ts
83
- var SCHEMA_SQL = `
84
- CREATE TABLE IF NOT EXISTS users (
85
- user_id TEXT PRIMARY KEY, -- semantic id doubles as login name: ^[a-z][a-z0-9_-]{1,31}$
86
- password_hash TEXT NOT NULL, -- scrypt$N$r$p$salt$hash(base64)
87
- is_admin INTEGER NOT NULL DEFAULT 0, -- 1 for the built-in admin (seeded at startup)
88
- password_is_initial INTEGER NOT NULL DEFAULT 0, -- 1=initial password (seeded/admin-set); cleared once the user changes it
89
- created_at TEXT NOT NULL
90
- );
91
- CREATE TABLE IF NOT EXISTS auth_sessions (
92
- token_hash TEXT PRIMARY KEY, -- sha256(token) hex; the cookie stores only the raw token
93
- user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
94
- created_at TEXT NOT NULL,
95
- expires_at TEXT NOT NULL, -- 7-day sliding renewal (topped up when <6 days remain)
96
- via TEXT -- 'password' | 'desktop'; NULL = legacy row (password)
97
- );
98
- CREATE TABLE IF NOT EXISTS projects (
99
- project_id TEXT PRIMARY KEY, -- directory name doubles as id; display name lives in project_config.toml
100
- owner_user_id TEXT NOT NULL REFERENCES users(user_id),
101
- created_at TEXT NOT NULL
102
- );
103
- CREATE TABLE IF NOT EXISTS project_members ( -- member grants only; owners are not stored here
104
- project_id TEXT NOT NULL REFERENCES projects(project_id) ON DELETE CASCADE,
105
- user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
106
- created_at TEXT NOT NULL,
107
- PRIMARY KEY (project_id, user_id)
108
- );
109
- CREATE TABLE IF NOT EXISTS agents ( -- index only; name/description live in system_config.yaml
110
- project_id TEXT NOT NULL,
111
- agent_id TEXT NOT NULL,
112
- created_at TEXT NOT NULL,
113
- PRIMARY KEY (project_id, agent_id)
114
- );
115
- CREATE TABLE IF NOT EXISTS sessions (
116
- session_id TEXT PRIMARY KEY,
117
- project_id TEXT NOT NULL,
118
- agent_id TEXT NOT NULL,
119
- provider TEXT NOT NULL, -- provider group of the session model (paired with model_id as a model reference)
120
- model_id TEXT NOT NULL, -- upstream model id (sent to AgentHub as-is; never concatenate <provider>/<id>)
121
- workspace TEXT NOT NULL,
122
- approval_mode TEXT NOT NULL DEFAULT 'allow-all', -- allow-all|deny-all|read-only|always-ask
123
- title TEXT, -- auto-generated by the model after the first exchange; NULL=not yet (frontend shows "New chat")
124
- archived_at TEXT, -- archive time; NULL=not archived (shown by default; archived ones move under "Archived")
125
- client TEXT, -- creating client: 'web' (created via the Web App) | 'cli' (adopted from a CLI Trace); NULL = legacy row, treated as web
126
- has_trace INTEGER NOT NULL DEFAULT 0, -- cache: a Trace record exists (set at task start / adoption / subagent registration; lazily backfilled by list hydration)
127
- created_at TEXT NOT NULL
128
- ); -- the schedule/subagent SOURCE is NOT stored: session_meta in the Trace is the single source of truth (see runtime/session-sources.ts); "client" is a different, DB-only axis (who created the row)
129
- CREATE INDEX IF NOT EXISTS idx_sessions_agent_created ON sessions(project_id, agent_id, created_at DESC);
130
- CREATE TABLE IF NOT EXISTS usage_records (
131
- id INTEGER PRIMARY KEY AUTOINCREMENT,
132
- ts TEXT NOT NULL,
133
- date TEXT NOT NULL, -- local date yyyy-mm-dd (aggregation key; same convention as Trace date directories)
134
- project_id TEXT NOT NULL,
135
- agent_id TEXT NOT NULL,
136
- session_id TEXT NOT NULL, -- top-level Session (child-session usage counts toward its main Session)
137
- origin_session_id TEXT, -- immediate source child Session (last of the origin chain); NULL=main session
138
- provider TEXT NOT NULL, -- provider group: paired with model_id as the attribution key; always GROUP BY provider, model_id
139
- model_id TEXT NOT NULL, -- upstream model id (paired with provider; same model_id across providers aggregates separately)
140
- cache_read INTEGER NOT NULL,
141
- cache_write INTEGER NOT NULL,
142
- output INTEGER NOT NULL,
143
- total INTEGER NOT NULL, -- from token_usage.request (one row per Request)
144
- status TEXT NOT NULL DEFAULT 'completed' -- request outcome: completed=success (with tokens); others=failure (0 tokens, for success rate)
145
- ); -- cost is not stored: computed at query time from current pricing
146
- CREATE INDEX IF NOT EXISTS idx_usage_project_date ON usage_records(project_id, date);
147
- CREATE INDEX IF NOT EXISTS idx_usage_session ON usage_records(session_id);
148
- CREATE TABLE IF NOT EXISTS error_records ( -- server-side error capture (the Costs page's "Errors" tab)
149
- id INTEGER PRIMARY KEY AUTOINCREMENT,
150
- ts TEXT NOT NULL,
151
- date TEXT NOT NULL, -- local date yyyy-mm-dd (same convention as usage_records)
152
- project_id TEXT, -- nullable: sign-in/registration and process-level errors have no Project context
153
- agent_id TEXT,
154
- session_id TEXT,
155
- source TEXT NOT NULL, -- http | session | usage | title | subagent | process | llm | environment | compaction | schedule
156
- kind TEXT NOT NULL, -- expected (HttpError, business 4xx) | unexpected (500/runtime)
157
- code TEXT NOT NULL, -- HttpError.code / internal / session_run_failed / ...
158
- status INTEGER, -- HTTP status code; NULL for non-HTTP sources
159
- message TEXT NOT NULL -- truncated to 500 chars (no stack stored: stacks go to logs only)
160
- );
161
- CREATE INDEX IF NOT EXISTS idx_error_project_date ON error_records(project_id, date);
162
- CREATE TABLE IF NOT EXISTS schedule_state ( -- schedule runtime state (files are declarative intent; the system never writes back)
163
- project_id TEXT NOT NULL,
164
- agent_id TEXT NOT NULL,
165
- name TEXT NOT NULL, -- file name (without .toml) doubles as the identifier
166
- creator_user_id TEXT, -- creator (recorded on API create; hand-edited file reconciliation falls back to the Project owner)
167
- start_at_ms INTEGER NOT NULL, -- defines identity: a start_at change counts as a new task instance, resetting fire state
168
- def_hash TEXT NOT NULL, -- file content fingerprint: any change clears the invalid flag (the file takes effect again after edits)
169
- last_slot_ms INTEGER, -- latest due slot already consumed (advances on fire or skip; no re-fire across restarts)
170
- last_fired_at TEXT, -- last actual fire time (for display)
171
- fired_once INTEGER NOT NULL DEFAULT 0, -- one-shot task has fired
172
- missed INTEGER NOT NULL DEFAULT 0, -- one-shot task was missed (flagged by startup/registration reconciliation; not backfilled)
173
- invalid_reason TEXT, -- invalidation reason (e.g. the bound Session was deleted); NULL=healthy
174
- PRIMARY KEY (project_id, agent_id, name)
175
- );
176
- CREATE TABLE IF NOT EXISTS goal_state ( -- goal-mode runtime state (GOAL.yaml on disk is the model-facing protocol; this row is what the UI reads)
177
- id INTEGER PRIMARY KEY AUTOINCREMENT, -- one row per goal run; a Session may run goals repeatedly, latest row wins for display
178
- session_id TEXT NOT NULL,
179
- project_id TEXT NOT NULL,
180
- agent_id TEXT NOT NULL,
181
- objective TEXT NOT NULL,
182
- status TEXT NOT NULL, -- active | complete | blocked | budget_limited | aborted (aborted is server-side only; on-disk status stays active)
183
- budget INTEGER NOT NULL, -- token budget; -1 = unlimited
184
- used INTEGER NOT NULL DEFAULT 0, -- uncached input + output, refreshed per round (mirrors the runner's accounting)
185
- rounds INTEGER NOT NULL DEFAULT 0,
186
- created_at TEXT NOT NULL,
187
- updated_at TEXT NOT NULL
188
- );
189
- CREATE INDEX IF NOT EXISTS idx_goal_session ON goal_state(session_id);
190
- CREATE TABLE IF NOT EXISTS ui_prefs (
191
- user_id TEXT PRIMARY KEY REFERENCES users(user_id) ON DELETE CASCADE,
192
- prefs_json TEXT NOT NULL -- {theme?, lastProjectId?, ...} free-form JSON
193
- );
194
- CREATE TABLE IF NOT EXISTS server_settings ( -- admin-level server-global settings (GET/PUT /api/admin/settings)
195
- key TEXT PRIMARY KEY, -- setting name, e.g. 'proxy_for_app'
196
- value TEXT NOT NULL -- JSON-encoded value; an absent row means the setting's built-in default
197
- );
198
- CREATE TABLE IF NOT EXISTS trace_files ( -- DERIVED CACHE of the on-disk Trace tree (services/trace-index.ts): the directories stay the single source of truth, every row is rebuildable from disk, and a row is never authority for absence \u2014 consumers reconcile + retry on a miss, so a stale index costs one extra scan, never a false 404
199
- project_id TEXT NOT NULL,
200
- agent_id TEXT NOT NULL,
201
- session_id TEXT NOT NULL,
202
- file_index INTEGER NOT NULL, -- shard index NNN of <session_id>_NNN.jsonl (name/path are reconstructed from the row, never stored: the data root may move)
203
- date TEXT NOT NULL, -- date directory name (local yyyy-mm-dd)
204
- size_bytes INTEGER NOT NULL, -- last observed size (listings stat-refresh the returned page and write back; an actively-appended shard may lag in between)
205
- page_stats TEXT, -- cached message-window scan state at this shard's END (services/message-window.ts ShardPrefixRecord JSON); immutable shards only, NULL = not yet computed, nulled whenever size_bytes changes
206
- PRIMARY KEY (project_id, agent_id, session_id, file_index)
207
- );
208
- CREATE INDEX IF NOT EXISTS idx_trace_files_agent_date ON trace_files(project_id, agent_id, date);
209
- CREATE INDEX IF NOT EXISTS idx_trace_files_session ON trace_files(session_id);
210
- CREATE TABLE IF NOT EXISTS trace_sessions ( -- per-session facts read ONCE at registration from the earliest shard's head (same derived-cache rules as trace_files; listing needs zero head-reads)
211
- session_id TEXT PRIMARY KEY,
212
- project_id TEXT NOT NULL,
213
- agent_id TEXT NOT NULL,
214
- source TEXT, -- session_meta origin: 'subagent' | 'schedule' | NULL = user-created (or head not yet readable)
215
- workspace TEXT NOT NULL DEFAULT '',
216
- title TEXT, -- first-prompt fallback title (sessions.title always wins when present)
217
- provider TEXT, -- model reference from session_meta (CLI adoption reads it from here; NULL = meta unreadable / legacy without provider)
218
- model_id TEXT,
219
- first_ts TEXT, -- first record's timestamp (adoption's createdAt fallback when the id embeds no time)
220
- meta_read INTEGER NOT NULL DEFAULT 0 -- 1 once the head parsed; 0 = facts unknown, retried by the next reconcile that touches the session
221
- );
222
- CREATE INDEX IF NOT EXISTS idx_trace_sessions_agent ON trace_sessions(project_id, agent_id);
223
- `;
224
-
225
- // src/db/database.ts
226
- var sqlite = process.getBuiltinModule("node:sqlite");
227
- function openDatabase(dbPath) {
228
- if (dbPath !== ":memory:") {
229
- mkdirSync(path.dirname(dbPath), { recursive: true });
230
- }
231
- const db = new sqlite.DatabaseSync(dbPath);
232
- db.exec("PRAGMA journal_mode = WAL;");
233
- db.exec("PRAGMA foreign_keys = ON;");
234
- db.exec(SCHEMA_SQL);
235
- ensureColumn(db, "sessions", "client", "TEXT");
236
- ensureColumn(db, "sessions", "has_trace", "INTEGER NOT NULL DEFAULT 0");
237
- ensureColumn(db, "auth_sessions", "via", "TEXT");
238
- ensureColumn(db, "trace_files", "page_stats", "TEXT");
239
- return db;
240
- }
241
- function ensureColumn(db, table, column, ddl) {
242
- const cols = db.prepare(`PRAGMA table_info(${table})`).all();
243
- if (cols.some((c) => c.name === column)) return;
244
- db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${ddl}`);
245
- }
246
-
247
101
  // src/db/repos/agents.ts
248
102
  var AgentsRepo = class {
249
103
  constructor(db) {
@@ -274,45 +128,6 @@ var AgentsRepo = class {
274
128
  }
275
129
  };
276
130
 
277
- // src/db/repos/auth-sessions.ts
278
- var AuthSessionsRepo = class {
279
- constructor(db) {
280
- this.db = db;
281
- }
282
- db;
283
- insert(row) {
284
- this.db.prepare(
285
- "INSERT INTO auth_sessions (token_hash, user_id, created_at, expires_at, via) VALUES (?, ?, ?, ?, ?)"
286
- ).run(row.tokenHash, row.userId, row.createdAt, row.expiresAt, row.via);
287
- }
288
- findByTokenHash(tokenHash) {
289
- const r = this.db.prepare("SELECT * FROM auth_sessions WHERE token_hash = ?").get(tokenHash);
290
- if (!r) return null;
291
- return {
292
- tokenHash: r.token_hash,
293
- userId: r.user_id,
294
- createdAt: r.created_at,
295
- expiresAt: r.expires_at,
296
- via: r.via ?? null
297
- };
298
- }
299
- /** Sliding renewal: update the expiration time. */
300
- touch(tokenHash, expiresAt) {
301
- this.db.prepare("UPDATE auth_sessions SET expires_at = ? WHERE token_hash = ?").run(expiresAt, tokenHash);
302
- }
303
- delete(tokenHash) {
304
- this.db.prepare("DELETE FROM auth_sessions WHERE token_hash = ?").run(tokenHash);
305
- }
306
- /** Opportunistically clean up expired sessions (called during login/validation). */
307
- deleteExpired(nowIso) {
308
- this.db.prepare("DELETE FROM auth_sessions WHERE expires_at < ?").run(nowIso);
309
- }
310
- /** Clear all sessions for a user (forces re-login after an admin resets the password). */
311
- deleteByUser(userId) {
312
- this.db.prepare("DELETE FROM auth_sessions WHERE user_id = ?").run(userId);
313
- }
314
- };
315
-
316
131
  // src/db/repos/errors.ts
317
132
  var MAX_ROWS = 2e4;
318
133
  var PRUNE_EVERY = 200;
@@ -712,11 +527,41 @@ var SchedulesRepo = class {
712
527
  }
713
528
  };
714
529
 
530
+ // src/services/attachment-limits.ts
531
+ var MB = 1024 * 1024;
532
+ var DEFAULT_ATTACHMENT_MAX_MB = 100;
533
+ var DEFAULT_ATTACHMENT_TOTAL_MB = 120;
534
+ var MIN_ATTACHMENT_MB = 1;
535
+ var MAX_ATTACHMENT_MB = 200;
536
+ var MAX_ATTACHMENT_COUNT = 20;
537
+ var INLINE_IMAGE_MAX_MB = 20;
538
+ function base64Length(bytes) {
539
+ return Math.ceil(bytes / 3) * 4;
540
+ }
541
+ var BODY_HEADROOM_BYTES = base64Length(INLINE_IMAGE_MAX_MB * MB) + 4 * MB;
542
+ function toAttachmentLimits(mb) {
543
+ return {
544
+ maxBytes: mb.attachmentMaxMb * MB,
545
+ totalBytes: mb.attachmentTotalMb * MB,
546
+ maxCount: MAX_ATTACHMENT_COUNT
547
+ };
548
+ }
549
+ var INLINE_IMAGE_MAX_BYTES = INLINE_IMAGE_MAX_MB * MB;
550
+ function bodyLimitBytes(mb) {
551
+ return base64Length(mb.attachmentTotalMb * MB) + BODY_HEADROOM_BYTES;
552
+ }
553
+ function clampAttachmentMb(value, fallback) {
554
+ if (!Number.isFinite(value) || !Number.isInteger(value)) return fallback;
555
+ return Math.min(MAX_ATTACHMENT_MB, Math.max(MIN_ATTACHMENT_MB, value));
556
+ }
557
+
715
558
  // src/db/repos/server-settings.ts
716
559
  var PROXY_FOR_APP_KEY = "proxy_for_app";
717
560
  var PROXY_FOR_AGENT_KEY = "proxy_for_agent";
718
561
  var LEGACY_USE_SYSTEM_PROXY_KEY = "use_system_proxy";
719
562
  var PROXY_URL_KEY = "proxy_url";
563
+ var ATTACHMENT_MAX_MB_KEY = "attachment_max_mb";
564
+ var ATTACHMENT_TOTAL_MB_KEY = "attachment_total_mb";
720
565
  var ServerSettingsRepo = class {
721
566
  constructor(db) {
722
567
  this.db = db;
@@ -771,6 +616,48 @@ var ServerSettingsRepo = class {
771
616
  setProxyUrl(value) {
772
617
  this.set(PROXY_URL_KEY, JSON.stringify(value));
773
618
  }
619
+ /**
620
+ * Shared read for the two attachment limits: the stored whole-MB number, clamped back into the
621
+ * legal range, or the built-in default when the row is absent or unreadable. Values only ever
622
+ * enter through the validated PUT, so the clamp is for a database that predates a change to the
623
+ * bounds (or was hand-edited) — using the nearest legal number is a better answer there than
624
+ * refusing to serve uploads at all.
625
+ */
626
+ getAttachmentMb(key, fallback) {
627
+ const raw = this.get(key);
628
+ if (raw === null) return fallback;
629
+ try {
630
+ const value = JSON.parse(raw);
631
+ return typeof value === "number" ? clampAttachmentMb(value, fallback) : fallback;
632
+ } catch {
633
+ return fallback;
634
+ }
635
+ }
636
+ /** Per-file composer attachment cap, in whole MB. */
637
+ getAttachmentMaxMb() {
638
+ return this.getAttachmentMb(ATTACHMENT_MAX_MB_KEY, DEFAULT_ATTACHMENT_MAX_MB);
639
+ }
640
+ setAttachmentMaxMb(value) {
641
+ this.set(ATTACHMENT_MAX_MB_KEY, JSON.stringify(value));
642
+ }
643
+ /** Per-message total attachment cap (decoded bytes), in whole MB. */
644
+ getAttachmentTotalMb() {
645
+ return this.getAttachmentMb(ATTACHMENT_TOTAL_MB_KEY, DEFAULT_ATTACHMENT_TOTAL_MB);
646
+ }
647
+ setAttachmentTotalMb(value) {
648
+ this.set(ATTACHMENT_TOTAL_MB_KEY, JSON.stringify(value));
649
+ }
650
+ /**
651
+ * The pair as one value — what the validators, the body cap and `/api/me` all read. Kept here
652
+ * so no caller has to remember that the two numbers are only meaningful together (the total is
653
+ * never below the per-file cap; the PUT enforces that against the effective post-write pair).
654
+ */
655
+ getAttachmentLimitsMb() {
656
+ return {
657
+ attachmentMaxMb: this.getAttachmentMaxMb(),
658
+ attachmentTotalMb: this.getAttachmentTotalMb()
659
+ };
660
+ }
774
661
  };
775
662
 
776
663
  // src/db/repos/sessions.ts
@@ -783,10 +670,14 @@ function mapRow4(r) {
783
670
  modelId: r.model_id,
784
671
  workspace: r.workspace,
785
672
  approvalMode: r.approval_mode,
673
+ thinkingLevel: r.thinking_level ?? null,
786
674
  title: r.title ?? null,
787
675
  archivedAt: r.archived_at ?? null,
788
676
  client: r.client ?? null,
789
677
  hasTrace: r.has_trace === 1,
678
+ // The open-time backfill leaves no NULLs; the coalesce only hardens against a row
679
+ // somehow inserted as NULL (degrades to createdAt instead of surfacing undefined).
680
+ lastActiveAt: r.last_active_at ?? r.created_at,
790
681
  createdAt: r.created_at
791
682
  };
792
683
  }
@@ -796,28 +687,47 @@ var SessionsRepo = class {
796
687
  }
797
688
  db;
798
689
  insert(row) {
799
- this.db.prepare(
800
- `INSERT INTO sessions (session_id, project_id, agent_id, provider, model_id, workspace, approval_mode, title, client, has_trace, created_at)
801
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
802
- ).run(
803
- row.sessionId,
804
- row.projectId,
805
- row.agentId,
806
- row.provider,
807
- row.modelId,
808
- row.workspace,
809
- row.approvalMode,
810
- row.title,
811
- row.client ?? null,
812
- row.hasTrace ? 1 : 0,
813
- row.createdAt
814
- );
690
+ this.runInsert("INSERT", row);
815
691
  }
816
692
  /** Idempotent insert: used when Trace directory discovery backfills a row (concurrent listing discovering the same Session no longer triggers a UNIQUE violation). */
817
693
  insertOrIgnore(row) {
694
+ this.runInsert("INSERT OR IGNORE", row);
695
+ }
696
+ /**
697
+ * Allocate the source Session's next persistent fork number and insert its fork atomically.
698
+ * Keeping both writes in one transaction prevents concurrent requests from sharing a number,
699
+ * while retaining `fork_count` on the source means deleting an older fork never reuses it.
700
+ */
701
+ insertFork(sourceSessionId, row) {
702
+ this.db.exec("BEGIN IMMEDIATE");
703
+ try {
704
+ const source = this.db.prepare(
705
+ `UPDATE sessions SET fork_count = fork_count + 1 WHERE session_id = ?
706
+ RETURNING title, fork_count`
707
+ ).get(sourceSessionId);
708
+ if (!source) throw new Error(`Source Session not found: ${sourceSessionId}`);
709
+ const forkTitle = source.title ? `${source.title} (${source.fork_count})` : `(${source.fork_count})`;
710
+ const forkRow = { ...row, title: forkTitle };
711
+ this.runInsert("INSERT", forkRow);
712
+ this.db.exec("COMMIT");
713
+ return forkRow;
714
+ } catch (err) {
715
+ this.db.exec("ROLLBACK");
716
+ throw err;
717
+ }
718
+ }
719
+ /**
720
+ * The two inserts' shared column list and binding order (they differ only in conflict
721
+ * handling). `last_active_at` defaults **in SQL** (`COALESCE(?, ?)` over the row's own
722
+ * created_at, bound twice — SQLite forbids referencing a sibling column inside VALUES,
723
+ * and a column added by ALTER TABLE cannot be given a DEFAULT): the column is nullable
724
+ * for legacy reasons, so this is the one place that can guarantee it is never written
725
+ * NULL, whatever a caller passes.
726
+ */
727
+ runInsert(verb, row) {
818
728
  this.db.prepare(
819
- `INSERT OR IGNORE INTO sessions (session_id, project_id, agent_id, provider, model_id, workspace, approval_mode, title, client, has_trace, created_at)
820
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
729
+ `${verb} INTO sessions (session_id, project_id, agent_id, provider, model_id, workspace, approval_mode, title, client, has_trace, last_active_at, created_at)
730
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE(?, ?), ?)`
821
731
  ).run(
822
732
  row.sessionId,
823
733
  row.projectId,
@@ -829,13 +739,34 @@ var SessionsRepo = class {
829
739
  row.title,
830
740
  row.client ?? null,
831
741
  row.hasTrace ? 1 : 0,
742
+ row.lastActiveAt ?? null,
743
+ row.createdAt,
832
744
  row.createdAt
833
745
  );
834
746
  }
835
- /** Flip the has_trace cache once a Trace record exists (task start / discovery hydration); idempotent. */
747
+ /** Flip the has_trace cache once a Trace record exists (discovery hydration); idempotent. */
836
748
  markHasTrace(sessionId) {
837
749
  this.db.prepare("UPDATE sessions SET has_trace = 1 WHERE session_id = ?").run(sessionId);
838
750
  }
751
+ /**
752
+ * A driven run touched this Session: flip the has_trace cache and stamp last_active_at in
753
+ * ONE statement (drive's run start — one WAL commit instead of two).
754
+ * `MAX(COALESCE(...))` keeps the stamp monotonic: a backwards clock step (NTP, resume
755
+ * from suspend) leaves the stored value alone rather than regressing the row.
756
+ */
757
+ markDriven(sessionId, at) {
758
+ this.db.prepare(
759
+ `UPDATE sessions SET has_trace = 1, last_active_at = MAX(COALESCE(last_active_at, ''), ?)
760
+ WHERE session_id = ?`
761
+ ).run(at, sessionId);
762
+ }
763
+ /** Stamp last_active_at alone (drive's run end); monotonic like markDriven. */
764
+ touchLastActive(sessionId, at) {
765
+ this.db.prepare(
766
+ `UPDATE sessions SET last_active_at = MAX(COALESCE(last_active_at, ''), ?)
767
+ WHERE session_id = ?`
768
+ ).run(at, sessionId);
769
+ }
839
770
  findById(sessionId) {
840
771
  const r = this.db.prepare("SELECT * FROM sessions WHERE session_id = ?").get(sessionId);
841
772
  return r ? mapRow4(r) : null;
@@ -860,6 +791,10 @@ var SessionsRepo = class {
860
791
  updateApprovalMode(sessionId, mode) {
861
792
  this.db.prepare("UPDATE sessions SET approval_mode = ? WHERE session_id = ?").run(mode, sessionId);
862
793
  }
794
+ /** Pin the session's thinking level (runs without one of their own then use it). */
795
+ updateThinkingLevel(sessionId, level) {
796
+ this.db.prepare("UPDATE sessions SET thinking_level = ? WHERE session_id = ?").run(level, sessionId);
797
+ }
863
798
  updateTitle(sessionId, title) {
864
799
  this.db.prepare("UPDATE sessions SET title = ? WHERE session_id = ?").run(title, sessionId);
865
800
  }
@@ -1199,92 +1134,8 @@ var UsageRepo = class {
1199
1134
  }
1200
1135
  };
1201
1136
 
1202
- // src/db/repos/users.ts
1203
- function mapRow5(r) {
1204
- return {
1205
- userId: r.user_id,
1206
- passwordHash: r.password_hash,
1207
- isAdmin: r.is_admin === 1,
1208
- passwordIsInitial: r.password_is_initial === 1,
1209
- createdAt: r.created_at
1210
- };
1211
- }
1212
- var UsersRepo = class {
1213
- constructor(db) {
1214
- this.db = db;
1215
- }
1216
- db;
1217
- insert(row) {
1218
- this.db.prepare(
1219
- "INSERT INTO users (user_id, password_hash, is_admin, password_is_initial, created_at) VALUES (?, ?, ?, ?, ?)"
1220
- ).run(
1221
- row.userId,
1222
- row.passwordHash,
1223
- row.isAdmin ? 1 : 0,
1224
- row.passwordIsInitial ? 1 : 0,
1225
- row.createdAt
1226
- );
1227
- }
1228
- findById(userId) {
1229
- const r = this.db.prepare("SELECT * FROM users WHERE user_id = ?").get(userId);
1230
- return r ? mapRow5(r) : null;
1231
- }
1232
- /** All users (for the admin user backend), ordered by creation time ascending. */
1233
- list() {
1234
- const rows = this.db.prepare("SELECT * FROM users ORDER BY created_at ASC, user_id ASC").all();
1235
- return rows.map(mapRow5);
1236
- }
1237
- count() {
1238
- const r = this.db.prepare("SELECT COUNT(*) AS n FROM users").get();
1239
- return r?.n ?? 0;
1240
- }
1241
- /** Update the password hash; isInitial marks whether the password was set by someone else (seed / admin). */
1242
- updatePassword(userId, passwordHash, isInitial) {
1243
- this.db.prepare("UPDATE users SET password_hash = ?, password_is_initial = ? WHERE user_id = ?").run(passwordHash, isInitial ? 1 : 0, userId);
1244
- }
1245
- /** Used by admin user deletion and account-creation compensation paths (owned Projects must be cleaned up first). */
1246
- delete(userId) {
1247
- this.db.prepare("DELETE FROM users WHERE user_id = ?").run(userId);
1248
- }
1249
- };
1250
-
1251
1137
  // src/auth/middleware.ts
1252
1138
  import { getCookie } from "hono/cookie";
1253
-
1254
- // src/http/errors.ts
1255
- var HttpError = class extends Error {
1256
- constructor(status, code, message) {
1257
- super(message);
1258
- this.status = status;
1259
- this.code = code;
1260
- this.name = "HttpError";
1261
- }
1262
- status;
1263
- code;
1264
- };
1265
- function errorBody(code, message) {
1266
- return { error: { code, message } };
1267
- }
1268
- function isMissingCredential(err) {
1269
- const message = err instanceof Error ? err.message : String(err);
1270
- return /missing credentials|api[_ ]?key/i.test(message);
1271
- }
1272
- function modelCredentialMissing(modelId) {
1273
- return new HttpError(
1274
- 400,
1275
- "model_credential_missing",
1276
- `Model ${modelId} has no API key yet. Configure it on the Models page first.`
1277
- );
1278
- }
1279
- function handleError(err, c) {
1280
- if (err instanceof HttpError) {
1281
- return c.json(errorBody(err.code, err.message), err.status);
1282
- }
1283
- console.error(`[server] Unhandled exception: ${err.stack ?? err.message}`);
1284
- return c.json(errorBody("internal", "Internal server error."), 500);
1285
- }
1286
-
1287
- // src/auth/middleware.ts
1288
1139
  var SESSION_COOKIE = "penguin_session";
1289
1140
  function authMiddleware(auth) {
1290
1141
  return async (c, next) => {
@@ -1299,253 +1150,25 @@ function authMiddleware(auth) {
1299
1150
  };
1300
1151
  }
1301
1152
  var WRITE_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
1153
+ var ALLOWED_WRITE_CONTENT_TYPES = [
1154
+ "application/json",
1155
+ "application/gzip",
1156
+ "application/octet-stream"
1157
+ ];
1302
1158
  var jsonOnlyWrites = async (c, next) => {
1303
1159
  if (WRITE_METHODS.has(c.req.method)) {
1304
- const contentType = c.req.header("content-type");
1305
- if (contentType && !contentType.toLowerCase().startsWith("application/json")) {
1160
+ const contentType = c.req.header("content-type")?.toLowerCase();
1161
+ if (contentType && !ALLOWED_WRITE_CONTENT_TYPES.some((t) => contentType.startsWith(t))) {
1306
1162
  throw new HttpError(
1307
1163
  415,
1308
1164
  "unsupported_media_type",
1309
- "Write requests only accept application/json."
1165
+ "Write requests only accept application/json (or, for the hot-update web push, a gzip artifact)."
1310
1166
  );
1311
1167
  }
1312
1168
  }
1313
1169
  await next();
1314
1170
  };
1315
1171
 
1316
- // src/auth/service.ts
1317
- import { createHash, randomBytes as randomBytes2, randomInt } from "crypto";
1318
-
1319
- // src/auth/password.ts
1320
- import { randomBytes, scrypt, timingSafeEqual } from "crypto";
1321
- var SCRYPT_N = 16384;
1322
- var SCRYPT_R = 8;
1323
- var SCRYPT_P = 1;
1324
- var SALT_BYTES = 16;
1325
- var KEY_BYTES = 64;
1326
- function scryptAsync(password, salt, keyLen, n, r, p) {
1327
- return new Promise((resolve, reject) => {
1328
- scrypt(password, salt, keyLen, { N: n, r, p, maxmem: 128 * 1024 * 1024 }, (err, key) => {
1329
- if (err) reject(err);
1330
- else resolve(key);
1331
- });
1332
- });
1333
- }
1334
- async function hashPassword(password) {
1335
- const salt = randomBytes(SALT_BYTES);
1336
- const key = await scryptAsync(password, salt, KEY_BYTES, SCRYPT_N, SCRYPT_R, SCRYPT_P);
1337
- return [
1338
- "scrypt",
1339
- String(SCRYPT_N),
1340
- String(SCRYPT_R),
1341
- String(SCRYPT_P),
1342
- salt.toString("base64"),
1343
- key.toString("base64")
1344
- ].join("$");
1345
- }
1346
- async function verifyPassword(password, stored) {
1347
- const parts = stored.split("$");
1348
- if (parts.length !== 6 || parts[0] !== "scrypt") return false;
1349
- const n = Number(parts[1]);
1350
- const r = Number(parts[2]);
1351
- const p = Number(parts[3]);
1352
- if (!Number.isInteger(n) || !Number.isInteger(r) || !Number.isInteger(p)) return false;
1353
- let salt;
1354
- let expected;
1355
- try {
1356
- salt = Buffer.from(parts[4], "base64");
1357
- expected = Buffer.from(parts[5], "base64");
1358
- } catch {
1359
- return false;
1360
- }
1361
- if (salt.length === 0 || expected.length === 0) return false;
1362
- let actual;
1363
- try {
1364
- actual = await scryptAsync(password, salt, expected.length, n, r, p);
1365
- } catch {
1366
- return false;
1367
- }
1368
- return actual.length === expected.length && timingSafeEqual(actual, expected);
1369
- }
1370
-
1371
- // src/auth/service.ts
1372
- var MIN_PASSWORD_LENGTH = 8;
1373
- var ADMIN_USER_ID = "admin";
1374
- var LOGIN_FREE_ATTEMPTS = 5;
1375
- var LOGIN_BACKOFF_START_MS = 1e3;
1376
- var LOGIN_BACKOFF_CAP_MS = 6e4;
1377
- var LOGIN_FAILURE_IDLE_MS = 15 * 6e4;
1378
- function generateInitialAdminPassword() {
1379
- return "penguin-" + String(randomInt(0, 1e4)).padStart(4, "0");
1380
- }
1381
- function sha256Hex(value) {
1382
- return createHash("sha256").update(value).digest("hex");
1383
- }
1384
- function toUserInfo(row) {
1385
- return {
1386
- userId: row.userId,
1387
- isAdmin: row.isAdmin,
1388
- passwordIsInitial: row.passwordIsInitial,
1389
- createdAt: row.createdAt
1390
- };
1391
- }
1392
- var AuthService = class {
1393
- constructor(deps2) {
1394
- this.deps = deps2;
1395
- this.now = deps2.now ?? (() => /* @__PURE__ */ new Date());
1396
- }
1397
- deps;
1398
- now;
1399
- /**
1400
- * Startup seeding (idempotent): creates the built-in admin and adopts
1401
- * default_project when the users table is empty; if the initial Project fails,
1402
- * the user row is rolled back and the server retries on next startup.
1403
- * Returns the initial password when it actually seeded — the caller prints it,
1404
- * the only place a generated password is ever shown — and null when users
1405
- * already exist.
1406
- */
1407
- async seedAdmin() {
1408
- if (this.deps.users.count() > 0) return null;
1409
- const password = this.deps.seedAdminPassword ?? generateInitialAdminPassword();
1410
- if (password.length < MIN_PASSWORD_LENGTH) {
1411
- throw new Error(
1412
- `PENGUIN_SEED_ADMIN_PASSWORD must be at least ${MIN_PASSWORD_LENGTH} characters.`
1413
- );
1414
- }
1415
- const user = {
1416
- userId: ADMIN_USER_ID,
1417
- passwordHash: await hashPassword(password),
1418
- isAdmin: true,
1419
- passwordIsInitial: true,
1420
- createdAt: this.now().toISOString()
1421
- };
1422
- this.deps.users.insert(user);
1423
- try {
1424
- await this.deps.provisionInitialProject(user, true);
1425
- } catch (err) {
1426
- this.deps.users.delete(user.userId);
1427
- throw err;
1428
- }
1429
- return password;
1430
- }
1431
- /** Whether the built-in admin still runs on its initial password (drives the startup reminder notice). */
1432
- adminPasswordIsInitial() {
1433
- return this.deps.users.findById(ADMIN_USER_ID)?.passwordIsInitial === true;
1434
- }
1435
- /** Consecutive login failures per userId (see the throttling comment on the constants). */
1436
- loginFailures = /* @__PURE__ */ new Map();
1437
- /** The wait imposed after `failures` consecutive failures (0 while within the free attempts). */
1438
- loginDelayMs(failures) {
1439
- const excess = failures - LOGIN_FREE_ATTEMPTS;
1440
- if (excess <= 0) return 0;
1441
- return Math.min(LOGIN_BACKOFF_START_MS * 2 ** (excess - 1), LOGIN_BACKOFF_CAP_MS);
1442
- }
1443
- async login(userId, password) {
1444
- const nowMs = this.now().getTime();
1445
- for (const [key, entry] of this.loginFailures) {
1446
- if (nowMs - entry.lastFailureAt > LOGIN_FAILURE_IDLE_MS) this.loginFailures.delete(key);
1447
- }
1448
- const failed = this.loginFailures.get(userId);
1449
- if (failed) {
1450
- const readyAt = failed.lastFailureAt + this.loginDelayMs(failed.failures);
1451
- if (nowMs < readyAt) {
1452
- throw new HttpError(
1453
- 429,
1454
- "too_many_attempts",
1455
- `Too many failed sign-in attempts. Try again in ${Math.ceil((readyAt - nowMs) / 1e3)}s.`
1456
- );
1457
- }
1458
- }
1459
- const row = this.deps.users.findById(userId);
1460
- const ok = row !== null && await verifyPassword(password, row.passwordHash);
1461
- if (!row || !ok) {
1462
- this.loginFailures.set(userId, {
1463
- failures: (failed?.failures ?? 0) + 1,
1464
- lastFailureAt: this.now().getTime()
1465
- });
1466
- throw new HttpError(401, "invalid_credentials", "Incorrect username or password.");
1467
- }
1468
- this.loginFailures.delete(userId);
1469
- this.deps.authSessions.deleteExpired(this.now().toISOString());
1470
- return { user: toUserInfo(row), token: this.issueSession(row.userId, "password") };
1471
- }
1472
- /**
1473
- * Desktop-mode sign-in: issues an admin session WITHOUT a password check — the caller
1474
- * (the desktop-login route) has already redeemed the shell's one-shot token, which is
1475
- * the credential here. Throws if the admin has not been seeded yet (desktop-login runs
1476
- * after startup seeding, so this only trips on a broken deployment).
1477
- */
1478
- loginDesktop() {
1479
- const row = this.deps.users.findById(ADMIN_USER_ID);
1480
- if (!row) {
1481
- throw new HttpError(500, "internal", "Built-in admin has not been seeded.");
1482
- }
1483
- this.deps.authSessions.deleteExpired(this.now().toISOString());
1484
- return { user: toUserInfo(row), token: this.issueSession(row.userId, "desktop") };
1485
- }
1486
- /** Self password change (user settings): validates the old password, and on success clears the initial-password flag; the current session remains valid. */
1487
- async changePassword(userId, oldPassword, newPassword) {
1488
- const row = this.deps.users.findById(userId);
1489
- if (!row || !await verifyPassword(oldPassword, row.passwordHash)) {
1490
- throw new HttpError(400, "password_mismatch", "Current password is incorrect.");
1491
- }
1492
- if (newPassword.length < MIN_PASSWORD_LENGTH) {
1493
- throw new HttpError(400, "invalid_password", "Password must be at least 8 characters.");
1494
- }
1495
- this.deps.users.updatePassword(userId, await hashPassword(newPassword), false);
1496
- this.deps.onPasswordChanged?.(userId);
1497
- }
1498
- /**
1499
- * Desktop-session password set: no old-password check. Only reachable for sessions
1500
- * established via desktop-login (the me route gates on sessionVia) — the seed password
1501
- * of a desktop-created root is random and never shown, so its holder has nothing to
1502
- * type into an old-password field; the shell's token already proved machine ownership.
1503
- */
1504
- async setPasswordDesktop(userId, newPassword) {
1505
- if (newPassword.length < MIN_PASSWORD_LENGTH) {
1506
- throw new HttpError(400, "invalid_password", "Password must be at least 8 characters.");
1507
- }
1508
- this.deps.users.updatePassword(userId, await hashPassword(newPassword), false);
1509
- this.deps.onPasswordChanged?.(userId);
1510
- }
1511
- logout(token) {
1512
- this.deps.authSessions.delete(sha256Hex(token));
1513
- }
1514
- /** Validates the cookie token: returns null if expired/unknown; sliding renewal once less than 6 days remain. */
1515
- authenticateWithMeta(token) {
1516
- const tokenHash = sha256Hex(token);
1517
- const session = this.deps.authSessions.findByTokenHash(tokenHash);
1518
- if (!session) return null;
1519
- const now = this.now();
1520
- const expiresAt = Date.parse(session.expiresAt);
1521
- if (!(expiresAt > now.getTime())) {
1522
- this.deps.authSessions.delete(tokenHash);
1523
- return null;
1524
- }
1525
- if (expiresAt - now.getTime() < this.deps.sessionRenewMs) {
1526
- this.deps.authSessions.touch(
1527
- tokenHash,
1528
- new Date(now.getTime() + this.deps.sessionTtlMs).toISOString()
1529
- );
1530
- }
1531
- const user = this.deps.users.findById(session.userId);
1532
- if (!user) return null;
1533
- return { user, via: session.via === "desktop" ? "desktop" : "password" };
1534
- }
1535
- issueSession(userId, via) {
1536
- const token = randomBytes2(32).toString("base64url");
1537
- const now = this.now();
1538
- this.deps.authSessions.insert({
1539
- tokenHash: sha256Hex(token),
1540
- userId,
1541
- createdAt: now.toISOString(),
1542
- expiresAt: new Date(now.getTime() + this.deps.sessionTtlMs).toISOString(),
1543
- via
1544
- });
1545
- return token;
1546
- }
1547
- };
1548
-
1549
1172
  // src/http/routes/admin.ts
1550
1173
  import { Hono as Hono2 } from "hono";
1551
1174
 
@@ -1749,7 +1372,17 @@ function parseProxyUrl(value) {
1749
1372
  throw new HttpError(
1750
1373
  400,
1751
1374
  "invalid_proxy_url",
1752
- "proxyUrl must be http://host[:port], https://host[:port], or host[:port] (empty or null clears it)."
1375
+ "proxyUrl must be a proxy URL undici supports \u2014 http(s)://host[:port] or socks5://host[:port] \u2014 or bare host[:port] (empty or null clears it)."
1376
+ );
1377
+ }
1378
+ function parseAttachmentMb(value, field) {
1379
+ if (typeof value === "number" && Number.isInteger(value)) {
1380
+ if (value >= MIN_ATTACHMENT_MB && value <= MAX_ATTACHMENT_MB) return value;
1381
+ }
1382
+ throw new HttpError(
1383
+ 400,
1384
+ "invalid_attachment_limit",
1385
+ `${field} must be a whole number of MB between ${MIN_ATTACHMENT_MB} and ${MAX_ATTACHMENT_MB}.`
1753
1386
  );
1754
1387
  }
1755
1388
  function adminSettingsRoutes(deps2) {
@@ -1764,7 +1397,8 @@ function adminSettingsRoutes(deps2) {
1764
1397
  settings: {
1765
1398
  proxyForApp: deps2.serverSettingsRepo.getProxyForApp(),
1766
1399
  proxyForAgent: deps2.serverSettingsRepo.getProxyForAgent(),
1767
- proxyUrl: deps2.serverSettingsRepo.getProxyUrl()
1400
+ proxyUrl: deps2.serverSettingsRepo.getProxyUrl(),
1401
+ ...deps2.serverSettingsRepo.getAttachmentLimitsMb()
1768
1402
  }
1769
1403
  });
1770
1404
  app2.get("/", (c) => c.json(settings()));
@@ -1774,9 +1408,25 @@ function adminSettingsRoutes(deps2) {
1774
1408
  const proxyForAgent = optionalBoolean(body, "proxyForAgent");
1775
1409
  const proxyUrlProvided = body.proxyUrl !== void 0;
1776
1410
  const proxyUrl = proxyUrlProvided ? parseProxyUrl(body.proxyUrl) : null;
1411
+ const attachmentMaxMb = body.attachmentMaxMb === void 0 ? void 0 : parseAttachmentMb(body.attachmentMaxMb, "attachmentMaxMb");
1412
+ const attachmentTotalMb = body.attachmentTotalMb === void 0 ? void 0 : parseAttachmentMb(body.attachmentTotalMb, "attachmentTotalMb");
1413
+ const stored = deps2.serverSettingsRepo.getAttachmentLimitsMb();
1414
+ const effectiveMax = attachmentMaxMb ?? stored.attachmentMaxMb;
1415
+ const effectiveTotal = attachmentTotalMb ?? stored.attachmentTotalMb;
1416
+ if (effectiveTotal < effectiveMax) {
1417
+ throw new HttpError(
1418
+ 400,
1419
+ "invalid_attachment_limit",
1420
+ `attachmentTotalMb (${effectiveTotal}) must not be below attachmentMaxMb (${effectiveMax}).`
1421
+ );
1422
+ }
1777
1423
  if (proxyForApp !== void 0) deps2.serverSettingsRepo.setProxyForApp(proxyForApp);
1778
1424
  if (proxyForAgent !== void 0) deps2.serverSettingsRepo.setProxyForAgent(proxyForAgent);
1779
1425
  if (proxyUrlProvided) deps2.serverSettingsRepo.setProxyUrl(proxyUrl);
1426
+ if (attachmentMaxMb !== void 0) deps2.serverSettingsRepo.setAttachmentMaxMb(attachmentMaxMb);
1427
+ if (attachmentTotalMb !== void 0) {
1428
+ deps2.serverSettingsRepo.setAttachmentTotalMb(attachmentTotalMb);
1429
+ }
1780
1430
  applyProxySettings({
1781
1431
  proxyForApp: deps2.serverSettingsRepo.getProxyForApp(),
1782
1432
  proxyUrl: deps2.serverSettingsRepo.getProxyUrl()
@@ -1833,12 +1483,12 @@ function authRoutes(deps2) {
1833
1483
  import { Hono as Hono5 } from "hono";
1834
1484
 
1835
1485
  // src/services/preview-token.ts
1836
- import { createHmac, randomBytes as randomBytes3, timingSafeEqual as timingSafeEqual2 } from "crypto";
1486
+ import { createHmac, randomBytes, timingSafeEqual } from "crypto";
1837
1487
  var PREVIEW_TOKEN_TTL_MS = 10 * 60 * 1e3;
1838
1488
  function b64url(buf) {
1839
1489
  return buf.toString("base64url");
1840
1490
  }
1841
- function createPreviewTokenSigner(secret = randomBytes3(32)) {
1491
+ function createPreviewTokenSigner(secret = randomBytes(32)) {
1842
1492
  const mac = (body) => createHmac("sha256", secret).update(body).digest();
1843
1493
  return {
1844
1494
  sign(payload) {
@@ -1851,7 +1501,7 @@ function createPreviewTokenSigner(secret = randomBytes3(32)) {
1851
1501
  const body = token.slice(0, dot);
1852
1502
  const provided = Buffer.from(token.slice(dot + 1), "base64url");
1853
1503
  const expected = mac(body);
1854
- if (provided.length !== expected.length || !timingSafeEqual2(provided, expected)) return null;
1504
+ if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) return null;
1855
1505
  let payload;
1856
1506
  try {
1857
1507
  payload = JSON.parse(
@@ -1937,7 +1587,14 @@ function meRoutes(deps2) {
1937
1587
  user: toUserInfo(c.var.user),
1938
1588
  previewIsolated: target !== null,
1939
1589
  desktopMode: deps2.desktop !== null,
1940
- sessionVia: c.var.sessionVia
1590
+ sessionVia: c.var.sessionVia,
1591
+ uploadLimits: {
1592
+ ...deps2.serverSettingsRepo.getAttachmentLimitsMb(),
1593
+ attachmentMaxCount: MAX_ATTACHMENT_COUNT,
1594
+ imageMaxMb: INLINE_IMAGE_MAX_MB,
1595
+ attachmentLimitMinMb: MIN_ATTACHMENT_MB,
1596
+ attachmentLimitMaxMb: MAX_ATTACHMENT_MB
1597
+ }
1941
1598
  });
1942
1599
  });
1943
1600
  app2.put("/password", async (c) => {
@@ -2123,6 +1780,154 @@ function membersRoutes(deps2) {
2123
1780
 
2124
1781
  // src/http/routes/models.ts
2125
1782
  import { Hono as Hono9 } from "hono";
1783
+
1784
+ // src/services/protocol-detect.ts
1785
+ import { resolveModelEnv } from "@prismshadow/penguin-core";
1786
+ var PROBE_TIMEOUT_MS = 5e3;
1787
+ function bearerHeaders(apiKey) {
1788
+ return {
1789
+ "content-type": "application/json",
1790
+ ...apiKey ? { authorization: `Bearer ${apiKey}` } : {}
1791
+ };
1792
+ }
1793
+ function anthropicHeaders(apiKey) {
1794
+ return {
1795
+ "content-type": "application/json",
1796
+ "anthropic-version": "2023-06-01",
1797
+ ...apiKey ? { authorization: `Bearer ${apiKey}`, "x-api-key": apiKey } : {}
1798
+ };
1799
+ }
1800
+ var PROTOCOL_PROBES = [
1801
+ { clientType: "openai-responses", path: "/responses", headers: bearerHeaders },
1802
+ { clientType: "ant-messages", path: "/v1/messages", headers: anthropicHeaders },
1803
+ { clientType: "openai-chat", path: "/chat/completions", headers: bearerHeaders }
1804
+ ];
1805
+ function envApiKeyForProtocol(clientType, env = process.env) {
1806
+ const envKey = resolveModelEnv("", clientType)?.envKey;
1807
+ if (!envKey) return void 0;
1808
+ return env[envKey]?.trim() || void 0;
1809
+ }
1810
+ function isHttpUrl(value) {
1811
+ try {
1812
+ const u = new URL(value);
1813
+ return u.protocol === "http:" || u.protocol === "https:";
1814
+ } catch {
1815
+ return false;
1816
+ }
1817
+ }
1818
+ function probeUrl(baseUrl, path20) {
1819
+ return baseUrl.trim().replace(/\/+$/, "") + path20;
1820
+ }
1821
+ function isApiErrorShape(body) {
1822
+ const err = body.error;
1823
+ if (err !== null && typeof err === "object") return true;
1824
+ if (typeof err === "string" && err !== "") return true;
1825
+ if (body.type === "error") return true;
1826
+ if (body.object === "error") return true;
1827
+ if (typeof body.message === "string" && body.message !== "") return true;
1828
+ const detail = body.detail;
1829
+ if (typeof detail === "string") return detail !== "";
1830
+ if (detail !== null && (typeof detail === "object" || typeof detail === "number")) return true;
1831
+ return false;
1832
+ }
1833
+ function isSuccessShape(clientType, body) {
1834
+ switch (clientType) {
1835
+ case "openai-responses":
1836
+ return body.object === "response" || Array.isArray(body.output);
1837
+ case "ant-messages":
1838
+ return body.type === "message" || Array.isArray(body.content) && "role" in body;
1839
+ case "openai-chat":
1840
+ return body.object === "chat.completion" || Array.isArray(body.choices);
1841
+ }
1842
+ }
1843
+ function classifyProbeResponse(clientType, status, bodyText) {
1844
+ if (status === 404 || status === 405) return "route_missing";
1845
+ let parsed;
1846
+ try {
1847
+ parsed = JSON.parse(bodyText);
1848
+ } catch {
1849
+ return "junk";
1850
+ }
1851
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return "junk";
1852
+ const body = parsed;
1853
+ if (status >= 200 && status < 300) return isSuccessShape(clientType, body) ? "served" : "junk";
1854
+ if (status >= 500) return "server_error";
1855
+ return isApiErrorShape(body) ? "served" : "junk";
1856
+ }
1857
+ function isTimeoutError(err) {
1858
+ return err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError");
1859
+ }
1860
+ var MAX_PROBE_BODY_BYTES = 64 * 1024;
1861
+ async function readCappedBody(res) {
1862
+ const body = res.body;
1863
+ if (!body) return { text: await res.text(), truncated: false };
1864
+ const reader = body.getReader();
1865
+ const chunks = [];
1866
+ let size = 0;
1867
+ let truncated = false;
1868
+ try {
1869
+ for (; ; ) {
1870
+ const { done, value } = await reader.read();
1871
+ if (done) break;
1872
+ if (!value) continue;
1873
+ size += value.byteLength;
1874
+ if (size > MAX_PROBE_BODY_BYTES) {
1875
+ truncated = true;
1876
+ break;
1877
+ }
1878
+ chunks.push(value);
1879
+ }
1880
+ } finally {
1881
+ await reader.cancel().catch(() => {
1882
+ });
1883
+ }
1884
+ return { text: truncated ? "" : Buffer.concat(chunks).toString("utf8"), truncated };
1885
+ }
1886
+ async function runProbe(spec, baseUrl, apiKey, fetchImpl, timeoutMs) {
1887
+ const url = probeUrl(baseUrl, spec.path);
1888
+ try {
1889
+ const res = await fetchImpl(url, {
1890
+ method: "POST",
1891
+ headers: spec.headers(apiKey),
1892
+ body: "{}",
1893
+ signal: AbortSignal.timeout(timeoutMs)
1894
+ // Redirects stay followed (fetch's default). It is tempting to pin `redirect: "manual"`
1895
+ // so a base URL cannot bounce the server somewhere it never named, but the SDKs the
1896
+ // real clients wrap follow redirects too — pinning it would report "no protocol" for
1897
+ // the ordinary case of a base URL that 301s http -> https, i.e. break the invariant
1898
+ // that a detected protocol is one that actually works after saving. The probe reply is
1899
+ // reduced to an outcome enum plus a status and is never echoed, so a followed redirect
1900
+ // leaks no response content.
1901
+ });
1902
+ const { text, truncated } = await readCappedBody(res);
1903
+ return {
1904
+ clientType: spec.clientType,
1905
+ url,
1906
+ outcome: truncated ? "junk" : classifyProbeResponse(spec.clientType, res.status, text),
1907
+ status: res.status
1908
+ };
1909
+ } catch (err) {
1910
+ return {
1911
+ clientType: spec.clientType,
1912
+ url,
1913
+ outcome: isTimeoutError(err) ? "timeout" : "network_error"
1914
+ };
1915
+ }
1916
+ }
1917
+ async function detectModelProtocol(options) {
1918
+ const fetchImpl = options.fetchImpl ?? fetch;
1919
+ const timeoutMs = options.timeoutMs ?? PROBE_TIMEOUT_MS;
1920
+ const probes = [];
1921
+ for (const spec of PROTOCOL_PROBES) {
1922
+ const apiKey = options.apiKey ?? envApiKeyForProtocol(spec.clientType, options.env);
1923
+ const probe = await runProbe(spec, options.baseUrl, apiKey, fetchImpl, timeoutMs);
1924
+ probes.push(probe);
1925
+ if (probe.outcome === "served") return { detected: spec.clientType, probes };
1926
+ }
1927
+ return { probes };
1928
+ }
1929
+
1930
+ // src/http/routes/models.ts
2126
1931
  function publishCredentialsUpdated(deps2, projectId) {
2127
1932
  const event = { type: "credentials_updated" };
2128
1933
  for (const row of deps2.sessionsRepo.listByProject(projectId)) {
@@ -2191,6 +1996,12 @@ function parseModelsUpdate(body) {
2191
1996
  }
2192
1997
  entry.maxTokens = m.maxTokens;
2193
1998
  }
1999
+ if (m.fastMode !== void 0) {
2000
+ if (typeof m.fastMode !== "boolean") {
2001
+ throw badRequest(`models[${i}].fastMode must be a boolean.`);
2002
+ }
2003
+ entry.fastMode = m.fastMode;
2004
+ }
2194
2005
  if (m.pricing !== void 0) {
2195
2006
  const p = m.pricing;
2196
2007
  if (p === null || typeof p !== "object" || Array.isArray(p)) {
@@ -2253,18 +2064,76 @@ function modelsRoutes(deps2) {
2253
2064
  publishCredentialsUpdated(deps2, projectId);
2254
2065
  return c.json(res);
2255
2066
  });
2256
- app2.put("/default", async (c) => {
2067
+ app2.put("/default", async (c) => {
2068
+ const projectId = requireValidId(c, "projectId");
2069
+ deps2.projectService.requireProjectOwner(c.var.user.userId, projectId);
2070
+ const body = await readJson(c);
2071
+ const ref = {
2072
+ provider: requireString(body, "provider", { minLen: 1, maxLen: 64 }),
2073
+ modelId: requireString(body, "modelId", { minLen: 1, maxLen: 200 })
2074
+ };
2075
+ const defaultModel = await deps2.projectConfigService.setDefaultModelRef(projectId, ref);
2076
+ return c.json({ defaultModel });
2077
+ });
2078
+ app2.post("/test", async (c) => {
2079
+ const projectId = requireValidId(c, "projectId");
2080
+ deps2.projectService.requireProjectOwner(c.var.user.userId, projectId);
2081
+ const body = await readJson(c);
2082
+ const req = {
2083
+ provider: requireString(body, "provider", { minLen: 1, maxLen: 64 }),
2084
+ modelId: requireString(body, "modelId", { minLen: 1, maxLen: 200 })
2085
+ };
2086
+ if (body.apiKey !== void 0) {
2087
+ if (typeof body.apiKey !== "string") throw badRequest("apiKey must be a string.");
2088
+ if (body.apiKey) req.apiKey = body.apiKey;
2089
+ }
2090
+ if (body.clearApiKey !== void 0) {
2091
+ if (typeof body.clearApiKey !== "boolean") throw badRequest("clearApiKey must be a boolean.");
2092
+ req.clearApiKey = body.clearApiKey;
2093
+ }
2094
+ if (body.speed !== void 0) {
2095
+ if (typeof body.speed !== "boolean") throw badRequest("speed must be a boolean.");
2096
+ req.speed = body.speed;
2097
+ }
2098
+ if (body.baseUrl !== void 0) {
2099
+ if (body.baseUrl !== null && typeof body.baseUrl !== "string") {
2100
+ throw badRequest("baseUrl must be a string or null.");
2101
+ }
2102
+ req.baseUrl = body.baseUrl ? body.baseUrl : null;
2103
+ }
2104
+ if (body.clientType !== void 0) {
2105
+ if (typeof body.clientType !== "string") throw badRequest("clientType must be a string.");
2106
+ if (body.clientType) req.clientType = body.clientType;
2107
+ }
2108
+ if (body.fastMode !== void 0) {
2109
+ if (typeof body.fastMode !== "boolean") throw badRequest("fastMode must be a boolean.");
2110
+ req.fastMode = body.fastMode;
2111
+ }
2112
+ return c.json(await deps2.projectConfigService.testModel(projectId, req));
2113
+ });
2114
+ app2.post("/detect", async (c) => {
2257
2115
  const projectId = requireValidId(c, "projectId");
2258
2116
  deps2.projectService.requireProjectOwner(c.var.user.userId, projectId);
2259
2117
  const body = await readJson(c);
2260
- const ref = {
2261
- provider: requireString(body, "provider", { minLen: 1, maxLen: 64 }),
2262
- modelId: requireString(body, "modelId", { minLen: 1, maxLen: 200 })
2118
+ const req = {
2119
+ baseUrl: requireString(body, "baseUrl", { minLen: 1, maxLen: 2e3 })
2263
2120
  };
2264
- const defaultModel = await deps2.projectConfigService.setDefaultModelRef(projectId, ref);
2265
- return c.json({ defaultModel });
2121
+ if (!isHttpUrl(req.baseUrl)) throw badRequest("baseUrl must be an absolute http(s) URL.");
2122
+ if (body.apiKey !== void 0) {
2123
+ if (typeof body.apiKey !== "string") throw badRequest("apiKey must be a string.");
2124
+ if (body.apiKey) req.apiKey = body.apiKey;
2125
+ }
2126
+ if (body.clearApiKey !== void 0) {
2127
+ if (typeof body.clearApiKey !== "boolean") throw badRequest("clearApiKey must be a boolean.");
2128
+ req.clearApiKey = body.clearApiKey;
2129
+ }
2130
+ if (body.provider !== void 0 || body.modelId !== void 0) {
2131
+ req.provider = requireString(body, "provider", { minLen: 1, maxLen: 64 });
2132
+ req.modelId = requireString(body, "modelId", { minLen: 1, maxLen: 200 });
2133
+ }
2134
+ return c.json(await deps2.projectConfigService.detectProtocol(projectId, req));
2266
2135
  });
2267
- app2.post("/test", async (c) => {
2136
+ app2.post("/detect-vision", async (c) => {
2268
2137
  const projectId = requireValidId(c, "projectId");
2269
2138
  deps2.projectService.requireProjectOwner(c.var.user.userId, projectId);
2270
2139
  const body = await readJson(c);
@@ -2280,21 +2149,17 @@ function modelsRoutes(deps2) {
2280
2149
  if (typeof body.clearApiKey !== "boolean") throw badRequest("clearApiKey must be a boolean.");
2281
2150
  req.clearApiKey = body.clearApiKey;
2282
2151
  }
2283
- if (body.speed !== void 0) {
2284
- if (typeof body.speed !== "boolean") throw badRequest("speed must be a boolean.");
2285
- req.speed = body.speed;
2286
- }
2287
2152
  if (body.baseUrl !== void 0) {
2288
2153
  if (body.baseUrl !== null && typeof body.baseUrl !== "string") {
2289
2154
  throw badRequest("baseUrl must be a string or null.");
2290
2155
  }
2291
- req.baseUrl = body.baseUrl ? body.baseUrl : null;
2156
+ req.baseUrl = body.baseUrl;
2292
2157
  }
2293
2158
  if (body.clientType !== void 0) {
2294
2159
  if (typeof body.clientType !== "string") throw badRequest("clientType must be a string.");
2295
2160
  if (body.clientType) req.clientType = body.clientType;
2296
2161
  }
2297
- return c.json(await deps2.projectConfigService.testModel(projectId, req));
2162
+ return c.json(await deps2.projectConfigService.detectVision(projectId, req));
2298
2163
  });
2299
2164
  return app2;
2300
2165
  }
@@ -2305,7 +2170,7 @@ import { DEFAULT_CHAT_THINKING_LEVELS, isValidId as isValidId2 } from "@prismsha
2305
2170
 
2306
2171
  // src/http/routes/sessions.ts
2307
2172
  import fs4 from "fs/promises";
2308
- import path4 from "path";
2173
+ import path3 from "path";
2309
2174
  import { Hono as Hono10 } from "hono";
2310
2175
  import { imageUrlMessage, scratchpadDir, userText } from "@prismshadow/penguin-core";
2311
2176
 
@@ -2551,7 +2416,7 @@ async function assertWorkspaceAllowed(args) {
2551
2416
  // src/services/workspace-files-service.ts
2552
2417
  import fs2 from "fs/promises";
2553
2418
  import { constants as fsc } from "fs";
2554
- import path2 from "path";
2419
+ import path from "path";
2555
2420
  var MAX_READ_BYTES = 50 * 1024 * 1024;
2556
2421
  var MAX_UPLOAD_BYTES = 14 * 1024 * 1024;
2557
2422
  var CONTENT_TYPES = {
@@ -2583,7 +2448,7 @@ var WorkspaceFilesService = class {
2583
2448
  /** Canonical path (realpath) of the Workspace root; 404 if it doesn't exist. */
2584
2449
  async realBase(workspace) {
2585
2450
  try {
2586
- return await fs2.realpath(path2.resolve(workspace));
2451
+ return await fs2.realpath(path.resolve(workspace));
2587
2452
  } catch {
2588
2453
  throw new HttpError(404, "workspace_missing", "This Session's Workspace no longer exists.");
2589
2454
  }
@@ -2596,13 +2461,13 @@ var WorkspaceFilesService = class {
2596
2461
  * compared, so a legitimate name like "..foo" isn't mistakenly rejected.
2597
2462
  */
2598
2463
  isInside(target, base) {
2599
- const rel = path2.relative(base, target);
2600
- return rel !== ".." && !rel.startsWith(`..${path2.sep}`) && !path2.isAbsolute(rel);
2464
+ const rel = path.relative(base, target);
2465
+ return rel !== ".." && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel);
2601
2466
  }
2602
2467
  /** Lexical prefix check (a relative path, once resolved, must still be inside the Workspace); returns the absolute target path. */
2603
2468
  lexicalTarget(base, rel) {
2604
2469
  if (rel.includes("\0")) throw badRequest("path is invalid.");
2605
- const target = path2.resolve(base, rel === "" ? "." : rel);
2470
+ const target = path.resolve(base, rel === "" ? "." : rel);
2606
2471
  if (!this.isInside(target, base)) {
2607
2472
  throw badRequest("path must be inside the Workspace.");
2608
2473
  }
@@ -2622,7 +2487,7 @@ var WorkspaceFilesService = class {
2622
2487
  */
2623
2488
  async resolveRead(workspace, rel) {
2624
2489
  const realBase = await this.realBase(workspace);
2625
- const target = this.lexicalTarget(path2.resolve(workspace), rel);
2490
+ const target = this.lexicalTarget(path.resolve(workspace), rel);
2626
2491
  let canonical;
2627
2492
  try {
2628
2493
  canonical = await fs2.realpath(target);
@@ -2651,10 +2516,10 @@ var WorkspaceFilesService = class {
2651
2516
  */
2652
2517
  async resolveWriteParent(workspace, rel) {
2653
2518
  const realBase = await this.realBase(workspace);
2654
- const target = this.lexicalTarget(path2.resolve(workspace), rel);
2655
- const name = path2.basename(target);
2519
+ const target = this.lexicalTarget(path.resolve(workspace), rel);
2520
+ const name = path.basename(target);
2656
2521
  if (name === "" || name === "." || name === "..") throw badRequest("path must be a file path.");
2657
- const parent = path2.dirname(target);
2522
+ const parent = path.dirname(target);
2658
2523
  let canonicalParent;
2659
2524
  try {
2660
2525
  canonicalParent = await fs2.realpath(parent);
@@ -2667,7 +2532,7 @@ var WorkspaceFilesService = class {
2667
2532
  break;
2668
2533
  } catch (probeErr) {
2669
2534
  if (probeErr.code !== "ENOENT") throw probeErr;
2670
- const up = path2.dirname(probe);
2535
+ const up = path.dirname(probe);
2671
2536
  if (up === probe) throw badRequest("path is invalid.");
2672
2537
  probe = up;
2673
2538
  }
@@ -2723,7 +2588,7 @@ var WorkspaceFilesService = class {
2723
2588
  const listed = await Promise.all(
2724
2589
  dirents.map(async (d) => {
2725
2590
  try {
2726
- const canonical = await fs2.realpath(path2.join(dir, d.name));
2591
+ const canonical = await fs2.realpath(path.join(dir, d.name));
2727
2592
  this.assertInside(canonical, realBase);
2728
2593
  const stat = await fs2.stat(canonical);
2729
2594
  return {
@@ -2775,10 +2640,10 @@ var WorkspaceFilesService = class {
2775
2640
  } else {
2776
2641
  data = await fs2.readFile(file);
2777
2642
  }
2778
- const ext = path2.extname(file).toLowerCase();
2643
+ const ext = path.extname(file).toLowerCase();
2779
2644
  return {
2780
2645
  data,
2781
- fileName: path2.basename(file),
2646
+ fileName: path.basename(file),
2782
2647
  contentType: CONTENT_TYPES[ext] ?? "application/octet-stream",
2783
2648
  scriptable: ext === ".html" || ext === ".htm" || ext === ".svg",
2784
2649
  ...truncated ? { truncated: true } : {}
@@ -2798,7 +2663,7 @@ var WorkspaceFilesService = class {
2798
2663
  throw new HttpError(413, "file_too_large", "Uploaded file exceeds the 14MB limit.");
2799
2664
  }
2800
2665
  const { dir, name } = await this.resolveWriteParent(workspace, rel);
2801
- const file = path2.join(dir, name);
2666
+ const file = path.join(dir, name);
2802
2667
  if (process.platform === "win32") {
2803
2668
  const st = await fs2.lstat(file).catch(() => null);
2804
2669
  if (st?.isSymbolicLink()) throw badRequest("path must not be a symlink.");
@@ -2825,16 +2690,13 @@ var WorkspaceFilesService = class {
2825
2690
 
2826
2691
  // src/services/task-attachments.ts
2827
2692
  import fs3 from "fs/promises";
2828
- import path3 from "path";
2829
- import { randomBytes as randomBytes4 } from "crypto";
2693
+ import path2 from "path";
2694
+ import { randomBytes as randomBytes2 } from "crypto";
2830
2695
  import {
2831
2696
  appendAttachmentLines,
2832
2697
  attachedFileLine,
2833
2698
  modelVisiblePath
2834
2699
  } from "@prismshadow/penguin-core";
2835
- var MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
2836
- var MAX_ATTACHMENT_COUNT = 20;
2837
- var MAX_TOTAL_ATTACHMENT_BYTES = 12 * 1024 * 1024;
2838
2700
  var MAX_STEM_BYTES = 80;
2839
2701
  var WINDOWS_RESERVED = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i;
2840
2702
  var INVISIBLE_CHAR = new RegExp("\\p{C}", "u");
@@ -2855,7 +2717,7 @@ function truncateBytes(value, maxBytes) {
2855
2717
  return out;
2856
2718
  }
2857
2719
  var MAX_NAME_ATTEMPTS = 16;
2858
- function parseAttachmentPart(part, index, field = "input") {
2720
+ function parseAttachmentPart(part, index, limits, field = "input") {
2859
2721
  const fileName = part.fileName;
2860
2722
  if (typeof fileName !== "string" || fileName.length === 0 || fileName.includes("/") || fileName.includes("\\") || fileName.includes("..") || fileName.includes("\0")) {
2861
2723
  throw badRequest(
@@ -2863,55 +2725,69 @@ function parseAttachmentPart(part, index, field = "input") {
2863
2725
  );
2864
2726
  }
2865
2727
  const dataUrl = part.dataUrl;
2866
- const match = typeof dataUrl === "string" ? /^data:[^,]*;base64,([A-Za-z0-9+/=\s]+)$/.exec(dataUrl) : null;
2728
+ const match = typeof dataUrl === "string" ? /^data:([^,]*);base64,([A-Za-z0-9+/=\s]+)$/.exec(dataUrl) : null;
2867
2729
  if (!match) {
2868
2730
  throw badRequest(`${field}[${index}].dataUrl must be a base64 data: URL of the file's bytes.`);
2869
2731
  }
2870
- const bytes = Buffer.from(match[1], "base64");
2732
+ const bytes = Buffer.from(match[2], "base64");
2871
2733
  if (bytes.length === 0) {
2872
2734
  throw badRequest(`${field}[${index}].dataUrl decodes to an empty file.`);
2873
2735
  }
2874
- if (bytes.length > MAX_ATTACHMENT_BYTES) {
2736
+ if (bytes.length > limits.maxBytes) {
2875
2737
  throw new HttpError(
2876
2738
  413,
2877
2739
  "file_too_large",
2878
- `Attached file exceeds the ${MAX_ATTACHMENT_BYTES / (1024 * 1024)}MB limit.`
2740
+ `Attached file exceeds the ${limits.maxBytes / (1024 * 1024)}MB limit.`
2879
2741
  );
2880
2742
  }
2881
- return { fileName, bytes };
2743
+ return { fileName, bytes, mime: match[1] };
2882
2744
  }
2883
- function assertAttachmentBudget(attachments) {
2884
- if (attachments.length > MAX_ATTACHMENT_COUNT) {
2745
+ async function readRecalledFiles(files) {
2746
+ const read = await Promise.all(
2747
+ files.map(async (f) => {
2748
+ try {
2749
+ const bytes = await fs3.readFile(f.path);
2750
+ const mime = f.mime || "application/octet-stream";
2751
+ return { fileName: f.fileName, dataUrl: `data:${mime};base64,${bytes.toString("base64")}` };
2752
+ } catch {
2753
+ return null;
2754
+ }
2755
+ })
2756
+ );
2757
+ return read.filter((f) => f !== null);
2758
+ }
2759
+ function assertAttachmentBudget(attachments, limits) {
2760
+ if (attachments.length > limits.maxCount) {
2885
2761
  throw new HttpError(
2886
2762
  413,
2887
2763
  "too_many_files",
2888
- `A message may carry at most ${MAX_ATTACHMENT_COUNT} attached files.`
2764
+ `A message may carry at most ${limits.maxCount} attached files.`
2889
2765
  );
2890
2766
  }
2891
2767
  let total = 0;
2892
2768
  for (const a of attachments) total += a.bytes.length;
2893
- if (total > MAX_TOTAL_ATTACHMENT_BYTES) {
2769
+ if (total > limits.totalBytes) {
2894
2770
  throw new HttpError(
2895
2771
  413,
2896
2772
  "payload_too_large",
2897
- `Attached files exceed the ${MAX_TOTAL_ATTACHMENT_BYTES / (1024 * 1024)}MB total limit for one message.`
2773
+ `Attached files exceed the ${limits.totalBytes / (1024 * 1024)}MB total limit for one message.`
2898
2774
  );
2899
2775
  }
2900
2776
  }
2901
2777
  function scratchpadName(fileName) {
2902
- const ext = sanitizeSegment(path3.extname(fileName));
2903
- const rawStem = fileName.slice(0, fileName.length - path3.extname(fileName).length);
2778
+ const ext = sanitizeSegment(path2.extname(fileName));
2779
+ const rawStem = fileName.slice(0, fileName.length - path2.extname(fileName).length);
2904
2780
  const stem = truncateBytes(sanitizeSegment(rawStem), MAX_STEM_BYTES).replace(/[. ]+$/, "");
2905
2781
  if (!stem || /^-+$/.test(stem)) return `file${ext}`;
2906
2782
  return WINDOWS_RESERVED.test(stem) ? `_${stem}${ext}` : `${stem}${ext}`;
2907
2783
  }
2908
2784
  async function writeAttachment(dir, attachment) {
2909
2785
  const base = scratchpadName(attachment.fileName);
2910
- const ext = path3.extname(base);
2786
+ const ext = path2.extname(base);
2911
2787
  const stem = base.slice(0, base.length - ext.length);
2912
2788
  for (let attempt = 0; attempt < MAX_NAME_ATTEMPTS; attempt++) {
2913
- const name = attempt === 0 ? base : `${stem}-${randomBytes4(3).toString("hex")}${ext}`;
2914
- const file = path3.join(dir, name);
2789
+ const name = attempt === 0 ? base : `${stem}-${randomBytes2(3).toString("hex")}${ext}`;
2790
+ const file = path2.join(dir, name);
2915
2791
  try {
2916
2792
  await fs3.writeFile(file, attachment.bytes, { flag: "wx" });
2917
2793
  return file;
@@ -2924,10 +2800,10 @@ async function writeAttachment(dir, attachment) {
2924
2800
  );
2925
2801
  }
2926
2802
  async function openScratchpadDir(root, sessionId) {
2927
- const dir = path3.join(root, sessionId);
2803
+ const dir = path2.join(root, sessionId);
2928
2804
  await fs3.mkdir(dir, { recursive: true });
2929
2805
  const canonicalRoot = await fs3.realpath(root);
2930
- const rel = path3.relative(canonicalRoot, await fs3.realpath(dir));
2806
+ const rel = path2.relative(canonicalRoot, await fs3.realpath(dir));
2931
2807
  if (rel !== sessionId) {
2932
2808
  throw new Error(
2933
2809
  `session scratchpad ${dir} resolves outside the agent scratchpad root; refusing to write attachments`
@@ -2970,7 +2846,14 @@ var APPROVAL_MODES = [
2970
2846
  "read-only",
2971
2847
  "always-ask"
2972
2848
  ];
2973
- var THINKING_LEVELS = ["none", "low", "medium", "high", "xhigh"];
2849
+ var THINKING_LEVELS = [
2850
+ "none",
2851
+ "low",
2852
+ "medium",
2853
+ "high",
2854
+ "xhigh",
2855
+ "max"
2856
+ ];
2974
2857
  var MESSAGES_PAGE_LIMIT_MAX = 1e3;
2975
2858
  var MESSAGES_PAGE_LIMIT_DEFAULT = 200;
2976
2859
  function pageLimit(raw, name) {
@@ -2983,8 +2866,12 @@ function pageLimit(raw, name) {
2983
2866
  }
2984
2867
  function appendPendingInputs(messages, pending) {
2985
2868
  if (pending.length === 0) return messages;
2986
- const tail = new Set(messages.slice(-50).map((m) => JSON.stringify(m)));
2987
- const missing = pending.filter((m) => !tail.has(JSON.stringify(m)));
2869
+ const envelopeKey = (message) => {
2870
+ const { tracePosition: _tracePosition, ...envelope } = message;
2871
+ return JSON.stringify(envelope);
2872
+ };
2873
+ const tail = new Set(messages.slice(-50).map(envelopeKey));
2874
+ const missing = pending.filter((message) => !tail.has(envelopeKey(message)));
2988
2875
  return missing.length > 0 ? [...messages, ...missing] : messages;
2989
2876
  }
2990
2877
  function messagesPageQuery(c) {
@@ -3020,28 +2907,44 @@ var IMAGE_DATA_URL = /^data:[^;,]+;base64,[A-Za-z0-9+/=\s]+$/;
3020
2907
  function requireImageUrl(url, field) {
3021
2908
  if (typeof url === "string") {
3022
2909
  if (url.startsWith("http://") || url.startsWith("https://")) return url;
3023
- if (IMAGE_DATA_URL.test(url)) return url;
2910
+ if (IMAGE_DATA_URL.test(url)) {
2911
+ const bytes = dataUrlByteLength(url);
2912
+ if (bytes > INLINE_IMAGE_MAX_BYTES) {
2913
+ throw new HttpError(
2914
+ 413,
2915
+ "image_too_large",
2916
+ `${field} exceeds the ${INLINE_IMAGE_MAX_MB}MB inline image limit.`
2917
+ );
2918
+ }
2919
+ return url;
2920
+ }
3024
2921
  }
3025
2922
  throw badRequest(
3026
2923
  `${field} must be an http(s) URL or a base64 data: URL (data:<mime>;base64,<bytes>).`
3027
2924
  );
3028
2925
  }
2926
+ function dataUrlByteLength(dataUrl) {
2927
+ const payload = dataUrl.slice(dataUrl.indexOf(",") + 1).replace(/[=\s]+/g, "");
2928
+ return Math.floor(payload.length * 3 / 4);
2929
+ }
3029
2930
  function resolveScratchpadFile(dir, fileName) {
3030
2931
  if (!fileName || fileName === "." || fileName === "..") return null;
3031
2932
  for (const ch of fileName) {
3032
2933
  const code = ch.codePointAt(0);
3033
2934
  if (code < 32 || code === 127 || ch === "/" || ch === "\\") return null;
3034
2935
  }
3035
- const resolved = path4.resolve(dir, fileName);
3036
- return path4.dirname(resolved) === path4.resolve(dir) ? resolved : null;
2936
+ const resolved = path3.resolve(dir, fileName);
2937
+ return path3.dirname(resolved) === path3.resolve(dir) ? resolved : null;
3037
2938
  }
3038
- function parseTaskInput(body) {
2939
+ function parseTaskInput(body, limits) {
3039
2940
  const input = body.input;
3040
2941
  if (!Array.isArray(input) || input.length === 0) {
3041
2942
  throw badRequest("input must be an array with at least one item.");
3042
2943
  }
3043
2944
  const messages = [];
3044
2945
  const attachments = [];
2946
+ const texts = [];
2947
+ const images = [];
3045
2948
  input.forEach((item, i) => {
3046
2949
  if (item === null || typeof item !== "object" || Array.isArray(item)) {
3047
2950
  throw badRequest(`input[${i}] must be an object.`);
@@ -3052,20 +2955,44 @@ function parseTaskInput(body) {
3052
2955
  throw badRequest(`input[${i}].text must be a non-empty string.`);
3053
2956
  }
3054
2957
  messages.push(userText(part.text));
2958
+ texts.push(part.text);
3055
2959
  return;
3056
2960
  }
3057
2961
  if (part.type === "image_url") {
3058
- messages.push(imageUrlMessage(requireImageUrl(part.imageUrl, `input[${i}].imageUrl`)));
2962
+ const url = requireImageUrl(part.imageUrl, `input[${i}].imageUrl`);
2963
+ messages.push(imageUrlMessage(url));
2964
+ images.push(url);
3059
2965
  return;
3060
2966
  }
3061
2967
  if (part.type === "file") {
3062
- attachments.push(parseAttachmentPart(part, i));
3063
- assertAttachmentBudget(attachments);
2968
+ attachments.push(parseAttachmentPart(part, i, limits));
2969
+ assertAttachmentBudget(attachments, limits);
3064
2970
  return;
3065
2971
  }
3066
2972
  throw badRequest(`input[${i}].type must be one of text / image_url / file.`);
3067
2973
  });
3068
- return { messages, attachments };
2974
+ return { messages, attachments, texts, images };
2975
+ }
2976
+ function recallStore(text, images, attachments, written) {
2977
+ return {
2978
+ text,
2979
+ images,
2980
+ files: written.map((p, i) => ({
2981
+ fileName: attachments[i].fileName,
2982
+ path: p,
2983
+ mime: attachments[i].mime
2984
+ }))
2985
+ };
2986
+ }
2987
+ async function recalledResponse(recall, thinkingLevel) {
2988
+ const files = await readRecalledFiles(recall.files);
2989
+ await removeAttachments(recall.files.map((f) => f.path));
2990
+ return {
2991
+ text: recall.text,
2992
+ images: recall.images,
2993
+ files,
2994
+ ...thinkingLevel !== void 0 ? { thinkingLevel } : {}
2995
+ };
3069
2996
  }
3070
2997
  function parseSteerImages(body) {
3071
2998
  const images = body.images;
@@ -3073,7 +3000,7 @@ function parseSteerImages(body) {
3073
3000
  if (!Array.isArray(images)) throw badRequest("images must be an array.");
3074
3001
  return images.map((url, i) => requireImageUrl(url, `images[${i}]`));
3075
3002
  }
3076
- function parseSteerFiles(body) {
3003
+ function parseSteerFiles(body, limits) {
3077
3004
  const files = body.files;
3078
3005
  if (files === void 0) return [];
3079
3006
  if (!Array.isArray(files)) throw badRequest("files must be an array.");
@@ -3082,8 +3009,8 @@ function parseSteerFiles(body) {
3082
3009
  if (item === null || typeof item !== "object" || Array.isArray(item)) {
3083
3010
  throw badRequest(`files[${i}] must be an object.`);
3084
3011
  }
3085
- attachments.push(parseAttachmentPart(item, i, "files"));
3086
- assertAttachmentBudget(attachments);
3012
+ attachments.push(parseAttachmentPart(item, i, limits, "files"));
3013
+ assertAttachmentBudget(attachments, limits);
3087
3014
  });
3088
3015
  return attachments;
3089
3016
  }
@@ -3197,6 +3124,7 @@ function sessionsRoutes(deps2) {
3197
3124
  const row = resolveSession(c);
3198
3125
  const body = await readJson(c);
3199
3126
  const approvalMode = optionalEnum(body, "approvalMode", APPROVAL_MODES);
3127
+ const thinkingLevel = optionalEnum(body, "thinkingLevel", THINKING_LEVELS);
3200
3128
  const archivedRaw = body.archived;
3201
3129
  const archived = typeof archivedRaw === "boolean" ? archivedRaw : void 0;
3202
3130
  const titleRaw = body.title;
@@ -3214,11 +3142,11 @@ function sessionsRoutes(deps2) {
3214
3142
  );
3215
3143
  }
3216
3144
  }
3217
- if (approvalMode === void 0 && archived === void 0 && title === void 0) {
3145
+ if (approvalMode === void 0 && thinkingLevel === void 0 && archived === void 0 && title === void 0) {
3218
3146
  throw new HttpError(
3219
3147
  400,
3220
3148
  "no_update",
3221
- "No updatable field provided (approvalMode / archived / title)."
3149
+ "No updatable field provided (approvalMode / thinkingLevel / archived / title)."
3222
3150
  );
3223
3151
  }
3224
3152
  let updated = { ...row };
@@ -3230,16 +3158,73 @@ function sessionsRoutes(deps2) {
3230
3158
  deps2.sessionsRepo.updateApprovalMode(row.sessionId, approvalMode);
3231
3159
  updated = { ...updated, approvalMode };
3232
3160
  }
3161
+ if (thinkingLevel !== void 0) {
3162
+ deps2.sessionsRepo.updateThinkingLevel(row.sessionId, thinkingLevel);
3163
+ updated = { ...updated, thinkingLevel };
3164
+ }
3233
3165
  if (archived !== void 0) {
3234
3166
  const at = archived ? (/* @__PURE__ */ new Date()).toISOString() : null;
3235
3167
  deps2.sessionsRepo.setArchived(row.sessionId, at);
3236
3168
  updated = { ...updated, archivedAt: at };
3237
3169
  }
3238
3170
  const hasTrace = await deps2.sessionService.hasTrace(updated);
3171
+ const fresh = deps2.sessionsRepo.findById(row.sessionId) ?? updated;
3239
3172
  return c.json({
3240
- session: await deps2.sessionService.toInfo(updated, hasTrace)
3173
+ session: await deps2.sessionService.toInfo(fresh, hasTrace)
3241
3174
  });
3242
3175
  });
3176
+ app2.post("/:sessionId/fork", async (c) => {
3177
+ const row = resolveSession(c);
3178
+ const body = await readJson(c);
3179
+ const raw = body.position;
3180
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
3181
+ throw badRequest("position must be a Trace position object.");
3182
+ }
3183
+ const { fileIndex, ordinal } = raw;
3184
+ if (!Number.isSafeInteger(fileIndex) || fileIndex < 1 || !Number.isSafeInteger(ordinal) || ordinal < 0) {
3185
+ throw badRequest("position.fileIndex must be positive and position.ordinal non-negative.");
3186
+ }
3187
+ const fork = await deps2.manager.atIdleBoundary(
3188
+ row.sessionId,
3189
+ () => deps2.traceService.forkSessionTrace(row.projectId, row.agentId, row.sessionId, {
3190
+ fileIndex,
3191
+ ordinal
3192
+ })
3193
+ );
3194
+ const forkRow = {
3195
+ sessionId: fork.sessionId,
3196
+ projectId: row.projectId,
3197
+ agentId: row.agentId,
3198
+ provider: row.provider,
3199
+ modelId: row.modelId,
3200
+ workspace: row.workspace,
3201
+ approvalMode: row.approvalMode,
3202
+ // insertFork replaces this with the source's current title plus its persistent number.
3203
+ title: null,
3204
+ client: "web",
3205
+ hasTrace: true,
3206
+ lastActiveAt: fork.createdAt,
3207
+ createdAt: fork.createdAt
3208
+ };
3209
+ try {
3210
+ const insertedForkRow = deps2.sessionsRepo.insertFork(row.sessionId, forkRow);
3211
+ deps2.sessionSources.set(fork.sessionId, null);
3212
+ return c.json(
3213
+ {
3214
+ session: await deps2.sessionService.toInfo(insertedForkRow, true)
3215
+ },
3216
+ 201
3217
+ );
3218
+ } catch (err) {
3219
+ deps2.sessionsRepo.deleteById(fork.sessionId);
3220
+ await deps2.traceService.deleteSessionTraces(row.projectId, row.agentId, fork.sessionId);
3221
+ await fs4.rm(
3222
+ path3.join(scratchpadDir(deps2.config.root, row.projectId, row.agentId), fork.sessionId),
3223
+ { recursive: true, force: true }
3224
+ );
3225
+ throw err;
3226
+ }
3227
+ });
3243
3228
  app2.delete("/:sessionId", async (c) => {
3244
3229
  const row = resolveSession(c);
3245
3230
  const runnings = deps2.manager.beginSessionDeletion(row.sessionId);
@@ -3252,7 +3237,7 @@ function sessionsRoutes(deps2) {
3252
3237
  }
3253
3238
  await deps2.traceService.deleteSessionTraces(row.projectId, row.agentId, row.sessionId);
3254
3239
  await fs4.rm(
3255
- path4.join(scratchpadDir(deps2.config.root, row.projectId, row.agentId), row.sessionId),
3240
+ path3.join(scratchpadDir(deps2.config.root, row.projectId, row.agentId), row.sessionId),
3256
3241
  { recursive: true, force: true }
3257
3242
  );
3258
3243
  deps2.sessionsRepo.deleteById(row.sessionId);
@@ -3267,7 +3252,7 @@ function sessionsRoutes(deps2) {
3267
3252
  const row = resolveSession(c);
3268
3253
  const fileName = c.req.param("fileName") ?? "";
3269
3254
  const filePath = resolveScratchpadFile(
3270
- path4.join(scratchpadDir(deps2.config.root, row.projectId, row.agentId), row.sessionId),
3255
+ path3.join(scratchpadDir(deps2.config.root, row.projectId, row.agentId), row.sessionId),
3271
3256
  fileName
3272
3257
  );
3273
3258
  if (!filePath) throw new HttpError(404, "file_not_found", "File does not exist.");
@@ -3284,7 +3269,7 @@ function sessionsRoutes(deps2) {
3284
3269
  ".gif": "image/gif",
3285
3270
  ".webp": "image/webp"
3286
3271
  };
3287
- const mime = MIME_BY_EXT[path4.extname(fileName).toLowerCase()];
3272
+ const mime = MIME_BY_EXT[path3.extname(fileName).toLowerCase()];
3288
3273
  return c.body(new Uint8Array(bytes), 200, {
3289
3274
  "content-type": mime ?? "application/octet-stream",
3290
3275
  // nosniff: the composer's file attachments land in this same directory, so the bytes
@@ -3356,14 +3341,16 @@ function sessionsRoutes(deps2) {
3356
3341
  const row = resolveSession(c);
3357
3342
  const channel = deps2.channels.get(row.sessionId);
3358
3343
  const pendingSteering = deps2.manager.pendingSteeringOf(row.sessionId);
3344
+ const pendingFollowUps = deps2.manager.pendingFollowUpsOf(row.sessionId);
3359
3345
  const initialEvents = [
3360
3346
  {
3361
3347
  type: "task_state",
3362
3348
  state: deps2.manager.statusOf(row.sessionId),
3363
3349
  queued: deps2.manager.pendingFollowUpCount(row.sessionId),
3364
- // Undelivered steering rides the snapshot too, so the composer's "steering queued"
3365
- // hint (and what it says) survives a reload.
3366
- ...pendingSteering.length > 0 ? { pendingSteering } : {}
3350
+ // Undelivered steering and queued follow-ups ride the snapshot too, so the composer's
3351
+ // queued hints (and what they say) survive a reload.
3352
+ ...pendingSteering.length > 0 ? { pendingSteering } : {},
3353
+ ...pendingFollowUps.length > 0 ? { pendingFollowUps } : {}
3367
3354
  },
3368
3355
  ...deps2.manager.pendingApprovals(row.sessionId).map((p) => ({
3369
3356
  type: "approval_request",
@@ -3378,8 +3365,9 @@ function sessionsRoutes(deps2) {
3378
3365
  const body = await readJson(c);
3379
3366
  const goal = parseGoalField(body);
3380
3367
  const thinkingLevel = optionalEnum(body, "thinkingLevel", THINKING_LEVELS);
3368
+ const limits = toAttachmentLimits(deps2.serverSettingsRepo.getAttachmentLimitsMb());
3381
3369
  if (goal) {
3382
- const { messages, attachments } = parseTaskInput(body);
3370
+ const { messages, attachments } = parseTaskInput(body, limits);
3383
3371
  const text = messages.filter((m) => m.payload.type === "text").map((m) => m.payload.text).join("\n").trim();
3384
3372
  if (!text) {
3385
3373
  throw badRequest("goal mode requires a non-empty text objective.");
@@ -3394,7 +3382,7 @@ function sessionsRoutes(deps2) {
3394
3382
  });
3395
3383
  return c.json({ sessionId }, 202);
3396
3384
  }
3397
- const parsed = parseTaskInput(body);
3385
+ const parsed = parseTaskInput(body, limits);
3398
3386
  const queueIfBusy = body.queueIfBusy === true;
3399
3387
  deps2.manager.assertCanAcceptTask(row.sessionId, { queueIfBusy });
3400
3388
  const { input, written } = await attachFilesToInput(
@@ -3406,7 +3394,10 @@ function sessionsRoutes(deps2) {
3406
3394
  try {
3407
3395
  const { sessionId, queued } = await deps2.manager.startTask(row.sessionId, input, {
3408
3396
  ...thinkingLevel !== void 0 ? { thinkingLevel } : {},
3409
- queueIfBusy
3397
+ queueIfBusy,
3398
+ // Original content, kept while the input waits in the follow-up queue so a recall
3399
+ // (DELETE /follow-ups/:id) can hand it back; unused when the task starts directly.
3400
+ recall: recallStore(parsed.texts.join("\n"), parsed.images, parsed.attachments, written)
3410
3401
  });
3411
3402
  return c.json({ sessionId, queued }, 202);
3412
3403
  } catch (err) {
@@ -3419,7 +3410,10 @@ function sessionsRoutes(deps2) {
3419
3410
  const body = await readJson(c);
3420
3411
  const text = typeof body.text === "string" ? body.text.trim() : "";
3421
3412
  const images = parseSteerImages(body);
3422
- const files = parseSteerFiles(body);
3413
+ const files = parseSteerFiles(
3414
+ body,
3415
+ toAttachmentLimits(deps2.serverSettingsRepo.getAttachmentLimitsMb())
3416
+ );
3423
3417
  if (!text && images.length === 0 && files.length === 0) {
3424
3418
  throw badRequest("text, images or files must carry the steering message.");
3425
3419
  }
@@ -3430,17 +3424,28 @@ function sessionsRoutes(deps2) {
3430
3424
  row.sessionId
3431
3425
  );
3432
3426
  try {
3433
- deps2.manager.steer(row.sessionId, input, {
3434
- text,
3435
- images: images.length,
3436
- files: files.length
3437
- });
3427
+ deps2.manager.steer(row.sessionId, input, recallStore(text, images, files, written));
3438
3428
  } catch (err) {
3439
3429
  await removeAttachments(written);
3440
3430
  throw err;
3441
3431
  }
3442
3432
  return c.body(null, 202);
3443
3433
  });
3434
+ app2.delete("/:sessionId/steer/:steerId", async (c) => {
3435
+ const row = resolveSession(c);
3436
+ const recall = deps2.manager.recallSteering(row.sessionId, pathParam(c, "steerId"));
3437
+ return c.json(await recalledResponse(recall));
3438
+ });
3439
+ app2.delete("/:sessionId/follow-ups/:followUpId", async (c) => {
3440
+ const row = resolveSession(c);
3441
+ const { recall, thinkingLevel } = deps2.manager.recallFollowUp(
3442
+ row.sessionId,
3443
+ pathParam(c, "followUpId")
3444
+ );
3445
+ return c.json(
3446
+ await recalledResponse(recall, thinkingLevel)
3447
+ );
3448
+ });
3444
3449
  app2.get("/:sessionId/goal", (c) => {
3445
3450
  const row = resolveSession(c);
3446
3451
  const g = deps2.goalsRepo.latestForSession(row.sessionId);
@@ -3498,6 +3503,25 @@ function sessionsRoutes(deps2) {
3498
3503
  }
3499
3504
  return c.body(null, 204);
3500
3505
  });
3506
+ app2.delete("/:sessionId/processes/:processId", (c) => {
3507
+ const row = resolveSession(c);
3508
+ const result = deps2.manager.removeProcess(row.sessionId, pathParam(c, "processId"));
3509
+ if (result === "running") {
3510
+ throw new HttpError(
3511
+ 409,
3512
+ "process_running",
3513
+ "Process is still running; stop it instead of removing it."
3514
+ );
3515
+ }
3516
+ if (result === "not_found") {
3517
+ throw new HttpError(
3518
+ 404,
3519
+ "process_not_found",
3520
+ "Process does not exist or has already been removed."
3521
+ );
3522
+ }
3523
+ return c.body(null, 204);
3524
+ });
3501
3525
  app2.post("/:sessionId/retry-now", (c) => {
3502
3526
  const row = resolveSession(c);
3503
3527
  const skipped = deps2.manager.retryNow(row.sessionId);
@@ -3745,7 +3769,7 @@ function memoryRoutes(deps2) {
3745
3769
  }
3746
3770
 
3747
3771
  // src/http/routes/schedules.ts
3748
- import { createHash as createHash2 } from "crypto";
3772
+ import { createHash } from "crypto";
3749
3773
  import { Hono as Hono14 } from "hono";
3750
3774
  import { isValidId as isValidId3 } from "@prismshadow/penguin-core";
3751
3775
 
@@ -3891,7 +3915,7 @@ function nextSlotAfter(def, nowMs) {
3891
3915
 
3892
3916
  // src/runtime/schedule-store.ts
3893
3917
  import fs6 from "fs/promises";
3894
- import path5 from "path";
3918
+ import path4 from "path";
3895
3919
  import { stringify as stringifyToml } from "smol-toml";
3896
3920
  import { resolveModelRef, scheduleDir } from "@prismshadow/penguin-core";
3897
3921
 
@@ -3988,7 +4012,7 @@ var ScheduleFileCache = class {
3988
4012
  }
3989
4013
  /** Reuses the cached parse while the file's stat still matches; re-reads otherwise. */
3990
4014
  async revalidate(dir, name, cached) {
3991
- const file = path5.join(dir, `${name}.toml`);
4015
+ const file = path4.join(dir, `${name}.toml`);
3992
4016
  let stat;
3993
4017
  try {
3994
4018
  stat = await fs6.stat(file);
@@ -4011,7 +4035,7 @@ var ScheduleFileCache = class {
4011
4035
  }
4012
4036
  };
4013
4037
  async function readScheduleFile(root, projectId, agentId, name) {
4014
- const file = path5.join(scheduleDir(root, projectId, agentId), `${name}.toml`);
4038
+ const file = path4.join(scheduleDir(root, projectId, agentId), `${name}.toml`);
4015
4039
  try {
4016
4040
  const raw = await fs6.readFile(file, "utf8");
4017
4041
  return { name, raw, parsed: parseScheduleFile(name, raw) };
@@ -4050,10 +4074,10 @@ async function validateScheduleModelRef(configs, projectId, def) {
4050
4074
  async function writeScheduleFile(root, projectId, agentId, name, raw) {
4051
4075
  const dir = scheduleDir(root, projectId, agentId);
4052
4076
  await fs6.mkdir(dir, { recursive: true });
4053
- await fs6.writeFile(path5.join(dir, `${name}.toml`), raw, "utf8");
4077
+ await fs6.writeFile(path4.join(dir, `${name}.toml`), raw, "utf8");
4054
4078
  }
4055
4079
  async function deleteScheduleFile(root, projectId, agentId, name) {
4056
- const file = path5.join(scheduleDir(root, projectId, agentId), `${name}.toml`);
4080
+ const file = path4.join(scheduleDir(root, projectId, agentId), `${name}.toml`);
4057
4081
  try {
4058
4082
  await fs6.unlink(file);
4059
4083
  return true;
@@ -4212,7 +4236,7 @@ async function upsert(deps2, userId, projectId, agentId, name, body) {
4212
4236
  agentId,
4213
4237
  name,
4214
4238
  startAtMs: parsed.def.startAtMs,
4215
- defHash: createHash2("sha1").update(raw).digest("hex"),
4239
+ defHash: createHash("sha1").update(raw).digest("hex"),
4216
4240
  creatorUserId: userId
4217
4241
  });
4218
4242
  await deps2.scheduler.reconcileAgent(projectId, agentId);
@@ -4305,7 +4329,7 @@ function benchmarksRoutes(deps2) {
4305
4329
 
4306
4330
  // src/http/routes/skills.ts
4307
4331
  import fs7 from "fs/promises";
4308
- import path6 from "path";
4332
+ import path5 from "path";
4309
4333
  import { unzipSync, strFromU8, zipSync } from "fflate";
4310
4334
  import { Hono as Hono16 } from "hono";
4311
4335
  import {
@@ -4408,7 +4432,7 @@ async function collectSkillArchive(dir, name) {
4408
4432
  let total = 0;
4409
4433
  const walk = async (abs, rel) => {
4410
4434
  for (const entry of await fs7.readdir(abs, { withFileTypes: true })) {
4411
- const absChild = path6.join(abs, entry.name);
4435
+ const absChild = path5.join(abs, entry.name);
4412
4436
  const relChild = `${rel}/${entry.name}`;
4413
4437
  if (entry.isDirectory()) {
4414
4438
  await walk(absChild, relChild);
@@ -4516,9 +4540,9 @@ function agentSkillsRoutes(deps2) {
4516
4540
  throw badRequest("The zip archive exceeds the 14MB limit.");
4517
4541
  }
4518
4542
  const skill = parseSkillArchive(archive);
4519
- const dir = path6.join(skillsDir(deps2.config.root, projectId, agentId), skill.name);
4543
+ const dir = path5.join(skillsDir(deps2.config.root, projectId, agentId), skill.name);
4520
4544
  if (!overwrite) {
4521
- const installed2 = await fs7.access(path6.join(dir, "SKILL.md")).then(
4545
+ const installed2 = await fs7.access(path5.join(dir, "SKILL.md")).then(
4522
4546
  () => true,
4523
4547
  () => false
4524
4548
  );
@@ -4528,8 +4552,8 @@ function agentSkillsRoutes(deps2) {
4528
4552
  }
4529
4553
  await fs7.rm(dir, { recursive: true, force: true });
4530
4554
  for (const [rel, data] of skill.files) {
4531
- const file = path6.join(dir, rel);
4532
- await fs7.mkdir(path6.dirname(file), { recursive: true });
4555
+ const file = path5.join(dir, rel);
4556
+ await fs7.mkdir(path5.dirname(file), { recursive: true });
4533
4557
  await fs7.writeFile(file, data);
4534
4558
  }
4535
4559
  return c.json(await listResponse(projectId, agentId), 201);
@@ -4539,9 +4563,9 @@ function agentSkillsRoutes(deps2) {
4539
4563
  const agentId = requireValidId(c, "agentId");
4540
4564
  deps2.projectService.requireProjectAccess(c.var.user.userId, projectId);
4541
4565
  const name = requireValidId(c, "name");
4542
- const dir = path6.join(skillsDir(deps2.config.root, projectId, agentId), name);
4566
+ const dir = path5.join(skillsDir(deps2.config.root, projectId, agentId), name);
4543
4567
  try {
4544
- await fs7.access(path6.join(dir, "SKILL.md"));
4568
+ await fs7.access(path5.join(dir, "SKILL.md"));
4545
4569
  } catch {
4546
4570
  throw new HttpError(404, "not_found", `Skill is not installed: ${name}`);
4547
4571
  }
@@ -4563,7 +4587,7 @@ function agentSkillsRoutes(deps2) {
4563
4587
  const agentId = requireValidId(c, "agentId");
4564
4588
  deps2.projectService.requireProjectAccess(c.var.user.userId, projectId);
4565
4589
  const name = requireValidId(c, "name");
4566
- const file = path6.join(skillsDir(deps2.config.root, projectId, agentId), name, "SKILL.md");
4590
+ const file = path5.join(skillsDir(deps2.config.root, projectId, agentId), name, "SKILL.md");
4567
4591
  try {
4568
4592
  await fs7.access(file);
4569
4593
  } catch {
@@ -4699,7 +4723,7 @@ function agentsRoutes(deps2) {
4699
4723
  // src/http/routes/dirs.ts
4700
4724
  import fs9 from "fs/promises";
4701
4725
  import os from "os";
4702
- import path7 from "path";
4726
+ import path6 from "path";
4703
4727
  import { Hono as Hono19 } from "hono";
4704
4728
  function dirsRoutes(deps2) {
4705
4729
  const app2 = new Hono19();
@@ -4708,7 +4732,7 @@ function dirsRoutes(deps2) {
4708
4732
  deps2.projectService.requireProjectAccess(c.var.user.userId, projectId);
4709
4733
  const raw = c.req.query("path");
4710
4734
  const target = raw && raw.trim() ? raw.trim() : os.homedir();
4711
- if (!path7.isAbsolute(target)) {
4735
+ if (!path6.isAbsolute(target)) {
4712
4736
  throw new HttpError(400, "dir_not_absolute", "Directory must be an absolute path.");
4713
4737
  }
4714
4738
  let real;
@@ -4731,8 +4755,8 @@ function dirsRoutes(deps2) {
4731
4755
  } catch {
4732
4756
  dirents = [];
4733
4757
  }
4734
- const entries = dirents.filter((d) => d.isDirectory()).map((d) => ({ name: d.name, path: path7.join(real, d.name) })).sort((a, b) => a.name.localeCompare(b.name));
4735
- const parent = path7.dirname(real);
4758
+ const entries = dirents.filter((d) => d.isDirectory()).map((d) => ({ name: d.name, path: path6.join(real, d.name) })).sort((a, b) => a.name.localeCompare(b.name));
4759
+ const parent = path6.dirname(real);
4736
4760
  return c.json({
4737
4761
  path: real,
4738
4762
  parent: parent === real ? null : parent,
@@ -5206,6 +5230,17 @@ var ChannelHub = class {
5206
5230
  peek(key) {
5207
5231
  return this.channels.get(key);
5208
5232
  }
5233
+ /**
5234
+ * Publishes to every live channel whose key starts with `prefix` — global
5235
+ * notices (e.g. `user:` for "the web assets were hot-swapped, reload").
5236
+ * Only reaches channels that exist (i.e. someone connected at least once);
5237
+ * that is the right scope for a notice that only matters to live clients.
5238
+ */
5239
+ broadcast(prefix, data, event) {
5240
+ for (const [key, ch] of this.channels) {
5241
+ if (key.startsWith(prefix)) ch.publish(data, event);
5242
+ }
5243
+ }
5209
5244
  /** Reclaim idle channels (skips active Sessions: no reclaim even without a publish while awaiting approval); `now` is injectable for tests. */
5210
5245
  sweep(now = Date.now()) {
5211
5246
  for (const [key, ch] of this.channels) {
@@ -5294,8 +5329,9 @@ var ErrorRecorder = class {
5294
5329
  };
5295
5330
 
5296
5331
  // src/runtime/session-manager.ts
5332
+ import { randomUUID as randomUUID2 } from "crypto";
5297
5333
  import fs10 from "fs/promises";
5298
- import path9 from "path";
5334
+ import path8 from "path";
5299
5335
  import {
5300
5336
  createAgent,
5301
5337
  findLatestTraceFile,
@@ -5556,7 +5592,7 @@ var SessionSources = class {
5556
5592
 
5557
5593
  // src/runtime/stream-error-watcher.ts
5558
5594
  import { isEventMessage, isModelMessage, isSessionMeta } from "@prismshadow/penguin-core";
5559
- import path8 from "path";
5595
+ import path7 from "path";
5560
5596
  var TOOL_NAMES_MAX = 1e3;
5561
5597
  var ORIGIN_CTX_MAX = 200;
5562
5598
  var LLM_FAILURES = {
@@ -5661,7 +5697,7 @@ var StreamErrorWatcher = class {
5661
5697
  registerOrigin(msg) {
5662
5698
  const key = originKey(msg);
5663
5699
  if (!key) return;
5664
- const agentId = path8.basename(path8.dirname(msg.payload.agent_state));
5700
+ const agentId = path7.basename(path7.dirname(msg.payload.agent_state));
5665
5701
  if (!agentId || agentId === "." || agentId === "..") return;
5666
5702
  this.originCtx.delete(key);
5667
5703
  this.originCtx.set(key, { agentId, sessionId: key });
@@ -5796,12 +5832,22 @@ var StreamErrorWatcher = class {
5796
5832
 
5797
5833
  // src/runtime/session-manager.ts
5798
5834
  function compactUnavailable(why) {
5799
- const messages = {
5800
- unsupported: "This Agent does not have context compaction configured.",
5801
- empty: "The current context has nothing to compact (no completed conversation turns yet).",
5802
- just_compacted: "The context was just compacted and there is no new conversation since; no need to compact again."
5835
+ const reasons = {
5836
+ unsupported: {
5837
+ code: "compaction_not_configured",
5838
+ message: "This Agent does not have context compaction configured."
5839
+ },
5840
+ empty: {
5841
+ code: "nothing_to_compact",
5842
+ message: "The current context has nothing to compact (no completed conversation turns yet)."
5843
+ },
5844
+ just_compacted: {
5845
+ code: "already_compacted",
5846
+ message: "The context was just compacted and there is no new conversation since; no need to compact again."
5847
+ }
5803
5848
  };
5804
- return new HttpError(409, "nothing_to_compact", messages[why]);
5849
+ const { code, message } = reasons[why];
5850
+ return new HttpError(409, code, message);
5805
5851
  }
5806
5852
  function createCoreSessionLoader(root, sources, opts = {}) {
5807
5853
  return {
@@ -5865,6 +5911,14 @@ var EARLY_TITLE_BODY_CHARS = 1e3;
5865
5911
  function agentKey(projectId, agentId) {
5866
5912
  return `${projectId}\0${agentId}`;
5867
5913
  }
5914
+ function followUpInfo(f) {
5915
+ return {
5916
+ id: f.id,
5917
+ text: f.recall?.text ?? "",
5918
+ images: f.recall?.images.length ?? 0,
5919
+ files: f.recall?.files.length ?? 0
5920
+ };
5921
+ }
5868
5922
  function isDeliveredSteering(msg) {
5869
5923
  const p = msg.payload;
5870
5924
  if (msg.type !== "model_msg" || p.type !== "text" || p.role !== "user") return false;
@@ -5909,6 +5963,7 @@ var SessionManager = class {
5909
5963
  constructor(deps2) {
5910
5964
  this.deps = deps2;
5911
5965
  this.log = deps2.log ?? ((line) => console.error(line));
5966
+ this.now = deps2.now ?? (() => /* @__PURE__ */ new Date());
5912
5967
  this.sweepTimer = setInterval(() => this.sweepIdle(), ENTRY_SWEEP_INTERVAL_MS);
5913
5968
  this.sweepTimer.unref?.();
5914
5969
  }
@@ -5928,6 +5983,8 @@ var SessionManager = class {
5928
5983
  /** Open streaming fragments of running sessions (fed by drive, served to GET /messages; see live-tail.ts). */
5929
5984
  liveTail = new LiveTailTracker();
5930
5985
  sweepTimer;
5986
+ /** Clock for persisted timestamps (see SessionManagerDeps.now); wall clock unless injected. */
5987
+ now;
5931
5988
  // —— Query surface (used by Session listing / Agent active-count / SSE subscription replay) ——
5932
5989
  statusOf(sessionId) {
5933
5990
  return this.entries.get(sessionId)?.status ?? "idle";
@@ -5944,7 +6001,11 @@ var SessionManager = class {
5944
6001
  }
5945
6002
  /** Steering messages queued but not yet delivered to the model (display mirror; see RuntimeEntry.pendingSteering). */
5946
6003
  pendingSteeringOf(sessionId) {
5947
- return this.entries.get(sessionId)?.pendingSteering ?? [];
6004
+ return (this.entries.get(sessionId)?.pendingSteering ?? []).map((p) => p.info);
6005
+ }
6006
+ /** Queued follow-up tasks awaiting auto-start, as their display/recall info (id + content summary). */
6007
+ pendingFollowUpsOf(sessionId) {
6008
+ return (this.entries.get(sessionId)?.followUps ?? []).map(followUpInfo);
5948
6009
  }
5949
6010
  /**
5950
6011
  * Live tail of a running session: one synthetic `partial_* start` OmniMessage per open
@@ -6044,6 +6105,22 @@ var SessionManager = class {
6044
6105
  const entry = this.entries.get(sessionId);
6045
6106
  if (entry && !opts?.queueIfBusy) this.assertIdle(entry);
6046
6107
  }
6108
+ /**
6109
+ * Run a metadata operation at an idle Session boundary under the same mutex as Task and
6110
+ * compaction starts. Session fork uses this to snapshot an append-only Trace without a Task
6111
+ * beginning between its status check and final read. An uncached Session is idle by definition;
6112
+ * unlike startTask this path deliberately does not load a heavyweight runtime.
6113
+ */
6114
+ async atIdleBoundary(sessionId, operation) {
6115
+ return this.withLock(sessionId, async () => {
6116
+ this.assertOpen();
6117
+ this.assertAgentNotDeleting(sessionId);
6118
+ this.assertSessionNotDeleting(sessionId);
6119
+ const entry = this.entries.get(sessionId);
6120
+ if (entry) this.assertIdle(entry);
6121
+ return operation();
6122
+ });
6123
+ }
6047
6124
  /**
6048
6125
  * Start a Task: get-or-load → 409
6049
6126
  * mutual-exclusion check → publish the input messages first → drive run in the
@@ -6063,8 +6140,11 @@ var SessionManager = class {
6063
6140
  const entry = await this.ensureEntry(sessionId);
6064
6141
  if (entry.status !== "idle" && opts?.queueIfBusy) {
6065
6142
  entry.followUps.push({
6143
+ id: randomUUID2(),
6066
6144
  input,
6067
- ...opts.thinkingLevel !== void 0 ? { thinkingLevel: opts.thinkingLevel } : {}
6145
+ ...opts.thinkingLevel !== void 0 ? { thinkingLevel: opts.thinkingLevel } : {},
6146
+ // The original content rides the queue so a recall can hand it back (see recallFollowUp).
6147
+ ...opts.recall !== void 0 ? { recall: opts.recall } : {}
6068
6148
  });
6069
6149
  entry.lastActivityMs = Date.now();
6070
6150
  this.publishState(entry, entry.status);
@@ -6123,10 +6203,11 @@ var SessionManager = class {
6123
6203
  objective,
6124
6204
  budget: args.budget
6125
6205
  });
6206
+ const thinkingLevel = this.runThinkingLevel(entry.sessionId, args.thinkingLevel);
6126
6207
  const gen = this.goalStream(entry, {
6127
6208
  input: args.input,
6128
6209
  budget: args.budget,
6129
- ...args.thinkingLevel !== void 0 ? { thinkingLevel: args.thinkingLevel } : {},
6210
+ ...thinkingLevel !== void 0 ? { thinkingLevel } : {},
6130
6211
  approve,
6131
6212
  signal: ac.signal,
6132
6213
  ...goalId !== void 0 ? { goalId } : {}
@@ -6207,7 +6288,18 @@ var SessionManager = class {
6207
6288
  });
6208
6289
  }
6209
6290
  /** Shared task launch (fresh tasks and auto-started follow-ups): flips to running, publishes the input, and drives the run with the per-turn thinking level (if any). Caller holds the session lock and has verified idle. */
6210
- launchTask(entry, input, thinkingLevel) {
6291
+ /**
6292
+ * Thinking level for one run: the level the request carried wins, else the level pinned
6293
+ * on the Session row (PATCH /api/sessions/:id — the Web App's in-chat picker), else
6294
+ * undefined so core keeps falling back to the Agent config. Resolved at LAUNCH time, so
6295
+ * a queued follow-up that carried no level of its own picks up the pin as it stands when
6296
+ * it finally starts, and a pin set mid-run applies from the next run.
6297
+ */
6298
+ runThinkingLevel(sessionId, requested) {
6299
+ return requested ?? this.deps.sessions.findById(sessionId)?.thinkingLevel ?? void 0;
6300
+ }
6301
+ launchTask(entry, input, requestedThinkingLevel) {
6302
+ const thinkingLevel = this.runThinkingLevel(entry.sessionId, requestedThinkingLevel);
6211
6303
  const channel = this.deps.channels.get(entry.sessionId);
6212
6304
  const ac = new AbortController();
6213
6305
  entry.status = "running";
@@ -6293,12 +6385,13 @@ var SessionManager = class {
6293
6385
  * or the run finished in the race window — the caller falls back to submitting a normal
6294
6386
  * task, which carries the same text and images.
6295
6387
  *
6296
- * `info` is the queued message's display summary: mirrored on the entry and broadcast via
6297
- * `task_state` (and the SSE subscribe snapshot) until the delivered `[user_steering]`
6298
- * message is observed on the stream — that is what keeps the composer's "steering queued"
6299
- * hint, content included, alive across reloads.
6388
+ * `recall` is the queued message's original content: its summary (with a fresh id) is
6389
+ * mirrored on the entry and broadcast via `task_state` (and the SSE subscribe snapshot)
6390
+ * until the delivered `[user_steering]` message is observed on the stream — that is what
6391
+ * keeps the composer's "steering queued" hint, content included, alive across reloads —
6392
+ * and the content itself is what a recall (recallSteering) hands back to the composer.
6300
6393
  */
6301
- steer(sessionId, input, info) {
6394
+ steer(sessionId, input, recall) {
6302
6395
  const entry = this.entries.get(sessionId);
6303
6396
  if (!entry || entry.status !== "running" || !entry.session.steer(input)) {
6304
6397
  throw new HttpError(
@@ -6307,9 +6400,68 @@ var SessionManager = class {
6307
6400
  "This Session has no Task in progress; send the message as a new task instead."
6308
6401
  );
6309
6402
  }
6310
- entry.pendingSteering.push(info);
6403
+ entry.pendingSteering.push({
6404
+ info: {
6405
+ id: randomUUID2(),
6406
+ text: recall.text,
6407
+ images: recall.images.length,
6408
+ files: recall.files.length
6409
+ },
6410
+ input,
6411
+ recall
6412
+ });
6413
+ entry.lastActivityMs = Date.now();
6414
+ this.publishState(entry, entry.status);
6415
+ }
6416
+ /**
6417
+ * Recall an undelivered steering message (#287): withdraw it from core's queue and the
6418
+ * display mirror, re-broadcast state, and hand back its original content for the composer
6419
+ * to restore. 409 `not_pending` when the entry is no longer queued — already delivered to
6420
+ * the model (core's `unsteer` says so authoritatively; the mirror can lag behind delivery
6421
+ * until the `[user_steering]` message is observed on the stream), the run exited, or the
6422
+ * id is unknown — all the same outcome for the caller: nothing left to take back.
6423
+ */
6424
+ recallSteering(sessionId, steerId) {
6425
+ const entry = this.entries.get(sessionId);
6426
+ const i = entry?.pendingSteering.findIndex((p) => p.info.id === steerId) ?? -1;
6427
+ const pending = i >= 0 ? entry.pendingSteering[i] : void 0;
6428
+ if (!entry || !pending || !(entry.session.unsteer?.(pending.input) ?? false)) {
6429
+ throw new HttpError(
6430
+ 409,
6431
+ "not_pending",
6432
+ "This steering message was already delivered to the model and can no longer be recalled."
6433
+ );
6434
+ }
6435
+ entry.pendingSteering.splice(i, 1);
6436
+ entry.lastActivityMs = Date.now();
6437
+ this.publishState(entry, entry.status);
6438
+ return pending.recall;
6439
+ }
6440
+ /**
6441
+ * Recall a queued follow-up task (#287): remove it from the queue before it auto-starts,
6442
+ * re-broadcast state, and hand back its original content (plus the thinking level it was
6443
+ * queued with). 409 `not_pending` when the id is not in the queue — it already
6444
+ * auto-started (or the runtime was evicted/restarted, which empties the queue) — or the
6445
+ * entry has no recall store to give back.
6446
+ */
6447
+ recallFollowUp(sessionId, followUpId) {
6448
+ const entry = this.entries.get(sessionId);
6449
+ const i = entry?.followUps.findIndex((f) => f.id === followUpId) ?? -1;
6450
+ const queued = i >= 0 ? entry.followUps[i] : void 0;
6451
+ if (!entry || !queued?.recall) {
6452
+ throw new HttpError(
6453
+ 409,
6454
+ "not_pending",
6455
+ "This follow-up message already started and can no longer be recalled."
6456
+ );
6457
+ }
6458
+ entry.followUps.splice(i, 1);
6311
6459
  entry.lastActivityMs = Date.now();
6312
6460
  this.publishState(entry, entry.status);
6461
+ return {
6462
+ recall: queued.recall,
6463
+ ...queued.thinkingLevel !== void 0 ? { thinkingLevel: queued.thinkingLevel } : {}
6464
+ };
6313
6465
  }
6314
6466
  /**
6315
6467
  * "Retry now" on the reconnect countdown: skip the in-progress backoff wait and fire
@@ -6354,6 +6506,32 @@ var SessionManager = class {
6354
6506
  if (killed) entry.lastActivityMs = Date.now();
6355
6507
  return killed;
6356
6508
  }
6509
+ /**
6510
+ * Removes one EXITED background command entry from a loaded session's process list.
6511
+ * A running process is refused ("running") — stopping is killProcess's job, and a
6512
+ * bare removal must never surprise-signal a live process group. "not_found" covers
6513
+ * an unloaded session and an unknown id alike: the entry is gone either way. The
6514
+ * removal itself reuses the kill path — core's registry removal signals the (dead)
6515
+ * group defensively and drops the row, exactly like input_command's post-exit reap.
6516
+ * The only race, an entry observed running that exits before a retry, is benign:
6517
+ * the caller gets "running", refreshes, and sees the row exited on the next look.
6518
+ *
6519
+ * Note what leaves with the row: the registry entry owns the ManagedSession holding
6520
+ * that process's captured output, so after a removal input_command on the same
6521
+ * process_id answers "unknown process_id". Removal is a deliberate discard, not just
6522
+ * a list tidy-up — the Web App says so at the button and in the docs.
6523
+ */
6524
+ removeProcess(sessionId, processId) {
6525
+ const entry = this.entries.get(sessionId);
6526
+ if (!entry) return "not_found";
6527
+ const info = entry.session.listBackgroundCommands?.().find((p) => p.processId === processId);
6528
+ if (!info) return "not_found";
6529
+ if (info.running) return "running";
6530
+ const removed = entry.session.killBackgroundCommand?.(processId) ?? false;
6531
+ if (!removed) return "not_found";
6532
+ entry.lastActivityMs = Date.now();
6533
+ return "removed";
6534
+ }
6357
6535
  /**
6358
6536
  * Disposes a just-removed entry's runtime — after its in-flight drive (if any)
6359
6537
  * settles, so interrupt cleanup never races a dying environment. Deleting a Session /
@@ -6571,7 +6749,6 @@ var SessionManager = class {
6571
6749
  * a title): it collects model text for automatic title generation.
6572
6750
  */
6573
6751
  async drive(entry, gen, titleSource) {
6574
- this.deps.sessions.markHasTrace(entry.sessionId);
6575
6752
  let earlyTitleFired = false;
6576
6753
  let mainBodyChars = 0;
6577
6754
  const ctx = {
@@ -6581,6 +6758,7 @@ var SessionManager = class {
6581
6758
  provider: entry.provider,
6582
6759
  modelId: entry.modelId
6583
6760
  };
6761
+ this.touchRow(ctx, (repo, at) => repo.markDriven(ctx.sessionId, at));
6584
6762
  const watcher = this.deps.errors ? new StreamErrorWatcher(this.deps.errors, {
6585
6763
  projectId: entry.projectId,
6586
6764
  agentId: entry.agentId,
@@ -6675,6 +6853,7 @@ var SessionManager = class {
6675
6853
  entry.running = null;
6676
6854
  entry.pendingSteering = [];
6677
6855
  entry.lastActivityMs = Date.now();
6856
+ this.touchRow(ctx, (repo, at) => repo.touchLastActive(ctx.sessionId, at));
6678
6857
  this.publishState(entry, "idle");
6679
6858
  if (titleSource && titleSource.userExcerpt.trim()) {
6680
6859
  this.deps.titles?.maybeGenerate(ctx, entry.session, {
@@ -6717,10 +6896,11 @@ var SessionManager = class {
6717
6896
  const childSid = msg.origin[msg.origin.length - 1];
6718
6897
  if (children.has(childSid)) return null;
6719
6898
  const p = msg.payload;
6720
- const agentId = path9.basename(path9.dirname(p.agent_state));
6899
+ const agentId = path8.basename(path8.dirname(p.agent_state));
6721
6900
  if (!agentId || agentId === "." || agentId === "..") return null;
6722
6901
  const source = asSessionSource(p.source) ?? "subagent";
6723
6902
  this.deps.sources.set(childSid, source);
6903
+ const createdAt = this.now().toISOString();
6724
6904
  this.deps.sessions.insertOrIgnore({
6725
6905
  sessionId: childSid,
6726
6906
  projectId: entry.projectId,
@@ -6734,7 +6914,10 @@ var SessionManager = class {
6734
6914
  title: null,
6735
6915
  // Spawned by this server's run (client NULL = web); its Trace exists by construction.
6736
6916
  hasTrace: true,
6737
- createdAt: (/* @__PURE__ */ new Date()).toISOString()
6917
+ // A subagent's own runs are driven through the PARENT entry's drive, so nothing ever
6918
+ // stamps this row: it stays at its registration time (see SessionRow.lastActiveAt).
6919
+ lastActiveAt: createdAt,
6920
+ createdAt
6738
6921
  });
6739
6922
  this.publishEvent(entry, {
6740
6923
  type: "session_created",
@@ -6753,12 +6936,50 @@ var SessionManager = class {
6753
6936
  children.set(childSid, child);
6754
6937
  return child;
6755
6938
  }
6939
+ /**
6940
+ * Runs one `sessions` row write for a driven run's bookkeeping (has_trace /
6941
+ * last_active_at), swallowing failures the same way the per-message `recorder.record`
6942
+ * call does: bookkeeping must never take down a run's lifecycle. The two call sites both
6943
+ * sit outside a covering try — the run-start write precedes drive's try block, the
6944
+ * run-end one lives in its finally — and the realistic failure (the DatabaseSync handle
6945
+ * closed by shutdown while a run outlived its drain window) throws ERR_INVALID_STATE.
6946
+ */
6947
+ touchRow(ctx, write) {
6948
+ try {
6949
+ write(this.deps.sessions, this.now().toISOString());
6950
+ } catch (err) {
6951
+ this.log(
6952
+ `[session] last-active bookkeeping failed: ${err instanceof Error ? err.message : String(err)}`
6953
+ );
6954
+ this.deps.errors?.record({ source: "session", err, ctx, code: "session_touch_failed" });
6955
+ }
6956
+ }
6756
6957
  publishState(entry, state) {
6757
6958
  this.publishEvent(entry, {
6758
6959
  type: "task_state",
6759
6960
  state,
6760
6961
  queued: entry.followUps.length,
6761
- ...entry.pendingSteering.length > 0 ? { pendingSteering: entry.pendingSteering } : {}
6962
+ ...entry.pendingSteering.length > 0 ? { pendingSteering: entry.pendingSteering.map((p) => p.info) } : {},
6963
+ ...entry.followUps.length > 0 ? { pendingFollowUps: entry.followUps.map(followUpInfo) } : {}
6964
+ });
6965
+ const notify = this.deps.notifyProjectUsers;
6966
+ if (!notify) return;
6967
+ let lastActiveAt;
6968
+ let hasTrace;
6969
+ try {
6970
+ const row = this.deps.sessions.findById(entry.sessionId);
6971
+ if (!row) return;
6972
+ lastActiveAt = row.lastActiveAt;
6973
+ hasTrace = row.hasTrace === true || state !== "idle";
6974
+ } catch {
6975
+ return;
6976
+ }
6977
+ notify(entry.projectId, {
6978
+ type: "session_state",
6979
+ sessionId: entry.sessionId,
6980
+ state,
6981
+ lastActiveAt,
6982
+ hasTrace
6762
6983
  });
6763
6984
  }
6764
6985
  publishEvent(entry, event) {
@@ -6780,7 +7001,7 @@ var SessionManager = class {
6780
7001
  };
6781
7002
 
6782
7003
  // src/runtime/scheduler.ts
6783
- import { createHash as createHash3 } from "crypto";
7004
+ import { createHash as createHash2 } from "crypto";
6784
7005
  import fs11 from "fs/promises";
6785
7006
  import { agentsDir, buildScheduledMessage, userText as userText2 } from "@prismshadow/penguin-core";
6786
7007
  var TICK_INTERVAL_MS = 3e4;
@@ -6957,7 +7178,7 @@ var Scheduler = class {
6957
7178
  }
6958
7179
  /** Register (or sync) run state; set the no-backfill baseline only at first registration / start_at reset. */
6959
7180
  registerEntry(projectId, agentId, ownerUserId, def, raw) {
6960
- const defHash = createHash3("sha1").update(raw).digest("hex");
7181
+ const defHash = createHash2("sha1").update(raw).digest("hex");
6961
7182
  const { row, fresh } = this.deps.repo.registerOrSync({
6962
7183
  projectId,
6963
7184
  agentId,
@@ -7240,7 +7461,7 @@ var UsageRecorder = class {
7240
7461
  };
7241
7462
 
7242
7463
  // src/services/ids.ts
7243
- import { randomBytes as randomBytes5 } from "crypto";
7464
+ import { randomBytes as randomBytes3 } from "crypto";
7244
7465
  var SEMANTIC_ID_PATTERN = /^[a-z][a-z0-9_]{1,63}$/;
7245
7466
  var USERNAME_PATTERN = /^[a-z][a-z0-9_]{1,31}$/;
7246
7467
  var PROJECT_SUFFIX_PATTERN = /^[a-z0-9_]+$/;
@@ -7317,9 +7538,9 @@ var AdminService = class {
7317
7538
  };
7318
7539
 
7319
7540
  // src/services/desktop-service.ts
7320
- import { createHash as createHash4, timingSafeEqual as timingSafeEqual3 } from "crypto";
7541
+ import { createHash as createHash3, timingSafeEqual as timingSafeEqual2 } from "crypto";
7321
7542
  function digest(value) {
7322
- return createHash4("sha256").update(value).digest();
7543
+ return createHash3("sha256").update(value).digest();
7323
7544
  }
7324
7545
  var DesktopService = class {
7325
7546
  tokenDigest;
@@ -7330,7 +7551,7 @@ var DesktopService = class {
7330
7551
  }
7331
7552
  /** Constant-time token check (no consumption). */
7332
7553
  verifyToken(candidate) {
7333
- return timingSafeEqual3(digest(candidate), this.tokenDigest);
7554
+ return timingSafeEqual2(digest(candidate), this.tokenDigest);
7334
7555
  }
7335
7556
  /** One-shot login redemption: true exactly once, for the correct token. */
7336
7557
  redeemLoginToken(candidate) {
@@ -7384,20 +7605,64 @@ import {
7384
7605
 
7385
7606
  // src/services/project-config-service.ts
7386
7607
  import fs12 from "fs/promises";
7387
- import path10 from "path";
7608
+ import path9 from "path";
7388
7609
  import { parse as parseToml2 } from "smol-toml";
7389
7610
  import {
7390
7611
  CHAT_APPROVAL_MODES,
7391
7612
  DEFAULT_CHAT_THINKING_LEVELS as DEFAULT_CHAT_THINKING_LEVELS2,
7392
7613
  GenerativeModel,
7614
+ canonicalClientType,
7393
7615
  catalogEntryFor,
7394
7616
  defaultProjectConfig,
7617
+ imageUrlMessage as imageUrlMessage2,
7395
7618
  projectConfigFromTable,
7396
7619
  projectConfigPath,
7397
7620
  renderProjectConfigToml,
7398
- resolveModelEnv,
7621
+ resolveModelEnv as resolveModelEnv2,
7399
7622
  userText as userText3
7400
7623
  } from "@prismshadow/penguin-core";
7624
+
7625
+ // src/services/vision-detect.ts
7626
+ var VISION_PROBE_IMAGE = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=";
7627
+ var VISION_PROBE_PROMPT = 'Reply with the single word "ok" and nothing else. Do not think or explain.\n<think></think>';
7628
+ var VISION_PROBE_MAX_TOKENS = 8;
7629
+ var VISION_PROBE_TIMEOUT_MS = 2e4;
7630
+ var UNSUPPORTED_PATTERNS = [
7631
+ "image_url",
7632
+ "image url",
7633
+ "does not support image",
7634
+ "not support image",
7635
+ "unsupported image",
7636
+ "image input",
7637
+ "image content",
7638
+ "invalid image",
7639
+ "vision",
7640
+ "multimodal",
7641
+ "modality",
7642
+ "image is not supported",
7643
+ "no support for image",
7644
+ "content type 'image",
7645
+ 'content type "image',
7646
+ "unsupported content",
7647
+ "cannot process image",
7648
+ "image not supported"
7649
+ ];
7650
+ function isImageRejection(message) {
7651
+ const m = message.toLowerCase();
7652
+ return UNSUPPORTED_PATTERNS.some((p) => m.includes(p));
7653
+ }
7654
+ function classifyVisionProbe(outcome, sawContent) {
7655
+ if (outcome.status === "completed") return "supported";
7656
+ if (sawContent) return "supported";
7657
+ const detail = "errorMessage" in outcome && outcome.errorMessage ? outcome.errorMessage : outcome.status;
7658
+ return isImageRejection(String(detail)) ? "unsupported" : "failed";
7659
+ }
7660
+ function classifyVisionProbeError(err) {
7661
+ const message = err instanceof Error ? err.message : String(err);
7662
+ return isImageRejection(message) ? "unsupported" : "failed";
7663
+ }
7664
+
7665
+ // src/services/project-config-service.ts
7401
7666
  function maskApiKey(key) {
7402
7667
  if (key.length <= 12) return "***";
7403
7668
  return `${key.slice(0, 4)}\u2026${key.slice(-4)}`;
@@ -7513,7 +7778,7 @@ var ProjectConfigService = class {
7513
7778
  */
7514
7779
  async writeRaw(projectId, data) {
7515
7780
  const file = this.filePath(projectId);
7516
- await fs12.mkdir(path10.dirname(file), { recursive: true });
7781
+ await fs12.mkdir(path9.dirname(file), { recursive: true });
7517
7782
  await fs12.writeFile(file, renderProjectConfigToml(data), { encoding: "utf8", mode: 384 });
7518
7783
  await fs12.chmod(file, 384);
7519
7784
  this.cache.delete(projectId);
@@ -7669,6 +7934,59 @@ var ProjectConfigService = class {
7669
7934
  * Tokens (single-digit output; speed mode spends up to its 64-token cap to have a
7670
7935
  * window worth timing), and writes no Trace and records no usage.
7671
7936
  */
7937
+ /**
7938
+ * Vision capability probe: sends one 1x1 PNG plus a one-word prompt and reports whether
7939
+ * the model took it (see services/vision-detect.ts for the verdict rules and why this
7940
+ * one costs real money, unlike the protocol probes).
7941
+ *
7942
+ * Credential resolution is the connectivity test's, verbatim — the request's key, else
7943
+ * the stored one, unless "clear" is checked. The environment layer needs no code here:
7944
+ * omitting apiKey lets AgentHub read the protocol's own variable, which is the same
7945
+ * chain protocol detection spells out by hand because it bypasses the SDK. Nothing
7946
+ * secret is returned; the failure message is the provider's own text, truncated.
7947
+ */
7948
+ async detectVision(projectId, req) {
7949
+ const raw = await this.readRaw(projectId);
7950
+ const entry = asArray(raw.models).find((m) => entryMatches(m, req.provider, req.modelId)) ?? {};
7951
+ const savedKey = optStr(entry.api_key);
7952
+ const apiKey = req.clearApiKey ? void 0 : req.apiKey ?? savedKey;
7953
+ const savedBaseUrl = optStr(entry.base_url);
7954
+ const baseUrl = req.baseUrl === null ? void 0 : req.baseUrl ?? savedBaseUrl;
7955
+ const clientType = canonicalClientType(req.clientType ?? optStr(entry.client_type));
7956
+ try {
7957
+ const llm = new GenerativeModel({
7958
+ modelId: req.modelId,
7959
+ ...apiKey ? { apiKey } : {},
7960
+ ...baseUrl ? { baseUrl } : {},
7961
+ ...clientType ? { clientType } : {},
7962
+ tools: [],
7963
+ thinkingLevel: "none",
7964
+ maxTokens: VISION_PROBE_MAX_TOKENS,
7965
+ requestTimeoutMs: VISION_PROBE_TIMEOUT_MS
7966
+ });
7967
+ const gen = llm.streamGenerate({
7968
+ newMessages: [userText3(VISION_PROBE_PROMPT), imageUrlMessage2(VISION_PROBE_IMAGE)]
7969
+ });
7970
+ let sawContent = false;
7971
+ for (; ; ) {
7972
+ const step = await gen.next();
7973
+ if (step.done) {
7974
+ const outcome = classifyVisionProbe(step.value, sawContent);
7975
+ if (outcome !== "failed") return { outcome };
7976
+ const detail = "errorMessage" in step.value && step.value.errorMessage ? step.value.errorMessage : step.value.status;
7977
+ return { outcome, message: String(detail).slice(0, 300) };
7978
+ }
7979
+ if (isProbeContent(step.value)) sawContent = true;
7980
+ }
7981
+ } catch (err) {
7982
+ const outcome = classifyVisionProbeError(err);
7983
+ if (outcome !== "failed") return { outcome };
7984
+ return {
7985
+ outcome,
7986
+ message: (err instanceof Error ? err.message : String(err)).slice(0, 300)
7987
+ };
7988
+ }
7989
+ }
7672
7990
  async testModel(projectId, req) {
7673
7991
  const raw = await this.readRaw(projectId);
7674
7992
  const entry = asArray(raw.models).find((m) => entryMatches(m, req.provider, req.modelId)) ?? {};
@@ -7676,7 +7994,8 @@ var ProjectConfigService = class {
7676
7994
  const apiKey = req.clearApiKey ? void 0 : req.apiKey ?? savedKey;
7677
7995
  const savedBaseUrl = optStr(entry.base_url);
7678
7996
  const baseUrl = req.baseUrl === null ? void 0 : req.baseUrl ?? savedBaseUrl;
7679
- const clientType = req.clientType ?? optStr(entry.client_type);
7997
+ const clientType = canonicalClientType(req.clientType ?? optStr(entry.client_type));
7998
+ const fastMode = req.fastMode ?? entry.fast_mode === true;
7680
7999
  const startedAt = Date.now();
7681
8000
  try {
7682
8001
  const llm = new GenerativeModel({
@@ -7684,6 +8003,7 @@ var ProjectConfigService = class {
7684
8003
  ...apiKey ? { apiKey } : {},
7685
8004
  ...baseUrl ? { baseUrl } : {},
7686
8005
  ...clientType ? { clientType } : {},
8006
+ ...fastMode ? { fastMode: true } : {},
7687
8007
  tools: [],
7688
8008
  thinkingLevel: "none",
7689
8009
  // Speed mode pairs a raised cap with a prompt that keeps generating (see
@@ -7728,6 +8048,31 @@ var ProjectConfigService = class {
7728
8048
  };
7729
8049
  }
7730
8050
  }
8051
+ /**
8052
+ * Protocol detection for a custom base URL (see services/protocol-detect.ts for the
8053
+ * probe order and classification). Credential resolution has three layers, in order:
8054
+ * 1. the request body's key (what the user just typed in the dialog);
8055
+ * 2. otherwise, when the optional paired reference names a stored entry and "clear"
8056
+ * isn't checked, that entry's saved key (the frontend only ever sees the mask);
8057
+ * 3. otherwise the environment variable for whichever protocol each probe speaks,
8058
+ * resolved inside detectModelProtocol because the protocol is the thing being
8059
+ * determined (ANTHROPIC_API_KEY for ant-messages, OPENAI_API_KEY for the two
8060
+ * OpenAI protocols).
8061
+ * Layers 2 and 3 are read server-side only and never travel back to the browser.
8062
+ * Detection still runs with no credential at all: a protocol-shaped 401/403 proves the
8063
+ * route. Never throws on probe failures — every outcome is reported per probe.
8064
+ */
8065
+ async detectProtocol(projectId, req) {
8066
+ let apiKey = req.apiKey;
8067
+ if (apiKey === void 0 && !req.clearApiKey && req.provider && req.modelId) {
8068
+ const raw = await this.readRaw(projectId);
8069
+ const entry = asArray(raw.models).find(
8070
+ (m) => entryMatches(m, req.provider, req.modelId)
8071
+ );
8072
+ apiKey = entry !== void 0 ? optStr(entry.api_key) : void 0;
8073
+ }
8074
+ return detectModelProtocol({ baseUrl: req.baseUrl, ...apiKey ? { apiKey } : {} });
8075
+ }
7731
8076
  /**
7732
8077
  * GET models view: masks credential (inline fields), flags the default Model;
7733
8078
  * the group is the entry's `provider` field, looked up in the built-in catalog by
@@ -7750,11 +8095,12 @@ var ProjectConfigService = class {
7750
8095
  cacheWrite: optNum(pricing.cache_write) ?? 0,
7751
8096
  output: optNum(pricing.output) ?? 0
7752
8097
  } : void 0;
7753
- const clientType = optStr(m.client_type);
8098
+ const clientType = canonicalClientType(optStr(m.client_type));
7754
8099
  const cat = catalogEntryFor(provider, modelId);
7755
- const envKey = resolveModelEnv(modelId, clientType)?.envKey;
8100
+ const envKey = resolveModelEnv2(modelId, clientType)?.envKey;
7756
8101
  const vision = typeof m.vision === "boolean" ? m.vision : cat?.supportsVision;
7757
8102
  const maxTokens = optNum(m.max_tokens);
8103
+ const fastMode = m.fast_mode === true ? true : void 0;
7758
8104
  const displayName = optStr(m.display_name) ?? cat?.displayName;
7759
8105
  const apiKey = optStr(m.api_key);
7760
8106
  const credBaseUrl = optStr(m.base_url);
@@ -7768,6 +8114,7 @@ var ProjectConfigService = class {
7768
8114
  ...clientType ? { clientType } : {},
7769
8115
  ...vision !== void 0 ? { vision } : {},
7770
8116
  ...maxTokens !== void 0 ? { maxTokens } : {},
8117
+ ...fastMode !== void 0 ? { fastMode } : {},
7771
8118
  ...envKey ? { envKey } : {},
7772
8119
  ...pricingDto ? { pricing: pricingDto } : {},
7773
8120
  ...apiKey !== void 0 || credBaseUrl !== void 0 ? {
@@ -7829,6 +8176,7 @@ var ProjectConfigService = class {
7829
8176
  delete next2.client_type;
7830
8177
  delete next2.vision;
7831
8178
  delete next2.max_tokens;
8179
+ delete next2.fast_mode;
7832
8180
  delete next2.pricing;
7833
8181
  delete next2.display_name;
7834
8182
  delete next2.request_model_id;
@@ -7837,9 +8185,10 @@ var ProjectConfigService = class {
7837
8185
  next2.display_name = entry.displayName;
7838
8186
  }
7839
8187
  if (entry.contextWindow !== void 0) next2.context_window = entry.contextWindow;
7840
- if (entry.clientType) next2.client_type = entry.clientType;
8188
+ if (entry.clientType) next2.client_type = canonicalClientType(entry.clientType);
7841
8189
  if (entry.vision !== void 0) next2.vision = entry.vision;
7842
8190
  if (entry.maxTokens !== void 0) next2.max_tokens = entry.maxTokens;
8191
+ if (entry.fastMode === true) next2.fast_mode = true;
7843
8192
  if (entry.pricing !== void 0) {
7844
8193
  next2.pricing = {
7845
8194
  unit: "usd_per_mtok",
@@ -7939,7 +8288,14 @@ function probeTps(outputTokens, windowMs) {
7939
8288
  }
7940
8289
 
7941
8290
  // src/services/agent-config-service.ts
7942
- var THINKING_LEVELS2 = ["none", "low", "medium", "high", "xhigh"];
8291
+ var THINKING_LEVELS2 = [
8292
+ "none",
8293
+ "low",
8294
+ "medium",
8295
+ "high",
8296
+ "xhigh",
8297
+ "max"
8298
+ ];
7943
8299
  var COMPACTION_MODES = ["summarize", "discard"];
7944
8300
  function asRecord(v) {
7945
8301
  return v !== null && typeof v === "object" && !Array.isArray(v) ? v : {};
@@ -8304,7 +8660,7 @@ function validateMcpServers(value) {
8304
8660
 
8305
8661
  // src/services/memory-service.ts
8306
8662
  import fs14 from "fs/promises";
8307
- import path11 from "path";
8663
+ import path10 from "path";
8308
8664
  import {
8309
8665
  MEMORY_INDEX_FILENAME,
8310
8666
  USER_SCOPE_KEY,
@@ -8365,7 +8721,7 @@ var MemoryService = class {
8365
8721
  } catch {
8366
8722
  }
8367
8723
  const workspaces = await Promise.all(
8368
- entries.map((key) => this.scopeInfo(path11.join(base, key), key, "workspace"))
8724
+ entries.map((key) => this.scopeInfo(path10.join(base, key), key, "workspace"))
8369
8725
  );
8370
8726
  workspaces.sort((a, b) => {
8371
8727
  if (a.updatedAt && b.updatedAt) return b.updatedAt.localeCompare(a.updatedAt);
@@ -8373,7 +8729,7 @@ var MemoryService = class {
8373
8729
  if (b.updatedAt) return 1;
8374
8730
  return a.scopeKey.localeCompare(b.scopeKey);
8375
8731
  });
8376
- const userScope = await this.scopeInfo(path11.join(base, USER_SCOPE_KEY), USER_SCOPE_KEY, "user");
8732
+ const userScope = await this.scopeInfo(path10.join(base, USER_SCOPE_KEY), USER_SCOPE_KEY, "user");
8377
8733
  return [userScope, ...workspaces];
8378
8734
  }
8379
8735
  async scopeInfo(dir, key, kind) {
@@ -8381,7 +8737,7 @@ var MemoryService = class {
8381
8737
  let latest = 0;
8382
8738
  for (const name of files) {
8383
8739
  try {
8384
- const stat = await fs14.stat(path11.join(dir, name));
8740
+ const stat = await fs14.stat(path10.join(dir, name));
8385
8741
  latest = Math.max(latest, stat.mtimeMs);
8386
8742
  } catch {
8387
8743
  }
@@ -8447,7 +8803,7 @@ var MemoryService = class {
8447
8803
  }
8448
8804
  /** Removes index lines linking to a deleted file. Best-effort: a missing or unreadable index is left alone. */
8449
8805
  async pruneIndexLines(dir, fileName) {
8450
- const indexPath = path11.join(dir, MEMORY_INDEX_FILENAME);
8806
+ const indexPath = path10.join(dir, MEMORY_INDEX_FILENAME);
8451
8807
  let content;
8452
8808
  try {
8453
8809
  content = await fs14.readFile(indexPath, "utf8");
@@ -8464,8 +8820,8 @@ var MemoryService = class {
8464
8820
  async fileInfo(dir, name) {
8465
8821
  try {
8466
8822
  const [content, stat] = await Promise.all([
8467
- fs14.readFile(path11.join(dir, name), "utf8"),
8468
- fs14.stat(path11.join(dir, name))
8823
+ fs14.readFile(path10.join(dir, name), "utf8"),
8824
+ fs14.stat(path10.join(dir, name))
8469
8825
  ]);
8470
8826
  return this.describe(name, content, stat.size, stat.mtime);
8471
8827
  } catch {
@@ -8524,7 +8880,7 @@ var MemoryService = class {
8524
8880
  async requireRealScopeDir(projectId, agentId, scopeKey, dir) {
8525
8881
  try {
8526
8882
  const realBase = await fs14.realpath(memoryDir(this.root, projectId, agentId));
8527
- if (await fs14.realpath(dir) === path11.join(realBase, scopeKey)) return dir;
8883
+ if (await fs14.realpath(dir) === path10.join(realBase, scopeKey)) return dir;
8528
8884
  } catch {
8529
8885
  }
8530
8886
  throw new HttpError(
@@ -8544,9 +8900,9 @@ var MemoryService = class {
8544
8900
  `File name must be a Markdown topic file \u2014 no path, no leading dot, and not the ${MEMORY_INDEX_FILENAME} index.`
8545
8901
  );
8546
8902
  }
8547
- const target = path11.join(dir, fileName);
8548
- const rel = path11.relative(dir, target);
8549
- if (rel !== fileName || rel.includes(path11.sep)) {
8903
+ const target = path10.join(dir, fileName);
8904
+ const rel = path10.relative(dir, target);
8905
+ if (rel !== fileName || rel.includes(path10.sep)) {
8550
8906
  throw badRequest("File name must not contain a path.");
8551
8907
  }
8552
8908
  return target;
@@ -8555,7 +8911,7 @@ var MemoryService = class {
8555
8911
 
8556
8912
  // src/services/agent-service.ts
8557
8913
  import fs15 from "fs/promises";
8558
- import path12 from "path";
8914
+ import path11 from "path";
8559
8915
  import {
8560
8916
  agentDir,
8561
8917
  agentsDir as agentsDir2,
@@ -8656,7 +9012,7 @@ var AgentService = class {
8656
9012
  const present = await Promise.all(
8657
9013
  dirents.filter((d) => d.isDirectory()).map(async (d) => {
8658
9014
  try {
8659
- await fs15.access(path12.join(base, d.name, "SKILL.md"));
9015
+ await fs15.access(path11.join(base, d.name, "SKILL.md"));
8660
9016
  return true;
8661
9017
  } catch {
8662
9018
  return false;
@@ -8677,7 +9033,7 @@ var AgentService = class {
8677
9033
  const counts = await Promise.all(
8678
9034
  scopes.filter((d) => d.isDirectory()).map(async (d) => {
8679
9035
  try {
8680
- const files = await fs15.readdir(path12.join(base, d.name), { withFileTypes: true });
9036
+ const files = await fs15.readdir(path11.join(base, d.name), { withFileTypes: true });
8681
9037
  return files.filter((f) => f.isFile() && isTopicFileName(f.name)).length;
8682
9038
  } catch {
8683
9039
  return 0;
@@ -8778,7 +9134,7 @@ var AgentService = class {
8778
9134
 
8779
9135
  // src/services/benchmark-service.ts
8780
9136
  import fs16 from "fs/promises";
8781
- import path13 from "path";
9137
+ import path12 from "path";
8782
9138
  import { parse as parseToml3 } from "smol-toml";
8783
9139
  import { parse as parseYaml2 } from "yaml";
8784
9140
  import { benchmarksDir } from "@prismshadow/penguin-core";
@@ -8809,8 +9165,8 @@ function stringOr(v) {
8809
9165
  return typeof v === "string" && v !== "" ? v : void 0;
8810
9166
  }
8811
9167
  function isWithin(parent, child) {
8812
- const relative = path13.relative(parent, child);
8813
- return relative !== "" && !relative.startsWith("..") && !path13.isAbsolute(relative);
9168
+ const relative = path12.relative(parent, child);
9169
+ return relative !== "" && !relative.startsWith("..") && !path12.isAbsolute(relative);
8814
9170
  }
8815
9171
  function statementTitle(statement, fallback) {
8816
9172
  const heading = /^#\s+(.+)$/m.exec(statement)?.[1]?.trim();
@@ -8906,13 +9262,13 @@ var BenchmarkService = class {
8906
9262
  }
8907
9263
  const benchmarks = [];
8908
9264
  for (const item of items.filter((i) => i.isDir).sort((a, b) => a.name.localeCompare(b.name))) {
8909
- benchmarks.push(await this.readBenchmark(path13.join(dir, item.name), item.name));
9265
+ benchmarks.push(await this.readBenchmark(path12.join(dir, item.name), item.name));
8910
9266
  }
8911
9267
  return { benchmarks };
8912
9268
  }
8913
9269
  async listCases(projectId, agentId, benchmarkId) {
8914
9270
  const baseDir = benchmarksDir(this.root, projectId, agentId);
8915
- const benchDir = path13.join(baseDir, benchmarkId);
9271
+ const benchDir = path12.join(baseDir, benchmarkId);
8916
9272
  let entries;
8917
9273
  let realBaseDir;
8918
9274
  let realBenchDir;
@@ -8937,7 +9293,7 @@ var BenchmarkService = class {
8937
9293
  entry.name,
8938
9294
  "statement"
8939
9295
  );
8940
- const realReadme = await fs16.realpath(path13.join(statementDir, "README.md"));
9296
+ const realReadme = await fs16.realpath(path12.join(statementDir, "README.md"));
8941
9297
  if (!isWithin(statementDir, realReadme)) throw new Error("README escapes Statement");
8942
9298
  cases.push({
8943
9299
  id: entry.name,
@@ -8970,16 +9326,16 @@ var BenchmarkService = class {
8970
9326
  return this.workspaceFiles.read(materialRoot, rel, options);
8971
9327
  }
8972
9328
  async caseMaterialRoot(projectId, agentId, benchmarkId, caseId, material) {
8973
- const benchDir = path13.join(benchmarksDir(this.root, projectId, agentId), benchmarkId);
8974
- const caseDir = path13.join(benchDir, caseId);
8975
- const materialRoot = path13.join(caseDir, material);
9329
+ const benchDir = path12.join(benchmarksDir(this.root, projectId, agentId), benchmarkId);
9330
+ const caseDir = path12.join(benchDir, caseId);
9331
+ const materialRoot = path12.join(caseDir, material);
8976
9332
  try {
8977
9333
  const [realBenchDir, realCaseDir, realMaterialRoot] = await Promise.all([
8978
9334
  fs16.realpath(benchDir),
8979
9335
  fs16.realpath(caseDir),
8980
9336
  fs16.realpath(materialRoot)
8981
9337
  ]);
8982
- if (!isWithin(realBenchDir, realCaseDir) || path13.dirname(realMaterialRoot) !== realCaseDir || path13.basename(realMaterialRoot) !== material) {
9338
+ if (!isWithin(realBenchDir, realCaseDir) || path12.dirname(realMaterialRoot) !== realCaseDir || path12.basename(realMaterialRoot) !== material) {
8983
9339
  throw new Error("Case material is not canonical");
8984
9340
  }
8985
9341
  return realMaterialRoot;
@@ -8993,7 +9349,7 @@ var BenchmarkService = class {
8993
9349
  let runs;
8994
9350
  try {
8995
9351
  const config2 = asRecord2(
8996
- parseToml3(await fs16.readFile(path13.join(benchDir, "benchmark_config.toml"), "utf8"))
9352
+ parseToml3(await fs16.readFile(path12.join(benchDir, "benchmark_config.toml"), "utf8"))
8997
9353
  );
8998
9354
  if (typeof config2.title === "string" && config2.title !== "") title = config2.title;
8999
9355
  if (typeof config2.description === "string" && config2.description !== "") {
@@ -9008,7 +9364,7 @@ var BenchmarkService = class {
9008
9364
  let evaluations = [];
9009
9365
  try {
9010
9366
  const scoreboard = asRecord2(
9011
- parseYaml2(await fs16.readFile(path13.join(benchDir, "scoreboard.yaml"), "utf8"))
9367
+ parseYaml2(await fs16.readFile(path12.join(benchDir, "scoreboard.yaml"), "utf8"))
9012
9368
  );
9013
9369
  if (Array.isArray(scoreboard.evaluations)) {
9014
9370
  evaluations = scoreboard.evaluations.map(toEvaluation).filter((e) => e !== null);
@@ -9033,9 +9389,9 @@ var BenchmarkService = class {
9033
9389
  };
9034
9390
 
9035
9391
  // src/services/snapshot-service.ts
9036
- import { randomBytes as randomBytes6 } from "crypto";
9392
+ import { randomBytes as randomBytes4 } from "crypto";
9037
9393
  import fs17 from "fs/promises";
9038
- import path14 from "path";
9394
+ import path13 from "path";
9039
9395
  import * as tar from "tar";
9040
9396
  import { parse as parseYaml3 } from "yaml";
9041
9397
  import {
@@ -9048,7 +9404,7 @@ import {
9048
9404
  } from "@prismshadow/penguin-core";
9049
9405
  var VAULT_BASENAME = ".vault.toml";
9050
9406
  function isVaultEntry(entryPath) {
9051
- return path14.posix.basename(entryPath.replaceAll("\\", "/")) === VAULT_BASENAME;
9407
+ return path13.posix.basename(entryPath.replaceAll("\\", "/")) === VAULT_BASENAME;
9052
9408
  }
9053
9409
  var SnapshotService = class {
9054
9410
  constructor(root) {
@@ -9072,14 +9428,14 @@ var SnapshotService = class {
9072
9428
  async ensureSnapshot(projectId, agentId) {
9073
9429
  const version = await this.currentVersion(projectId, agentId);
9074
9430
  const dir = snapshotsDir(this.root, projectId, agentId);
9075
- const file = path14.join(dir, `v${version}.tar.gz`);
9431
+ const file = path13.join(dir, `v${version}.tar.gz`);
9076
9432
  try {
9077
9433
  await fs17.access(file);
9078
9434
  return { version, file };
9079
9435
  } catch {
9080
9436
  }
9081
9437
  await fs17.mkdir(dir, { recursive: true });
9082
- const tmp = `${file}.tmp-${randomBytes6(4).toString("hex")}`;
9438
+ const tmp = `${file}.tmp-${randomBytes4(4).toString("hex")}`;
9083
9439
  await tar.create(
9084
9440
  {
9085
9441
  gzip: true,
@@ -9107,12 +9463,12 @@ var SnapshotService = class {
9107
9463
  async importArchive(projectId, agentId, archive, confirm) {
9108
9464
  const current2 = await this.currentVersion(projectId, agentId);
9109
9465
  const base = agentDir2(this.root, projectId, agentId);
9110
- const staging = path14.join(base, `.import-${randomBytes6(6).toString("hex")}`);
9466
+ const staging = path13.join(base, `.import-${randomBytes4(6).toString("hex")}`);
9111
9467
  await fs17.mkdir(staging, { recursive: true });
9112
9468
  try {
9113
- const archiveFile = path14.join(staging, "archive.tar.gz");
9469
+ const archiveFile = path13.join(staging, "archive.tar.gz");
9114
9470
  await fs17.writeFile(archiveFile, archive);
9115
- const extractDir = path14.join(staging, "extracted");
9471
+ const extractDir = path13.join(staging, "extracted");
9116
9472
  await fs17.mkdir(extractDir, { recursive: true });
9117
9473
  try {
9118
9474
  await tar.extract({
@@ -9123,7 +9479,7 @@ var SnapshotService = class {
9123
9479
  } catch {
9124
9480
  throw badRequest("Import failed: not a valid tar.gz snapshot package.");
9125
9481
  }
9126
- const configPath = path14.join(extractDir, "agent_state", "system_config.yaml");
9482
+ const configPath = path13.join(extractDir, "agent_state", "system_config.yaml");
9127
9483
  let parsed;
9128
9484
  try {
9129
9485
  parsed = parseYaml3(await fs17.readFile(configPath, "utf8"));
@@ -9149,16 +9505,16 @@ var SnapshotService = class {
9149
9505
  }
9150
9506
  await this.ensureSnapshot(projectId, agentId);
9151
9507
  const stateDir = agentStateDir2(this.root, projectId, agentId);
9152
- const incomingState = path14.join(extractDir, "agent_state");
9508
+ const incomingState = path13.join(extractDir, "agent_state");
9153
9509
  let vault = null;
9154
9510
  try {
9155
9511
  vault = await fs17.readFile(agentVaultPath(this.root, projectId, agentId));
9156
9512
  } catch {
9157
9513
  }
9158
9514
  if (vault !== null) {
9159
- await fs17.writeFile(path14.join(incomingState, VAULT_BASENAME), vault, { mode: 384 });
9515
+ await fs17.writeFile(path13.join(incomingState, VAULT_BASENAME), vault, { mode: 384 });
9160
9516
  }
9161
- const trash = path14.join(staging, "replaced-agent_state");
9517
+ const trash = path13.join(staging, "replaced-agent_state");
9162
9518
  await fs17.rename(stateDir, trash);
9163
9519
  try {
9164
9520
  await fs17.rename(incomingState, stateDir);
@@ -9483,7 +9839,7 @@ import { createAgent as createAgent2, isSessionMeta as isSessionMeta5 } from "@p
9483
9839
 
9484
9840
  // src/services/trace-index.ts
9485
9841
  import fs19 from "fs/promises";
9486
- import path15 from "path";
9842
+ import path14 from "path";
9487
9843
  import { agentsDir as agentsDir3, isSessionMeta as isSessionMeta4, tracesDir as tracesDir2 } from "@prismshadow/penguin-core";
9488
9844
 
9489
9845
  // src/internal/trace-head.ts
@@ -9516,7 +9872,7 @@ async function readTraceHead(filePath) {
9516
9872
  var TRACE_FILE_RE = /^(.+)_(\d{3})\.jsonl$/;
9517
9873
  function traceFilePath(root, row) {
9518
9874
  const name = `${row.sessionId}_${String(row.fileIndex).padStart(3, "0")}.jsonl`;
9519
- return path15.join(tracesDir2(root, row.projectId, row.agentId), row.date, name);
9875
+ return path14.join(tracesDir2(root, row.projectId, row.agentId), row.date, name);
9520
9876
  }
9521
9877
  async function listDirs(dir) {
9522
9878
  try {
@@ -9576,9 +9932,10 @@ var TraceIndexService = class {
9576
9932
  */
9577
9933
  counters = { gateStats: 0, dirScans: 0, headReads: 0 };
9578
9934
  /**
9579
- * Brings one Agent's index in step with disk. Hot path (nothing changed): two stat
9580
- * calls, zero readdir. `force` ignores the mtime gate and diffs every date dir (the
9581
- * consumers' miss-retry path).
9935
+ * Brings one Agent's index in step with disk. Hot path: one root stat, then the
9936
+ * newest date dir — a change there short-circuits — else the remaining N-1 date dirs
9937
+ * concurrently; zero readdir either way. `force` ignores the mtime gate and diffs
9938
+ * every date dir (the consumers' miss-retry path).
9582
9939
  */
9583
9940
  reconcileAgent(projectId, agentId, opts = {}) {
9584
9941
  const key = `${projectId}\0${agentId}`;
@@ -9610,11 +9967,18 @@ var TraceIndexService = class {
9610
9967
  return;
9611
9968
  }
9612
9969
  if (!force && cached && cached.root === rootMtime) {
9613
- const newest = [...cached.dates.keys()].sort().at(-1);
9970
+ const dates = [...cached.dates.keys()].sort();
9971
+ const newest = dates.pop();
9614
9972
  if (newest === void 0) return;
9615
9973
  this.counters.gateStats += 1;
9616
- const m = await statMtime(path15.join(dir, newest));
9617
- if (m !== null && m === cached.dates.get(newest)) return;
9974
+ const newestMtime = await statMtime(path14.join(dir, newest));
9975
+ let changed = newestMtime === null || newestMtime !== cached.dates.get(newest);
9976
+ if (!changed && dates.length > 0) {
9977
+ this.counters.gateStats += dates.length;
9978
+ const older = await Promise.all(dates.map((d) => statMtime(path14.join(dir, d))));
9979
+ changed = older.some((m, i) => m === null || m !== cached.dates.get(dates[i]));
9980
+ }
9981
+ if (!changed) return;
9618
9982
  }
9619
9983
  this.counters.dirScans += 1;
9620
9984
  const next = { root: cacheable(rootMtime), dates: /* @__PURE__ */ new Map() };
@@ -9627,14 +9991,14 @@ var TraceIndexService = class {
9627
9991
  const newSessions = /* @__PURE__ */ new Set();
9628
9992
  const dateDirs = await listDirs(dir);
9629
9993
  for (const date of dateDirs) {
9630
- const m = await statMtime(path15.join(dir, date));
9994
+ const m = await statMtime(path14.join(dir, date));
9631
9995
  if (m === null) continue;
9632
9996
  next.dates.set(date, cacheable(m));
9633
9997
  if (!force && cached?.dates.get(date) === m) continue;
9634
9998
  const known = new Map(
9635
9999
  (knownByDate.get(date) ?? []).map((r) => [`${r.sessionId}\0${r.fileIndex}`, r])
9636
10000
  );
9637
- for (const file of await listFiles(path15.join(dir, date))) {
10001
+ for (const file of await listFiles(path14.join(dir, date))) {
9638
10002
  const match = TRACE_FILE_RE.exec(file);
9639
10003
  if (!match) continue;
9640
10004
  const sessionId = match[1];
@@ -9642,7 +10006,7 @@ var TraceIndexService = class {
9642
10006
  const fileKey = `${sessionId}\0${fileIndex}`;
9643
10007
  let size = 0;
9644
10008
  try {
9645
- size = (await fs19.stat(path15.join(dir, date, file))).size;
10009
+ size = (await fs19.stat(path14.join(dir, date, file))).size;
9646
10010
  } catch {
9647
10011
  continue;
9648
10012
  }
@@ -9748,9 +10112,11 @@ var SessionService = class {
9748
10112
  modelId: row.modelId,
9749
10113
  workspace: row.workspace,
9750
10114
  approvalMode: row.approvalMode,
10115
+ ...row.thinkingLevel ? { thinkingLevel: row.thinkingLevel } : {},
9751
10116
  ...row.title !== null ? { title: row.title } : {},
9752
10117
  ...source !== void 0 ? { source } : {},
9753
10118
  createdAt: row.createdAt,
10119
+ lastActiveAt: row.lastActiveAt,
9754
10120
  status: this.deps.manager.statusOf(row.sessionId),
9755
10121
  pendingApprovalCount: this.deps.manager.pendingApprovalCount(row.sessionId),
9756
10122
  pendingFollowUpCount: this.deps.manager.pendingFollowUpCount(row.sessionId),
@@ -9962,6 +10328,7 @@ var SessionService = class {
9962
10328
  session.sessionId,
9963
10329
  isSessionMeta5(metaMsg) ? asSessionSource(metaMsg.payload.source) ?? null : null
9964
10330
  );
10331
+ const createdAt = (/* @__PURE__ */ new Date()).toISOString();
9965
10332
  const row = {
9966
10333
  sessionId: session.sessionId,
9967
10334
  projectId: args.projectId,
@@ -9974,7 +10341,9 @@ var SessionService = class {
9974
10341
  // Everything created through this service is the Web App's (schedule runs included);
9975
10342
  // "cli" is stamped only by Trace adoption. NULL means a legacy row, treated as web.
9976
10343
  client: "web",
9977
- createdAt: (/* @__PURE__ */ new Date()).toISOString()
10344
+ // Creation is the first activity; the first driven run advances it (see SessionManager.drive).
10345
+ lastActiveAt: createdAt,
10346
+ createdAt
9978
10347
  };
9979
10348
  this.deps.sessions.insert(row);
9980
10349
  this.deps.manager.adopt(row, session);
@@ -10029,6 +10398,7 @@ var SessionService = class {
10029
10398
  if (!facts?.metaRead) return null;
10030
10399
  if (facts.provider === null || facts.modelId === null) return null;
10031
10400
  this.deps.sources.set(sessionId, facts.source);
10401
+ const createdAt = sessionIdCreatedAt(sessionId) ?? facts.firstTs ?? (/* @__PURE__ */ new Date()).toISOString();
10032
10402
  const row = {
10033
10403
  sessionId,
10034
10404
  projectId,
@@ -10043,7 +10413,11 @@ var SessionService = class {
10043
10413
  // (web-only) excludes these rows; the "show CLI sessions" preference includes them.
10044
10414
  client: "cli",
10045
10415
  hasTrace: true,
10046
- createdAt: sessionIdCreatedAt(sessionId) ?? facts.firstTs ?? (/* @__PURE__ */ new Date()).toISOString()
10416
+ createdAt,
10417
+ // The CLI's own activity leaves no mark on this row (this server drives none of its
10418
+ // runs), so an adopted Session reads as last-active at its creation time until it is
10419
+ // resumed here; the Trace tail is not consulted (see SessionRow.lastActiveAt).
10420
+ lastActiveAt: createdAt
10047
10421
  };
10048
10422
  this.deps.sessions.insertOrIgnore(row);
10049
10423
  return row;
@@ -10056,16 +10430,69 @@ function localDate(d) {
10056
10430
 
10057
10431
  // src/services/trace-service.ts
10058
10432
  import fs20 from "fs/promises";
10059
- import path16 from "path";
10433
+ import path15 from "path";
10434
+ import { randomUUID as randomUUID3 } from "crypto";
10060
10435
  import {
10436
+ attachedFileLine as attachedFileLine2,
10437
+ attachedImageLine,
10061
10438
  isSessionMeta as isSessionMeta6,
10439
+ matchAttachedFileLine,
10440
+ matchAttachedImageLine,
10441
+ modelVisiblePath as modelVisiblePath2,
10062
10442
  parseTraceLines as parseTraceLines2,
10063
10443
  parseUserSteeringText as parseUserSteeringText3,
10064
10444
  readTraceTolerant as readTraceTolerant2,
10445
+ resumeTrace,
10446
+ scratchpadDir as scratchpadDir2,
10065
10447
  tracesDir as tracesDir3
10066
10448
  } from "@prismshadow/penguin-core";
10067
10449
  var IMPORT_SESSION_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
10068
10450
  var MAX_SUBAGENT_DEPTH = 4;
10451
+ function forkSessionId(date) {
10452
+ const pad = (n) => String(n).padStart(2, "0");
10453
+ return `session-${formatLocalDate(date)}-${pad(date.getHours())}-${pad(date.getMinutes())}-${pad(date.getSeconds())}-${randomUUID3().replaceAll("-", "").slice(0, 8)}`;
10454
+ }
10455
+ function isAssistantText(msg) {
10456
+ const p = msg.payload;
10457
+ return msg.type === "model_msg" && p.type === "text" && p.role === "assistant" && typeof p.text === "string" && p.text.trim() !== "";
10458
+ }
10459
+ function isTaskStartingUser(msg) {
10460
+ const p = msg.payload;
10461
+ if (msg.type !== "model_msg") return false;
10462
+ if (p.type === "image_url") return true;
10463
+ if (p.type !== "text" || p.role !== "user" || typeof p.text !== "string") return false;
10464
+ if (parseUserSteeringText3(p.text) !== null) return false;
10465
+ return !p.text.startsWith("[context_summary]") && !p.text.startsWith("<context_summary>");
10466
+ }
10467
+ function rewriteForkText(text, sourceScratchpad, forkScratchpad) {
10468
+ const source = modelVisiblePath2(sourceScratchpad).replace(/\/$/, "");
10469
+ const target = modelVisiblePath2(forkScratchpad).replace(/\/$/, "");
10470
+ const rewriteTarget = (value) => {
10471
+ if (value === source) return target;
10472
+ if (value.startsWith(`${source}/`)) return `${target}${value.slice(source.length)}`;
10473
+ return null;
10474
+ };
10475
+ return text.split("\n").map((line) => {
10476
+ const trimmed = line.trim();
10477
+ const image = matchAttachedImageLine(trimmed);
10478
+ if (image !== null) {
10479
+ const rewritten = rewriteTarget(image);
10480
+ return rewritten === null ? line : line.replace(trimmed, attachedImageLine(rewritten));
10481
+ }
10482
+ const file = matchAttachedFileLine(trimmed);
10483
+ if (file !== null) {
10484
+ const rewritten = rewriteTarget(file);
10485
+ return rewritten === null ? line : line.replace(trimmed, attachedFileLine2(rewritten));
10486
+ }
10487
+ return line;
10488
+ }).join("\n");
10489
+ }
10490
+ function positioned(messages, fileIndex, fromOrdinal = 0) {
10491
+ return messages.map((msg, i) => ({
10492
+ ...msg,
10493
+ tracePosition: { fileIndex, ordinal: fromOrdinal + i }
10494
+ }));
10495
+ }
10069
10496
  function subagentPointer(msg) {
10070
10497
  if (msg.type !== "event_msg") return null;
10071
10498
  const p = msg.payload;
@@ -10084,8 +10511,10 @@ var TraceService = class {
10084
10511
  /**
10085
10512
  * All of this Session's Trace files (sorted by index ascending), served from the
10086
10513
  * index. An empty answer force-reconciles once and retries before being believed:
10087
- * disk is the source of truth, and a gate blind spot (an external write into an old
10088
- * date dir) must cost one extra scan, never a false 404.
10514
+ * disk is the source of truth, and a gate blind spot (a backdated write, or an
10515
+ * in-place append, which moves no directory mtime — see trace-index's header) must
10516
+ * cost one extra scan, never a false 404. Note this retry only covers a WHOLE-session
10517
+ * miss: a session with some shards indexed and a later one missed is served as-is.
10089
10518
  */
10090
10519
  async locateAll(projectId, agentId, sessionId) {
10091
10520
  await this.deps.index.reconcileAgent(projectId, agentId);
@@ -10140,7 +10569,8 @@ var TraceService = class {
10140
10569
  const files = await this.locateAll(projectId, agentId, sessionId);
10141
10570
  const out = [];
10142
10571
  for (const file of files) {
10143
- out.push(...await this.expandMessages(projectId, await this.readShard(file.path), ctx));
10572
+ const raw = await this.readShard(file.path);
10573
+ out.push(...await this.expandMessages(projectId, positioned(raw, file.index), ctx));
10144
10574
  }
10145
10575
  return out;
10146
10576
  }
@@ -10349,11 +10779,154 @@ var TraceService = class {
10349
10779
  const messages = shardMessages.get(pos) ?? await this.readShard(files[pos].path);
10350
10780
  const from = pos === start.pos ? start.ordinal : 0;
10351
10781
  const to = pos === endPos && endOrdinal !== null ? Math.min(endOrdinal, messages.length) : messages.length;
10352
- for (let i = from; i < to; i++) windowRaw.push(messages[i]);
10782
+ for (let i = from; i < to; i++) {
10783
+ windowRaw.push({
10784
+ ...messages[i],
10785
+ tracePosition: { fileIndex: files[pos].index, ordinal: i }
10786
+ });
10787
+ }
10353
10788
  }
10354
10789
  const expanded = await this.expandMessages(projectId, windowRaw, ctx);
10355
10790
  return { messages: expanded, ...before !== void 0 ? { before } : {}, prior };
10356
10791
  }
10792
+ /**
10793
+ * Clone a source Session through one completed root-assistant reply. The supplied position
10794
+ * names the reply record itself; this method validates it against raw Trace structure and
10795
+ * chooses the exclusive cut after the request's closing records but before a later Task or
10796
+ * compaction. Earlier shards stay intact so the fork renders the same history, while core
10797
+ * resumes from the selected shard exactly as it would after a restart.
10798
+ */
10799
+ async forkSessionTrace(projectId, agentId, sourceSessionId, position) {
10800
+ const invalid = (message) => new HttpError(400, "invalid_fork_position", message);
10801
+ const files = await this.locateAll(projectId, agentId, sourceSessionId);
10802
+ const targetFilePos = files.findIndex((file) => file.index === position.fileIndex);
10803
+ if (targetFilePos < 0) throw invalid("The selected reply no longer exists in this Session.");
10804
+ const shards = [];
10805
+ for (let i = 0; i <= targetFilePos; i++) shards.push(await this.readShard(files[i].path));
10806
+ const targetShard = shards[targetFilePos];
10807
+ const target = targetShard[position.ordinal];
10808
+ if (!target || !isAssistantText(target)) {
10809
+ throw invalid("The fork position must identify a completed assistant reply.");
10810
+ }
10811
+ let inCompaction = false;
10812
+ for (let filePos = 0; filePos <= targetFilePos; filePos++) {
10813
+ const messages = shards[filePos];
10814
+ const stop = filePos === targetFilePos ? position.ordinal : messages.length;
10815
+ for (let i = 0; i < stop; i++) {
10816
+ const p = messages[i].payload;
10817
+ if (messages[i].type === "event_msg" && p.type === "compaction_begin") {
10818
+ inCompaction = true;
10819
+ } else if (messages[i].type === "event_msg" && p.type === "compaction_end") {
10820
+ inCompaction = false;
10821
+ }
10822
+ }
10823
+ }
10824
+ if (inCompaction) throw invalid("A compaction summary cannot be used as a fork point.");
10825
+ let cutOrdinal = position.ordinal + 1;
10826
+ let completedRequest = false;
10827
+ for (let i = position.ordinal + 1; i < targetShard.length; i++) {
10828
+ const msg = targetShard[i];
10829
+ const p = msg.payload;
10830
+ if (isTaskStartingUser(msg) || msg.type === "event_msg" && p.type === "compaction_begin") {
10831
+ break;
10832
+ }
10833
+ if (isAssistantText(msg)) {
10834
+ throw invalid("The selected record is not the final assistant reply of its Task.");
10835
+ }
10836
+ cutOrdinal = i + 1;
10837
+ if (msg.type === "event_msg" && p.type === "request_end") {
10838
+ if (p.status === "completed") completedRequest = true;
10839
+ else throw invalid("The selected assistant reply belongs to an incomplete request.");
10840
+ }
10841
+ }
10842
+ if (!completedRequest) throw invalid("The selected assistant reply has not completed yet.");
10843
+ const resumed = resumeTrace(targetShard.slice(0, cutOrdinal));
10844
+ if (resumed.contextClosed || resumed.carryOver.length > 0 || !resumed.history.some((message) => message === target)) {
10845
+ throw invalid("The selected reply is not a complete resumable Task boundary.");
10846
+ }
10847
+ const firstMeta = shards.flat().find(isSessionMeta6);
10848
+ if (!firstMeta) throw invalid("The source Trace has no session metadata.");
10849
+ const created = /* @__PURE__ */ new Date();
10850
+ const createdAt = created.toISOString();
10851
+ const newSessionId = forkSessionId(created);
10852
+ const scratchpadRoot = scratchpadDir2(this.root, projectId, agentId);
10853
+ const sourceScratchpad = path15.join(scratchpadRoot, sourceSessionId);
10854
+ const forkScratchpad = path15.join(scratchpadRoot, newSessionId);
10855
+ const written = [];
10856
+ const rewrite = (msg) => {
10857
+ if (isSessionMeta6(msg)) {
10858
+ const sourcePrompt = msg.payload.system_prompt;
10859
+ const sourceVisible = modelVisiblePath2(sourceScratchpad);
10860
+ const forkVisible = modelVisiblePath2(forkScratchpad);
10861
+ const payload = {
10862
+ ...msg.payload,
10863
+ session_id: newSessionId,
10864
+ system_prompt: sourcePrompt.split(sourceSessionId).join(newSessionId).split(sourceVisible).join(forkVisible)
10865
+ };
10866
+ delete payload.source;
10867
+ return { ...msg, payload };
10868
+ }
10869
+ const p = msg.payload;
10870
+ if (msg.type === "model_msg" && p.type === "text" && p.role === "user" && typeof p.text === "string") {
10871
+ return {
10872
+ ...msg,
10873
+ payload: {
10874
+ ...msg.payload,
10875
+ text: rewriteForkText(p.text, sourceScratchpad, forkScratchpad)
10876
+ }
10877
+ };
10878
+ }
10879
+ return msg;
10880
+ };
10881
+ try {
10882
+ try {
10883
+ const stat = await fs20.stat(sourceScratchpad);
10884
+ if (stat.isDirectory()) {
10885
+ await fs20.mkdir(scratchpadRoot, { recursive: true });
10886
+ await fs20.cp(sourceScratchpad, forkScratchpad, {
10887
+ recursive: true,
10888
+ force: false,
10889
+ errorOnExist: true
10890
+ });
10891
+ }
10892
+ } catch (err) {
10893
+ if (err.code !== "ENOENT") throw err;
10894
+ }
10895
+ for (let filePos = 0; filePos <= targetFilePos; filePos++) {
10896
+ const sourceFile = files[filePos];
10897
+ const sourceMessages = shards[filePos];
10898
+ const limit = filePos === targetFilePos ? cutOrdinal : sourceMessages.length;
10899
+ const records = sourceMessages.slice(0, limit).map(rewrite);
10900
+ if (!isSessionMeta6(records[0])) {
10901
+ throw invalid(`Trace shard ${sourceFile.index} does not start with session metadata.`);
10902
+ }
10903
+ const dir = path15.join(tracesDir3(this.root, projectId, agentId), sourceFile.date);
10904
+ await fs20.mkdir(dir, { recursive: true });
10905
+ const file = path15.join(
10906
+ dir,
10907
+ `${newSessionId}_${String(sourceFile.index).padStart(3, "0")}.jsonl`
10908
+ );
10909
+ const body = records.map((record) => JSON.stringify(record)).join("\n") + "\n";
10910
+ await fs20.writeFile(file, body, { encoding: "utf8", flag: "wx" });
10911
+ written.push(file);
10912
+ this.deps.index.registerImportedFile({
10913
+ projectId,
10914
+ agentId,
10915
+ sessionId: newSessionId,
10916
+ fileIndex: sourceFile.index,
10917
+ date: sourceFile.date,
10918
+ sizeBytes: Buffer.byteLength(body, "utf8"),
10919
+ records
10920
+ });
10921
+ }
10922
+ return { sessionId: newSessionId, createdAt };
10923
+ } catch (err) {
10924
+ await Promise.all(written.map((file) => fs20.rm(file, { force: true }).catch(() => void 0)));
10925
+ await fs20.rm(forkScratchpad, { recursive: true, force: true }).catch(() => void 0);
10926
+ this.deps.index.removeSession(projectId, agentId, newSessionId);
10927
+ throw err;
10928
+ }
10929
+ }
10357
10930
  /** List of Trace files (index / date / size / mtime). */
10358
10931
  async listTraceFiles(projectId, agentId, sessionId) {
10359
10932
  const files = await this.locateAll(projectId, agentId, sessionId);
@@ -10400,6 +10973,7 @@ var TraceService = class {
10400
10973
  let steeringImages = false;
10401
10974
  let sawToolCallThisRequest = false;
10402
10975
  let compactionActive = false;
10976
+ let compactionMode = null;
10403
10977
  const taskStats = /* @__PURE__ */ new Map();
10404
10978
  const ensureTask = (ti) => {
10405
10979
  let t = taskStats.get(ti);
@@ -10447,7 +11021,10 @@ var TraceService = class {
10447
11021
  requests.push(openRequest);
10448
11022
  if (!hasOrigin) {
10449
11023
  const t = ensureTask(taskIndex);
10450
- if (compactionActive) t.compaction = true;
11024
+ if (compactionActive) {
11025
+ t.compaction = true;
11026
+ if (compactionMode !== null) t.compactionMode = compactionMode;
11027
+ }
10451
11028
  if (t.startTs === "") t.startTs = msg.timestamp;
10452
11029
  if (pendingFrom !== null) {
10453
11030
  for (let k = pendingFrom; k < mi; k++) {
@@ -10513,11 +11090,14 @@ var TraceService = class {
10513
11090
  if (!hasOrigin) {
10514
11091
  continuation = false;
10515
11092
  compactionActive = true;
11093
+ const mode = p.mode;
11094
+ compactionMode = mode === "summarize" || mode === "discard" ? mode : null;
10516
11095
  }
10517
11096
  } else if (p.type === "compaction_end") {
10518
11097
  if (!hasOrigin) {
10519
11098
  continuation = false;
10520
11099
  compactionActive = false;
11100
+ compactionMode = null;
10521
11101
  }
10522
11102
  } else if (p.type === "token_usage") {
10523
11103
  const request = p.request;
@@ -10612,6 +11192,13 @@ var TraceService = class {
10612
11192
  const t = ensureTask(ti);
10613
11193
  if (t.messageFrom < 0 || k < t.messageFrom) t.messageFrom = k;
10614
11194
  if (k > t.messageTo) t.messageTo = k;
11195
+ const cur = messages[k];
11196
+ const cp = cur.payload;
11197
+ const fromSubagent = cur.origin !== void 0 && cur.origin.length > 0;
11198
+ if (!fromSubagent && cur.type === "event_msg" && cp.type === "compaction_begin") {
11199
+ t.compaction = true;
11200
+ if (cp.mode === "summarize" || cp.mode === "discard") t.compactionMode = cp.mode;
11201
+ }
10615
11202
  if (messages[k].type === "session_meta") continue;
10616
11203
  const ts = messages[k].timestamp;
10617
11204
  if (t.endTs === "" || ts > t.endTs) t.endTs = ts;
@@ -10812,9 +11399,9 @@ var TraceService = class {
10812
11399
  const ts = Date.parse(first.timestamp);
10813
11400
  const date = formatLocalDate(Number.isNaN(ts) ? /* @__PURE__ */ new Date() : new Date(ts));
10814
11401
  const index = 1;
10815
- const dir = path16.join(tracesDir3(this.root, projectId, agentId), date);
11402
+ const dir = path15.join(tracesDir3(this.root, projectId, agentId), date);
10816
11403
  await fs20.mkdir(dir, { recursive: true });
10817
- const file = path16.join(dir, `${sessionId}_${String(index).padStart(3, "0")}.jsonl`);
11404
+ const file = path15.join(dir, `${sessionId}_${String(index).padStart(3, "0")}.jsonl`);
10818
11405
  const body = content.replace(/\n+$/, "") + "\n";
10819
11406
  try {
10820
11407
  await fs20.writeFile(file, body, { encoding: "utf8", flag: "wx" });
@@ -11132,11 +11719,573 @@ var UsageService = class {
11132
11719
  }
11133
11720
  };
11134
11721
 
11135
- // src/http/routes/preview.ts
11722
+ // src/hmr/host.ts
11723
+ import crypto from "crypto";
11724
+ import fs21 from "fs";
11725
+ import fsp from "fs/promises";
11726
+ import path16 from "path";
11727
+ import zlib from "zlib";
11728
+ import { pathToFileURL } from "url";
11729
+ import { boot, initialDoc, upgrade } from "@prismshadow/penguin-core/kernel";
11730
+
11731
+ // src/hmr/resources.ts
11732
+ var HotResources = class {
11733
+ map = /* @__PURE__ */ new Map();
11734
+ register(id, resource, dispose) {
11735
+ this.map.set(id, { resource, dispose });
11736
+ }
11737
+ claim(id) {
11738
+ return this.map.get(id)?.resource;
11739
+ }
11740
+ release(id) {
11741
+ this.map.delete(id);
11742
+ }
11743
+ /**
11744
+ * Process-exit sweep: run every registered disposer. Not part of any upgrade path —
11745
+ * resources are meant to outlive swaps. A throwing disposer must not strand the rest.
11746
+ */
11747
+ disposeAll() {
11748
+ for (const entry of this.map.values()) {
11749
+ try {
11750
+ entry.dispose?.();
11751
+ } catch {
11752
+ }
11753
+ }
11754
+ this.map.clear();
11755
+ }
11756
+ };
11757
+
11758
+ // src/platform/platform.ts
11759
+ import { defineIface, schema, type } from "@prismshadow/penguin-core/kernel";
11760
+ var PlatformIface = defineIface({
11761
+ name: "platform",
11762
+ version: 1,
11763
+ context: schema(type({ motd: "string" })),
11764
+ methods: ["park", "info"]
11765
+ });
11766
+ var platformImpl = {
11767
+ create(_ctx, context) {
11768
+ return {
11769
+ park: () => ({ motd: context.motd }),
11770
+ info: () => ({
11771
+ impl: "packaged",
11772
+ ifaceVersion: PlatformIface.version,
11773
+ motd: context.motd
11774
+ })
11775
+ };
11776
+ }
11777
+ };
11778
+ var packagedPlatform = {
11779
+ id: "packaged",
11780
+ iface: PlatformIface,
11781
+ impl: platformImpl,
11782
+ context: { motd: "hello from the penguin hot platform" }
11783
+ };
11784
+
11785
+ // src/hmr/host.ts
11786
+ var STORE_KEEP = 2;
11787
+ var HmrHost = class {
11788
+ constructor(root) {
11789
+ this.root = root;
11790
+ this.hmrDir = path16.join(root, "hmr");
11791
+ this.storeDir = path16.join(this.hmrDir, "store");
11792
+ this.manifestPath = path16.join(this.hmrDir, "harness.json");
11793
+ }
11794
+ root;
11795
+ resources = new HotResources();
11796
+ instance = null;
11797
+ implId = packagedPlatform.id;
11798
+ hmrDir;
11799
+ storeDir;
11800
+ manifestPath;
11801
+ /**
11802
+ * Single-flight guard for the first boot (see ensure()): every concurrent caller before the
11803
+ * first successful boot awaits the SAME promise, so restore() and the packaged-default
11804
+ * fallback can never run twice and race each other into assigning `this.instance` (a double
11805
+ * boot, or a restored version clobbered by a concurrently-booted packaged default).
11806
+ */
11807
+ initPromise = null;
11808
+ /** The freeze, as a queue: everything the HTTP layer gates on chains here. */
11809
+ opQueue = Promise.resolve();
11810
+ warn(msg) {
11811
+ process.stderr.write(`[hmr] ${msg}
11812
+ `);
11813
+ }
11814
+ currentImplId() {
11815
+ return this.implId;
11816
+ }
11817
+ /**
11818
+ * The request gate: resolves once no swap is in flight. Requests arriving
11819
+ * during an upgrade wait here — they observe latency, never an error.
11820
+ */
11821
+ waitIdle() {
11822
+ return this.opQueue.then(
11823
+ () => void 0,
11824
+ () => void 0
11825
+ );
11826
+ }
11827
+ /**
11828
+ * Lazy first boot. Resumes the last committed version (platform + cli + web)
11829
+ * from disk when present; otherwise boots the packaged platform v1 with a
11830
+ * fresh document. Concurrent callers before the first boot completes share one
11831
+ * in-flight promise (see `initPromise`) rather than each racing their own
11832
+ * restore()/packaged-boot.
11833
+ */
11834
+ ensure() {
11835
+ if (this.instance !== null) return Promise.resolve(this.instance);
11836
+ this.initPromise ??= this.initialize();
11837
+ return this.initPromise;
11838
+ }
11839
+ /** Runs exactly once per process (guarded by `initPromise` in ensure()). */
11840
+ async initialize() {
11841
+ try {
11842
+ await this.restore();
11843
+ if (this.instance === null) {
11844
+ const bundle = packagedPlatform;
11845
+ this.instance = await boot(
11846
+ bundle.impl,
11847
+ bundle.iface,
11848
+ initialDoc(bundle.iface, bundle.context),
11849
+ this.resources
11850
+ );
11851
+ }
11852
+ return this.instance;
11853
+ } catch (err) {
11854
+ this.initPromise = null;
11855
+ throw err;
11856
+ }
11857
+ }
11858
+ /**
11859
+ * Resume from harness.json, as ONE unit: the platform bundle, the cli bundle's own
11860
+ * existence, and the web artifact are all read and validated BEFORE anything is
11861
+ * committed to `this.instance` / `this.webMem` — so a failure partway through (a
11862
+ * pruned bundle, a missing web artifact, a missing cli artifact) never leaves
11863
+ * platform and web resumed from different versions. The cli bundle is never
11864
+ * imported here (this process never runs it — only packages/cli's own loader
11865
+ * does); its file just has to exist, so a restore can never leave `cli.bundle`
11866
+ * pointing at nothing. Any failure is non-fatal: it warns and leaves the runtime
11867
+ * to boot the packaged default — a bad persisted version must never brick the
11868
+ * runtime. Only ever called once (from initialize(), itself single-flighted by
11869
+ * ensure()'s `initPromise`).
11870
+ *
11871
+ * Boots the restored bundle against its OWN fresh initial context (bundle.context),
11872
+ * not a resumed state — state is never written to disk (see the module doc), so a
11873
+ * restart always resumes the pushed CODE with a clean slate, never last run's doc.
11874
+ */
11875
+ async restore() {
11876
+ let manifest;
11877
+ try {
11878
+ manifest = JSON.parse(await fsp.readFile(this.manifestPath, "utf8"));
11879
+ } catch {
11880
+ return;
11881
+ }
11882
+ if (manifest.platform === void 0 && manifest.web === void 0) {
11883
+ return;
11884
+ }
11885
+ try {
11886
+ if (manifest.platform === void 0) {
11887
+ throw new Error("harness.json has no `platform` entry");
11888
+ }
11889
+ if (manifest.cli === void 0) {
11890
+ throw new Error("harness.json has no `cli` entry");
11891
+ }
11892
+ if (manifest.web === void 0) {
11893
+ throw new Error("harness.json has no `web` entry");
11894
+ }
11895
+ const cliPath = path16.join(this.hmrDir, manifest.cli.bundle);
11896
+ if (!fs21.existsSync(cliPath)) {
11897
+ throw new Error(`cli bundle '${manifest.cli.bundle}' does not exist`);
11898
+ }
11899
+ const bundle = await this.importBundleFile(path16.join(this.hmrDir, manifest.platform.bundle));
11900
+ const gz = await fsp.readFile(path16.join(this.hmrDir, manifest.web.manifest));
11901
+ const webMem = filesMapFromGzip(gz);
11902
+ const instance = await boot(
11903
+ bundle.impl,
11904
+ bundle.iface,
11905
+ initialDoc(bundle.iface, bundle.context),
11906
+ this.resources
11907
+ );
11908
+ this.instance = instance;
11909
+ this.implId = bundle.id;
11910
+ this.webMem = webMem;
11911
+ } catch (err) {
11912
+ this.warn(
11913
+ `persisted version failed to restore (platform+cli+web are committed as one unit); using the packaged default: ${errMsg(err)}`
11914
+ );
11915
+ }
11916
+ }
11917
+ /** Strictly request-driven; serialized on the op queue (never auto-triggered). */
11918
+ upgradeAll(target) {
11919
+ const run = this.opQueue.then(() => this.doUpgradeAll(target));
11920
+ this.opQueue = run.then(
11921
+ () => void 0,
11922
+ () => void 0
11923
+ );
11924
+ return run;
11925
+ }
11926
+ async doUpgradeAll(target) {
11927
+ const current2 = await this.ensure();
11928
+ if (typeof target.web["index.html"] !== "string") {
11929
+ throw new Error("web dist manifest has no index.html");
11930
+ }
11931
+ const webMem = /* @__PURE__ */ new Map();
11932
+ for (const [rel, b64] of Object.entries(target.web)) {
11933
+ if (!isSafeRelPath(rel)) throw new Error(`unsafe path in web dist manifest: ${rel}`);
11934
+ webMem.set(rel, Buffer.from(b64, "base64"));
11935
+ }
11936
+ const platformPath = await this.writeInlinePlatformBundle(target.platform);
11937
+ const bundle = await this.importBundleFile(platformPath);
11938
+ const source = target.source ?? null;
11939
+ const result = await upgrade({
11940
+ current: current2,
11941
+ impl: bundle.impl,
11942
+ iface: bundle.iface,
11943
+ resources: this.resources
11944
+ });
11945
+ if (result.status === "blocked") {
11946
+ return {
11947
+ status: "blocked",
11948
+ dropped: result.dropped,
11949
+ missing: result.missing,
11950
+ invalid: result.invalid
11951
+ };
11952
+ }
11953
+ this.instance = result.instance;
11954
+ this.implId = bundle.id;
11955
+ this.webMem = webMem;
11956
+ const digest2 = filesDigest(target.web);
11957
+ const gz = zlib.gzipSync(Buffer.from(JSON.stringify({ files: target.web })));
11958
+ const persisted = await this.persistVersion(
11959
+ target.platform,
11960
+ target.cli,
11961
+ gz,
11962
+ digest2.slice(0, 16)
11963
+ );
11964
+ return {
11965
+ status: "ok",
11966
+ mode: result.mode,
11967
+ impl: bundle.id,
11968
+ source,
11969
+ web: { rev: digest2.slice(0, 12) },
11970
+ persisted
11971
+ };
11972
+ }
11973
+ /** Layer (a) core: import one JS file, cache-busted so re-imports load fresh code. */
11974
+ async importBundleFile(file) {
11975
+ const resolved = path16.resolve(file);
11976
+ if (!fs21.existsSync(resolved)) throw new Error(`bundle file '${file}' does not exist`);
11977
+ const url = `${pathToFileURL(resolved).href}?v=${Date.now()}`;
11978
+ const mod = await import(url);
11979
+ if (mod.hotPlatform === void 0) {
11980
+ throw new Error(`${file} does not export 'hotPlatform'`);
11981
+ }
11982
+ if (mod.hotPlatform.context === void 0) {
11983
+ throw new Error(`${file}'s 'hotPlatform' has no 'context'`);
11984
+ }
11985
+ return mod.hotPlatform;
11986
+ }
11987
+ /**
11988
+ * Materializes the inline platform bundle (the single-file ESM sent in the
11989
+ * request body) to disk and returns its path — how a push reaches a runtime
11990
+ * over HTTP alone: the bytes travel in the request, the server writes them,
11991
+ * then loads them via importBundleFile. Only the platform artifact needs
11992
+ * this treatment: it is the only one of the three this process ever
11993
+ * imports and boots. The cli artifact is never imported here (see
11994
+ * persistVersion) — it goes straight from request body to content-addressed
11995
+ * store.
11996
+ */
11997
+ async writeInlinePlatformBundle(content) {
11998
+ const dir = path16.join(this.hmrDir, "uploads");
11999
+ await fsp.mkdir(dir, { recursive: true });
12000
+ const file = path16.join(dir, `platform-${sha1(content).slice(0, 16)}.mjs`);
12001
+ await fsp.writeFile(file, content, "utf8");
12002
+ return file;
12003
+ }
12004
+ // -- Web platform (the frontend package's built dist) ---------------------
12005
+ /**
12006
+ * The pushed web dist held in memory (relPath → bytes) — always installed
12007
+ * together with a platform + cli version (see doUpgradeAll); never written or
12008
+ * read file-by-file (see persistVersion). Null before anything has ever been
12009
+ * pushed or restored.
12010
+ */
12011
+ webMem = null;
12012
+ /** Static hosting's resolution: an in-memory pushed/restored dist, or null (the caller falls back to the configured webDist). */
12013
+ resolveWebSource() {
12014
+ return this.webMem !== null ? { kind: "mem", files: this.webMem } : null;
12015
+ }
12016
+ // -- Persistence ----------------------------------------------------------
12017
+ /**
12018
+ * Content-addresses the platform bundle (its CODE only — see the module doc: state
12019
+ * is never persisted), the cli bundle (never imported — just stored, for
12020
+ * packages/cli's own loader to pick up), and the web gzip artifact, then flips
12021
+ * harness.json ONCE — `platform`, `cli`, and `web` all land in the SAME atomic
12022
+ * rename, never three separate commits that could leave one pointer ahead of the
12023
+ * others. `platform.bundle` and `cli.bundle` are genuinely independent files
12024
+ * (distinct content, distinct sha) rather than the same physical bundle under two
12025
+ * manifest keys.
12026
+ *
12027
+ * Returns whether the commit succeeded — the caller surfaces this to clients
12028
+ * (`persisted` in UpgradeOutcome) so a live swap that could not be written to disk
12029
+ * is visibly at risk of reverting on the next restart, rather than silently ok.
12030
+ */
12031
+ async persistVersion(platformContent, cliContent, webGz, webSha) {
12032
+ try {
12033
+ const platformSha = sha1(platformContent).slice(0, 16);
12034
+ const platformDir = path16.join(this.storeDir, "platform");
12035
+ await fsp.mkdir(platformDir, { recursive: true });
12036
+ await fsp.writeFile(path16.join(platformDir, `${platformSha}.mjs`), platformContent, "utf8");
12037
+ const cliSha = sha1(cliContent).slice(0, 16);
12038
+ const cliDir = path16.join(this.storeDir, "cli");
12039
+ await fsp.mkdir(cliDir, { recursive: true });
12040
+ await fsp.writeFile(path16.join(cliDir, `${cliSha}.mjs`), cliContent, "utf8");
12041
+ const webDir = path16.join(this.storeDir, "web");
12042
+ await fsp.mkdir(webDir, { recursive: true });
12043
+ await fsp.writeFile(path16.join(webDir, `${webSha}.webz`), webGz);
12044
+ await this.commitManifest(() => ({
12045
+ platform: { bundle: `store/platform/${platformSha}.mjs` },
12046
+ cli: { bundle: `store/cli/${cliSha}.mjs` },
12047
+ web: { manifest: `store/web/${webSha}.webz` }
12048
+ }));
12049
+ return true;
12050
+ } catch (err) {
12051
+ this.warn(`update not persisted (filesystem unavailable?): ${errMsg(err)}`);
12052
+ return false;
12053
+ }
12054
+ }
12055
+ /** Writes and atomically replaces harness.json (the single commit point for a whole version). */
12056
+ async commitManifest(next) {
12057
+ await fsp.mkdir(this.hmrDir, { recursive: true });
12058
+ const manifest = next();
12059
+ const tmp = `${this.manifestPath}.${process.pid}.tmp`;
12060
+ await fsp.writeFile(tmp, JSON.stringify(manifest, null, 2));
12061
+ await fsp.rename(tmp, this.manifestPath);
12062
+ await this.pruneStore(manifest);
12063
+ }
12064
+ /**
12065
+ * Store GC: keep at most STORE_KEEP versions — the committed one is always
12066
+ * kept, the rest by recency. Best-effort; ordered after the manifest flip so
12067
+ * nothing referenced can be pruned. `platform`, `cli`, and `web` are three
12068
+ * independent subtrees now (no shared file to piggyback a sweep on), so each
12069
+ * gets its own pass.
12070
+ */
12071
+ async pruneStore(manifest) {
12072
+ const keepNewest = async (dir, keys, referenced, remove) => {
12073
+ let names;
12074
+ try {
12075
+ names = await fsp.readdir(dir);
12076
+ } catch {
12077
+ return;
12078
+ }
12079
+ const byKey = /* @__PURE__ */ new Map();
12080
+ for (const name of names) {
12081
+ const key = keys(name);
12082
+ if (key === null) continue;
12083
+ try {
12084
+ const mtime = (await fsp.stat(path16.join(dir, name))).mtimeMs;
12085
+ byKey.set(key, Math.max(byKey.get(key) ?? 0, mtime));
12086
+ } catch {
12087
+ }
12088
+ }
12089
+ const ranked = [...byKey.entries()].sort((a, b) => b[1] - a[1]).map(([k]) => k);
12090
+ const kept = new Set(ranked.slice(0, STORE_KEEP));
12091
+ if (referenced !== null) kept.add(referenced);
12092
+ for (const key of ranked) {
12093
+ if (!kept.has(key)) await remove(key).catch(() => void 0);
12094
+ }
12095
+ };
12096
+ const platformDir = path16.join(this.storeDir, "platform");
12097
+ const platformBundleRef = manifest.platform?.bundle.match(/([0-9a-f]+)\.mjs$/)?.[1] ?? null;
12098
+ await keepNewest(
12099
+ platformDir,
12100
+ (name) => /^([0-9a-f]+)\.mjs$/.exec(name)?.[1] ?? null,
12101
+ platformBundleRef,
12102
+ (sha) => fsp.rm(path16.join(platformDir, `${sha}.mjs`), { force: true })
12103
+ );
12104
+ const cliDir = path16.join(this.storeDir, "cli");
12105
+ const cliRef = manifest.cli?.bundle.match(/([0-9a-f]+)\.mjs$/)?.[1] ?? null;
12106
+ await keepNewest(
12107
+ cliDir,
12108
+ (name) => /^([0-9a-f]+)\.mjs$/.exec(name)?.[1] ?? null,
12109
+ cliRef,
12110
+ (sha) => fsp.rm(path16.join(cliDir, `${sha}.mjs`), { force: true })
12111
+ );
12112
+ const webDir = path16.join(this.storeDir, "web");
12113
+ const webRef = manifest.web?.manifest.match(/([0-9a-f]+)\.webz$/)?.[1] ?? null;
12114
+ await keepNewest(
12115
+ webDir,
12116
+ (name) => /^([0-9a-f]+)\.webz$/.exec(name)?.[1] ?? null,
12117
+ webRef,
12118
+ (sha) => fsp.rm(path16.join(webDir, `${sha}.webz`), { force: true })
12119
+ );
12120
+ }
12121
+ /** Process-exit sweep only; never part of an upgrade. */
12122
+ dispose() {
12123
+ this.instance?.dispose();
12124
+ this.instance = null;
12125
+ this.resources.disposeAll();
12126
+ }
12127
+ };
12128
+ function errMsg(err) {
12129
+ return err instanceof Error ? err.message : String(err);
12130
+ }
12131
+ function sha1(content) {
12132
+ return crypto.createHash("sha1").update(content).digest("hex");
12133
+ }
12134
+ function filesDigest(files) {
12135
+ const hash = crypto.createHash("sha1");
12136
+ for (const rel of Object.keys(files).sort()) hash.update(rel).update("\0").update(files[rel]);
12137
+ return hash.digest("hex");
12138
+ }
12139
+ function isSafeRelPath(rel) {
12140
+ if (rel === "" || rel.startsWith("/") || rel.includes("\\")) return false;
12141
+ return rel.split("/").every((seg) => seg !== "" && seg !== "." && seg !== "..");
12142
+ }
12143
+ function filesFromGzip(gz) {
12144
+ let parsed;
12145
+ try {
12146
+ parsed = JSON.parse(zlib.gunzipSync(gz).toString("utf8"));
12147
+ } catch (err) {
12148
+ throw new Error(`invalid gzip web dist artifact: ${errMsg(err)}`);
12149
+ }
12150
+ if (typeof parsed.files !== "object" || parsed.files === null) {
12151
+ throw new Error("gzip web dist artifact has no `files`");
12152
+ }
12153
+ return parsed.files;
12154
+ }
12155
+ function filesMapFromGzip(gz) {
12156
+ const files = filesFromGzip(gz);
12157
+ const mem = /* @__PURE__ */ new Map();
12158
+ for (const [rel, b64] of Object.entries(files)) {
12159
+ if (!isSafeRelPath(rel)) throw new Error(`unsafe path in web dist manifest: ${rel}`);
12160
+ mem.set(rel, Buffer.from(b64, "base64"));
12161
+ }
12162
+ return mem;
12163
+ }
12164
+
12165
+ // src/hmr/routes.ts
12166
+ import zlib2 from "zlib";
11136
12167
  import { Hono as Hono24 } from "hono";
12168
+ var LOOPBACK_HOSTS = /* @__PURE__ */ new Set(["127.0.0.1", "::1", "localhost"]);
12169
+ function hmrRoutes(deps2) {
12170
+ const routes = new Hono24();
12171
+ const hmr = deps2.hmr;
12172
+ const cookieAuth = authMiddleware(deps2.authService);
12173
+ routes.use("*", async (c, next) => {
12174
+ if (!LOOPBACK_HOSTS.has(deps2.config.host.toLowerCase())) {
12175
+ const proto = deps2.config.trustProxy ? c.req.header("x-forwarded-proto") ?? new URL(c.req.url).protocol.replace(":", "") : new URL(c.req.url).protocol.replace(":", "");
12176
+ if (proto !== "https") {
12177
+ throw new HttpError(
12178
+ 403,
12179
+ "hmr_disabled",
12180
+ "Hot platform APIs are disabled on a non-loopback bind without HTTPS. Serve over HTTPS to enable them."
12181
+ );
12182
+ }
12183
+ }
12184
+ const gated = async () => {
12185
+ if (!c.req.path.endsWith("/upgrade")) await hmr.waitIdle();
12186
+ await next();
12187
+ };
12188
+ return cookieAuth(c, async () => {
12189
+ if (!c.get("user").isAdmin) {
12190
+ throw new HttpError(403, "forbidden", "Hot platform APIs are admin-only.");
12191
+ }
12192
+ await gated();
12193
+ });
12194
+ });
12195
+ routes.post("/upgrade", async (c) => {
12196
+ const contentType = (c.req.header("content-type") ?? "").split(";")[0].trim().toLowerCase();
12197
+ if (contentType !== "application/gzip" && contentType !== "application/octet-stream") {
12198
+ throw new HttpError(
12199
+ 400,
12200
+ "bad_request",
12201
+ "expected a gzip(JSON.stringify({ platform, cli, web })) body (Content-Type application/gzip or application/octet-stream)"
12202
+ );
12203
+ }
12204
+ let payload;
12205
+ try {
12206
+ const gz = Buffer.from(await c.req.arrayBuffer());
12207
+ payload = JSON.parse(zlib2.gunzipSync(gz).toString("utf8"));
12208
+ } catch (err) {
12209
+ throw new HttpError(
12210
+ 400,
12211
+ "bad_request",
12212
+ `invalid gzip upgrade payload: ${err instanceof Error ? err.message : String(err)}`
12213
+ );
12214
+ }
12215
+ if (typeof payload.platform !== "string") {
12216
+ throw new HttpError(400, "bad_request", "payload has no `platform` (string)");
12217
+ }
12218
+ if (typeof payload.cli !== "string") {
12219
+ throw new HttpError(400, "bad_request", "payload has no `cli` (string)");
12220
+ }
12221
+ if (typeof payload.web?.files !== "object" || payload.web.files === null) {
12222
+ throw new HttpError(
12223
+ 400,
12224
+ "bad_request",
12225
+ "payload has no `web.files` (a { relPath: base64 } map)"
12226
+ );
12227
+ }
12228
+ let outcome;
12229
+ try {
12230
+ outcome = await hmr.upgradeAll({
12231
+ platform: payload.platform,
12232
+ cli: payload.cli,
12233
+ web: payload.web.files,
12234
+ ...payload.source ? { source: payload.source } : {}
12235
+ });
12236
+ } catch (err) {
12237
+ throw new HttpError(400, "bad_request", err instanceof Error ? err.message : String(err));
12238
+ }
12239
+ if (outcome.status === "ok") {
12240
+ deps2.channels.broadcast(
12241
+ "user:",
12242
+ { type: "web_updated", rev: outcome.web.rev },
12243
+ "server_event"
12244
+ );
12245
+ }
12246
+ return c.json(outcome);
12247
+ });
12248
+ return routes;
12249
+ }
12250
+
12251
+ // src/hmr/http-seam.ts
12252
+ var RESERVED_PREFIX = "/api/hmr";
12253
+ function platformHttpSeam(hmr) {
12254
+ return async (c, next) => {
12255
+ if (c.req.path.startsWith(RESERVED_PREFIX)) return next();
12256
+ await hmr.waitIdle();
12257
+ let handler;
12258
+ try {
12259
+ const instance = await hmr.ensure();
12260
+ handler = instance.api.http?.bind(instance.api);
12261
+ } catch {
12262
+ return next();
12263
+ }
12264
+ if (!handler) return next();
12265
+ let response;
12266
+ try {
12267
+ response = await handler(c.req.raw);
12268
+ } catch (err) {
12269
+ return c.json(
12270
+ {
12271
+ error: {
12272
+ code: "platform_error",
12273
+ message: `The platform failed to handle ${c.req.method} ${c.req.path}: ${err instanceof Error ? err.message : String(err)}`
12274
+ }
12275
+ },
12276
+ 500
12277
+ );
12278
+ }
12279
+ if (response === null) return next();
12280
+ return response;
12281
+ };
12282
+ }
12283
+
12284
+ // src/http/routes/preview.ts
12285
+ import { Hono as Hono25 } from "hono";
11137
12286
  var DIRECTORY_INDEX = "index.html";
11138
12287
  function previewRoutes(deps2) {
11139
- const app2 = new Hono24();
12288
+ const app2 = new Hono25();
11140
12289
  app2.get("/:token/*", async (c) => {
11141
12290
  const token = c.req.param("token");
11142
12291
  const payload = token ? deps2.previewTokens.verify(token) : null;
@@ -11180,7 +12329,6 @@ function previewRoutes(deps2) {
11180
12329
  }
11181
12330
 
11182
12331
  // src/app.ts
11183
- var MAX_BODY_BYTES = 20 * 1024 * 1024;
11184
12332
  function buildAppDeps(config2, overrides = {}) {
11185
12333
  const db = openDatabase(config2.dbPath);
11186
12334
  const log = overrides.log ?? ((line) => console.log(line));
@@ -11240,7 +12388,24 @@ function buildAppDeps(config2, overrides = {}) {
11240
12388
  errors,
11241
12389
  titles,
11242
12390
  log,
11243
- goals: goalsRepo
12391
+ goals: goalsRepo,
12392
+ // Run-state flips reach the whole login session, not just the tab watching that one
12393
+ // conversation. Audience = the Project's owner plus its members, i.e. exactly who
12394
+ // ProjectsRepo.listAccessible would grant the Project to — nobody learns that a Session
12395
+ // they cannot open changed state.
12396
+ //
12397
+ // `peek`, deliberately not `get`: a user who has never opened an event stream has no
12398
+ // channel, and conjuring one to buffer badge updates nobody is listening to is pure waste
12399
+ // (their next connection fetches the list, which carries the same statuses anyway).
12400
+ notifyProjectUsers: (projectId, event) => {
12401
+ const ownerUserId = projectsRepo.findById(projectId)?.ownerUserId;
12402
+ if (ownerUserId === void 0) return;
12403
+ const audience = /* @__PURE__ */ new Set([ownerUserId, ...membersRepo.list(projectId).map((m) => m.userId)]);
12404
+ for (const userId of audience) {
12405
+ channels.peek(userChannelKey(userId))?.publish(event, "server_event");
12406
+ }
12407
+ },
12408
+ ...overrides.now ? { now: overrides.now } : {}
11244
12409
  });
11245
12410
  managerRef = manager;
11246
12411
  const projectService = new ProjectService({
@@ -11333,11 +12498,12 @@ function buildAppDeps(config2, overrides = {}) {
11333
12498
  sessionSources,
11334
12499
  errors,
11335
12500
  desktop: config2.desktopToken !== null ? new DesktopService(config2.desktopToken) : null,
12501
+ hmr: new HmrHost(config2.root),
11336
12502
  log
11337
12503
  };
11338
12504
  }
11339
12505
  function createApp(deps2) {
11340
- const app2 = new Hono25();
12506
+ const app2 = new Hono26();
11341
12507
  app2.onError((err, c) => {
11342
12508
  const projectId = attributedProjectId(c, deps2);
11343
12509
  deps2.errors.record({
@@ -11369,22 +12535,35 @@ function createApp(deps2) {
11369
12535
  await next();
11370
12536
  });
11371
12537
  }
11372
- app2.use(
11373
- "/api/*",
11374
- bodyLimit({
11375
- maxSize: MAX_BODY_BYTES,
11376
- // Its default is a bare text/plain 413; throw the App's own error instead so the response
11377
- // stays the documented `payload_too_large` body that every client already handles.
11378
- onError: () => {
11379
- throw new HttpError(413, "payload_too_large", "Request body exceeds the 20MB limit.");
11380
- }
11381
- })
11382
- );
12538
+ let capped = null;
12539
+ app2.use("/api/*", (c, next) => {
12540
+ const size = bodyLimitBytes(deps2.serverSettingsRepo.getAttachmentLimitsMb());
12541
+ if (capped === null || capped.size !== size) {
12542
+ capped = {
12543
+ size,
12544
+ mw: bodyLimit({
12545
+ maxSize: size,
12546
+ // Its default is a bare text/plain 413; throw the App's own error instead so the
12547
+ // response stays the documented `payload_too_large` body that every client handles.
12548
+ onError: () => {
12549
+ throw new HttpError(
12550
+ 413,
12551
+ "payload_too_large",
12552
+ `Request body exceeds the ${Math.floor(size / (1024 * 1024))}MB limit.`
12553
+ );
12554
+ }
12555
+ })
12556
+ };
12557
+ }
12558
+ return capped.mw(c, next);
12559
+ });
11383
12560
  app2.use("/api/*", jsonOnlyWrites);
11384
12561
  app2.route("/api/auth", authRoutes(deps2));
11385
12562
  if (deps2.desktop) {
11386
12563
  app2.route("/api/desktop", desktopRoutes(deps2));
11387
12564
  }
12565
+ app2.route("/api/hmr", hmrRoutes(deps2));
12566
+ app2.use("*", platformHttpSeam(deps2.hmr));
11388
12567
  const auth = authMiddleware(deps2.authService);
11389
12568
  app2.use("/api/*", auth);
11390
12569
  app2.route("/api/me", meRoutes(deps2));
@@ -11411,9 +12590,10 @@ function createApp(deps2) {
11411
12590
  app2.route("/api/projects/:projectId/usage", usageRoutes(deps2));
11412
12591
  app2.route("/api/sessions", sessionsRoutes(deps2));
11413
12592
  app2.route("/preview", previewRoutes(deps2));
11414
- if (fs21.existsSync(deps2.config.webDist)) {
11415
- registerStaticRoutes(app2, deps2.config.webDist);
11416
- }
12593
+ registerStaticRoutes(app2, async () => {
12594
+ await deps2.hmr.ensure();
12595
+ return deps2.hmr.resolveWebSource() ?? { kind: "dir", dir: deps2.config.webDist };
12596
+ });
11417
12597
  return app2;
11418
12598
  }
11419
12599
  function attributedProjectId(c, deps2) {
@@ -11440,41 +12620,58 @@ var CONTENT_TYPES2 = {
11440
12620
  ".txt": "text/plain; charset=utf-8",
11441
12621
  ".woff2": "font/woff2"
11442
12622
  };
11443
- function registerStaticRoutes(app2, webDist) {
12623
+ function registerStaticRoutes(app2, resolveSource) {
11444
12624
  app2.get("*", async (c) => {
11445
12625
  const reqPath = decodeURIComponent(c.req.path);
11446
12626
  if (reqPath.startsWith("/api/")) {
11447
12627
  return c.json(errorBody("not_found", "Endpoint does not exist."), 404);
11448
12628
  }
11449
- const rel = reqPath.replace(/^\/+/, "");
11450
- const resolved = path17.resolve(webDist, rel === "" ? "index.html" : rel);
12629
+ const rel = reqPath.replace(/^\/+/, "") || "index.html";
12630
+ const source = await resolveSource();
12631
+ if (source.kind === "mem") {
12632
+ const servedPath = source.files.has(rel) ? rel : "index.html";
12633
+ const content2 = source.files.get(servedPath);
12634
+ if (content2 === void 0) {
12635
+ return c.json(errorBody("not_found", "Resource does not exist."), 404);
12636
+ }
12637
+ const type3 = CONTENT_TYPES2[path17.extname(servedPath).toLowerCase()] ?? "application/octet-stream";
12638
+ return new Response(new Uint8Array(content2), {
12639
+ status: 200,
12640
+ headers: { "Content-Type": type3 }
12641
+ });
12642
+ }
12643
+ const webDist = source.dir;
12644
+ if (!fs22.existsSync(webDist)) {
12645
+ return c.json(errorBody("not_found", "Resource does not exist."), 404);
12646
+ }
12647
+ const resolved = path17.resolve(webDist, rel);
11451
12648
  const base = path17.resolve(webDist);
11452
12649
  const target = resolved === base || resolved.startsWith(base + path17.sep) ? resolved : path17.join(base, "index.html");
11453
12650
  let file = target;
11454
12651
  try {
11455
- const stat = await fsp.stat(file);
12652
+ const stat = await fsp2.stat(file);
11456
12653
  if (stat.isDirectory()) file = path17.join(file, "index.html");
11457
- await fsp.access(file);
12654
+ await fsp2.access(file);
11458
12655
  } catch {
11459
12656
  file = path17.join(base, "index.html");
11460
12657
  }
11461
12658
  let content;
11462
12659
  try {
11463
- content = await fsp.readFile(file);
12660
+ content = await fsp2.readFile(file);
11464
12661
  } catch {
11465
12662
  return c.json(errorBody("not_found", "Resource does not exist."), 404);
11466
12663
  }
11467
- const type = CONTENT_TYPES2[path17.extname(file).toLowerCase()] ?? "application/octet-stream";
12664
+ const type2 = CONTENT_TYPES2[path17.extname(file).toLowerCase()] ?? "application/octet-stream";
11468
12665
  return new Response(new Uint8Array(content), {
11469
12666
  status: 200,
11470
- headers: { "Content-Type": type }
12667
+ headers: { "Content-Type": type2 }
11471
12668
  });
11472
12669
  });
11473
12670
  }
11474
12671
 
11475
12672
  // src/config.ts
11476
- import { randomBytes as randomBytes7 } from "crypto";
11477
- import fs22 from "fs";
12673
+ import { randomBytes as randomBytes5 } from "crypto";
12674
+ import fs23 from "fs";
11478
12675
  import path18 from "path";
11479
12676
  import { fileURLToPath } from "url";
11480
12677
  import { DEFAULT_SERVER_PORT, resolveRoot } from "@prismshadow/penguin-core";
@@ -11482,7 +12679,7 @@ var DAY_MS = 24 * 60 * 60 * 1e3;
11482
12679
  function defaultWebDist() {
11483
12680
  const here = path18.dirname(fileURLToPath(import.meta.url));
11484
12681
  const bundled = path18.resolve(here, "..", "web-dist");
11485
- if (fs22.existsSync(bundled)) return bundled;
12682
+ if (fs23.existsSync(bundled)) return bundled;
11486
12683
  return path18.resolve(here, "..", "..", "web", "dist");
11487
12684
  }
11488
12685
  function normalizePreviewOrigin(raw) {
@@ -11520,11 +12717,12 @@ function resolveServerConfig(env = process.env) {
11520
12717
  // mode without a pinned value seeds a FULLY random password rather than the
11521
12718
  // printable penguin-<4 digits>: desktop sign-in goes through the shell's token, so
11522
12719
  // the seed never needs to be read — and index.ts deliberately does not print it.
11523
- seedAdminPassword: env.PENGUIN_SEED_ADMIN_PASSWORD?.trim() || (desktopToken !== null ? randomBytes7(24).toString("base64url") : null),
12720
+ seedAdminPassword: env.PENGUIN_SEED_ADMIN_PASSWORD?.trim() || (desktopToken !== null ? randomBytes5(24).toString("base64url") : null),
11524
12721
  authSessionTtlMs: 7 * DAY_MS,
11525
12722
  authSessionRenewMs: 6 * DAY_MS,
11526
12723
  desktopToken,
11527
- portFile: env.PENGUIN_PORT_FILE?.trim() || null
12724
+ portFile: env.PENGUIN_PORT_FILE?.trim() || null,
12725
+ trustProxy: env.PENGUIN_TRUST_PROXY === "1"
11528
12726
  };
11529
12727
  }
11530
12728
 
@@ -11566,11 +12764,11 @@ deps.goalsRepo.abortOrphanedActive();
11566
12764
  var appHost = loopbackHostRoles(config.host)?.app ?? config.host;
11567
12765
  var ipv6Loopback = null;
11568
12766
  function writePortFile(file, port) {
11569
- fs23.mkdirSync(path19.dirname(file), { recursive: true });
12767
+ fs24.mkdirSync(path19.dirname(file), { recursive: true });
11570
12768
  const tmp = `${file}.${process.pid}.tmp`;
11571
- fs23.writeFileSync(tmp, `${port}
12769
+ fs24.writeFileSync(tmp, `${port}
11572
12770
  `);
11573
- fs23.renameSync(tmp, file);
12771
+ fs24.renameSync(tmp, file);
11574
12772
  }
11575
12773
  var server = serve({ fetch: app.fetch, hostname: config.host, port: config.port }, (info) => {
11576
12774
  console.log(`penguin-server started: http://${appHost}:${info.port}`);
@@ -11597,7 +12795,7 @@ function cleanupInstanceFiles() {
11597
12795
  releaseServerLock(config.root);
11598
12796
  if (config.portFile !== null) {
11599
12797
  try {
11600
- fs23.rmSync(config.portFile, { force: true });
12798
+ fs24.rmSync(config.portFile, { force: true });
11601
12799
  } catch {
11602
12800
  }
11603
12801
  }
@@ -11609,6 +12807,7 @@ async function shutdown(signal, exitCode = 0) {
11609
12807
  console.log(`Received ${signal}, shutting down\u2026`);
11610
12808
  deps.scheduler.stop();
11611
12809
  await deps.manager.shutdown(5e3);
12810
+ deps.hmr.dispose();
11612
12811
  deps.channels.dispose();
11613
12812
  ipv6Loopback?.close();
11614
12813
  server.close(() => {