@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
@@ -1,10 +1,12 @@
1
1
  // `crtr view` — the TUI view library.
2
2
  //
3
- // Views are switchable raw-ANSI terminal surfaces, each a self-contained .mjs
4
- // the host loads and runs. This branch assembles the leaves: `list` enumerates
5
- // what's available, `run` hosts one (interactive in tmux, static dump when
6
- // piped), `new` scaffolds a fresh view.mjs, and the hidden `pick` backs the
7
- // /view popup. Each leaf owns its own help one level down.
3
+ // Views are switchable surfaces with two targets from one portable core.mjs:
4
+ // the tmux TUI (`run`) and a React+Tailwind web page. This branch assembles the
5
+ // TUI-side leaves: `list` enumerates what's available, `run` hosts the TUI
6
+ // target (interactive in tmux, static dump when piped), `new` scaffolds a fresh
7
+ // view directory, and the hidden `pick` backs the /view popup. Each leaf owns
8
+ // its own help one level down. The web target is no longer served per-view here:
9
+ // it is hosted by the unified `crtr web serve` (the shell's /view/<id> route).
8
10
  import { defineBranch } from '../core/command.js';
9
11
  import { viewListLeaf } from './view-list.js';
10
12
  import { viewRunLeaf } from './view-run.js';
@@ -22,7 +24,7 @@ export function registerView() {
22
24
  help: {
23
25
  name: 'view',
24
26
  summary: 'host and author switchable raw-ANSI terminal views',
25
- model: '`list` when you do not know which views exist — a flat roster (id/title/description/scope) across project→user→builtin. `run <name>` opens one full-screen in the current pane (tmux-only interactive; piped it prints the view\'s static dump and exits 0), forwarding --port/--target onto the view; pass --window/--split to open it as a persistent monitor (new window / split) you flip between with Alt+V then ]/[. `cycle` switches a monitor pane to the next/prev view in place (what those keys drive). `new <name>` scaffolds a runnable view.mjs stub you edit. `pick` is a hidden raw-ANSI picker the /view popup shells. Append `-h` at any leaf for its schema.',
27
+ model: '`list` when you do not know which views exist — a flat roster (id/title/description/scope) across project→user→builtin. `run <name>` opens the TUI target full-screen in the current pane (tmux-only interactive; piped it prints the view\'s static dump and exits 0), forwarding --port/--target onto the view; pass --window/--split to open it as a persistent monitor (new window / split) you flip between with Alt+V then ]/[. The web target (React+Tailwind) is served by the unified `crtr web serve` (the shell\'s /view/<id> route), not per-view here. `cycle` switches a monitor pane to the next/prev view in place (what those keys drive). `new <name>` scaffolds a runnable view directory (core.mjs + tui.mjs + web.jsx + text.mjs) you edit. `pick` is a hidden raw-ANSI picker the /view popup shells. Append `-h` at any leaf for its schema.',
26
28
  },
27
29
  children: [viewListLeaf, viewRunLeaf, viewCycleLeaf, viewNewLeaf, viewPickLeaf],
28
30
  });
