@zuzuucodes/cli 1.5.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (198) hide show
  1. package/bin/zuzuu.mjs +12 -3
  2. package/package.json +1 -1
  3. package/web-app/dist/auth.js +91 -0
  4. package/web-app/dist/server.js +16 -79
  5. package/web-app/dist/zuzuu-cli.js +124 -0
  6. package/web-app/dist/{zuzuu-api.js → zuzuu-routes.js} +46 -116
  7. package/web-app/web-dist/assets/CommandPalette-DhBdR7X3.js +45 -0
  8. package/web-app/web-dist/assets/DiffTab-CqxwSjI2.js +1 -0
  9. package/web-app/web-dist/assets/EditorPane-94QPFR9R.js +41 -0
  10. package/web-app/web-dist/assets/MonacoFile-D76epTrG.js +1 -0
  11. package/web-app/web-dist/assets/angular-html-BVBpGdXr.js +1 -0
  12. package/web-app/web-dist/assets/{angular-ts-CD_OonCa.js → angular-ts-BfdufMKP.js} +1 -1
  13. package/web-app/web-dist/assets/{apl-uOGC3x4e.js → apl-DWBSSoBH.js} +1 -1
  14. package/web-app/web-dist/assets/{astro-B6ybQmWG.js → astro-3LtMP0Sq.js} +1 -1
  15. package/web-app/web-dist/assets/{blade-B1QGRlVx.js → blade-llJRbbtR.js} +1 -1
  16. package/web-app/web-dist/assets/c-Wt1voDr2.js +1 -0
  17. package/web-app/web-dist/assets/{cobol-BgqgtYWn.js → cobol-x_HIyl2P.js} +1 -1
  18. package/web-app/web-dist/assets/{coffee-0wIRKYlr.js → coffee-CThvmt4R.js} +1 -1
  19. package/web-app/web-dist/assets/cpp-NtAeskI3.js +1 -0
  20. package/web-app/web-dist/assets/{crystal-CyTK3qFN.js → crystal-DNu_sX0G.js} +1 -1
  21. package/web-app/web-dist/assets/css-DJp_X0uY.js +1 -0
  22. package/web-app/web-dist/assets/{cssMode-Dx3ub8Pk.js → cssMode-ByQBaInt.js} +1 -1
  23. package/web-app/web-dist/assets/dist-DQqjtuhV.js +153 -0
  24. package/web-app/web-dist/assets/{edge-CvML9pwC.js → edge-ozw5tpLl.js} +1 -1
  25. package/web-app/web-dist/assets/{editor.api2-BmGoRSl4.js → editor.api2-C7skgoRB.js} +1 -1
  26. package/web-app/web-dist/assets/{elixir-CrjqTiSc.js → elixir-VhA6FeZt.js} +1 -1
  27. package/web-app/web-dist/assets/{elm-C4JtJ0Au.js → elm-dREJmIFz.js} +1 -1
  28. package/web-app/web-dist/assets/{erb-Cmeb-29V.js → erb-CIg6G69l.js} +1 -1
  29. package/web-app/web-dist/assets/{freemarker2-B5LAi19B.js → freemarker2-CBBwP9JV.js} +1 -1
  30. package/web-app/web-dist/assets/{git-rebase-CXqdToiP.js → git-rebase-B44mJPta.js} +1 -1
  31. package/web-app/web-dist/assets/{glimmer-js-Kq-kdTyV.js → glimmer-js-vH_gHG0-.js} +1 -1
  32. package/web-app/web-dist/assets/{glimmer-ts-D0RKLJNf.js → glimmer-ts--abOzSAQ.js} +1 -1
  33. package/web-app/web-dist/assets/glsl-Dv5r7kPw.js +1 -0
  34. package/web-app/web-dist/assets/graphql-CB4jsw2E.js +1 -0
  35. package/web-app/web-dist/assets/{hack-trjVF3Po.js → hack-DvEYX148.js} +1 -1
  36. package/web-app/web-dist/assets/haml-zE6W3STP.js +1 -0
  37. package/web-app/web-dist/assets/{handlebars-B8_x7Zx7.js → handlebars-CzBR2SDs.js} +1 -1
  38. package/web-app/web-dist/assets/{handlebars-g7ZhGhI_.js → handlebars-tXdfxEd6.js} +1 -1
  39. package/web-app/web-dist/assets/html-C8UlPnhE.js +1 -0
  40. package/web-app/web-dist/assets/{html-CfvRMgoC.js → html-DgPn1QYH.js} +1 -1
  41. package/web-app/web-dist/assets/{html-derivative-BYX_F_XH.js → html-derivative-CY6NRz-J.js} +1 -1
  42. package/web-app/web-dist/assets/{htmlMode-DM6oHc7c.js → htmlMode-BtdIDgA2.js} +1 -1
  43. package/web-app/web-dist/assets/{http-BIVDpHT-.js → http-Cyd7bS_S.js} +1 -1
  44. package/web-app/web-dist/assets/{hurl-CFsshMju.js → hurl-CWPsiEpf.js} +1 -1
  45. package/web-app/web-dist/assets/index-B27_WOhS.css +2 -0
  46. package/web-app/web-dist/assets/index-De6DWTZM.js +7 -0
  47. package/web-app/web-dist/assets/java-CGc3VwQr.js +1 -0
  48. package/web-app/web-dist/assets/{javascript-Bxx2wV4w.js → javascript-5m05n-Be.js} +1 -1
  49. package/web-app/web-dist/assets/javascript-CUt1pgmJ.js +1 -0
  50. package/web-app/web-dist/assets/{jinja-_ZS5zWwe.js → jinja-CD-Z-FLd.js} +1 -1
  51. package/web-app/web-dist/assets/{jison-D8mMEpcs.js → jison-imPNup1l.js} +1 -1
  52. package/web-app/web-dist/assets/json-Bg9ijW3F.js +1 -0
  53. package/web-app/web-dist/assets/{jsonMode-DflaUwqW.js → jsonMode-BG32YnTY.js} +1 -1
  54. package/web-app/web-dist/assets/jsx-CY6oMTks.js +1 -0
  55. package/web-app/web-dist/assets/{julia-D4h2DZrs.js → julia-Dc3O-irA.js} +1 -1
  56. package/web-app/web-dist/assets/{just-bMqQi3xg.js → just-BhOq_Kbv.js} +1 -1
  57. package/web-app/web-dist/assets/{latex-DThYi3CX.js → latex-Cu4Y1d5w.js} +1 -1
  58. package/web-app/web-dist/assets/lib-KIOQTlcs.js +1 -0
  59. package/web-app/web-dist/assets/{liquid-CUjzzP4r.js → liquid-3ZnQzTbs.js} +1 -1
  60. package/web-app/web-dist/assets/{liquid-CesB-zzl.js → liquid-CvXMrjlQ.js} +1 -1
  61. package/web-app/web-dist/assets/{lspLanguageFeatures-gTnJsses.js → lspLanguageFeatures-6KXALSrl.js} +1 -1
  62. package/web-app/web-dist/assets/lua-BjLEUjKY.js +1 -0
  63. package/web-app/web-dist/assets/{marko-yoGoLK2m.js → marko-DvhNOisQ.js} +1 -1
  64. package/web-app/web-dist/assets/{mdc-BvtXU6eH.js → mdc-Bm9TpL1X.js} +1 -1
  65. package/web-app/web-dist/assets/{mdx-DrXGQbNB.js → mdx-DffTEkNE.js} +1 -1
  66. package/web-app/web-dist/assets/{monaco-setup-wbBeb0oN.js → monaco-setup-DM3A5_VI.js} +3 -3
  67. package/web-app/web-dist/assets/{nginx-DoUz032F.js → nginx-Bhc82uuv.js} +1 -1
  68. package/web-app/web-dist/assets/{nim-B0Pl8B4R.js → nim-DXTVBFnF.js} +1 -1
  69. package/web-app/web-dist/assets/{perl-D2tfAALb.js → perl-C7veXV9z.js} +1 -1
  70. package/web-app/web-dist/assets/{php-BImCcX5X.js → php-BRiuMnnr.js} +1 -1
  71. package/web-app/web-dist/assets/{pug-BcnpC8P_.js → pug-C5hz5LQ7.js} +1 -1
  72. package/web-app/web-dist/assets/{python-ypRCBnvu.js → python-DyLAD3Wt.js} +1 -1
  73. package/web-app/web-dist/assets/{qml-DFDAunHY.js → qml-BdUV3aTS.js} +1 -1
  74. package/web-app/web-dist/assets/r-8R7vtdQc.js +1 -0
  75. package/web-app/web-dist/assets/{razor-aqrhpwqZ.js → razor-C49xQTPQ.js} +1 -1
  76. package/web-app/web-dist/assets/{razor-1_376SZM.js → razor-DRL52XO2.js} +1 -1
  77. package/web-app/web-dist/assets/react-vendor-CCIEwYL0.js +9 -0
  78. package/web-app/web-dist/assets/regexp-Omp9DhTb.js +1 -0
  79. package/web-app/web-dist/assets/{rst-2vG6f11Y.js → rst-BHX71KW9.js} +1 -1
  80. package/web-app/web-dist/assets/{ruby-Dj6bCFXR.js → ruby-B--HzjGU.js} +1 -1
  81. package/web-app/web-dist/assets/{sas-BhVZ4qL2.js → sas-DrLaYOK_.js} +1 -1
  82. package/web-app/web-dist/assets/scss-DdSxiZKl.js +1 -0
  83. package/web-app/web-dist/assets/shellscript-DwcUjJBL.js +1 -0
  84. package/web-app/web-dist/assets/{shellsession-CyO2fnhB.js → shellsession-CPZkydE6.js} +1 -1
  85. package/web-app/web-dist/assets/{soy-DIkw6E88.js → soy-Br5FhD7c.js} +1 -1
  86. package/web-app/web-dist/assets/sql-DNssxck8.js +1 -0
  87. package/web-app/web-dist/assets/{stata-DvkM932O.js → stata-DXn1tqOr.js} +1 -1
  88. package/web-app/web-dist/assets/{surrealql-B4-Q8tqV.js → surrealql-IeLNQw0f.js} +1 -1
  89. package/web-app/web-dist/assets/{svelte-p6yBy-Ki.js → svelte-DOdLCIlh.js} +1 -1
  90. package/web-app/web-dist/assets/{templ-C7EkuiZr.js → templ-CIwIngms.js} +1 -1
  91. package/web-app/web-dist/assets/{tex-DkmD8uFC.js → tex-D8QMumu5.js} +1 -1
  92. package/web-app/web-dist/assets/{ts-tags-U-hncHg4.js → ts-tags-BMVY4q-l.js} +1 -1
  93. package/web-app/web-dist/assets/{tsMode-DRwkDcoK.js → tsMode-BndVBac5.js} +1 -1
  94. package/web-app/web-dist/assets/tsx-5Eka4NBX.js +1 -0
  95. package/web-app/web-dist/assets/{twig-CU0OP-IA.js → twig-C8o_5mgw.js} +1 -1
  96. package/web-app/web-dist/assets/{typescript-DnLjiKtn.js → typescript-B1w9vqKF.js} +1 -1
  97. package/web-app/web-dist/assets/typescript-DOu2WMV5.js +1 -0
  98. package/web-app/web-dist/assets/{vue-Db7nY3ba.js → vue-BU18DNDL.js} +1 -1
  99. package/web-app/web-dist/assets/{vue-html-BvAbiAw1.js → vue-html-BeluIYX0.js} +1 -1
  100. package/web-app/web-dist/assets/{vue-vine-BEaIQIlA.js → vue-vine-DGUAbOCX.js} +1 -1
  101. package/web-app/web-dist/assets/{xml-an4Nuuqq.js → xml-D8uAlVv5.js} +1 -1
  102. package/web-app/web-dist/assets/xml-DIqSwXR3.js +1 -0
  103. package/web-app/web-dist/assets/{xsl-D3NQgH22.js → xsl-Ct_-YIAy.js} +1 -1
  104. package/web-app/web-dist/assets/xterm-B1ffpRuj.js +36 -0
  105. package/web-app/web-dist/assets/xterm-addons-psDEiUMC.js +136 -0
  106. package/web-app/web-dist/assets/{yaml-Diiu6O9P.js → yaml-Bb7jXyQv.js} +1 -1
  107. package/web-app/web-dist/assets/yaml-DTtCYNlS.js +1 -0
  108. package/web-app/web-dist/index.html +6 -3
  109. package/zuzuu/actions/trail.mjs +1 -1
  110. package/zuzuu/commands/act.mjs +1 -1
  111. package/zuzuu/commands/capture.mjs +2 -2
  112. package/zuzuu/commands/code.mjs +2 -2
  113. package/zuzuu/commands/digest.mjs +2 -2
  114. package/zuzuu/commands/distill.mjs +15 -16
  115. package/zuzuu/commands/doctor.mjs +39 -4
  116. package/zuzuu/commands/enable.mjs +1 -1
  117. package/zuzuu/commands/eval.mjs +3 -36
  118. package/zuzuu/commands/faculty.mjs +102 -19
  119. package/zuzuu/commands/generation.mjs +3 -4
  120. package/zuzuu/commands/hook.mjs +7 -7
  121. package/zuzuu/commands/inbox.mjs +1 -6
  122. package/zuzuu/commands/init.mjs +5 -4
  123. package/zuzuu/commands/knowledge.mjs +1 -1
  124. package/zuzuu/commands/migrations/home.mjs +96 -0
  125. package/zuzuu/commands/migrations/index.mjs +48 -0
  126. package/zuzuu/commands/{migrate.mjs → migrations/items.mjs} +34 -246
  127. package/zuzuu/commands/migrations/proposals.mjs +100 -0
  128. package/zuzuu/commands/proposals.mjs +131 -0
  129. package/zuzuu/commands/review.mjs +13 -227
  130. package/zuzuu/commands/session.mjs +8 -2
  131. package/zuzuu/commands/sessions.mjs +159 -0
  132. package/zuzuu/commands/status.mjs +3 -3
  133. package/zuzuu/commands/trace.mjs +1 -1
  134. package/zuzuu/{capture-core.mjs → core/capture-core.mjs} +3 -3
  135. package/zuzuu/{store.mjs → core/store.mjs} +1 -1
  136. package/zuzuu/digest/compose.mjs +96 -0
  137. package/zuzuu/eval/score.mjs +14 -1
  138. package/zuzuu/faculties/actions/index.mjs +283 -0
  139. package/zuzuu/faculties/guardrails/index.mjs +320 -0
  140. package/zuzuu/faculties/instructions/index.mjs +288 -0
  141. package/zuzuu/faculties/knowledge/index.mjs +185 -0
  142. package/zuzuu/{memory/adapter.mjs → faculties/memory/index.mjs} +37 -9
  143. package/zuzuu/faculty/generation/read.mjs +206 -0
  144. package/zuzuu/faculty/generation/write.mjs +207 -0
  145. package/zuzuu/faculty/items.mjs +11 -5
  146. package/zuzuu/faculty/module.mjs +74 -0
  147. package/zuzuu/faculty/pending.mjs +63 -0
  148. package/zuzuu/faculty/registry.mjs +204 -18
  149. package/zuzuu/faculty/render.mjs +59 -0
  150. package/zuzuu/faculty/trail.mjs +1 -1
  151. package/zuzuu/{guardrails.mjs → guardrails/engine.mjs} +1 -1
  152. package/zuzuu/{scaffold.mjs → home/scaffold.mjs} +12 -2
  153. package/zuzuu/live/live-store.mjs +2 -2
  154. package/zuzuu/live/reconcile.mjs +2 -2
  155. package/zuzuu/sessions/git.mjs +47 -0
  156. package/zuzuu/{session-git.mjs → sessions/session-git.mjs} +5 -43
  157. package/web-app/web-dist/assets/DiffTab-BpGp1akx.js +0 -1
  158. package/web-app/web-dist/assets/MonacoFile-CqbVacUZ.js +0 -1
  159. package/web-app/web-dist/assets/angular-html-CmT26mqM.js +0 -1
  160. package/web-app/web-dist/assets/c-BvoqrSVH.js +0 -1
  161. package/web-app/web-dist/assets/cpp-BXsk94m0.js +0 -1
  162. package/web-app/web-dist/assets/css-Z8oOGxII.js +0 -1
  163. package/web-app/web-dist/assets/dist-C6R6xoyX.js +0 -153
  164. package/web-app/web-dist/assets/glsl-KwyfU2aa.js +0 -1
  165. package/web-app/web-dist/assets/graphql-DSeOUAa2.js +0 -1
  166. package/web-app/web-dist/assets/haml-azVoxQRV.js +0 -1
  167. package/web-app/web-dist/assets/html-D_7P5S4m.js +0 -1
  168. package/web-app/web-dist/assets/index-DHpC851f.js +0 -268
  169. package/web-app/web-dist/assets/index-O-t1gyMG.css +0 -2
  170. package/web-app/web-dist/assets/java-D4RbCvBe.js +0 -1
  171. package/web-app/web-dist/assets/javascript-Cb010CKM.js +0 -1
  172. package/web-app/web-dist/assets/json-DWgqV4D1.js +0 -1
  173. package/web-app/web-dist/assets/jsx-CZjSJa1f.js +0 -1
  174. package/web-app/web-dist/assets/lua-TGj_6NzO.js +0 -1
  175. package/web-app/web-dist/assets/r-fCpuAR7u.js +0 -1
  176. package/web-app/web-dist/assets/regexp-B4yxx-Ty.js +0 -1
  177. package/web-app/web-dist/assets/scss-QdjMO_xV.js +0 -1
  178. package/web-app/web-dist/assets/shellscript-BnlgeVVx.js +0 -1
  179. package/web-app/web-dist/assets/sql-DGnQv6iD.js +0 -1
  180. package/web-app/web-dist/assets/tsx-MJ0-9sYG.js +0 -1
  181. package/web-app/web-dist/assets/typescript-C17ZkDe8.js +0 -1
  182. package/web-app/web-dist/assets/xml-CA9lHFQV.js +0 -1
  183. package/web-app/web-dist/assets/yaml-CwRYMJka.js +0 -1
  184. package/zuzuu/actions/adapter.mjs +0 -122
  185. package/zuzuu/digest.mjs +0 -154
  186. package/zuzuu/faculty/generation.mjs +0 -398
  187. package/zuzuu/guardrails/adapter.mjs +0 -103
  188. package/zuzuu/instructions/adapter.mjs +0 -93
  189. package/zuzuu/knowledge/adapter.mjs +0 -99
  190. package/zuzuu/miners/actions.mjs +0 -112
  191. package/zuzuu/miners/guardrails.mjs +0 -176
  192. package/zuzuu/miners/instructions.mjs +0 -157
  193. package/zuzuu/miners/knowledge.mjs +0 -25
  194. package/zuzuu/miners/memory.mjs +0 -27
  195. package/zuzuu/miners/registry.mjs +0 -31
  196. /package/web-app/web-dist/assets/{chunk-QTnfLwEv.js → rolldown-runtime-QTnfLwEv.js} +0 -0
  197. /package/zuzuu/{session.mjs → core/session.mjs} +0 -0
  198. /package/zuzuu/{inject.mjs → home/inject.mjs} +0 -0
