@crouton-kit/crouter 0.3.27 → 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 (343) 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 +8 -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__/reconnect-giveup.test.js +30 -0
  49. package/dist/clients/attach/__tests__/slash-quit-copy.test.js +42 -0
  50. package/dist/clients/attach/__tests__/titled-editor.test.d.ts +1 -0
  51. package/dist/clients/attach/__tests__/titled-editor.test.js +39 -0
  52. package/dist/clients/attach/attach-cmd.js +445 -41
  53. package/dist/clients/attach/auth-pickers.d.ts +9 -0
  54. package/dist/clients/attach/auth-pickers.js +194 -0
  55. package/dist/clients/attach/canvas-panels.d.ts +12 -0
  56. package/dist/clients/attach/canvas-panels.js +78 -0
  57. package/dist/clients/attach/chat-view.d.ts +27 -2
  58. package/dist/clients/attach/chat-view.js +98 -9
  59. package/dist/clients/attach/clipboard-image.d.ts +47 -8
  60. package/dist/clients/attach/clipboard-image.js +150 -26
  61. package/dist/clients/attach/clipboard-text.d.ts +3 -0
  62. package/dist/clients/attach/clipboard-text.js +39 -0
  63. package/dist/clients/attach/config-load.d.ts +54 -7
  64. package/dist/clients/attach/config-load.js +99 -3
  65. package/dist/clients/attach/context-message.d.ts +12 -0
  66. package/dist/clients/attach/context-message.js +72 -0
  67. package/dist/clients/attach/git-info.d.ts +20 -0
  68. package/dist/clients/attach/git-info.js +44 -0
  69. package/dist/clients/attach/graph-overlay.d.ts +43 -0
  70. package/dist/clients/attach/graph-overlay.js +306 -0
  71. package/dist/clients/attach/input-controller.d.ts +81 -13
  72. package/dist/clients/attach/input-controller.js +307 -67
  73. package/dist/clients/attach/pickers.d.ts +58 -0
  74. package/dist/clients/attach/pickers.js +171 -0
  75. package/dist/clients/attach/slash-commands.d.ts +44 -1
  76. package/dist/clients/attach/slash-commands.js +250 -27
  77. package/dist/clients/attach/titled-editor.d.ts +33 -0
  78. package/dist/clients/attach/titled-editor.js +84 -0
  79. package/dist/clients/attach/view-socket.d.ts +37 -2
  80. package/dist/clients/attach/view-socket.js +131 -7
  81. package/dist/clients/web/dev-server.d.ts +7 -0
  82. package/dist/clients/web/dev-server.js +59 -0
  83. package/dist/clients/web/events.d.ts +14 -0
  84. package/dist/clients/web/events.js +151 -0
  85. package/dist/clients/web/server.d.ts +18 -0
  86. package/dist/clients/web/server.js +450 -0
  87. package/dist/clients/web/web-cmd.d.ts +2 -0
  88. package/dist/clients/web/web-cmd.js +120 -0
  89. package/dist/commands/canvas.js +1 -2
  90. package/dist/commands/chord.js +1 -1
  91. package/dist/commands/dashboard.js +5 -1
  92. package/dist/commands/human/prompts.js +3 -3
  93. package/dist/commands/human/queue.d.ts +17 -0
  94. package/dist/commands/human/queue.js +111 -4
  95. package/dist/commands/memory/__tests__/lint-schema.test.js +27 -4
  96. package/dist/commands/memory/lint.d.ts +5 -4
  97. package/dist/commands/memory/lint.js +23 -7
  98. package/dist/commands/memory/read.js +2 -2
  99. package/dist/commands/memory/shared.d.ts +7 -1
  100. package/dist/commands/memory/shared.js +18 -3
  101. package/dist/commands/memory/write.js +20 -7
  102. package/dist/commands/memory.js +4 -4
  103. package/dist/commands/node.d.ts +1 -1
  104. package/dist/commands/node.js +64 -65
  105. package/dist/commands/pkg.js +1 -2
  106. package/dist/commands/revive.js +11 -3
  107. package/dist/commands/sys/feedback.d.ts +1 -0
  108. package/dist/commands/sys/feedback.js +163 -0
  109. package/dist/commands/sys/sync.d.ts +1 -0
  110. package/dist/commands/sys/sync.js +187 -0
  111. package/dist/commands/sys.js +4 -2
  112. package/dist/commands/view-cycle.js +2 -2
  113. package/dist/commands/view-list.js +8 -8
  114. package/dist/commands/view-new.js +21 -17
  115. package/dist/commands/view-pick.js +1 -1
  116. package/dist/commands/view-run.js +35 -14
  117. package/dist/commands/view.js +8 -6
  118. package/dist/commands/workspace.d.ts +2 -0
  119. package/dist/commands/workspace.js +161 -0
  120. package/dist/core/__tests__/broker-double-spawn.test.d.ts +1 -0
  121. package/dist/core/__tests__/broker-double-spawn.test.js +143 -0
  122. package/dist/core/__tests__/broker-fork-seam.test.d.ts +1 -0
  123. package/dist/core/__tests__/broker-fork-seam.test.js +102 -0
  124. package/dist/core/__tests__/broker-sdk-wiring.test.js +2 -0
  125. package/dist/core/__tests__/broker-snapshot-history.test.d.ts +1 -0
  126. package/dist/core/__tests__/broker-snapshot-history.test.js +105 -0
  127. package/dist/core/__tests__/child-death-wake.test.js +56 -111
  128. package/dist/core/__tests__/close.test.js +14 -20
  129. package/dist/core/__tests__/context-intro.test.js +19 -19
  130. package/dist/core/__tests__/daemon-boot.test.js +30 -23
  131. package/dist/core/__tests__/draw-style.test.js +1 -1
  132. package/dist/core/__tests__/error-stall-recycle.test.d.ts +1 -0
  133. package/dist/core/__tests__/error-stall-recycle.test.js +141 -0
  134. package/dist/core/__tests__/fixtures/fake-engine.d.ts +24 -1
  135. package/dist/core/__tests__/fixtures/fake-engine.js +32 -2
  136. package/dist/core/__tests__/fixtures/fake-pi-host.js +2 -2
  137. package/dist/core/__tests__/focuses.test.js +25 -13
  138. package/dist/core/__tests__/fork.test.js +22 -5
  139. package/dist/core/__tests__/full/broker-control-preempt.test.d.ts +1 -0
  140. package/dist/core/__tests__/full/broker-control-preempt.test.js +61 -0
  141. package/dist/core/__tests__/full/broker-model-changed-broadcast.test.d.ts +1 -0
  142. package/dist/core/__tests__/full/broker-model-changed-broadcast.test.js +57 -0
  143. package/dist/core/__tests__/full/broker-navigate-tree-rewelcome.test.d.ts +1 -0
  144. package/dist/core/__tests__/full/broker-navigate-tree-rewelcome.test.js +80 -0
  145. package/dist/core/__tests__/full/broker-pane-resolution.test.js +90 -0
  146. package/dist/core/__tests__/full/cascade-close.test.js +22 -8
  147. package/dist/core/__tests__/full/detach-focus.test.js +66 -169
  148. package/dist/core/__tests__/helpers/harness.d.ts +6 -9
  149. package/dist/core/__tests__/helpers/harness.js +19 -36
  150. package/dist/core/__tests__/human-node-not-supervised.test.d.ts +1 -0
  151. package/dist/core/__tests__/human-node-not-supervised.test.js +83 -0
  152. package/dist/core/__tests__/human-stranded-deliver.test.d.ts +1 -0
  153. package/dist/core/__tests__/human-stranded-deliver.test.js +108 -0
  154. package/dist/core/__tests__/lifecycle.test.js +1 -19
  155. package/dist/core/__tests__/live-mutation-verbs.test.js +5 -5
  156. package/dist/core/__tests__/memory-resolver.test.js +1 -1
  157. package/dist/core/__tests__/on-read-dedup-resume.test.d.ts +1 -0
  158. package/dist/core/__tests__/on-read-dedup-resume.test.js +81 -0
  159. package/dist/core/__tests__/refresh-stall-recycle.test.d.ts +1 -0
  160. package/dist/core/__tests__/refresh-stall-recycle.test.js +88 -0
  161. package/dist/core/__tests__/relaunch-root.test.d.ts +1 -0
  162. package/dist/core/__tests__/relaunch-root.test.js +114 -0
  163. package/dist/core/__tests__/reset.test.js +19 -79
  164. package/dist/core/__tests__/steer-note.test.js +9 -7
  165. package/dist/core/__tests__/tmux-surface.test.js +13 -7
  166. package/dist/core/bootstrap.d.ts +0 -3
  167. package/dist/core/bootstrap.js +1 -143
  168. package/dist/core/canvas/browse/__tests__/model.test.js +86 -1
  169. package/dist/core/canvas/browse/__tests__/render.test.js +2 -1
  170. package/dist/core/canvas/browse/app.js +131 -8
  171. package/dist/core/canvas/browse/model.d.ts +23 -5
  172. package/dist/core/canvas/browse/model.js +68 -13
  173. package/dist/core/canvas/browse/render.d.ts +7 -1
  174. package/dist/core/canvas/browse/render.js +178 -47
  175. package/dist/core/canvas/canvas.js +1 -1
  176. package/dist/core/canvas/focuses.d.ts +4 -4
  177. package/dist/core/canvas/focuses.js +7 -7
  178. package/dist/core/canvas/nav-model.d.ts +162 -0
  179. package/dist/core/canvas/nav-model.js +487 -0
  180. package/dist/core/canvas/paths.d.ts +7 -0
  181. package/dist/core/canvas/paths.js +9 -0
  182. package/dist/core/canvas/render.d.ts +68 -9
  183. package/dist/core/canvas/render.js +195 -61
  184. package/dist/core/canvas/types.d.ts +16 -23
  185. package/dist/core/config.js +1 -4
  186. package/dist/core/memory-resolver.js +43 -5
  187. package/dist/core/runtime/bearings.d.ts +1 -1
  188. package/dist/core/runtime/bearings.js +8 -8
  189. package/dist/core/runtime/branded-host.d.ts +16 -0
  190. package/dist/core/runtime/branded-host.js +127 -0
  191. package/dist/core/runtime/broker-protocol.d.ts +212 -6
  192. package/dist/core/runtime/broker-sdk.d.ts +0 -12
  193. package/dist/core/runtime/broker-sdk.js +77 -6
  194. package/dist/core/runtime/broker.d.ts +5 -1
  195. package/dist/core/runtime/broker.js +520 -59
  196. package/dist/core/runtime/close.d.ts +12 -2
  197. package/dist/core/runtime/close.js +37 -16
  198. package/dist/core/runtime/front-door.js +14 -7
  199. package/dist/core/runtime/host.d.ts +14 -34
  200. package/dist/core/runtime/host.js +14 -50
  201. package/dist/core/runtime/launch.d.ts +12 -9
  202. package/dist/core/runtime/launch.js +27 -15
  203. package/dist/core/runtime/lifecycle.d.ts +1 -1
  204. package/dist/core/runtime/lifecycle.js +15 -19
  205. package/dist/core/runtime/nodes.d.ts +0 -55
  206. package/dist/core/runtime/nodes.js +6 -74
  207. package/dist/core/runtime/placement.d.ts +91 -334
  208. package/dist/core/runtime/placement.js +262 -828
  209. package/dist/core/runtime/promote.d.ts +2 -0
  210. package/dist/core/runtime/promote.js +24 -6
  211. package/dist/core/runtime/recap.d.ts +8 -0
  212. package/dist/core/runtime/recap.js +107 -0
  213. package/dist/core/runtime/recycle.js +25 -61
  214. package/dist/core/runtime/reset.d.ts +43 -37
  215. package/dist/core/runtime/reset.js +131 -218
  216. package/dist/core/runtime/revive.d.ts +9 -29
  217. package/dist/core/runtime/revive.js +45 -135
  218. package/dist/core/runtime/spawn.d.ts +15 -11
  219. package/dist/core/runtime/spawn.js +168 -136
  220. package/dist/core/runtime/surface-bg.d.ts +11 -0
  221. package/dist/core/runtime/surface-bg.js +68 -0
  222. package/dist/core/runtime/tmux.d.ts +15 -43
  223. package/dist/core/runtime/tmux.js +38 -80
  224. package/dist/core/scope.d.ts +1 -2
  225. package/dist/core/scope.js +1 -5
  226. package/dist/core/skill-sync/__tests__/dry-run-wrote-count.test.d.ts +1 -0
  227. package/dist/core/skill-sync/__tests__/dry-run-wrote-count.test.js +57 -0
  228. package/dist/core/skill-sync/builtins.d.ts +42 -0
  229. package/dist/core/skill-sync/builtins.js +112 -0
  230. package/dist/core/skill-sync/claude-plugins.d.ts +23 -0
  231. package/dist/core/skill-sync/claude-plugins.js +71 -0
  232. package/dist/core/skill-sync/engine.d.ts +42 -0
  233. package/dist/core/skill-sync/engine.js +633 -0
  234. package/dist/core/skill-sync/export.d.ts +23 -0
  235. package/dist/core/skill-sync/export.js +86 -0
  236. package/dist/core/skill-sync/manifest.d.ts +64 -0
  237. package/dist/core/skill-sync/manifest.js +181 -0
  238. package/dist/core/skill-sync/profile.d.ts +76 -0
  239. package/dist/core/skill-sync/profile.js +173 -0
  240. package/dist/core/skill-sync/snapshot.d.ts +57 -0
  241. package/dist/core/skill-sync/snapshot.js +120 -0
  242. package/dist/core/spawn.d.ts +14 -0
  243. package/dist/core/spawn.js +29 -9
  244. package/dist/core/substrate/index.d.ts +2 -2
  245. package/dist/core/substrate/index.js +7 -7
  246. package/dist/core/substrate/injected-store.d.ts +10 -0
  247. package/dist/core/substrate/injected-store.js +55 -0
  248. package/dist/core/substrate/on-read.js +68 -6
  249. package/dist/core/substrate/render.d.ts +8 -11
  250. package/dist/core/substrate/render.js +29 -43
  251. package/dist/core/substrate/schema.d.ts +16 -11
  252. package/dist/core/substrate/schema.js +32 -31
  253. package/dist/core/tui/host.d.ts +12 -4
  254. package/dist/core/tui/host.js +280 -149
  255. package/dist/core/view/bridge.d.ts +10 -0
  256. package/dist/core/view/bridge.js +31 -0
  257. package/dist/core/view/chrome.d.ts +9 -0
  258. package/dist/core/view/chrome.js +22 -0
  259. package/dist/core/view/contract.d.ts +171 -0
  260. package/dist/core/view/contract.js +23 -0
  261. package/dist/core/view/loader.d.ts +31 -0
  262. package/dist/core/view/loader.js +188 -0
  263. package/dist/core/view/transport-local.d.ts +7 -0
  264. package/dist/core/view/transport-local.js +70 -0
  265. package/dist/core/view/transport.d.ts +4 -0
  266. package/dist/core/view/transport.js +15 -0
  267. package/dist/daemon/crtrd.d.ts +38 -27
  268. package/dist/daemon/crtrd.js +303 -376
  269. package/dist/daemon/manage.js +6 -1
  270. package/dist/index.js +1 -1
  271. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +28 -20
  272. package/dist/pi-extensions/canvas-context-intro.js +4 -4
  273. package/dist/pi-extensions/canvas-doc-substrate.js +29 -20
  274. package/dist/pi-extensions/canvas-nav.js +35 -385
  275. package/dist/pi-extensions/canvas-recap.d.ts +37 -0
  276. package/dist/pi-extensions/canvas-recap.js +236 -0
  277. package/dist/pi-extensions/canvas-resume.js +2 -1
  278. package/dist/pi-extensions/canvas-stophook.d.ts +15 -13
  279. package/dist/pi-extensions/canvas-stophook.js +119 -103
  280. package/dist/pi-extensions/canvas-view.js +2 -1
  281. package/dist/pi-extensions/widget-order-bus.d.ts +6 -0
  282. package/dist/pi-extensions/widget-order-bus.js +34 -0
  283. package/dist/prompts/view.d.ts +2 -2
  284. package/dist/prompts/view.js +145 -73
  285. package/dist/types.d.ts +1 -6
  286. package/dist/types.js +1 -3
  287. package/dist/web/ViewChrome.d.ts +7 -0
  288. package/dist/web/ViewChrome.js +28 -0
  289. package/dist/web/ViewPane.d.ts +39 -0
  290. package/dist/web/ViewPane.js +48 -0
  291. package/dist/web/index.d.ts +6 -0
  292. package/dist/web/index.js +16 -0
  293. package/dist/web/runtime.d.ts +39 -0
  294. package/dist/web/runtime.js +133 -0
  295. package/dist/web/states.d.ts +24 -0
  296. package/dist/web/states.js +24 -0
  297. package/dist/web/transport-http.d.ts +5 -0
  298. package/dist/web/transport-http.js +28 -0
  299. package/dist/web-client/assets/index-BUvQb4hR.css +2 -0
  300. package/dist/web-client/assets/index-ClLQXYAE.js +10 -0
  301. package/dist/web-client/index.html +13 -0
  302. package/package.json +20 -6
  303. package/dist/builtin-views/canvas/client.mjs +0 -303
  304. package/dist/builtin-views/canvas/view.mjs +0 -576
  305. package/dist/builtin-views/git-pr/client.mjs +0 -440
  306. package/dist/builtin-views/git-pr/view.mjs +0 -675
  307. package/dist/builtin-views/inbox/sources/gmail.mjs +0 -965
  308. package/dist/builtin-views/inbox/sources/linkedin.mjs +0 -427
  309. package/dist/builtin-views/inbox/view.mjs +0 -889
  310. package/dist/builtin-views/linkedin/client.mjs +0 -610
  311. package/dist/builtin-views/linkedin/view.mjs +0 -1171
  312. package/dist/commands/pkg/bridge.d.ts +0 -1
  313. package/dist/commands/pkg/bridge.js +0 -137
  314. package/dist/commands/skill/author.d.ts +0 -3
  315. package/dist/commands/skill/author.js +0 -140
  316. package/dist/commands/skill/shared.d.ts +0 -3
  317. package/dist/commands/skill/shared.js +0 -19
  318. package/dist/commands/skill.d.ts +0 -2
  319. package/dist/commands/skill.js +0 -21
  320. package/dist/commands/tmux-spread.d.ts +0 -2
  321. package/dist/commands/tmux-spread.js +0 -144
  322. package/dist/core/__tests__/full/daemon-liveness-pane.full.test.js +0 -292
  323. package/dist/core/__tests__/full/placement-focus.test.js +0 -309
  324. package/dist/core/__tests__/full/placement-reconcile.test.js +0 -212
  325. package/dist/core/__tests__/full/placement-revive.test.js +0 -238
  326. package/dist/core/__tests__/full/placement-teardown.test.js +0 -221
  327. package/dist/core/__tests__/home-session.test.js +0 -186
  328. package/dist/core/__tests__/relaunch.test.js +0 -335
  329. package/dist/core/bridge-map.d.ts +0 -19
  330. package/dist/core/bridge-map.js +0 -73
  331. package/dist/core/tui/contract.d.ts +0 -83
  332. package/dist/core/tui/contract.js +0 -8
  333. package/dist/core/tui/loader.d.ts +0 -16
  334. package/dist/core/tui/loader.js +0 -94
  335. package/dist/prompts/skill.d.ts +0 -2
  336. package/dist/prompts/skill.js +0 -650
  337. /package/dist/{core/__tests__/full/daemon-liveness-pane.full.test.d.ts → clients/attach/__tests__/action-parity.test.d.ts} +0 -0
  338. /package/dist/{core/__tests__/full/placement-focus.test.d.ts → clients/attach/__tests__/clipboard-image.test.d.ts} +0 -0
  339. /package/dist/{core/__tests__/full/placement-reconcile.test.d.ts → clients/attach/__tests__/editor-newline.test.d.ts} +0 -0
  340. /package/dist/{core/__tests__/full/placement-revive.test.d.ts → clients/attach/__tests__/git-info.test.d.ts} +0 -0
  341. /package/dist/{core/__tests__/full/placement-teardown.test.d.ts → clients/attach/__tests__/onrequest-wired.test.d.ts} +0 -0
  342. /package/dist/{core/__tests__/home-session.test.d.ts → clients/attach/__tests__/reconnect-giveup.test.d.ts} +0 -0
  343. /package/dist/{core/__tests__/relaunch.test.d.ts → clients/attach/__tests__/slash-quit-copy.test.d.ts} +0 -0
