@north-light/crouter 0.3.173 → 0.3.175

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 (674) hide show
  1. package/bin/runtime-selector.mjs +8 -1
  2. package/dist/api/__tests__/{client.test.js → serial/client.test.js} +2 -135
  3. package/dist/api/client.d.ts +12 -21
  4. package/dist/api/client.js +22 -31
  5. package/dist/api/dto/broker-ops.d.ts +158 -0
  6. package/dist/api/dto/broker-ops.js +6 -0
  7. package/dist/api/dto/crons.d.ts +6 -0
  8. package/dist/api/dto/human.d.ts +7 -1
  9. package/dist/api/dto/inbox.d.ts +1 -71
  10. package/dist/api/dto/inbox.js +1 -9
  11. package/dist/api/dto/nodes.d.ts +6 -7
  12. package/dist/api/dto/reviews.d.ts +1 -11
  13. package/dist/api/routes.d.ts +7 -4
  14. package/dist/api/routes.js +8 -5
  15. package/dist/build-root.js +3 -3
  16. package/dist/builtin-memory/04-base-worker.md +1 -4
  17. package/dist/builtin-memory/05-kinds/advisor/00-base.md +1 -5
  18. package/dist/builtin-memory/05-kinds/advisor/01-orchestrator.md +2 -7
  19. package/dist/builtin-memory/05-kinds/design/00-base.md +1 -6
  20. package/dist/builtin-memory/05-kinds/developer/00-base.md +1 -6
  21. package/dist/builtin-memory/05-kinds/explore/00-base.md +1 -4
  22. package/dist/builtin-memory/05-kinds/explore/01-orchestrator.md +1 -2
  23. package/dist/builtin-memory/05-kinds/general/00-base.md +1 -3
  24. package/dist/builtin-memory/05-kinds/plan/00-base.md +1 -6
  25. package/dist/builtin-memory/05-kinds/plan/reviewers/00-base.md +1 -2
  26. package/dist/builtin-memory/05-kinds/plan/reviewers/architecture-fit.md +1 -3
  27. package/dist/builtin-memory/05-kinds/plan/reviewers/code-smells.md +1 -3
  28. package/dist/builtin-memory/05-kinds/plan/reviewers/pattern-consistency.md +1 -3
  29. package/dist/builtin-memory/05-kinds/plan/reviewers/requirements-coverage.md +1 -3
  30. package/dist/builtin-memory/05-kinds/plan/reviewers/security.md +1 -4
  31. package/dist/builtin-memory/05-kinds/review/companion/00-base.md +2 -3
  32. package/dist/builtin-memory/05-kinds/spec/00-base.md +1 -2
  33. package/dist/builtin-memory/advisor/council.md +1 -6
  34. package/dist/builtin-memory/design.md +2 -7
  35. package/dist/builtin-memory/development.md +9 -14
  36. package/dist/builtin-memory/internal/marketplaces.md +2 -6
  37. package/dist/builtin-memory/internal/nodes-and-canvas.md +1 -1
  38. package/dist/builtin-memory/internal/plugins.md +2 -8
  39. package/dist/builtin-memory/internal/storage-tiers.md +1 -2
  40. package/dist/builtin-memory/testing.md +41 -0
  41. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/{provider-rotation.test.ts → serial/provider-rotation.test.ts} +27 -179
  42. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/claude-plugin-commands.ts +5 -15
  43. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/frontmatter-rules/index.ts +1 -28
  44. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/memory-slash-commands.ts +43 -88
  45. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.js +7 -1
  46. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +9 -1
  47. package/dist/cli.js +2 -2
  48. package/dist/clients/attach/__tests__/attach-remote-readonly.test.js +37 -131
  49. package/dist/clients/attach/__tests__/chat-view-snapshot-ordering.test.js +71 -152
  50. package/dist/clients/attach/__tests__/context-message.test.js +16 -174
  51. package/dist/clients/attach/__tests__/crtr-output.test.js +36 -2069
  52. package/dist/clients/attach/__tests__/diagram.test.js +2 -2
  53. package/dist/clients/attach/__tests__/edit-diff.test.js +0 -4
  54. package/dist/clients/attach/__tests__/file-review-focus.test.js +2 -1
  55. package/dist/clients/attach/__tests__/group-activity.test.js +40 -229
  56. package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.js +26 -76
  57. package/dist/clients/attach/__tests__/prompt-preview.test.js +4 -70
  58. package/dist/clients/attach/__tests__/ref-autocomplete.test.js +11 -125
  59. package/dist/clients/attach/__tests__/titled-editor-preview.test.js +14 -205
  60. package/dist/clients/attach/command.d.ts +2 -1
  61. package/dist/clients/attach/command.js +81 -76
  62. package/dist/clients/attach/input/capabilities.d.ts +27 -0
  63. package/dist/clients/attach/input/capabilities.js +96 -0
  64. package/dist/clients/attach/input/controller.d.ts +20 -182
  65. package/dist/clients/attach/input/controller.js +128 -537
  66. package/dist/clients/attach/input/overlay-owner.d.ts +41 -0
  67. package/dist/clients/attach/input/overlay-owner.js +199 -0
  68. package/dist/clients/attach/overlays/file-review.d.ts +0 -4
  69. package/dist/clients/attach/overlays/file-review.js +13 -5
  70. package/dist/clients/attach/overlays/graph.js +2 -10
  71. package/dist/clients/attach/render/chat-view.d.ts +4 -3
  72. package/dist/clients/attach/render/chat-view.js +39 -33
  73. package/dist/clients/attach/render/group-activity.d.ts +1 -0
  74. package/dist/clients/attach/render/group-activity.js +20 -13
  75. package/dist/clients/attach/render/group-recap.js +12 -3
  76. package/dist/clients/attach/session/connection.js +9 -15
  77. package/dist/clients/attach/session/editor-inventory.d.ts +6 -5
  78. package/dist/clients/attach/session/editor-inventory.js +7 -8
  79. package/dist/clients/attach/session/file-links.d.ts +13 -0
  80. package/dist/clients/attach/session/file-links.js +107 -0
  81. package/dist/clients/attach/session/frame.d.ts +3 -0
  82. package/dist/clients/attach/session/frame.js +10 -1
  83. package/dist/clients/attach/session/frames.js +2 -3
  84. package/dist/clients/attach/session/input-wiring.d.ts +4 -1
  85. package/dist/clients/attach/session/input-wiring.js +15 -14
  86. package/dist/clients/attach/session/mouse.d.ts +6 -1
  87. package/dist/clients/attach/session/mouse.js +8 -5
  88. package/dist/clients/attach/session/profile-files.js +18 -12
  89. package/dist/clients/attach/session/reconnect.js +1 -1
  90. package/dist/clients/attach/session/state-sync.js +1 -2
  91. package/dist/clients/attach/session/surface-host.d.ts +3 -2
  92. package/dist/clients/attach/session/surface-host.js +4 -4
  93. package/dist/clients/attach/slash/dispatch.d.ts +8 -31
  94. package/dist/clients/attach/slash/dispatch.js +34 -81
  95. package/dist/clients/attach/viewer.js +820 -860
  96. package/dist/clients/conversation/projection.d.ts +79 -0
  97. package/dist/clients/conversation/projection.js +341 -0
  98. package/dist/clients/inbox/__tests__/serial/inbox-controller.test.js +159 -0
  99. package/dist/clients/inbox/__tests__/serial/mount-panel.test.js +151 -0
  100. package/dist/clients/inbox/controller.d.ts +0 -4
  101. package/dist/clients/inbox/controller.js +0 -2
  102. package/dist/clients/inbox/review/__tests__/review-core.test.js +4 -293
  103. package/dist/clients/inbox/review/__tests__/teardown-notice.test.js +100 -0
  104. package/dist/clients/inbox/review/comments-client.d.ts +3 -2
  105. package/dist/clients/inbox/review/comments-client.js +12 -12
  106. package/dist/clients/inbox/review/companion-pane.d.ts +30 -0
  107. package/dist/clients/inbox/review/companion-pane.js +86 -0
  108. package/dist/clients/inbox/review/document-surface.d.ts +182 -0
  109. package/dist/clients/inbox/review/{document-region.js → document-surface.js} +252 -48
  110. package/dist/clients/inbox/review/frame.js +5 -4
  111. package/dist/clients/inbox/review/launch.d.ts +8 -8
  112. package/dist/clients/inbox/review/launch.js +52 -92
  113. package/dist/clients/inbox/review/nvim.d.ts +2 -0
  114. package/dist/clients/inbox/review/nvim.js +14 -12
  115. package/dist/clients/inbox/review/review-client.d.ts +12 -8
  116. package/dist/clients/inbox/review/review-client.js +46 -35
  117. package/dist/clients/inbox/review-adapter.d.ts +3 -10
  118. package/dist/clients/inbox/review-adapter.js +27 -19
  119. package/dist/clients/inbox/surface.d.ts +0 -4
  120. package/dist/clients/inbox/surface.js +0 -2
  121. package/dist/clients/surfaces/host.d.ts +11 -3
  122. package/dist/commands/__tests__/api-client-cold-start-diagnostic.test.js +7 -34
  123. package/dist/commands/__tests__/human.test.js +11 -322
  124. package/dist/commands/api-client.d.ts +5 -5
  125. package/dist/commands/api-client.js +6 -85
  126. package/dist/commands/canvas-browse.js +3 -29
  127. package/dist/commands/canvas-config.js +1 -1
  128. package/dist/commands/canvas-use.js +3 -3
  129. package/dist/commands/canvas.js +1 -1
  130. package/dist/commands/cron.js +5 -1
  131. package/dist/commands/human/queue.d.ts +0 -4
  132. package/dist/commands/human/queue.js +7 -40
  133. package/dist/commands/memory/list.d.ts +14 -0
  134. package/dist/commands/memory/list.js +35 -31
  135. package/dist/commands/memory/read.d.ts +7 -0
  136. package/dist/commands/memory/read.js +14 -8
  137. package/dist/commands/memory/write.js +13 -12
  138. package/dist/commands/node/bash.d.ts +1 -0
  139. package/dist/commands/node/bash.js +198 -0
  140. package/dist/commands/node/create.d.ts +23 -0
  141. package/dist/commands/node/create.js +207 -0
  142. package/dist/commands/node/inspect.d.ts +2 -0
  143. package/dist/commands/node/inspect.js +302 -0
  144. package/dist/commands/node/lifecycle.d.ts +4 -0
  145. package/dist/commands/node/lifecycle.js +415 -0
  146. package/dist/commands/node/message.d.ts +1 -0
  147. package/dist/commands/node/message.js +164 -0
  148. package/dist/commands/node/pyramid.d.ts +2 -0
  149. package/dist/commands/node/pyramid.js +79 -0
  150. package/dist/commands/node/subscription.d.ts +1 -0
  151. package/dist/commands/node/subscription.js +82 -0
  152. package/dist/commands/node/wait.d.ts +1 -0
  153. package/dist/commands/node/wait.js +159 -0
  154. package/dist/commands/node-lifecycle-revive.js +10 -57
  155. package/dist/commands/node.d.ts +0 -42
  156. package/dist/commands/node.js +11 -2065
  157. package/dist/commands/search.js +1 -1
  158. package/dist/commands/surface/node/cycle.d.ts +1 -0
  159. package/dist/commands/surface/node/cycle.js +83 -0
  160. package/dist/commands/surface/node/focus.d.ts +1 -0
  161. package/dist/commands/surface/node/focus.js +67 -0
  162. package/dist/commands/surface/node/id.d.ts +1 -0
  163. package/dist/commands/surface/node/id.js +38 -0
  164. package/dist/commands/surface/node/pin.d.ts +1 -0
  165. package/dist/commands/surface/node/pin.js +42 -0
  166. package/dist/commands/surface/node/placement.d.ts +46 -0
  167. package/dist/commands/surface/node/placement.js +124 -0
  168. package/dist/commands/surface-node.js +4 -1
  169. package/dist/commands/surface-reload.js +1 -2
  170. package/dist/commands/surface-tmux-spread.js +1 -1
  171. package/dist/commands/surface.js +5 -20
  172. package/dist/commands/sys/__tests__/setup-core.test.js +18 -271
  173. package/dist/commands/sys/config.js +6 -6
  174. package/dist/commands/sys/logs.js +1 -1
  175. package/dist/commands/sys/panels/broker-limits-panel.d.ts +17 -0
  176. package/dist/commands/sys/panels/broker-limits-panel.js +104 -0
  177. package/dist/commands/sys/panels/dependencies-panel.d.ts +14 -0
  178. package/dist/commands/sys/panels/dependencies-panel.js +80 -0
  179. package/dist/commands/sys/panels/keybindings-panel.d.ts +56 -0
  180. package/dist/commands/sys/panels/keybindings-panel.js +703 -0
  181. package/dist/commands/sys/panels/keys-panel.d.ts +16 -0
  182. package/dist/commands/sys/panels/keys-panel.js +60 -0
  183. package/dist/commands/sys/panels/models-panel.d.ts +77 -0
  184. package/dist/commands/sys/panels/models-panel.js +647 -0
  185. package/dist/commands/sys/panels/panel.d.ts +45 -0
  186. package/dist/commands/sys/panels/panel.js +137 -0
  187. package/dist/commands/sys/panels/plugins-panel.d.ts +16 -0
  188. package/dist/commands/sys/panels/plugins-panel.js +80 -0
  189. package/dist/commands/sys/panels/prefs-panel.d.ts +21 -0
  190. package/dist/commands/sys/panels/prefs-panel.js +85 -0
  191. package/dist/commands/sys/panels/provider-panel.d.ts +62 -0
  192. package/dist/commands/sys/panels/provider-panel.js +538 -0
  193. package/dist/commands/sys/settings-shell.d.ts +55 -0
  194. package/dist/commands/sys/settings-shell.js +400 -0
  195. package/dist/commands/sys/settings.d.ts +2 -0
  196. package/dist/commands/sys/settings.js +31 -0
  197. package/dist/commands/sys/setup-core.d.ts +3 -33
  198. package/dist/commands/sys/setup-core.js +27 -78
  199. package/dist/commands/sys/setup-settings-runner.d.ts +24 -0
  200. package/dist/commands/sys/setup-settings-runner.js +63 -0
  201. package/dist/commands/sys/setup-wizard.d.ts +26 -125
  202. package/dist/commands/sys/setup-wizard.js +209 -2473
  203. package/dist/commands/sys/setup.js +5 -7
  204. package/dist/commands/sys.js +3 -2
  205. package/dist/core/__tests__/broker-extension-canvas-db-boundary.test.js +50 -0
  206. package/dist/core/__tests__/broker-launch-candidates.test.js +20 -9
  207. package/dist/core/__tests__/broker-preflight.test.js +1 -1
  208. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +108 -27
  209. package/dist/core/__tests__/canvas-inbox-watcher.test.js +14 -0
  210. package/dist/core/__tests__/canvas.test.js +2 -36
  211. package/dist/core/__tests__/close.test.js +14 -3
  212. package/dist/core/__tests__/context-intro.test.js +32 -143
  213. package/dist/core/__tests__/cron-broker-capacity.test.js +22 -3
  214. package/dist/core/__tests__/daemon-boot.test.js +2 -20
  215. package/dist/core/__tests__/daemon-wedge.test.js +1 -1
  216. package/dist/core/__tests__/dead-node-policy-table.test.js +1 -18
  217. package/dist/core/__tests__/fixtures/fake-engine.d.ts +1 -1
  218. package/dist/core/__tests__/fixtures/fake-engine.js +2 -2
  219. package/dist/core/__tests__/fixtures/fake-pi-host.js +2 -2
  220. package/dist/core/__tests__/focuses.test.js +4 -98
  221. package/dist/core/__tests__/helpers/broker-clients.d.ts +0 -3
  222. package/dist/core/__tests__/helpers/broker-clients.js +0 -14
  223. package/dist/core/__tests__/helpers/harness.d.ts +0 -8
  224. package/dist/core/__tests__/helpers/harness.js +7 -87
  225. package/dist/core/__tests__/helpers/viewer-panes.js +1 -1
  226. package/dist/core/__tests__/inline-memory-refs.test.js +5 -134
  227. package/dist/core/__tests__/kickoff.test.js +1 -1
  228. package/dist/core/__tests__/memory-resolver-precedence.test.js +29 -1
  229. package/dist/core/__tests__/migration.test.js +35 -48
  230. package/dist/core/__tests__/on-read-dedup-resume.test.js +22 -12
  231. package/dist/core/__tests__/passive-subscription.test.js +26 -49
  232. package/dist/core/__tests__/preview-registry-sync.test.js +1 -2
  233. package/dist/core/__tests__/prune.test.js +94 -1
  234. package/dist/core/__tests__/review-model-floor.test.js +0 -32
  235. package/dist/core/__tests__/seam/broker-attach-multiclient.test.js +255 -0
  236. package/dist/core/__tests__/{full → seam}/broker-attach-stream.test.js +39 -7
  237. package/dist/core/__tests__/{full → seam}/broker-crash-teardown.test.js +30 -29
  238. package/dist/core/__tests__/{full → seam}/broker-pane-resolution.test.js +54 -5
  239. package/dist/core/__tests__/{full → seam}/broker-provider-retry.test.js +134 -82
  240. package/dist/core/__tests__/{full → seam}/broker-whip-interrupt.test.js +15 -4
  241. package/dist/core/__tests__/{full/idle-release-settlement.test.js → seam/dormancy-release.test.js} +69 -17
  242. package/dist/core/__tests__/{full/yield-message-transaction.test.js → seam/yield-refresh-transaction.test.js} +31 -9
  243. package/dist/core/__tests__/secrets.test.js +1 -7
  244. package/dist/core/__tests__/{broker-fork-seam.test.js → serial/broker-fork-seam.test.js} +58 -7
  245. package/dist/core/__tests__/{broker-sdk-wiring.test.js → serial/broker-sdk-wiring.test.js} +18 -162
  246. package/dist/core/__tests__/{broker-snapshot-history.test.js → serial/broker-snapshot-history.test.js} +9 -9
  247. package/dist/core/__tests__/{command-plugins.test.js → serial/command-plugins.test.js} +105 -15
  248. package/dist/core/__tests__/{deferred-no-wake.test.js → serial/deferred-no-wake.test.js} +23 -20
  249. package/dist/core/__tests__/{flagship-lifecycle.test.js → serial/flagship-lifecycle.test.js} +7 -7
  250. package/dist/core/__tests__/{host-teardown-process-group.test.js → serial/host-teardown-process-group.test.js} +6 -6
  251. package/dist/core/__tests__/{human-deliver-e2e.test.js → serial/human-deliver-e2e.test.js} +12 -12
  252. package/dist/core/__tests__/{live-mutation.test.js → serial/live-mutation.test.js} +5 -5
  253. package/dist/core/__tests__/{refresh-stall-recycle.test.js → serial/refresh-stall-recycle.test.js} +5 -14
  254. package/dist/core/__tests__/{revive.test.js → serial/revive.test.js} +15 -100
  255. package/dist/core/__tests__/{spawn-root.test.js → serial/spawn-root.test.js} +32 -15
  256. package/dist/core/__tests__/serial/subscription-delivery.test.js +126 -0
  257. package/dist/core/__tests__/serial/tmux-surface.test.js +256 -0
  258. package/dist/core/__tests__/{worktree.test.js → serial/worktree.test.js} +15 -15
  259. package/dist/core/__tests__/session-model.test.js +0 -18
  260. package/dist/core/__tests__/spawn-worktree-id-conflict.test.js +9 -0
  261. package/dist/core/__tests__/watchdog-abort-arms-retry.test.js +50 -0
  262. package/dist/core/broker-client/__tests__/transport-relay.test.js +15 -16
  263. package/dist/core/broker-client/__tests__/transport-socket.test.js +28 -0
  264. package/dist/core/broker-client/client.d.ts +2 -2
  265. package/dist/core/broker-client/client.js +4 -7
  266. package/dist/core/broker-client/transport-relay.d.ts +13 -19
  267. package/dist/core/broker-client/transport-relay.js +19 -34
  268. package/dist/core/broker-client/transport-socket.d.ts +2 -2
  269. package/dist/core/broker-client/transport-socket.js +9 -2
  270. package/dist/core/broker-client/transport.d.ts +7 -3
  271. package/dist/core/broker-client/transport.js +4 -4
  272. package/dist/core/canvas/__tests__/attention.test.js +1 -18
  273. package/dist/core/canvas/__tests__/remote-canvas-source.test.js +7 -64
  274. package/dist/core/canvas/__tests__/remote-transport.test.js +2 -3
  275. package/dist/core/canvas/__tests__/render-remote.test.js +7 -52
  276. package/dist/core/canvas/browse/app.d.ts +2 -17
  277. package/dist/core/canvas/browse/app.js +16 -88
  278. package/dist/core/canvas/browse/model.d.ts +0 -4
  279. package/dist/core/canvas/browse/model.js +1 -12
  280. package/dist/core/canvas/browse/render.js +6 -3
  281. package/dist/core/canvas/crons.d.ts +8 -5
  282. package/dist/core/canvas/crons.js +7 -6
  283. package/dist/core/canvas/extensions.d.ts +7 -3
  284. package/dist/core/canvas/extensions.js +16 -4
  285. package/dist/core/canvas/nav-model.d.ts +0 -4
  286. package/dist/core/canvas/nav-model.js +2 -14
  287. package/dist/core/canvas/node-order.d.ts +3 -0
  288. package/dist/core/canvas/node-order.js +12 -0
  289. package/dist/core/canvas/remote-canvas-source.d.ts +3 -6
  290. package/dist/core/canvas/remote-canvas-source.js +8 -15
  291. package/dist/core/command.js +11 -4
  292. package/dist/core/config.js +3 -1
  293. package/dist/core/events/envelope.js +1 -1
  294. package/dist/core/events/read.js +7 -21
  295. package/dist/core/events/source.d.ts +0 -2
  296. package/dist/core/events/source.js +0 -16
  297. package/dist/core/events/types.d.ts +1 -1
  298. package/dist/core/fault-classifier.d.ts +1 -1
  299. package/dist/core/feed/passive.d.ts +0 -11
  300. package/dist/core/feed/passive.js +1 -62
  301. package/dist/core/human/__tests__/{inbox-core.test.js → serial/inbox-core.test.js} +6 -6
  302. package/dist/core/keybindings/__tests__/resolve.test.js +1 -96
  303. package/dist/core/keybindings/background-bash.js +3 -4
  304. package/dist/core/keybindings/catalog.js +1 -1
  305. package/dist/core/memory/inline-ref-grammar.js +3 -3
  306. package/dist/core/memory-resolver.d.ts +11 -25
  307. package/dist/core/memory-resolver.js +51 -33
  308. package/dist/core/model-routes.d.ts +15 -0
  309. package/dist/core/model-routes.js +12 -0
  310. package/dist/core/pool-credential-store.js +1 -1
  311. package/dist/core/preview-registry.js +8 -9
  312. package/dist/core/profiles/__tests__/root-profile.test.js +4 -14
  313. package/dist/core/review/realize.d.ts +0 -13
  314. package/dist/core/review/realize.js +0 -40
  315. package/dist/core/review/types.d.ts +1 -1
  316. package/dist/core/runtime/__tests__/engine-route.test.js +2 -2
  317. package/dist/core/runtime/bearings.js +1 -11
  318. package/dist/core/runtime/broker/client-registry.d.ts +33 -0
  319. package/dist/core/runtime/broker/client-registry.js +164 -0
  320. package/dist/core/runtime/broker/daemon-ops.d.ts +19 -0
  321. package/dist/core/runtime/broker/daemon-ops.js +49 -0
  322. package/dist/core/runtime/broker/event-projection.d.ts +42 -0
  323. package/dist/core/runtime/broker/event-projection.js +178 -0
  324. package/dist/core/runtime/broker/fault-retry.d.ts +39 -0
  325. package/dist/core/runtime/broker/fault-retry.js +277 -0
  326. package/dist/core/runtime/broker/frame-dispatch.d.ts +238 -0
  327. package/dist/core/runtime/broker/frame-dispatch.js +1537 -0
  328. package/dist/core/runtime/broker/inbox.d.ts +22 -0
  329. package/dist/core/runtime/broker/inbox.js +167 -0
  330. package/dist/core/runtime/broker/passive.d.ts +16 -0
  331. package/dist/core/runtime/broker/passive.js +44 -0
  332. package/dist/core/runtime/broker/read-ops.d.ts +23 -0
  333. package/dist/core/runtime/broker/read-ops.js +150 -0
  334. package/dist/core/runtime/broker/rebind.d.ts +74 -0
  335. package/dist/core/runtime/broker/rebind.js +211 -0
  336. package/dist/core/runtime/broker/tool-groups.d.ts +39 -0
  337. package/dist/core/runtime/broker/tool-groups.js +244 -0
  338. package/dist/core/runtime/broker-extension-render.d.ts +11 -0
  339. package/dist/core/runtime/broker-extension-render.js +181 -0
  340. package/dist/core/runtime/broker-persona-guidance.d.ts +13 -0
  341. package/dist/core/runtime/broker-persona-guidance.js +58 -0
  342. package/dist/core/runtime/broker-protocol.d.ts +1 -1
  343. package/dist/core/runtime/broker-protocol.js +1 -1
  344. package/dist/core/runtime/broker.d.ts +5 -196
  345. package/dist/core/runtime/broker.js +276 -3059
  346. package/dist/core/runtime/canvas-extensions.js +11 -2
  347. package/dist/core/runtime/fault.js +1 -1
  348. package/dist/core/runtime/model-selection.d.ts +9 -0
  349. package/dist/core/runtime/model-selection.js +12 -0
  350. package/dist/core/runtime/model-swap.d.ts +3 -3
  351. package/dist/core/runtime/model-swap.js +33 -25
  352. package/dist/core/runtime/placement-tmux.d.ts +3 -14
  353. package/dist/core/runtime/placement-tmux.js +11 -80
  354. package/dist/core/runtime/placement.d.ts +4 -2
  355. package/dist/core/runtime/placement.js +7 -12
  356. package/dist/core/runtime/promote.d.ts +14 -1
  357. package/dist/core/runtime/promote.js +1 -1
  358. package/dist/core/runtime/reset.d.ts +1 -1
  359. package/dist/core/runtime/roadmap.js +1 -1
  360. package/dist/core/runtime/spawn.d.ts +8 -0
  361. package/dist/core/runtime/spawn.js +36 -6
  362. package/dist/core/runtime/stop-guard.d.ts +4 -3
  363. package/dist/core/runtime/stop-guard.js +36 -18
  364. package/dist/core/runtime/stop-signals.d.ts +4 -1
  365. package/dist/core/runtime/stream-watchdog.js +4 -5
  366. package/dist/core/runtime/structured-output.d.ts +15 -2
  367. package/dist/core/runtime/structured-output.js +35 -12
  368. package/dist/core/runtime/tmux-bindings.d.ts +25 -0
  369. package/dist/core/runtime/tmux-bindings.js +682 -0
  370. package/dist/core/runtime/tmux-chrome.d.ts +2 -1
  371. package/dist/core/runtime/tmux-chrome.js +3 -2
  372. package/dist/core/runtime/{tmux.d.ts → tmux-driver.d.ts} +18 -26
  373. package/dist/core/runtime/tmux-driver.js +598 -0
  374. package/dist/core/runtime/view-socket.d.ts +14 -0
  375. package/dist/core/runtime/view-socket.js +83 -0
  376. package/dist/core/session-model/session-state.d.ts +1 -2
  377. package/dist/core/session-model/session-state.js +5 -8
  378. package/dist/core/spawn.d.ts +1 -1
  379. package/dist/core/spawn.js +2 -2
  380. package/dist/core/substrate/gate.d.ts +1 -1
  381. package/dist/core/substrate/on-read-pure.d.ts +7 -0
  382. package/dist/core/substrate/on-read-pure.js +272 -0
  383. package/dist/core/termrender/termrender.js +32 -2
  384. package/dist/core/user-settings.d.ts +23 -1
  385. package/dist/core/user-settings.js +18 -1
  386. package/dist/daemon/api/__tests__/node-model-validation.test.js +134 -0
  387. package/dist/daemon/api/__tests__/review-terminal-conflict.test.js +98 -0
  388. package/dist/daemon/api/__tests__/{full → seam}/api-server.test.js +6 -48
  389. package/dist/daemon/api/__tests__/{full → seam}/b10-attach-modelauth.test.js +17 -44
  390. package/dist/daemon/api/__tests__/{full/push-parity.test.js → seam/leaf-api-parity.test.js} +61 -42
  391. package/dist/daemon/api/bridge.js +40 -81
  392. package/dist/daemon/api/handlers/broker-ops.d.ts +2 -0
  393. package/dist/daemon/api/handlers/broker-ops.js +383 -0
  394. package/dist/daemon/api/handlers/crons.js +9 -4
  395. package/dist/daemon/api/handlers/files.js +6 -7
  396. package/dist/daemon/api/handlers/inbox.js +2 -340
  397. package/dist/daemon/api/handlers/nodes.d.ts +10 -1
  398. package/dist/daemon/api/handlers/nodes.js +74 -39
  399. package/dist/daemon/api/handlers/review-comments.js +5 -1
  400. package/dist/daemon/api/handlers/reviews.js +0 -19
  401. package/dist/daemon/api/map.js +25 -4
  402. package/dist/daemon/api/router.js +2 -3
  403. package/dist/daemon/api/server.js +5 -7
  404. package/dist/daemon/cron-run.d.ts +4 -0
  405. package/dist/daemon/cron-run.js +30 -13
  406. package/dist/daemon/crtrd.d.ts +3 -58
  407. package/dist/daemon/crtrd.js +28 -1244
  408. package/dist/daemon/manage.js +0 -1
  409. package/dist/daemon/reconcilers/broker-supervision.d.ts +50 -0
  410. package/dist/daemon/reconcilers/broker-supervision.js +549 -0
  411. package/dist/daemon/reconcilers/cron-lane.d.ts +10 -0
  412. package/dist/daemon/reconcilers/cron-lane.js +32 -0
  413. package/dist/daemon/reconcilers/dormant-inbox.d.ts +18 -0
  414. package/dist/daemon/reconcilers/dormant-inbox.js +143 -0
  415. package/dist/daemon/reconcilers/storage-maintenance.d.ts +22 -0
  416. package/dist/daemon/reconcilers/storage-maintenance.js +213 -0
  417. package/dist/daemon/review/finish.js +2 -5
  418. package/dist/pi-extensions/__tests__/canvas-context-intro.test.js +34 -53
  419. package/dist/pi-extensions/__tests__/canvas-recap.test.js +16 -113
  420. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +72 -99
  421. package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.js +29 -12
  422. package/dist/pi-extensions/broker-local.d.ts +20 -0
  423. package/dist/pi-extensions/broker-local.js +89 -0
  424. package/dist/pi-extensions/canvas-context-intro.d.ts +3 -3
  425. package/dist/pi-extensions/canvas-context-intro.js +22 -11
  426. package/dist/pi-extensions/canvas-doc-substrate.js +17 -34
  427. package/dist/pi-extensions/canvas-goal-capture.js +7 -12
  428. package/dist/pi-extensions/canvas-inbox-watcher.js +124 -139
  429. package/dist/pi-extensions/canvas-passive-context.d.ts +3 -3
  430. package/dist/pi-extensions/canvas-passive-context.js +22 -18
  431. package/dist/pi-extensions/canvas-recap.d.ts +2 -2
  432. package/dist/pi-extensions/canvas-recap.js +48 -21
  433. package/dist/pi-extensions/canvas-review-boundary.js +10 -4
  434. package/dist/pi-extensions/canvas-stophook.d.ts +0 -5
  435. package/dist/pi-extensions/canvas-stophook.js +149 -431
  436. package/dist/pi-extensions/canvas-structured-output.js +52 -30
  437. package/dist/pi-extensions/envelope-merge.d.ts +13 -0
  438. package/dist/pi-extensions/envelope-merge.js +29 -0
  439. package/dist/shared/working-activity.d.ts +3 -2
  440. package/dist/shared/working-activity.js +3 -2
  441. package/dist/types.d.ts +6 -5
  442. package/dist/types.js +2 -2
  443. package/docs/compat/hearth-crtr-v1.md +4 -12
  444. package/docs/compat/hearth-crtr-v2.md +3 -11
  445. package/docs/compat/hearth-crtr-v3.md +4 -12
  446. package/docs/compat/hearth-crtr-v4.md +3 -11
  447. package/docs/compat/hearth-crtr-v5.md +3 -11
  448. package/docs/public-api.md +3 -7
  449. package/package.json +7 -49
  450. package/runtime.lock.json +2766 -14533
  451. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/claude-plugin-commands.test.ts +0 -49
  452. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/memory-slash-commands.test.ts +0 -194
  453. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/statusline.test.ts +0 -68
  454. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/frontmatter-rules/test.ts +0 -119
  455. package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +0 -439
  456. package/dist/clients/attach/__tests__/attach-keybindings.test.js +0 -170
  457. package/dist/clients/attach/__tests__/crtr-output-coverage.test.js +0 -57
  458. package/dist/clients/attach/__tests__/editor-frame-title.test.js +0 -19
  459. package/dist/clients/attach/__tests__/input-controller-extension-command.test.js +0 -32
  460. package/dist/clients/attach/__tests__/markdown-headings.test.js +0 -35
  461. package/dist/clients/attach/__tests__/session-identity-refresh.test.js +0 -32
  462. package/dist/clients/conversation/conversation.d.ts +0 -65
  463. package/dist/clients/conversation/conversation.js +0 -318
  464. package/dist/clients/conversation/frames.d.ts +0 -30
  465. package/dist/clients/conversation/frames.js +0 -52
  466. package/dist/clients/conversation/region.d.ts +0 -51
  467. package/dist/clients/conversation/region.js +0 -265
  468. package/dist/clients/inbox/__tests__/inbox-controller.test.js +0 -247
  469. package/dist/clients/inbox/__tests__/mount-panel.test.js +0 -776
  470. package/dist/clients/inbox/review/__tests__/remap.test.js +0 -196
  471. package/dist/clients/inbox/review/columns.d.ts +0 -22
  472. package/dist/clients/inbox/review/columns.js +0 -40
  473. package/dist/clients/inbox/review/composition.d.ts +0 -60
  474. package/dist/clients/inbox/review/composition.js +0 -278
  475. package/dist/clients/inbox/review/document-region.d.ts +0 -120
  476. package/dist/clients/surfaces/region.d.ts +0 -16
  477. package/dist/clients/web/__tests__/push-engine.test.js +0 -158
  478. package/dist/clients/web/__tests__/push-registry.test.d.ts +0 -1
  479. package/dist/clients/web/__tests__/push-registry.test.js +0 -177
  480. package/dist/clients/web/dev-server.d.ts +0 -7
  481. package/dist/clients/web/dev-server.js +0 -46
  482. package/dist/clients/web/events.d.ts +0 -19
  483. package/dist/clients/web/events.js +0 -163
  484. package/dist/clients/web/push-engine.d.ts +0 -44
  485. package/dist/clients/web/push-engine.js +0 -125
  486. package/dist/clients/web/push-registry.d.ts +0 -85
  487. package/dist/clients/web/push-registry.js +0 -373
  488. package/dist/clients/web/server.d.ts +0 -19
  489. package/dist/clients/web/server.js +0 -661
  490. package/dist/clients/web/web-client/shared/protocol.d.ts +0 -214
  491. package/dist/clients/web/web-client/shared/protocol.js +0 -8
  492. package/dist/clients/web/web-cmd.d.ts +0 -2
  493. package/dist/clients/web/web-cmd.js +0 -141
  494. package/dist/commands/__tests__/api-canvas-source.test.d.ts +0 -1
  495. package/dist/commands/__tests__/api-canvas-source.test.js +0 -37
  496. package/dist/commands/__tests__/canvas-config.test.d.ts +0 -1
  497. package/dist/commands/__tests__/canvas-config.test.js +0 -107
  498. package/dist/commands/__tests__/node-no-kickoff-flag.test.d.ts +0 -1
  499. package/dist/commands/__tests__/node-no-kickoff-flag.test.js +0 -73
  500. package/dist/commands/__tests__/node-worktree-flag.test.d.ts +0 -1
  501. package/dist/commands/__tests__/node-worktree-flag.test.js +0 -49
  502. package/dist/commands/__tests__/search-contents.test.d.ts +0 -1
  503. package/dist/commands/__tests__/search-contents.test.js +0 -52
  504. package/dist/commands/memory/__tests__/lint.test.d.ts +0 -1
  505. package/dist/commands/memory/__tests__/lint.test.js +0 -15
  506. package/dist/commands/sys/__tests__/config-broker-thresholds.test.d.ts +0 -1
  507. package/dist/commands/sys/__tests__/config-broker-thresholds.test.js +0 -95
  508. package/dist/commands/sys/__tests__/config-keybindings.test.d.ts +0 -1
  509. package/dist/commands/sys/__tests__/config-keybindings.test.js +0 -55
  510. package/dist/commands/sys/__tests__/helpers/setup-runtime.d.ts +0 -5
  511. package/dist/commands/sys/__tests__/helpers/setup-runtime.js +0 -19
  512. package/dist/commands/sys/__tests__/setup-front-door.test.d.ts +0 -1
  513. package/dist/commands/sys/__tests__/setup-front-door.test.js +0 -102
  514. package/dist/commands/sys/__tests__/setup-models.test.d.ts +0 -1
  515. package/dist/commands/sys/__tests__/setup-models.test.js +0 -205
  516. package/dist/commands/sys/__tests__/setup-providers-viewport.test.d.ts +0 -1
  517. package/dist/commands/sys/__tests__/setup-providers-viewport.test.js +0 -155
  518. package/dist/commands/sys/__tests__/sync-deps.test.d.ts +0 -1
  519. package/dist/commands/sys/__tests__/sync-deps.test.js +0 -179
  520. package/dist/commands/sys/__tests__/sync-import.test.d.ts +0 -1
  521. package/dist/commands/sys/__tests__/sync-import.test.js +0 -315
  522. package/dist/core/__tests__/base-worker-prompt.test.d.ts +0 -1
  523. package/dist/core/__tests__/base-worker-prompt.test.js +0 -57
  524. package/dist/core/__tests__/command-plugins-surfaces.test.d.ts +0 -1
  525. package/dist/core/__tests__/command-plugins-surfaces.test.js +0 -359
  526. package/dist/core/__tests__/connection-reconnect-resume.test.d.ts +0 -1
  527. package/dist/core/__tests__/connection-reconnect-resume.test.js +0 -36
  528. package/dist/core/__tests__/finalization-migration.test.d.ts +0 -1
  529. package/dist/core/__tests__/finalization-migration.test.js +0 -38
  530. package/dist/core/__tests__/fixtures/extra-extension.d.ts +0 -1
  531. package/dist/core/__tests__/fixtures/extra-extension.js +0 -3
  532. package/dist/core/__tests__/fixtures/spawn-proof-target.test.d.ts +0 -1
  533. package/dist/core/__tests__/fixtures/spawn-proof-target.test.js +0 -15
  534. package/dist/core/__tests__/fork.test.d.ts +0 -1
  535. package/dist/core/__tests__/fork.test.js +0 -108
  536. package/dist/core/__tests__/full/broker-attach-limits.test.d.ts +0 -1
  537. package/dist/core/__tests__/full/broker-attach-limits.test.js +0 -147
  538. package/dist/core/__tests__/full/broker-deliver-ack.test.d.ts +0 -1
  539. package/dist/core/__tests__/full/broker-deliver-ack.test.js +0 -73
  540. package/dist/core/__tests__/full/broker-dialogs.test.d.ts +0 -1
  541. package/dist/core/__tests__/full/broker-dialogs.test.js +0 -180
  542. package/dist/core/__tests__/full/broker-display-snapshot.test.d.ts +0 -1
  543. package/dist/core/__tests__/full/broker-display-snapshot.test.js +0 -75
  544. package/dist/core/__tests__/full/broker-dormant-wake.test.d.ts +0 -1
  545. package/dist/core/__tests__/full/broker-dormant-wake.test.js +0 -130
  546. package/dist/core/__tests__/full/broker-model-changed-broadcast.test.d.ts +0 -1
  547. package/dist/core/__tests__/full/broker-model-changed-broadcast.test.js +0 -57
  548. package/dist/core/__tests__/full/broker-navigate-tree-rewelcome.test.d.ts +0 -1
  549. package/dist/core/__tests__/full/broker-navigate-tree-rewelcome.test.js +0 -80
  550. package/dist/core/__tests__/full/cascade-close.test.d.ts +0 -1
  551. package/dist/core/__tests__/full/cascade-close.test.js +0 -240
  552. package/dist/core/__tests__/full/detach-focus.test.d.ts +0 -1
  553. package/dist/core/__tests__/full/detach-focus.test.js +0 -126
  554. package/dist/core/__tests__/full/idle-release-settlement.test.d.ts +0 -1
  555. package/dist/core/__tests__/full/issue-146-yield-spawn-focus.test.d.ts +0 -1
  556. package/dist/core/__tests__/full/issue-146-yield-spawn-focus.test.js +0 -107
  557. package/dist/core/__tests__/full/park-clock-release.test.d.ts +0 -1
  558. package/dist/core/__tests__/full/park-clock-release.test.js +0 -56
  559. package/dist/core/__tests__/full/yield-message-transaction.test.d.ts +0 -1
  560. package/dist/core/__tests__/memory-index-render.test.d.ts +0 -1
  561. package/dist/core/__tests__/memory-index-render.test.js +0 -60
  562. package/dist/core/__tests__/model-routes.test.d.ts +0 -1
  563. package/dist/core/__tests__/model-routes.test.js +0 -41
  564. package/dist/core/__tests__/on-read-identity.test.d.ts +0 -1
  565. package/dist/core/__tests__/on-read-identity.test.js +0 -61
  566. package/dist/core/__tests__/phase4-review-migration.test.d.ts +0 -1
  567. package/dist/core/__tests__/phase4-review-migration.test.js +0 -100
  568. package/dist/core/__tests__/prune-to-limit.test.d.ts +0 -1
  569. package/dist/core/__tests__/prune-to-limit.test.js +0 -126
  570. package/dist/core/__tests__/reset.test.d.ts +0 -1
  571. package/dist/core/__tests__/reset.test.js +0 -69
  572. package/dist/core/__tests__/spawn-no-kickoff.test.d.ts +0 -1
  573. package/dist/core/__tests__/spawn-no-kickoff.test.js +0 -115
  574. package/dist/core/__tests__/steer-note.test.d.ts +0 -1
  575. package/dist/core/__tests__/steer-note.test.js +0 -41
  576. package/dist/core/__tests__/subcommand-tier.test.d.ts +0 -1
  577. package/dist/core/__tests__/subcommand-tier.test.js +0 -99
  578. package/dist/core/__tests__/subscription-delivery.test.js +0 -211
  579. package/dist/core/__tests__/test-runner-cheap-sharding.test.d.ts +0 -1
  580. package/dist/core/__tests__/test-runner-cheap-sharding.test.js +0 -97
  581. package/dist/core/__tests__/tmux-surface.test.js +0 -484
  582. package/dist/core/__tests__/wake-bearings.test.d.ts +0 -1
  583. package/dist/core/__tests__/wake-bearings.test.js +0 -102
  584. package/dist/core/canvas/__tests__/remote-event-stream.test.d.ts +0 -1
  585. package/dist/core/canvas/__tests__/remote-event-stream.test.js +0 -146
  586. package/dist/core/canvas/__tests__/source-resolve.test.d.ts +0 -1
  587. package/dist/core/canvas/__tests__/source-resolve.test.js +0 -77
  588. package/dist/core/canvas/browse/__tests__/model.test.d.ts +0 -1
  589. package/dist/core/canvas/browse/__tests__/model.test.js +0 -290
  590. package/dist/core/canvas/browse/__tests__/rebuild-coalescer.test.d.ts +0 -1
  591. package/dist/core/canvas/browse/__tests__/rebuild-coalescer.test.js +0 -134
  592. package/dist/core/canvas/browse/__tests__/render.test.d.ts +0 -1
  593. package/dist/core/canvas/browse/__tests__/render.test.js +0 -111
  594. package/dist/core/canvas/browse/rebuild-coalescer.d.ts +0 -7
  595. package/dist/core/canvas/browse/rebuild-coalescer.js +0 -61
  596. package/dist/core/canvas/remote-event-stream.d.ts +0 -24
  597. package/dist/core/canvas/remote-event-stream.js +0 -94
  598. package/dist/core/feed/__tests__/coalesce-human.test.d.ts +0 -1
  599. package/dist/core/feed/__tests__/coalesce-human.test.js +0 -36
  600. package/dist/core/feed/__tests__/empty-node-guard.test.d.ts +0 -1
  601. package/dist/core/feed/__tests__/empty-node-guard.test.js +0 -45
  602. package/dist/core/host-exports/__tests__/export-prunes-boot-skill.test.d.ts +0 -1
  603. package/dist/core/host-exports/__tests__/export-prunes-boot-skill.test.js +0 -84
  604. package/dist/core/human/__tests__/visible-paths.test.d.ts +0 -1
  605. package/dist/core/human/__tests__/visible-paths.test.js +0 -28
  606. package/dist/core/keybindings/__tests__/inbox-affordance.test.d.ts +0 -1
  607. package/dist/core/keybindings/__tests__/inbox-affordance.test.js +0 -38
  608. package/dist/core/review/__tests__/project.test.d.ts +0 -1
  609. package/dist/core/review/__tests__/project.test.js +0 -71
  610. package/dist/core/runtime/__tests__/command-expansion.test.d.ts +0 -1
  611. package/dist/core/runtime/__tests__/command-expansion.test.js +0 -25
  612. package/dist/core/runtime/__tests__/session-visibility.test.d.ts +0 -1
  613. package/dist/core/runtime/__tests__/session-visibility.test.js +0 -119
  614. package/dist/core/runtime/__tests__/spawn-at-given-id.test.d.ts +0 -1
  615. package/dist/core/runtime/__tests__/spawn-at-given-id.test.js +0 -34
  616. package/dist/core/runtime/tmux.js +0 -1314
  617. package/dist/daemon/api/__tests__/full/push-parity.test.d.ts +0 -1
  618. package/dist/daemon/api/__tests__/full/worktree-parity.test.d.ts +0 -1
  619. package/dist/daemon/api/__tests__/full/worktree-parity.test.js +0 -160
  620. package/dist/daemon/api/__tests__/inbox.test.d.ts +0 -1
  621. package/dist/daemon/api/__tests__/inbox.test.js +0 -489
  622. package/dist/web-client/apple-touch-icon.png +0 -0
  623. package/dist/web-client/assets/fragment-mono-latin-400-normal-BYwT3kSJ.woff +0 -0
  624. package/dist/web-client/assets/fragment-mono-latin-400-normal-yxdJ5AmL.woff2 +0 -0
  625. package/dist/web-client/assets/fragment-mono-latin-ext-400-normal-BbKYyvR9.woff2 +0 -0
  626. package/dist/web-client/assets/fragment-mono-latin-ext-400-normal-CT4YFKeK.woff +0 -0
  627. package/dist/web-client/assets/index-CCPaLeW7.css +0 -2
  628. package/dist/web-client/assets/index-kyOWsnZK.js +0 -86
  629. package/dist/web-client/assets/martian-mono-cyrillic-wght-normal-B84CD5C_.woff2 +0 -0
  630. package/dist/web-client/assets/martian-mono-latin-ext-wght-normal-DlL6xMw5.woff2 +0 -0
  631. package/dist/web-client/assets/martian-mono-latin-wght-normal-5W32yIyr.woff2 +0 -0
  632. package/dist/web-client/assets/workbox-window.prod.es5-Bd17z0YL.js +0 -2
  633. package/dist/web-client/icon-192.png +0 -0
  634. package/dist/web-client/icon-512.png +0 -0
  635. package/dist/web-client/icon-maskable-512.png +0 -0
  636. package/dist/web-client/index.html +0 -17
  637. package/dist/web-client/manifest.webmanifest +0 -1
  638. package/dist/web-client/sw.js +0 -1
  639. /package/dist/api/__tests__/{client.test.d.ts → serial/client.test.d.ts} +0 -0
  640. /package/dist/clients/inbox/__tests__/{inbox-controller.test.d.ts → serial/inbox-controller.test.d.ts} +0 -0
  641. /package/dist/clients/inbox/__tests__/{mount-panel.test.d.ts → serial/mount-panel.test.d.ts} +0 -0
  642. /package/dist/clients/{attach/__tests__/attach-chrome-remote.test.d.ts → inbox/review/__tests__/teardown-notice.test.d.ts} +0 -0
  643. /package/dist/{clients/attach/__tests__/attach-keybindings.test.d.ts → core/__tests__/broker-extension-canvas-db-boundary.test.d.ts} +0 -0
  644. /package/dist/{clients/attach/__tests__/crtr-output-coverage.test.d.ts → core/__tests__/seam/broker-attach-multiclient.test.d.ts} +0 -0
  645. /package/dist/core/__tests__/{full → seam}/broker-attach-stream.test.d.ts +0 -0
  646. /package/dist/core/__tests__/{full → seam}/broker-crash-teardown.test.d.ts +0 -0
  647. /package/dist/core/__tests__/{full → seam}/broker-pane-resolution.test.d.ts +0 -0
  648. /package/dist/core/__tests__/{full → seam}/broker-provider-retry.test.d.ts +0 -0
  649. /package/dist/core/__tests__/{full → seam}/broker-whip-interrupt.test.d.ts +0 -0
  650. /package/dist/{clients/attach/__tests__/editor-frame-title.test.d.ts → core/__tests__/seam/dormancy-release.test.d.ts} +0 -0
  651. /package/dist/{clients/attach/__tests__/input-controller-extension-command.test.d.ts → core/__tests__/seam/yield-refresh-transaction.test.d.ts} +0 -0
  652. /package/dist/core/__tests__/{broker-fork-seam.test.d.ts → serial/broker-fork-seam.test.d.ts} +0 -0
  653. /package/dist/core/__tests__/{broker-sdk-wiring.test.d.ts → serial/broker-sdk-wiring.test.d.ts} +0 -0
  654. /package/dist/core/__tests__/{broker-snapshot-history.test.d.ts → serial/broker-snapshot-history.test.d.ts} +0 -0
  655. /package/dist/core/__tests__/{command-plugins.test.d.ts → serial/command-plugins.test.d.ts} +0 -0
  656. /package/dist/core/__tests__/{deferred-no-wake.test.d.ts → serial/deferred-no-wake.test.d.ts} +0 -0
  657. /package/dist/core/__tests__/{flagship-lifecycle.test.d.ts → serial/flagship-lifecycle.test.d.ts} +0 -0
  658. /package/dist/core/__tests__/{host-teardown-process-group.test.d.ts → serial/host-teardown-process-group.test.d.ts} +0 -0
  659. /package/dist/core/__tests__/{human-deliver-e2e.test.d.ts → serial/human-deliver-e2e.test.d.ts} +0 -0
  660. /package/dist/core/__tests__/{live-mutation.test.d.ts → serial/live-mutation.test.d.ts} +0 -0
  661. /package/dist/core/__tests__/{refresh-stall-recycle.test.d.ts → serial/refresh-stall-recycle.test.d.ts} +0 -0
  662. /package/dist/core/__tests__/{revive.test.d.ts → serial/revive.test.d.ts} +0 -0
  663. /package/dist/core/__tests__/{spawn-root.test.d.ts → serial/spawn-root.test.d.ts} +0 -0
  664. /package/dist/core/__tests__/{subscription-delivery.test.d.ts → serial/subscription-delivery.test.d.ts} +0 -0
  665. /package/dist/core/__tests__/{tmux-surface.test.d.ts → serial/tmux-surface.test.d.ts} +0 -0
  666. /package/dist/core/__tests__/{worktree.test.d.ts → serial/worktree.test.d.ts} +0 -0
  667. /package/dist/{clients/attach/__tests__/markdown-headings.test.d.ts → core/__tests__/watchdog-abort-arms-retry.test.d.ts} +0 -0
  668. /package/dist/{clients/attach/__tests__/session-identity-refresh.test.d.ts → core/broker-client/__tests__/transport-socket.test.d.ts} +0 -0
  669. /package/dist/core/human/__tests__/{inbox-core.test.d.ts → serial/inbox-core.test.d.ts} +0 -0
  670. /package/dist/{clients/inbox/review/__tests__/remap.test.d.ts → daemon/api/__tests__/node-model-validation.test.d.ts} +0 -0
  671. /package/dist/{clients/surfaces/region.js → daemon/api/__tests__/review-terminal-conflict.test.d.ts} +0 -0
  672. /package/dist/daemon/api/__tests__/{full → seam}/api-server.test.d.ts +0 -0
  673. /package/dist/daemon/api/__tests__/{full → seam}/b10-attach-modelauth.test.d.ts +0 -0
  674. /package/dist/{clients/web/__tests__/push-engine.test.d.ts → daemon/api/__tests__/seam/leaf-api-parity.test.d.ts} +0 -0
