@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,11 +1,12 @@
1
1
  // broker.ts — the headless node broker (design §4, §5; plan T4).
2
2
  //
3
- // One broker process per `--headless` node. It hosts ONE pi engine IN-PROCESS
3
+ // One broker process per node — the SOLE host. It hosts ONE pi engine IN-PROCESS
4
4
  // via the SDK (createAgentSession), is the SOLE writer of the node's session
5
5
  // `.jsonl`, listens on `nodeDir(id)/view.sock`, fans the single engine event
6
6
  // stream out to N viewers, serializes a single controller's drive commands, and
7
- // routes blocking extension dialogs. It is the headless analog of pi-in-a-pane:
8
- // it runs one turn-cycle and, when the engine settles (the stophook calls
7
+ // routes blocking extension dialogs. The engine has no terminal of its own; a
8
+ // tmux pane (or web tab) is only a viewer of this socket. It
9
+ // runs one turn-cycle and, when the engine settles (the stophook calls
9
10
  // ctx.shutdown(), or the engine goes idle), disposes the engine and exits 0 —
10
11
  // the daemon then routes revival per the intent the bound stophook set.
11
12
  //
@@ -16,10 +17,13 @@
16
17
  // the real canvas-stophook fires session_start).
17
18
  import { createServer } from 'node:net';
18
19
  import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
20
+ import { execFile } from 'node:child_process';
21
+ import { tmpdir } from 'node:os';
19
22
  import { join } from 'node:path';
20
23
  import { randomUUID } from 'node:crypto';
21
24
  import { getAgentDir, initTheme, } from '@earendil-works/pi-coding-agent';
22
25
  import { jobDir, nodeDir } from '../canvas/paths.js';
26
+ import { getNode, updateNode } from '../canvas/index.js';
23
27
  import { FRONT_DOOR_ENV } from './front-door.js';
24
28
  import { piInvocationToSdkConfig } from './launch.js';
25
29
  import { assertEngineVersion, loadBrokerEngine } from './broker-sdk.js';