@@ -0,0 +1,84 @@
1
+ // titled-editor.ts — the `crtr attach` input editor with two bits of chrome pi's
2
+ // stock editor doesn't give us:
3
+ // 1. the session name painted INTO the top border as a solid-background chip,
4
+ // so it stays pinned to the editor instead of scrolling off as a free badge;
5
+ // 2. a border color that tracks the agent's thinking level (set by attach-cmd
6
+ // on each state update), mirroring pi's `getThinkingBorderColor`.
7
+ // Both are pure render-layer chrome; nothing here touches the socket or session.
8
+ import { CustomEditor } from '@earendil-works/pi-coding-agent';
9
+ import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui';
10
+ /** Per-level border/chip color as a 24-bit RGB triple — ONE blue-violet hue at
11
+ * CONSTANT perceived lightness (luma ≈ 120) whose SATURATION alone climbs with
12
+ * the budget: a near-gray blue at `minimal` deepening to a vivid violet at
13
+ * `xhigh`, never getting lighter or darker — only more colorful. (pi paints its
14
+ * own `thinking*` theme colors off its public palette surface, so we own this
15
+ * ramp directly — truecolor, not the 16-color ANSI hues, so the saturation
16
+ * climb actually reads.) `off` falls back to the caller's default border color. */
17
+ const THINKING_RGB = {
18
+ off: undefined, // default border color
19
+ minimal: [125, 115, 135], // near-gray blue-violet
20
+ low: [129, 109, 151], // muted blue-violet
21
+ medium: [134, 103, 168], // violet
22
+ high: [139, 98, 184], // deeper violet
23
+ xhigh: [144, 92, 200], // vivid violet
24
+ };
25
+ /** The default (thinking `off`) title chip: reverse video + a space of padding
26
+ * each side, so the name reads as a label sitting on the border rule. Used as
27
+ * the fallback when no thinking color applies. */
28
+ export const defaultTitleStyle = (s) => `\x1b[7m ${s} \x1b[27m`;
29
+ /** Resolve the editor border colorizer for a thinking level. Unknown / `off` →
30
+ * the supplied `fallback` (the theme's default border color). */
31
+ export function thinkingBorderColor(level, fallback) {
32
+ const rgb = level === undefined ? undefined : THINKING_RGB[level];
33
+ if (rgb === undefined)
34
+ return fallback;
35
+ const [r, g, b] = rgb;
36
+ return (s) => `\x1b[38;2;${r};${g};${b}m${s}\x1b[39m`;
37
+ }
38
+ /** Resolve the title-chip styler for a thinking level: the level's color as the
39
+ * chip BACKGROUND with bold white text (a space of padding each side), so the
40
+ * session name reads as a solid label in the same hue as the border. Unknown /
41
+ * `off` → the supplied `fallback` (the reverse-video default chip). */
42
+ export function thinkingTitleStyle(level, fallback) {
43
+ const rgb = level === undefined ? undefined : THINKING_RGB[level];
44
+ if (rgb === undefined)
45
+ return fallback;
46
+ const [r, g, b] = rgb;
47
+ return (s) => `\x1b[48;2;${r};${g};${b}m\x1b[97m\x1b[1m ${s} \x1b[0m`;
48
+ }
49
+ /** Compose the replacement top-border line: solid title chip + border rule +
50
+ * info chip, never wider than `width` (pi-tui hard-crashes on an over-wide
51
+ * line). The info chip yields entirely when the chip leaves it almost no room,
52
+ * and truncates when it only partially fits. Exported pure for the overflow
53
+ * regression test. */
54
+ export function composeTopBorder(width, title, info, titleStyle, borderColor) {
55
+ const chip = title ? titleStyle(truncateToWidth(title, Math.max(1, width - 4), '…')) : '';
56
+ const chipW = visibleWidth(chip);
57
+ const avail = width - chipW;
58
+ let infoChip = '';
59
+ if (info && avail > 4) {
60
+ infoChip = visibleWidth(info) <= avail ? info : truncateToWidth(info, avail, '…');
61
+ }
62
+ const fill = Math.max(0, width - chipW - visibleWidth(infoChip));
63
+ return chip + borderColor('─'.repeat(fill)) + infoChip;
64
+ }
65
+ export class TitledEditor extends CustomEditor {
66
+ /** Session-name chip painted into the LEFT of the top border. Empty → plain. */
67
+ title = '';
68
+ /** Pre-styled context string painted into the RIGHT of the top border (cwd /
69
+ * branch / git status). Already colorized by the caller; empty → omitted. */
70
+ info = '';
71
+ /** Paint the chip solid so the name reads as a label sitting on the border
72
+ * rule. Defaults to the reverse-video chip; attach-cmd swaps in a
73
+ * thinking-colored background (bold white text) on each state update. */
74
+ titleStyle = defaultTitleStyle;
75
+ render(width) {
76
+ const lines = super.render(width);
77
+ if ((this.title || this.info) && lines.length > 0) {
78
+ // Replace the stock top border; dashes in the current (thinking-aware)
79
+ // border color.
80
+ lines[0] = composeTopBorder(width, this.title, this.info, this.titleStyle, (s) => this.borderColor(s));
81
+ }
82
+ return lines;
83
+ }
84
+ }
@@ -1,8 +1,23 @@
1
1
  import { EventEmitter } from 'node:events';