@@ -17,63 +17,41 @@
17
17
  // engine seam — the fake engine receives the real, extension-loaded loader so
18
18
  // the real canvas-stophook fires session_start).
19
19
  import { createServer } from 'node:net';
20
- import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
21
- import { execFile } from 'node:child_process';
22
- import { tmpdir } from 'node:os';
20
+ import { existsSync, readFileSync, unlinkSync } from 'node:fs';
23
21
  import { join } from 'node:path';
24
22
  import { randomUUID } from 'node:crypto';
25
23
  import { mark } from '../timing.js';
26
24
  import { getAgentDir, initTheme, ModelRegistry, ModelRuntime, } from '@earendil-works/pi-coding-agent';
27
- import { isContextOverflow } from '@earendil-works/pi-ai';
28
- import { jobDir, nodeDir, sessionListCachePath, viewSocketPath } from '../canvas/paths.js';
25
+ import { nodeDir, viewSocketPath } from '../canvas/paths.js';
29
26
  import { bindBrokerEventSource } from '../events/source.js';
30
27
  import { emitEvent } from '../events/emit.js';
31
28
  import { operationIdContext } from '../events/operation-id.js';
32
29
  import { findMissingPackagePaths } from './package-health.js';
33
- import { SessionListCache } from './session-list-cache.js';
34
- import { getNode, updateNode } from '../canvas/index.js';
35
- import { readConfig } from '../config.js';
36
- import { atomicWriteJson, readJsonIfExists } from '../fs-utils.js';
37
- import { buildRefInventory } from '../memory/inline-ref-inventory.js';
38
- import { buildGuidance } from '../memory/inline-ref-guidance.js';
39
- import { clearSessionCache } from '../substrate/session-cache.js';
40
30
  import { FRONT_DOOR_ENV } from './front-door.js';
41
- import { CANVAS_EXTENSIONS, configuredLadderProviders, isProviderPinnedModelToken, nextLadderModel, normalizeModel, piInvocationToSdkConfig, } from './launch.js';
31
+ import { CANVAS_EXTENSIONS, piInvocationToSdkConfig, } from './launch.js';
42
32
  import { assertEngineVersion, loadBrokerEngine } from './broker-sdk.js';