@@ -0,0 +1,2 @@
1
+ import type { BranchDef } from '../core/command.js';
2
+ export declare function registerWorkspace(): BranchDef;
@@ -0,0 +1,161 @@
1
+ // workspace.ts — `crtr workspace enter`: the agent-centric editor layout.
2
+ //
3
+ // Turns the current tmux window into an editor view for attached headless nodes:
4
+ // a narrow LEFT rail (the `workspace-sidebar` view — this graph + other live
5
+ // agents in this cwd, with ⚑ inbox flags) beside a CHAT pane (`crtr attach`).
6
+ // Selecting a node in the rail swaps it into the chat pane.
7
+ //
8
+ // Layout build (the only two driver acts, both through placement's seam):
9
+ // 1. splitWindow(here, before+size) → the rail pane, left of here, runs
10
+ // `crtr view run workspace-sidebar`.
11
+ // 2. `crtr node focus <node> --pane <here>` → swaps the chat node's attach
12
+ // viewer into here's slot (here's old content relocates to the backstage).
13
+ // The rail finds the chat pane by DISCOVERY (the `@crtr_node` tag attach sets),
14
+ // so it is robust to that swap — no launch-time pane id is threaded into it.
15
+ //
16
+ // Command shape: the path-walker forbids a flat top-level leaf (same constraint
17
+ // as attach), so this is a BRANCH wrapping one leaf: `crtr workspace enter`.
18
+ import { execFile } from 'node:child_process';
19
+ import { defineBranch, defineLeaf } from '../core/command.js';
20
+ import { InputError } from '../core/io.js';
21
+ import { getNode, listNodes } from '../core/canvas/index.js';
22
+ import { inTmux, currentTmux, splitWindow, piCommand } from '../core/runtime/placement.js';
23
+ const RAIL_WIDTH = 32;
24
+ /** Newest LIVE (active|idle) forest root started in `cwd` — the default node the
25
+ * chat pane attaches to when `--node` is omitted. A root has no parent inside
26
+ * the cwd-scoped set. Returns null when this cwd has no live root. */
27
+ function defaultChatNode(cwd) {
28
+ const rows = listNodes({ status: ['active', 'idle'] }).filter((r) => r.cwd === cwd);
29
+ if (rows.length === 0)
30
+ return null;
31
+ const ids = new Set(rows.map((r) => r.node_id));
32
+ const roots = rows.filter((r) => r.parent === null || !ids.has(r.parent));
33
+ const pool = roots.length > 0 ? roots : rows;
34
+ pool.sort((a, b) => (a.created < b.created ? 1 : -1)); // newest first
35
+ return pool[0].node_id;
36
+ }
37
+ const workspaceEnterLeaf = defineLeaf({
38
+ name: 'enter',
39
+ description: 'enter the editor view — a node rail beside a chat pane, in this window',
40
+ whenToUse: 'you want to work in the agent-centric editor layout: a left rail listing this graph + the other live agents in this working directory (with ⚑ inbox flags) beside a chat pane attached to a node. Selecting a node in the rail swaps it into the chat pane. Opens in the CURRENT tmux window. tmux-only.',
41
+ help: {
42
+ name: 'workspace enter',
43
+ summary: 'split the current window into a left node-rail (the workspace-sidebar view) + a chat pane (crtr attach); ↵ in the rail swaps a node into the chat. tmux-only.',
44
+ params: [
45
+ {
46
+ kind: 'flag',
47
+ name: 'node',
48
+ type: 'string',
49
+ required: false,
50
+ constraint: 'Node to attach in the chat pane. Default: the newest live root in this cwd (rail-only, empty chat, if none).',
51
+ },
52
+ {
53
+ kind: 'flag',
54
+ name: 'width',
55
+ type: 'int',
56
+ required: false,
57
+ default: RAIL_WIDTH,
58
+ constraint: `Rail width in columns (default ${RAIL_WIDTH}).`,
59
+ },
60
+ ],
61
+ output: [
62
+ { name: 'rail', type: 'string', required: false, constraint: 'tmux pane id of the rail.' },
63
+ { name: 'chat', type: 'string', required: false, constraint: 'node id attached in the chat pane (absent if none).' },
64
+ { name: 'note', type: 'string', required: false, constraint: 'Set only on the non-TTY/piped path.' },
65
+ ],
66
+ outputKind: 'object',
67
+ effects: [
68
+ 'Splits a narrow LEFT pane in the current window running the workspace-sidebar view.',
69
+ 'Swaps the chosen (or default) node into the current pane as a chat (crtr node focus), relocating the current pane to the backstage.',
70
+ 'Outside a TTY/tmux: prints a notice and exits 0 (no layout change).',
71
+ ],
72
+ },
73
+ run: async (input) => {
74
+ const node = input['node'];
75
+ const width = input['width'] ?? RAIL_WIDTH;
76
+ if (!process.stdout.isTTY) {
77
+ return { note: 'crtr workspace enter builds a tmux layout — run it in a tmux pane (a TTY), not a pipe.' };
78
+ }
79
+ if (!inTmux()) {
80
+ throw new InputError({
81
+ error: 'not_in_tmux',
82
+ message: 'crtr workspace is tmux-only — run it inside the crtr tmux session',
83
+ next: 'Open it from inside tmux.',
84
+ });
85
+ }
86
+ const here = currentTmux();
87
+ if (here === null) {
88
+ throw new InputError({
89
+ error: 'no_tmux_location',
90
+ message: 'could not resolve the current tmux pane',
91
+ next: 'Run this inside a tmux pane.',
92
+ });
93
+ }
94
+ // Resolve the chat node (explicit --node, else newest live root here).
95
+ let chatNode = null;
96
+ if (node !== undefined && node !== '') {
97
+ if (getNode(node) === null) {
98
+ throw new InputError({
99
+ error: 'not_found',
100
+ message: `no node: ${node}`,
101
+ received: node,
102
+ next: 'List nodes with `crtr node inspect list`.',
103
+ });
104
+ }
105
+ chatNode = node;
106
+ }
107
+ else {
108
+ chatNode = defaultChatNode(process.cwd());
109
+ }
110
+ // 1. The rail — a narrow split to the LEFT of here. It discovers the chat
111
+ // pane itself (the @crtr_node tag), so no pane id is threaded in.
112
+ const railCmd = piCommand(['view', 'run', 'workspace-sidebar', '--target', here.pane], 'crtr');
113
+ const rail = splitWindow(here.pane, {
114
+ cwd: process.cwd(),
115
+ env: {},
116
+ command: railCmd,
117
+ before: true,
118
+ size: Math.max(16, width),
119
+ });
120
+ if (rail === null) {
121
+ throw new InputError({
122
+ error: 'split_failed',
123
+ message: 'tmux could not split the rail pane',
124
+ next: 'Check the tmux server is reachable, then retry.',
125
+ });
126
+ }
127
+ // 2. The chat — swap the node's attach viewer into here's slot. Best-effort:
128
+ // the rail is already up, so a focus failure degrades to a rail-only view
129
+ // the user can drive (↵ a node) rather than aborting the whole layout.
130
+ if (chatNode !== null) {
131
+ await new Promise((resolve) => {
132
+ execFile('crtr', ['node', 'focus', chatNode, '--pane', here.pane], () => resolve());
133
+ });
134
+ }
135
+ return chatNode !== null ? { rail, chat: chatNode } : { rail };
136
+ },
137
+ render: (result) => {
138
+ if (result['note'] !== undefined)
139
+ return String(result['note']);
140
+ const chat = result['chat'];
141
+ return chat !== undefined
142
+ ? `Workspace ready — rail ${result['rail']}, chat attached to ${chat}.`
143
+ : `Workspace ready — rail ${result['rail']}. No live agent here yet; press ↵ on a node in the rail to open one.`;
144
+ },
145
+ });
146
+ export function registerWorkspace() {
147
+ return defineBranch({
148
+ name: 'workspace',
149
+ rootEntry: {
150
+ concept: 'the agent-centric editor view — a node rail beside a chat pane, in one tmux window',
151
+ desc: 'enter the editor layout for attached headless nodes',
152
+ useWhen: 'you want an editor-style workspace in tmux: a left rail of this graph + other live agents in this cwd (with ⚑ inbox flags) next to a chat pane attached to a node, where selecting a node in the rail swaps it into the chat. tmux-only.',
153
+ },
154
+ help: {
155
+ name: 'workspace',
156
+ summary: 'enter the agent-centric editor view (node rail + chat pane) in the current tmux window',
157
+ model: '`enter` splits the current window into a narrow left rail (the workspace-sidebar view — this graph + other live agents in this cwd, ⚑ inbox flags) and a chat pane (`crtr attach`) attached to a node (--node, or the newest live root here). ↵ in the rail swaps the selected node into the chat pane. tmux-only; piped it prints a notice and exits 0.',
158
+ },
159
+ children: [workspaceEnterLeaf],
160
+ });
161
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,143 @@
1
+ // Run with: node --import tsx/esm --test src/core/__tests__/broker-double-spawn.test.ts
2
+ //
3
+ // BUG-REGRESSION (broker-universal-host cut, design §C "double-spawn verdict";
4
+ // taste/broker-is-the-host). Two coupled invariants the cut introduced:
5
+ //
6
+ // 1. A pre-cut row carries host_kind='tmux' (or null). While its RAW pi pid is
7
+ // still ALIVE the daemon must NEVER relaunch it — a relaunch would put a
8
+ // SECOND vehicle on the same .jsonl (the double-spawn). Liveness is the
9
+ // recorded engine pid alone; an alive pid is always 'leave'.
10
+ // 2. When that pid dies and the node revives, it comes back as a BROKER and the
11
+ // row's host_kind is lazily COERCED tmux→'broker' (reviveNode, revive.ts
12
+ // ~91-97) so inspect/history read honest values and the daemon never
13
+ // branches it back to a tmux host.
14
+ //
15
+ // Three legs, strongest→cheapest: the pure verdict (D1a), the direct reviveNode
16
+ // coerce (D1b), and the full daemon integration over a real live→dead pid (D1c).
17
+ // Regression check: if the cut's coerce is removed, D1b/D1c go RED (host_kind
18
+ // stays 'tmux'); if an alive pid is ever revived, D1c's "no relaunch" asserts go
19
+ // RED (cycles bumps / host_kind flips while the pid is still alive).
20
+ import { test } from 'node:test';
21
+ import assert from 'node:assert/strict';
22
+ import { spawn, spawnSync } from 'node:child_process';
23
+ import { createHeadlessHarness } from './helpers/harness.js';
24
+ import { createNode } from '../canvas/canvas.js';
25
+ import { closeDb } from '../canvas/db.js';
26
+ import { reviveNode } from '../runtime/revive.js';
27
+ import { livenessVerdict, isPidAlive } from '../../daemon/crtrd.js';
28
+ // A pid reaped (dead) by the time spawnSync returns — the "dead but supervised"
29
+ // pid the grace path judges.
30
+ function deadPid() {
31
+ const r = spawnSync('true', [], { stdio: 'ignore' });
32
+ return r.pid ?? 0x7ffffffe;
33
+ }
34
+ /** Fabricate a PRE-CUT host_kind='tmux' row directly (the harness's
35
+ * fabricateBrokerNode hardcodes host_kind:'broker', so we mirror its body here
36
+ * with host_kind:'tmux' — no harness edit). The result is a row the daemon
37
+ * supervises by pid exactly as a legacy tmux node would be. */
38
+ function fabricateTmuxNode(o) {
39
+ const meta = {
40
+ node_id: o.id,
41
+ name: o.id,
42
+ created: new Date().toISOString(),
43
+ cwd: process.cwd(),
44
+ host_kind: 'tmux',
45
+ kind: 'developer',
46
+ mode: 'base',
47
+ lifecycle: 'terminal',
48
+ parent: null,
49
+ pi_session_id: o.pi_session_id,
50
+ status: 'active',
51
+ intent: null,
52
+ pi_pid: o.pi_pid,
53
+ };
54
+ createNode(meta);
55
+ closeDb();
56
+ return o.id;
57
+ }
58
+ // REVIVE_GRACE_MS is 20_000 (crtrd.ts); pick offsets well inside / well past it.
59
+ const T_ALIVE = 6_000_000;
60
+ const T_DEAD = 6_100_000; // first dead observation
61
+ const T_PAST = T_DEAD + 25_000; // > 20s after first-observed-dead → revive
62
+ // ---------------------------------------------------------------------------
63
+ // D1a — pure verdict: an alive pid is NEVER relaunched (no double-spawn).
64
+ // ---------------------------------------------------------------------------
65
+ test('D1a livenessVerdict: an ALIVE pid is left alone (never a double-spawn); a DEAD pid pends then revives', () => {
66
+ assert.equal(livenessVerdict(true, null), 'leave', 'alive pid, first obs → leave');
67
+ assert.equal(livenessVerdict(true, 10_000_000), 'leave', 'alive pid is NEVER relaunched');
68
+ assert.equal(livenessVerdict(false, null), 'pending', 'dead, first obs → pending (double-spawn guard)');
69
+ assert.equal(livenessVerdict(false, 1_000), 'pending', 'dead, within grace → pending');
70
+ assert.equal(livenessVerdict(false, 10_000_000), 'revive', 'dead past grace → revive');
71
+ });
72
+ // ---------------------------------------------------------------------------
73
+ // D1b — direct reviveNode coerce: a tmux row flips to 'broker' on revive.
74
+ // ---------------------------------------------------------------------------
75
+ test('D1b reviveNode coerces a pre-cut host_kind=tmux row to broker', async () => {
76
+ const h = await createHeadlessHarness({ sessionPrefix: 'crtr-d1b' });
77
+ try {
78
+ const id = fabricateTmuxNode({ id: 'd1b-node', pi_pid: deadPid(), pi_session_id: 'sess-d1b' });
79
+ assert.equal(h.node(id).host_kind, 'tmux', 'precondition: row is a legacy tmux host');
80
+ // reviveNode's lazy coerce runs at the top, before any launch (revive.ts ~91-97).
81
+ reviveNode(id, { resume: true });
82
+ assert.equal(h.node(id).host_kind, 'broker', 'tmux→broker coerced on revive (the daemon never branches it back)');
83
+ }
84
+ finally {
85
+ await h.dispose();
86
+ }
87
+ });
88
+ // ---------------------------------------------------------------------------
89
+ // D1c — full integration: an ALIVE tmux pid is NOT relaunched; once it dies the
90
+ // node revives as a broker (host_kind coerced, cycles bumped).
91
+ // ---------------------------------------------------------------------------
92
+ test('D1c daemon: a live tmux pi is never double-spawned; on death it revives as a broker', { timeout: 30_000 }, async () => {
93
+ const h = await createHeadlessHarness({ sessionPrefix: 'crtr-d1c' });
94
+ // A real live subprocess whose pid stands in for the raw pi vehicle.
95
+ const child = spawn('sleep', ['300'], { stdio: 'ignore' });
96
+ const livePid = child.pid;
97
+ try {
98
+ assert.equal(isPidAlive(livePid), true, 'precondition: the raw pi pid is alive');
99
+ // pi_session_id SET so the dead-pid path routes the crash/grace revive, NOT
100
+ // the never-booted boot-failure branch.
101
+ const id = fabricateTmuxNode({ id: 'd1c-node', pi_pid: livePid, pi_session_id: 'sess-d1c' });
102
+ assert.equal(h.node(id).host_kind, 'tmux', 'precondition: pre-cut tmux host');
103
+ assert.equal(h.node(id).cycles ?? 0, 0, 'no revive yet');
104
+ // --- TICK while the pi is ALIVE: must be a no-op. The daemon never
105
+ // relaunches a live pid, so reviveNode is never called → NO coerce, NO
106
+ // cycle bump, pid unchanged. This is the double-spawn guard. ---
107
+ await h.tick(T_ALIVE);
108
+ {
109
+ const n = h.node(id);
110
+ assert.equal(n.host_kind, 'tmux', 'live pid: host_kind NOT coerced (reviveNode never ran → no double-spawn)');
111
+ assert.equal(n.cycles ?? 0, 0, 'live pid: NO revive (cycles still 0)');
112
+ assert.equal(n.pi_pid, livePid, 'live pid: pi_pid unchanged — no second vehicle launched');
113
+ }
114
+ // Kill the raw pi and wait until the daemon would observe it dead.
115
+ child.kill('SIGKILL');
116
+ await h.waitFor(() => (isPidAlive(livePid) ? null : true), { label: 'raw pi dead', timeoutMs: 10_000 });
117
+ // --- TICK: first dead observation → pending (within grace). No revive. ---
118
+ await h.tick(T_DEAD);
119
+ {
120
+ const n = h.node(id);
121
+ assert.equal(n.cycles ?? 0, 0, 'first-observed-dead: pends, no revive yet');
122
+ assert.equal(n.host_kind, 'tmux', 'still tmux until the revive actually fires');
123
+ }
124
+ // --- TICK past the grace: the crash/grace revive fires → reviveNode coerces
125
+ // host_kind tmux→broker and bumps cycles (observable instantly, the
126
+ // model effect lands before the detached broker spawn — grace-clock). ---
127
+ await h.tick(T_PAST);
128
+ {
129
+ const n = h.node(id);
130
+ assert.equal(n.cycles ?? 0, 1, 'past grace: revived → cycles bumped to 1');
131
+ assert.equal(n.host_kind, 'broker', 'past grace: host_kind coerced tmux→broker on revive');
132
+ }
133
+ }
134
+ finally {
135
+ try {
136
+ child.kill('SIGKILL');
137
+ }
138
+ catch {
139
+ /* already gone */
140
+ }
141
+ await h.dispose();
142
+ }
143
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,102 @@
1
+ // Run with: node --import tsx/esm --test src/core/__tests__/broker-fork-seam.test.ts
2
+ //
3
+ // BUG-REGRESSION (broker-universal-host cut, design §B.broker; U1 report
4
+ // mq8ftenr-5d55313d). Before the cut, the headless broker had NO fork path — its
5
+ // session selection THREW `--fork is not supported by the headless broker`. After
6
+ // the cut EVERY node is a broker, so `node new --fork-from <id>` would always hit
7
+ // that throw. The fix routes the broker's fork through pi's REAL fork seam,
8
+ // SessionManager.forkFrom (broker.ts buildBrokerSession, the `forking` branch) —
9
+ // the same method pi's own `--fork` CLI flag uses.
10
+ //
11
+ // This test drives the BROKER's OWN fork branch (broker.ts buildBrokerSession,
12
+ // the `forking` branch ~1106) end to end: it calls `buildBrokerSession` with
13
+ // `cfg.forkFrom` set to a real source session .jsonl — the EXACT path a
14
+ // `node new --fork-from <id>` boot takes through the broker — and asserts the
15
+ // resulting live session is a genuine fork: a NEW session id (not a colliding
16
+ // copy) and a `parentSession` header pointing at the source, with the source's
17
+ // history copied in. Because it invokes `buildBrokerSession` (not
18
+ // `SessionManager.forkFrom` directly), it goes RED if broker.ts's fork branch
19
+ // reverts to `throw "--fork is not supported by the headless broker"` — it
20
+ // guards its own stated regression.
21
+ //
22
+ // Like the C3/C4 real-SDK wiring tests, it drives the REAL engine (broker-sdk's
23
+ // static SDK re-exports) — NOT the CRTR_BROKER_ENGINE fake the lifecycle suite
24
+ // uses, whose fixture omits forkFrom and so could never exercise this branch. It
25
+ // runs offline (fork + session/services assembly are all local: no network/auth).
26
+ //
27
+ // (fork.test.ts covers the pure argv/resolve layers — buildPiArgv emitting
28
+ // `--fork`, resolveForkSource turning a ref into a path. THIS file covers the
29
+ // broker runtime branch those layers feed.)
30
+ import { test, before, after } from 'node:test';
31
+ import assert from 'node:assert/strict';
32
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
33
+ import { tmpdir } from 'node:os';
34
+ import { join } from 'node:path';
35
+ import { createAgentSessionServices, createAgentSessionFromServices, SessionManager, VERSION, } from '../runtime/broker-sdk.js';
36
+ import { buildBrokerSession } from '../runtime/broker.js';
37
+ // The REAL engine — bypasses the CRTR_BROKER_ENGINE fake entirely (the whole
38
+ // point: the fake omits forkFrom, so only the real SDK can prove the branch).
39
+ const realEngine = {
40
+ createAgentSessionServices,
41
+ createAgentSessionFromServices,
42
+ SessionManager,
43
+ VERSION,
44
+ };
45
+ let dir;
46
+ before(() => {
47
+ dir = mkdtempSync(join(tmpdir(), 'crtr-fork-seam-'));
48
+ });
49
+ after(() => {
50
+ rmSync(dir, { recursive: true, force: true });
51
+ });
52
+ /** A minimal but VALID pi session .jsonl (the exact on-disk shape v3 emits): a
53
+ * `session` header line + two `message` entries. This is what a node that has
54
+ * run a turn or two carries on disk — the source the broker forks from. */
55
+ function writeSourceSession(path, srcId) {
56
+ const lines = [
57
+ JSON.stringify({ type: 'session', version: 3, id: srcId, timestamp: '2026-06-10T20:27:32.891Z', cwd: dir }),
58
+ JSON.stringify({ type: 'message', id: 'a42170e1', parentId: null, timestamp: '2026-06-10T20:27:32.893Z', message: { role: 'user', content: 'source turn one' } }),
59
+ JSON.stringify({ type: 'message', id: 'f309b932', parentId: 'a42170e1', timestamp: '2026-06-10T20:27:32.893Z', message: { role: 'assistant', content: 'source reply one' } }),
60
+ ];
61
+ writeFileSync(path, lines.join('\n') + '\n');
62
+ return 2; // entry count (the two message lines)
63
+ }
64
+ test('D2 broker fork branch: buildBrokerSession(cfg.forkFrom) yields a NEW id + parentSession header, copying history (never throws "fork not supported")', async () => {
65
+ // 1. A real source session on disk.
66
+ const srcFile = join(dir, 'src.jsonl');
67
+ const srcId = '019eb338-029b-7536-b3eb-d33bd6f641a8';
68
+ const srcEntryCount = writeSourceSession(srcFile, srcId);
69
+ // 2. Drive the BROKER's fork branch: buildBrokerSession with cfg.forkFrom set —
70
+ // the same recipe a `node new --fork-from <id>` boot produces. The OLD broker
71
+ // THREW in this branch ("--fork is not supported by the headless broker"), so
72
+ // if broker.ts:~1106 reverts to that throw, this rejects and the test goes RED.
73
+ const cfg = { cwd: dir, extensionPaths: [], forkFrom: srcFile };
74
+ let session;
75
+ let resuming;
76
+ await assert.doesNotReject(async () => {
77
+ const built = await buildBrokerSession(realEngine, cfg);
78
+ session = built.session;
79
+ resuming = built.resuming;
80
+ }, 'the broker fork branch must NOT throw "fork is not supported by the headless broker"');
81
+ assert.ok(session !== undefined, 'buildBrokerSession returned a live session for the fork');
82
+ try {
83
+ // 3. A fork boots as a FRESH spawn (resuming=false) so the kickoff firstPrompt
84
+ // fires — the new node gets the source history AND a new task to start on.
85
+ assert.equal(resuming, false, 'a fork is a fresh spawn (resuming=false) so the kickoff fires');
86
+ // 4. Real fork metadata on the broker's selected SessionManager, not a
87
+ // colliding .jsonl copy.
88
+ const sm = session.sessionManager;
89
+ assert.notEqual(sm.getSessionId(), srcId, 'the fork is a NEW session id (not a copy that would collide on resume)');
90
+ const header = sm.getHeader();
91
+ assert.equal(header?.parentSession, srcFile, 'the fork header records parentSession pointing at the source .jsonl');
92
+ // 5. The source history is carried into the fork (fork copies, not empty
93
+ // start). The broker boot may append its own session-start entry, so the
94
+ // fork holds AT LEAST the source's messages — a naïve empty session would
95
+ // have zero.
96
+ assert.ok(sm.getEntries().length >= srcEntryCount, `the fork carries the source conversation history forward ` +
97
+ `(>= ${srcEntryCount} source entries; got ${sm.getEntries().length})`);
98
+ }
99
+ finally {
100
+ session.dispose();
101
+ }
102
+ });
@@ -107,6 +107,7 @@ test('C2 — zero-viewer UI context resolves dialogs to deny/cancel immediately
107
107
  throw new Error('C2: must NOT forward a dialog when no controller is attached');
108
108
  },
109
109
  pending: new Map(),
110
+ broadcast: () => { },
110
111
  });
111
112
  // confirm() WITH a large timeout: the fix must resolve at once (deny), NOT after
112
113
  // the timeout — the old timeout-reliant path would have waited 60s (or, with no
@@ -144,6 +145,7 @@ test('M2 — a forwarded dialog stays pending on controller detach, resolving on
144
145
  },
145
146
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
146
147
  pending: pending,
148
+ broadcast: () => { },
147
149
  });