2
- import { type BrokerToClient, type ClientToBroker } from '../../core/runtime/broker-protocol.js';
2
+ import type { NodeRow } from '../../core/canvas/types.js';
3
+ import { type BrokerDataFrame, type BrokerToClient, type ClientToBroker, type DequeueFrame, type GetSettingsFrame, type GetTreeFrame, type ListModelsFrame, type ListScopedModelsFrame, type ListSessionsFrame } from '../../core/runtime/broker-protocol.js';
4
+ /** A correlated read-op (or `dequeue`) request MINUS the client-chosen `id` —
5
+ * {@link ViewSocketClient.request} mints the `id`, sends the frame, and resolves
6
+ * with the matching `data` reply. The picker/operator code builds these; the
7
+ * socket owns the correlation token so callers never hand-roll one. */
8
+ export type ReadOpRequest = Omit<ListModelsFrame, 'id'> | Omit<ListSessionsFrame, 'id'> | Omit<GetTreeFrame, 'id'> | Omit<GetSettingsFrame, 'id'> | Omit<ListScopedModelsFrame, 'id'> | Omit<DequeueFrame, 'id'>;
3
9
  /** Surfaced when the node has no reachable broker at connect time (no socket
4
10
  * file, or a stale socket with nothing listening). The command catches this to
5
11
  * exit non-zero with a focus/revive hint. */