43
33
  import { resolveBundledPiPackageDir } from './pi-cli.js';
44
- import { classify } from '../fault-classifier.js';
45
34
  import { PoolCredentialStore } from '../pool-credential-store.js';
46
- import { clearFault, isModelNotFoundError, isProviderUnconfiguredError, readFault, recordFault, recordFaultForCurrentNode } from './fault.js';
47
35
  import { clearCleanAbort, markBusy, markCleanAbort } from './busy.js';
48
- import { extractCoolingDeadline } from './managed-provider-cooling.js';
49
- import { AUTH_FAULT_RECOVERY_BODY, CONNECTION_FAULT_RECOVERY_BODY, PROVIDER_FAULT_RECOVERY_BODY, formatModelFallbackRecovery, generatedContextText, } from '../../shared/generated-context.js';
50
- import { assistantVisibleText, isTrueUserMessage, normalizeToolGroupSummaryRecord, } from '../../shared/tool-groups.js';
51
- import { headlessToolGroupSummary, serializeToolGroup } from './tool-group-summary.js';
52
- import { markFatalOnExhaust, nextFaultRetry, policyFor } from './fault-recovery.js';
36
+ import { recordFaultForCurrentNode } from './fault.js';
37
+ import { modelRequestFromConfig, selectInitialModelRoute, unregisteredConcreteModel } from '../model-routes.js';
38
+ import { KNOWN_STREAM_WAIT_UI } from './stream-watchdog.js';
53
39
  import { probeOnline as probeOnlineReal } from './connectivity.js';
54
- import { CRTR_CYCLE_CUSTOM_TYPE, cycleAwareMessages, withoutYieldAbort } from './session-cycles.js';
55
- import { boundaryRootedTree, REVIEW_BOUNDARY_CUSTOM_TYPE, visibleMessages } from './session-visibility.js';
56
- import { BUILTIN_SLASH_COMMANDS, piSessionsRoot } from './pi-vendored.js';
57
- import { StreamWatchdog, resolveStreamWatchdogMs, releaseKnownStreamWait, KNOWN_STREAM_WAIT_UI } from './stream-watchdog.js';
58
- import { isDeterministicCommandExpansion } from './command-expansion.js';
59
- import { DEFAULT_WORKING_ACTIVITY, randomWorkingActivity } from '../../shared/working-activity.js';
60
- import { isManagedProvider, readSubscriptionPool, resolveProviderCandidates } from '../subscription-state.js';
61
- import { expandModelCandidates, modelRequestFromConfig, probeRouteAvailability, selectInitialModelRoute } from '../model-routes.js';
62
- import { encodeFrame, FrameDecoder, FrameOverflowError, BROKER_READ_CAPS, } from './broker-protocol.js';
40
+ import { CRTR_CYCLE_CUSTOM_TYPE, cycleAwareMessages } from './session-cycles.js';
41
+ import { visibleMessages } from './session-visibility.js';
42
+ import { isUnknownModel } from './model-selection.js';
43
+ import { BrokerClientRegistry } from './broker/client-registry.js';
44
+ import { ToolGroupTracker } from './broker/tool-groups.js';
45
+ import { FaultRetry } from './broker/fault-retry.js';
46
+ import { EventProjection } from './broker/event-projection.js';
47
+ import { createFrameDispatchContext, handleFrame } from './broker/frame-dispatch.js';
48
+ import { RebindDriver } from './broker/rebind.js';
49
+ import { brokerExtensionState, commitBrokerModel } from './broker/daemon-ops.js';
50
+ import { FrameOverflowError, } from './broker-protocol.js';
63
51
  /** A review companion may only navigate or fork within a branch whose ancestry
64
52
  * has crossed its durable transcript boundary. The broker sends this typed local
65
53
  * failure as a dedicated error frame without invoking pi's tree mutation. */