148
150
  // Controller attached → confirm() forwards + registers a pending dialog (with the
149
151
  // request retained for welcome.pending_dialog / re-route, T4) and does NOT
@@ -0,0 +1,105 @@
1
+ // Run with: node --import tsx/esm --test src/core/__tests__/broker-snapshot-history.test.ts
2
+ //
3
+ // BUG-REGRESSION (real, observed; surfaced during the crouter-web build, proven
4
+ // broker-side). After a revive resumes a previously-CLOSED pi session, the
5
+ // broker's `welcome` snapshot must present the SAME ordered, complete turn
6
+ // history the persisted session `.jsonl` holds — no omitted or reordered turn.
7
+ //
8
+ // ROOT CAUSE: the broker's `buildSnapshot` served `session.messages`
9
+ // (= `agent.state.messages`, the agent's LIVE in-memory array). pi's recovery
10
+ // paths — auto-retry, context-overflow recovery, and compaction — SLICE the
11
+ // errored/superseded assistant message out of `state.messages` while
12
+ // DELIBERATELY keeping it on disk ("keep in session for history",
13
+ // agent-session.js). So `session.messages` can OMIT a turn the `.jsonl` still
14
+ // holds. crouter-web's static normalizer reads the full ordered history correctly
15
+ // (pi `SessionManager.buildSessionContext` over the file), so a dormant viewer
16
+ // saw the complete turn — then, after a revive, the broker's live welcome
17
+ // snapshot served the diverged (omitted) array. The fix makes the snapshot
18
+ // reconstruct from the persisted session tree (the SAME source the normalizer
19
+ // uses), so live == persisted == disk (single source of truth).
20
+ //
21
+ // This drives the REAL pi SDK offline (no tmux, no broker process, no network) —
22
+ // exactly the seam where the bug lives: it builds a real resumed broker session,
23
+ // SIMULATES pi's retry slice by popping the last assistant off the live
24
+ // `agent.state.messages` (leaving the persisted tree intact), and asserts the
25
+ // snapshot follows the persisted history, not the sliced live array.
26
+ import { test } from 'node:test';
27
+ import assert from 'node:assert/strict';
28
+ import { mkdtempSync, rmSync } from 'node:fs';
29
+ import { tmpdir } from 'node:os';
30
+ import { join } from 'node:path';
31
+ import { createAgentSessionServices, createAgentSessionFromServices, SessionManager, VERSION, } from '../runtime/broker-sdk.js';
32
+ import { buildBrokerSession, snapshotMessages } from '../runtime/broker.js';
33
+ const realEngine = {
34
+ createAgentSessionServices,
35
+ createAgentSessionFromServices,
36
+ SessionManager,
37
+ VERSION,
38
+ };
39
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
40
+ function userMsg(text) {
41
+ return { role: 'user', content: [{ type: 'text', text }] };
42
+ }
43
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
44
+ function assistantMsg(text, stopReason = 'stop') {
45
+ return {
46
+ role: 'assistant',
47
+ content: [{ type: 'text', text }],
48
+ api: 'anthropic-messages',
49
+ provider: 'anthropic',
50
+ model: 'sonnet',
51
+ usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 2, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
52
+ stopReason,
53
+ timestamp: Date.now(),
54
+ };
55
+ }
56
+ test('broker welcome snapshot serves the persisted history, not pi\u2019s sliced live array (omit-on-revive regression)', async () => {
57
+ const cwd = mkdtempSync(join(tmpdir(), 'crtr-snaphist-'));
58
+ try {
59
+ // 1. Build a real persisted session whose tail mimics an auto-retried turn:
60
+ // user \u2192 errored assistant (kept on disk) \u2192 retried assistant. This is what
61
+ // pi's `.jsonl` holds after a retryable error (529 / overflow / network).
62
+ const seed = SessionManager.create(cwd);
63
+ seed.appendMessage(userMsg('q1'));
64
+ seed.appendMessage(assistantMsg('a1'));
65
+ seed.appendMessage(userMsg('q2 — triggers a retryable error'));
66
+ seed.appendMessage(assistantMsg('errored attempt', 'error')); // kept in session for history
67
+ seed.appendMessage(assistantMsg('retried answer')); // the successful retry
68
+ const sessionFile = seed.getSessionFile();
69
+ assert.ok(sessionFile, 'seed session persisted a .jsonl');
70
+ // The persisted, ordered history a dormant reader / crouter-web normalizer sees.
71
+ const persisted = SessionManager.open(sessionFile).buildSessionContext().messages;
72
+ assert.equal(persisted.length, 5, 'disk holds all five messages incl. the errored attempt');
73
+ // 2. Revive: the broker resumes the closed session via the SERVICES path.
74
+ const { session } = await buildBrokerSession(realEngine, {
75
+ cwd,
76
+ extensionPaths: [],
77
+ resumeSessionPath: sessionFile,
78
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
79
+ });
80
+ try {
81
+ assert.equal(session.messages.length, 5, 'at boot the live array == persisted history');
82
+ // 3. Simulate pi's auto-retry/overflow slice: it removes the errored
83
+ // assistant from agent.state.messages but KEEPS it on disk. We reproduce
84
+ // that exact mutation on the live array (the persisted tree is untouched).
85
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
86
+ const live = session.agent.state.messages;
87
+ const popped = live.pop(); // drop the last assistant from the LIVE array only
88
+ assert.equal(popped.role, 'assistant', 'sliced an assistant off the live array');
89
+ assert.equal(session.messages.length, 4, 'the live array now OMITS the persisted turn');
90
+ // 4. The bug: pre-fix `buildSnapshot` served `session.messages` (4) \u2014 omitting
91
+ // a turn the .jsonl still holds. The fix serves the persisted history (5),
92
+ // byte-identical to the normalizer.
93
+ const snap = snapshotMessages(session);
94
+ assert.equal(snap.length, persisted.length, 'snapshot presents the COMPLETE persisted history (omit-on-revive regression)');
95
+ assert.notEqual(snap.length, session.messages.length, 'snapshot does NOT echo the sliced live array');
96
+ assert.deepEqual(snap, persisted, 'snapshot is byte-identical to the static normalizer\u2019s buildSessionContext (single source of truth)');
97
+ }
98
+ finally {
99
+ session.dispose();
100
+ }
101
+ }
102
+ finally {
103
+ rmSync(cwd, { recursive: true, force: true });
104
+ }
105
+ });