package/bin/zuzuu.mjs CHANGED
@@ -21,11 +21,12 @@ import { doctor } from '../zuzuu/commands/doctor.mjs';
21
21
  import { enable, disable } from '../zuzuu/commands/enable.mjs';
22
22
  import { runHook } from '../zuzuu/commands/hook.mjs';
23
23
  import { remember, recall, knowledge } from '../zuzuu/commands/knowledge.mjs';
24
- import { review, proposals } from '../zuzuu/commands/review.mjs';
24
+ import { review } from '../zuzuu/commands/review.mjs';
25
+ import { proposals } from '../zuzuu/commands/proposals.mjs';
25
26
  import { distill } from '../zuzuu/commands/distill.mjs';
26
27
  import { digest } from '../zuzuu/commands/digest.mjs';
27
28
  import { act } from '../zuzuu/commands/act.mjs';
28
- import { migrate } from '../zuzuu/commands/migrate.mjs';
29
+ import { migrate } from '../zuzuu/commands/migrations/index.mjs';
29
30
  import { generation } from '../zuzuu/commands/generation.mjs';
30
31
  import { evalCmd } from '../zuzuu/commands/eval.mjs';
31
32
  import { code } from '../zuzuu/commands/code.mjs';
@@ -33,6 +34,7 @@ import { web } from '../zuzuu/commands/web.mjs';
33
34
  import { explain } from '../zuzuu/commands/explain.mjs';
