@crouton-kit/crouter 0.3.28 → 0.3.29

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 (322) hide show
  1. package/dist/build-root.js +4 -2
  2. package/dist/builtin-memory/crouter-development/marketplaces.md +7 -6
  3. package/dist/builtin-memory/crouter-development/personas/base-prompt.md +1 -1
  4. package/dist/builtin-memory/crouter-development/personas/orchestrator-prompt.md +1 -1
  5. package/dist/builtin-memory/crouter-development/personas.md +1 -1
  6. package/dist/builtin-memory/crouter-development/plugins.md +20 -18
  7. package/dist/builtin-memory/design.md +1 -1
  8. package/dist/builtin-memory/development.md +1 -1
  9. package/dist/builtin-memory/internal/INDEX.md +1 -1
  10. package/dist/builtin-memory/internal/examples/INDEX.md +1 -1
  11. package/dist/builtin-memory/internal/examples/imessage-assistant.md +2 -2
  12. package/dist/builtin-memory/internal/nodes-and-canvas.md +1 -1
  13. package/dist/builtin-memory/internal/storage-tiers.md +1 -1
  14. package/dist/builtin-memory/planning.md +1 -1
  15. package/dist/builtin-memory/spec.md +6 -4
  16. package/dist/builtin-personas/orchestration-kernel.md +4 -5
  17. package/dist/builtin-personas/runtime-base.md +5 -4
  18. package/dist/builtin-personas/spec/PERSONA.md +8 -4
  19. package/dist/builtin-personas/spec/orchestrator.md +5 -3
  20. package/dist/builtin-personas/spec/requirements/PERSONA.md +10 -0
  21. package/dist/builtin-views/canvas/core.mjs +586 -0
  22. package/dist/builtin-views/canvas/text.mjs +58 -0
  23. package/dist/builtin-views/canvas/tui.mjs +165 -0
  24. package/dist/builtin-views/canvas/web.jsx +120 -0
  25. package/dist/builtin-views/git-pr/core.mjs +673 -0
  26. package/dist/builtin-views/git-pr/text.mjs +84 -0
  27. package/dist/builtin-views/git-pr/tui.mjs +302 -0
  28. package/dist/builtin-views/git-pr/web.jsx +216 -0
  29. package/dist/builtin-views/inbox/_lib/render.mjs +1 -3
  30. package/dist/builtin-views/inbox/core.mjs +1273 -0
  31. package/dist/builtin-views/inbox/text.mjs +73 -0
  32. package/dist/builtin-views/inbox/tui.mjs +312 -0
  33. package/dist/builtin-views/inbox/web.jsx +188 -0
  34. package/dist/builtin-views/linkedin/core.mjs +906 -0
  35. package/dist/builtin-views/linkedin/text.mjs +69 -0
  36. package/dist/builtin-views/linkedin/tui.mjs +427 -0
  37. package/dist/builtin-views/linkedin/web.jsx +206 -0
  38. package/dist/builtin-views/workspace-sidebar/core.mjs +621 -0
  39. package/dist/builtin-views/workspace-sidebar/text.mjs +53 -0
  40. package/dist/builtin-views/workspace-sidebar/tui.mjs +142 -0
  41. package/dist/builtin-views/workspace-sidebar/web.jsx +109 -0
  42. package/dist/cli.js +3 -3
  43. package/dist/clients/attach/__tests__/action-parity.test.js +48 -0
  44. package/dist/clients/attach/__tests__/clipboard-image.test.js +76 -0
  45. package/dist/clients/attach/__tests__/editor-newline.test.js +57 -0
  46. package/dist/clients/attach/__tests__/git-info.test.js +31 -0
  47. package/dist/clients/attach/__tests__/onrequest-wired.test.js +20 -0
  48. package/dist/clients/attach/__tests__/slash-quit-copy.test.js +42 -0
  49. package/dist/clients/attach/__tests__/titled-editor.test.js +39 -0
  50. package/dist/clients/attach/attach-cmd.js +282 -46
  51. package/dist/clients/attach/auth-pickers.d.ts +9 -0
  52. package/dist/clients/attach/auth-pickers.js +194 -0
  53. package/dist/clients/attach/canvas-panels.d.ts +4 -2
  54. package/dist/clients/attach/canvas-panels.js +45 -17
  55. package/dist/clients/attach/chat-view.d.ts +27 -2
  56. package/dist/clients/attach/chat-view.js +98 -9
  57. package/dist/clients/attach/clipboard-image.d.ts +47 -8
  58. package/dist/clients/attach/clipboard-image.js +150 -26
  59. package/dist/clients/attach/clipboard-text.d.ts +3 -0
  60. package/dist/clients/attach/clipboard-text.js +39 -0
  61. package/dist/clients/attach/config-load.d.ts +54 -7
  62. package/dist/clients/attach/config-load.js +99 -3
  63. package/dist/clients/attach/context-message.d.ts +12 -0
  64. package/dist/clients/attach/context-message.js +72 -0
  65. package/dist/clients/attach/git-info.d.ts +20 -0
  66. package/dist/clients/attach/git-info.js +44 -0
  67. package/dist/clients/attach/graph-overlay.d.ts +13 -4
  68. package/dist/clients/attach/graph-overlay.js +59 -19
  69. package/dist/clients/attach/input-controller.d.ts +75 -13
  70. package/dist/clients/attach/input-controller.js +305 -67
  71. package/dist/clients/attach/pickers.d.ts +58 -0
  72. package/dist/clients/attach/pickers.js +171 -0
  73. package/dist/clients/attach/slash-commands.d.ts +22 -0
  74. package/dist/clients/attach/slash-commands.js +91 -25
  75. package/dist/clients/attach/titled-editor.d.ts +33 -0
  76. package/dist/clients/attach/titled-editor.js +84 -0
  77. package/dist/clients/attach/view-socket.d.ts +18 -1
  78. package/dist/clients/attach/view-socket.js +70 -1
  79. package/dist/clients/web/dev-server.d.ts +7 -0
  80. package/dist/clients/web/dev-server.js +59 -0
  81. package/dist/clients/web/events.d.ts +14 -0
  82. package/dist/clients/web/events.js +151 -0
  83. package/dist/clients/web/server.d.ts +18 -0
  84. package/dist/clients/web/server.js +450 -0
  85. package/dist/clients/web/web-cmd.d.ts +2 -0
  86. package/dist/clients/web/web-cmd.js +120 -0
  87. package/dist/commands/canvas.js +1 -2
  88. package/dist/commands/chord.js +1 -1
  89. package/dist/commands/dashboard.js +5 -1
  90. package/dist/commands/memory/__tests__/lint-schema.test.js +6 -6
  91. package/dist/commands/memory/lint.js +14 -2
  92. package/dist/commands/memory/read.js +2 -2
  93. package/dist/commands/memory/shared.d.ts +7 -1
  94. package/dist/commands/memory/shared.js +18 -3
  95. package/dist/commands/memory/write.js +8 -4
  96. package/dist/commands/memory.js +4 -4
  97. package/dist/commands/node.d.ts +1 -1
  98. package/dist/commands/node.js +64 -65
  99. package/dist/commands/pkg.js +1 -2
  100. package/dist/commands/revive.js +11 -3
  101. package/dist/commands/sys/sync.d.ts +1 -0
  102. package/dist/commands/sys/sync.js +187 -0
  103. package/dist/commands/sys.js +3 -2
  104. package/dist/commands/view-cycle.js +2 -2
  105. package/dist/commands/view-list.js +8 -8
  106. package/dist/commands/view-new.js +21 -17
  107. package/dist/commands/view-pick.js +1 -1
  108. package/dist/commands/view-run.js +35 -14
  109. package/dist/commands/view.js +8 -6
  110. package/dist/commands/workspace.d.ts +2 -0
  111. package/dist/commands/workspace.js +161 -0
  112. package/dist/core/__tests__/broker-double-spawn.test.d.ts +1 -0
  113. package/dist/core/__tests__/broker-double-spawn.test.js +143 -0
  114. package/dist/core/__tests__/broker-fork-seam.test.d.ts +1 -0
  115. package/dist/core/__tests__/broker-fork-seam.test.js +102 -0
  116. package/dist/core/__tests__/broker-sdk-wiring.test.js +2 -0
  117. package/dist/core/__tests__/child-death-wake.test.js +56 -111
  118. package/dist/core/__tests__/close.test.js +14 -20
  119. package/dist/core/__tests__/context-intro.test.js +19 -19
  120. package/dist/core/__tests__/daemon-boot.test.js +30 -23
  121. package/dist/core/__tests__/draw-style.test.js +1 -1
  122. package/dist/core/__tests__/error-stall-recycle.test.d.ts +1 -0
  123. package/dist/core/__tests__/error-stall-recycle.test.js +141 -0
  124. package/dist/core/__tests__/fixtures/fake-engine.d.ts +17 -1
  125. package/dist/core/__tests__/fixtures/fake-engine.js +22 -2
  126. package/dist/core/__tests__/fixtures/fake-pi-host.js +2 -2
  127. package/dist/core/__tests__/focuses.test.js +25 -13
  128. package/dist/core/__tests__/fork.test.js +22 -5
  129. package/dist/core/__tests__/full/broker-control-preempt.test.d.ts +1 -0
  130. package/dist/core/__tests__/full/broker-control-preempt.test.js +61 -0
  131. package/dist/core/__tests__/full/broker-model-changed-broadcast.test.d.ts +1 -0
  132. package/dist/core/__tests__/full/broker-model-changed-broadcast.test.js +57 -0
  133. package/dist/core/__tests__/full/broker-navigate-tree-rewelcome.test.d.ts +1 -0
  134. package/dist/core/__tests__/full/broker-navigate-tree-rewelcome.test.js +80 -0
  135. package/dist/core/__tests__/full/broker-pane-resolution.test.js +90 -0
  136. package/dist/core/__tests__/full/cascade-close.test.js +22 -8
  137. package/dist/core/__tests__/full/detach-focus.test.js +66 -169
  138. package/dist/core/__tests__/helpers/harness.d.ts +6 -9
  139. package/dist/core/__tests__/helpers/harness.js +19 -36
  140. package/dist/core/__tests__/human-node-not-supervised.test.d.ts +1 -0
  141. package/dist/core/__tests__/human-node-not-supervised.test.js +83 -0
  142. package/dist/core/__tests__/lifecycle.test.js +1 -19
  143. package/dist/core/__tests__/live-mutation-verbs.test.js +5 -5
  144. package/dist/core/__tests__/memory-resolver.test.js +1 -1
  145. package/dist/core/__tests__/on-read-dedup-resume.test.js +2 -2
  146. package/dist/core/__tests__/refresh-stall-recycle.test.d.ts +1 -0
  147. package/dist/core/__tests__/refresh-stall-recycle.test.js +88 -0
  148. package/dist/core/__tests__/relaunch-root.test.d.ts +1 -0
  149. package/dist/core/__tests__/relaunch-root.test.js +114 -0
  150. package/dist/core/__tests__/reset.test.js +19 -79
  151. package/dist/core/__tests__/steer-note.test.js +9 -7
  152. package/dist/core/__tests__/tmux-surface.test.js +13 -7
  153. package/dist/core/bootstrap.d.ts +0 -3
  154. package/dist/core/bootstrap.js +1 -143
  155. package/dist/core/canvas/browse/__tests__/model.test.js +86 -1
  156. package/dist/core/canvas/browse/__tests__/render.test.js +2 -1
  157. package/dist/core/canvas/browse/app.js +131 -8
  158. package/dist/core/canvas/browse/model.d.ts +23 -5
  159. package/dist/core/canvas/browse/model.js +68 -13
  160. package/dist/core/canvas/browse/render.d.ts +7 -1
  161. package/dist/core/canvas/browse/render.js +178 -47
  162. package/dist/core/canvas/canvas.js +1 -1
  163. package/dist/core/canvas/focuses.d.ts +4 -4
  164. package/dist/core/canvas/focuses.js +7 -7
  165. package/dist/core/canvas/nav-model.js +8 -7
  166. package/dist/core/canvas/render.d.ts +68 -9
  167. package/dist/core/canvas/render.js +195 -61
  168. package/dist/core/canvas/types.d.ts +16 -23
  169. package/dist/core/config.js +1 -4
  170. package/dist/core/memory-resolver.js +43 -5
  171. package/dist/core/runtime/bearings.d.ts +1 -1
  172. package/dist/core/runtime/bearings.js +8 -8
  173. package/dist/core/runtime/branded-host.d.ts +16 -0
  174. package/dist/core/runtime/branded-host.js +127 -0
  175. package/dist/core/runtime/broker-protocol.d.ts +212 -6
  176. package/dist/core/runtime/broker.d.ts +4 -1
  177. package/dist/core/runtime/broker.js +494 -58
  178. package/dist/core/runtime/close.d.ts +12 -2
  179. package/dist/core/runtime/close.js +37 -16
  180. package/dist/core/runtime/front-door.js +6 -14
  181. package/dist/core/runtime/host.d.ts +14 -34
  182. package/dist/core/runtime/host.js +14 -50
  183. package/dist/core/runtime/launch.d.ts +12 -9
  184. package/dist/core/runtime/launch.js +27 -15
  185. package/dist/core/runtime/lifecycle.d.ts +1 -1
  186. package/dist/core/runtime/lifecycle.js +15 -19
  187. package/dist/core/runtime/nodes.d.ts +0 -55
  188. package/dist/core/runtime/nodes.js +6 -74
  189. package/dist/core/runtime/placement.d.ts +91 -335
  190. package/dist/core/runtime/placement.js +262 -840
  191. package/dist/core/runtime/promote.d.ts +2 -0
  192. package/dist/core/runtime/promote.js +24 -6
  193. package/dist/core/runtime/recap.d.ts +8 -0
  194. package/dist/core/runtime/recap.js +107 -0
  195. package/dist/core/runtime/recycle.js +25 -61
  196. package/dist/core/runtime/reset.d.ts +43 -37
  197. package/dist/core/runtime/reset.js +131 -218
  198. package/dist/core/runtime/revive.d.ts +9 -29
  199. package/dist/core/runtime/revive.js +40 -139
  200. package/dist/core/runtime/spawn.d.ts +15 -16
  201. package/dist/core/runtime/spawn.js +165 -194
  202. package/dist/core/runtime/surface-bg.d.ts +11 -0
  203. package/dist/core/runtime/surface-bg.js +68 -0
  204. package/dist/core/runtime/tmux.d.ts +13 -50
  205. package/dist/core/runtime/tmux.js +33 -87
  206. package/dist/core/scope.d.ts +1 -2
  207. package/dist/core/scope.js +1 -5
  208. package/dist/core/skill-sync/__tests__/dry-run-wrote-count.test.d.ts +1 -0
  209. package/dist/core/skill-sync/__tests__/dry-run-wrote-count.test.js +57 -0
  210. package/dist/core/skill-sync/builtins.d.ts +42 -0
  211. package/dist/core/skill-sync/builtins.js +112 -0
  212. package/dist/core/skill-sync/claude-plugins.d.ts +23 -0
  213. package/dist/core/skill-sync/claude-plugins.js +71 -0
  214. package/dist/core/skill-sync/engine.d.ts +42 -0
  215. package/dist/core/skill-sync/engine.js +633 -0
  216. package/dist/core/skill-sync/export.d.ts +23 -0
  217. package/dist/core/skill-sync/export.js +86 -0
  218. package/dist/core/skill-sync/manifest.d.ts +64 -0
  219. package/dist/core/skill-sync/manifest.js +181 -0
  220. package/dist/core/skill-sync/profile.d.ts +76 -0
  221. package/dist/core/skill-sync/profile.js +173 -0
  222. package/dist/core/skill-sync/snapshot.d.ts +57 -0
  223. package/dist/core/skill-sync/snapshot.js +120 -0
  224. package/dist/core/substrate/index.d.ts +1 -1
  225. package/dist/core/substrate/index.js +1 -1
  226. package/dist/core/substrate/injected-store.js +3 -3
  227. package/dist/core/substrate/on-read.js +68 -6
  228. package/dist/core/substrate/render.d.ts +8 -11
  229. package/dist/core/substrate/render.js +29 -43
  230. package/dist/core/substrate/schema.d.ts +10 -3
  231. package/dist/core/substrate/schema.js +6 -3
  232. package/dist/core/tui/host.d.ts +12 -4
  233. package/dist/core/tui/host.js +280 -149
  234. package/dist/core/view/bridge.d.ts +10 -0
  235. package/dist/core/view/bridge.js +31 -0
  236. package/dist/core/view/chrome.d.ts +9 -0
  237. package/dist/core/view/chrome.js +22 -0
  238. package/dist/core/view/contract.d.ts +171 -0
  239. package/dist/core/view/contract.js +23 -0
  240. package/dist/core/view/loader.d.ts +31 -0
  241. package/dist/core/view/loader.js +188 -0
  242. package/dist/core/view/transport-local.d.ts +7 -0
  243. package/dist/core/view/transport-local.js +70 -0
  244. package/dist/core/view/transport.d.ts +4 -0
  245. package/dist/core/view/transport.js +15 -0
  246. package/dist/daemon/crtrd.d.ts +38 -27
  247. package/dist/daemon/crtrd.js +303 -376
  248. package/dist/daemon/manage.js +6 -1
  249. package/dist/index.js +1 -1
  250. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +28 -20
  251. package/dist/pi-extensions/canvas-context-intro.js +4 -4
  252. package/dist/pi-extensions/canvas-doc-substrate.js +16 -16
  253. package/dist/pi-extensions/canvas-nav.js +5 -0
  254. package/dist/pi-extensions/canvas-recap.d.ts +37 -0
  255. package/dist/pi-extensions/canvas-recap.js +236 -0
  256. package/dist/pi-extensions/canvas-resume.js +2 -1
  257. package/dist/pi-extensions/canvas-stophook.d.ts +14 -12
  258. package/dist/pi-extensions/canvas-stophook.js +87 -101
  259. package/dist/pi-extensions/canvas-view.js +2 -1
  260. package/dist/pi-extensions/widget-order-bus.d.ts +6 -0
  261. package/dist/pi-extensions/widget-order-bus.js +34 -0
  262. package/dist/prompts/view.d.ts +2 -2
  263. package/dist/prompts/view.js +145 -73
  264. package/dist/types.d.ts +1 -6
  265. package/dist/types.js +1 -3
  266. package/dist/web/ViewChrome.d.ts +7 -0
  267. package/dist/web/ViewChrome.js +28 -0
  268. package/dist/web/ViewPane.d.ts +39 -0
  269. package/dist/web/ViewPane.js +48 -0
  270. package/dist/web/index.d.ts +6 -0
  271. package/dist/web/index.js +16 -0
  272. package/dist/web/runtime.d.ts +39 -0
  273. package/dist/web/runtime.js +133 -0
  274. package/dist/web/states.d.ts +24 -0
  275. package/dist/web/states.js +24 -0
  276. package/dist/web/transport-http.d.ts +5 -0
  277. package/dist/web/transport-http.js +28 -0
  278. package/dist/web-client/assets/index-BUvQb4hR.css +2 -0
  279. package/dist/web-client/assets/index-ClLQXYAE.js +10 -0
  280. package/dist/web-client/index.html +13 -0
  281. package/package.json +20 -6
  282. package/dist/builtin-views/canvas/client.mjs +0 -303
  283. package/dist/builtin-views/canvas/view.mjs +0 -576
  284. package/dist/builtin-views/git-pr/client.mjs +0 -440
  285. package/dist/builtin-views/git-pr/view.mjs +0 -675
  286. package/dist/builtin-views/inbox/sources/gmail.mjs +0 -965
  287. package/dist/builtin-views/inbox/sources/linkedin.mjs +0 -427
  288. package/dist/builtin-views/inbox/view.mjs +0 -889
  289. package/dist/builtin-views/linkedin/client.mjs +0 -610
  290. package/dist/builtin-views/linkedin/view.mjs +0 -1171
  291. package/dist/commands/pkg/bridge.d.ts +0 -1
  292. package/dist/commands/pkg/bridge.js +0 -137
  293. package/dist/commands/skill/author.d.ts +0 -3
  294. package/dist/commands/skill/author.js +0 -140
  295. package/dist/commands/skill/shared.d.ts +0 -3
  296. package/dist/commands/skill/shared.js +0 -19
  297. package/dist/commands/skill.d.ts +0 -2
  298. package/dist/commands/skill.js +0 -21
  299. package/dist/commands/tmux-spread.d.ts +0 -2
  300. package/dist/commands/tmux-spread.js +0 -144
  301. package/dist/core/__tests__/full/daemon-liveness-pane.full.test.js +0 -292
  302. package/dist/core/__tests__/full/placement-focus.test.js +0 -309
  303. package/dist/core/__tests__/full/placement-reconcile.test.js +0 -212
  304. package/dist/core/__tests__/full/placement-revive.test.js +0 -238
  305. package/dist/core/__tests__/full/placement-teardown.test.js +0 -297
  306. package/dist/core/__tests__/home-session.test.js +0 -186
  307. package/dist/core/__tests__/relaunch.test.js +0 -335
  308. package/dist/core/bridge-map.d.ts +0 -19
  309. package/dist/core/bridge-map.js +0 -73
  310. package/dist/core/tui/contract.d.ts +0 -83
  311. package/dist/core/tui/contract.js +0 -8
  312. package/dist/core/tui/loader.d.ts +0 -16
  313. package/dist/core/tui/loader.js +0 -94
  314. package/dist/prompts/skill.d.ts +0 -2
  315. package/dist/prompts/skill.js +0 -650
  316. /package/dist/{core/__tests__/full/daemon-liveness-pane.full.test.d.ts → clients/attach/__tests__/action-parity.test.d.ts} +0 -0
  317. /package/dist/{core/__tests__/full/placement-focus.test.d.ts → clients/attach/__tests__/clipboard-image.test.d.ts} +0 -0
  318. /package/dist/{core/__tests__/full/placement-reconcile.test.d.ts → clients/attach/__tests__/editor-newline.test.d.ts} +0 -0
  319. /package/dist/{core/__tests__/full/placement-revive.test.d.ts → clients/attach/__tests__/git-info.test.d.ts} +0 -0
  320. /package/dist/{core/__tests__/full/placement-teardown.test.d.ts → clients/attach/__tests__/onrequest-wired.test.d.ts} +0 -0
  321. /package/dist/{core/__tests__/home-session.test.d.ts → clients/attach/__tests__/slash-quit-copy.test.d.ts} +0 -0
  322. /package/dist/{core/__tests__/relaunch.test.d.ts → clients/attach/__tests__/titled-editor.test.d.ts} +0 -0