@@ -83,14 +87,13 @@ export async function runBroker(nodeId) {
83
87
  // front-door recursion guard (runtime/CLAUDE.md fork-bomb invariant). The host
84
88
  // (T6) also sets this; we set it again so the broker is self-sufficient.
85
89
  process.env[FRONT_DOOR_ENV] = '1';
86
- // The broker is PANELESS. A `--headless` child spawned by a tmux-resident
87
- // parent inherits the parent's TMUX_PANE/TMUX in its env (T6 spawns with
88
- // {...process.env}); left in place, the bound stophook's refresh-yield branch
89
- // (canvas-stophook.ts) would call reviveInPlace on the PARENT's %pane_id
90
- // (respawn-pane -k) — hijacking the parent's pane with a fresh pi, killing the
91
- // parent and orphaning this broker. Strip both so every stophook pane-branch
92
- // takes its no-pane path (→ ctx.shutdown() → our shutdownHandler → clean exit;
93
- // the daemon then respawns the node as a broker).
90
+ // The broker is PANELESS and detached, but a broker spawned by a tmux-resident
91
+ // parent inherits the parent's TMUX_PANE/TMUX in its env (the launcher spawns
92
+ // with {...process.env}). Left in place, every `crtr` command the engine runs
93
+ // (bash tool, goal-capture naming, a child spawn) would resolve `currentTmux()`
94
+ // to the PARENT's pane/session and act there — e.g. open viewers beside, or
95
+ // close, the parent's pane. Strip both so this broker's children see no tmux
96
+ // context and take their paneless path.
94
97
  delete process.env['TMUX_PANE'];
95
98
  delete process.env['TMUX'];
96
99
  // 1. Version tripwire + load the launch recipe the host serialized.
@@ -110,12 +113,6 @@ export async function runBroker(nodeId) {
110
113
  const launchFile = join(dir, 'broker-launch.json');
111
114
  const inv = JSON.parse(readFileSync(launchFile, 'utf8'));
112
115
  const cfg = piInvocationToSdkConfig(inv); // also merges inv.env
113
- // Fork-on-spawn is the runtime.fork() path (createAgentSessionRuntime), not
114
- // wired headless in Phase 3. Fail loud rather than silently mis-resume.
115
- if (cfg.forkFrom !== undefined && cfg.forkFrom !== '') {
116
- throw new Error(`[broker] --fork is not supported by the headless broker in Phase 3 ` +
117
- `(forkFrom=${cfg.forkFrom}); the SDK fork path is createAgentSessionRuntime.fork()`);
118
- }
119
116
  // 2–4. Build the engine session via the SERVICES path (C3) — see
120
117
  // buildBrokerSession below. Register it so the FATAL exit path (M3) can
121
118
  // dispose it and reap detached bash children.
@@ -157,12 +154,25 @@ export async function runBroker(nodeId) {
157
154
  let controllerId = null;
158
155
  let disposed = false;
159
156
  let server;
157
+ // Liveness-aware: a controllerId whose client's transport is already gone counts
158
+ // as NO controller, so control self-heals the instant a controller's peer departs
159
+ // — WITHOUT waiting for the socket 'close' event. On a unix socket a peer
160
+ // destroy() delivers EOF (readableEnded) promptly, but the matching 'close' can
161
+ // lag arbitrarily while undrainable pending writes to the gone peer flush; under
162
+ // load that lag stranded control on a dead client and froze admission (a fresh
163
+ // controller hello was denied for the whole window — the one-writer reattach
164
+ // deadlock the G9 gate locks). Treating an ended/destroyed/unwritable holder as
165
+ // free closes that window at every read of the controller (admission included).
160
166
  const controllerClient = () => {
161
167
  if (controllerId === null)
162
168
  return null;
163
- for (const c of clients)
164
- if (c.id === controllerId)
165
- return c;
169
+ for (const c of clients) {
170
+ if (c.id !== controllerId)
171
+ continue;
172
+ if (c.socket.destroyed || c.socket.readableEnded || !c.socket.writable)
173
+ return null;
174
+ return c;
175
+ }
166
176
  return null;
167
177
  };
168
178
  // Persist viewer presence to job/attach.json on every viewer state change
@@ -309,8 +319,42 @@ export async function runBroker(nodeId) {
309
319
  persistAttachState(); // every control change is a viewer-state change
310
320
  broadcast({ type: 'control_changed', controller_id: controllerId });
311
321
  };
322
+ // Persist a live model switch into the node's durable launch recipe so it
323
+ // survives a yield/revive. pi's `/model` (→ set_model/cycle_model) only
324
+ // mutates the in-memory engine; without this the node reverts to its
325
+ // persona/spawn model on the next revive. We write the full `provider/id`
326
+ // (which normalizeModel passes through unchanged) to BOTH model_override (so
327
+ // polymorphs preserve it via buildLaunchSpec) and launch.model (the recipe
328
+ // buildPiArgv replays on revive). Best-effort: a degenerate/ephemeral node
329
+ // with no canvas row just skips persistence. CRTR_NODE_ID is set in the
330
+ // broker's own env (merged by piInvocationToSdkConfig at boot).
331
+ const persistModelChoice = () => {
332
+ const nodeId = process.env['CRTR_NODE_ID'];
333
+ const m = session.model;
334
+ if (nodeId === undefined || nodeId === '' || m === null || m === undefined)
335
+ return;
336
+ const spec = `${m.provider}/${m.id}`;
337
+ try {
338
+ const meta = getNode(nodeId);
339
+ if (meta === null)
340
+ return;
341
+ const launch = meta.launch !== undefined ? { ...meta.launch, model: spec } : undefined;
342
+ updateNode(nodeId, { model_override: spec, ...(launch !== undefined ? { launch } : {}) });
343
+ }
344
+ catch {
345
+ // Persistence is best-effort; the live switch already succeeded.
346
+ }
347
+ };
348
+ // pi emits no AgentSessionEvent for a model switch, so after a successful
349
+ // set_model/cycle_model the broker announces it itself — otherwise the new
350
+ // model reaches no viewer (the requester gets only a bare ack) and every
351
+ // footer shows the stale model until the next unrelated event.
352
+ const broadcastModelChanged = () => {
353
+ persistModelChoice();
354
+ broadcast({ type: 'model_changed', model: session.model?.id });
355
+ };
312
356
  const buildSnapshot = () => ({
313
- messages: session.messages,
357
+ messages: snapshotMessages(session),
314
358
  stats: session.getSessionStats(),
315
359
  state: {
316
360
  sessionId: session.sessionId,
@@ -339,6 +383,7 @@ export async function runBroker(nodeId) {
339
383
  role: client.role,
340
384
  controller_id: controllerId,
341
385
  pending_dialog: first !== undefined ? first.request : null,
386
+ agentDir: getAgentDir(),
342
387
  }, true);
343
388
  };
344
389
  // T4 re-route on become-controller: a dialog raised while a prior controller was
@@ -407,6 +452,7 @@ export async function runBroker(nodeId) {
407
452
  controller: controllerClient,
408
453
  forward: (client, request) => sendFrame(client, request),
409
454
  pending: pendingDialogs,
455
+ broadcast,
410
456
  });
411
457
  // -------------------------------------------------------------------------
412
458
  // 3+4 (plan): bind the FULL canvas extensions (mode 'print') AND fan the single
@@ -520,18 +566,29 @@ export async function runBroker(nodeId) {
520
566
  // -------------------------------------------------------------------------
521
567
  /** Reject a non-controller for a controller-only op. Returns true when rejected
522
568
  * (the caller should `break`). */
523
- const notController = (client, what) => {
569
+ const notController = (client, what, id) => {
524
570
  if (client.id === controllerId)
525
571
  return false;
526
572
  sendFrame(client, {
527
573
  type: 'error',
528
574
  code: 'not_controller',
529
575
  message: `only the controlling client may ${what}`,
576
+ // M1: echo a correlated request's id so its pending-by-id promise rejects
577
+ // rather than hanging (e.g. a non-controller `dequeue`).
578
+ ...(id !== undefined ? { id } : {}),
530
579
  });
531
580
  return true;
532
581
  };
533
582
  const ackTo = (client, op, ok = true, detail) => sendFrame(client, { type: 'ack', for: op, ok, ...(detail !== undefined ? { detail } : {}) });
534
- const engineErrorTo = (client) => (err) => sendFrame(client, { type: 'error', code: 'engine_error', message: String(err) });
583
+ const engineErrorTo = (client, id) => (err) => sendFrame(client, {
584
+ type: 'error',
585
+ code: 'engine_error',
586
+ message: String(err),
587
+ // M1: a correlated read-op / dequeue failure echoes its id so the viewer's
588
+ // pending-by-id promise rejects instead of hanging; uncorrelated callers
589
+ // pass no id (unchanged behavior).
590
+ ...(id !== undefined ? { id } : {}),
591
+ });
535
592
  /** Resolve a `provider/id` model spec against the LIVE services registry (which
536
593
  * carries any extension-registered providers). Pure: returns undefined on a
537
594
  * malformed spec or an unknown model. */
@@ -541,6 +598,63 @@ export async function runBroker(nodeId) {
541
598
  return undefined;
542
599
  return services.modelRegistry.find(spec.slice(0, slash), spec.slice(slash + 1));
543
600
  };
601
+ /** Resolve a model from a free-text query — `/model opus`, `/model fable`. Tries
602
+ * the exact `provider/id` spec first; failing that, searches the registry the
603
+ * way the picker's search box does (case-insensitive substring on the model id
604
+ * and the `provider/id` key) and returns the BEST match, preferring authed/
605
+ * available models. Returns undefined only when nothing matches. */
606
+ const resolveModelQuery = (query) => {
607
+ const exact = findModelSpec(query);
608
+ if (exact)
609
+ return exact;
610
+ const q = query.trim().toLowerCase();
611
+ if (q === '')
612
+ return undefined;
613
+ let all;
614
+ try {
615
+ all = services.modelRegistry.getAll();
616
+ }
617
+ catch {
618
+ return undefined;
619
+ }
620
+ let availableKeys;
621
+ try {
622
+ availableKeys = new Set(services.modelRegistry.getAvailable().map(modelKey));
623
+ }
624
+ catch {
625
+ availableKeys = new Set();
626
+ }
627
+ // Rank: exact id (100) > exact provider/id (90) > id prefix (80) > id
628
+ // substring (60) > provider/id substring (40); +5 if the model is available.
629
+ const rank = (m) => {
630
+ const id = m.id.toLowerCase();
631
+ const key = modelKey(m).toLowerCase();
632
+ let base = -1;
633
+ if (id === q)
634
+ base = 100;
635
+ else if (key === q)
636
+ base = 90;
637
+ else if (id.startsWith(q))
638
+ base = 80;
639
+ else if (id.includes(q))
640
+ base = 60;
641
+ else if (key.includes(q))
642
+ base = 40;
643
+ else
644
+ return -1;
645
+ return base + (availableKeys.has(modelKey(m)) ? 5 : 0);
646
+ };
647
+ let best;
648
+ let bestRank = 0;
649
+ for (const m of all) {
650
+ const r = rank(m);
651
+ if (r > bestRank) {
652
+ bestRank = r;
653
+ best = m;
654
+ }
655
+ }
656
+ return best;
657
+ };
544
658
  /** The merged command list for `get_commands` (C6/M9): the engine's registered
545
659
  * extension + skill commands and file-based prompt templates, MERGED with the
546
660
  * vendored BUILTIN_SLASH_COMMANDS (RPC omits builtins). Returned to the viewer
@@ -569,6 +683,128 @@ export async function runBroker(nodeId) {
569
683
  }
570
684
  return out;
571
685
  };
686
+ // -------------------------------------------------------------------------
687
+ // Read-op data builders (operator-view picker payloads, §5 Unit A). Each is a
688
+ // PURE getter read against the live engine session — the data a native pi
689
+ // picker's constructor needs, serialized for the viewer. Not controller-gated
690
+ // (read-only, like get_commands), so the web bridge's observer connection can
691
+ // populate pickers too.
692
+ // -------------------------------------------------------------------------
693
+ const toModelRef = (m) => m === undefined ? null : { provider: m.provider, id: m.id };
694
+ const modelKey = (m) => `${m.provider}/${m.id}`;
695
+ const buildListModelsData = (id) => {
696
+ const reg = session.modelRegistry;
697
+ let availableIds = [];
698
+ try {
699
+ availableIds = reg.getAvailable().map(modelKey);
700
+ }
701
+ catch {
702
+ /* getAvailable touches auth config; degrade to "none known available" */
703
+ }
704
+ return {
705
+ type: 'data',
706
+ id,
707
+ kind: 'list_models',
708
+ models: reg.getAll(),
709
+ current: toModelRef(session.model),
710
+ availableIds,
711
+ scopedModels: session.scopedModels.map((s) => ({
712
+ model: s.model,
713
+ thinkingLevel: s.thinkingLevel,
714
+ })),
715
+ enabledModelIds: session.settingsManager.getEnabledModels() ?? null,
716
+ };
717
+ };
718
+ const buildScopedModelsData = (id) => ({
719
+ type: 'data',
720
+ id,
721
+ kind: 'list_scoped_models',
722
+ allModels: session.modelRegistry.getAll(),
723
+ enabledModelIds: session.settingsManager.getEnabledModels() ?? null,
724
+ });
725
+ const buildGetTreeData = (id) => {
726
+ const sm = session.sessionManager;
727
+ const forkPoints = session.getUserMessagesForForking().map((u) => ({
728
+ id: u.entryId,
729
+ text: u.text,
730
+ timestamp: sm.getEntry(u.entryId)?.timestamp,
731
+ }));
732
+ return {
733
+ type: 'data',
734
+ id,
735
+ kind: 'get_tree',
736
+ tree: sm.getTree(),
737
+ currentLeafId: sm.getLeafId(),
738
+ forkPoints,
739
+ };
740
+ };
741
+ const buildSettingsData = (id) => {
742
+ const sm = session.settingsManager;
743
+ // Theme names are enumerable broker-side via the resource loader. The theme
744
+ // SUBMENU itself is viewer-local (theme is a viewer-only concern), but the
745
+ // settings menu still shows currentTheme/availableThemes, so include them.
746
+ let availableThemes = [];
747
+ try {
748
+ availableThemes = session.resourceLoader
749
+ .getThemes()
750
+ .themes.map((t) => t.name ?? '')
751
+ .filter((n) => n !== '');
752
+ }
753
+ catch {
754
+ /* loader without themes — viewer falls back to its local theme registry */
755
+ }
756
+ const settings = {
757
+ autoCompact: session.autoCompactionEnabled,
758
+ showImages: sm.getShowImages(),
759
+ imageWidthCells: sm.getImageWidthCells(),
760
+ autoResizeImages: sm.getImageAutoResize(),
761
+ blockImages: sm.getBlockImages(),
762
+ enableSkillCommands: sm.getEnableSkillCommands(),
763
+ steeringMode: session.steeringMode,
764
+ followUpMode: session.followUpMode,
765
+ transport: sm.getTransport(),
766
+ httpIdleTimeoutMs: sm.getHttpIdleTimeoutMs(),
767
+ thinkingLevel: session.thinkingLevel,
768
+ availableThinkingLevels: session.getAvailableThinkingLevels(),
769
+ currentTheme: sm.getTheme() ?? '',
770
+ availableThemes,
771
+ hideThinkingBlock: sm.getHideThinkingBlock(),
772
+ collapseChangelog: sm.getCollapseChangelog(),
773
+ enableInstallTelemetry: sm.getEnableInstallTelemetry(),
774
+ doubleEscapeAction: sm.getDoubleEscapeAction(),
775
+ treeFilterMode: sm.getTreeFilterMode(),
776
+ showHardwareCursor: sm.getShowHardwareCursor(),
777
+ editorPaddingX: sm.getEditorPaddingX(),
778
+ autocompleteMaxVisible: sm.getAutocompleteMaxVisible(),
779
+ quietStartup: sm.getQuietStartup(),
780
+ clearOnShrink: sm.getClearOnShrink(),
781
+ showTerminalProgress: sm.getShowTerminalProgress(),
782
+ warnings: sm.getWarnings(),
783
+ defaultProjectTrust: sm.getDefaultProjectTrust(),
784
+ autoRetry: session.autoRetryEnabled,
785
+ model: toModelRef(session.model),
786
+ };
787
+ return { type: 'data', id, kind: 'get_settings', settings };
788
+ };
789
+ const buildSessionsData = async (id, scope) => {
790
+ const sm = session.sessionManager;
791
+ const raw = scope === 'all'
792
+ ? await engine.SessionManager.listAll()
793
+ : await engine.SessionManager.list(sm.getCwd(), sm.getSessionDir());
794
+ const sessions = raw.map((s) => ({
795
+ ...s,
796
+ created: s.created.toISOString(),
797
+ modified: s.modified.toISOString(),
798
+ }));
799
+ return {
800
+ type: 'data',
801
+ id,
802
+ kind: 'list_sessions',
803
+ scope,
804
+ sessions,
805
+ currentSessionFile: session.sessionFile,
806
+ };
807
+ };
572
808
  /** Run a session-replacing op (new_session/switch_session/fork). The runtime
573
809
  * rebinds extensions + re-subscribes via setRebindSession before it resolves,
574
810
  * so on success we just re-snapshot every viewer onto the new session. */
@@ -594,9 +830,11 @@ export async function runBroker(nodeId) {
594
830
  case 'hello': {
595
831
  client.id = frame.client_id;
596
832
  client.helloed = true;
597
- // First-attach-wins controller (§5.3): admit as controller iff one was
598
- // requested and none is currently held; otherwise read-only observer.
599
- if (frame.role === 'controller' && controllerId === null) {
833
+ // First-attach-wins (§5.3), but only against a LIVE controller: admit as
834
+ // controller iff none is currently held by a live client (controllerClient
835
+ // is liveness-aware, so a controllerId stranded on a departed peer reads as
836
+ // free here). Otherwise read-only observer.
837
+ if (frame.role === 'controller' && controllerClient() === null) {
600
838
  client.role = 'controller';
601
839
  controllerId = client.id;
602
840
  }
@@ -631,19 +869,24 @@ export async function runBroker(nodeId) {
631
869
  break;
632
870
  }
633
871
  case 'request_control': {
634
- if (controllerId === null) {
635
- controllerId = client.id;
636
- client.role = 'controller';
637
- broadcastControlChanged();
638
- reroutePendingDialogsTo(client); // T4: hand the new controller pending dialogs
639
- }
640
- else {
641
- sendFrame(client, {
642
- type: 'error',
643
- code: 'control_held',
644
- message: 'another client holds control',
645
- });
646
- }
872
+ // §D preemptive handoff (last-requester-wins): a control request ALWAYS
873
+ // succeeds, reassigning control to the requester and demoting the prior
874
+ // controller to observer. This makes a tmux pane and a web tab true peers —
875
+ // either can take control of a node the other currently drives — which is
876
+ // the broker-is-the-host invariant (the prior cooperative-only model could
877
+ // not preempt an idle/abandoned controller, the common case). The prior
878
+ // controller demotes itself on receiving the control_changed broadcast
879
+ // (attach-cmd.ts already does this; the web client implements the same
880
+ // rule). Idempotent when the requester already holds control.
881
+ if (client.id === controllerId)
882
+ break;
883
+ const prior = controllerClient();
884
+ if (prior !== null)
885
+ prior.role = 'observer';
886
+ controllerId = client.id;
887
+ client.role = 'controller';
888
+ broadcastControlChanged();
889
+ reroutePendingDialogsTo(client); // T4: hand the new controller pending dialogs
647
890
  break;
648
891
  }
649
892
  case 'release_control': {
@@ -663,7 +906,9 @@ export async function runBroker(nodeId) {
663
906
  break;
664
907
  let model;
665
908
  try {
666
- model = findModelSpec(frame.model);
909
+ // Accept both an exact `provider/id` (from the picker) and a free-text
910
+ // query (`/model opus`) — resolveModelQuery falls back to a search match.
911
+ model = resolveModelQuery(frame.model);
667
912
  }
668
913
  catch (err) {
669
914
  // N2: registry.find should never throw on the real SDK, but a degenerate
@@ -675,17 +920,53 @@ export async function runBroker(nodeId) {
675
920
  sendFrame(client, {
676
921
  type: 'error',
677
922
  code: 'engine_error',
678
- message: `model '${frame.model}' not found in the registry`,
923
+ message: `no model matching '${frame.model}' in the registry`,
679
924
  });
680
925
  break;
681
926
  }
682
- void session.setModel(model).then(() => ackTo(client, 'set_model')).catch(engineErrorTo(client));
927
+ void session
928
+ .setModel(model)
929
+ .then(() => {
930
+ ackTo(client, 'set_model');
931
+ broadcastModelChanged();
932
+ })
933
+ .catch(engineErrorTo(client));
683
934
  break;
684
935
  }
685
936
  case 'cycle_model': {
686
937
  if (notController(client, 'cycle the model'))
687
938
  break;
688
- void session.cycleModel().then(() => ackTo(client, 'cycle_model')).catch(engineErrorTo(client));
939
+ void session
940
+ .cycleModel(frame.direction)
941
+ .then(() => {
942
+ ackTo(client, 'cycle_model');
943
+ broadcastModelChanged();
944
+ })
945
+ .catch(engineErrorTo(client));
946
+ break;
947
+ }
948
+ case 'cycle_thinking': {
949
+ if (notController(client, 'cycle the thinking level'))
950
+ break;
951
+ try {
952
+ session.cycleThinkingLevel();
953
+ ackTo(client, 'cycle_thinking');
954
+ }
955
+ catch (err) {
956
+ engineErrorTo(client)(err);
957
+ }
958
+ break;
959
+ }
960
+ case 'dequeue': {
961
+ if (notController(client, 'dequeue messages', frame.id))
962
+ break;
963
+ try {
964
+ const { steering, followUp } = session.clearQueue();
965
+ sendFrame(client, { type: 'data', id: frame.id, kind: 'dequeue', steering, followUp });
966
+ }
967
+ catch (err) {
968
+ engineErrorTo(client, frame.id)(err);
969
+ }
689
970
  break;
690
971
  }
691
972
  case 'set_thinking_level': {
@@ -761,12 +1042,68 @@ export async function runBroker(nodeId) {
761
1042
  }
762
1043
  break;
763
1044
  }
1045
+ // --- read-op picker data (§5 Unit A) ----------------------------------
1046
+ // NOT controller-gated: read-only, like get_commands. The web bridge's
1047
+ // observer connection populates pickers through these too.
1048
+ case 'list_models': {
1049
+ try {
1050
+ sendFrame(client, buildListModelsData(frame.id));
1051
+ }
1052
+ catch (err) {
1053
+ engineErrorTo(client, frame.id)(err);
1054
+ }
1055
+ break;
1056
+ }
1057
+ case 'list_scoped_models': {
1058
+ try {
1059
+ sendFrame(client, buildScopedModelsData(frame.id));
1060
+ }
1061
+ catch (err) {
1062
+ engineErrorTo(client, frame.id)(err);
1063
+ }
1064
+ break;
1065
+ }
1066
+ case 'get_tree': {
1067
+ try {
1068
+ sendFrame(client, buildGetTreeData(frame.id));
1069
+ }
1070
+ catch (err) {
1071
+ engineErrorTo(client, frame.id)(err);
1072
+ }
1073
+ break;
1074
+ }
1075
+ case 'get_settings': {
1076
+ try {
1077
+ sendFrame(client, buildSettingsData(frame.id));
1078
+ }
1079
+ catch (err) {
1080
+ engineErrorTo(client, frame.id)(err);
1081
+ }
1082
+ break;
1083
+ }
1084
+ case 'list_sessions': {
1085
+ // Session listing reads the session dir off disk — async; reply when it
1086
+ // resolves (or relay the error, correlated by id so the viewer doesn't hang).
1087
+ void buildSessionsData(frame.id, frame.scope ?? 'cwd')
1088
+ .then((d) => sendFrame(client, d))
1089
+ .catch(engineErrorTo(client, frame.id));
1090
+ break;
1091
+ }
764
1092
  case 'navigate_tree': {
765
1093
  if (notController(client, 'navigate the session tree'))
766
1094
  break;
1095
+ // navigateTree rewinds IN-PLACE (same session file, new leaf) and emits no
1096
+ // relayed event, so every viewer must be re-snapshotted onto the rewound
1097
+ // transcript — same reWelcomeAll the session-replacing ops use. The ack's
1098
+ // detail carries the navigated-to user message's text (pi parity: the
1099
+ // interactive tree navigator restores it to the editor for re-editing).
767
1100
  void session
768
1101
  .navigateTree(frame.targetId, frame.options)
769
- .then((r) => ackTo(client, 'navigate_tree', !r.cancelled))
1102
+ .then((r) => {
1103
+ if (!r.cancelled)
1104
+ reWelcomeAll();
1105
+ ackTo(client, 'navigate_tree', !r.cancelled, r.editorText);
1106
+ })
770
1107
  .catch(engineErrorTo(client));
771
1108
  break;
772
1109
  }
@@ -814,6 +1151,74 @@ export async function runBroker(nodeId) {
814
1151
  runReplacement(client, 'fork', (rt) => rt.fork(frame.entryId));
815
1152
  break;
816
1153
  }
1154
+ case 'clone': {
1155
+ if (notController(client, 'clone the session'))
1156
+ break;
1157
+ const sm = session.sessionManager;
1158
+ const leafId = sm.getLeafId();
1159
+ if (leafId === null) {
1160
+ ackTo(client, 'clone', false, 'no current leaf to clone from');
1161
+ break;
1162
+ }
1163
+ let newPath;
1164
+ try {
1165
+ newPath = sm.createBranchedSession(leafId);
1166
+ }
1167
+ catch (err) {
1168
+ engineErrorTo(client)(err);
1169
+ break;
1170
+ }
1171
+ if (newPath === undefined) {
1172
+ ackTo(client, 'clone', false, 'createBranchedSession returned no path');
1173
+ break;
1174
+ }
1175
+ runReplacement(client, 'clone', (rt) => rt.switchSession(newPath));
1176
+ break;
1177
+ }
1178
+ case 'share': {
1179
+ if (notController(client, 'share the session'))
1180
+ break;
1181
+ const tmpPath = join(tmpdir(), `pi-share-${Date.now()}.html`);
1182
+ void session.exportToHtml(tmpPath)
1183
+ .then(() => new Promise((resolve, reject) => {
1184
+ execFile('gh', ['gist', 'create', '--secret', tmpPath], { timeout: 30_000, maxBuffer: 1024 * 1024 }, (err, stdout) => {
1185
+ try {
1186
+ if (existsSync(tmpPath))
1187
+ unlinkSync(tmpPath);
1188
+ }
1189
+ catch { /* cleanup */ }
1190
+ if (err) {
1191
+ reject(err);
1192
+ return;
1193
+ }
1194
+ resolve(stdout.trim());
1195
+ });
1196
+ }))
1197
+ .then((url) => {
1198
+ ackTo(client, 'share', true, url);
1199
+ })
1200
+ .catch((err) => {
1201
+ const msg = String(err?.message ?? err);
1202
+ ackTo(client, 'share', false, msg.includes('not found') || msg.includes('gh: command not found') || msg.includes('ENOENT')
1203
+ ? '`gh` CLI not found — install GitHub CLI and authenticate with `gh auth login`'
1204
+ : `share failed: ${msg}`);
1205
+ });
1206
+ break;
1207
+ }
1208
+ case 'reload_auth': {
1209
+ if (notController(client, 'reload auth'))
1210
+ break;
1211
+ try {
1212
+ services.authStorage.reload();
1213
+ services.modelRegistry.refresh();
1214
+ ackTo(client, 'reload_auth');
1215
+ broadcastModelChanged();
1216
+ }
1217
+ catch (err) {
1218
+ engineErrorTo(client)(err);
1219
+ }
1220
+ break;
1221
+ }
817
1222
  case 'bye':
818
1223
  client.socket.end();
819
1224
  break;
@@ -907,17 +1312,36 @@ export async function runBroker(nodeId) {
907
1312
  }
908
1313
  }
909
1314
  // ---------------------------------------------------------------------------
1315
+ // snapshotMessages — the catch-up snapshot's ordered message history.
1316
+ //
1317
+ // The broker is the SOLE writer of the node's session `.jsonl`, so the session
1318
+ // manager's persisted tree (`buildSessionContext`) is the canonical, complete,
1319
+ // ordered history — byte-identical to what a dormant reader (crouter-web's static
1320
+ // normalizer) reconstructs from the same file. We serve THAT, NOT the agent's
1321
+ // live `session.messages` (`agent.state.messages`), because pi's recovery paths
1322
+ // mutate the live array away from the persisted history: auto-retry, context-
1323
+ // overflow recovery, and compaction each SLICE the errored/superseded assistant
1324
+ // message out of `state.messages` while DELIBERATELY keeping it on disk ("keep in
1325
+ // session for history", agent-session.js). So `session.messages` can OMIT — or, via
1326
+ // branch/compaction reshaping, reorder — a turn the `.jsonl` still holds. Pre-
1327
+ // close that drift is invisible (the live stream and the live array agree); after
1328
+ // a revive the welcome snapshot built from the reloaded array would diverge from
1329
+ // the on-disk history a dormant viewer just saw. Reconstructing from the session
1330
+ // manager makes the live snapshot == the persisted history (single source of
1331
+ // truth); the relayed live stream then continues from there.
1332
+ //
1333
+ // `AgentSession.sessionManager` is a public readonly field of the real pi SDK; the
1334
+ // fake-engine test fixture mirrors the same `sessionManager.buildSessionContext()`
1335
+ // surface, so this is a single path with no engine-capability fallback.
1336
+ export function snapshotMessages(session) {
1337
+ return session.sessionManager.buildSessionContext().messages;
1338
+ }
1339
+ // ---------------------------------------------------------------------------
910
1340
  // buildBrokerSession (plan T4 steps 2–4) — turn the launch recipe into a live
911
1341
  // engine session via the pi SDK SERVICES path. Exported so the C3/C4 real-SDK
912
1342
  // regression tests can drive the EXACT production wiring (not the mock).
913
1343
  // ---------------------------------------------------------------------------
914
1344
  export async function buildBrokerSession(engine, cfg) {
915
- // Fork-on-spawn is the runtime.fork() path (createAgentSessionRuntime), not
916
- // wired headless in Phase 3. Fail loud rather than silently mis-resume.
917
- if (cfg.forkFrom !== undefined && cfg.forkFrom !== '') {
918
- throw new Error(`[broker] --fork is not supported by the headless broker in Phase 3 ` +
919
- `(forkFrom=${cfg.forkFrom}); the SDK fork path is createAgentSessionRuntime.fork()`);
920
- }
921
1345
  // 2. Build cwd-bound runtime services via the SERVICES path (C3) — NOT plain
922
1346
  // createAgentSession. createAgentSessionServices builds + reloads the
923
1347
  // resource loader (the `-e` canvas extensions + --append-system-prompt) and
@@ -978,9 +1402,12 @@ export async function buildBrokerSession(engine, cfg) {
978
1402
  });
979
1403
  return { ...created, services };
980
1404
  };
981
- // 4. Open (resume) or create (fresh) the session — the broker is the sole writer.
1405
+ // 4. Select the SessionManager: FORK (spawn-time --fork), OPEN (resume), or
1406
+ // CREATE (fresh). The broker is the sole writer of the resulting .jsonl.
982
1407
  const resumePath = cfg.resumeSessionPath;
983
- if ((resumePath === undefined || resumePath === '') &&
1408
+ const forking = cfg.forkFrom !== undefined && cfg.forkFrom !== '';
1409
+ if (!forking &&
1410
+ (resumePath === undefined || resumePath === '') &&
984
1411
  cfg.resumeSessionId !== undefined &&
985
1412
  cfg.resumeSessionId !== '') {
986
1413
  // SessionManager.open() takes a FILE path — it only path-normalizes; it does
@@ -992,10 +1419,29 @@ export async function buildBrokerSession(engine, cfg) {
992
1419
  throw new Error(`[broker] resume requires a session .jsonl PATH; got only a bare id ` +
993
1420
  `'${cfg.resumeSessionId}' (pi_session_file was never captured for this node)`);
994
1421
  }
995
- const resuming = resumePath !== undefined && resumePath !== '';
996
- const sessionManager = resuming
997
- ? engine.SessionManager.open(resumePath)
998
- : engine.SessionManager.create(cfg.cwd);
1422
+ // A fork is a fresh spawn (resuming=false) so the kickoff firstPrompt fires: a
1423
+ // `node new --fork-from <id>` gives the new node the source's full history AND a
1424
+ // new task to start on. A resume replays the inbox instead and sends no kickoff.
1425
+ const resuming = !forking && resumePath !== undefined && resumePath !== '';
1426
+ let sessionManager;
1427
+ if (forking) {
1428
+ // Spawn-time fork via pi's REAL fork seam, SessionManager.forkFrom — the exact
1429
+ // method pi's own `--fork` CLI flag uses (main.js forkSessionOrExit). It writes
1430
+ // a NEW session file with a NEW id and a `parentSession` header pointing at the
1431
+ // source, then copies the source's history into it. NOT a naïve `.jsonl` copy
1432
+ // (which would collide session ids and drop the fork metadata), and NOT
1433
+ // runtime.fork() (which forks an entry WITHIN an already-loaded session — there
1434
+ // is no current session to fork at boot). The caller (spawn.ts resolveForkSource)
1435
+ // resolves cfg.forkFrom to an absolute source .jsonl before launch, so a bad ref
1436
+ // fails loudly here (forkFrom throws on an empty/invalid/header-less source).
1437
+ sessionManager = engine.SessionManager.forkFrom(cfg.forkFrom, cfg.cwd);
1438
+ }
1439
+ else if (resuming) {
1440
+ sessionManager = engine.SessionManager.open(resumePath);
1441
+ }
1442
+ else {
1443
+ sessionManager = engine.SessionManager.create(cfg.cwd);
1444
+ }
999
1445
  // When the engine exposes the replacement API (real SDK), wrap the builder in an
1000
1446
  // AgentSessionRuntime so new_session/switch_session/fork work; the runtime
1001
1447
  // re-invokes `buildForManager` for each new SessionManager. The fake-engine
@@ -1101,18 +1547,33 @@ export function makeBrokerUiContext(deps) {
1101
1547
  confirm: (title, message, opts) => dialogPromise(false, { type: 'extension_ui_request', id: randomUUID(), method: 'confirm', title, message, timeout: opts?.timeout }, (r) => ('cancelled' in r && r.cancelled ? false : 'confirmed' in r ? r.confirmed : false), opts),
1102
1548
  input: (title, placeholder, opts) => dialogPromise(undefined, { type: 'extension_ui_request', id: randomUUID(), method: 'input', title, placeholder, timeout: opts?.timeout }, (r) => ('cancelled' in r && r.cancelled ? undefined : 'value' in r ? r.value : undefined), opts),
1103
1549
  editor: (title, prefill) => dialogPromise(undefined, { type: 'extension_ui_request', id: randomUUID(), method: 'editor', title, prefill }, (r) => ('cancelled' in r && r.cancelled ? undefined : 'value' in r ? r.value : undefined)),
1550
+ // Non-blocking extension-UI relay (§5 Unit A task 3): broadcast these to all
1551
+ // viewers as display frames instead of dropping them. The viewer (Unit E) owns
1552
+ // the slots that render them. The other non-blocking methods stay inert.
1553
+ setStatus: (key, text) => deps.broadcast({ type: 'display_status', key, text }),
1554
+ setWidget: (key, content, options) => {
1555
+ // setWidget's component-factory overload cannot be serialized over the
1556
+ // socket (R1 caveat) — drop it and relay only the string[] form, exactly as
1557
+ // pi's own RPC setWidget carries only `widgetLines`.
1558
+ if (typeof content === 'function')
1559
+ return;
1560
+ deps.broadcast({
1561
+ type: 'display_widget',
1562
+ key,
1563
+ lines: content,
1564
+ placement: options?.placement ?? 'aboveEditor',
1565
+ });
1566
+ },
1567
+ setTitle: (title) => deps.broadcast({ type: 'display_title', title }),
1104
1568
  // Inert print-mode surface (never reached by a Model hook in Phase 3).
1105
1569
  notify: noop,
1106
1570
  onTerminalInput: () => noop,
1107
- setStatus: noop,
1108
1571
  setWorkingMessage: noop,
1109
1572
  setWorkingVisible: noop,
1110
1573
  setWorkingIndicator: noop,
1111
1574
  setHiddenThinkingLabel: noop,
1112
- setWidget: noop,
1113
1575
  setFooter: noop,
1114
1576
  setHeader: noop,
1115
- setTitle: noop,
1116
1577
  custom: () => Promise.resolve(undefined),
1117
1578
  pasteToEditor: noop,
1118
1579
  setEditorText: noop,