12
+ /** The reconnect supervisor's give-up predicate (extracted pure so it is
13
+ * testable without a socket or TUI). After a broker close the viewer KEEPS
14
+ * re-dialing the same `view.sock` while the node is still alive — a yield
15
+ * leaves `status='active'` (intent='refresh') and the daemon revives a fresh
16
+ * broker on the same path. It gives up only when the node is genuinely gone:
17
+ * a terminal status (done/dead/canceled) or a reaped row (null). `idle` is NOT
18
+ * terminal — an idle-release node revives on its next inbox wake, so keep
19
+ * trying (the supervisor's own ~30s bound caps an indefinite wait). */
20
+ export declare function reconnectShouldGiveUp(row: NodeRow | null): boolean;
6
21
  export declare class BrokerUnavailableError extends Error {
7
22
  readonly nodeId: string;
8
23
  constructor(nodeId: string);
@@ -24,15 +39,35 @@ export interface ViewSocketClient {
24
39
  export declare class ViewSocketClient extends EventEmitter {
25
40
  private readonly nodeId;
26
41
  private socket;
27
- private readonly decoder;
42
+ private decoder;
28
43
  private closeEmitted;
44
+ /** In-flight correlated read-ops, keyed by the `id` minted in {@link request}.
45
+ * Resolved by the matching `data` frame / rejected by the matching `error`
46
+ * frame in {@link onData}, the request's timeout, or socket teardown. */
47
+ private pending;
29
48
  constructor(nodeId: string);
49
+ /** Issue a correlated read-op and resolve with the broker's `data` reply (or
50
+ * reject on the correlated `error`, a timeout, or socket teardown). Mints the
51
+ * `id`, sends `{...frame, id}`, and parks a resolver consumed by {@link onData}.
52
+ * The reply is narrowed by the caller on its `kind`. */
53
+ request(frame: ReadOpRequest): Promise<BrokerDataFrame>;
54
+ /** Reject + clear every in-flight request (socket gone / decode error) so a
55
+ * picker fetch never hangs past the connection it rode on. */
56
+ private rejectAllPending;
30
57
  /** The broker binds `join(nodeDir(id), 'view.sock')` — resolve it the same way. */
31
58
  get socketPath(): string;
32
59
  /** Open the connection. Wire up listeners; emits `connect` on success or
33
60
  * `error` (a {@link BrokerUnavailableError} for ECONNREFUSED/ENOENT) on
34
61
  * failure. Idempotent guard is the caller's job — call once. */
35
62
  connect(): void;
63
+ /** Re-establish the socket after a broker exit (a yield→revive cycle), on the
64
+ * SAME stable `view.sock` path. Installs a FRESH FrameDecoder (a half-frame
65
+ * from the dead stream must not corrupt the new one) and resets the close
66
+ * guard, then re-dials. Resolves on `connect` (caller re-sends `hello`);
67
+ * rejects on the dial's `error` (ECONNREFUSED while the new broker is
68
+ * mid-boot, or ENOENT before it re-binds the socket) — both retryable. A
69
+ * post-connect error flows through the normal `onError`→`close` path. */
70
+ redial(): Promise<void>;
36
71
  /** Encode + write one client→broker frame. No-op on a dead/absent socket
37
72
  * (a `close` event drives teardown); never throws. */
38
73
  send(frame: ClientToBroker): void;
@@ -9,20 +9,40 @@
9
9
  // what keeps a broker alive, not attach). It also never uses pi's `RpcClient`
10
10
  // (which would spawn its own engine).
11
11
  //
12
- // No reconnect: a dropped broker means the engine's turn-cycle settled and the
13
- // broker disposed+exited; a fresh focus re-runs attach. On EOF/close → emit
14
- // `close` (the caller renders "broker gone" + exits). On a connect-time
15
- // ECONNREFUSED/ENOENT → a `BrokerUnavailableError` so the command exits non-zero
16
- // with a clear message. Decode is bounded by `CLIENT_READ_CAPS`; a broker that
17
- // somehow sends an oversized frame surfaces as a clean error+exit, not a crash.
12
+ // Reconnect: a dropped broker emits `close`; the caller's reconnect supervisor
13
+ // decides whether to re-dial (a yield/revive keeps the SAME `view.sock` path
14
+ // `redial()` re-establishes the socket on it) or give up ("broker gone"). On a
15
+ // connect-time ECONNREFUSED/ENOENT → a `BrokerUnavailableError` so the FIRST
16
+ // connect exits non-zero with a clear message; the same codes during a redial
17
+ // reject the redial promise as retryable. Decode is bounded by
18
+ // `CLIENT_READ_CAPS`; a broker that somehow sends an oversized frame surfaces as
19
+ // a clean error+exit, not a crash.
18
20
  import { EventEmitter } from 'node:events';
21
+ import { randomUUID } from 'node:crypto';
19
22
  import { createConnection } from 'node:net';
20
23
  import { join } from 'node:path';
21
24
  import { nodeDir } from '../../core/canvas/paths.js';
22
25
  import { CLIENT_READ_CAPS, encodeFrame, FrameDecoder, FrameOverflowError, } from '../../core/runtime/broker-protocol.js';
26
+ /** How long {@link ViewSocketClient.request} waits for the correlated reply before
27
+ * rejecting — bounds a pending picker fetch if the broker drops the request
28
+ * (it should always reply with `data` or a correlated `error`). */
29
+ const REQUEST_TIMEOUT_MS = 10_000;
23
30
  /** Surfaced when the node has no reachable broker at connect time (no socket
24
31
  * file, or a stale socket with nothing listening). The command catches this to
25
32
  * exit non-zero with a focus/revive hint. */
33
+ /** The reconnect supervisor's give-up predicate (extracted pure so it is
34
+ * testable without a socket or TUI). After a broker close the viewer KEEPS
35
+ * re-dialing the same `view.sock` while the node is still alive — a yield
36
+ * leaves `status='active'` (intent='refresh') and the daemon revives a fresh
37
+ * broker on the same path. It gives up only when the node is genuinely gone:
38
+ * a terminal status (done/dead/canceled) or a reaped row (null). `idle` is NOT
39
+ * terminal — an idle-release node revives on its next inbox wake, so keep
40
+ * trying (the supervisor's own ~30s bound caps an indefinite wait). */
41
+ export function reconnectShouldGiveUp(row) {
42
+ if (row === null)
43
+ return true;
44
+ return row.status === 'done' || row.status === 'dead' || row.status === 'canceled';
45
+ }
26
46
  export class BrokerUnavailableError extends Error {
27
47
  nodeId;
28
48
  constructor(nodeId) {
@@ -36,10 +56,48 @@ export class ViewSocketClient extends EventEmitter {
36
56
  socket;
37
57
  decoder = new FrameDecoder(CLIENT_READ_CAPS);
38
58
  closeEmitted = false;
59
+ /** In-flight correlated read-ops, keyed by the `id` minted in {@link request}.
60
+ * Resolved by the matching `data` frame / rejected by the matching `error`
61
+ * frame in {@link onData}, the request's timeout, or socket teardown. */
62
+ pending = new Map();
39
63
  constructor(nodeId) {
40
64
  super();
41
65
  this.nodeId = nodeId;
42
66
  }
67
+ /** Issue a correlated read-op and resolve with the broker's `data` reply (or
68
+ * reject on the correlated `error`, a timeout, or socket teardown). Mints the
69
+ * `id`, sends `{...frame, id}`, and parks a resolver consumed by {@link onData}.
70
+ * The reply is narrowed by the caller on its `kind`. */
71
+ request(frame) {
72
+ const id = randomUUID();
73
+ return new Promise((resolve, reject) => {
74
+ // Fail fast on a dead/absent socket rather than parking a promise that the
75
+ // reply will never reach (it would otherwise hang the full timeout). A
76
+ // post-`close` request lands here too — `rejectAllPending` already ran.
77
+ const sock = this.socket;
78
+ if (sock === undefined || sock.destroyed) {
79
+ reject(new Error(`cannot issue '${frame.type}': no live broker connection`));
80
+ return;
81
+ }
82
+ const timer = setTimeout(() => {
83
+ this.pending.delete(id);
84
+ reject(new Error(`request '${frame.type}' timed out after ${REQUEST_TIMEOUT_MS}ms`));
85
+ }, REQUEST_TIMEOUT_MS);
86
+ if (typeof timer.unref === 'function')
87
+ timer.unref();
88
+ this.pending.set(id, { resolve, reject, timer });
89
+ this.send({ ...frame, id });
90
+ });
91
+ }
92
+ /** Reject + clear every in-flight request (socket gone / decode error) so a
93
+ * picker fetch never hangs past the connection it rode on. */
94
+ rejectAllPending(reason) {
95
+ for (const { reject, timer } of this.pending.values()) {
96
+ clearTimeout(timer);
97
+ reject(new Error(reason));
98
+ }
99
+ this.pending.clear();
100
+ }
43
101
  /** The broker binds `join(nodeDir(id), 'view.sock')` — resolve it the same way. */
44
102
  get socketPath() {
45
103
  return join(nodeDir(this.nodeId), 'view.sock');
@@ -55,6 +113,46 @@ export class ViewSocketClient extends EventEmitter {
55
113
  socket.on('error', (err) => this.onError(err));
56
114
  socket.on('close', () => this.onClose());
57
115
  }
116
+ /** Re-establish the socket after a broker exit (a yield→revive cycle), on the
117
+ * SAME stable `view.sock` path. Installs a FRESH FrameDecoder (a half-frame
118
+ * from the dead stream must not corrupt the new one) and resets the close
119
+ * guard, then re-dials. Resolves on `connect` (caller re-sends `hello`);
120
+ * rejects on the dial's `error` (ECONNREFUSED while the new broker is
121
+ * mid-boot, or ENOENT before it re-binds the socket) — both retryable. A
122
+ * post-connect error flows through the normal `onError`→`close` path. */
123
+ redial() {
124
+ this.destroy();
125
+ this.decoder = new FrameDecoder(CLIENT_READ_CAPS);
126
+ this.closeEmitted = false;
127
+ return new Promise((resolve, reject) => {
128
+ const socket = createConnection(this.socketPath);
129
+ this.socket = socket;
130
+ let settled = false;
131
+ socket.on('connect', () => {
132
+ if (settled)
133
+ return;
134
+ settled = true;
135
+ this.emit('connect');
136
+ resolve();
137
+ });
138
+ socket.on('data', (chunk) => this.onData(chunk));
139
+ socket.on('error', (err) => {
140
+ if (settled) {
141
+ this.onError(err);
142
+ return;
143
+ }
144
+ settled = true;
145
+ try {
146
+ socket.destroy();
147
+ }
148
+ catch {
149
+ /* ignore */
150
+ }
151
+ reject(err);
152
+ });
153
+ socket.on('close', () => this.onClose());
154
+ });
155
+ }
58
156
  /** Encode + write one client→broker frame. No-op on a dead/absent socket
59
157
  * (a `close` event drives teardown); never throws. */
60
158
  send(frame) {
@@ -83,12 +181,37 @@ export class ViewSocketClient extends EventEmitter {
83
181
  const msg = err instanceof FrameOverflowError
84
182
  ? `broker sent an oversized frame (${err.message}) — disconnecting`
85
183
  : `failed to decode a broker frame: ${String(err)}`;
184
+ this.rejectAllPending(msg);
86
185
  this.emitError(new Error(msg));
87
186
  this.destroy();
88
187
  return;
89
188
  }
90
189
  for (const raw of frames) {
91
- this.emit('frame', raw);
190
+ const frame = raw;
191
+ // Correlated replies (read-ops + dequeue) are consumed by the pending-by-id
192
+ // resolver, NOT re-emitted as a generic 'frame' (the attach frame router
193
+ // would otherwise treat a `data` frame as an AgentSessionEvent). A `data`
194
+ // frame is ALWAYS a reply, so it is swallowed unconditionally — even a late
195
+ // post-timeout one (no pending entry) is dropped, never leaked to the
196
+ // router. An `error` is correlated only when its `id` matches an in-flight
197
+ // request; an uncorrelated error still flows through.
198
+ if (frame.type === 'data') {
199
+ const entry = typeof frame.id === 'string' ? this.pending.get(frame.id) : undefined;
200
+ if (entry) {
201
+ this.pending.delete(frame.id);
202
+ clearTimeout(entry.timer);
203
+ entry.resolve(frame);
204
+ }
205
+ continue;
206
+ }
207
+ if (frame.type === 'error' && typeof frame.id === 'string' && this.pending.has(frame.id)) {
208
+ const entry = this.pending.get(frame.id);
209
+ this.pending.delete(frame.id);
210
+ clearTimeout(entry.timer);
211
+ entry.reject(new Error(frame.message || `request failed: ${frame.code}`));
212
+ continue;
213
+ }
214
+ this.emit('frame', frame);
92
215
  }
93
216
  }
94
217
  onError(err) {
@@ -104,6 +227,7 @@ export class ViewSocketClient extends EventEmitter {
104
227
  if (this.closeEmitted)
105
228
  return;
106
229
  this.closeEmitted = true;
230
+ this.rejectAllPending('broker connection closed');
107
231
  this.emit('close');
108
232
  }
109
233
  destroy() {
@@ -0,0 +1,7 @@
1
+ import type { Server as HttpServer } from 'node:http';
2
+ import type { ViteDevServer } from 'vite';
3
+ /** Create a Vite dev server in middleware mode, HMR bound to `httpServer`.
4
+ * Mirrors the old `view serve` resolve setup so the shell's bare imports
5
+ * (react, the `@crouton-kit/crouter/web` self-subpath) resolve from the
6
+ * generated/shell root. Lazily imports vite so the shipped path never loads it. */
7
+ export declare function createDevServer(httpServer: HttpServer): Promise<ViteDevServer>;
@@ -0,0 +1,59 @@
1
+ // dev-server.ts — `crtr web serve --dev` Vite middleware mode.
2
+ //
3
+ // The shipped server serves a prebuilt static shell bundle from dist/web-client/
4
+ // (§11). In --dev a contributor iterating on the shell or a builtin view gets
5
+ // HMR instead: a Vite dev server in MIDDLEWARE mode mounted on the SAME HTTP
6
+ // server as the bridge + WS relay. This is the one place Vite runs at runtime —
7
+ // the mainstream "Vite middleware in dev, static dist in prod" split.
8
+ //
9
+ // The bridge (POST /__crtr/source), the SSE lane (GET /__crtr/events) and the
10
+ // WS relay are owned by server.ts and checked BEFORE Vite's middlewares; this
11
+ // module only supplies the asset/HMR middleware. Vite's HMR WebSocket shares
12
+ // the HTTP server (server.hmr.server) — server.ts's upgrade handler routes
13
+ // `/node/<id>` to the broker relay and leaves every other upgrade for Vite.
14
+ import { resolve, dirname, join } from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+ const HERE = dirname(fileURLToPath(import.meta.url));
17
+ /** The shell SPA Vite root — the in-tree shell project (`src/clients/web/web-client/`).
18
+ * --dev is a from-source contributor loop, so this resolves against the source
19
+ * tree (HERE is `dist/clients/web` when built, `src/clients/web` under tsx);
20
+ * walk to the package root and into src. */
21
+ function resolveShellRoot(packageRoot) {
22
+ return join(packageRoot, 'src', 'clients', 'web', 'web-client');
23
+ }
24
+ /** Create a Vite dev server in middleware mode, HMR bound to `httpServer`.
25
+ * Mirrors the old `view serve` resolve setup so the shell's bare imports
26
+ * (react, the `@crouton-kit/crouter/web` self-subpath) resolve from the
27
+ * generated/shell root. Lazily imports vite so the shipped path never loads it. */
28
+ export async function createDevServer(httpServer) {
29
+ const packageRoot = resolve(HERE, '../../..');
30
+ const nodeModules = join(packageRoot, 'node_modules');
31
+ const shellRoot = resolveShellRoot(packageRoot);
32
+ const { createServer } = await import('vite');
33
+ const react = (await import('@vitejs/plugin-react')).default;
34
+ const tailwindcss = (await import('@tailwindcss/vite')).default;
35
+ return createServer({
36
+ configFile: false,
37
+ root: shellRoot,
38
+ plugins: [react(), tailwindcss()],
39
+ appType: 'spa',
40
+ resolve: {
41
+ alias: {
42
+ react: join(nodeModules, 'react'),
43
+ 'react-dom': join(nodeModules, 'react-dom'),
44
+ // The crtr web runtime is a self-subpath of crouter's own package;
45
+ // pin it to the built barrel so the shell can import it from any root.
46
+ '@crouton-kit/crouter/web': join(packageRoot, 'dist', 'web', 'index.js'),
47
+ },
48
+ dedupe: ['react', 'react-dom'],
49
+ },
50
+ server: {
51
+ middlewareMode: true,
52
+ // Share our HTTP server for HMR; server.ts's upgrade handler hands every
53
+ // non-`/node/<id>` upgrade to Vite (the vite-hmr WebSocket).
54
+ hmr: { server: httpServer },
55
+ fs: { allow: [shellRoot, packageRoot] },
56
+ },
57
+ logLevel: 'warn',
58
+ });
59
+ }
@@ -0,0 +1,14 @@
1
+ import type { ServerResponse } from 'node:http';
2
+ export type ChangeKind = 'nodes' | 'inbox';
3
+ export interface EventHub {
4
+ /** Register an SSE client: writes the event-stream headers, streams events
5
+ * until the connection closes, and self-removes on close. */
6
+ addClient: (res: ServerResponse) => void;
7
+ /** Tear down watchers + heartbeat and end all open streams. */
8
+ close: () => void;
9
+ }
10
+ /** Start the change-event hub: open the two fs watchers once, fan their
11
+ * debounced invalidations out to every connected SSE client. Watchers run for
12
+ * the server's lifetime (cheap — two inotify/FSEvents registrations), not
13
+ * per-client, so N browsers share one watch. */
14
+ export declare function startEventHub(): EventHub;
@@ -0,0 +1,151 @@
1
+ // events.ts — the `crtr web` realtime push lane (the §7 SSE seam, built for v1).
2
+ //
3
+ // ONE server-sent-events stream, `GET /__crtr/events`, that carries CHANGE
4
+ // INVALIDATIONS — never data. The shell / its ViewPanes subscribe and, on an
5
+ // event, re-pull through their existing Sources (the bridge) instead of waiting
6
+ // on a fixed poll tick. SSE carries "something in this class changed"; the
7
+ // consumer decides what to re-read. This keeps the event vocabulary tiny and
8
+ // generic and the contract unchanged (a ViewPane still just runs `refresh`).
9
+ //
10
+ // Event vocabulary (deliberately minimal): { kind: 'nodes' | 'inbox', ts }.
11
+ // - 'nodes' — the node graph changed (topology, status/intent transitions,
12
+ // naming). Source: canvas.db (+ its WAL sidecar) mtime. Every transition()
13
+ // and canvas mutation writes the WAL, so a watch on the home dir's
14
+ // `canvas.db*` files detects all of them with one cheap watcher.
15
+ // - 'inbox' — a node's inbox.jsonl gained a message. Source: a recursive watch
16
+ // on the nodes/ tree, filtered to `inbox.jsonl` writes.
17
+ //
18
+ // WHY these sources (the §7 seam left the event source open): they are the
19
+ // cheapest things that detect node-graph + inbox changes without a daemon hook
20
+ // or a db trigger — pure fs.watch on the two on-disk authorities (canvas.db for
21
+ // the graph, inbox.jsonl appends for messages). No polling, no canvas.db reads.
22
+ import { watch } from 'node:fs';
23
+ import { crtrHome, nodesRoot, ensureHome } from '../../core/canvas/paths.js';
24
+ /** Coalesce a burst of fs events into one emission per kind. WAL writes and
25
+ * inbox appends arrive in clusters (a single transition can touch -wal/-shm);
26
+ * a short trailing debounce collapses each cluster into a single invalidation,
27
+ * which is all a re-pull consumer needs. */
28
+ const DEBOUNCE_MS = 150;
29
+ /** SSE keepalive comment cadence — keeps proxies/clients from idling the stream
30
+ * shut. A bare `:` comment line is ignored by EventSource. */
31
+ const HEARTBEAT_MS = 25_000;
32
+ /** Start the change-event hub: open the two fs watchers once, fan their
33
+ * debounced invalidations out to every connected SSE client. Watchers run for
34
+ * the server's lifetime (cheap — two inotify/FSEvents registrations), not
35
+ * per-client, so N browsers share one watch. */
36
+ export function startEventHub() {
37
+ ensureHome();
38
+ const clients = new Set();
39
+ const watchers = [];
40
+ const timers = new Map();
41
+ const broadcast = (kind) => {
42
+ const line = `data: ${JSON.stringify({ kind, ts: Date.now() })}\n\n`;
43
+ for (const res of clients) {
44
+ try {
45
+ res.write(line);
46
+ }
47
+ catch {
48
+ /* a dead stream is reaped by its own 'close' handler */
49
+ }
50
+ }
51
+ };
52
+ // Debounce per kind: an fs event arms a trailing timer; a fresh event within
53
+ // the window resets it, so a write cluster emits exactly once.
54
+ const emit = (kind) => {
55
+ const existing = timers.get(kind);
56
+ if (existing !== undefined)
57
+ clearTimeout(existing);
58
+ timers.set(kind, setTimeout(() => {
59
+ timers.delete(kind);
60
+ broadcast(kind);
61
+ }, DEBOUNCE_MS));
62
+ };
63
+ // Watcher 1 — the canvas home dir (non-recursive). canvas.db lives in WAL
64
+ // mode, so graph writes land in canvas.db-wal and checkpoints in canvas.db;
65
+ // watching the dir catches every `canvas.db*` sidecar touch → 'nodes'.
66
+ try {
67
+ const homeWatcher = watch(crtrHome(), (_event, filename) => {
68
+ if (filename !== null && filename.startsWith('canvas.db'))
69
+ emit('nodes');
70
+ });
71
+ homeWatcher.on('error', () => {
72
+ /* watch dropped (dir replaced) — non-fatal; the poll cadence still backs us */
73
+ });
74
+ watchers.push(homeWatcher);
75
+ }
76
+ catch {
77
+ /* fs.watch unavailable on this platform — degrade to poll-only (no SSE) */
78
+ }
79
+ // Watcher 2 — the nodes/ tree (recursive). An inbox.jsonl append is a message
80
+ // landing; a meta.json write is a graph/identity change (naming, polymorph).
81
+ // Recursive fs.watch is supported on macOS/Windows always and Linux ≥ v20.
82
+ try {
83
+ const nodesWatcher = watch(nodesRoot(), { recursive: true }, (_event, filename) => {
84
+ if (filename === null)
85
+ return;
86
+ if (filename.endsWith('inbox.jsonl'))
87
+ emit('inbox');
88
+ else if (filename.endsWith('meta.json'))
89
+ emit('nodes');
90
+ });
91
+ nodesWatcher.on('error', () => {
92
+ /* recursive watch refused/dropped — non-fatal */
93
+ });
94
+ watchers.push(nodesWatcher);
95
+ }
96
+ catch {
97
+ /* recursive watch unsupported — inbox push degrades to poll-only */
98
+ }
99
+ const heartbeat = setInterval(() => {
100
+ for (const res of clients) {
101
+ try {
102
+ res.write(': ping\n\n');
103
+ }
104
+ catch {
105
+ /* reaped by its 'close' handler */
106
+ }
107
+ }
108
+ }, HEARTBEAT_MS);
109
+ heartbeat.unref();
110
+ return {
111
+ addClient(res) {
112
+ res.writeHead(200, {
113
+ 'content-type': 'text/event-stream; charset=utf-8',
114
+ 'cache-control': 'no-cache, no-transform',
115
+ connection: 'keep-alive',
116
+ // The browser EventSource is same-origin here; no CORS header needed.
117
+ });
118
+ // An initial comment flushes headers and confirms the stream is open.
119
+ res.write(': connected\n\n');
120
+ clients.add(res);
121
+ const drop = () => {
122
+ clients.delete(res);
123
+ };
124
+ res.on('close', drop);
125
+ res.on('error', drop);
126
+ },
127
+ close() {
128
+ clearInterval(heartbeat);
129
+ for (const t of timers.values())
130
+ clearTimeout(t);
131
+ timers.clear();
132
+ for (const w of watchers) {
133
+ try {
134
+ w.close();
135
+ }
136
+ catch {
137
+ /* ignore */
138
+ }
139
+ }
140
+ for (const res of clients) {
141
+ try {
142
+ res.end();
143
+ }
144
+ catch {
145
+ /* ignore */
146
+ }
147
+ }
148
+ clients.clear();
149
+ },
150
+ };
151
+ }
@@ -0,0 +1,18 @@
1
+ export interface WebServerOptions {
2
+ port: number;
3
+ host?: string;
4
+ /** --dev: mount a Vite dev server (middleware mode, HMR) for asset serving
5
+ * instead of the static dist/web-client/ bundle. The bridge + SSE + WS relay
6
+ * are identical in both modes. */
7
+ dev?: boolean;
8
+ }
9
+ export interface RunningWebServer {
10
+ url: string;
11
+ port: number;
12
+ clientDir: string;
13
+ close: () => Promise<void>;
14
+ }
15
+ /** Build + start the unified web server. Binds 127.0.0.1 only (remote access is
16
+ * out of scope — §9). Resolves once the server is listening. Async because
17
+ * --dev awaits a Vite middleware server before listening. */
18
+ export declare function startWebServer(opts: WebServerOptions): Promise<RunningWebServer>;