@@ -0,0 +1,450 @@
1
+ // server.ts — the unified `crtr web serve` host: the crouter web UI server.
2
+ //
3
+ // ONE long-running server process on 127.0.0.1 that serves THREE concerns on one
4
+ // origin (the §2 unified server):
5
+ // • GET /* → the shell SPA (static dist/web-client/, or Vite
6
+ // middleware in --dev) + SPA fallback.
7
+ // • POST /__crtr/source → the source + command bridge: decode a SourceRequest
8
+ // and run it through the LOCAL transport (exec/file/
9
+ // http in this cwd). This serves views' READS and the
10
+ // command WRITES (`crtr …` subprocesses). Lifted here
11
+ // from the deleted `view serve`.
12
+ // • GET /__crtr/events → the SSE change-invalidation lane (events.ts).
13
+ // • WS /node/<id> → open that node's ALREADY-running broker `view.sock`
14
+ // and relay frames VERBATIM both directions. The relay
15
+ // adds NOTHING — the browser is the SAME protocol peer
16
+ // as `crtr attach`.
17
+ //
18
+ // §0 ONE-WRITER INVARIANT: the WS relay is ONLY a socket relay. It NEVER calls
19
+ // reviveNode, NEVER spawns `pi --session`, NEVER touches SessionManager, and
20
+ // NEVER opens/writes a `.jsonl`. It opens a `node:net` connection to an
21
+ // ALREADY-running broker's `view.sock`; if the broker is not running the WS is
22
+ // closed with a clear reason. The relay NEVER launches an engine. ALL writes go
23
+ // through the bridge running `crtr` SUBPROCESSES — this process is never the
24
+ // sanctioned writer; the CLI it shells out to is.
25
+ //
26
+ // One `view.sock` connection per browser-WS (N browsers of one node = N broker
27
+ // clients) — the broker fans out natively, so there is no bridge-side fan-out.
28
+ import { createServer as createHttpServer } from 'node:http';
29
+ import { createConnection } from 'node:net';
30
+ import { createReadStream, existsSync, statSync } from 'node:fs';
31
+ import { fileURLToPath } from 'node:url';
32
+ import { dirname, extname, join, normalize, resolve } from 'node:path';
33
+ import { WebSocketServer } from 'ws';
34
+ import { nodeDir } from '../../core/canvas/paths.js';
35
+ import { getNode } from '../../core/canvas/index.js';
36
+ import { BROKER_READ_CAPS, CLIENT_READ_CAPS, FrameDecoder, FrameOverflowError, } from '../../core/runtime/broker-protocol.js';
37
+ import { createLocalTransport } from '../../core/view/transport-local.js';
38
+ import { runSourceRequest } from '../../core/view/bridge.js';
39
+ import { startEventHub } from './events.js';
40
+ import { createDevServer } from './dev-server.js';
41
+ const HERE = dirname(fileURLToPath(import.meta.url));
42
+ /** Candidate dirs to serve the shell SPA bundle from, in priority order: an
43
+ * explicit override, the compiled-module-relative `dist/web-client/` (what
44
+ * `vite build` emits and what we serve in production), and the placeholder
45
+ * copied from source. The first that exists wins. (--dev bypasses this entirely
46
+ * — Vite middleware owns asset serving in that mode.) */
47
+ function resolveClientDir() {
48
+ const candidates = [
49
+ process.env['CRTR_WEB_CLIENT_DIR'],
50
+ join(HERE, '../../web-client'),
51
+ resolve(HERE, '../../../dist/web-client'),
52
+ ].filter((p) => p !== undefined && p !== '');
53
+ for (const dir of candidates) {
54
+ if (existsSync(dir))
55
+ return dir;
56
+ }
57
+ // Fall back to the module-relative path even if absent; missing-file handling
58
+ // below returns a clear 503 so a not-yet-built shell is diagnosable.
59
+ return join(HERE, '../../web-client');
60
+ }
61
+ const CONTENT_TYPES = {
62
+ '.html': 'text/html; charset=utf-8',
63
+ '.js': 'text/javascript; charset=utf-8',
64
+ '.mjs': 'text/javascript; charset=utf-8',
65
+ '.css': 'text/css; charset=utf-8',
66
+ '.json': 'application/json; charset=utf-8',
67
+ '.svg': 'image/svg+xml',
68
+ '.png': 'image/png',
69
+ '.jpg': 'image/jpeg',
70
+ '.jpeg': 'image/jpeg',
71
+ '.gif': 'image/gif',
72
+ '.webp': 'image/webp',
73
+ '.ico': 'image/x-icon',
74
+ '.woff': 'font/woff',
75
+ '.woff2': 'font/woff2',
76
+ '.map': 'application/json; charset=utf-8',
77
+ };
78
+ function contentTypeFor(path) {
79
+ return CONTENT_TYPES[extname(path).toLowerCase()] ?? 'application/octet-stream';
80
+ }
81
+ /** Match a WS/GET path of the shape `/node/<id>` (with any number of leading
82
+ * segments, future-proofing a mount prefix). Returns the decoded node id, or
83
+ * null if the path doesn't match or the id is undecodable (malformed `%`). The
84
+ * caller must still VALIDATE the id (no separators / `..`) before building a
85
+ * filesystem path — see {@link isSafeNodeId}. */
86
+ function nodeIdFromPath(pathname) {
87
+ const m = /\/node\/([^/?#]+)\/?$/.exec(pathname);
88
+ if (m === null)
89
+ return null;
90
+ try {
91
+ return decodeURIComponent(m[1]);
92
+ }
93
+ catch {
94
+ return null; // malformed percent-encoding — treat as no match
95
+ }
96
+ }
97
+ /** A node id is used to build `nodeDir(id)/view.sock`, so it MUST NOT contain a
98
+ * path separator or `..` (e.g. a decoded `..%2f..` would escape `nodes/`). */
99
+ function isSafeNodeId(id) {
100
+ return id !== '' && !/[/\\]/.test(id) && !id.includes('..');
101
+ }
102
+ /** A WS close reason is capped at 123 UTF-8 bytes by the protocol. */
103
+ function clampReason(reason) {
104
+ const buf = Buffer.from(reason, 'utf8');
105
+ return buf.byteLength <= 123 ? reason : buf.subarray(0, 123).toString('utf8');
106
+ }
107
+ /** Read a request body fully into a string. Buffer.concat before decoding so a
108
+ * multibyte char split across chunks (UTF-8 in a write command's stdin) is
109
+ * never corrupted. */
110
+ function readBody(req) {
111
+ return new Promise((res, rej) => {
112
+ const chunks = [];
113
+ req.on('data', (c) => {
114
+ chunks.push(c);
115
+ });
116
+ req.on('end', () => res(Buffer.concat(chunks).toString('utf8')));
117
+ req.on('error', rej);
118
+ });
119
+ }
120
+ /** Serve a static file from the client dir, with SPA fallback to index.html for
121
+ * any unmatched (non-asset) GET — so `/` and `/node/<id>` both boot the app
122
+ * shell. Returns true if it wrote a response. */
123
+ function serveStatic(clientDir, req, res) {
124
+ const url = new URL(req.url ?? '/', 'http://127.0.0.1');
125
+ let pathname;
126
+ try {
127
+ pathname = decodeURIComponent(url.pathname);
128
+ }
129
+ catch {
130
+ // Malformed percent-encoding (e.g. `GET /%`) — never let it throw out of
131
+ // the request listener and crash the whole multiplexing server (C1).
132
+ res.writeHead(400, { 'content-type': 'text/plain; charset=utf-8' });
133
+ res.end('bad request\n');
134
+ return;
135
+ }
136
+ // Resolve within clientDir; reject traversal.
137
+ const rel = normalize(pathname).replace(/^(\.\.(\/|\\|$))+/, '');
138
+ let filePath = join(clientDir, rel);
139
+ if (!filePath.startsWith(clientDir)) {
140
+ res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' });
141
+ res.end('forbidden\n');
142
+ return;
143
+ }
144
+ // Directory or root → index.html. A path with no file extension that does not
145
+ // exist → SPA fallback to index.html (so deep links like /node/<id> boot).
146
+ const isFile = existsSync(filePath) && statSync(filePath).isFile();
147
+ if (!isFile) {
148
+ filePath = join(clientDir, 'index.html');
149
+ }
150
+ if (!existsSync(filePath)) {
151
+ res.writeHead(503, { 'content-type': 'text/plain; charset=utf-8' });
152
+ res.end('crtr web shell bundle not found — run `npm run build` to emit it into ' +
153
+ `${clientDir} (or pass --dev for Vite middleware)\n`);
154
+ return;
155
+ }
156
+ res.writeHead(200, { 'content-type': contentTypeFor(filePath), 'cache-control': 'no-cache' });
157
+ if (req.method === 'HEAD') {
158
+ res.end();
159
+ return;
160
+ }
161
+ const stream = createReadStream(filePath);
162
+ stream.on('error', () => {
163
+ if (!res.headersSent)
164
+ res.writeHead(500);
165
+ res.end();
166
+ });
167
+ stream.pipe(res);
168
+ }
169
+ /** Relay one browser WS ⇄ one node broker `view.sock`. Opens the socket on
170
+ * connect; forwards each complete frame socket→WS (bounded decode, one WS
171
+ * message per frame) and re-frames each WS message WS→socket (trailing
172
+ * newline). The relay is semantically verbatim — it never INTERPRETS a frame
173
+ * (it only splits/joins the newline framing). Closes the WS on socket
174
+ * gone/no-broker, destroys the socket on WS close; idempotent teardown, no
175
+ * leaks. Validates the node id first (path-safety + existence). */
176
+ function bridgeConnection(ws, nodeId) {
177
+ if (!isSafeNodeId(nodeId)) {
178
+ try {
179
+ ws.close(1008, clampReason(`invalid node id: ${nodeId}`));
180
+ }
181
+ catch {
182
+ /* ignore */
183
+ }
184
+ return;
185
+ }
186
+ if (getNode(nodeId) === null) {
187
+ try {
188
+ ws.close(1011, clampReason(`no node ${nodeId}`));
189
+ }
190
+ catch {
191
+ /* ignore */
192
+ }
193
+ return;
194
+ }
195
+ const sockPath = join(nodeDir(nodeId), 'view.sock');
196
+ const socket = createConnection(sockPath);
197
+ const decoder = new FrameDecoder(CLIENT_READ_CAPS);
198
+ let closed = false;
199
+ const teardown = (wsCode, reason) => {
200
+ if (closed)
201
+ return;
202
+ closed = true;
203
+ if (!socket.destroyed) {
204
+ try {
205
+ socket.destroy();
206
+ }
207
+ catch {
208
+ /* ignore */
209
+ }
210
+ }
211
+ try {
212
+ ws.close(wsCode, clampReason(reason));
213
+ }
214
+ catch {
215
+ /* ignore */
216
+ }
217
+ };
218
+ // socket → WS: bounded decode, one WS text message per complete frame.
219
+ socket.on('data', (chunk) => {
220
+ let frames;
221
+ try {
222
+ frames = decoder.push(chunk);
223
+ }
224
+ catch (err) {
225
+ const msg = err instanceof FrameOverflowError
226
+ ? `broker sent an oversized frame (${err.message})`
227
+ : `failed to decode a broker frame for ${nodeId}`;
228
+ teardown(1009, msg);
229
+ return;
230
+ }
231
+ for (const frame of frames) {
232
+ if (ws.readyState === ws.OPEN)
233
+ ws.send(JSON.stringify(frame));
234
+ }
235
+ });
236
+ socket.on('error', (err) => {
237
+ const reason = err.code === 'ECONNREFUSED' || err.code === 'ENOENT'
238
+ ? `no running broker for ${nodeId}`
239
+ : `view socket error for ${nodeId}: ${err.message}`;
240
+ teardown(1011, reason);
241
+ });
242
+ socket.on('close', () => teardown(1000, `broker for ${nodeId} closed`));
243
+ // WS → socket: re-frame each message as one newline-terminated JSON line.
244
+ // VERBATIM (no parse) — the broker validates and bounds its own reads.
245
+ ws.on('message', (data) => {
246
+ if (closed || socket.destroyed)
247
+ return;
248
+ const buf = Array.isArray(data)
249
+ ? Buffer.concat(data)
250
+ : Buffer.isBuffer(data)
251
+ ? data
252
+ : Buffer.from(new Uint8Array(data));
253
+ const text = buf.toString('utf8');
254
+ const line = text.endsWith('\n') ? text : text + '\n';
255
+ try {
256
+ socket.write(line);
257
+ }
258
+ catch {
259
+ /* dead socket — its 'close' drives teardown */
260
+ }
261
+ });
262
+ ws.on('close', () => teardown(1000, 'browser closed'));
263
+ ws.on('error', () => teardown(1011, 'browser ws error'));
264
+ }
265
+ /** Build + start the unified web server. Binds 127.0.0.1 only (remote access is
266
+ * out of scope — §9). Resolves once the server is listening. Async because
267
+ * --dev awaits a Vite middleware server before listening. */
268
+ export async function startWebServer(opts) {
269
+ const host = opts.host ?? '127.0.0.1';
270
+ const dev = opts.dev ?? false;
271
+ const clientDir = resolveClientDir();
272
+ // The bridge runs view sources + command verbs locally, in the cwd crtr was
273
+ // invoked from (a git-pr view inspects THIS repo; a `crtr node new` lands here).
274
+ const transport = createLocalTransport({ cwd: process.cwd() });
275
+ // The SSE change lane — one hub, fanned to every connected EventSource.
276
+ const eventHub = startEventHub();
277
+ // Same-origin allowlist (M1): a browser bypasses same-origin policy on both
278
+ // WebSockets AND cross-origin POSTs, so any web page the user visits could
279
+ // otherwise open ws://127.0.0.1:PORT/node/<id> and DRIVE their agents, OR POST
280
+ // /__crtr/source to run ARBITRARY exec. The same gate guards all three write/
281
+ // drive surfaces (WS upgrade, bridge POST, SSE GET). Populated once we know the
282
+ // bound port. A request with NO Origin (a CLI/curl/test client, not a browser)
283
+ // is allowed — the threat model is foreign browser pages, which always send it.
284
+ let allowedOrigins = new Set();
285
+ const originAllowed = (origin) => {
286
+ if (origin === undefined || origin === '')
287
+ return true;
288
+ return allowedOrigins.has(origin);
289
+ };
290
+ // Assigned before listen() when --dev; the request handler closes over it.
291
+ let vite;
292
+ const httpServer = createHttpServer((req, res) => {
293
+ try {
294
+ const url = new URL(req.url ?? '/', 'http://127.0.0.1');
295
+ const pathname = url.pathname;
296
+ // The source + command bridge: POST /__crtr/source → run the SourceRequest
297
+ // through the local transport. Origin-gated (same M1 gate as the WS relay):
298
+ // arbitrary exec from a foreign browser page is exactly what M1 prevents.
299
+ if (pathname === '/__crtr/source') {
300
+ if (req.method !== 'POST') {
301
+ res.writeHead(405, { 'content-type': 'text/plain; charset=utf-8', allow: 'POST' });
302
+ res.end('method not allowed\n');
303
+ return;
304
+ }
305
+ if (!originAllowed(req.headers.origin)) {
306
+ res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' });
307
+ res.end('forbidden\n');
308
+ return;
309
+ }
310
+ void readBody(req)
311
+ .then((body) => runSourceRequest(transport, body))
312
+ .then(({ status, body }) => {
313
+ res.writeHead(status, { 'content-type': 'application/json' });
314
+ res.end(body);
315
+ })
316
+ .catch((e) => {
317
+ res.writeHead(500, { 'content-type': 'application/json' });
318
+ res.end(JSON.stringify({ ok: false, stdout: '', stderr: e instanceof Error ? e.message : String(e) }));
319
+ });
320
+ return;
321
+ }
322
+ // The SSE change-invalidation lane: GET /__crtr/events. Origin-gated too —
323
+ // a foreign page should not even learn the user's graph is mutating.
324
+ if (pathname === '/__crtr/events') {
325
+ if (req.method !== 'GET') {
326
+ res.writeHead(405, { 'content-type': 'text/plain; charset=utf-8', allow: 'GET' });
327
+ res.end('method not allowed\n');
328
+ return;
329
+ }
330
+ if (!originAllowed(req.headers.origin)) {
331
+ res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' });
332
+ res.end('forbidden\n');
333
+ return;
334
+ }
335
+ eventHub.addClient(res);
336
+ return;
337
+ }
338
+ // --dev: Vite middleware owns all remaining asset/HTML serving (incl. its
339
+ // own SPA fallback). It is mounted AFTER the bridge + SSE checks above, so
340
+ // those never fall through to Vite.
341
+ if (vite !== undefined) {
342
+ vite.middlewares(req, res);
343
+ return;
344
+ }
345
+ // Shipped: static shell bundle + SPA fallback.
346
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
347
+ res.writeHead(405, { 'content-type': 'text/plain; charset=utf-8', allow: 'GET, HEAD' });
348
+ res.end('method not allowed\n');
349
+ return;
350
+ }
351
+ serveStatic(clientDir, req, res);
352
+ }
353
+ catch {
354
+ // Belt-and-suspenders: nothing in the handler should throw now (C1), but a
355
+ // request listener that throws crashes the whole daemon — never allow it.
356
+ if (!res.headersSent)
357
+ res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' });
358
+ res.end('internal error\n');
359
+ }
360
+ });
361
+ // noServer: we route the upgrade ourselves so only `/node/<id>` upgrades.
362
+ // maxPayload bounds the INBOUND browser→broker path, so it tracks the broker's
363
+ // own read cap (BROKER_READ_CAPS), not the generous client cap — otherwise a
364
+ // browser could buffer 256MiB/message before the broker's 24MiB decoder rejects
365
+ // it (memory amplification, M3).
366
+ const wss = new WebSocketServer({ noServer: true, maxPayload: BROKER_READ_CAPS.maxLineBytes });
367
+ httpServer.on('upgrade', (req, socket, head) => {
368
+ // A raw upgrade socket with no 'error' listener throws on a write to a reset
369
+ // peer — attach one before we ever write/destroy it (n8).
370
+ socket.on('error', () => {
371
+ /* peer reset mid-handshake — destroy below or already destroyed */
372
+ });
373
+ try {
374
+ const url = new URL(req.url ?? '/', 'http://127.0.0.1');
375
+ const nodeId = nodeIdFromPath(url.pathname);
376
+ if (nodeId === null) {
377
+ // --dev: Vite's HMR WebSocket shares this HTTP server. Vite registers its
378
+ // OWN 'upgrade' listener (via server.hmr.server); leave every non-`/node/`
379
+ // upgrade for it instead of destroying the socket.
380
+ if (vite !== undefined)
381
+ return;
382
+ socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
383
+ socket.destroy();
384
+ return;
385
+ }
386
+ if (!originAllowed(req.headers.origin)) {
387
+ socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
388
+ socket.destroy();
389
+ return;
390
+ }
391
+ wss.handleUpgrade(req, socket, head, (ws) => bridgeConnection(ws, nodeId));
392
+ }
393
+ catch {
394
+ // Never let an upgrade-handler throw escape and crash the daemon (C1).
395
+ try {
396
+ socket.destroy();
397
+ }
398
+ catch {
399
+ /* ignore */
400
+ }
401
+ }
402
+ });
403
+ // --dev: create the Vite middleware server BEFORE listen so the request
404
+ // handler's `vite` is set and Vite's HMR upgrade listener is attached (ours
405
+ // was added first, so a vite-hmr upgrade hits ours → nodeId null → returns →
406
+ // Vite's listener handles it).
407
+ if (dev) {
408
+ vite = await createDevServer(httpServer);
409
+ }
410
+ return new Promise((resolveListening, reject) => {
411
+ httpServer.once('error', reject);
412
+ httpServer.listen(opts.port, host, () => {
413
+ httpServer.off('error', reject);
414
+ // A server error AFTER listen (e.g. an accept failure) must not be an
415
+ // unhandled 'error' that crashes the process (n9) — log + carry on.
416
+ httpServer.on('error', (err) => {
417
+ process.stderr.write(`crtr web: server error: ${err.message}\n`);
418
+ });
419
+ const addr = httpServer.address();
420
+ const port = typeof addr === 'object' && addr !== null ? addr.port : opts.port;
421
+ const url = `http://${host}:${port}`;
422
+ allowedOrigins = new Set([
423
+ `http://${host}:${port}`,
424
+ `http://127.0.0.1:${port}`,
425
+ `http://localhost:${port}`,
426
+ ]);
427
+ const close = () => new Promise((done) => {
428
+ eventHub.close();
429
+ void vite?.close().catch(() => {
430
+ /* best-effort */
431
+ });
432
+ wss.clients.forEach((c) => {
433
+ try {
434
+ c.terminate();
435
+ }
436
+ catch {
437
+ /* ignore */
438
+ }
439
+ });
440
+ wss.close(() => {
441
+ httpServer.close(() => done());
442
+ // Idle keep-alive HTTP connections would otherwise keep close()'s
443
+ // callback from ever firing — drop them so shutdown completes (m4).
444
+ httpServer.closeAllConnections();
445
+ });
446
+ });
447
+ resolveListening({ url, port, clientDir, close });
448
+ });
449
+ });
450
+ }
@@ -0,0 +1,2 @@
1
+ import type { BranchDef } from '../../core/command.js';
2
+ export declare function registerWeb(): BranchDef;
@@ -0,0 +1,120 @@
1
+ // web-cmd.ts — the `crtr web` command leaf + registration.
2
+ //
3
+ // Starts the ONE long-running unified web server (server.ts): the crouter web UI
4
+ // host. It serves the shell SPA, the source + command bridge (POST
5
+ // /__crtr/source), the SSE change lane (GET /__crtr/events), and a VERBATIM
6
+ // browser ⇄ broker relay on `ws://127.0.0.1:PORT/node/<id>` (the browser becomes
7
+ // the SAME protocol peer as `crtr attach`). The node is selected by the URL, so
8
+ // there is NO positional node here.
9
+ //
10
+ // Command shape (plan §3.6): the CLI path-walker forbids a flat top-level leaf
11
+ // (`defineRoot.subtrees` is `BranchDef[]`), so `web` is a BRANCH wrapping a
12
+ // single leaf: `crtr web serve`.
13
+ import { spawn } from 'node:child_process';
14
+ import { defineBranch, defineLeaf } from '../../core/command.js';
15
+ import { startWebServer } from './server.js';
16
+ const DEFAULT_PORT = 7878;
17
+ /** Best-effort open of the default browser to `url` (the `--open` flag). Never
18
+ * throws — a failed open just leaves the user to click the printed URL. */
19
+ function openBrowser(url) {
20
+ const platform = process.platform;
21
+ const [cmd, args] = platform === 'darwin'
22
+ ? ['open', [url]]
23
+ : platform === 'win32'
24
+ ? ['cmd', ['/c', 'start', '', url]]
25
+ : ['xdg-open', [url]];
26
+ try {
27
+ const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
28
+ child.on('error', () => {
29
+ /* no browser opener available — the printed URL is the fallback */
30
+ });
31
+ child.unref();
32
+ }
33
+ catch {
34
+ /* ignore */
35
+ }
36
+ }
37
+ const webServeLeaf = defineLeaf({
38
+ name: 'serve',
39
+ description: 'start the crouter web UI server — the shell, the source/command bridge, and a browser ⇄ broker relay',
40
+ whenToUse: 'you want the crouter web UI: a browser shell to watch the canvas and drive headless nodes, instead of a terminal pane. It starts ONE long-running local server (127.0.0.1) that serves the shell SPA, the source + command bridge (POST /__crtr/source — views\' reads AND `crtr` command writes), an SSE change-invalidation lane (GET /__crtr/events), and a VERBATIM browser ⇄ broker relay on /node/<id> (the browser becomes the same protocol peer as `crtr attach`: one controller drives, extra viewers follow read-only). The relay NEVER starts an engine — each node must already have a running headless broker (focus or revive it first); all writes flow through `crtr` subprocesses the bridge runs. Pass --dev for Vite middleware (HMR) while iterating on the shell. The server runs until ctrl+c; the node is picked in the browser via the URL, not on the command line.',
41
+ help: {
42
+ name: 'web serve',
43
+ summary: 'start the long-running crouter web UI server on 127.0.0.1: serves the shell SPA, the POST /__crtr/source bridge, the GET /__crtr/events SSE lane, and a VERBATIM browser ⇄ node-broker relay (browser picks the node via /node/<id>)',
44
+ params: [
45
+ {
46
+ kind: 'flag',
47
+ name: 'port',
48
+ type: 'int',
49
+ required: false,
50
+ default: DEFAULT_PORT,
51
+ constraint: `TCP port to bind on 127.0.0.1. Default ${DEFAULT_PORT}.`,
52
+ },
53
+ {
54
+ kind: 'flag',
55
+ name: 'open',
56
+ type: 'bool',
57
+ required: false,
58
+ default: false,
59
+ constraint: 'Auto-open the default browser to the served URL after the server starts.',
60
+ },
61
+ {
62
+ kind: 'flag',
63
+ name: 'dev',
64
+ type: 'bool',
65
+ required: false,
66
+ default: false,
67
+ constraint: 'Serve shell assets via a Vite dev server (middleware mode, HMR) instead of the prebuilt dist/web-client/ bundle. For contributors iterating on the shell or a builtin view. The bridge, SSE lane, and WS relay are identical in both modes.',
68
+ },
69
+ ],
70
+ output: [],
71
+ outputKind: 'object',
72
+ effects: [
73
+ 'Binds a long-running HTTP+WebSocket server on 127.0.0.1:<port> and keeps serving until interrupted (ctrl+c / SIGTERM). Safe to run non-interactively (daemon-like): it prints the URL and serves.',
74
+ 'Serves the shell SPA (static dist/web-client/, or Vite middleware under --dev), runs the POST /__crtr/source bridge (exec/file/http in this cwd), streams the GET /__crtr/events SSE change lane, and on a browser ws://127.0.0.1:<port>/node/<id> connection opens that node\'s running broker view.sock and relays frames VERBATIM both directions.',
75
+ 'The relay NEVER spawns pi and NEVER writes any session — it only opens a socket to an already-running broker; if the broker is not running, the browser connection is closed with a clear reason. All graph mutations go through `crtr` subprocesses the bridge runs.',
76
+ ],
77
+ },
78
+ run: async (input) => {
79
+ const port = input['port'] ?? DEFAULT_PORT;
80
+ const open = input['open'] ?? false;
81
+ const dev = input['dev'] ?? false;
82
+ const server = await startWebServer({ port, dev });
83
+ const nodeUrlShape = `${server.url}/node/<id>`;
84
+ process.stdout.write(`crtr web serving on ${server.url}${dev ? ' (--dev: Vite middleware)' : ''}\n` +
85
+ ` open a node: ${nodeUrlShape}\n` +
86
+ ` bridge: POST ${server.url}/__crtr/source\n` +
87
+ ` events (SSE): GET ${server.url}/__crtr/events\n` +
88
+ ` shell assets: ${dev ? 'Vite middleware (HMR)' : server.clientDir}\n` +
89
+ ` (ctrl+c to stop)\n`);
90
+ if (open)
91
+ openBrowser(server.url);
92
+ // Long-running: resolve only on a shutdown signal, tearing down cleanly.
93
+ await new Promise((resolveShutdown) => {
94
+ const shutdown = () => {
95
+ void server.close().then(() => resolveShutdown());
96
+ };
97
+ process.once('SIGINT', shutdown);
98
+ process.once('SIGTERM', shutdown);
99
+ process.once('SIGHUP', shutdown);
100
+ });
101
+ return;
102
+ },
103
+ render: () => '',
104
+ });
105
+ export function registerWeb() {
106
+ return defineBranch({
107
+ name: 'web',
108
+ rootEntry: {
109
+ concept: 'the crouter web UI server — one host serving the browser shell, the source/command bridge, an SSE change lane, and a VERBATIM relay to nodes\' running brokers',
110
+ desc: 'serve the crouter web UI for the canvas and headless nodes',
111
+ useWhen: 'you want the crouter web UI in a browser instead of a tmux pane: watch the canvas and drive headless nodes. It runs one long-lived local server that serves the shell SPA, runs view sources + `crtr` command writes over POST /__crtr/source, streams canvas-change invalidations over GET /__crtr/events (SSE), and relays a browser connection VERBATIM to a node\'s already-running broker on /node/<id> — same protocol peer as `crtr attach`. The node is selected in the browser via the URL; the relay never starts an engine, so each node must already have a running headless broker.',
112
+ },
113
+ help: {
114
+ name: 'web',
115
+ summary: 'serve the crouter web UI: the shell SPA, the source/command bridge, the SSE change lane, and a VERBATIM relay to headless nodes\' brokers',
116
+ model: '`serve` starts the ONE long-running unified server on 127.0.0.1 (--port, default 7878; --open to launch the browser; --dev for Vite middleware/HMR). It serves the shell SPA (static dist/web-client/ shipped, Vite middleware in --dev), the POST /__crtr/source bridge (views\' reads AND `crtr` command writes via the local exec/file/http transport), the GET /__crtr/events SSE change-invalidation lane, and on a browser ws://127.0.0.1:PORT/node/<id> connection opens that node\'s broker view.sock and relays frames VERBATIM both ways — the browser is the same protocol peer as `crtr attach` (one controller drives, extra viewers are read-only; arbitration + dialogs ride the same frames). The relay NEVER spawns pi or writes a session: the node must already have a running headless broker (focus or revive it first); all graph mutations flow through `crtr` subprocesses the bridge runs. The server keeps running until ctrl+c; the node is chosen in the browser via the URL, not on the command line.',
117
+ },
118
+ children: [webServeLeaf],
119
+ });
120
+ }
@@ -12,7 +12,6 @@ import { browseLeaf } from './canvas-browse.js';
12
12
  import { reviveLeaf } from './revive.js';
13
13
  import { attentionBranch } from './attention.js';
14
14
  import { daemonBranch } from './daemon.js';
15
- import { tmuxSpreadLeaf } from './tmux-spread.js';
16
15
  import { chordLeaf } from './chord.js';
17
16
  import { canvasPruneLeaf } from './canvas-prune.js';
18
17
  import { historyBranch } from './canvas-history.js';
@@ -29,6 +28,6 @@ export function registerCanvas() {
29
28
  summary: 'observe and supervise the whole agent graph',
30
29
  model: 'Canvas-wide operations, distinct from per-node work (`node`) and a node\'s own spine I/O (`push`/`feed`). `dashboard` renders the subscription forest as a tree; `browse` opens an interactive full-screen navigator (tabs/tree/search) over the whole canvas and resumes the chosen node; `attention` aggregates pending human asks across the graph; `revive` reopens a window for a done/idle/dead/canceled node; `history` searches and recalls the content record (reports + context docs) of past work in a cwd; `daemon` manages the thin crtrd supervisor that auto-revives nodes on window exit; `prune` bounds growth by deleting terminal nodes past a TTL.',
31
30
  },
32
- children: [dashboardLeaf, browseLeaf, attentionBranch, reviveLeaf, historyBranch, tmuxSpreadLeaf, daemonBranch, chordLeaf, canvasPruneLeaf],
31
+ children: [dashboardLeaf, browseLeaf, attentionBranch, reviveLeaf, historyBranch, daemonBranch, chordLeaf, canvasPruneLeaf],
33
32
  });