66
- export const REVIEW_BOUNDARY_NAVIGATION_CODE = 'review_boundary_navigation';
67
- export class ReviewBoundaryNavigationError extends Error {
68
- targetId;
69
- code = REVIEW_BOUNDARY_NAVIGATION_CODE;
70
- constructor(targetId) {
71
- super(`review companion cannot navigate before the visible transcript boundary (target: ${targetId})`);
72
- this.targetId = targetId;
73
- this.name = 'ReviewBoundaryNavigationError';
74
- }
75
- }
76
- const THINKING_LEVELS = new Set(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']);
54
+ export { ReviewBoundaryNavigationError, REVIEW_BOUNDARY_NAVIGATION_CODE } from './broker/frame-dispatch.js';
77
55
  /**
78
56
  * pi 0.82 made `ModelRuntime` the canonical model/auth surface and demoted
79
57
  * `ModelRegistry` to the synchronous facade over it (`find`/`getAll`/
@@ -89,282 +67,6 @@ function registryOf(services) {
89
67
  function modelRuntimePaths(agentDir) {
90
68
  return { authPath: join(agentDir, 'auth.json'), modelsPath: join(agentDir, 'models.json') };
91
69
  }
92
- function parseModelSpec(spec) {
93
- const i = spec.lastIndexOf(':');
94
- if (i <= 0)
95
- return { modelSpec: spec };
96
- const suffix = spec.slice(i + 1);
97
- if (!THINKING_LEVELS.has(suffix))
98
- return { modelSpec: spec };
99
- return { modelSpec: spec.slice(0, i), thinkingLevel: suffix };
100
- }
101
- /**
102
- * The pinned pi SDK never yields `session.model === undefined` for a model-less
103
- * boot: Agent.createMutableAgentState() falls back to a hardcoded
104
- * `{ provider:'unknown', id:'unknown', api:'unknown', … }` stub (DEFAULT_MODEL)
105
- * whenever no model is supplied at construction, and `session.model` returns it
106
- * verbatim. This mirrors the SDK's own (non-exported) interactive-mode.js
107
- * `isUnknownModel()` so the broker recognizes that stub as "no model" wherever it
108
- * guards the model-less launch path (kickoff deferral, reload_auth re-drive) and
109
- * refuses to record/broadcast it as a real `unknown/unknown` model.
110
- */
111
- export function isUnknownModel(model) {
112
- return (model !== null &&
113
- model !== undefined &&
114
- model.provider === 'unknown' &&
115
- model.id === 'unknown' &&
116
- model.api === 'unknown');
117
- }
118
- /**
119
- * Route a writable client's `prompt`/`follow_up` frame against the LIVE session
120
- * state. The client picks its frame type off a possibly-STALE `isStreaming`
121
- * snapshot, so the broker is authoritative and the client's choice is a HINT:
122
- *
123
- * - m-B (streaming-safe prompt): a `prompt` arriving mid-stream needs
124
- * PromptOptions.streamingBehavior ("Required if streaming"; prompt() throws
125
- * without it). "steer" mirrors pi interactive-mode's own
126
- * Enter-while-streaming submit (`prompt(text, { streamingBehavior: "steer" })`).
127
- * - m-C (idle-safe follow_up): pi's followUp() only enqueues into a RUNNING
128
- * agent loop — on an idle session nothing ever drains the queue and the text
129
- * is silently lost. An idle `follow_up` therefore runs as a prompt now.
130
- *
131
- * driveEngine calls this synchronously with `session.isStreaming`, so the
132
- * routed state cannot go stale between the check and the engine call.
133
- * C1: images ride as a field on PromptOptions (pi: `prompt(text, { images })`).
134
- */
135
- export function resolveEngineRoute(frame, isStreaming) {
136
- if (frame.type === 'follow_up' && isStreaming)
137
- return { call: 'followUp' };
138
- const options = {};
139
- if (frame.images !== undefined)
140
- options.images = frame.images;
141
- if (isStreaming)
142
- options.streamingBehavior = 'steer';
143
- return { call: 'prompt', options };
144
- }
145
- /**
146
- * True iff `text` would be consumed by pi's OWN leading-name preflight before
147
- * `session.prompt/steer/followUp` ever sees it as prose — an extension command
148
- * (`session.extensionRunner.getCommand`), a `/skill:name` skill, or a `/name`
149
- * prompt template (mirrors `_tryExecuteExtensionCommand` / `_expandSkillCommand`
150
- * / `expandPromptTemplate` in the pinned SDK verbatim, including the exact
151
- * name-parsing substrings). Inline memory-reference guidance must NEVER fire
152
- * for these — the leading token routes to the engine's own command surface,
153
- * not prose referencing a memory doc. Each lookup is independently wrapped so
154
- * an engine missing one accessor (the fake, or a degraded session) degrades
155
- * that check to "not a match" rather than aborting the whole classification —
156
- * matching `buildCommandList`'s existing "no live extensionRunner" tolerance.
157
- */
158
- export function isLeadingEngineCommand(text, session) {
159
- if (text.startsWith('/')) {
160
- const spaceIndex = text.indexOf(' ');
161
- const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
162
- try {
163
- if (session.extensionRunner?.getCommand(commandName))
164
- return true;
165
- }
166
- catch {
167
- /* engine without a live extensionRunner (e.g. the fake) — not a match */
168
- }
169
- }
170
- if (text.startsWith('/skill:')) {
171
- const spaceIndex = text.indexOf(' ');
172
- const skillName = spaceIndex === -1 ? text.slice(7) : text.slice(7, spaceIndex);
173
- try {
174
- if (session.resourceLoader?.getSkills().skills.some((s) => s.name === skillName))
175
- return true;
176
- }
177
- catch {
178
- /* engine without a live resourceLoader — not a match */
179
- }
180
- }
181
- if (text.startsWith('/')) {
182
- const match = text.match(/^\/([^\s]+)(?:\s+([\s\S]*))?$/);
183
- const templateName = match?.[1];
184
- if (templateName !== undefined) {
185
- try {
186
- if (session.promptTemplates?.some((t) => t.name === templateName))
187
- return true;
188
- }
189
- catch {
190
- /* engine without a live promptTemplates getter — not a match */
191
- }
192
- }
193
- }
194
- return false;
195
- }
196
- /** A `steer` frame normally maps directly to pi's steer queue. Engine commands,
197
- * skills, and templates must instead pass through prompt()'s leading-command
198
- * preflight; with streamingBehavior:'steer', ordinary expanded text still lands
199
- * in the same queue. */
200
- export function resolveSteerCall(text, session) {
201
- return isLeadingEngineCommand(text, session) ? 'prompt' : 'steer';
202
- }
203
- /**
204
- * Enumerate every leading-token STRING this session's engine would
205
- * independently consume as a leading command/skill/template dispatch BEFORE
206
- * inline memory-reference guidance ever applies — the read-op sibling of
207
- * `isLeadingEngineCommand`'s point-lookup, gathered as a full enumeration for
208
- * viewer-side suppression instead of a per-submission membership check. Same
209
- * three sources, same shape of the leading match `isLeadingEngineCommand`
210
- * checks (bare extension-command invocation names and prompt-template names,
211
- * `skill:<name>` for skills), so `/${token}` round-trips through
212
- * `isLeadingEngineCommand(text, session)` for every emitted token (locked by
213
- * a test). Kept STRICTLY separate from `buildCommandList` (command
214
- * completion) and the ref inventory (`RefMeta` rows) — this is neither.
215
- */
216
- export function engineLeadingCommandTokens(session) {
217
- const out = [];
218
- try {
219
- for (const c of session.extensionRunner?.getRegisteredCommands() ?? [])
220
- out.push(c.invocationName);
221
- }
222
- catch {
223
- /* engine without a live extensionRunner (e.g. the fake) — none */
224
- }
225
- try {
226
- for (const s of session.resourceLoader?.getSkills().skills ?? [])
227
- out.push(`skill:${s.name}`);
228
- }
229
- catch {
230
- /* engine without a live resourceLoader — none */
231
- }
232
- try {
233
- for (const t of session.promptTemplates ?? [])
234
- out.push(t.name);
235
- }
236
- catch {
237
- /* engine without a live promptTemplates getter — none */
238
- }
239
- return out;
240
- }
241
- /**
242
- * Choose how the hidden `crtr-memory-refs` guidance custom message rides
243
- * alongside a human frame's own engine call (design Flow B). Pure over the
244
- * frame's routing outcome — never reads live session state itself, so a
245
- * caller can decide the delivery mode from the exact same `route`
246
- * `resolveEngineRoute` already computed for the human call.
247
- *
248
- * - `steer` frame -> 'steer' (always; no route call exists).
249
- * - a TRUE mid-stream follow_up (`routeCall === 'followUp'`) -> 'followUp'.
250
- * - every other `prompt`/`follow_up` routed as `routeCall === 'prompt'`:
251
- * idle -> 'nextTurn' (the ONLY case guidance precedes the human call —
252
- * pi's `prompt()` reads+clears its pending-nextTurn queue synchronously
253
- * into the SAME turn as the new user message); streaming (m-B
254
- * steer-while-streaming) -> 'steer', never 'nextTurn' (that would attach
255
- * to a later, unrelated turn).
256
- */
257
- export function chooseGuidanceDeliveryMode(frameType, routeCall, isStreaming) {
258
- if (frameType === 'steer')
259
- return 'steer';
260
- if (routeCall === 'followUp')
261
- return 'followUp';
262
- return isStreaming ? 'steer' : 'nextTurn';
263
- }
264
- /**
265
- * Deliver a `nextTurn` guidance custom message ATOMICALLY around the idle-
266
- * prompt call it's meant to ride alongside. Pi's `_pendingNextTurnMessages`
267
- * queue (the only public seam for `deliverAs:'nextTurn'`) is drained ONLY deep
268
- * inside `session.prompt()`, well after several await points — extension-
269
- * command interception, the `input` extension transform, a streaming reroute,
270
- * model/auth validation, pre-turn compaction — that can return or throw BEFORE
271
- * the drain ever runs. `sendCustomMessage`'s nextTurn branch pushes
272
- * synchronously and unconditionally, so any of those early exits strands the
273
- * pushed guidance in the queue to attach itself to whichever LATER, unrelated
274
- * prompt happens to reach the drain first.
275
- *
276
- * There is no public SDK seam to observe or cancel a pending nextTurn message
277
- * (`_pendingNextTurnMessages` is TS-`private` only — a plain runtime field, not
278
- * `#private`), so this reaches into that one field and removes EXACTLY the
279
- * entry this call pushed, identified by object identity via a locally-
280
- * generated marker riding the message's own `details` field (never surfaced to
281
- * the model, and irrelevant if the message WAS consumed normally — `details`
282
- * is just carried through like any other field once folded into a real turn).
283
- * Runs UNCONDITIONALLY after the human call settles, success or failure/early-
284
- * return alike — a "successful" `prompt()` promise can just as easily be an
285
- * early return that skipped the drain (an intercepted extension command, an
286
- * `input` transform, a streaming reroute) as a genuine consumed turn, so only
287
- * checking `.catch` is not enough.
288
- */
289
- export async function promptWithNextTurnGuidance(session, guidance, runPrompt) {
290
- // Must survive Pi's structured-clone step when the queued custom message is
291
- // folded into the turn. A Symbol reaches that step in `details` and aborts
292
- // the real prompt with "could not be cloned" before the provider runs.
293
- const marker = `crtr-memory-refs-pending:${randomUUID()}`;
294
- let queued = false;
295
- try {
296
- await session.sendCustomMessage({ customType: 'crtr-memory-refs', content: guidance, display: false, details: marker }, { deliverAs: 'nextTurn' });
297
- queued = true;
298
- }
299
- catch (err) {
300
- emitEvent({
301
- level: 'warn',
302
- event: 'broker.memory_refs.guidance_send_failed',
303
- error: err,
304
- });
305
- }
306
- try {
307
- await runPrompt();
308
- }
309
- finally {
310
- if (queued)
311
- clearOwnPendingNextTurnGuidance(session, marker);
312
- }
313
- }
314
- /** Splice out exactly the pending-nextTurn entry `marker` identifies (object-
315
- * identity match on `details`), if it is still present — a no-op (and safe)
316
- * if `session.prompt()` already drained it normally, or if the session/field
317
- * shape doesn't match (defensive against an engine without the field, e.g. a
318
- * future SDK bump or a structurally-different test double). */
319
- function clearOwnPendingNextTurnGuidance(session, marker) {
320
- const pending = session._pendingNextTurnMessages;
321
- if (!Array.isArray(pending))
322
- return;
323
- const idx = pending.findIndex((m) => m?.details === marker);
324
- if (idx !== -1)
325
- pending.splice(idx, 1);
326
- }
327
- /** True iff a parsed JSONL branch-entry line is a hidden (`display:false`)
328
- * custom message — the general display boundary crossed if left in an
329
- * exported branch (not just `crtr-memory-refs` guidance; any hidden custom
330
- * content). Covers both persisted shapes a branch entry can take: a
331
- * `type:'custom_message'` entry (display at the entry's own top level) and a
332
- * `type:'message'` entry wrapping a `role:'custom'` message (display nested
333
- * under `.message`). */
334
- export function isHiddenJsonlBranchEntry(entry) {
335
- if (entry === null || typeof entry !== 'object')
336
- return false;
337
- const e = entry;
338
- if (e.type === 'custom_message' && e.display === false)
339
- return true;
340
- if (e.type === 'message' && e.message?.role === 'custom' && e.message.display === false)
341
- return true;
342
- return false;
343
- }
344
- /** Rewrite an already-exported JSONL file in place, dropping every hidden
345
- * (`display:false`) branch entry while re-chaining `parentId` so the
346
- * remaining entries stay a single valid linear branch — applying the general
347
- * display:false boundary to `session.exportToJsonl`'s own output, which
348
- * serializes every entry verbatim regardless of display. The header line
349
- * (line 1) is copied through untouched. */
350
- export function stripHiddenEntriesFromJsonlExport(filePath) {
351
- const raw = readFileSync(filePath, 'utf8');
352
- const lines = raw.split('\n').filter((l) => l !== '');
353
- if (lines.length === 0)
354
- return;
355
- const [headerLine, ...entryLines] = lines;
356
- let prevId = null;
357
- const kept = [];
358
- for (const line of entryLines) {
359
- const entry = JSON.parse(line);
360
- if (isHiddenJsonlBranchEntry(entry))
361
- continue;
362
- kept.push(JSON.stringify({ ...entry, parentId: prevId }));
363
- if (typeof entry.id === 'string')
364
- prevId = entry.id;
365
- }
366
- writeFileSync(filePath, `${[headerLine, ...kept].join('\n')}\n`);
367
- }
368
70
  function formatModelSpec(model, thinkingLevel) {
369
71
  if (model === null || model === undefined || isUnknownModel(model))
370
72
  return null;
@@ -378,33 +80,9 @@ function formatExactModelSpec(model, thinkingLevel) {
378
80
  const base = formatModelSpec(model, undefined);
379
81
  return base === null ? null : `${base}:${thinkingLevel === undefined || thinkingLevel === '' ? 'off' : thinkingLevel}`;
380
82
  }
381
- function faultRetryProbe() {
382
- const mode = process.env['CRTR_FAULT_RETRY_PROBE'];
383
- if (mode === 'always')
384
- return async () => true;
385
- if (mode === 'never')
386
- return async () => false;
387
- return probeOnlineReal;
388
- }
389
83
  // ---------------------------------------------------------------------------
390
84
  // Tunables (T3 backpressure / T4 dialog anti-deadlock)
391
85
  // ---------------------------------------------------------------------------
392
- /** Per-viewer outbound high-water mark (M1). A viewer whose unflushed backlog
393
- * exceeds EITHER bound is DROPPED (socket destroyed) rather than allowed to
394
- * apply indefinite backpressure to the shared engine. Modeled on pi's RPC
395
- * output-guard: bound the queue, shed the slow viewer. */
396
- const MAX_QUEUED_FRAMES = 1000;
397
- const MAX_PENDING_BYTES = 32 * 1024 * 1024; // 32 MiB
398
- /** F2 (attach typing-lag fix, 2026-06-09): coalesce window for relayed
399
- * `message_update` frames. pi emits one per content delta and each carries the
400
- * FULL accumulated assistant message, so a verbatim relay re-serializes (and
401
- * every viewer re-parses + re-renders) the whole message per token — O(len²)
402
- * bytes over a long reply, and measured ~3× the viewer's render CPU vs 75 ms
403
- * coalescing. Only the LATEST pending update matters (each supersedes the
404
- * last), so the relay keeps one and flushes it on this timer — or immediately,
405
- * BEFORE any other event type, preserving ordering (an update never arrives
406
- * after its message_end). */
407
- const MESSAGE_UPDATE_COALESCE_MS = 75;
408
86
  /** Broker-side default dialog timeout (C2 anti-deadlock, T4). When an extension
409
87
  * dialog is forwarded to every writable client, the broker ALWAYS arms a timeout
410
88
  * (this default, or a shorter per-dialog `opts.timeout` if the extension passed
@@ -412,36 +90,6 @@ const MESSAGE_UPDATE_COALESCE_MS = 75;
412
90
  * — can never hang the agent turn forever. On fire it resolves to the SAFE default
413
91
  * (deny for confirm; cancel/undefined for select/input/editor). */
414
92
  const DEFAULT_DIALOG_TIMEOUT_MS = 120_000; // 120 s
415
- /** Admission bound for `runReplacement` (one active head + at most one queued
416
- * follow-up) and the mutation-through-rebind deadline each admitted head gets
417
- * once it becomes the tail's head. The SDK has no cancellation primitive for
418
- * newSession/switchSession/fork/bindExtensions, so an admitted replacement that
419
- * neither settles nor completes within its deadline leaves runtime/shared state
420
- * indeterminate — the only safe response is broker-terminal exit, never a retry
421
- * and never letting a queued replacement start against that uncertain state. */
422
- const MAX_ADMITTED_REPLACEMENTS = 2; // active + at most one queued
423
- const REPLACEMENT_DEADLINE_MS = 30_000;
424
- /** Locally branded sentinel `runReplacement`'s deadline race resolves to when
425
- * the budget elapses before the runtime promise settles — never compared via
426
- * string matching. */
427
- const REPLACEMENT_DEADLINE_EXCEEDED = Symbol('replacement-deadline-exceeded');
428
- const PENDING_TOOL_SUMMARY_KEY_MAX_CHARS = 1_000;
429
- const PENDING_TOOL_SUMMARY_SEGMENT_MAX_CHARS = 10_000;
430
- function normalizePendingToolGroupSummary(value) {
431
- if (value === null || typeof value !== 'object' || Array.isArray(value))
432
- return null;
433
- const record = value;
434
- if (Object.keys(record).sort().join(',') !== 'key,segment,version')
435
- return null;
436
- if (record.version !== 1 || typeof record.key !== 'string' || typeof record.segment !== 'string')
437
- return null;
438
- if (record.key.length === 0 ||
439
- record.key.length > PENDING_TOOL_SUMMARY_KEY_MAX_CHARS ||
440
- record.segment.length === 0 ||
441
- record.segment.length > PENDING_TOOL_SUMMARY_SEGMENT_MAX_CHARS)
442
- return null;
443
- return { version: 1, key: record.key, segment: record.segment };
444
- }
445
93
  // ---------------------------------------------------------------------------
446
94
  // M3 (scout mq5thyli): the active engine session, exposed so broker-cli's FATAL
447
95
  // handlers (uncaughtException / unhandledRejection / the runBroker reject) can
@@ -567,14 +215,14 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
567
215
  // Review identity is immutable for the broker's lifetime. Snapshot producers
568
216
  // close over this one construction-time classification rather than re-reading
569
217
  // canvas metadata for every welcome or session replacement.
570
- const reviewCompanion = getNode(nodeId)?.review_binding != null;
218
+ const reviewCompanion = (await brokerExtensionState(nodeId)).node.review_binding != null;
571
219
  // M3: name the boot session as the process-fatal disposal target immediately —
572
220
  // enqueueRebind reassigns this on every later replacement, but without this
573
221
  // initial assignment `activeSession` stays null (and disposeActiveSession() a
574
222
  // no-op) for every exit that happens before the FIRST session replacement,
575
223
  // which is the common case.
576
224
  activeSession = session;
577
- const retryProbe = faultRetryProbe();
225
+ const retryProbe = probeOnlineReal;
578
226
  // Initialize pi's process-global theme. The pi binary does this at boot
579
227
  // (main.js: initTheme(settingsManager.getTheme(), appMode === 'interactive')),
580
228
  // but the SDK convenience path the broker uses does NOT. Without it, ANY
@@ -621,2714 +269,278 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
621
269
  // survives a session replacement (new_session/switch_session/fork), not only at
622
270
  // boot.
623
271
  // -------------------------------------------------------------------------
624
- // Socket fan-out state
272
+ // Socket fan-out, dialogs, and transcript tool groups
625
273
  // -------------------------------------------------------------------------
626
274
  const sockPath = viewSocketPath(nodeId);
627
- const attachPath = join(jobDir(nodeId), 'attach.json');
628
- const clients = new Set();
275
+ const registry = new BrokerClientRegistry(nodeId);
629
276
  const pendingDialogs = new Map();
630
- // Fire-and-forget extension display state must survive a viewer detach. The
631
- // extension may paint a widget (notably the inactivity brief) while nobody is
632
- // attached; retain the latest keyed state so the next hello can replay it
633
- // immediately after welcome instead of making "persistent" mean "only if you
634
- // happened to be watching when the frame fired".
635
- const displayStatuses = new Map();
636
- const displayWidgets = new Map();
637
- let displayTitle;
638
- let workingActivity = DEFAULT_WORKING_ACTIVITY;
639
- const toolSummariesPath = join(jobDir(nodeId), 'tool-summaries.json');
640
- const pendingToolSummaryPath = join(jobDir(nodeId), 'pending-tool-summary.json');
641
- const toolGroupSummaries = new Map();
642
- try {
643
- const persisted = readJsonIfExists(toolSummariesPath);
644
- for (const [key, summary] of Object.entries(normalizeToolGroupSummaryRecord(persisted))) {
645
- toolGroupSummaries.set(key, summary);
646
- }
647
- }
648
- catch {
649
- // A malformed sidecar merely withholds summaries; transcript tools remain visible.
650
- }
651
- const summaryAttempted = new Set(toolGroupSummaries.keys());
652
- const generatingToolGroups = new Map();
653
- const toolGroupsByCallId = new Map();
654
- const summaryTasks = new Set();
655
- let openToolGroup;
656
- let lastToolGroupBoundaryProse = '';
657
- let sawAssistantProse = false;
277
+ const toolGroups = new ToolGroupTracker(nodeId, (frame) => registry.broadcast(frame));
658
278
  let disposed = false;
659
279
  let disposePending = false;
660
- let agentRunActive = false;
661
- let shutdownRequested = false;
662
280
  let server;
663
- // Every client whose `hello` claimed the writable role and whose transport is
664
- // still usable. Liveness matters because on a unix socket a peer destroy()
665
- // delivers EOF (readableEnded) promptly while the matching 'close' can lag
666
- // arbitrarily behind undrainable pending writes — a departed peer must not be
667
- // counted as an answerer a dialog fan-out waits on. Roles are FIXED per client,
668
- // so this is a pure filter, never an arbitration.
669
- const writableClients = () => {
670
- const out = [];
671
- for (const c of clients) {
672
- if (!c.helloed || c.role !== 'controller')
673
- continue;
674
- if (c.socket.destroyed || c.socket.readableEnded || !c.socket.writable)
675
- continue;
676
- out.push(c);
281
+ let uiContext;
282
+ let rebind;
283
+ let faultRetry;
284
+ let projection;
285
+ let notifyTurnAccepted = () => { };
286
+ let resetFrameState = () => { };
287
+ rebind = new RebindDriver({
288
+ nodeId, cfg, session, services, runtime, registry, toolGroups,
289
+ uiContext: () => uiContext,
290
+ faultRetry: () => faultRetry,
291
+ projection: () => projection,
292
+ setActiveSession: (candidate) => { activeSession = candidate; },
293
+ disposeAndExit: (reason, status) => disposeAndExit(reason, status),
294
+ onSessionRebind: () => resetFrameState(),
295
+ reWelcomeAll: () => reWelcomeAll(),
296
+ });
297
+ // Pi's model controls mutate only the live engine. Commit the concrete model
298
+ // and thinking choice through crtrd before acknowledging or broadcasting it,
299
+ // so the daemon remains the only durable canvas owner.
300
+ const persistModelChoice = async (pinnedOverride, userSelected = false) => {
301
+ const liveSession = rebind.session();
302
+ const spec = formatModelSpec(liveSession.model, liveSession.thinkingLevel);
303
+ if (spec === null)
304
+ return null;
305
+ const result = await commitBrokerModel(nodeId, { spec, pinnedOverride, userSelected });
306
+ if (userSelected) {
307
+ delete process.env['CRTR_MODEL_INTENT'];
308
+ cfg.model = result.modelOverride;
677
309
  }
678
- return out;
310
+ return result.modelOverride;
679
311
  };
680
- // Persist viewer presence to job/attach.json when the set of helloed viewers
681
- // changes (a hello is accepted, or a client drops/is shed) so out-of-process
682
- // readers (the GRAPH view's attached-row tint) can see whether a human is
683
- // watching this paneless node. Presence is a truthful COUNT and nothing more —
684
- // there is no owner to record. Plain writeFileSync, matching telemetry.json's
685
- // convention; best-effort — presence writing must never crash the broker.
686
- // disposeAndExit unlinks the file, so a clean exit never leaves a stale claim
687
- // (readers additionally trust it only while the node is 'active', fencing off
688
- // a crash-orphaned file).
689
- const persistAttachState = () => {
690
- try {
691
- let viewers = 0;
692
- for (const c of clients)
693
- if (c.helloed)
694
- viewers += 1;
695
- const dirPath = jobDir(nodeId);
696
- if (!existsSync(dirPath))
697
- mkdirSync(dirPath, { recursive: true });
698
- writeFileSync(attachPath, JSON.stringify({ viewers, updated: new Date().toISOString() }, null, 2), 'utf8');
699
- }
700
- catch {
701
- /* presence is best-effort; never crash the broker */
702
- }
312
+ // Pi emits no AgentSessionEvent for a model switch, so after a successful
313
+ // set_model/cycle_model the broker announces it itself. A durable change is
314
+ // broadcast only after crtrd returns the committed override.
315
+ const broadcastModelChanged = async (persist = true, pinnedOverride, userSelected = false) => {
316
+ const liveSession = rebind.session();
317
+ const liveSpec = formatModelSpec(liveSession.model, liveSession.thinkingLevel);
318
+ const spec = persist ? await persistModelChoice(pinnedOverride, userSelected) : liveSpec;
319
+ registry.broadcast({ type: 'model_changed', model: isUnknownModel(liveSession.model) ? undefined : liveSession.model, spec });
703
320
  };
704
- // Shed a client — destroy the socket + remove it — used by both the M1
705
- // backpressure drop and the G7 frame-overflow drop. 'close' (→ drop) follows
706
- // the destroy.
707
- const dropClient = (client, reason) => {
708
- if (!clients.has(client))
709
- return; // already gone
710
- // info, not debug: shedding a viewer (M1 backpressure / G7 frame overflow)
711
- // must be visible in the canonical log without CRTR_LOG=debug — debug-level
712
- // events are dropped by emitEvent outside that env, which silently hid
713
- // every shed.
714
- emitEvent({
715
- level: 'info',
716
- event: 'broker.runtime.diagnostic',
717
- fields: { message: `dropping viewer ${client.id || '(pre-hello)'} — ${reason}` },
321
+ faultRetry = new FaultRetry({
322
+ nodeId,
323
+ cfg,
324
+ retryProbe,
325
+ currentSession: () => rebind.session(),
326
+ currentServices: () => rebind.services(),
327
+ installedGeneration: () => rebind.installedGeneration(),
328
+ registryOf,
329
+ formatModelSpec,
330
+ broadcastModelChanged,
331
+ });
332
+ projection = new EventProjection({
333
+ registry,
334
+ faultRetry,
335
+ installedGeneration: () => rebind.installedGeneration(),
336
+ currentSession: () => rebind.session(),
337
+ notifyTurnAccepted: () => notifyTurnAccepted(),
338
+ emitStartupMilestone,
339
+ formatModelSpec,
340
+ refreshIntent: async () => (await brokerExtensionState(nodeId)).node.intent === 'refresh',
341
+ onRefreshIntentError: (error) => {
342
+ emitEvent({ level: 'error', event: 'broker.daemon_state.read_failed', error });
343
+ disposeAndExit('daemon-state-read-failed', 1);
344
+ },
345
+ });
346
+ const buildSnapshot = () => {
347
+ const liveSession = rebind.session();
348
+ return ({
349
+ messages: snapshotMessages(liveSession, reviewCompanion),
350
+ stats: liveSession.getSessionStats(),
351
+ // pi's `get_state` (rpc-mode.js), field for field, from the same getters.
352
+ // The ONE divergence: a model-less session (undefined OR the SDK
353
+ // unknown-stub) reports no model, matching broadcastModelChanged — never a
354
+ // fictitious `unknown` selection.
355
+ state: {
356
+ model: isUnknownModel(liveSession.model) ? undefined : liveSession.model,
357
+ thinkingLevel: liveSession.thinkingLevel,
358
+ isStreaming: liveSession.isStreaming,
359
+ isCompacting: liveSession.isCompacting,
360
+ steeringMode: liveSession.steeringMode,
361
+ followUpMode: liveSession.followUpMode,
362
+ sessionFile: liveSession.sessionFile,
363
+ sessionId: liveSession.sessionId,
364
+ sessionName: liveSession.sessionName,
365
+ autoCompactionEnabled: liveSession.autoCompactionEnabled,
366
+ messageCount: liveSession.messages.length,
367
+ pendingMessageCount: liveSession.pendingMessageCount,
368
+ },
369
+ // Broker-owned levels (see BrokerSnapshot.display). Copied out so a later
370
+ // mutation of the retained maps cannot alias a frame already serialized.
371
+ display: {
372
+ ...registry.displaySnapshot(),
373
+ },
374
+ toolGroupSummaries: toolGroups.summaries(),
375
+ workingActivity: projection.currentWorkingActivity(),
718
376
  });
719
- clients.delete(client);
720
- persistAttachState(); // a viewer was shed
721
- try {
722
- client.socket.destroy();
723
- }
724
- catch {
725
- /* ignore */
726
- }
727
377
  };
728
- // `bypassHwm` (F1): the catch-up `welcome` carries the full message history and
729
- // can legitimately be tens of MiB — far larger than a steady-state live frame.
730
- // It is still ACCOUNTED (so a genuinely stalled client that also floods on live
731
- // events is still shed by the NEXT frame's check), but it must never be the
732
- // REASON a healthy client is dropped before it has had a chance to drain it.
733
- const sendFrame = (client, frame, bypassHwm = false) => {
734
- let data;
735
- try {
736
- data = encodeFrame(frame);
737
- }
738
- catch {
739
- return; // an unserializable frame must never crash the broker
740
- }
741
- const bytes = Buffer.byteLength(data);
742
- // M1 high-water mark: a viewer that has fallen too far behind on draining its
743
- // socket is shed rather than allowed to grow the broker's memory unbounded.
744
- if (!bypassHwm &&
745
- (client.queuedFrames + 1 > MAX_QUEUED_FRAMES ||
746
- client.pendingBytes + bytes > MAX_PENDING_BYTES)) {
747
- dropClient(client, `backpressure high-water mark exceeded (queued=${client.queuedFrames}, pending=${client.pendingBytes}B)`);
378
+ // Send a client its catch-up snapshot. welcome.pending_dialog carries a single
379
+ // still-in-flight dialog to a WRITABLE client attaching mid-dialog; an observer
380
+ // can never answer one, so observers get null. The pendingDialogs map is
381
+ // insertion-ordered — the first entry is the canonical one carried here; any
382
+ // extras are replayed explicitly by the caller (rare: concurrent dialogs).
383
+ const sendWelcome = (client) => {
384
+ const first = client.role === 'controller' ? pendingDialogs.values().next().value : undefined;
385
+ registry.sendFrame(client, {
386
+ type: 'welcome',
387
+ snapshot: buildSnapshot(),
388
+ role: client.role,
389
+ pending_dialog: first !== undefined ? first.request : null,
390
+ agentDir: getAgentDir(),
391
+ }, true);
392
+ // No display_* replay: the retained extension state rides IN the snapshot
393
+ // (`snapshot.display`), so catch-up is one atomic frame. A viewer attaching
394
+ // after an idle brief was painted still sees it on first paint, and there is
395
+ // no window between `welcome` and the replay where chrome renders blank.
396
+ };
397
+ // Attach-mid-dialog replay: a dialog raised before this client attached is
398
+ // still pending (a peer detaching never cancels one — see makeBrokerUiContext),
399
+ // so a writable client joining afterwards is handed every in-flight dialog and
400
+ // becomes a full peer in the race to answer it. `welcome.pending_dialog` already
401
+ // carried the FIRST entry, so this replays only the extras.
402
+ const replayExtraPendingDialogsTo = (client) => {
403
+ const pend = [...pendingDialogs.values()];
404
+ for (let i = 1; i < pend.length; i++)
405
+ registry.sendFrame(client, pend[i].request);
406
+ };
407
+ // After a session-replacing op (new_session/switch_session/fork) the engine's
408
+ // entire message history changed, so every attached viewer must rebuild from a
409
+ // fresh snapshot of the NEW session.
410
+ const reWelcomeAll = () => {
411
+ registry.forEachHelloed(sendWelcome);
412
+ };
413
+ // -------------------------------------------------------------------------
414
+ // The single exit helper (plan §0): dispose the engine, close + unlink the
415
+ // socket, exit 0. Idempotent — every trigger converges here.
416
+ // -------------------------------------------------------------------------
417
+ const completeExit = (status) => {
418
+ if (disposed)
748
419
  return;
749
- }
750
- client.queuedFrames += 1;
751
- client.pendingBytes += bytes;
420
+ disposed = true;
421
+ // Daemon teardown cannot wait for a recap's 45-second model timeout. Preserve
422
+ // exactly the newest already-capped input so the successor broker can finish
423
+ // it, persist the normal summary, and remove the handoff record.
424
+ toolGroups.persistPendingSummary();
425
+ projection.dispose();
426
+ // disposeActiveSession atomically clears the process-global pointer before
427
+ // disposing — whichever engine session (committed or an in-flight rebind
428
+ // candidate) it currently names, matching broker-cli's fatal-handler primitive.
429
+ disposeActiveSession();
752
430
  try {
753
- // The per-write completion callback fires when THIS chunk is flushed to the
754
- // OS — a finer-grained drain signal than a socket-wide 'drain' event, and it
755
- // keeps the accounting exact. write()'s boolean return is the coarse
756
- // "buffer full" hint; the per-chunk callback is what we account on.
757
- client.socket.write(data, () => {
758
- client.queuedFrames -= 1;
759
- client.pendingBytes -= bytes;
760
- });
431
+ server?.close();
761
432
  }
762
433
  catch {
763
- // Dead socket: undo this frame's accounting; 'close' → drop() cleans the rest.
764
- client.queuedFrames -= 1;
765
- client.pendingBytes -= bytes;
766
- }
767
- };
768
- const broadcast = (frame) => {
769
- for (const c of clients)
770
- if (c.helloed)
771
- sendFrame(c, frame);
772
- };
773
- /** Retain and broadcast extension UI frames. The three display METHODS of pi's
774
- * `extension_ui_request` envelope are levels, so their last value is kept for
775
- * the welcome snapshot; updating a keyed entry moves it to the end, matching
776
- * pi's insertion-ordered widget behavior. Every other method (notify, the
777
- * blocking dialogs) simply passes through. */
778
- const broadcastUi = (frame) => {
779
- if (frame.type === 'extension_ui_request') {
780
- if (frame.method === 'setStatus') {
781
- displayStatuses.delete(frame.statusKey);
782
- if (frame.statusText !== undefined)
783
- displayStatuses.set(frame.statusKey, frame.statusText);
784
- }
785
- else if (frame.method === 'setWidget') {
786
- displayWidgets.delete(frame.widgetKey);
787
- if (frame.widgetLines !== undefined) {
788
- displayWidgets.set(frame.widgetKey, {
789
- lines: [...frame.widgetLines],
790
- placement: frame.widgetPlacement ?? 'aboveEditor',
791
- });
792
- }
793
- }
794
- else if (frame.method === 'setTitle') {
795
- displayTitle = frame.title;
796
- }
797
- }
798
- broadcast(frame);
799
- };
800
- /** Record and asynchronously summarize the maximal non-boundary region between
801
- * visible prose messages. A boundary can land while a tool is still running,
802
- * so closed groups retain their call ids until every result has joined the
803
- * same serialized segment; only then may they become collapsible. */
804
- const ensureOpenToolGroup = () => {
805
- if (openToolGroup === undefined) {
806
- openToolGroup = {
807
- toolCount: 0,
808
- pieces: [],
809
- boundaryProse: lastToolGroupBoundaryProse,
810
- pendingToolIds: new Set(),
811
- closed: false,
812
- summarizeWhenSettled: false,
813
- };
434
+ /* ignore */
814
435
  }
815
- return openToolGroup;
816
- };
817
- const clearPendingToolGroupSummary = (input) => {
436
+ // Destroy every viewer socket without waiting for close events.
437
+ registry.dispose();
818
438
  try {
819
- const persisted = normalizePendingToolGroupSummary(readJsonIfExists(pendingToolSummaryPath));
820
- if (persisted?.key === input.key && persisted.segment === input.segment)
821
- unlinkSync(pendingToolSummaryPath);
439
+ if (existsSync(sockPath))
440
+ unlinkSync(sockPath);
822
441
  }
823
442
  catch {
824
- // The durable summary is already authoritative. A stale handoff may cost a
825
- // no-op read on the next boot, but must not revoke the recap just written.
443
+ /* ignore */
826
444
  }
445
+ // A clean exit never leaves a stale presence claim behind.
446
+ registry.clearAttachState();
447
+ process.exit(status);
827
448
  };
828
- const generateToolGroupSummary = (input) => {
829
- const { key, segment } = input;
830
- if (summaryAttempted.has(key) || generatingToolGroups.has(key))
449
+ const disposeAndExit = (reason, status = 0) => {
450
+ if (disposed)
831
451
  return;
832
- summaryAttempted.add(key);
833
- generatingToolGroups.set(key, input);
834
- let task;
835
- task = headlessToolGroupSummary(segment)
836
- .then((summary) => {
837
- if (summary === null)
838
- return;
839
- toolGroupSummaries.set(key, summary);
840
- try {
841
- atomicWriteJson(toolSummariesPath, Object.fromEntries(toolGroupSummaries));
842
- }
843
- catch {
844
- // A delivery without durable state would disappear on reattach, so do
845
- // not advertise it. The calls remain folded normally instead.
846
- toolGroupSummaries.delete(key);
452
+ const graceful = reason === 'shutdown-hook' || reason === 'shutdown-after-settle';
453
+ // A graceful shutdown (yield, dormancy, daemon restart) is the final
454
+ // boundary of the conversation's last tool group. Group state lives only in
455
+ // this process, so a group left open here is unsummarizable forever and its
456
+ // calls render raw in every future view of the transcript. Close it now —
457
+ // generateSettledToolGroup registers its task synchronously, so the wait
458
+ // below covers the summary it launches. Any pending tool arguments are
459
+ // included without inventing a result; the visible abort remains outside
460
+ // the recap and Ctrl+O retains every underlying call row.
461
+ if (graceful)
462
+ toolGroups.finishOpenToolGroup(true);
463
+ // A normal terminal shutdown follows a visible boundary that may have just
464
+ // launched a capped (45s) headless summary. Keep the broker and its child
465
+ // alive until those already-started tasks persist/broadcast, rather than
466
+ // process.exit() orphaning pi and losing the sidecar. Abrupt failures and
467
+ // signals retain their existing immediate teardown behavior.
468
+ if (graceful && toolGroups.hasSummaryTasks()) {
469
+ if (disposePending)
847
470
  return;
848
- }
849
- clearPendingToolGroupSummary(input);
850
- broadcast({ type: 'tool_group_summary', key, summary });
851
- })
852
- .catch(() => undefined)
853
- .finally(() => {
854
- generatingToolGroups.delete(key);
855
- summaryTasks.delete(task);
856
- });
857
- summaryTasks.add(task);
858
- };
859
- const generateSettledToolGroup = (group) => {
860
- if (!group.closed || group.pendingToolIds.size > 0 || group.key === undefined || !group.summarizeWhenSettled)
471
+ disposePending = true;
472
+ void toolGroups.waitForSummaryTasks().finally(() => completeExit(status));
861
473
  return;
862
- generateToolGroupSummary({
863
- version: 1,
864
- key: group.key,
865
- segment: serializeToolGroup(group.boundaryProse, group.pieces),
866
- });
474
+ }
475
+ completeExit(status);
867
476
  };
477
+ // -------------------------------------------------------------------------
478
+ // Extension-dialog routing (C2). makeBrokerUiContext owns the dialogPromise;
479
+ // here we just give it the three broker-side hooks it needs: the live set of
480
+ // writable clients (empty = nobody can answer → noOp fallback), how to forward a
481
+ // dialog to one of them, and the pending-dialog registry whichever one answers
482
+ // first settles through. The zero-writable path NEVER hangs and NEVER waits on a
483
+ // per-dialog timeout (design §5.4's timeout premise is false — see
484
+ // makeBrokerUiContext).
485
+ // -------------------------------------------------------------------------
486
+ uiContext = makeBrokerUiContext({
487
+ writable: () => registry.writableClients(),
488
+ forward: (client, request) => registry.sendFrame(client, request),
489
+ pending: pendingDialogs,
490
+ broadcast: (frame) => registry.broadcastUi(frame),
491
+ // A deliberate managed-provider cooldown wait is known liveness, not a wedged
492
+ // stream: pause the dead-stream watchdog for its duration. Only pause for a
493
+ // finite future deadline, and resume ONLY while the same turn is still
494
+ // awaiting the provider (streaming, no tool running) so a wait that outlived
495
+ // its turn can't rearm a stale session; otherwise fully reset.
496
+ onKnownStreamWait: (deadlineMs) => projection.knownStreamWait(deadlineMs),
497
+ });
498
+ // Bind the rebind driver before accepting socket frames. It owns subscription
499
+ // replacement and its bounded runtime-mutation admission lane.
500
+ rebind.setRebindSession();
501
+ await rebind.enqueueRebind(session, services);
502
+ const frameDispatch = createFrameDispatchContext({
503
+ nodeId, cfg, registry, toolGroups, projection, rebind, reviewCompanion,
504
+ pendingDialogs, sendWelcome, replayExtraPendingDialogsTo, reWelcomeAll, disposeAndExit,
505
+ persistModelChoice, broadcastModelChanged, registryOf, formatModelSpec,
506
+ formatExactModelSpec, resolveLaunchModel,
507
+ });
508
+ notifyTurnAccepted = frameDispatch.notifyTurnAccepted;
509
+ resetFrameState = frameDispatch.resetForSession;
510
+ // -------------------------------------------------------------------------
511
+ // 4 (plan): the socket listener — unlink any stale socket first.
512
+ // -------------------------------------------------------------------------
868
513
  try {
869
- const pending = normalizePendingToolGroupSummary(readJsonIfExists(pendingToolSummaryPath));
870
- if (pending === null) {
871
- if (existsSync(pendingToolSummaryPath)) {
872
- unlinkSync(pendingToolSummaryPath);
873
- emitEvent({
874
- level: 'warn',
875
- event: 'broker.startup.warning',
876
- fields: { stage: 'pending_tool_summary', message: 'removed an invalid pending tool summary' },
877
- });
878
- }
879
- }
880
- else if (toolGroupSummaries.has(pending.key)) {
881
- unlinkSync(pendingToolSummaryPath);
882
- }
883
- else {
884
- generateToolGroupSummary(pending);
885
- }
514
+ if (existsSync(sockPath))
515
+ unlinkSync(sockPath);
886
516
  }
887
- catch (error) {
888
- emitEvent({
889
- level: 'warn',
890
- event: 'broker.startup.warning',
891
- fields: {
892
- stage: 'pending_tool_summary',
893
- message: error instanceof Error ? error.message : String(error),
894
- },
895
- });
517
+ catch {
518
+ /* a fresh listen below will surface a real problem */
896
519
  }
897
- const finishOpenToolGroup = (settleInterruptedTools = false) => {
898
- const group = openToolGroup;
899
- openToolGroup = undefined;
900
- if (group === undefined)
901
- return;
902
- group.closed = true;
903
- // An abort is itself the visible evidence that any pending calls did not
904
- // finish. Their recorded arguments still belong to the recap, and Ctrl+O
905
- // retains the full rows, so let the completed pre-abort span summarize.
906
- if (settleInterruptedTools) {
907
- for (const id of group.pendingToolIds)
908
- toolGroupsByCallId.delete(id);
909
- group.pendingToolIds.clear();
910
- }
911
- // Configuration is read at the boundary, not when a slow tool later settles.
912
- group.summarizeWhenSettled = readConfig('user').summarize_tool_calls;
913
- generateSettledToolGroup(group);
914
- };
915
- const visibleBoundary = (prose) => {
916
- finishOpenToolGroup();
917
- lastToolGroupBoundaryProse = prose;
918
- };
919
- const observeToolGroupEvent = (event) => {
920
- // The viewer turns every compaction_end into a summary, cancellation, or
921
- // error line, each of which is a hard transcript seam.
922
- if (event.type === 'compaction_end') {
923
- finishOpenToolGroup();
924
- return;
925
- }
926
- // A failed automatic retry is rendered as ChatView's visible error line.
927
- // Successful retries only clear activity and remain within the same group.
928
- if (event.type === 'auto_retry_end' && event.success !== true) {
929
- finishOpenToolGroup();
930
- return;
931
- }
932
- // The attach viewer renders aborted and errored assistant endings as visible
933
- // transcript seams. Close and settle the pre-abort group here so pending tool
934
- // arguments can be summarized without pulling the seam into the recap.
935
- if (event.type === 'message_end') {
936
- const message = event.message;
937
- if (message !== null && typeof message === 'object') {
938
- const item = message;
939
- if (item.role === 'assistant' && (item.stopReason === 'aborted' || item.stopReason === 'error')) {
940
- finishOpenToolGroup(true);
941
- }
942
- }
943
- return;
944
- }
945
- if (event.type === 'tool_execution_start') {
946
- const group = ensureOpenToolGroup();
947
- const id = typeof event.toolCallId === 'string' ? event.toolCallId : '';
948
- if (group.key === undefined && id !== '')
949
- group.key = id;
950
- if (id !== '') {
951
- group.pendingToolIds.add(id);
952
- toolGroupsByCallId.set(id, group);
520
+ server = createServer((socket) => {
521
+ const client = registry.create(socket);
522
+ socket.on('data', (chunk) => {
523
+ let frames;
524
+ try {
525
+ frames = client.decoder.push(chunk);
953
526
  }
954
- group.toolCount += 1;
955
- group.pieces.push({ type: 'tool', name: typeof event.toolName === 'string' ? event.toolName : 'tool', args: event.args });
956
- return;
957
- }
958
- if (event.type === 'tool_execution_end') {
959
- const id = typeof event.toolCallId === 'string' ? event.toolCallId : '';
960
- const group = id === '' ? openToolGroup : toolGroupsByCallId.get(id);
961
- if (group !== undefined) {
962
- group.pieces.push({
963
- type: 'result',
964
- name: typeof event.toolName === 'string' ? event.toolName : 'tool',
965
- result: event.result,
966
- isError: event.isError === true,
967
- });
968
- if (id !== '') {
969
- group.pendingToolIds.delete(id);
970
- toolGroupsByCallId.delete(id);
527
+ catch (err) {
528
+ // G7: a client frame over the bounded decoder caps (FrameOverflowError) is
529
+ // cap-and-dropped — best-effort error frame, then destroy the peer. The
530
+ // broker survives. The try/catch is around push() itself (not just the
531
+ // per-frame loop) because push() throws BEFORE returning any frames.
532
+ if (err instanceof FrameOverflowError) {
533
+ registry.sendFrame(client, { type: 'error', code: 'frame_overflow', message: err.message });
534
+ registry.dropClient(client, `frame overflow: ${err.message}`);
535
+ }
536
+ else {
537
+ registry.dropClient(client, `decoder error: ${String(err)}`);
971
538
  }
972
- generateSettledToolGroup(group);
973
- }
974
- return;
975
- }
976
- if (event.type !== 'message_start' && event.type !== 'message_update')
977
- return;
978
- const message = event.message;
979
- if (message === null || typeof message !== 'object')
980
- return;
981
- const item = message;
982
- const role = item.role;
983
- if (role === 'assistant') {
984
- if (event.type === 'message_start')
985
- sawAssistantProse = false;
986
- const prose = assistantVisibleText(item);
987
- if (!sawAssistantProse && prose.trim() !== '') {
988
- sawAssistantProse = true;
989
- visibleBoundary(prose);
990
- }
991
- return;
992
- }
993
- if (event.type !== 'message_start')
994
- return;
995
- if (role === 'compactionSummary' || role === 'branchSummary' || role === 'bashExecution' || role === 'status' || role === 'error' || item.customType === 'crtr-cycle-divider') {
996
- finishOpenToolGroup();
997
- return;
998
- }
999
- if (isTrueUserMessage(item)) {
1000
- visibleBoundary(generatedContextText(item));
1001
- return;
1002
- }
1003
- if (item.display === false)
1004
- return;
1005
- const text = generatedContextText(item);
1006
- const isBearingsOrRevive = item.customType === 'crtr-context' || (role === 'user' && text.startsWith('You have been revived fresh after a context refresh'));
1007
- if (isBearingsOrRevive) {
1008
- if (openToolGroup !== undefined && text !== '')
1009
- openToolGroup.pieces.push({ type: 'text', text });
1010
- return;
1011
- }
1012
- // Generated inbox/situational/nudge and visible custom output are part of
1013
- // the between-message activity and may begin a group before its first tool.
1014
- const group = ensureOpenToolGroup();
1015
- if (text !== '')
1016
- group.pieces.push({ type: 'text', text });
1017
- };
1018
- // ---------------------------------------------------------------------------
1019
- // F2: message_update coalescing. `relayEvent` is the single entry point for
1020
- // engine events headed to viewers: a message_update is held (latest wins) and
1021
- // flushed on a short timer; ANY other event flushes the held update first,
1022
- // synchronously, so cross-type ordering is exactly the engine's. The timer is
1023
- // unref'd — it must never hold the broker process open past dispose.
1024
- // ---------------------------------------------------------------------------
1025
- let pendingUpdate = null;
1026
- let pendingUpdateTimer = null;
1027
- let faultRetryTimer = null;
1028
- let nextSubscriptionGeneration = 0;
1029
- let installedGeneration;
1030
- // Dead-provider-stream detector (see stream-watchdog.ts): poked while a turn
1031
- // is awaiting the provider (NOT while a tool executes — a silent long tool
1032
- // must not trip it), cleared at agent_end. Fires at most once per stall,
1033
- // aborting the session so the fault-retry machinery (armed at agent_settled)
1034
- // re-drives the turn instead of the broker wedging until the daemon's
1035
- // 20-minute hard kick.
1036
- const streamWatchdogMs = resolveStreamWatchdogMs();
1037
- const streamWatchdog = new StreamWatchdog((armedGeneration) => {
1038
- const generation = installedGeneration;
1039
- if (generation === undefined || armedGeneration !== generation.id)
1040
- return;
1041
- emitEvent({
1042
- level: 'debug',
1043
- event: 'broker.runtime.diagnostic',
1044
- fields: { message: `stream watchdog: no engine event for ${streamWatchdogMs}ms while streaming — aborting stalled provider stream` },
1045
- });
1046
- void generation.session.abort().catch((err) => {
1047
- emitEvent({
1048
- level: 'debug',
1049
- event: 'broker.runtime.diagnostic',
1050
- fields: { message: `stream watchdog abort failed: ${String(err)}` },
1051
- });
1052
- });
1053
- }, streamWatchdogMs);
1054
- const flushPendingUpdate = () => {
1055
- if (pendingUpdateTimer !== null) {
1056
- clearTimeout(pendingUpdateTimer);
1057
- pendingUpdateTimer = null;
1058
- }
1059
- if (pendingUpdate !== null) {
1060
- const frame = pendingUpdate;
1061
- pendingUpdate = null;
1062
- broadcast(frame);
1063
- }
1064
- };
1065
- const clearFaultRetryTimer = () => {
1066
- if (faultRetryTimer === null)
1067
- return;
1068
- clearTimeout(faultRetryTimer);
1069
- faultRetryTimer = null;
1070
- };
1071
- const failedRetryRoutes = new Set();
1072
- let providerFallbackInFlight = null;
1073
- // Models that have already produced a 404 not_found this session. Guards the
1074
- // not-found fallback below against an infinite swap→re-drive→404 loop when the
1075
- // fallback TARGET is itself unavailable (or equals the model that just failed).
1076
- const notFoundModels = new Set();
1077
- let notFoundFallbackInFlight = null;
1078
- // Tier 3 model-less boot: the first-prompt kickoff can't fire at listen time
1079
- // when the session booted with no model at all. Hold the prompt text here and
1080
- // fire it once a model actually becomes available (reload_auth re-drive,
1081
- // below) instead of the old unconditional call. For the common model-present
1082
- // path this fires immediately at listen time — byte-identical to the prior
1083
- // behavior.
1084
- let kickoffPending;
1085
- let kickoffInFlight = false;
1086
- // One /login can fan the same reload into this broker through several clients.
1087
- // Coalesce those frames onto one local auth refresh + route reconciliation so
1088
- // an idle prompt can await the exact work promised by the reload ack.
1089
- let reloadAuthInFlight = null;
1090
- // A fresh auth reload is also the explicit recovery trigger for a turn parked
1091
- // on a fatal provider-auth fault. Keep at most one rewind + prompt in flight
1092
- // when the daemon and an attached viewer both fan the same login update.
1093
- let authFaultRedriveInFlight = false;
1094
- // A model-less boot is EITHER a literal `undefined` model (the fake engine) OR
1095
- // the real SDK's unknown-stub (see isUnknownModel) — the real pinned engine
1096
- // NEVER yields `undefined`, so every model-less guard must accept both.
1097
- const sessionHasNoModel = () => session.model === undefined || isUnknownModel(session.model);
1098
- const drivePendingKickoff = () => {
1099
- if (kickoffPending === undefined || kickoffInFlight || sessionHasNoModel())
1100
- return;
1101
- const prompt = kickoffPending;
1102
- kickoffPending = undefined;
1103
- kickoffInFlight = true;
1104
- trackPromptDispatch(operationIdContext.fresh(() => session.prompt(prompt)
1105
- .catch((err) => {
1106
- emitEvent({
1107
- level: 'error',
1108
- event: 'broker.prompt.first_failed',
1109
- error: err,
1110
- });
1111
- })
1112
- .finally(() => {
1113
- kickoffInFlight = false;
1114
- })));
1115
- };
1116
- const maybeSwitchProviderForRetry = (event) => {
1117
- if (event.type !== 'auto_retry_start')
1118
- return;
1119
- const retry = event;
1120
- const kind = classify('pi→provider', retry.errorMessage ?? '').kind;
1121
- if (kind !== 'rate-limit' && kind !== 'connection')
1122
- return;
1123
- if (providerFallbackInFlight !== null)
1124
- return;
1125
- const generation = installedGeneration;
1126
- if (generation === undefined)
1127
- return;
1128
- const candidateSession = session;
1129
- const candidateServices = services;
1130
- // An exact-consult launch locks the session to its one captured model.
1131
- // Other requests advance only through their declared route expansion.
1132
- if (cfg.modelExact === true)
1133
- return;
1134
- const currentSpec = formatModelSpec(session.model, session.thinkingLevel);
1135
- if (currentSpec === null)
1136
- return;
1137
- const routeRequest = modelRequestFromConfig(cfg.model, process.env['CRTR_MODEL_INTENT'], false);
1138
- if (routeRequest !== undefined) {
1139
- const routes = expandModelCandidates(routeRequest, process.env['CRTR_NODE_CWD'] ?? cfg.cwd, process.env['CRTR_PROFILE_ID']);
1140
- const current = parseModelSpec(currentSpec);
1141
- const liveRank = routes.find((candidate) => `${candidate.providerId}/${candidate.modelId}` === current.modelSpec && candidate.thinkingLevel === current.thinkingLevel)?.rank;
1142
- const nextRoute = liveRank === undefined ? undefined : routes.slice(liveRank + 1).find((candidate) => !failedRetryRoutes.has(candidate.routeId) && resolveProviderCandidates([{ ...candidate, ...probeRouteAvailability(candidate, registryOf(candidateServices)), automaticFallback: true }]).candidates.length > 0);
1143
- if (nextRoute === undefined)
1144
- return;
1145
- const fallbackPromise = (async () => {
1146
- const target = registryOf(candidateServices).find(nextRoute.providerId, nextRoute.modelId);
1147
- if (!target)
1148
- return;
1149
- await candidateSession.setModel(target);
1150
- if (installedGeneration !== generation || session !== candidateSession)
1151
- return;
1152
- candidateSession.setThinkingLevel((nextRoute.thinkingLevel ?? 'off'));
1153
- failedRetryRoutes.add(nextRoute.routeId);
1154
- broadcastModelChanged(false);
1155
- })().catch((err) => { emitEvent({ level: 'debug', event: 'broker.runtime.diagnostic', fields: { message: `provider route fallback failed: ${err instanceof Error ? err.message : String(err)}` } }); }).finally(() => {
1156
- if (installedGeneration === generation)
1157
- providerFallbackInFlight = null;
1158
- });
1159
- providerFallbackInFlight = fallbackPromise;
1160
- return;
1161
- }
1162
- };
1163
- // A 404 or unconfigured provider is terminal to pi's ordinary retry loop.
1164
- // Advance once through the request's declared route expansion, then re-drive
1165
- // the turn on the selected viable route. The raw registry never supplies this
1166
- // recovery path: it is an explicit routing decision, not a last-resort guess.
1167
- const maybeFallbackOnUnusableModel = (event) => {
1168
- if (event.type !== 'message_end')
1169
- return;
1170
- const msg = event.message;
1171
- if (msg === undefined || msg.role !== 'assistant' || msg.stopReason !== 'error')
1172
- return;
1173
- const unconfigured = isProviderUnconfiguredError(msg.errorMessage ?? '');
1174
- if (!unconfigured && !isModelNotFoundError(msg.errorMessage ?? ''))
1175
- return;
1176
- // Exact-consult launches fail closed. A concrete request's expansion already
1177
- // contains only the identical model through its eligible credential sources.
1178
- if (cfg.modelExact === true)
1179
- return;
1180
- if (notFoundFallbackInFlight !== null)
1181
- return;
1182
- const generation = installedGeneration;
1183
- if (generation === undefined)
1184
- return;
1185
- const candidateSession = session;
1186
- const candidateServices = services;
1187
- const currentSpec = formatModelSpec(session.model, session.thinkingLevel);
1188
- if (currentSpec === null)
1189
- return;
1190
- const current = parseModelSpec(currentSpec);
1191
- // A 404 rejects the provider/model itself, not only one thinking setting.
1192
- notFoundModels.add(current.modelSpec);
1193
- const routeRequest = modelRequestFromConfig(cfg.model, process.env['CRTR_MODEL_INTENT'], false);
1194
- const routes = routeRequest ? expandModelCandidates(routeRequest, process.env['CRTR_NODE_CWD'] ?? cfg.cwd, process.env['CRTR_PROFILE_ID']) : [];
1195
- const liveRank = routes.find((candidate) => `${candidate.providerId}/${candidate.modelId}` === current.modelSpec && candidate.thinkingLevel === current.thinkingLevel)?.rank;
1196
- const nextRoute = liveRank === undefined ? undefined : routes.slice(liveRank + 1).find((candidate) => {
1197
- if (notFoundModels.has(`${candidate.providerId}/${candidate.modelId}`))
1198
- return false;
1199
- return resolveProviderCandidates([{ ...candidate, ...probeRouteAvailability(candidate, registryOf(candidateServices)), automaticFallback: true }]).candidates.length > 0;
1200
- });
1201
- if (!nextRoute)
1202
- return;
1203
- const target = `${nextRoute.providerId}/${nextRoute.modelId}${nextRoute.thinkingLevel ? `:${nextRoute.thinkingLevel}` : ''}`;
1204
- const fallbackPromise = (async () => {
1205
- const model = registryOf(candidateServices).find(nextRoute.providerId, nextRoute.modelId);
1206
- if (model === undefined) {
1207
- emitEvent({
1208
- level: 'debug',
1209
- event: 'broker.runtime.diagnostic',
1210
- fields: { message: `${unconfigured ? 'unconfigured-provider' : 'not_found'} fallback skipped: ${target} is not registered` },
1211
- });
1212
- return;
1213
- }
1214
- await candidateSession.setModel(model);
1215
- if (installedGeneration !== generation || session !== candidateSession)
1216
- return;
1217
- candidateSession.setThinkingLevel((nextRoute.thinkingLevel ?? 'off'));
1218
- broadcastModelChanged();
1219
- const resolved = formatModelSpec(candidateSession.model, candidateSession.thinkingLevel) ?? target;
1220
- emitEvent({
1221
- level: 'debug',
1222
- event: 'broker.runtime.diagnostic',
1223
- fields: { message: `${unconfigured ? 'unconfigured-provider' : 'not_found'} fallback: ${currentSpec} unavailable → ${resolved}; re-driving turn` },
1224
- });
1225
- // Re-drive: a 404 left the turn errored, so the model swap alone changes
1226
- // nothing until the next prompt — re-issue so the now-healthy model picks
1227
- // the task back up. We fire on message_end, BEFORE the errored turn has
1228
- // fully settled, so wait for the engine to go idle first: prompt() THROWS
1229
- // if called while streaming without a streamingBehavior, which would swap
1230
- // the model but silently skip the continuation.
1231
- await candidateSession.agent.waitForIdle();
1232
- if (installedGeneration !== generation || session !== candidateSession)
1233
- return;
1234
- // The parked fatal describes a model this session no longer uses. Clearing
1235
- // it after the errored turn has settled (waitForIdle is past agent_end, so
1236
- // publishSettledProviderFault has already written it) keeps a recovered
1237
- // node from reviving straight back into fault-recovery chrome.
1238
- if (unconfigured)
1239
- clearFault(nodeId, { link: 'pi→provider' });
1240
- await operationIdContext.fresh(() => session.prompt(formatModelFallbackRecovery(currentSpec, resolved, unconfigured ? 'credential' : 'not-found')));
1241
- })()
1242
- .catch((err) => {
1243
- emitEvent({
1244
- level: 'debug',
1245
- event: 'broker.runtime.diagnostic',
1246
- fields: { message: `${unconfigured ? 'unconfigured-provider' : 'not_found'} fallback failed: ${err instanceof Error ? err.message : String(err)}` },
1247
- });
1248
- })
1249
- .finally(() => {
1250
- if (installedGeneration === generation)
1251
- notFoundFallbackInFlight = null;
1252
- });
1253
- notFoundFallbackInFlight = fallbackPromise;
1254
- };
1255
- const isActiveAutoFault = (fault) => {
1256
- if (fault === null)
1257
- return false;
1258
- if (fault.retry.disposition !== 'auto')
1259
- return false;
1260
- if (fault.retry.by !== 'daemon')
1261
- return false;
1262
- return fault.kind === 'connection' || fault.kind === 'rate-limit' || fault.kind === 'overloaded';
1263
- };
1264
- const faultRetryDelayMs = (fault, now) => {
1265
- if (fault.retry.nextAt !== undefined) {
1266
- const ms = Date.parse(fault.retry.nextAt) - now;
1267
- return Number.isFinite(ms) ? Math.max(0, ms) : null;
1268
- }
1269
- const plan = nextFaultRetry(fault, now);
1270
- return plan?.delayMs ?? null;
1271
- };
1272
- // Node timers accept only a signed int32 delay. Long fault deadlines (for
1273
- // example an invalid credential's remediation backoff) are chunked so they
1274
- // cannot overflow into an immediate retry.
1275
- const MAX_TIMEOUT_MS = 2_147_483_647;
1276
- const clearAndArmFaultRetry = (delayMs, generation) => {
1277
- if (installedGeneration !== generation)
1278
- return;
1279
- clearFaultRetryTimer();
1280
- const chunkMs = Math.min(Math.max(0, delayMs), MAX_TIMEOUT_MS);
1281
- const remainingMs = Math.max(0, delayMs - chunkMs);
1282
- faultRetryTimer = setTimeout(() => {
1283
- faultRetryTimer = null;
1284
- if (installedGeneration !== generation)
1285
- return;
1286
- if (remainingMs > 0) {
1287
- clearAndArmFaultRetry(remainingMs, generation);
1288
- return;
1289
- }
1290
- void runFaultRetry().catch((err) => {
1291
- emitEvent({
1292
- level: 'debug',
1293
- event: 'broker.runtime.diagnostic',
1294
- fields: { message: `fault retry fire failed: ${err instanceof Error ? err.message : String(err)}` },
1295
- });
1296
- });
1297
- }, chunkMs);
1298
- if (typeof faultRetryTimer.unref === 'function')
1299
- faultRetryTimer.unref();
1300
- };
1301
- const scheduleFaultRetry = () => {
1302
- const generation = installedGeneration;
1303
- if (generation === undefined || session.isStreaming)
1304
- return;
1305
- if (faultRetryTimer !== null)
1306
- return;
1307
- const fault = readFault(nodeId);
1308
- if (!isActiveAutoFault(fault)) {
1309
- clearFaultRetryTimer();
1310
- return;
1311
- }
1312
- const delayMs = faultRetryDelayMs(fault, Date.now());
1313
- if (delayMs === null)
1314
- return;
1315
- clearAndArmFaultRetry(delayMs, generation);
1316
- };
1317
- const publishSettledProviderFault = (generation) => {
1318
- const agentEnd = generation.stagedProviderAgentEnd;
1319
- const overflowFailure = generation.stagedOverflowFailure;
1320
- generation.stagedProviderAgentEnd = null;
1321
- generation.stagedOverflowFailure = null;
1322
- const settledSession = generation.session;
1323
- const messages = Array.isArray(agentEnd?.messages) ? agentEnd.messages : [];
1324
- const last = [...messages].reverse().find((message) => typeof message === 'object' && message !== null && message.role === 'assistant');
1325
- if (overflowFailure !== null) {
1326
- // The settled terminal outcome supersedes any auto marker retained while
1327
- // the broker re-drove this same turn; fault priority must not preserve a
1328
- // now-obsolete retry plan over Pi's definitive recovery result.
1329
- clearFault(nodeId, { link: 'pi→provider' });
1330
- recordFault(nodeId, {
1331
- link: 'pi→provider',
1332
- op: 'context overflow recovery',
1333
- kind: 'context-overflow',
1334
- retry: { disposition: 'fatal' },
1335
- message: overflowFailure.errorMessage,
1336
- anchorEntryId: settledSession.sessionManager.getLeafId?.() ?? undefined,
1337
- });
1338
- return;
1339
- }
1340
- if (last?.stopReason !== 'error')
1341
- return;
1342
- const raw = typeof last.errorMessage === 'string'
1343
- ? last.errorMessage
1344
- : 'engine error (no errorMessage recorded)';
1345
- const uncompactedOverflow = isContextOverflow(last, settledSession.model?.contextWindow);
1346
- const classified = classify('pi→provider', last);
1347
- const coolingDeadline = extractCoolingDeadline(last);
1348
- if (uncompactedOverflow)
1349
- clearFault(nodeId, { link: 'pi→provider' });
1350
- recordFault(nodeId, {
1351
- link: 'pi→provider',
1352
- op: 'generation turn',
1353
- kind: uncompactedOverflow ? 'context-overflow' : classified.kind,
1354
- retry: uncompactedOverflow
1355
- ? { disposition: 'fatal' }
1356
- : classified.disposition === 'auto'
1357
- ? { disposition: 'auto', by: 'daemon', ...(coolingDeadline !== undefined ? { nextAt: new Date(coolingDeadline).toISOString() } : {}) }
1358
- : { disposition: classified.disposition },
1359
- message: raw,
1360
- anchorEntryId: settledSession.sessionManager.getLeafId?.() ?? undefined,
1361
- });
1362
- };
1363
- const runFaultRetry = async () => {
1364
- const generation = installedGeneration;
1365
- if (generation === undefined)
1366
- return;
1367
- const candidateSession = generation.session;
1368
- if (candidateSession.isStreaming)
1369
- return;
1370
- const initial = readFault(nodeId);
1371
- if (!isActiveAutoFault(initial))
1372
- return;
1373
- let fault = initial;
1374
- if (initial.kind === 'connection') {
1375
- const online = await retryProbe();
1376
- // The probe can take seconds. A turn may have started, or the marker may
1377
- // have been cleared/replaced/recovered, in that window. Re-validate
1378
- // against fresh state before re-arming or consuming an attempt —
1379
- // otherwise a stale retry resurrects a recovered episode or double-drives
1380
- // a now-streaming session.
1381
- if (installedGeneration !== generation || session !== candidateSession || candidateSession.isStreaming)
1382
- return;
1383
- const fresh = readFault(nodeId);
1384
- if (!isActiveAutoFault(fresh) || fresh.since !== initial.since)
1385
- return;
1386
- if (!online) {
1387
- clearAndArmFaultRetry(policyFor(fresh.kind).baseDelayMs, generation);
1388
- return;
1389
- }
1390
- fault = fresh;
1391
- }
1392
- const now = Date.now();
1393
- const attempt = fault.retry.attempt ?? 0;
1394
- const policy = policyFor(fault.kind);
1395
- if (attempt >= policy.max) {
1396
- recordFault(nodeId, markFatalOnExhaust(fault));
1397
- return;
1398
- }
1399
- const plan = nextFaultRetry(fault, now);
1400
- if (plan === null) {
1401
- recordFault(nodeId, markFatalOnExhaust(fault));
1402
- return;
1403
- }
1404
- const anchorEntryId = fault.anchorEntryId;
1405
- if (anchorEntryId !== undefined) {
1406
- try {
1407
- const leafId = candidateSession.sessionManager.getLeafId?.() ?? null;
1408
- if (leafId !== anchorEntryId) {
1409
- await candidateSession.navigateTree(anchorEntryId);
1410
- if (installedGeneration !== generation || session !== candidateSession)
1411
- return;
1412
- }
1413
- }
1414
- catch (err) {
1415
- emitEvent({
1416
- level: 'debug',
1417
- event: 'broker.runtime.diagnostic',
1418
- fields: { message: `fault retry rewind skipped: ${err instanceof Error ? err.message : String(err)}` },
1419
- });
1420
- }
1421
- }
1422
- if (installedGeneration !== generation || session !== candidateSession)
1423
- return;
1424
- recordFault(nodeId, plan.fault);
1425
- await operationIdContext.fresh(() => session.prompt(fault.kind === 'connection' ? CONNECTION_FAULT_RECOVERY_BODY : PROVIDER_FAULT_RECOVERY_BODY));
1426
- };
1427
- // Waiters for "the engine ACCEPTED a prompt and a turn is now running". A
1428
- // routed prompt's own promise settles only when the whole TURN ends, which is
1429
- // minutes too late to ack a deliver; agent_start is the engine's own signal
1430
- // that the call got past its model/auth gate. The deliver ack races these two.
1431
- const turnAcceptedWaiters = new Set();
1432
- const notifyTurnAccepted = () => {
1433
- if (turnAcceptedWaiters.size === 0)
1434
- return;
1435
- const waiters = [...turnAcceptedWaiters];
1436
- turnAcceptedWaiters.clear();
1437
- for (const waiter of waiters)
1438
- waiter();
1439
- };
1440
- const relayEvent = (event, generation) => {
1441
- maybeSwitchProviderForRetry(event);
1442
- maybeFallbackOnUnusableModel(event);
1443
- const type = event.type;
1444
- if (type === 'agent_start') {
1445
- try {
1446
- workingActivity = randomWorkingActivity(readConfig('user').working_gerunds);
1447
- }
1448
- catch {
1449
- workingActivity = DEFAULT_WORKING_ACTIVITY;
1450
- }
1451
- // Announce the selected label before relaying pi's verbatim agent_start so
1452
- // every viewer paints this turn with the same random choice.
1453
- broadcast({ type: 'working_activity', activity: workingActivity });
1454
- notifyTurnAccepted();
1455
- emitStartupMilestone('broker.startup.agent_start', formatModelSpec(session.model, session.thinkingLevel));
1456
- }
1457
- if (type === 'message_update') {
1458
- const message = event.message;
1459
- if (message?.role === 'assistant')
1460
- emitStartupMilestone('broker.startup.provider_first_delta', formatModelSpec(session.model, session.thinkingLevel));
1461
- }
1462
- if (type === 'message_end') {
1463
- const message = event.message;
1464
- if (message?.role === 'assistant')
1465
- emitStartupMilestone('broker.startup.assistant_message_end', formatModelSpec(session.model, session.thinkingLevel));
1466
- }
1467
- if (type === 'agent_start' || type === 'turn_start')
1468
- clearFaultRetryTimer();
1469
- // The watchdog covers ONLY time awaiting the provider. `session.isStreaming`
1470
- // stays true through tool execution, so a legitimately silent tool lasting
1471
- // longer than the timeout would otherwise be aborted mid-run: disarm while
1472
- // any tool executes and rearm once the last one ends. Multiple tools can run
1473
- // concurrently, so track in-flight ids and only rearm when the set drains.
1474
- if (type === 'agent_end') {
1475
- // Clear the in-flight set too: an abnormal run can emit agent_end after a
1476
- // tool_execution_start with no matching tool_execution_end. A leftover id
1477
- // would keep executingTools non-empty forever, gating every future poke
1478
- // (size === 0) and permanently disarming the watchdog until a rebind.
1479
- generation.executingTools.clear();
1480
- streamWatchdog.reset();
1481
- }
1482
- else if (type === 'tool_execution_start') {
1483
- generation.executingTools.add(event.toolCallId ?? '');
1484
- streamWatchdog.clear();
1485
- }
1486
- else if (type === 'tool_execution_end') {
1487
- generation.executingTools.delete(event.toolCallId ?? '');
1488
- if (generation.executingTools.size === 0 && generation.session.isStreaming)
1489
- streamWatchdog.poke(generation.id);
1490
- }
1491
- else if (generation.session.isStreaming && generation.executingTools.size === 0) {
1492
- streamWatchdog.poke(generation.id);
1493
- }
1494
- if (type === 'message_update') {
1495
- pendingUpdate = event; // latest full-message update supersedes the held one
1496
- if (pendingUpdateTimer === null) {
1497
- pendingUpdateTimer = setTimeout(flushPendingUpdate, MESSAGE_UPDATE_COALESCE_MS);
1498
- if (typeof pendingUpdateTimer.unref === 'function')
1499
- pendingUpdateTimer.unref();
1500
- }
1501
- return;
1502
- }
1503
- // Any non-update event: flush the held update FIRST so a viewer never sees
1504
- // (e.g.) message_end before the update it supersedes. A refresh-yield aborts
1505
- // its own turn; the visible cycle divider names that boundary, so do not also
1506
- // render it as an operation failure.
1507
- flushPendingUpdate();
1508
- if (type === 'message_end' && getNode(nodeId)?.intent === 'refresh') {
1509
- const messageEnd = event;
1510
- broadcast({ ...messageEnd, message: withoutYieldAbort(messageEnd.message) });
1511
- return;
1512
- }
1513
- broadcast(event);
1514
- };
1515
- // Persist the live model + thinking level into the node's durable launch
1516
- // recipe so a daemon restart/revive recreates the settings the user sees.
1517
- // pi's model/thinking controls only mutate the in-memory engine; without this
1518
- // the node reverts to its persona/spawn settings on the next broker boot. We
1519
- // write the full `provider/id:thinking` spec to BOTH model_override (so
1520
- // polymorphs preserve it via buildLaunchSpec) and launch.model (the recipe
1521
- // buildPiArgv replays on revive). Best-effort: a degenerate/ephemeral node
1522
- // with no canvas row just skips persistence. CRTR_NODE_ID is set in the
1523
- // broker's own env (merged by piInvocationToSdkConfig at boot).
1524
- // `pinnedOverride`, when passed, is the durable launch-recipe decision for
1525
- // an explicit model change, derived from its raw pre-normalize token. A
1526
- // `userSelected` change also clears the older logical route intent: otherwise
1527
- // resolveLaunchModel ignores the newly persisted concrete spec at reboot and
1528
- // resolves the persona's original family/strength again. Algorithmic provider
1529
- // fallback leaves that intent intact.
1530
- const persistModelChoice = (pinnedOverride, userSelected = false) => {
1531
- const nodeId = process.env['CRTR_NODE_ID'];
1532
- const spec = formatModelSpec(session.model, session.thinkingLevel);
1533
- if (nodeId === undefined || nodeId === '' || spec === null)
1534
- return;
1535
- try {
1536
- const meta = getNode(nodeId);
1537
- if (meta === null)
1538
- return;
1539
- const launch = meta.launch === undefined
1540
- ? undefined
1541
- : userSelected
1542
- ? (() => {
1543
- const { modelIntent: _modelIntent, env, ...rest } = meta.launch;
1544
- const { CRTR_MODEL_INTENT: _intentEnv, ...remainingEnv } = env;
1545
- return {
1546
- ...rest,
1547
- model: spec,
1548
- ...(pinnedOverride !== undefined ? { modelProviderPinned: pinnedOverride } : {}),
1549
- env: remainingEnv,
1550
- };
1551
- })()
1552
- : { ...meta.launch, model: spec, ...(pinnedOverride !== undefined ? { modelProviderPinned: pinnedOverride } : {}) };
1553
- if (userSelected) {
1554
- delete process.env['CRTR_MODEL_INTENT'];
1555
- cfg.model = spec;
1556
- }
1557
- updateNode(nodeId, { model_override: spec, ...(launch !== undefined ? { launch } : {}) });
1558
- }
1559
- catch {
1560
- // Persistence is best-effort; the live switch already succeeded.
1561
- }
1562
- };
1563
- // pi emits no AgentSessionEvent for a model switch, so after a successful
1564
- // set_model/cycle_model the broker announces it itself — otherwise the new
1565
- // model reaches no viewer (the requester gets only a bare ack) and every
1566
- // footer shows the stale model until the next unrelated event.
1567
- const broadcastModelChanged = (persist = true, pinnedOverride, userSelected = false) => {
1568
- if (persist)
1569
- persistModelChoice(pinnedOverride, userSelected);
1570
- broadcast({ type: 'model_changed', model: isUnknownModel(session.model) ? undefined : session.model, spec: formatModelSpec(session.model, session.thinkingLevel) });
1571
- };
1572
- const buildSnapshot = () => ({
1573
- messages: snapshotMessages(session, reviewCompanion),
1574
- stats: session.getSessionStats(),
1575
- // pi's `get_state` (rpc-mode.js), field for field, from the same getters.
1576
- // The ONE divergence: a model-less session (undefined OR the SDK
1577
- // unknown-stub) reports no model, matching broadcastModelChanged — never a
1578
- // fictitious `unknown` selection.
1579
- state: {
1580
- model: isUnknownModel(session.model) ? undefined : session.model,
1581
- thinkingLevel: session.thinkingLevel,
1582
- isStreaming: session.isStreaming,
1583
- isCompacting: session.isCompacting,
1584
- steeringMode: session.steeringMode,
1585
- followUpMode: session.followUpMode,
1586
- sessionFile: session.sessionFile,
1587
- sessionId: session.sessionId,
1588
- sessionName: session.sessionName,
1589
- autoCompactionEnabled: session.autoCompactionEnabled,
1590
- messageCount: session.messages.length,
1591
- pendingMessageCount: session.pendingMessageCount,
1592
- },
1593
- // Broker-owned levels (see BrokerSnapshot.display). Copied out so a later
1594
- // mutation of the retained maps cannot alias a frame already serialized.
1595
- display: {
1596
- statuses: Object.fromEntries(displayStatuses),
1597
- widgets: Object.fromEntries([...displayWidgets].map(([k, w]) => [k, { lines: [...w.lines], placement: w.placement }])),
1598
- title: displayTitle,
1599
- },
1600
- toolGroupSummaries: Object.fromEntries(toolGroupSummaries),
1601
- workingActivity,
1602
- });
1603
- // Send a client its catch-up snapshot. welcome.pending_dialog carries a single
1604
- // still-in-flight dialog to a WRITABLE client attaching mid-dialog; an observer
1605
- // can never answer one, so observers get null. The pendingDialogs map is
1606
- // insertion-ordered — the first entry is the canonical one carried here; any
1607
- // extras are replayed explicitly by the caller (rare: concurrent dialogs).
1608
- const sendWelcome = (client) => {
1609
- const first = client.role === 'controller' ? pendingDialogs.values().next().value : undefined;
1610
- sendFrame(client, {
1611
- type: 'welcome',
1612
- snapshot: buildSnapshot(),
1613
- role: client.role,
1614
- pending_dialog: first !== undefined ? first.request : null,
1615
- agentDir: getAgentDir(),
1616
- }, true);
1617
- // No display_* replay: the retained extension state rides IN the snapshot
1618
- // (`snapshot.display`), so catch-up is one atomic frame. A viewer attaching
1619
- // after an idle brief was painted still sees it on first paint, and there is
1620
- // no window between `welcome` and the replay where chrome renders blank.
1621
- };
1622
- // Attach-mid-dialog replay: a dialog raised before this client attached is
1623
- // still pending (a peer detaching never cancels one — see makeBrokerUiContext),
1624
- // so a writable client joining afterwards is handed every in-flight dialog and
1625
- // becomes a full peer in the race to answer it. `welcome.pending_dialog` already
1626
- // carried the FIRST entry, so this replays only the extras.
1627
- const replayExtraPendingDialogsTo = (client) => {
1628
- const pend = [...pendingDialogs.values()];
1629
- for (let i = 1; i < pend.length; i++)
1630
- sendFrame(client, pend[i].request);
1631
- };
1632
- // After a session-replacing op (new_session/switch_session/fork) the engine's
1633
- // entire message history changed, so every attached viewer must rebuild from a
1634
- // fresh snapshot of the NEW session.
1635
- const reWelcomeAll = () => {
1636
- for (const c of clients)
1637
- if (c.helloed)
1638
- sendWelcome(c);
1639
- };
1640
- // -------------------------------------------------------------------------
1641
- // The single exit helper (plan §0): dispose the engine, close + unlink the
1642
- // socket, exit 0. Idempotent — every trigger converges here.
1643
- // -------------------------------------------------------------------------
1644
- const completeExit = (status) => {
1645
- if (disposed)
1646
- return;
1647
- disposed = true;
1648
- // Daemon teardown cannot wait for a recap's 45-second model timeout. Preserve
1649
- // exactly the newest already-capped input so the successor broker can finish
1650
- // it, persist the normal summary, and remove the handoff record.
1651
- const pendingSummary = [...generatingToolGroups.values()].at(-1);
1652
- if (pendingSummary !== undefined) {
1653
- try {
1654
- atomicWriteJson(pendingToolSummaryPath, pendingSummary);
1655
- }
1656
- catch (error) {
1657
- emitEvent({
1658
- level: 'warn',
1659
- event: 'broker.runtime.diagnostic',
1660
- fields: {
1661
- message: `failed to persist pending tool summary: ${error instanceof Error ? error.message : String(error)}`,
1662
- },
1663
- });
1664
- }
1665
- }
1666
- streamWatchdog.reset();
1667
- // disposeActiveSession atomically clears the process-global pointer before
1668
- // disposing — whichever engine session (committed or an in-flight rebind
1669
- // candidate) it currently names, matching broker-cli's fatal-handler primitive.
1670
- disposeActiveSession();
1671
- try {
1672
- server?.close();
1673
- }
1674
- catch {
1675
- /* ignore */
1676
- }
1677
- // Finite and best-effort: destroy every currently tracked client socket so
1678
- // teardown below can't race a viewer write, then drop the set. Do not wait
1679
- // for 'close' events.
1680
- for (const client of clients) {
1681
- try {
1682
- client.socket.destroy();
1683
- }
1684
- catch {
1685
- /* ignore */
1686
- }
1687
- }
1688
- clients.clear();
1689
- try {
1690
- if (existsSync(sockPath))
1691
- unlinkSync(sockPath);
1692
- }
1693
- catch {
1694
- /* ignore */
1695
- }
1696
- try {
1697
- // A clean exit never leaves a stale presence claim behind.
1698
- if (existsSync(attachPath))
1699
- unlinkSync(attachPath);
1700
- }
1701
- catch {
1702
- /* ignore */
1703
- }
1704
- process.exit(status);
1705
- };
1706
- const disposeAndExit = (reason, status = 0) => {
1707
- if (disposed)
1708
- return;
1709
- const graceful = reason === 'shutdown-hook' || reason === 'shutdown-after-settle';
1710
- // A graceful shutdown (yield, dormancy, daemon restart) is the final
1711
- // boundary of the conversation's last tool group. Group state lives only in
1712
- // this process, so a group left open here is unsummarizable forever and its
1713
- // calls render raw in every future view of the transcript. Close it now —
1714
- // generateSettledToolGroup registers its task synchronously, so the wait
1715
- // below covers the summary it launches. Any pending tool arguments are
1716
- // included without inventing a result; the visible abort remains outside
1717
- // the recap and Ctrl+O retains every underlying call row.
1718
- if (graceful)
1719
- finishOpenToolGroup(true);
1720
- // A normal terminal shutdown follows a visible boundary that may have just
1721
- // launched a capped (45s) headless summary. Keep the broker and its child
1722
- // alive until those already-started tasks persist/broadcast, rather than
1723
- // process.exit() orphaning pi and losing the sidecar. Abrupt failures and
1724
- // signals retain their existing immediate teardown behavior.
1725
- if (graceful && summaryTasks.size > 0) {
1726
- if (disposePending)
1727
- return;
1728
- disposePending = true;
1729
- void Promise.allSettled([...summaryTasks]).finally(() => completeExit(status));
1730
- return;
1731
- }
1732
- completeExit(status);
1733
- };
1734
- // A stophook can request shutdown while pi is still unwinding a completed
1735
- // turn. Let that turn reach agent_settled first: inbox delivery commits its
1736
- // durable cursor there, and only there. Explicit operator shutdown and OS
1737
- // signals remain abrupt through disposeAndExit.
1738
- const requestShutdown = () => {
1739
- if (disposed || shutdownRequested)
1740
- return;
1741
- if (!agentRunActive) {
1742
- disposeAndExit('shutdown-hook');
1743
- return;
1744
- }
1745
- shutdownRequested = true;
1746
- };
1747
- // -------------------------------------------------------------------------
1748
- // Extension-dialog routing (C2). makeBrokerUiContext owns the dialogPromise;
1749
- // here we just give it the three broker-side hooks it needs: the live set of
1750
- // writable clients (empty = nobody can answer → noOp fallback), how to forward a
1751
- // dialog to one of them, and the pending-dialog registry whichever one answers
1752
- // first settles through. The zero-writable path NEVER hangs and NEVER waits on a
1753
- // per-dialog timeout (design §5.4's timeout premise is false — see
1754
- // makeBrokerUiContext).
1755
- // -------------------------------------------------------------------------
1756
- const uiContext = makeBrokerUiContext({
1757
- writable: writableClients,
1758
- forward: (client, request) => sendFrame(client, request),
1759
- pending: pendingDialogs,
1760
- broadcast: broadcastUi,
1761
- // A deliberate managed-provider cooldown wait is known liveness, not a wedged
1762
- // stream: pause the dead-stream watchdog for its duration. Only pause for a
1763
- // finite future deadline, and resume ONLY while the same turn is still
1764
- // awaiting the provider (streaming, no tool running) so a wait that outlived
1765
- // its turn can't rearm a stale session; otherwise fully reset.
1766
- onKnownStreamWait: (deadlineMs) => {
1767
- if (!Number.isFinite(deadlineMs) || deadlineMs <= Date.now())
1768
- return () => { };
1769
- const generation = installedGeneration;
1770
- if (generation === undefined)
1771
- return () => { };
1772
- const waitingSession = generation.session;
1773
- streamWatchdog.pause();
1774
- return () => {
1775
- if (installedGeneration !== generation || session !== waitingSession)
1776
- return;
1777
- releaseKnownStreamWait(streamWatchdog, generation.id, waitingSession.isStreaming, generation.executingTools.size > 0);
1778
- };
1779
- },
1780
- });
1781
- // -------------------------------------------------------------------------
1782
- // 3+4 (plan): bind the FULL canvas extensions (mode 'rpc') AND fan the single
1783
- // engine event stream to all clients, VERBATIM — both wrapped in one
1784
- // ticketed rebind lane so a session-replacing op (new_session/switch_session/fork)
1785
- // can re-run them against the NEW session. This mirrors pi's rpc-mode callback:
1786
- // the AgentSessionRuntime tears down the old
1787
- // session, builds the next, then calls this back; we re-bind extensions and
1788
- // re-subscribe so the canvas hooks + the fan-out follow the live session.
1789
- //
1790
- // bindExtensions fires session_start → the stophook records pi_session_id/file
1791
- // + recordPid(pid); the shutdownHandler is the precise broker-exit trigger (the
1792
- // stophook calls ctx.shutdown() only in its done / idle-release / refresh
1793
- // branches, NOT on reprompt). A requested in-turn shutdown exits after the
1794
- // subscriber observes agent_settled, while every other branch stays alive. The
1795
- // relay must not infer shutdown from raw agent_end: that would override the
1796
- // stophook's routing decision. shutdownHandler + the inbound `shutdown` frame
1797
- // are the only exit triggers.
1798
- //
1799
- // m7: the subscriber body is wrapped in try/catch — a synchronous throw out of
1800
- // a subscriber otherwise propagates as a run failure that would crash the broker.
1801
- // -------------------------------------------------------------------------
1802
- let unsubscribe;
1803
- const removeInstalledSubscription = () => {
1804
- const installed = unsubscribe;
1805
- installed?.();
1806
- unsubscribe = undefined;
1807
- installedGeneration = undefined;
1808
- };
1809
- let rebindTail = Promise.resolve();
1810
- let latestRebindRequest = 0;
1811
- const captureSystemPrompt = (candidateSession) => {
1812
- try {
1813
- const dir = jobDir(nodeId);
1814
- mkdirSync(dir, { recursive: true });
1815
- writeFileSync(join(dir, 'system-prompt.md'), candidateSession.systemPrompt, 'utf8');
1816
- }
1817
- catch {
1818
- /* non-fatal: diagnostic only */
1819
- }
1820
- };
1821
- const executeSubscriptionCallback = (generation, event) => {
1822
- try {
1823
- if (event.type === 'agent_start')
1824
- agentRunActive = true;
1825
- if (event.type === 'agent_end') {
1826
- generation.stagedProviderAgentEnd = event;
1827
- // A continuation after failed compaction owns the final settled outcome.
1828
- generation.stagedOverflowFailure = null;
1829
- }
1830
- if (event.type === 'compaction_end' &&
1831
- event.reason === 'overflow' &&
1832
- event.willRetry === false &&
1833
- typeof event.errorMessage === 'string') {
1834
- generation.stagedOverflowFailure = { errorMessage: event.errorMessage };
1835
- }
1836
- if (event.type === 'agent_settled') {
1837
- agentRunActive = false;
1838
- publishSettledProviderFault(generation);
1839
- if (shutdownRequested) {
1840
- disposeAndExit('shutdown-after-settle');
1841
- return;
1842
- }
1843
- }
1844
- observeToolGroupEvent(event);
1845
- relayEvent(event, generation);
1846
- if (event.type === 'agent_settled')
1847
- scheduleFaultRetry();
1848
- }
1849
- catch (err) {
1850
- emitEvent({
1851
- level: 'debug',
1852
- event: 'broker.runtime.diagnostic',
1853
- fields: { message: `event relay threw: ${String(err)}` },
1854
- });
1855
- }
1856
- };
1857
- const enqueueRebind = (candidateSession, candidateServices) => {
1858
- const ticket = ++latestRebindRequest;
1859
- const task = rebindTail.then(async () => {
1860
- if (ticket !== latestRebindRequest)
1861
- return;
1862
- installedGeneration?.executingTools.clear();
1863
- removeInstalledSubscription();
1864
- // This candidate is now the engine session a mid-rebind fatal cleanup must
1865
- // dispose — set before the awaited bindExtensions so a timeout/fatal during
1866
- // that await disposes the candidate rather than the stale committed session.
1867
- // Shared session/services ownership still only commits below, post-await.
1868
- activeSession = candidateSession;
1869
- streamWatchdog.reset();
1870
- agentRunActive = false;
1871
- if (pendingUpdateTimer !== null) {
1872
- clearTimeout(pendingUpdateTimer);
1873
- pendingUpdateTimer = null;
1874
- }
1875
- pendingUpdate = null;
1876
- clearFaultRetryTimer();
1877
- providerFallbackInFlight = null;
1878
- notFoundFallbackInFlight = null;
1879
- reloadAuthInFlight = null;
1880
- openToolGroup = undefined;
1881
- toolGroupsByCallId.clear();
1882
- sawAssistantProse = false;
1883
- await candidateSession.bindExtensions({
1884
- uiContext,
1885
- // 'rpc' — NOT 'print'. Per pi docs/rpc.md:1078 this is exactly our shape:
1886
- // a headless engine whose UI methods are functional through a remote client
1887
- // (our uiContext relays them as `display_*` / `extension_ui_request` frames).
1888
- // 'print' would tell every extension `ctx.mode === 'print'` — pi's own label
1889
- // for "extensions run but can't prompt" — while we hand them a uiContext that
1890
- // can. (`ctx.hasUI` is derived from uiContext identity, not from mode, so it
1891
- // was already true; this fixes the mode label the extensions actually read.)
1892
- // The `ctx.mode === 'tui'` guards in canvas-view/-resume/-commands stay dead.
1893
- mode: 'rpc',
1894
- shutdownHandler: requestShutdown,
1895
- });
1896
- if (ticket !== latestRebindRequest)
1897
- return;
1898
- removeInstalledSubscription();
1899
- session = candidateSession;
1900
- services = candidateServices;
1901
- if (cfg.editorName !== undefined && cfg.editorName !== '') {
1902
- try {
1903
- candidateSession.setSessionName(cfg.editorName);
1904
- }
1905
- catch {
1906
- /* non-fatal: naming is cosmetic */
1907
- }
1908
- }
1909
- const generation = {
1910
- id: ++nextSubscriptionGeneration,
1911
- session: candidateSession,
1912
- fallback: { blocked: false },
1913
- stagedProviderAgentEnd: null,
1914
- stagedOverflowFailure: null,
1915
- executingTools: new Set(),
1916
- };
1917
- unsubscribe = session.subscribe((event) => {
1918
- if (installedGeneration !== generation)
1919
- return;
1920
- const ambientId = operationIdContext.current();
1921
- if (ambientId !== undefined) {
1922
- executeSubscriptionCallback(generation, event);
1923
- return;
1924
- }
1925
- const fallback = generation.fallback;
1926
- if (event.type === 'turn_start' && fallback.storedId !== undefined) {
1927
- fallback.storedId = undefined;
1928
- fallback.blocked = true;
1929
- operationIdContext.fresh(() => {
1930
- emitEvent({
1931
- level: 'warn',
1932
- event: 'broker.turn.fallback_overlap',
1933
- fields: { generation: generation.id, engine_event: event.type },
1934
- });
1935
- executeSubscriptionCallback(generation, event);
1936
- });
1937
- return;
1938
- }
1939
- if (event.type === 'turn_start' && !fallback.blocked) {
1940
- operationIdContext.fresh((operationId) => {
1941
- fallback.storedId = operationId;
1942
- executeSubscriptionCallback(generation, event);
1943
- });
1944
- return;
1945
- }
1946
- const storedId = fallback.storedId;
1947
- if (storedId !== undefined && !fallback.blocked) {
1948
- operationIdContext.run(storedId, () => {
1949
- executeSubscriptionCallback(generation, event);
1950
- if (event.type === 'agent_settled' && fallback.storedId === storedId) {
1951
- fallback.storedId = undefined;
1952
- }
1953
- });
1954
- return;
1955
- }
1956
- executeSubscriptionCallback(generation, event);
1957
- });
1958
- installedGeneration = generation;
1959
- captureSystemPrompt(candidateSession);
1960
- });
1961
- rebindTail = task.catch(() => undefined);
1962
- return task;
1963
- };
1964
- // Register the rebind BEFORE the first bind so a replacement triggered during
1965
- // startup is honored; then use the same ticketed lane for the initial install.
1966
- runtime?.setRebindSession((candidateSession) => enqueueRebind(candidateSession, runtime.services));
1967
- await enqueueRebind(session, services);
1968
- // -------------------------------------------------------------------------
1969
- // Drive the engine on behalf of a writable client.
1970
- // -------------------------------------------------------------------------
1971
- // ---------------------------------------------------------------------------
1972
- // Inline memory-reference inventory (design: inline memory references) — a
1973
- // single broker-level memo shared by BOTH the `list_memory_refs` read-op
1974
- // reply and per-submission guidance resolution below, so a burst of reads +
1975
- // submissions between reloads never re-walks the corpus per call. Cleared
1976
- // (work item 3) only on a SUCCESSFUL `session.reload()` — a failed reload
1977
- // leaves the prior memo intact, since nothing about the corpus is known to
1978
- // have changed.
1979
- // ---------------------------------------------------------------------------
1980
- let memoizedRefInventory = null;
1981
- const getRefInventory = () => {
1982
- if (memoizedRefInventory === null)
1983
- memoizedRefInventory = buildRefInventory();
1984
- return memoizedRefInventory;
1985
- };
1986
- const buildListMemoryRefsData = (id) => ({
1987
- type: 'data',
1988
- id,
1989
- kind: 'list_memory_refs',
1990
- refs: getRefInventory().refs,
1991
- engineLeadingCommandNames: engineLeadingCommandTokens(session),
1992
- });
1993
- /** Resolve the hidden guidance text for a human frame's verbatim text, or
1994
- * `null` when no guidance should be sent (a leading engine command/skill/
1995
- * template, no resolvable refs, or a degraded inventory/guidance build —
1996
- * work item 6: any failure here degrades to "no guidance", logged once as a
1997
- * structured warning, and NEVER blocks/alters the human delivery below). */
1998
- const resolveFrameGuidance = (text) => {
1999
- try {
2000
- if (isLeadingEngineCommand(text, session))
2001
- return null;
2002
- return buildGuidance(text, getRefInventory().names);
2003
- }
2004
- catch (err) {
2005
- emitEvent({
2006
- level: 'warn',
2007
- event: 'broker.memory_refs.guidance_resolve_failed',
2008
- error: err,
2009
- });
2010
- return null;
2011
- }
2012
- };
2013
- // Prompt dispatches whose promise has not yet settled. `session.prompt()`
2014
- // does async setup (input transforms, model/auth validation, compaction)
2015
- // BEFORE `_isAgentRunActive` flips, so for a short window after a routed
2016
- // deliver the engine reads idle and `session.abort()` is a silent no-op — an
2017
- // id-bearing abort racing that window would miss the turn entirely (verified
2018
- // live). The counter lets it distinguish "genuinely idle" from "turn still
2019
- // igniting" and wait for the run to become abortable.
2020
- let pendingPromptDispatches = 0;
2021
- const trackPromptDispatch = (p) => {
2022
- pendingPromptDispatches++;
2023
- const tracked = Promise.resolve(p);
2024
- void tracked.finally(() => {
2025
- pendingPromptDispatches--;
2026
- });
2027
- return tracked;
2028
- };
2029
- /**
2030
- * Drive the engine for one client frame. For a prompt/follow_up frame this
2031
- * returns a promise that settles with the engine call's OUTCOME — `undefined`
2032
- * when the call succeeded, the rejection value when it did not — so a caller
2033
- * that must report success (the `deliver` ack) can read it instead of assuming
2034
- * the dispatch worked. Every other frame type keeps the fire-and-forget shape
2035
- * and returns `undefined`.
2036
- */
2037
- const driveEngine = (client, frame) => {
2038
- // The engine call's failure, captured for the returned outcome. relayError
2039
- // still relays the error frame; recording it is what lets the caller ack
2040
- // from the outcome rather than from the fact that a call was made at all.
2041
- let failure;
2042
- const relayError = (err) => {
2043
- failure = err;
2044
- sendFrame(client, { type: 'error', code: 'engine_error', message: String(err) });
2045
- };
2046
- // `prompt { interrupt: true }` is one atomic viewer intention: end whatever
2047
- // is producing now, then start this as a new turn. A normal mid-stream
2048
- // prompt deliberately steers into pi's queue; the whip must not.
2049
- if (frame.type === 'prompt' && frame.interrupt === true) {
2050
- return (async () => {
2051
- if (session.isBashRunning) {
2052
- session.abortBash();
2053
- }
2054
- else {
2055
- // A prompt can be in pi's async setup before isStreaming flips. Match
2056
- // the correlated interrupt's guard so a whip cannot silently miss it.
2057
- if (!session.isStreaming && pendingPromptDispatches > 0) {
2058
- const deadline = Date.now() + 5_000;
2059
- while (Date.now() < deadline && !session.isStreaming && pendingPromptDispatches > 0) {
2060
- await new Promise((resolve) => setTimeout(resolve, 25));
2061
- }
2062
- }
2063
- if (session.isStreaming)
2064
- await session.abort();
2065
- }
2066
- return driveEngine(client, {
2067
- type: 'prompt',
2068
- text: frame.text,
2069
- ...(frame.images !== undefined ? { images: frame.images } : {}),
2070
- });
2071
- })().catch((err) => {
2072
- relayError(err);
2073
- return failure;
2074
- });
2075
- }
2076
- // Hidden `crtr-memory-refs` guidance rides ALONGSIDE the human frame's own
2077
- // engine call, never rewriting `frame.text` — see resolveFrameGuidance /
2078
- // chooseGuidanceDeliveryMode above. A failed SEND (as opposed to a failed
2079
- // resolve, handled above) is likewise logged-and-swallowed: it must never
2080
- // reach `relayError`, which is reserved for the human call's own outcome.
2081
- const sendGuidance = (guidance, deliverAs) => session
2082
- .sendCustomMessage({ customType: 'crtr-memory-refs', content: guidance, display: false }, { deliverAs })
2083
- .catch((err) => {
2084
- emitEvent({
2085
- level: 'warn',
2086
- event: 'broker.memory_refs.guidance_send_failed',
2087
- error: err,
2088
- });
2089
- });
2090
- switch (frame.type) {
2091
- case 'prompt':
2092
- case 'follow_up': {
2093
- // Settle on the engine call's outcome: `failure` is set by relayError
2094
- // before the caught promise resolves, so reading it after the chain is
2095
- // the call's verdict.
2096
- const settle = (p) => p.then(() => failure);
2097
- const dispatch = () => {
2098
- const route = resolveEngineRoute(frame, session.isStreaming);
2099
- const guidance = resolveFrameGuidance(frame.text);
2100
- if (guidance === null) {
2101
- if (route.call === 'prompt') {
2102
- // A routed prompt STARTS a turn — including an idle follow_up
2103
- // escalated by m-C — so it gets a fresh operation id. A true
2104
- // (mid-stream) followUp rides the current turn's operation, like
2105
- // steer.
2106
- return settle(trackPromptDispatch(operationIdContext.fresh(() => session.prompt(frame.text, route.options).catch(relayError))));
2107
- }
2108
- // C1: pi's followUp() takes images as a POSITIONAL 2nd arg (`followUp(text, images?)`).
2109
- return settle(session.followUp(frame.text, frame.images).catch(relayError));
2110
- }
2111
- const deliverAs = chooseGuidanceDeliveryMode(frame.type, route.call, session.isStreaming);
2112
- if (route.call === 'prompt') {
2113
- if (deliverAs === 'nextTurn') {
2114
- // The ONLY ordering where guidance precedes the human call — pi's
2115
- // prompt() reads+clears its pending-nextTurn queue synchronously
2116
- // at call time, injecting it into the SAME turn as the new user
2117
- // message. promptWithNextTurnGuidance queues the guidance and then
2118
- // ATOMICALLY clears its own pending entry if prompt() settles
2119
- // without ever reaching that drain (an early throw/return leaves
2120
- // nothing stranded to leak into a later, unrelated turn).
2121
- return settle(trackPromptDispatch(promptWithNextTurnGuidance(session, guidance, () => operationIdContext.fresh(() => session.prompt(frame.text, route.options))).catch(relayError)));
2122
- }
2123
- return settle(trackPromptDispatch(operationIdContext.fresh(() => session.prompt(frame.text, route.options).catch(relayError).then(() => sendGuidance(guidance, deliverAs)))));
2124
- }
2125
- // deliverAs is always 'followUp' here per chooseGuidanceDeliveryMode's
2126
- // contract (a followUp routeCall never yields 'nextTurn').
2127
- return settle(session.followUp(frame.text, frame.images).catch(relayError).then(() => sendGuidance(guidance, deliverAs)));
2128
- };
2129
- // /login acknowledges before its asynchronous local refresh completes.
2130
- // An idle turn must not overtake that refresh and launch on the stale
2131
- // route; true mid-stream follow-ups keep their immediate queue behavior.
2132
- const authReload = session.isStreaming ? undefined : reloadAuthInFlight?.promise;
2133
- if (authReload === undefined)
2134
- return dispatch();
2135
- const waitingGeneration = installedGeneration;
2136
- const waitingSession = session;
2137
- return authReload.then(() => {
2138
- if (installedGeneration !== waitingGeneration || session !== waitingSession) {
2139
- relayError(new Error('session changed while prompt waited for authentication reload'));
2140
- return failure;
2141
- }
2142
- return dispatch();
2143
- });
2144
- }
2145
- case 'steer': {
2146
- // A viewer submits ordinary text as `steer` while the agent is running,
2147
- // including a slash command injected by the tmux prefix menu. pi's
2148
- // steer() deliberately rejects registered extension commands because
2149
- // they cannot be queued; prompt() performs the command preflight first
2150
- // and executes it immediately even while streaming. It also expands a
2151
- // matching skill/template before applying the same steer behavior.
2152
- if (resolveSteerCall(frame.text, session) === 'prompt') {
2153
- void session.prompt(frame.text, { images: frame.images, streamingBehavior: 'steer' }).catch(relayError);
2154
- break;
2155
- }
2156
- const guidance = resolveFrameGuidance(frame.text);
2157
- // C1: pi's steer() takes images as a POSITIONAL 2nd arg (`steer(text, images?)`).
2158
- if (guidance === null) {
2159
- void session.steer(frame.text, frame.images).catch(relayError);
2160
- break;
2161
- }
2162
- void session.steer(frame.text, frame.images).catch(relayError).then(() => sendGuidance(guidance, 'steer'));
2163
- break;
2164
- }
2165
- case 'abort': {
2166
- // esc/abort cancels whatever is live: a running `!` command (abortBash)
2167
- // takes precedence over the agent turn, mirroring pi interactive mode.
2168
- if (session.isBashRunning) {
2169
- session.abortBash();
2170
- if (frame.id !== undefined)
2171
- ackTo(client, 'abort', true, 'bash', frame.id);
2172
- break;
2173
- }
2174
- if (frame.id === undefined) {
2175
- // A viewer's Esc — unchanged instant semantics, no ack.
2176
- void session.abort().catch(relayError);
2177
- break;
2178
- }
2179
- // An id-bearing abort (a one-shot daemon interrupt) must close the
2180
- // pre-turn window: a routed deliver whose prompt() is still in async
2181
- // setup reads as idle and abort() would silently miss it. If a prompt
2182
- // dispatch is pending, wait (bounded) for the run to become abortable
2183
- // — or for every dispatch to settle without one (an early return) —
2184
- // then abort and ack with what was actually interrupted.
2185
- void (async () => {
2186
- try {
2187
- if (!session.isStreaming && pendingPromptDispatches > 0) {
2188
- const deadline = Date.now() + 5_000;
2189
- while (Date.now() < deadline && !session.isStreaming && pendingPromptDispatches > 0) {
2190
- await new Promise((r) => setTimeout(r, 25));
2191
- }
2192
- }
2193
- const detail = session.isStreaming ? 'turn' : 'idle';
2194
- await session.abort();
2195
- ackTo(client, 'abort', true, detail, frame.id);
2196
- }
2197
- catch (err) {
2198
- engineErrorTo(client, frame.id)(err);
2199
- }
2200
- })();
2201
- break;
2202
- }
2203
- case 'bash': {
2204
- // `!`/`!!` bash: run via session.executeBash (records a bashExecution
2205
- // context message, starts NO agent turn) and relay its lifecycle to ALL
2206
- // viewers as bash_start/bash_output/bash_end so each renders the live
2207
- // component. Serialized like pi's interactive mode — reject a second `!`
2208
- // while one is already running rather than racing _bashAbortController.
2209
- const command = frame.command;
2210
- if (command.trim() === '')
2211
- break;
2212
- if (session.isBashRunning) {
2213
- sendFrame(client, {
2214
- type: 'error',
2215
- code: 'bash_busy',
2216
- message: 'A bash command is already running',
2217
- });
2218
- break;
2219
- }
2220
- const exclude = frame.excludeFromContext === true;
2221
- // Order before any held assistant message_update, so a `!` run mid-stream
2222
- // never renders ahead of the update it follows.
2223
- flushPendingUpdate();
2224
- // The live bash component is a visible transcript boundary, even though
2225
- // its persisted bashExecution message arrives later via the session.
2226
- finishOpenToolGroup();
2227
- broadcast({ type: 'bash_start', command, excludeFromContext: exclude });
2228
- void session
2229
- .executeBash(command, (chunk) => broadcast({ type: 'bash_output', chunk }), {
2230
- excludeFromContext: exclude,
2231
- })
2232
- .then((result) => {
2233
- broadcast({
2234
- type: 'bash_end',
2235
- exitCode: result.exitCode,
2236
- cancelled: result.cancelled,
2237
- truncated: result.truncated,
2238
- fullOutputPath: result.fullOutputPath,
2239
- });
2240
- })
2241
- .catch(relayError);
2242
- break;
2243
- }
2244
- }
2245
- };
2246
- // -------------------------------------------------------------------------
2247
- // Command-op helpers (T3, §2.3). The write guard is hoisted here (it was
2248
- // inlined twice) and reused by all mutating ops; the ack/error replies
2249
- // and a few resolvers keep the per-op cases one-liners.
2250
- // -------------------------------------------------------------------------
2251
- /** Reject a read-only client for a mutating op. The decision reads this
2252
- * client's OWN fixed role — there is no shared writer slot, so any number of
2253
- * writable clients pass concurrently. Returns true when rejected (the caller
2254
- * should `break`). */
2255
- const notWritable = (client, what, id) => {
2256
- if (client.role === 'controller')
2257
- return false;
2258
- sendFrame(client, {
2259
- type: 'error',
2260
- code: 'read_only',
2261
- message: `a read-only client may not ${what}`,
2262
- // M1: echo a correlated request's id so its pending-by-id promise rejects
2263
- // rather than hanging (e.g. a read-only `dequeue`).
2264
- ...(id !== undefined ? { id } : {}),
2265
- });
2266
- return true;
2267
- };
2268
- const ackTo = (client, op, ok = true, detail, id) => sendFrame(client, {
2269
- type: 'ack',
2270
- for: op,
2271
- ok,
2272
- ...(detail !== undefined ? { detail } : {}),
2273
- ...(id !== undefined ? { id } : {}),
2274
- });
2275
- const engineErrorTo = (client, id) => (err) => sendFrame(client, {
2276
- type: 'error',
2277
- code: 'engine_error',
2278
- message: String(err),
2279
- // M1: a correlated read-op / dequeue failure echoes its id so the viewer's
2280
- // pending-by-id promise rejects instead of hanging; uncorrelated callers
2281
- // pass no id (unchanged behavior).
2282
- ...(id !== undefined ? { id } : {}),
2283
- });
2284
- const isPreBoundaryReviewEntry = (entryId) => reviewCompanion &&
2285
- !session.sessionManager.getBranch(entryId).some((entry) => entry.type === 'custom_message' && entry.customType === REVIEW_BOUNDARY_CUSTOM_TYPE);
2286
- const reviewBoundaryRefusalTo = (client, targetId) => {
2287
- const refusal = new ReviewBoundaryNavigationError(targetId);
2288
- sendFrame(client, {
2289
- type: 'error',
2290
- code: refusal.code,
2291
- message: refusal.message,
2292
- });
2293
- };
2294
- /** Resolve a `provider/id` model spec against the LIVE services registry (which
2295
- * carries any extension-registered providers). Pure: returns undefined on a
2296
- * malformed spec or an unknown model. */
2297
- const findModelSpec = (spec) => {
2298
- const { modelSpec } = parseModelSpec(spec);
2299
- const slash = modelSpec.indexOf('/');
2300
- if (slash <= 0)
2301
- return undefined;
2302
- return registryOf(services).find(modelSpec.slice(0, slash), modelSpec.slice(slash + 1));
2303
- };
2304
- /** Resolve a model from a free-text query — `/model opus`, `/model fable`. Tries
2305
- * the exact `provider/id` spec first; failing that, searches the registry the
2306
- * way the picker's search box does (case-insensitive substring on the model id
2307
- * and the `provider/id` key) and returns the BEST match, preferring authed/
2308
- * available models. Returns undefined only when nothing matches. */
2309
- const resolveModelQuery = (query) => {
2310
- const exact = findModelSpec(query);
2311
- if (exact)
2312
- return exact;
2313
- const q = query.trim().toLowerCase();
2314
- if (q === '')
2315
- return undefined;
2316
- let all;
2317
- try {
2318
- all = registryOf(services).getAll();
2319
- }
2320
- catch {
2321
- return undefined;
2322
- }
2323
- let availableKeys;
2324
- try {
2325
- availableKeys = new Set(registryOf(services).getAvailable().map(modelKey));
2326
- }
2327
- catch {
2328
- availableKeys = new Set();
2329
- }
2330
- // Rank: exact id (100) > exact provider/id (90) > id prefix (80) > id
2331
- // substring (60) > provider/id substring (40); +5 if the model is available.
2332
- const rank = (m) => {
2333
- const id = m.id.toLowerCase();
2334
- const key = modelKey(m).toLowerCase();
2335
- let base = -1;
2336
- if (id === q)
2337
- base = 100;
2338
- else if (key === q)
2339
- base = 90;
2340
- else if (id.startsWith(q))
2341
- base = 80;
2342
- else if (id.includes(q))
2343
- base = 60;
2344
- else if (key.includes(q))
2345
- base = 40;
2346
- else
2347
- return -1;
2348
- return base + (availableKeys.has(modelKey(m)) ? 5 : 0);
2349
- };
2350
- let best;
2351
- let bestRank = 0;
2352
- for (const m of all) {
2353
- const r = rank(m);
2354
- if (r > bestRank) {
2355
- bestRank = r;
2356
- best = m;
2357
- }
2358
- }
2359
- return best;
2360
- };
2361
- const buildCommandList = () => {
2362
- const out = [];
2363
- for (const b of BUILTIN_SLASH_COMMANDS) {
2364
- out.push({ name: b.name, description: b.description, source: 'builtin' });
2365
- }
2366
- try {
2367
- for (const c of session.extensionRunner.getRegisteredCommands()) {
2368
- const expansion = c.expansion;
2369
- out.push({
2370
- name: c.invocationName,
2371
- description: c.description ?? '',
2372
- source: 'command',
2373
- ...(isDeterministicCommandExpansion(expansion) ? { expansion } : {}),
2374
- });
2375
- }
2376
- }
2377
- catch {
2378
- /* an engine without a live extensionRunner (e.g. the fake) — builtins only */
2379
- }
2380
- try {
2381
- for (const t of session.promptTemplates) {
2382
- out.push({ name: t.name, description: t.description, source: 'template', template: t.content });
2383
- }
2384
- }
2385
- catch {
2386
- /* ignore */
2387
- }
2388
- return out;
2389
- };
2390
- // -------------------------------------------------------------------------
2391
- // Read-op data builders (operator-view picker payloads, §5 Unit A). Each is a
2392
- // PURE getter read against the live engine session — the data a native pi
2393
- // picker's constructor needs, serialized for the viewer. Not role-gated
2394
- // (read-only, like get_commands), so the web bridge's observer connection can
2395
- // populate pickers too.
2396
- // -------------------------------------------------------------------------
2397
- // A model-less session's model is EITHER undefined OR the SDK unknown-stub;
2398
- // both map to the WireModelRef no-model value (null), so list_models.current
2399
- // and get_settings.settings.model never disclose a fictitious `unknown/unknown`.
2400
- const toModelRef = (m) => m === undefined || isUnknownModel(m) ? null : { provider: m.provider, id: m.id };
2401
- const modelKey = (m) => `${m.provider}/${m.id}`;
2402
- const buildListModelsData = (id) => {
2403
- const reg = registryOf(session);
2404
- let availableIds = [];
2405
- try {
2406
- availableIds = reg.getAvailable().map(modelKey);
2407
- }
2408
- catch {
2409
- /* getAvailable touches auth config; degrade to "none known available" */
2410
- }
2411
- return {
2412
- type: 'data',
2413
- id,
2414
- kind: 'list_models',
2415
- models: reg.getAll(),
2416
- current: toModelRef(session.model),
2417
- availableIds,
2418
- scopedModels: session.scopedModels.map((s) => ({
2419
- model: s.model,
2420
- thinkingLevel: s.thinkingLevel,
2421
- })),
2422
- enabledModelIds: session.settingsManager.getEnabledModels() ?? null,
2423
- };
2424
- };
2425
- const buildScopedModelsData = (id) => ({
2426
- type: 'data',
2427
- id,
2428
- kind: 'list_scoped_models',
2429
- allModels: registryOf(session).getAll(),
2430
- enabledModelIds: session.settingsManager.getEnabledModels() ?? null,
2431
- });
2432
- const buildGetTreeData = (id) => {
2433
- const sm = session.sessionManager;
2434
- const tree = reviewCompanion ? boundaryRootedTree(sm.getTree()) : sm.getTree();
2435
- const visibleEntryIds = new Set();
2436
- const collectEntryIds = (nodes) => {
2437
- for (const node of nodes) {
2438
- visibleEntryIds.add(node.entry.id);
2439
- collectEntryIds(node.children);
2440
- }
2441
- };
2442
- collectEntryIds(tree);
2443
- const forkPoints = session.getUserMessagesForForking()
2444
- .filter((u) => visibleEntryIds.has(u.entryId))
2445
- .map((u) => ({
2446
- id: u.entryId,
2447
- text: u.text,
2448
- timestamp: sm.getEntry(u.entryId)?.timestamp,
2449
- }));
2450
- const currentLeafId = sm.getLeafId();
2451
- return {
2452
- type: 'data',
2453
- id,
2454
- kind: 'get_tree',
2455
- tree,
2456
- currentLeafId: currentLeafId !== null && visibleEntryIds.has(currentLeafId) ? currentLeafId : null,
2457
- forkPoints,
2458
- };
2459
- };
2460
- const buildSettingsData = (id) => {
2461
- const sm = session.settingsManager;
2462
- // Theme names are enumerable broker-side via the resource loader. The theme
2463
- // SUBMENU itself is viewer-local (theme is a viewer-only concern), but the
2464
- // settings menu still shows currentTheme/availableThemes, so include them.
2465
- let availableThemes = [];
2466
- try {
2467
- availableThemes = session.resourceLoader
2468
- .getThemes()
2469
- .themes.map((t) => t.name ?? '')
2470
- .filter((n) => n !== '');
2471
- }
2472
- catch {
2473
- /* loader without themes — viewer falls back to its local theme registry */
2474
- }
2475
- const settings = {
2476
- autoCompact: session.autoCompactionEnabled,
2477
- showImages: sm.getShowImages(),
2478
- imageWidthCells: sm.getImageWidthCells(),
2479
- autoResizeImages: sm.getImageAutoResize(),
2480
- blockImages: sm.getBlockImages(),
2481
- enableSkillCommands: sm.getEnableSkillCommands(),
2482
- steeringMode: session.steeringMode,
2483
- followUpMode: session.followUpMode,
2484
- transport: sm.getTransport(),
2485
- httpIdleTimeoutMs: sm.getHttpIdleTimeoutMs(),
2486
- thinkingLevel: session.thinkingLevel,
2487
- availableThinkingLevels: session.getAvailableThinkingLevels(),
2488
- currentTheme: sm.getTheme() ?? '',
2489
- // Light/dark appearance is a property of the terminal the VIEWER is attached
2490
- // to; the headless broker has none, so emit a placeholder. The viewer owns
2491
- // the theme submenu and resolves real terminal appearance locally.
2492
- terminalTheme: 'dark',
2493
- availableThemes,
2494
- hideThinkingBlock: sm.getHideThinkingBlock(),
2495
- collapseChangelog: sm.getCollapseChangelog(),
2496
- enableInstallTelemetry: sm.getEnableInstallTelemetry(),
2497
- doubleEscapeAction: sm.getDoubleEscapeAction(),
2498
- treeFilterMode: sm.getTreeFilterMode(),
2499
- showHardwareCursor: sm.getShowHardwareCursor(),
2500
- editorPaddingX: sm.getEditorPaddingX(),
2501
- autocompleteMaxVisible: sm.getAutocompleteMaxVisible(),
2502
- quietStartup: sm.getQuietStartup(),
2503
- clearOnShrink: sm.getClearOnShrink(),
2504
- showTerminalProgress: sm.getShowTerminalProgress(),
2505
- warnings: sm.getWarnings(),
2506
- defaultProjectTrust: sm.getDefaultProjectTrust(),
2507
- showCacheMissNotices: sm.getShowCacheMissNotices(),
2508
- outputPad: sm.getOutputPad(),
2509
- autoRetry: session.autoRetryEnabled,
2510
- model: toModelRef(session.model),
2511
- };
2512
- return { type: 'data', id, kind: 'get_settings', settings };
2513
- };
2514
- // Cache per-session wire info keyed by (path, mtime, size) so reopening the
2515
- // picker doesn't re-read the whole corpus (see session-list-cache.ts).
2516
- const sessionListCache = new SessionListCache(sessionListCachePath());
2517
- // Pre-warm the cwd scope (the picker's default tab) in the background so the
2518
- // first /resume open on this fresh broker is already warm.
2519
- sessionListCache.prewarm(session.sessionManager.getSessionDir());
2520
- const buildSessionsData = async (id, scope) => {
2521
- const sm = session.sessionManager;
2522
- const sessionDir = sm.getSessionDir();
2523
- const sessions = scope === 'all'
2524
- ? await sessionListCache.listAll(piSessionsRoot())
2525
- : await sessionListCache.listDir(sessionDir);
2526
- return {
2527
- type: 'data',
2528
- id,
2529
- kind: 'list_sessions',
2530
- scope,
2531
- sessions,
2532
- currentSessionFile: session.sessionFile,
2533
- };
2534
- };
2535
- /** Runtime-mutating viewer replacements are serialized through their complete
2536
- * rebind, re-welcome, and client settlement. Admission is bounded to at most
2537
- * one active head plus one queued follow-up (`MAX_ADMITTED_REPLACEMENTS`); a
2538
- * request over that cap is a definite, correlated rejection that never touches
2539
- * the tail. Each admitted head gets one `REPLACEMENT_DEADLINE_MS` budget
2540
- * covering the full runtime mutation through its awaited rebind. The SDK has
2541
- * no cancellation primitive for these mutations, so a rejection or an expired
2542
- * deadline after admission leaves runtime/shared state indeterminate — the
2543
- * only truthful response is broker-terminal exit, never a retry, and the tail
2544
- * never advances to the queued closure against that uncertain state. */
2545
- let replacementTail = Promise.resolve();
2546
- let admittedReplacements = 0;
2547
- const runReplacement = (client, op, run) => {
2548
- if (runtime === undefined) {
2549
- sendFrame(client, {
2550
- type: 'error',
2551
- code: 'engine_error',
2552
- message: 'session replacement unsupported at this engine pin',
2553
- });
2554
- return;
2555
- }
2556
- if (admittedReplacements >= MAX_ADMITTED_REPLACEMENTS) {
2557
- operationIdContext.fresh(() => {
2558
- emitEvent({
2559
- level: 'warn',
2560
- event: 'broker.session_replacement.rejected',
2561
- outcome: 'skipped',
2562
- fields: {
2563
- op,
2564
- reason: 'capacity',
2565
- admitted: MAX_ADMITTED_REPLACEMENTS,
2566
- cap: MAX_ADMITTED_REPLACEMENTS,
2567
- },
2568
- });
2569
- });
2570
- ackTo(client, op, false, `session replacement busy (${MAX_ADMITTED_REPLACEMENTS} active or queued)`);
2571
- return;
2572
- }
2573
- admittedReplacements++;
2574
- const task = replacementTail.then(async () => {
2575
- try {
2576
- // The whole attempt — success, cancellation, rejection, and timeout —
2577
- // executes inside one fresh operation scope so its outcome event (of
2578
- // whichever kind fires) carries one coherent operation id.
2579
- await operationIdContext.fresh(async () => {
2580
- const startedAt = process.hrtime.bigint();
2581
- let timer;
2582
- try {
2583
- const deadline = new Promise((resolve) => {
2584
- timer = setTimeout(() => resolve(REPLACEMENT_DEADLINE_EXCEEDED), REPLACEMENT_DEADLINE_MS);
2585
- });
2586
- const outcome = await Promise.race([run(runtime), deadline]);
2587
- if (outcome === REPLACEMENT_DEADLINE_EXCEEDED) {
2588
- emitEvent({
2589
- level: 'error',
2590
- event: 'broker.session_replacement.timed_out',
2591
- outcome: 'failed',
2592
- duration_ms: Number(process.hrtime.bigint() - startedAt) / 1_000_000,
2593
- fields: { op, deadline_ms: REPLACEMENT_DEADLINE_MS, mutation_outcome: 'indeterminate' },
2594
- });
2595
- ackTo(client, op, false, 'session replacement timed out; broker restarting because session state is indeterminate');
2596
- disposeAndExit('session-replacement-timeout', 1);
2597
- return;
2598
- }
2599
- if (!outcome.cancelled)
2600
- reWelcomeAll();
2601
- ackTo(client, op, !outcome.cancelled, outcome.cancelled ? outcome.detail ?? 'cancelled by extension' : undefined);
2602
- }
2603
- catch (err) {
2604
- emitEvent({
2605
- level: 'error',
2606
- event: 'broker.session_replacement.failed',
2607
- outcome: 'failed',
2608
- error: err,
2609
- fields: { op, mutation_outcome: 'indeterminate' },
2610
- });
2611
- ackTo(client, op, false, 'session replacement failed; broker restarting because session state is indeterminate');
2612
- disposeAndExit('session-replacement-failed', 1);
2613
- }
2614
- finally {
2615
- clearTimeout(timer);
2616
- }
2617
- });
2618
- }
2619
- finally {
2620
- admittedReplacements--;
2621
- }
2622
- });
2623
- replacementTail = task.catch(() => undefined);
2624
- };
2625
- const handleFrame = (client, frame) => {
2626
- switch (frame.type) {
2627
- case 'hello': {
2628
- // Admit at the REQUESTED role — unconditionally. `controller` is a
2629
- // per-client write capability, not a singleton slot, so an arriving
2630
- // writable client never demotes (or is demoted by) an already-attached
2631
- // one: N terminal panes and browser tabs are all writable at once.
2632
- //
2633
- // A repeated hello on an already-helloed socket must not RE-role it: the
2634
- // role is fixed for the socket's lifetime, so every downstream gate and
2635
- // every already-fanned-out dialog keep the authority the first welcome
2636
- // stated. A duplicate hello still re-sends the catch-up snapshot.
2637
- const firstHello = !client.helloed;
2638
- client.id = frame.client_id;
2639
- if (firstHello) {
2640
- client.helloed = true;
2641
- client.role = frame.role === 'controller' ? 'controller' : 'observer';
2642
- }
2643
- sendWelcome(client);
2644
- if (firstHello)
2645
- persistAttachState(); // a helloed viewer arrived
2646
- // welcome carried the FIRST pending dialog; replay any extras so a writable
2647
- // client attaching mid-dialog can answer every in-flight dialog.
2648
- if (client.role === 'controller')
2649
- replayExtraPendingDialogsTo(client);
2650
- break;
2651
- }
2652
- case 'prompt':
2653
- case 'steer':
2654
- case 'follow_up':
2655
- case 'abort':
2656
- case 'bash': {
2657
- if (notWritable(client, 'drive the engine'))
2658
- break;
2659
- driveEngine(client, frame);
2660
- break;
2661
- }
2662
- case 'deliver': {
2663
- // Interactive delivery from a one-shot server client (crtrd's
2664
- // `POST /messages` `delivery:'interactive'` path). Routed HERE,
2665
- // broker-side, because only the broker authoritatively knows streaming
2666
- // state: the synthesized `prompt` frame reuses resolveEngineRoute's m-B
2667
- // routing (idle → prompt(); mid-stream → prompt({streamingBehavior:
2668
- // 'steer'}), pi's own Enter-while-streaming submit) plus the memory-refs
2669
- // guidance path — byte-identical behavior to a viewer's Enter. Acked
2670
- // once ACCEPTED (correlated by the frame's id) so the daemon returns
2671
- // deterministically; `detail` reports which route was taken.
2672
- //
2673
- // The ack reports the engine call's OUTCOME, never merely the fact that
2674
- // a call was made: `session.prompt()` rejects outright when the model's
2675
- // provider has no credential, and acking unconditionally reported
2676
- // `delivered:true` for a message the engine had refused — the caller's
2677
- // one-shot client then closed before the error frame arrived and the
2678
- // message vanished with no trace. A prompt route therefore acks on the
2679
- // race between acceptance (agent_start) and rejection; a steer route
2680
- // joins a turn already running, so no new agent_start is coming and its
2681
- // ack stays at routing time.
2682
- if (notWritable(client, 'drive the engine', frame.id))
2683
- break;
2684
- const via = session.isStreaming ? 'steer' : 'prompt';
2685
- let delivered = false;
2686
- const ackAccepted = () => {
2687
- if (delivered)
2688
- return;
2689
- delivered = true;
2690
- ackTo(client, 'deliver', true, via, frame.id);
2691
- };
2692
- if (via === 'prompt')
2693
- turnAcceptedWaiters.add(ackAccepted);
2694
- const outcome = driveEngine(client, {
2695
- type: 'prompt',
2696
- text: frame.text,
2697
- ...(frame.images !== undefined ? { images: frame.images } : {}),
2698
- });
2699
- if (via === 'steer' || outcome === undefined) {
2700
- turnAcceptedWaiters.delete(ackAccepted);
2701
- ackAccepted();
2702
- break;
2703
- }
2704
- void outcome.then((failure) => {
2705
- turnAcceptedWaiters.delete(ackAccepted);
2706
- if (failure === undefined) {
2707
- ackAccepted();
2708
- return;
2709
- }
2710
- // The error frame already went to the client, but a one-shot deliver
2711
- // client is gone by then — record it where it survives.
2712
- emitEvent({ level: 'error', event: 'broker.prompt.deliver_failed', error: failure });
2713
- if (delivered)
2714
- return;
2715
- delivered = true;
2716
- ackTo(client, 'deliver', false, String(failure), frame.id);
2717
- });
2718
- break;
2719
- }
2720
- case 'extension_ui_response': {
2721
- if (notWritable(client, 'answer dialogs'))
2722
- break;
2723
- // First response for this id wins: `resolve` settles the entry and deletes
2724
- // it, so every later response from a peer that was also fanned this dialog
2725
- // finds nothing here and is a silent no-op.
2726
- pendingDialogs.get(frame.id)?.resolve(frame);
2727
- break;
2728
- }
2729
- // --- extended engine-command ops (T3, §1.2 floor set) ------------------
2730
- case 'set_model': {
2731
- if (notWritable(client, 'set the model'))
2732
- break;
2733
- const requested = parseModelSpec(frame.model);
2734
- let model;
2735
- let normalized;
2736
- try {
2737
- // Tier/alias specs (`opus`, `strong`, `anthropic/strong`, …) MUST resolve
2738
- // through the config ladder before touching the registry: the fuzzy
2739
- // registry search rank-ties `opus` between claude-3-opus-20240229 and
2740
- // claude-opus-4-8, and the deprecated 2024 model wins on catalog order.
2741
- // Concrete `provider/id` specs and genuine substrings (`fable`) pass
2742
- // through normalizeModel unchanged and take the exact/search path below.
2743
- normalized = parseModelSpec(normalizeModel(requested.modelSpec));
2744
- model = resolveModelQuery(normalized.modelSpec);
2745
- }
2746
- catch (err) {
2747
- // N2: registry.find should never throw on the real SDK, but a degenerate
2748
- // engine must still get a reply rather than a silently-dropped frame.
2749
- engineErrorTo(client)(err);
2750
- break;
2751
- }
2752
- if (model === undefined) {
2753
- sendFrame(client, {
2754
- type: 'error',
2755
- code: 'engine_error',
2756
- message: `no model matching '${frame.model}' in the registry`,
2757
- });
2758
- break;
2759
- }
2760
- void session
2761
- .setModel(model)
2762
- .then(() => {
2763
- // The caller's explicit `:thinking` wins; else the ladder cell's.
2764
- const thinking = requested.thinkingLevel ?? normalized.thinkingLevel;
2765
- if (thinking !== undefined)
2766
- session.setThinkingLevel(thinking);
2767
- ackTo(client, 'set_model');
2768
- // frame.pinned, when the sender pre-normalized `frame.model` (e.g.
2769
- // setModelLive), carries the RAW-token pin decision explicitly.
2770
- // Otherwise frame.model itself IS the raw/explicit token (a
2771
- // slash-command arg, or a concrete picker pick) and derives it.
2772
- broadcastModelChanged(true, frame.pinned !== undefined ? frame.pinned : isProviderPinnedModelToken(frame.model), true);
2773
- })
2774
- .catch(engineErrorTo(client));
2775
- break;
2776
- }
2777
- case 'deliver_custom_message': {
2778
- if (notWritable(client, 'deliver a custom message'))
2779
- break;
2780
- // Never triggers a turn either way. `nextTurn` folds the message into
2781
- // whatever turn comes next (situational context); omitting it pushes
2782
- // straight onto the message list so it lands FIRST in a still-empty
2783
- // session (a freshly-claimed spare's bearings). The inbox watcher's own
2784
- // situational send uses `followUp` + triggerTurn because there its job
2785
- // IS to wake the node.
2786
- void session
2787
- .sendCustomMessage({
2788
- customType: frame.customType,
2789
- content: frame.content,
2790
- display: true,
2791
- ...(frame.details !== undefined ? { details: frame.details } : {}),
2792
- }, ...(frame.deliverAs !== undefined ? [{ deliverAs: frame.deliverAs }] : []))
2793
- .then(() => ackTo(client, 'deliver_custom_message'))
2794
- .catch(engineErrorTo(client));
2795
- break;
2796
- }
2797
- case 'cycle_model': {
2798
- if (notWritable(client, 'cycle the model'))
2799
- break;
2800
- void session
2801
- .cycleModel(frame.direction)
2802
- .then(() => {
2803
- ackTo(client, 'cycle_model');
2804
- broadcastModelChanged(true, undefined, true);
2805
- })
2806
- .catch(engineErrorTo(client));
2807
- break;
2808
- }
2809
- case 'cycle_ladder': {
2810
- if (notWritable(client, 'cycle the model ladder'))
2811
- break;
2812
- // Resolve the next interleaved-ladder rung from the current model+thinking
2813
- // spec, then reuse the set_model path (registry resolve + thinking apply).
2814
- // The cycle only offers signed-in provider columns — same rule as the
2815
- // setup wizard's Models panel: pool occupancy for a managed provider
2816
- // (read live, so a login that landed mid-session counts immediately),
2817
- // else the registry's auth status. When nobody is signed in the helper
2818
- // returns every provider, so the cycle stays walkable pre-login.
2819
- const offeredProviders = (() => {
2820
- try {
2821
- const registry = registryOf(services);
2822
- return configuredLadderProviders((providerId) => (isManagedProvider(providerId) && readSubscriptionPool(providerId).length > 0)
2823
- || registry.getProviderAuthStatus(providerId).configured);
2824
- }
2825
- catch {
2826
- return undefined; // auth state unreadable — cycle the full ladder
2827
- }
2828
- })();
2829
- const currentSpec = formatModelSpec(session.model, session.thinkingLevel) ?? '';
2830
- const nextSpec = parseModelSpec(nextLadderModel(currentSpec, frame.direction === 'backward' ? -1 : 1, offeredProviders));
2831
- let ladderModel;
2832
- try {
2833
- ladderModel = resolveModelQuery(nextSpec.modelSpec);
2834
- }
2835
- catch (err) {
2836
- engineErrorTo(client)(err);
2837
- break;
2838
- }
2839
- if (ladderModel === undefined) {
2840
- sendFrame(client, {
2841
- type: 'error',
2842
- code: 'engine_error',
2843
- message: `ladder model '${nextSpec.modelSpec}' not in the registry`,
2844
- });
2845
- break;
2846
- }
2847
- void session
2848
- .setModel(ladderModel)
2849
- .then(() => {
2850
- if (nextSpec.thinkingLevel !== undefined)
2851
- session.setThinkingLevel(nextSpec.thinkingLevel);
2852
- ackTo(client, 'cycle_ladder');
2853
- broadcastModelChanged(true, undefined, true);
2854
- })
2855
- .catch(engineErrorTo(client));
2856
- break;
2857
- }
2858
- case 'cycle_thinking': {
2859
- if (notWritable(client, 'cycle the thinking level'))
2860
- break;
2861
- try {
2862
- session.cycleThinkingLevel();
2863
- persistModelChoice(undefined, true);
2864
- ackTo(client, 'cycle_thinking');
2865
- }
2866
- catch (err) {
2867
- engineErrorTo(client)(err);
2868
- }
2869
- break;
2870
- }
2871
- case 'dequeue': {
2872
- if (notWritable(client, 'dequeue messages', frame.id))
2873
- break;
2874
- try {
2875
- const { steering, followUp } = session.clearQueue();
2876
- sendFrame(client, { type: 'data', id: frame.id, kind: 'dequeue', steering, followUp });
2877
- }
2878
- catch (err) {
2879
- engineErrorTo(client, frame.id)(err);
2880
- }
2881
- break;
2882
- }
2883
- case 'set_thinking_level': {
2884
- if (notWritable(client, 'set the thinking level'))
2885
- break;
2886
- try {
2887
- session.setThinkingLevel(frame.level);
2888
- persistModelChoice(undefined, true);
2889
- ackTo(client, 'set_thinking_level');
2890
- }
2891
- catch (err) {
2892
- engineErrorTo(client)(err);
2893
- }
2894
- break;
2895
- }
2896
- case 'set_auto_retry': {
2897
- if (notWritable(client, 'set auto-retry'))
2898
- break;
2899
- try {
2900
- session.setAutoRetryEnabled(frame.enabled);
2901
- ackTo(client, 'set_auto_retry');
2902
- }
2903
- catch (err) {
2904
- engineErrorTo(client)(err);
2905
- }
2906
- break;
2907
- }
2908
- case 'set_auto_compaction': {
2909
- if (notWritable(client, 'set auto-compaction'))
2910
- break;
2911
- try {
2912
- session.setAutoCompactionEnabled(frame.enabled);
2913
- ackTo(client, 'set_auto_compaction');
2914
- }
2915
- catch (err) {
2916
- engineErrorTo(client)(err);
2917
- }
2918
- break;
2919
- }
2920
- case 'compact': {
2921
- if (notWritable(client, 'compact the session'))
2922
- break;
2923
- void session
2924
- .compact(frame.instructions)
2925
- .then(() => ackTo(client, 'compact'))
2926
- .catch(engineErrorTo(client));
2927
- break;
2928
- }
2929
- case 'set_session_name': {
2930
- if (notWritable(client, 'rename the session'))
2931
- break;
2932
- try {
2933
- session.setSessionName(frame.name);
2934
- ackTo(client, 'set_session_name');
2935
- }
2936
- catch (err) {
2937
- engineErrorTo(client)(err);
2938
- }
2939
- break;
2940
- }
2941
- case 'get_commands': {
2942
- // OBSERVERS may call this: the merged command inventory is static
2943
- // (extensions + templates + skills + builtins) and drives nothing in the
2944
- // engine, so it is not a controller-gated op. The web bridge fetches it on
2945
- // its observer-by-default upstream connection to populate the palette.
2946
- // The merged command list rides in ack.detail as JSON (the foundation's
2947
- // AckFrame.detail field) — the viewer (T6) JSON.parses it when for ===
2948
- // 'get_commands'. Keeps every command op a uniform ack reply.
2949
- try {
2950
- ackTo(client, 'get_commands', true, JSON.stringify(buildCommandList()));
2951
- }
2952
- catch (err) {
2953
- engineErrorTo(client)(err);
2954
- }
2955
- break;
2956
- }
2957
- // --- read-op picker data (§5 Unit A) ----------------------------------
2958
- // NOT controller-gated: read-only, like get_commands. The web bridge's
2959
- // observer connection populates pickers through these too.
2960
- case 'list_models': {
2961
- try {
2962
- sendFrame(client, buildListModelsData(frame.id));
2963
- }
2964
- catch (err) {
2965
- engineErrorTo(client, frame.id)(err);
2966
- }
2967
- break;
2968
- }
2969
- case 'list_scoped_models': {
2970
- try {
2971
- sendFrame(client, buildScopedModelsData(frame.id));
2972
- }
2973
- catch (err) {
2974
- engineErrorTo(client, frame.id)(err);
2975
- }
2976
- break;
2977
- }
2978
- case 'list_memory_refs': {
2979
- try {
2980
- sendFrame(client, buildListMemoryRefsData(frame.id));
2981
- }
2982
- catch (err) {
2983
- engineErrorTo(client, frame.id)(err);
2984
- }
2985
- break;
2986
- }
2987
- case 'get_tree': {
2988
- try {
2989
- sendFrame(client, buildGetTreeData(frame.id));
2990
- }
2991
- catch (err) {
2992
- engineErrorTo(client, frame.id)(err);
2993
- }
2994
- break;
2995
- }
2996
- case 'get_settings': {
2997
- try {
2998
- sendFrame(client, buildSettingsData(frame.id));
2999
- }
3000
- catch (err) {
3001
- engineErrorTo(client, frame.id)(err);
3002
- }
3003
- break;
3004
- }
3005
- case 'list_sessions': {
3006
- // Session listing reads the session dir off disk — async; reply when it
3007
- // resolves (or relay the error, correlated by id so the viewer doesn't hang).
3008
- void buildSessionsData(frame.id, frame.scope ?? 'cwd')
3009
- .then((d) => sendFrame(client, d))
3010
- .catch(engineErrorTo(client, frame.id));
3011
- break;
3012
- }
3013
- case 'get_human_fork_coordinates': {
3014
- // NOT controller-gated: this is a pure observer read. Branch creation is
3015
- // intentionally delegated to a detached SDK manager in the ask publisher;
3016
- // calling createBranchedSession here corrupts this live manager's identity.
3017
- try {
3018
- const sm = session.sessionManager;
3019
- const sourceFile = sm.getSessionFile();
3020
- const leafId = sm.getLeafId();
3021
- if (sourceFile === undefined || sourceFile === '' || leafId === null) {
3022
- engineErrorTo(client, frame.id)(new Error('no persisted source session leaf'));
3023
- break;
3024
- }
3025
- sendFrame(client, {
3026
- type: 'data',
3027
- id: frame.id,
3028
- kind: 'human_fork_coordinates',
3029
- sourceFile,
3030
- sourceSessionId: sm.getSessionId(),
3031
- leafId,
3032
- modelSpec: formatExactModelSpec(session.model, session.thinkingLevel),
3033
- });
3034
- }
3035
- catch (err) {
3036
- engineErrorTo(client, frame.id)(err);
3037
- }
3038
- break;
3039
- }
3040
- case 'navigate_tree': {
3041
- if (notWritable(client, 'navigate the session tree'))
3042
- break;
3043
- if (isPreBoundaryReviewEntry(frame.targetId)) {
3044
- reviewBoundaryRefusalTo(client, frame.targetId);
3045
- break;
3046
- }
3047
- // navigateTree rewinds IN-PLACE (same session file, new leaf) and emits no
3048
- // relayed event, so every viewer must be re-snapshotted onto the rewound
3049
- // transcript — same reWelcomeAll the session-replacing ops use. The ack's
3050
- // detail carries the navigated-to user message's text (pi parity: the
3051
- // interactive tree navigator restores it to the editor for re-editing).
3052
- void session
3053
- .navigateTree(frame.targetId, frame.options)
3054
- .then((r) => {
3055
- if (!r.cancelled)
3056
- reWelcomeAll();
3057
- ackTo(client, 'navigate_tree', !r.cancelled, r.editorText);
3058
- })
3059
- .catch(engineErrorTo(client));
3060
- break;
3061
- }
3062
- case 'reload': {
3063
- if (notWritable(client, 'reload'))
3064
- break;
3065
- // A SUCCESSFUL reload invalidates the memoized ref inventory (work item
3066
- // 3) so the next read-op/submission re-walks the corpus, AND clears the
3067
- // shared substrate parse cache buildRefInventory reads through —
3068
- // otherwise a fresh buildRefInventory() call would still see the stale
3069
- // cached docs. A FAILED reload (`.catch`) leaves both untouched: nothing
3070
- // about the corpus is known to have changed.
3071
- void session
3072
- .reload()
3073
- .then(() => {
3074
- memoizedRefInventory = null;
3075
- clearSessionCache();
3076
- ackTo(client, 'reload');
3077
- })
3078
- .catch(engineErrorTo(client));
3079
- break;
3080
- }
3081
- case 'export': {
3082
- if (notWritable(client, 'export the session'))
3083
- break;
3084
- if (frame.format === 'jsonl') {
3085
- try {
3086
- const outPath = session.exportToJsonl(frame.path);
3087
- stripHiddenEntriesFromJsonlExport(outPath);
3088
- ackTo(client, 'export');
3089
- }
3090
- catch (err) {
3091
- engineErrorTo(client)(err);
3092
- }
3093
- }
3094
- else {
3095
- void session
3096
- .exportToHtml(frame.path)
3097
- .then(() => ackTo(client, 'export'))
3098
- .catch(engineErrorTo(client));
3099
- }
3100
- break;
3101
- }
3102
- case 'new_session': {
3103
- if (notWritable(client, 'start a new session'))
3104
- break;
3105
- runReplacement(client, 'new_session', (rt) => rt.newSession());
3106
- break;
3107
- }
3108
- case 'switch_session': {
3109
- if (notWritable(client, 'switch sessions'))
3110
- break;
3111
- runReplacement(client, 'switch_session', (rt) => rt.switchSession(frame.path));
3112
- break;
3113
- }
3114
- case 'fork': {
3115
- if (notWritable(client, 'fork the session'))
3116
- break;
3117
- if (isPreBoundaryReviewEntry(frame.entryId)) {
3118
- reviewBoundaryRefusalTo(client, frame.entryId);
3119
- break;
3120
- }
3121
- runReplacement(client, 'fork', (rt) => rt.fork(frame.entryId));
3122
- break;
3123
- }
3124
- case 'clone': {
3125
- if (notWritable(client, 'clone the session'))
3126
- break;
3127
- runReplacement(client, 'clone', async (rt) => {
3128
- const sm = session.sessionManager;
3129
- const leafId = sm.getLeafId();
3130
- if (leafId === null) {
3131
- return { cancelled: true, detail: 'no current leaf to clone from' };
3132
- }
3133
- const newPath = sm.createBranchedSession(leafId);
3134
- if (newPath === undefined) {
3135
- return { cancelled: true, detail: 'createBranchedSession returned no path' };
3136
- }
3137
- return rt.switchSession(newPath);
3138
- });
3139
- break;
3140
- }
3141
- case 'share': {
3142
- if (notWritable(client, 'share the session'))
3143
- break;
3144
- const tmpPath = join(tmpdir(), `pi-share-${Date.now()}.html`);
3145
- void session.exportToHtml(tmpPath)
3146
- .then(() => new Promise((resolve, reject) => {
3147
- execFile('gh', ['gist', 'create', '--secret', tmpPath], { timeout: 30_000, maxBuffer: 1024 * 1024 }, (err, stdout) => {
3148
- try {
3149
- if (existsSync(tmpPath))
3150
- unlinkSync(tmpPath);
3151
- }
3152
- catch { /* cleanup */ }
3153
- if (err) {
3154
- reject(err);
3155
- return;
3156
- }
3157
- resolve(stdout.trim());
3158
- });
3159
- }))
3160
- .then((url) => {
3161
- ackTo(client, 'share', true, url);
3162
- })
3163
- .catch((err) => {
3164
- const msg = String(err?.message ?? err);
3165
- ackTo(client, 'share', false, msg.includes('not found') || msg.includes('gh: command not found') || msg.includes('ENOENT')
3166
- ? '`gh` CLI not found — install GitHub CLI and authenticate with `gh auth login`'
3167
- : `share failed: ${msg}`);
3168
- });
3169
- break;
3170
- }
3171
- case 'reload_auth': {
3172
- // Open to any client, observers included: reload_auth is an idempotent
3173
- // local re-read of shared credentials + the runtime's availability
3174
- // snapshot. It does not steer the conversation, so the daemon's
3175
- // canvas-wide /login fan can ride an observer connection.
3176
- try {
3177
- ackTo(client, 'reload_auth');
3178
- const authGeneration = installedGeneration;
3179
- const existingAuthReload = reloadAuthInFlight;
3180
- let authReloadPromise;
3181
- if (existingAuthReload !== null && existingAuthReload.generation === authGeneration) {
3182
- authReloadPromise = existingAuthReload.promise;
3183
- }
3184
- if (authGeneration !== undefined && authReloadPromise === undefined) {
3185
- const authSession = session;
3186
- const authServices = services;
3187
- authReloadPromise = (async () => {
3188
- // Credentials are read fresh by PoolCredentialStore; only the
3189
- // ModelRuntime snapshot needs rebuilding. Authentication reloads
3190
- // never fetch provider catalogs over the network.
3191
- await authServices.modelRuntime.refresh({ allowNetwork: false });
3192
- if (installedGeneration !== authGeneration || session !== authSession)
3193
- return;
3194
- const modelLess = authSession.model === undefined || isUnknownModel(authSession.model);
3195
- const reconcilesLogicalRoute = cfg.modelExact !== true
3196
- && cfg.modelProviderPinned !== true
3197
- && (process.env['CRTR_MODEL_INTENT'] ?? '').trim() !== '';
3198
- if (modelLess || reconcilesLogicalRoute) {
3199
- const resolved = await resolveLaunchModel(authServices, cfg);
3200
- if (installedGeneration !== authGeneration || session !== authSession)
3201
- return;
3202
- if (resolved !== undefined) {
3203
- const currentSpec = formatModelSpec(authSession.model, authSession.thinkingLevel);
3204
- const resolvedSpec = formatModelSpec(resolved.model, resolved.thinkingLevel);
3205
- if (resolvedSpec !== null && resolvedSpec !== currentSpec) {
3206
- await authSession.setModel(resolved.model);
3207
- if (installedGeneration !== authGeneration || session !== authSession)
3208
- return;
3209
- authSession.setThinkingLevel((resolved.thinkingLevel ?? 'off'));
3210
- }
3211
- }
3212
- }
3213
- if (installedGeneration !== authGeneration || session !== authSession)
3214
- return;
3215
- // Persist an algorithmic route choice without clearing the logical
3216
- // intent that makes future reconciliation possible.
3217
- broadcastModelChanged();
3218
- drivePendingKickoff();
3219
- })().catch((err) => {
3220
- emitEvent({ level: 'warn', event: 'broker.auth_reload.reconcile_failed', error: err });
3221
- });
3222
- const work = { generation: authGeneration, promise: authReloadPromise };
3223
- reloadAuthInFlight = work;
3224
- void authReloadPromise.finally(() => {
3225
- if (reloadAuthInFlight === work)
3226
- reloadAuthInFlight = null;
3227
- });
3228
- }
3229
- const authFault = readFault(nodeId);
3230
- const authFaultGeneration = installedGeneration;
3231
- const authFaultSession = session;
3232
- if (authFault?.link === 'pi→provider'
3233
- && authFault.kind === 'auth'
3234
- && !authFaultSession.isStreaming
3235
- && !authFaultRedriveInFlight) {
3236
- authFaultRedriveInFlight = true;
3237
- void (async () => {
3238
- try {
3239
- if (authReloadPromise !== undefined)
3240
- await authReloadPromise;
3241
- if (installedGeneration !== authFaultGeneration || session !== authFaultSession)
3242
- return;
3243
- const anchorEntryId = authFault.anchorEntryId;
3244
- if (anchorEntryId !== undefined) {
3245
- try {
3246
- const leafId = authFaultSession.sessionManager.getLeafId?.() ?? null;
3247
- if (leafId !== anchorEntryId)
3248
- await authFaultSession.navigateTree(anchorEntryId);
3249
- if (installedGeneration !== authFaultGeneration || session !== authFaultSession)
3250
- return;
3251
- }
3252
- catch (err) {
3253
- emitEvent({ level: 'warn', event: 'broker.auth_fault.rewind_skipped', error: err });
3254
- }
3255
- }
3256
- const fresh = readFault(nodeId);
3257
- if (installedGeneration !== authFaultGeneration
3258
- || session !== authFaultSession
3259
- || authFaultSession.isStreaming
3260
- || fresh?.link !== 'pi→provider'
3261
- || fresh.kind !== 'auth'
3262
- || fresh.since !== authFault.since)
3263
- return;
3264
- clearFault(nodeId, { link: 'pi→provider' });
3265
- await authFaultSession.prompt(AUTH_FAULT_RECOVERY_BODY);
3266
- }
3267
- catch (err) {
3268
- emitEvent({ level: 'error', event: 'broker.auth_fault.redrive_failed', error: err });
3269
- }
3270
- finally {
3271
- authFaultRedriveInFlight = false;
3272
- }
3273
- })();
3274
- }
3275
- }
3276
- catch (err) {
3277
- engineErrorTo(client)(err);
3278
- }
3279
- break;
3280
- }
3281
- case 'bye':
3282
- client.socket.end();
3283
- break;
3284
- case 'shutdown':
3285
- disposeAndExit('shutdown-frame');
3286
- break;
3287
- }
3288
- };
3289
- // -------------------------------------------------------------------------
3290
- // 4 (plan): the socket listener — unlink any stale socket first.
3291
- // -------------------------------------------------------------------------
3292
- try {
3293
- if (existsSync(sockPath))
3294
- unlinkSync(sockPath);
3295
- }
3296
- catch {
3297
- /* a fresh listen below will surface a real problem */
3298
- }
3299
- server = createServer((socket) => {
3300
- const client = {
3301
- id: '',
3302
- role: 'observer',
3303
- socket,
3304
- decoder: new FrameDecoder(BROKER_READ_CAPS),
3305
- helloed: false,
3306
- pendingBytes: 0,
3307
- queuedFrames: 0,
3308
- };
3309
- clients.add(client);
3310
- socket.on('data', (chunk) => {
3311
- let frames;
3312
- try {
3313
- frames = client.decoder.push(chunk);
3314
- }
3315
- catch (err) {
3316
- // G7: a client frame over the bounded decoder caps (FrameOverflowError) is
3317
- // cap-and-dropped — best-effort error frame, then destroy the peer. The
3318
- // broker survives. The try/catch is around push() itself (not just the
3319
- // per-frame loop) because push() throws BEFORE returning any frames.
3320
- if (err instanceof FrameOverflowError) {
3321
- sendFrame(client, { type: 'error', code: 'frame_overflow', message: err.message });
3322
- dropClient(client, `frame overflow: ${err.message}`);
3323
- }
3324
- else {
3325
- dropClient(client, `decoder error: ${String(err)}`);
3326
- }
3327
- return;
539
+ return;
3328
540
  }
3329
541
  for (const raw of frames) {
3330
542
  try {
3331
- handleFrame(client, raw);
543
+ handleFrame(frameDispatch, client, raw);
3332
544
  }
3333
545
  catch {
3334
546
  /* one bad frame never crashes the broker */
@@ -3336,8 +548,7 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
3336
548
  }
3337
549
  });
3338
550
  const drop = () => {
3339
- clients.delete(client);
3340
- persistAttachState(); // a viewer disconnected
551
+ registry.removeClient(client); // a viewer disconnected
3341
552
  // A writable client detaching does NOT cancel in-flight dialogs — they stay
3342
553
  // pending under the broker-side default timeout, so a peer that is still
3343
554
  // attached (or one that attaches next and gets the replay) can still answer.
@@ -3366,13 +577,12 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
3366
577
  // reload_auth re-drive resolves a model, at which point it fires. For the
3367
578
  // common model-present path this fires immediately — unchanged behavior.
3368
579
  if (cfg.firstPrompt !== undefined && cfg.firstPrompt !== '') {
3369
- kickoffPending = cfg.firstPrompt;
3370
- drivePendingKickoff();
580
+ frameDispatch.kickoff(cfg.firstPrompt);
3371
581
  }
3372
582
  });
3373
583
  // Initialize presence to the truthful zero state at boot — this also
3374
584
  // overwrites a stale attach.json a crashed prior incarnation left behind.
3375
- persistAttachState();
585
+ registry.persistAttachState();
3376
586
  // A daemon fleet bounce gives a busy Pi a short, bounded chance to persist
3377
587
  // its aborted turn before teardown. `session.abort()` resolves only after Pi
3378
588
  // reaches idle (and thus after its message_end persistence); only then may we
@@ -3385,23 +595,23 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
3385
595
  if (disposed || signalExitInFlight)
3386
596
  return;
3387
597
  signalExitInFlight = true;
3388
- const busy = agentRunActive || session.isStreaming || pendingPromptDispatches > 0;
598
+ const busy = rebind.isAgentRunActive() || rebind.session().isStreaming || frameDispatch.pendingPromptDispatches() > 0;
3389
599
  if (busy) {
3390
600
  const deadline = Date.now() + ABORT_BEFORE_EXIT_MS;
3391
601
  // prompt() has a real async pre-turn window where abort() silently no-ops.
3392
602
  // Wait only within the daemon's fixed teardown budget for it to become
3393
603
  // abortable. A still-pending setup is deliberately marked dirty below so
3394
604
  // recovery starts a cycle instead of silently losing the prompt on resume.
3395
- while (pendingPromptDispatches > 0 && !session.isStreaming && Date.now() < deadline) {
605
+ while (frameDispatch.pendingPromptDispatches() > 0 && !rebind.session().isStreaming && Date.now() < deadline) {
3396
606
  await new Promise((resolve) => setTimeout(resolve, 25));
3397
607
  }
3398
- if (session.isStreaming) {
608
+ if (rebind.session().isStreaming) {
3399
609
  const remaining = deadline - Date.now();
3400
610
  if (remaining > 0) {
3401
611
  let timer;
3402
612
  try {
3403
613
  const settled = await Promise.race([
3404
- session.abort().then(() => true, () => false),
614
+ rebind.session().abort().then(() => true, () => false),
3405
615
  new Promise((resolve) => { timer = setTimeout(() => resolve(false), remaining); }),
3406
616
  ]);
3407
617
  if (settled)
@@ -3413,7 +623,7 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
3413
623
  }
3414
624
  }
3415
625
  }
3416
- else if (pendingPromptDispatches > 0 || agentRunActive) {
626
+ else if (frameDispatch.pendingPromptDispatches() > 0 || rebind.isAgentRunActive()) {
3417
627
  // The engine never reached an abortable persisted turn in the budget.
3418
628
  // Preserve the existing dirty-death recovery cycle rather than certifying
3419
629
  // a session that may not contain the prompt it was preparing.
@@ -3475,10 +685,17 @@ async function resolveLaunchModel(services, cfg) {
3475
685
  const routeRequest = modelRequestFromConfig(cfg.model, process.env['CRTR_MODEL_INTENT'], cfg.modelExact === true);
3476
686
  if (routeRequest === undefined)
3477
687
  return undefined;
3478
- const selection = selectInitialModelRoute(routeRequest, registryOf(services), {
688
+ const registry = registryOf(services);
689
+ const routeOptions = {
3479
690
  cwd: process.env['CRTR_NODE_CWD'] ?? cfg.cwd,
3480
691
  profileId: process.env['CRTR_PROFILE_ID'],
3481
- });
692
+ };
693
+ const unknown = unregisteredConcreteModel(routeRequest, registry, routeOptions);
694
+ if (unknown !== undefined) {
695
+ recordFaultForCurrentNode({ link: 'crtr→pi', op: 'model resolution at launch', kind: 'model-not-found', retry: { disposition: 'fatal' }, message: `model '${unknown.request.spec}' is not registered in the serving model registry` });
696
+ throw new Error(`[broker] FATAL: model '${unknown.request.spec}' is not registered in the serving model registry`);
697
+ }
698
+ const selection = selectInitialModelRoute(routeRequest, registry, routeOptions);
3482
699
  const selected = selection.selected;
3483
700
  if (selected !== undefined) {
3484
701
  const candidate = selected.candidate;