34
35
  import { inbox } from '../zuzuu/commands/inbox.mjs';
35
36
  import { session } from '../zuzuu/commands/session.mjs';
37
+ import { sessions } from '../zuzuu/commands/sessions.mjs';
36
38
  import { faculty } from '../zuzuu/commands/faculty.mjs';
37
39
 
38
40
  function parseArgs(argv) {
@@ -79,6 +81,9 @@ usage: zuzuu <command> [options]
79
81
  list a faculty's envelope items (one doc · one line per item)
80
82
  faculty schema <f> [--json]
81
83
  print a faculty's payload schema (JSON-Schema subset)
84
+ faculty manifest <f> [--json]
85
+ print a faculty's module manifest (faculty.json)
86
+ faculty overview [--json] every faculty in one shot: ui + counts + top items + pending
82
87
  digest [--json] [--budget N]
83
88
  print the session-start grounding brief
84
89
  act [list|show <slug>|new <slug>|schema <slug>]
@@ -99,6 +104,9 @@ usage: zuzuu <command> [options]
99
104
  disable remove the background hooks
100
105
  session [status|merge|continue|discard]
101
106
  the invisible session branch (one per agent session)
107
+ sessions [--json] recorded sessions with lifecycle state labels
108
+ session inspect <id> [--json]
109
+ one session: trace summary + per-faculty mined signals
102
110
  eval [--faculty f] rank pending proposals by eval score, highest first
103
111
  migrate [--home|--items] one-time migrators: proposal schema · --home moves agent/ → .zuzuu/
104
112
  · --items rewrites legacy faculty shapes → the envelope standard
@@ -123,7 +131,7 @@ switch (cmd) {
123
131
  case 'knowledge': await knowledge(args); break;
124
132
  case 'digest': digest(args); break;
125
133
  case 'act': act(args); break;
126
- case 'distill': distill(args); break;
134
+ case 'distill': await distill(args); break;
127
135
  case 'inbox': inbox(args); break;
128
136
  case 'review': await review(args); break;
129
137
  case 'proposals': proposals(args); break;
@@ -134,6 +142,7 @@ switch (cmd) {
134
142
  case 'disable': disable(args); break;
135
143
  case 'hook': runHook(args._[0], { host: args.host, session: args.session }); break;
136
144
  case 'session': session(args); break;
145
+ case 'sessions': sessions(args); break;
137
146
  case 'faculty': faculty(args); break;
138
147
  case 'eval': evalCmd(args); break;
139
148
  case 'migrate': migrate(args); break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zuzuucodes/cli",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -0,0 +1,91 @@
1
+ // The daemon's auth + request-origin gates, extracted from server.ts:
2
+ // Host-header allowlist (DNS rebinding), Origin allowlist (cross-site WS
3
+ // hijacking / CSRF), and token-in-URL → HttpOnly cookie auth. One AuthGate
4
+ // instance guards both the Hono HTTP app and the WS upgrade path.
5
+ import crypto from "node:crypto";
6
+ import { getCookie, setCookie } from "hono/cookie";
7
+ const AUTH_COOKIE = "webcode_auth";
8
+ const COOKIE_MAX_AGE = 30 * 24 * 3600;
9
+ export class AuthGate {
10
+ authSessions = new Set();
11
+ allowedHosts;
12
+ allowedOrigins;
13
+ token;
14
+ constructor(cfg) {
15
+ this.token = cfg.token;
16
+ const hostNames = ["127.0.0.1", "localhost", "[::1]"];
17
+ this.allowedHosts = new Set(hostNames.flatMap((h) => [h, `${h}:${cfg.port}`]));
18
+ this.allowedOrigins = new Set([
19
+ ...hostNames.map((h) => `http://${h}:${cfg.port}`),
20
+ ...(cfg.extraOrigins ?? []),
21
+ ]);
22
+ // hosted: also accept the public hostname (Fly's edge sets Host to it);
23
+ // Host/Origin defense stays on, just widened to the one public origin.
24
+ if (cfg.publicHost) {
25
+ this.allowedHosts.add(cfg.publicHost.toLowerCase());
26
+ this.allowedOrigins.add(`https://${cfg.publicHost}`);
27
+ this.allowedOrigins.add(`http://${cfg.publicHost}`);
28
+ }
29
+ }
30
+ /** Host allowlist defeats DNS rebinding: rebinding changes DNS, not the Host header. */
31
+ hostAllowed(host) {
32
+ return !!host && this.allowedHosts.has(host.toLowerCase());
33
+ }
34
+ /** Origin allowlist defeats cross-site WS hijacking / CSRF from arbitrary websites. */
35
+ originAllowed(origin) {
36
+ return origin === undefined || this.allowedOrigins.has(origin);
37
+ }
38
+ /** WS upgrade path: is the request's cookie an authenticated session? */
39
+ cookieAuthed(cookieHeader) {
40
+ if (!cookieHeader)
41
+ return false;
42
+ const match = /(?:^|;\s*)webcode_auth=([^;]+)/.exec(cookieHeader);
43
+ return !!match && this.authSessions.has(match[1]);
44
+ }
45
+ /** App-wide gate: Host/Origin allowlists + the ?token= → cookie exchange. */
46
+ gate() {
47
+ return async (c, next) => {
48
+ if (!this.hostAllowed(c.req.header("host"))) {
49
+ return c.text("forbidden host", 403);
50
+ }
51
+ if (!this.originAllowed(c.req.header("origin"))) {
52
+ return c.text("forbidden origin", 403);
53
+ }
54
+ // Token exchange: any page request carrying ?token= gets a cookie.
55
+ const token = c.req.query("token");
56
+ if (token && !c.req.path.startsWith("/api/")) {
57
+ if (!timingSafeEqualStr(token, this.token))
58
+ return c.text("invalid token", 403);
59
+ const secret = crypto.randomBytes(24).toString("base64url");
60
+ this.authSessions.add(secret);
61
+ setCookie(c, AUTH_COOKIE, secret, {
62
+ httpOnly: true,
63
+ sameSite: "Strict",
64
+ path: "/",
65
+ maxAge: COOKIE_MAX_AGE,
66
+ });
67
+ const url = new URL(c.req.url);
68
+ url.searchParams.delete("token");
69
+ // /auth?token=… exists so the Vite dev server can proxy the
70
+ // exchange; land on the app root afterwards either way.
71
+ const dest = url.pathname === "/auth" ? "/" : url.pathname + url.search;
72
+ return c.redirect(dest);
73
+ }
74
+ await next();
75
+ };
76
+ }
77
+ /** /api/* gate: only cookie-authenticated sessions pass. */
78
+ requireAuth() {
79
+ return async (c, next) => {
80
+ if (!this.authSessions.has(getCookie(c, AUTH_COOKIE) ?? "")) {
81
+ return c.json({ error: "unauthorized" }, 401);
82
+ }
83
+ await next();
84
+ };
85
+ }
86
+ }
87
+ function timingSafeEqualStr(a, b) {
88
+ const ba = Buffer.from(a);
89
+ const bb = Buffer.from(b);
90
+ return ba.length === bb.length && crypto.timingSafeEqual(ba, bb);
91
+ }
@@ -1,17 +1,17 @@
1
- import crypto from "node:crypto";
2
1
  import fs from "node:fs";
3
2
  import fsp from "node:fs/promises";
4
3
  import path from "node:path";
5
4
  import { Readable } from "node:stream";
6
5
  import { Hono } from "hono";
7
- import { getCookie, setCookie } from "hono/cookie";
8
6
  import { serve } from "@hono/node-server";
9
7
  import { WebSocketServer } from "ws";
10
8
  import { execFile } from "node:child_process";
11
9
  import { promisify } from "node:util";
12
10
  import { SessionManager } from "./sessions.js";
11
+ import { AuthGate } from "./auth.js";
13
12
  import { createFsApi } from "./fs-api.js";
14
- import { createZuzuuApi, runZuzuuMut } from "./zuzuu-api.js";
13
+ import { createZuzuuApi } from "./zuzuu-routes.js";
14
+ import { runZuzuuMut } from "./zuzuu-cli.js";
15
15
  import { search } from "./search.js";
16
16
  import { listFiles } from "./file-list.js";
17
17
  import { listWorkflows, saveWorkflow } from "./workflows.js";
@@ -23,8 +23,6 @@ const execFileAsync = promisify(execFile);
23
23
  import { handleTermSocket } from "./ws-term.js";
24
24
  import { handleFsSocket } from "./ws-fs.js";
25
25
  import { PathError, resolveSafe, safeJoin } from "./safe-path.js";
26
- const AUTH_COOKIE = "webcode_auth";
27
- const COOKIE_MAX_AGE = 30 * 24 * 3600;
28
26
  /** Host CLIs an agent/command session may run. Argv-spawned, never a shell. */
29
27
  const DEFAULT_COMMAND_ALLOWLIST = ["claude", "gemini", "codex", "pi", "opencode", "zuzuu"];
30
28
  const STATIC_MIME = {
@@ -45,9 +43,7 @@ export class WebcodeServer {
45
43
  /** mutable workspace root — switchable at runtime via switchTo() */
46
44
  root;
47
45
  startedAt = Date.now();
48
- authSessions = new Set();
49
- allowedHosts;
50
- allowedOrigins;
46
+ auth;
51
47
  commandAllowlist;
52
48
  server = null;
53
49
  constructor(cfg) {
@@ -55,19 +51,12 @@ export class WebcodeServer {
55
51
  this.root = cfg.root;
56
52
  this.commandAllowlist = new Set(cfg.commandAllowlist ?? DEFAULT_COMMAND_ALLOWLIST);
57
53
  this.sessions = new SessionManager(cfg.root);
58
- const hostNames = ["127.0.0.1", "localhost", "[::1]"];
59
- this.allowedHosts = new Set(hostNames.flatMap((h) => [h, `${h}:${cfg.port}`]));
60
- this.allowedOrigins = new Set([
61
- ...hostNames.map((h) => `http://${h}:${cfg.port}`),
62
- ...(cfg.extraOrigins ?? []),
63
- ]);
64
- // hosted: also accept the public hostname (Fly's edge sets Host to it);
65
- // Host/Origin defense stays on, just widened to the one public origin.
66
- if (cfg.publicHost) {
67
- this.allowedHosts.add(cfg.publicHost.toLowerCase());
68
- this.allowedOrigins.add(`https://${cfg.publicHost}`);
69
- this.allowedOrigins.add(`http://${cfg.publicHost}`);
70
- }
54
+ this.auth = new AuthGate({
55
+ port: cfg.port,
56
+ token: cfg.token,
57
+ ...(cfg.extraOrigins !== undefined ? { extraOrigins: cfg.extraOrigins } : {}),
58
+ ...(cfg.publicHost !== undefined ? { publicHost: cfg.publicHost } : {}),
59
+ });
71
60
  this.app = this.buildApp();
72
61
  }
73
62
  /**
@@ -101,60 +90,13 @@ export class WebcodeServer {
101
90
  return { cliAbsent: true };
102
91
  return { ok: false, ...(r.stderr !== undefined ? { stderr: r.stderr } : {}), ...(r.data !== undefined ? { refusal: r.data } : {}) };
103
92
  }
104
- // ── security gates ─────────────────────────────────────────────────
105
- /** Host allowlist defeats DNS rebinding: rebinding changes DNS, not the Host header. */
106
- hostAllowed(host) {
107
- return !!host && this.allowedHosts.has(host.toLowerCase());
108
- }
109
- /** Origin allowlist defeats cross-site WS hijacking / CSRF from arbitrary websites. */
110
- originAllowed(origin) {
111
- return origin === undefined || this.allowedOrigins.has(origin);
112
- }
113
- cookieAuthed(cookieHeader) {
114
- if (!cookieHeader)
115
- return false;
116
- const match = /(?:^|;\s*)webcode_auth=([^;]+)/.exec(cookieHeader);
117
- return !!match && this.authSessions.has(match[1]);
118
- }
119
93
  // ── HTTP app ───────────────────────────────────────────────────────
120
94
  buildApp() {
121
95
  const { cfg } = this;
122
96
  const app = new Hono();
123
- app.use("*", async (c, next) => {
124
- if (!this.hostAllowed(c.req.header("host"))) {
125
- return c.text("forbidden host", 403);
126
- }
127
- if (!this.originAllowed(c.req.header("origin"))) {
128
- return c.text("forbidden origin", 403);
129
- }
130
- // Token exchange: any page request carrying ?token= gets a cookie.
131
- const token = c.req.query("token");
132
- if (token && !c.req.path.startsWith("/api/")) {
133
- if (!timingSafeEqualStr(token, cfg.token))
134
- return c.text("invalid token", 403);
135
- const secret = crypto.randomBytes(24).toString("base64url");
136
- this.authSessions.add(secret);
137
- setCookie(c, AUTH_COOKIE, secret, {
138
- httpOnly: true,
139
- sameSite: "Strict",
140
- path: "/",
141
- maxAge: COOKIE_MAX_AGE,
142
- });
143
- const url = new URL(c.req.url);
144
- url.searchParams.delete("token");
145
- // /auth?token=… exists so the Vite dev server can proxy the
146
- // exchange; land on the app root afterwards either way.
147
- const dest = url.pathname === "/auth" ? "/" : url.pathname + url.search;
148
- return c.redirect(dest);
149
- }
150
- await next();
151
- });
152
- app.use("/api/*", async (c, next) => {
153
- if (!this.authSessions.has(getCookie(c, AUTH_COOKIE) ?? "")) {
154
- return c.json({ error: "unauthorized" }, 401);
155
- }
156
- await next();
157
- });
97
+ // security gates live in auth.ts: Host/Origin allowlists + token→cookie
98
+ app.use("*", this.auth.gate());
99
+ app.use("/api/*", this.auth.requireAuth());
158
100
  app.get("/api/workspace", (c) => {
159
101
  const body = {
160
102
  root: this.root,
@@ -434,11 +376,11 @@ export class WebcodeServer {
434
376
  socket.write(`HTTP/1.1 ${status} ${msg}\r\nConnection: close\r\n\r\n`);
435
377
  socket.destroy();
436
378
  };
437
- if (!this.hostAllowed(req.headers.host))
379
+ if (!this.auth.hostAllowed(req.headers.host))
438
380
  return reject(403, "Forbidden");
439
- if (!this.originAllowed(req.headers.origin))
381
+ if (!this.auth.originAllowed(req.headers.origin))
440
382
  return reject(403, "Forbidden");
441
- if (!this.cookieAuthed(req.headers.cookie))
383
+ if (!this.auth.cookieAuthed(req.headers.cookie))
442
384
  return reject(401, "Unauthorized");
443
385
  const url = new URL(req.url ?? "/", "http://localhost");
444
386
  const termMatch = /^\/ws\/term\/([0-9a-f]+)$/.exec(url.pathname);
@@ -462,8 +404,3 @@ export class WebcodeServer {
462
404
  this.server?.close();
463
405
  }
464
406
  }
465
- function timingSafeEqualStr(a, b) {
466
- const ba = Buffer.from(a);
467
- const bb = Buffer.from(b);
468
- return ba.length === bb.length && crypto.timingSafeEqual(ba, bb);
469
- }
@@ -0,0 +1,124 @@
1
+ // The zuzuu CLI spawn layer — the ONLY place the daemon shells out to the
2
+ // `zuzuu` binary. Two flavours:
3
+ // runZuzuu — reads: any failure (absent, non-zero, unparseable) → null;
4
+ // callers degrade to file-read fallbacks.
5
+ // runZuzuuMut — mutations + CLI-only reads: failures are distinguished
6
+ // (binary absent vs command failed + stderr tail) so routes
7
+ // can answer 503 vs 502.
8
+ // Always argv arrays (never a shell), time-boxed, cwd-scoped to the workspace.
9
+ import { spawn, spawnSync } from "node:child_process";
10
+ /** Spawn `zuzuu <args> --json` in `root`. Returns parsed JSON, or null on any
11
+ * failure (binary absent, non-zero exit, unparseable). Read-only + time-boxed. */
12
+ export function runZuzuu(root, args, opts = {}) {
13
+ const binary = opts.binary ?? "zuzuu";
14
+ const timeoutMs = opts.timeoutMs ?? 5000;
15
+ return new Promise((resolve) => {
16
+ let out = "";
17
+ let done = false;
18
+ const finish = (v) => { if (!done) {
19
+ done = true;
20
+ resolve(v);
21
+ } };
22
+ let child;
23
+ try {
24
+ child = spawn(binary, [...args, "--json"], { cwd: root, stdio: ["ignore", "pipe", "ignore"] });
25
+ }
26
+ catch {
27
+ finish(null);
28
+ return;
29
+ }
30
+ const timer = setTimeout(() => { try {
31
+ child.kill();
32
+ }
33
+ catch { /* noop */ } finish(null); }, timeoutMs);
34
+ child.stdout?.on("data", (b) => { out += b.toString(); });
35
+ child.on("error", () => { clearTimeout(timer); finish(null); });
36
+ child.on("close", (code) => {
37
+ clearTimeout(timer);
38
+ if (code !== 0)
39
+ return finish(null);
40
+ try {
41
+ finish(JSON.parse(out));
42
+ }
43
+ catch {
44
+ finish(null);
45
+ }
46
+ });
47
+ });
48
+ }
49
+ const STDERR_TAIL = 2048;
50
+ /** Spawn `zuzuu <args> --json` where the caller needs to distinguish failures
51
+ * (mutations, and reads with no file fallback). Unlike runZuzuu: binary
52
+ * absent vs command failed (with a stderr tail) are separate results, so
53
+ * routes can answer 503 vs 502. Stdout must parse as JSON on success. */
54
+ export function runZuzuuMut(root, args, opts = {}) {
55
+ const binary = opts.binary ?? "zuzuu";
56
+ const timeoutMs = opts.timeoutMs ?? 10_000;
57
+ return new Promise((resolve) => {
58
+ let out = "";
59
+ let err = "";
60
+ let done = false;
61
+ const finish = (v) => { if (!done) {
62
+ done = true;
63
+ resolve(v);
64
+ } };
65
+ let child;
66
+ try {
67
+ child = spawn(binary, [...args, "--json"], { cwd: root, stdio: ["ignore", "pipe", "pipe"] });
68
+ }
69
+ catch {
70
+ finish({ ok: false, code: "absent" });
71
+ return;
72
+ }
73
+ const timer = setTimeout(() => {
74
+ try {
75
+ child.kill();
76
+ }
77
+ catch { /* noop */ }
78
+ finish({ ok: false, code: "failed", stderr: "zuzuu timed out" });
79
+ }, timeoutMs);
80
+ child.stdout?.on("data", (b) => { out += b.toString(); });
81
+ child.stderr?.on("data", (b) => {
82
+ err += b.toString();
83
+ if (err.length > STDERR_TAIL)
84
+ err = err.slice(-STDERR_TAIL);
85
+ });
86
+ child.on("error", (e) => {
87
+ clearTimeout(timer);
88
+ if (e.code === "ENOENT")
89
+ finish({ ok: false, code: "absent" });
90
+ else
91
+ finish({ ok: false, code: "failed", stderr: e.message });
92
+ });
93
+ child.on("close", (code) => {
94
+ clearTimeout(timer);
95
+ if (code !== 0) {
96
+ // zuzuu prints structured JSON even on refusals (exit 1, e.g.
97
+ // empty-squash-with-checkpoints) — keep it so the UI can act on reason.
98
+ try {
99
+ const parsed = JSON.parse(out);
100
+ return finish({ ok: false, code: "failed", stderr: err.slice(-STDERR_TAIL), data: parsed });
101
+ }
102
+ catch {
103
+ return finish({ ok: false, code: "failed", stderr: err.slice(-STDERR_TAIL) });
104
+ }
105
+ }
106
+ try {
107
+ finish({ ok: true, data: JSON.parse(out) });
108
+ }
109
+ catch {
110
+ finish({ ok: false, code: "failed", stderr: "unparseable JSON from zuzuu" });
111
+ }
112
+ });
113
+ });
114
+ }
115
+ /** Best-effort: is the zuzuu binary runnable? */
116
+ export function binAvailable(binary) {
117
+ try {
118
+ const r = spawnSync(binary, ["version"], { stdio: "ignore", timeout: 3000 });
119
+ return !r.error && r.status === 0;
120
+ }
121
+ catch {
122
+ return false;
123
+ }
124
+ }
@@ -1,133 +1,20 @@
1
1
  // /api/zuzuu/* — observe + act routes over a project's zuzuu `.zuzuu/` home.
2
2
  // Reads: raw data (proposals, generations, sessions, digest) comes from disk;
3
3
  // computed views (status, inbox, eval, generation diff) shell out to
4
- // `zuzuu <cmd> --json` and fall back to file-reads when the binary is absent.
4
+ // `zuzuu <cmd> --json` (the spawn layer lives in zuzuu-cli.ts) and fall back
5
+ // to file-reads when the binary is absent.
5
6
  // Writes: mutations (approve/reject, mint, rollback) are CLI-ONLY — the daemon
6
7
  // never reimplements faculty writes; no CLI → 503. Mirrors fs-api.ts.
7
8
  import fsp from "node:fs/promises";
8
9
  import { existsSync } from "node:fs";
9
- import { spawn, spawnSync } from "node:child_process";
10
10
  import path from "node:path";
11
11
  import { Hono } from "hono";
12
12
  import { PathError, resolveSafe } from "./safe-path.js";
13
+ import { binAvailable, runZuzuu, runZuzuuMut } from "./zuzuu-cli.js";
13
14
  const FACULTIES = ["knowledge", "memory", "actions", "instructions", "guardrails"];
14
15
  /** Ids/slugs/generation-ids that may ride into a zuzuu argv. Validated BEFORE any spawn. */
15
16
  const SAFE_ID = /^[a-z0-9][a-z0-9._-]{0,127}$/i;
16
17
  const MAX_REASON_LEN = 500;
17
- /** Spawn `zuzuu <args> --json` in `root`. Returns parsed JSON, or null on any
18
- * failure (binary absent, non-zero exit, unparseable). Read-only + time-boxed. */
19
- export function runZuzuu(root, args, opts = {}) {
20
- const binary = opts.binary ?? "zuzuu";
21
- const timeoutMs = opts.timeoutMs ?? 5000;
22
- return new Promise((resolve) => {
23
- let out = "";
24
- let done = false;
25
- const finish = (v) => { if (!done) {
26
- done = true;
27
- resolve(v);
28
- } };
29
- let child;
30
- try {
31
- child = spawn(binary, [...args, "--json"], { cwd: root, stdio: ["ignore", "pipe", "ignore"] });
32
- }
33
- catch {
34
- finish(null);
35
- return;
36
- }
37
- const timer = setTimeout(() => { try {
38
- child.kill();
39
- }
40
- catch { /* noop */ } finish(null); }, timeoutMs);
41
- child.stdout?.on("data", (b) => { out += b.toString(); });
42
- child.on("error", () => { clearTimeout(timer); finish(null); });
43
- child.on("close", (code) => {
44
- clearTimeout(timer);
45
- if (code !== 0)
46
- return finish(null);
47
- try {
48
- finish(JSON.parse(out));
49
- }
50
- catch {
51
- finish(null);
52
- }
53
- });
54
- });
55
- }
56
- const STDERR_TAIL = 2048;
57
- /** Spawn `zuzuu <args> --json` for a MUTATION. Unlike runZuzuu, failures are
58
- * distinguished: binary absent vs command failed (with a stderr tail), so
59
- * routes can answer 503 vs 502. Stdout must parse as JSON on success. */
60
- export function runZuzuuMut(root, args, opts = {}) {
61
- const binary = opts.binary ?? "zuzuu";
62
- const timeoutMs = opts.timeoutMs ?? 10_000;
63
- return new Promise((resolve) => {
64
- let out = "";
65
- let err = "";
66
- let done = false;
67
- const finish = (v) => { if (!done) {
68
- done = true;
69
- resolve(v);
70
- } };
71
- let child;
72
- try {
73
- child = spawn(binary, [...args, "--json"], { cwd: root, stdio: ["ignore", "pipe", "pipe"] });
74
- }
75
- catch {
76
- finish({ ok: false, code: "absent" });
77
- return;
78
- }
79
- const timer = setTimeout(() => {
80
- try {
81
- child.kill();
82
- }
83
- catch { /* noop */ }
84
- finish({ ok: false, code: "failed", stderr: "zuzuu timed out" });
85
- }, timeoutMs);
86
- child.stdout?.on("data", (b) => { out += b.toString(); });
87
- child.stderr?.on("data", (b) => {
88
- err += b.toString();
89
- if (err.length > STDERR_TAIL)
90
- err = err.slice(-STDERR_TAIL);
91
- });
92
- child.on("error", (e) => {
93
- clearTimeout(timer);
94
- if (e.code === "ENOENT")
95
- finish({ ok: false, code: "absent" });
96
- else
97
- finish({ ok: false, code: "failed", stderr: e.message });
98
- });
99
- child.on("close", (code) => {
100
- clearTimeout(timer);
101
- if (code !== 0) {
102
- // zuzuu prints structured JSON even on refusals (exit 1, e.g.
103
- // empty-squash-with-checkpoints) — keep it so the UI can act on reason.
104
- try {
105
- const parsed = JSON.parse(out);
106
- return finish({ ok: false, code: "failed", stderr: err.slice(-STDERR_TAIL), data: parsed });
107
- }
108
- catch {
109
- return finish({ ok: false, code: "failed", stderr: err.slice(-STDERR_TAIL) });
110
- }
111
- }
112
- try {
113
- finish({ ok: true, data: JSON.parse(out) });
114
- }
115
- catch {
116
- finish({ ok: false, code: "failed", stderr: "unparseable JSON from zuzuu" });
117
- }
118
- });
119
- });
120
- }
121
- /** Best-effort: is the zuzuu binary runnable? */
122
- function binAvailable(binary) {
123
- try {
124
- const r = spawnSync(binary, ["version"], { stdio: "ignore", timeout: 3000 });
125
- return !r.error && r.status === 0;
126
- }
127
- catch {
128
- return false;
129
- }
130
- }
131
18
  // ── Faculty Standard envelope listing ────────────────────────────────────
132
19
  // The CLI is the parser of record (`zuzuu faculty items <f> --json` returns
133
20
  // the full envelopes incl. payload/body). When it's absent we degrade to a
@@ -278,6 +165,28 @@ export function createZuzuuApi(getRoot, opts = {}) {
278
165
  }));
279
166
  return c.json({ faculties });
280
167
  });
168
+ // The batched faculty surface: ONE `zuzuu faculty overview --json` spawn
169
+ // covers all faculties (manifest ui descriptors + counts + top titles +
170
+ // pending) — replaces the 5-spawn-per-cycle /faculties pattern for the
171
+ // panel root. CLI absent → peek fallback (counts survive, ui descriptors
172
+ // degrade to the web kit's built-in metadata).
173
+ app.get("/overview", async (c) => {
174
+ const viaCli = await runZuzuu(root, ["faculty", "overview"], { binary: opts.binary });
175
+ if (viaCli && Array.isArray(viaCli.faculties))
176
+ return c.json(viaCli);
177
+ const agent = await agentDir();
178
+ const faculties = await Promise.all(FACULTIES.map(async (id) => {
179
+ const [items, proposals] = await Promise.all([peekFacultyItems(agent, id), proposalsOf(agent, id)]);
180
+ return {
181
+ id,
182
+ title: id.charAt(0).toUpperCase() + id.slice(1),
183
+ counts: { items: items.length, pending: proposals.length, errors: 0 },
184
+ top: items.slice(0, 3).map((it) => String(it.title ?? it.id)),
185
+ declarative: false,
186
+ };
187
+ }));
188
+ return c.json({ faculties, degraded: true });
189
+ });
281
190
  app.get("/faculty/:key", async (c) => {
282
191
  const key = c.req.param("key");
283
192
  if (!FACULTIES.includes(key))
@@ -320,7 +229,13 @@ export function createZuzuuApi(getRoot, opts = {}) {
320
229
  generations: gens.map((g) => ({ id: String(g.id), mintedAt: g.mintedAt ?? null, mintedFrom: g.mintedFrom ?? [] })),
321
230
  });
322
231
  });
232
+ // Sessions list: CLI-first (`zuzuu sessions --json` — state-labelled:
233
+ // active|completed|abandoned|crashed|captured); falls back to the raw
234
+ // sessions.json index (no state labels) when the CLI is absent.
323
235
  app.get("/sessions", async (c) => {
236
+ const viaCli = await runZuzuu(root, ["sessions"], { binary: opts.binary });
237
+ if (viaCli && Array.isArray(viaCli.sessions))
238
+ return c.json(viaCli);
324
239
  const agent = await agentDir();
325
240
  try {
326
241
  const idx = JSON.parse(await fsp.readFile(path.join(agent, "sessions.json"), "utf8"));
@@ -330,6 +245,21 @@ export function createZuzuuApi(getRoot, opts = {}) {
330
245
  return c.json({ sessions: [] });
331
246
  }
332
247
  });
248
+ // One session's observability document (`zuzuu session inspect <id> --json`:
249
+ // trace summary + per-faculty mined signals + warnings). CLI-only — the
250
+ // daemon never re-mines transcripts; absent → 503, failed → 502.
251
+ app.get("/session-inspect/:id", async (c) => {
252
+ const id = c.req.param("id");
253
+ if (!SAFE_ID.test(id))
254
+ return c.json({ error: "bad id" }, 400);
255
+ const r = await runZuzuuMut(root, ["session", "inspect", id], { binary: opts.binary });
256
+ if (!r.ok) {
257
+ return r.code === "absent"
258
+ ? c.json({ error: "zuzuu CLI required" }, 503)
259
+ : c.json({ error: "zuzuu command failed", stderr: r.stderr ?? "", data: r.data ?? null }, 502);
260
+ }
261
+ return c.json(r.data);
262
+ });
333
263
  app.get("/digest", async (c) => {
334
264
  const agent = await agentDir();
335
265
  try {