34
33
  }
@@ -86,7 +86,7 @@ export const chordLeaf = defineLeaf({
86
86
  { name: 'action', type: 'string', required: false, constraint: 'What ran: a crtr argv string, "graph-toggle", or "noop".' },
87
87
  ],
88
88
  outputKind: 'object',
89
- effects: ['Runs a `crtr` subcommand (focus/close/tmux-spread/…) or sends `/graph` into the pane, per the matched bind.'],
89
+ effects: ['Runs a `crtr` subcommand (focus/close/…) or sends `/graph` into the pane, per the matched bind.'],
90
90
  },
91
91
  run: async (input) => {
92
92
  const pane = input['pane'] ?? process.env['TMUX_PANE'];
@@ -8,7 +8,7 @@
8
8
  import { defineLeaf } from '../core/command.js';
9
9
  import { InputError } from '../core/io.js';
10
10
  import { getNode, listNodes } from '../core/canvas/index.js';
11
- import { renderTree, renderForest, dashboardRows, dashboardRowsAll } from '../core/canvas/render.js';
11
+ import { renderTree, renderForest, dashboardRows, dashboardRowsAll, enrichRows } from '../core/canvas/render.js';
12
12
  // ---------------------------------------------------------------------------
13
13
  // dashboard show — the main leaf
14
14
  // ---------------------------------------------------------------------------
@@ -57,7 +57,11 @@ export const dashboardLeaf = defineLeaf({
57
57
  }
58
58
  // Full forest: all nodes on the canvas.
59
59
  const allNodes = listNodes();
60
+ // dashboardRowsAll is the cheap-boot builder: ctx_tokens/asks are 0 and `name`
61
+ // is the handle only until enriched. The JSON consumer wants the real values,
62
+ // so fold the deferred fields in before emitting (the rendered TREE is unaffected).
60
63
  const rows = dashboardRowsAll();
64
+ enrichRows(rows);
61
65
  return {
62
66
  tree: renderForest(),
63
67
  nodes: allNodes.length,