@north-light/crouter 0.3.233 → 0.3.235

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 (411) hide show
  1. package/dist/api/client.js +16 -15
  2. package/dist/api/dto/nodes.d.ts +11 -0
  3. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/integration/provider-rotation.test.ts +1 -1
  4. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/claude-plugin-commands.ts +14 -17
  5. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/crtr-commands/index.ts +7 -11
  6. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/frontmatter-rules/index.ts +8 -8
  7. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/memory-slash-commands.ts +10 -11
  8. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/pi-shell-runner.ts +2 -2
  9. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.d.ts +3 -3
  10. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.js +7 -7
  11. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +8 -8
  12. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/statusline.ts +6 -6
  13. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/sysprompt-window.ts +3 -3
  14. package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +1 -1
  15. package/dist/clients/attach/chrome/ticket-panel.js +15 -2
  16. package/dist/clients/attach/overlays/graph.js +0 -3
  17. package/dist/clients/attach/overlays/mcp.js +1 -2
  18. package/dist/clients/attach/render/chat-view.js +0 -12
  19. package/dist/clients/attach/render/crtr-output.js +0 -2
  20. package/dist/clients/attach/render/tool-calls.js +0 -6
  21. package/dist/clients/attach/slash/dispatch.js +8 -11
  22. package/dist/clients/attach/viewer.js +535 -537
  23. package/dist/clients/conversation/projection.js +0 -2
  24. package/dist/clients/inbox/controller.js +22 -3
  25. package/dist/clients/inbox/review/companion-pane.js +0 -4
  26. package/dist/clients/inbox/review-adapter.js +0 -7
  27. package/dist/clients/inbox/tui/slots.d.ts +3 -3
  28. package/dist/clients/inbox/tui/types.d.ts +2 -2
  29. package/dist/commands/api-client.js +0 -8
  30. package/dist/commands/attention.js +1 -9
  31. package/dist/commands/canvas-browse.js +2 -1
  32. package/dist/commands/canvas-config.js +0 -10
  33. package/dist/commands/canvas-history.js +2 -1
  34. package/dist/commands/cron.js +10 -23
  35. package/dist/commands/dashboard.js +0 -2
  36. package/dist/commands/human/feedback.js +2 -1
  37. package/dist/commands/human/prompts.js +2 -5
  38. package/dist/commands/human/queue.js +2 -7
  39. package/dist/commands/human/request.js +0 -14
  40. package/dist/commands/human/review.js +2 -1
  41. package/dist/commands/human.js +1 -1
  42. package/dist/commands/memory/__tests__/command-selector-and-mutation-guards.test.js +4 -4
  43. package/dist/commands/memory/__tests__/memory-list-and-lint-ignore.test.d.ts +1 -0
  44. package/dist/commands/memory/__tests__/memory-list-and-lint-ignore.test.js +96 -0
  45. package/dist/commands/memory/delete.js +1 -1
  46. package/dist/commands/memory/edit.js +6 -3
  47. package/dist/commands/memory/find.js +2 -2
  48. package/dist/commands/memory/lint.js +2 -1
  49. package/dist/commands/memory/list.d.ts +4 -0
  50. package/dist/commands/memory/list.js +20 -14
  51. package/dist/commands/memory/move.js +1 -1
  52. package/dist/commands/memory/read.d.ts +2 -7
  53. package/dist/commands/memory/read.js +5 -16
  54. package/dist/commands/memory/shared.d.ts +1 -0
  55. package/dist/commands/memory/shared.js +17 -8
  56. package/dist/commands/memory/write.js +8 -4
  57. package/dist/commands/node/bash.js +2 -1
  58. package/dist/commands/node/create.js +2 -1
  59. package/dist/commands/node/inspect.js +11 -10
  60. package/dist/commands/node/lifecycle.js +5 -13
  61. package/dist/commands/node/message.js +11 -6
  62. package/dist/commands/node/subscription.js +7 -2
  63. package/dist/commands/node/wait.js +9 -4
  64. package/dist/commands/node-context.js +2 -1
  65. package/dist/commands/node-lifecycle-revive.js +0 -2
  66. package/dist/commands/node-worktree.js +2 -1
  67. package/dist/commands/pkg/browse/actions.d.ts +1 -1
  68. package/dist/commands/pkg/browse/actions.js +4 -4
  69. package/dist/commands/pkg/browse/catalog.d.ts +1 -1
  70. package/dist/commands/pkg/browse/catalog.js +15 -15
  71. package/dist/commands/pkg/browse/command-view.d.ts +1 -1
  72. package/dist/commands/pkg/browse/command-view.js +3 -3
  73. package/dist/commands/pkg/browse/detail-panel.d.ts +1 -1
  74. package/dist/commands/pkg/browse/detail-panel.js +8 -8
  75. package/dist/commands/pkg/browse/doc-view.d.ts +1 -1
  76. package/dist/commands/pkg/browse/doc-view.js +3 -3
  77. package/dist/commands/pkg/browse/list-panel.d.ts +1 -1
  78. package/dist/commands/pkg/browse/list-panel.js +3 -3
  79. package/dist/commands/pkg/browse/model.d.ts +3 -3
  80. package/dist/commands/pkg/browse/shell.d.ts +2 -2
  81. package/dist/commands/pkg/browse/shell.js +13 -13
  82. package/dist/commands/pkg/plugin-manage.js +3 -2
  83. package/dist/commands/pkg/shared.js +0 -2
  84. package/dist/commands/pkg.js +1 -0
  85. package/dist/commands/profile/delete.js +2 -1
  86. package/dist/commands/profile/rename.js +1 -1
  87. package/dist/commands/push.js +2 -3
  88. package/dist/commands/revive.js +0 -2
  89. package/dist/commands/surface/node/placement.js +2 -1
  90. package/dist/commands/surface-edit.js +2 -1
  91. package/dist/commands/sys/config.js +1 -4
  92. package/dist/commands/sys/daemon.js +0 -10
  93. package/dist/commands/sys/doctor.js +3 -2
  94. package/dist/commands/sys/panels/broker-limits-panel.d.ts +2 -2
  95. package/dist/commands/sys/panels/broker-limits-panel.js +4 -4
  96. package/dist/commands/sys/panels/dependencies-panel.d.ts +2 -2
  97. package/dist/commands/sys/panels/dependencies-panel.js +2 -2
  98. package/dist/commands/sys/panels/keybindings-panel.d.ts +1 -1
  99. package/dist/commands/sys/panels/keybindings-panel.js +5 -5
  100. package/dist/commands/sys/panels/models-panel.d.ts +4 -4
  101. package/dist/commands/sys/panels/models-panel.js +11 -10
  102. package/dist/commands/sys/panels/plugins-panel.d.ts +2 -2
  103. package/dist/commands/sys/panels/plugins-panel.js +2 -2
  104. package/dist/commands/sys/panels/prefs-panel.d.ts +2 -2
  105. package/dist/commands/sys/panels/prefs-panel.js +4 -4
  106. package/dist/commands/sys/panels/profiles-panel.d.ts +1 -1
  107. package/dist/commands/sys/panels/profiles-panel.js +14 -13
  108. package/dist/commands/sys/panels/provider-panel.d.ts +3 -3
  109. package/dist/commands/sys/panels/provider-panel.js +8 -8
  110. package/dist/commands/sys/settings-shell.d.ts +3 -3
  111. package/dist/commands/sys/settings-shell.js +15 -15
  112. package/dist/commands/sys/setup-settings-runner.d.ts +3 -3
  113. package/dist/commands/sys/setup-settings-runner.js +5 -5
  114. package/dist/commands/sys/setup-wizard.d.ts +4 -4
  115. package/dist/commands/sys/setup-wizard.js +8 -8
  116. package/dist/commands/sys/support/prepare.js +4 -4
  117. package/dist/commands/sys/support/submit.js +2 -2
  118. package/dist/commands/sys/sync-deps.js +0 -2
  119. package/dist/commands/sys/sync-project-guidance.js +2 -1
  120. package/dist/commands/sys/sysprompt.js +2 -1
  121. package/dist/commands/sys.js +1 -0
  122. package/dist/core/__tests__/boot.test.js +0 -8
  123. package/dist/core/__tests__/broker-extension-canvas-db-boundary.test.js +23 -1
  124. package/dist/core/__tests__/canvas-db-boundary.test.d.ts +1 -0
  125. package/dist/core/__tests__/canvas-db-boundary.test.js +46 -0
  126. package/dist/core/__tests__/canvas.test.js +1 -1
  127. package/dist/core/__tests__/child-death-wake.test.js +0 -2
  128. package/dist/core/__tests__/cli-help-contract.test.d.ts +1 -0
  129. package/dist/core/__tests__/cli-help-contract.test.js +150 -0
  130. package/dist/core/__tests__/cron-armed-context-failure.test.d.ts +1 -0
  131. package/dist/core/__tests__/cron-armed-context-failure.test.js +145 -0
  132. package/dist/core/__tests__/daemon-wedge.test.js +0 -4
  133. package/dist/core/__tests__/dead-node-policy-table.test.js +0 -4
  134. package/dist/core/__tests__/fixtures/fake-engine.js +4 -14
  135. package/dist/core/__tests__/fixtures/fake-pi-host.js +5 -5
  136. package/dist/core/__tests__/focuses.test.js +0 -6
  137. package/dist/core/__tests__/fs-utils.test.d.ts +1 -0
  138. package/dist/core/__tests__/fs-utils.test.js +50 -0
  139. package/dist/core/__tests__/helpers/harness.js +1 -1
  140. package/dist/core/__tests__/inline-memory-refs.test.js +0 -2
  141. package/dist/core/__tests__/integration/broker-fork-seam.test.js +0 -2
  142. package/dist/core/__tests__/integration/broker-sdk-wiring.test.js +3 -16
  143. package/dist/core/__tests__/integration/command-plugins.test.js +21 -24
  144. package/dist/core/__tests__/integration/deferred-no-wake.test.js +0 -6
  145. package/dist/core/__tests__/integration/flagship-lifecycle.test.js +0 -26
  146. package/dist/core/__tests__/integration/live-mutation-verbs.test.js +0 -4
  147. package/dist/core/__tests__/integration/live-mutation.test.js +1 -5
  148. package/dist/core/__tests__/integration/refresh-stall-recycle.test.js +1 -5
  149. package/dist/core/__tests__/integration/revive.test.js +0 -12
  150. package/dist/core/__tests__/integration/subscription-delivery.test.js +0 -6
  151. package/dist/core/__tests__/integration/tmux-surface.test.js +24 -8
  152. package/dist/core/__tests__/integration/worktree-reap.test.js +0 -6
  153. package/dist/core/__tests__/lifecycle.test.js +0 -18
  154. package/dist/core/__tests__/memory-resolver-precedence.test.js +1 -1
  155. package/dist/core/__tests__/migration.test.js +2 -2
  156. package/dist/core/__tests__/on-read-dedup-resume.test.js +3 -3
  157. package/dist/core/__tests__/pid-identity-match.test.js +1 -1
  158. package/dist/core/__tests__/plugin-kinds.test.js +1 -1
  159. package/dist/core/__tests__/plugin-page-components.test.js +1 -1
  160. package/dist/core/__tests__/preview-registry-sync.test.js +42 -1
  161. package/dist/core/__tests__/respawn-throttle.test.js +0 -4
  162. package/dist/core/__tests__/revive-all.test.js +5 -5
  163. package/dist/core/__tests__/revive-parked-fresh.test.js +62 -11
  164. package/dist/core/__tests__/seam/broker-attach-multiclient.test.js +0 -8
  165. package/dist/core/__tests__/seam/broker-attach-stream.test.js +0 -8
  166. package/dist/core/__tests__/seam/broker-crash-teardown.test.js +0 -2
  167. package/dist/core/__tests__/tmux-driver-boundary.test.d.ts +1 -0
  168. package/dist/core/__tests__/tmux-driver-boundary.test.js +50 -0
  169. package/dist/core/__tests__/watchdog-abort-arms-retry.test.js +11 -6
  170. package/dist/core/activity-signal.d.ts +7 -0
  171. package/dist/core/activity-signal.js +38 -0
  172. package/dist/core/auto-update.js +2 -1
  173. package/dist/core/bootstrap.js +2 -1
  174. package/dist/core/canvas/__tests__/remote-canvas-source.test.js +1 -1
  175. package/dist/core/canvas/__tests__/remote-transport.test.js +1 -1
  176. package/dist/core/canvas/__tests__/render-remote.test.js +1 -1
  177. package/dist/core/canvas/browse/app.js +3 -2
  178. package/dist/core/canvas/browse/model.d.ts +1 -1
  179. package/dist/core/canvas/browse/model.js +0 -4
  180. package/dist/core/canvas/canvas.d.ts +22 -0
  181. package/dist/core/canvas/canvas.js +61 -19
  182. package/dist/core/canvas/crons.js +0 -6
  183. package/dist/core/canvas/db.d.ts +14 -20
  184. package/dist/core/canvas/db.js +30 -1332
  185. package/dist/core/canvas/focuses.js +0 -4
  186. package/dist/core/canvas/history.js +2 -1
  187. package/dist/core/canvas/meta-file.js +18 -27
  188. package/dist/core/canvas/migrations.d.ts +20 -0
  189. package/dist/core/canvas/migrations.js +1339 -0
  190. package/dist/core/canvas/nav-model.js +1 -11
  191. package/dist/core/canvas/nav-render.js +0 -8
  192. package/dist/core/canvas/remote-canvas-source.d.ts +8 -0
  193. package/dist/core/canvas/remote-canvas-source.js +9 -3
  194. package/dist/core/canvas/render-source.js +31 -13
  195. package/dist/core/command-manifests/manifest.js +0 -10
  196. package/dist/core/command-manifests/registry.d.ts +2 -0
  197. package/dist/core/command-manifests/schema.js +0 -4
  198. package/dist/core/command-plugins/compose.js +4 -2
  199. package/dist/core/command-plugins/discovery.d.ts +1 -0
  200. package/dist/core/command-plugins/discovery.js +1 -1
  201. package/dist/core/command-plugins/transport/http-invoke.js +0 -14
  202. package/dist/core/command.js +2 -6
  203. package/dist/core/config.js +2 -1
  204. package/dist/core/fault-classifier.js +8 -0
  205. package/dist/core/feed/inbox.d.ts +10 -0
  206. package/dist/core/feed/inbox.js +18 -12
  207. package/dist/core/fs-utils.d.ts +9 -2
  208. package/dist/core/fs-utils.js +7 -15
  209. package/dist/core/help.js +0 -8
  210. package/dist/core/host-exports/export.d.ts +2 -2
  211. package/dist/core/host-exports/export.js +9 -12
  212. package/dist/core/human/answer.d.ts +2 -2
  213. package/dist/core/human/convention.d.ts +2 -5
  214. package/dist/core/human/convention.js +3 -13
  215. package/dist/core/human/page-schema.d.ts +2 -2
  216. package/dist/core/human/page-schema.js +2 -2
  217. package/dist/core/human/root.js +6 -23
  218. package/dist/core/human/scan.js +24 -7
  219. package/dist/core/human/tickets.d.ts +4 -1
  220. package/dist/core/human/tickets.js +20 -6
  221. package/dist/core/io.js +0 -12
  222. package/dist/core/memory/identity.js +0 -4
  223. package/dist/core/memory/inline-ref-inventory.js +3 -2
  224. package/dist/core/memory/lint.js +3 -6
  225. package/dist/core/memory-resolver.d.ts +5 -0
  226. package/dist/core/memory-resolver.js +14 -12
  227. package/dist/core/pagination.d.ts +2 -0
  228. package/dist/core/pagination.js +2 -3
  229. package/dist/core/preview-registry.d.ts +14 -0
  230. package/dist/core/preview-registry.js +2 -4
  231. package/dist/core/preview-result-path.js +2 -1
  232. package/dist/core/profiles/manifest.d.ts +37 -5
  233. package/dist/core/profiles/manifest.js +44 -24
  234. package/dist/core/profiles/select.js +0 -4
  235. package/dist/core/review/signal.js +4 -23
  236. package/dist/core/review/ticket-filter.d.ts +3 -3
  237. package/dist/core/review/ticket-filter.js +4 -9
  238. package/dist/core/runtime/__tests__/engine-route.test.js +1 -1
  239. package/dist/core/runtime/__tests__/spawn-env.test.js +0 -8
  240. package/dist/core/runtime/bearings-render.d.ts +56 -0
  241. package/dist/core/runtime/bearings-render.js +241 -0
  242. package/dist/core/runtime/bearings.d.ts +2 -17
  243. package/dist/core/runtime/bearings.js +12 -198
  244. package/dist/core/runtime/broker/auth-reload.d.ts +39 -0
  245. package/dist/core/runtime/broker/auth-reload.js +149 -0
  246. package/dist/core/runtime/broker/engine-drive.d.ts +26 -0
  247. package/dist/core/runtime/broker/engine-drive.js +257 -0
  248. package/dist/core/runtime/broker/engine-routing.d.ts +143 -0
  249. package/dist/core/runtime/broker/engine-routing.js +211 -0
  250. package/dist/core/runtime/broker/event-projection.js +3 -2
  251. package/dist/core/runtime/broker/fault-retry.js +11 -4
  252. package/dist/core/runtime/broker/frame-client.d.ts +38 -0
  253. package/dist/core/runtime/broker/frame-client.js +129 -0
  254. package/dist/core/runtime/broker/frame-dispatch.d.ts +12 -189
  255. package/dist/core/runtime/broker/frame-dispatch.js +89 -1476
  256. package/dist/core/runtime/broker/frame-engine-control.d.ts +20 -0
  257. package/dist/core/runtime/broker/frame-engine-control.js +231 -0
  258. package/dist/core/runtime/broker/frame-memory-refs.d.ts +19 -0
  259. package/dist/core/runtime/broker/frame-memory-refs.js +41 -0
  260. package/dist/core/runtime/broker/frame-model-query.d.ts +13 -0
  261. package/dist/core/runtime/broker/frame-model-query.js +69 -0
  262. package/dist/core/runtime/broker/frame-read.d.ts +19 -0
  263. package/dist/core/runtime/broker/frame-read.js +116 -0
  264. package/dist/core/runtime/broker/frame-replies.d.ts +24 -0
  265. package/dist/core/runtime/broker/frame-replies.js +64 -0
  266. package/dist/core/runtime/broker/frame-session.d.ts +34 -0
  267. package/dist/core/runtime/broker/frame-session.js +203 -0
  268. package/dist/core/runtime/broker/rebind.js +50 -0
  269. package/dist/core/runtime/broker/turn-ignition.d.ts +42 -0
  270. package/dist/core/runtime/broker/turn-ignition.js +74 -0
  271. package/dist/core/runtime/broker-extension-render.js +9 -85
  272. package/dist/core/runtime/broker-protocol.js +0 -4
  273. package/dist/core/runtime/broker-sdk.js +2 -1
  274. package/dist/core/runtime/broker.d.ts +1 -1
  275. package/dist/core/runtime/broker.js +5 -23
  276. package/dist/core/runtime/fault.js +3 -2
  277. package/dist/core/runtime/headless-pi.js +3 -2
  278. package/dist/core/runtime/host.js +2 -3
  279. package/dist/core/runtime/kickoff.js +0 -4
  280. package/dist/core/runtime/launch-target.js +2 -1
  281. package/dist/core/runtime/launch.d.ts +2 -2
  282. package/dist/core/runtime/launch.js +20 -10
  283. package/dist/core/runtime/lifecycle.js +17 -26
  284. package/dist/core/runtime/managed-provider-cooling.d.ts +1 -1
  285. package/dist/core/runtime/naming.js +0 -2
  286. package/dist/core/runtime/nodes.js +2 -1
  287. package/dist/core/runtime/persona.js +0 -6
  288. package/dist/core/runtime/placement-tmux.js +2 -5
  289. package/dist/core/runtime/placement.js +4 -11
  290. package/dist/core/runtime/recap.js +4 -3
  291. package/dist/core/runtime/reopen.js +4 -7
  292. package/dist/core/runtime/reset.js +0 -4
  293. package/dist/core/runtime/resume-root.js +0 -2
  294. package/dist/core/runtime/revive.js +17 -13
  295. package/dist/core/runtime/session-list-cache.js +2 -2
  296. package/dist/core/runtime/shell-expansion.js +0 -2
  297. package/dist/core/runtime/spawn.js +0 -2
  298. package/dist/core/runtime/stop-guard.js +28 -16
  299. package/dist/core/runtime/stream-watchdog.d.ts +0 -1
  300. package/dist/core/runtime/stream-watchdog.js +13 -18
  301. package/dist/core/runtime/tmux-bindings.js +0 -2
  302. package/dist/core/runtime/tmux-driver.d.ts +12 -0
  303. package/dist/core/runtime/tmux-driver.js +18 -13
  304. package/dist/core/runtime/warm-pool.js +0 -4
  305. package/dist/core/scope.js +4 -3
  306. package/dist/core/spawn.js +2 -3
  307. package/dist/core/subscription-state.js +10 -17
  308. package/dist/core/substrate/__tests__/surface-match-pre-command.test.js +3 -3
  309. package/dist/core/substrate/frontmatter-validation.d.ts +1 -0
  310. package/dist/core/substrate/frontmatter-validation.js +1 -1
  311. package/dist/core/substrate/render.js +0 -12
  312. package/dist/core/substrate/schema.js +0 -12
  313. package/dist/core/substrate/surface-match.js +0 -2
  314. package/dist/core/termrender/display.js +2 -1
  315. package/dist/core/tui/page-host.d.ts +3 -3
  316. package/dist/core/tui/page-host.js +4 -4
  317. package/dist/core/tui/panel.js +3 -3
  318. package/dist/core/wake.js +0 -12
  319. package/dist/core/worktree.js +0 -2
  320. package/dist/daemon/__tests__/reconciler-signature.test.d.ts +1 -0
  321. package/dist/daemon/__tests__/reconciler-signature.test.js +36 -0
  322. package/dist/daemon/api/__tests__/error-seam.test.js +27 -0
  323. package/dist/daemon/api/__tests__/seam/api-server.test.js +2 -16
  324. package/dist/daemon/api/__tests__/seam/b10-attach-modelauth.test.js +3 -11
  325. package/dist/daemon/api/__tests__/seam/leaf-api-parity.test.js +0 -6
  326. package/dist/daemon/api/handlers/attach.js +2 -1
  327. package/dist/daemon/api/handlers/broker-ops.js +3 -6
  328. package/dist/daemon/api/handlers/canvas.js +12 -18
  329. package/dist/daemon/api/handlers/feedback-comments.js +0 -8
  330. package/dist/daemon/api/handlers/health.js +3 -2
  331. package/dist/daemon/api/handlers/human-requests.js +0 -10
  332. package/dist/daemon/api/handlers/human.js +1 -5
  333. package/dist/daemon/api/handlers/inbox.js +0 -18
  334. package/dist/daemon/api/handlers/memory.js +1 -1
  335. package/dist/daemon/api/handlers/messages.js +16 -20
  336. package/dist/daemon/api/handlers/nodes.js +23 -22
  337. package/dist/daemon/api/handlers/reports.js +1 -1
  338. package/dist/daemon/api/handlers/reviews.js +1 -6
  339. package/dist/daemon/api/handlers/worktree.js +6 -22
  340. package/dist/daemon/api/map.d.ts +4 -2
  341. package/dist/daemon/api/map.js +22 -8
  342. package/dist/daemon/api/router.js +0 -4
  343. package/dist/daemon/api/server.js +2 -5
  344. package/dist/daemon/boot-migrations.d.ts +14 -0
  345. package/dist/daemon/boot-migrations.js +31 -0
  346. package/dist/daemon/cron/armed-context.d.ts +21 -0
  347. package/dist/daemon/cron/armed-context.js +100 -0
  348. package/dist/daemon/cron/capture.d.ts +20 -0
  349. package/dist/daemon/cron/capture.js +45 -0
  350. package/dist/daemon/cron/disposition.d.ts +19 -0
  351. package/dist/daemon/cron/disposition.js +52 -0
  352. package/dist/daemon/cron/escalation.d.ts +12 -0
  353. package/dist/daemon/cron/escalation.js +124 -0
  354. package/dist/daemon/cron/held.d.ts +4 -0
  355. package/dist/daemon/cron/held.js +20 -0
  356. package/dist/daemon/cron/lease-recovery.d.ts +3 -0
  357. package/dist/daemon/cron/lease-recovery.js +119 -0
  358. package/dist/daemon/cron/live-runs.d.ts +11 -0
  359. package/dist/daemon/cron/live-runs.js +23 -0
  360. package/dist/daemon/cron/passes.d.ts +11 -0
  361. package/dist/daemon/cron/passes.js +78 -0
  362. package/dist/daemon/cron/settle.d.ts +28 -0
  363. package/dist/daemon/cron/settle.js +37 -0
  364. package/dist/daemon/cron/sinks.d.ts +2 -0
  365. package/dist/daemon/cron/sinks.js +100 -0
  366. package/dist/daemon/cron-run.d.ts +5 -41
  367. package/dist/daemon/cron-run.js +193 -882
  368. package/dist/daemon/crtrd-cli.js +2 -1
  369. package/dist/daemon/crtrd.js +22 -22
  370. package/dist/daemon/fleet.js +0 -4
  371. package/dist/daemon/human/deliver-action.js +2 -1
  372. package/dist/daemon/human/sweep.js +15 -4
  373. package/dist/daemon/manage.d.ts +2 -2
  374. package/dist/daemon/manage.js +4 -13
  375. package/dist/daemon/pidfile.js +2 -2
  376. package/dist/daemon/reconcilers/broker-supervision.js +27 -30
  377. package/dist/daemon/reconcilers/dormant-inbox.d.ts +1 -1
  378. package/dist/daemon/reconcilers/dormant-inbox.js +2 -1
  379. package/dist/daemon/reconcilers/live-obligation.js +2 -2
  380. package/dist/daemon/reconcilers/pending-review-submit.d.ts +1 -1
  381. package/dist/daemon/reconcilers/pending-review-submit.js +2 -1
  382. package/dist/daemon/reconcilers/storage-maintenance.d.ts +2 -1
  383. package/dist/daemon/reconcilers/storage-maintenance.js +2 -1
  384. package/dist/daemon/review/companion.js +2 -2
  385. package/dist/daemon/review/sweep.js +8 -3
  386. package/dist/migrations/001-surfaces-frontmatter.js +0 -6
  387. package/dist/migrations/corpus.js +2 -5
  388. package/dist/migrations/registry.js +15 -2
  389. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +0 -4
  390. package/dist/pi-extensions/broker-local.js +2 -1
  391. package/dist/pi-extensions/canvas-bash-valve.js +7 -20
  392. package/dist/pi-extensions/canvas-context-intro.js +2 -7
  393. package/dist/pi-extensions/canvas-doc-substrate.js +2 -3
  394. package/dist/pi-extensions/canvas-goal-capture.js +2 -1
  395. package/dist/pi-extensions/canvas-inbox-watcher.js +5 -12
  396. package/dist/pi-extensions/canvas-passive-context.js +2 -5
  397. package/dist/pi-extensions/canvas-preview-result.js +2 -1
  398. package/dist/pi-extensions/canvas-prompt-scrub.js +2 -1
  399. package/dist/pi-extensions/canvas-recap.js +3 -16
  400. package/dist/pi-extensions/canvas-review-boundary.js +2 -5
  401. package/dist/pi-extensions/canvas-stophook.js +2 -26
  402. package/dist/pi-extensions/canvas-structured-output.js +2 -1
  403. package/dist/pi-extensions/canvas-tool-guide.js +2 -1
  404. package/dist/shared/__tests__/env-boundary.test.d.ts +1 -0
  405. package/dist/shared/__tests__/env-boundary.test.js +93 -0
  406. package/dist/shared/env.d.ts +68 -0
  407. package/dist/shared/env.js +171 -0
  408. package/package.json +3 -1
  409. package/runtime.lock.json +2 -2
  410. package/dist/core/canvas/render.d.ts +0 -47
  411. package/dist/core/canvas/render.js +0 -245
@@ -1,1497 +1,110 @@
1
- import { execFile } from 'node:child_process';
2
- import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
3
- import { randomUUID } from 'node:crypto';
4
- import { tmpdir } from 'node:os';
5
- import { join } from 'node:path';
6
- import { AUTH_FAULT_RECOVERY_BODY, formatCard, SITUATIONAL_CONTEXT_CUSTOM_TYPE, } from '../../../shared/generated-context.js';
7
- import { emitEvent } from '../../events/emit.js';
8
- import { operationIdContext } from '../../events/operation-id.js';
9
- import { buildRefInventory } from '../../memory/inline-ref-inventory.js';
10
- import { buildGuidance } from '../../memory/inline-ref-guidance.js';
11
- import { clearSessionCache } from '../../substrate/session-cache.js';
12
- import { captureSessionChatCapabilities } from '../command-surface.js';
13
- import { promptWithAdvertisedCommandInvocation } from '../advertised-command-invocation.js';
14
- export { promptWithAdvertisedCommandInvocation } from '../advertised-command-invocation.js';
15
- import { configuredLadderProviders, isProviderPinnedModelToken, nextLadderModel, normalizeModel, } from '../launch.js';
16
- import { clearFault, readFault } from '../fault.js';
17
- import { isManagedProvider, readSubscriptionPool, } from '../../subscription-state.js';
18
- import { SessionListCache } from '../session-list-cache.js';
19
- import { sessionListCachePath } from '../../canvas/paths.js';
20
- import { isReviewBoundary } from '../session-visibility.js';
21
- import { isUnknownModel, parseModelSpec } from '../model-selection.js';
22
- import { normalizeSnapshotTail } from '../broker-protocol.js';
23
- import { buildCommandList, buildGetTreeData, buildListMemoryRefsData, buildListModelsData, buildScopedModelsData, buildSessionsData, buildSettingsData, modelKey, } from './read-ops.js';
24
- export const REVIEW_BOUNDARY_NAVIGATION_CODE = 'review_boundary_navigation';
25
- export class ReviewBoundaryNavigationError extends Error {
26
- targetId;
27
- code = REVIEW_BOUNDARY_NAVIGATION_CODE;
28
- constructor(targetId) {
29
- super(`review companion cannot navigate before the visible transcript boundary (target: ${targetId})`);
30
- this.targetId = targetId;
31
- this.name = 'ReviewBoundaryNavigationError';
32
- }
33
- }
34
- /**
35
- * Route a writable client's `prompt`/`follow_up` frame against the LIVE session
36
- * state. The client picks its frame type off a possibly-STALE `isStreaming`
37
- * snapshot, so the broker is authoritative and the client's choice is a HINT:
38
- *
39
- * - m-B (streaming-safe prompt): a `prompt` arriving mid-stream needs
40
- * PromptOptions.streamingBehavior ("Required if streaming"; prompt() throws
41
- * without it). "steer" mirrors pi interactive-mode's own
42
- * Enter-while-streaming submit (`prompt(text, { streamingBehavior: "steer" })`).
43
- * - m-C (idle-safe follow_up): pi's followUp() only enqueues into a RUNNING
44
- * agent loop — on an idle session nothing ever drains the queue and the text
45
- * is silently lost. An idle `follow_up` therefore runs as a prompt now.
46
- *
47
- * driveEngine calls this synchronously with `session.isStreaming`, so the
48
- * routed state cannot go stale between the check and the engine call.
49
- * C1: images ride as a field on PromptOptions (pi: `prompt(text, { images })`).
50
- */
51
- export function resolveEngineRoute(frame, isStreaming) {
52
- if (frame.type === 'follow_up' && isStreaming)
53
- return { call: 'followUp' };
54
- const options = {};
55
- if (frame.images !== undefined)
56
- options.images = frame.images;
57
- if (isStreaming)
58
- options.streamingBehavior = 'steer';
59
- return { call: 'prompt', options };
60
- }
61
- /**
62
- * True iff `text` would be consumed by pi's OWN leading-name preflight before
63
- * `session.prompt/steer/followUp` ever sees it as prose — an extension command
64
- * (`session.extensionRunner.getCommand`), a `/skill:name` skill, or a `/name`
65
- * prompt template (mirrors `_tryExecuteExtensionCommand` / `_expandSkillCommand`
66
- * / `expandPromptTemplate` in the pinned SDK verbatim, including the exact
67
- * name-parsing substrings). Inline memory-reference guidance must NEVER fire
68
- * for these — the leading token routes to the engine's own command surface,
69
- * not prose referencing a memory doc. Each lookup is independently wrapped so
70
- * an engine missing one accessor (the fake, or a degraded session) degrades
71
- * that check to "not a match" rather than aborting the whole classification —
72
- * matching `buildCommandList`'s existing "no live extensionRunner" tolerance.
73
- */
74
- export function isLeadingEngineCommand(text, session) {
75
- if (text.startsWith('/')) {
76
- const spaceIndex = text.indexOf(' ');
77
- const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
78
- try {
79
- if (session.extensionRunner?.getCommand(commandName))
80
- return true;
81
- }
82
- catch {
83
- /* engine without a live extensionRunner (e.g. the fake) — not a match */
84
- }
85
- }
86
- if (text.startsWith('/skill:')) {
87
- const spaceIndex = text.indexOf(' ');
88
- const skillName = spaceIndex === -1 ? text.slice(7) : text.slice(7, spaceIndex);
89
- try {
90
- if (session.resourceLoader?.getSkills().skills.some((s) => s.name === skillName))
91
- return true;
92
- }
93
- catch {
94
- /* engine without a live resourceLoader — not a match */
95
- }
96
- }
97
- if (text.startsWith('/')) {
98
- const match = text.match(/^\/([^\s]+)(?:\s+([\s\S]*))?$/);
99
- const templateName = match?.[1];
100
- if (templateName !== undefined) {
101
- try {
102
- if (session.promptTemplates?.some((t) => t.name === templateName))
103
- return true;
104
- }
105
- catch {
106
- /* engine without a live promptTemplates getter — not a match */
107
- }
108
- }
109
- }
110
- return false;
111
- }
112
- /** A `steer` frame normally maps directly to pi's steer queue. Engine commands,
113
- * skills, and templates must instead pass through prompt()'s leading-command
114
- * preflight; with streamingBehavior:'steer', ordinary expanded text still lands
115
- * in the same queue. */
116
- export function resolveSteerCall(text, session) {
117
- return isLeadingEngineCommand(text, session) ? 'prompt' : 'steer';
118
- }
119
- /**
120
- * Enumerate every leading-token STRING this session's engine would
121
- * independently consume as a leading command/skill/template dispatch BEFORE
122
- * inline memory-reference guidance ever applies — the read-op sibling of
123
- * `isLeadingEngineCommand`'s point-lookup, gathered as a full enumeration for
124
- * viewer-side suppression instead of a per-submission membership check. Same
125
- * three sources, same shape of the leading match `isLeadingEngineCommand`
126
- * checks (bare extension-command invocation names and prompt-template names,
127
- * `skill:<name>` for skills), so `/${token}` round-trips through
128
- * `isLeadingEngineCommand(text, session)` for every emitted token (locked by
129
- * a test). Kept STRICTLY separate from `buildCommandList` (command
130
- * completion) and the ref inventory (`RefMeta` rows) — this is neither.
131
- */
132
- export function engineLeadingCommandTokens(session) {
133
- const out = [];
134
- try {
135
- for (const c of session.extensionRunner?.getRegisteredCommands() ?? [])
136
- out.push(c.invocationName);
137
- }
138
- catch {
139
- /* engine without a live extensionRunner (e.g. the fake) — none */
140
- }
141
- try {
142
- for (const s of session.resourceLoader?.getSkills().skills ?? [])
143
- out.push(`skill:${s.name}`);
144
- }
145
- catch {
146
- /* engine without a live resourceLoader — none */
147
- }
148
- try {
149
- for (const t of session.promptTemplates ?? [])
150
- out.push(t.name);
151
- }
152
- catch {
153
- /* engine without a live promptTemplates getter — none */
154
- }
155
- return out;
156
- }
157
- /**
158
- * Choose how the hidden `crtr-memory-refs` guidance custom message rides
159
- * alongside a human frame's own engine call (design Flow B). Pure over the
160
- * frame's routing outcome — never reads live session state itself, so a
161
- * caller can decide the delivery mode from the exact same `route`
162
- * `resolveEngineRoute` already computed for the human call.
163
- *
164
- * - `steer` frame -> 'steer' (always; no route call exists).
165
- * - a TRUE mid-stream follow_up (`routeCall === 'followUp'`) -> 'followUp'.
166
- * - every other `prompt`/`follow_up` routed as `routeCall === 'prompt'`:
167
- * idle -> 'nextTurn' (the ONLY case guidance precedes the human call —
168
- * pi's `prompt()` reads+clears its pending-nextTurn queue synchronously
169
- * into the SAME turn as the new user message); streaming (m-B
170
- * steer-while-streaming) -> 'steer', never 'nextTurn' (that would attach
171
- * to a later, unrelated turn).
172
- */
173
- export function chooseGuidanceDeliveryMode(frameType, routeCall, isStreaming) {
174
- if (frameType === 'steer')
175
- return 'steer';
176
- if (routeCall === 'followUp')
177
- return 'followUp';
178
- return isStreaming ? 'steer' : 'nextTurn';
179
- }
180
- /**
181
- * Deliver a `nextTurn` guidance custom message ATOMICALLY around the idle-
182
- * prompt call it's meant to ride alongside. Pi's `_pendingNextTurnMessages`
183
- * queue (the only public seam for `deliverAs:'nextTurn'`) is drained ONLY deep
184
- * inside `session.prompt()`, well after several await points — extension-
185
- * command interception, the `input` extension transform, a streaming reroute,
186
- * model/auth validation, pre-turn compaction — that can return or throw BEFORE
187
- * the drain ever runs. `sendCustomMessage`'s nextTurn branch pushes
188
- * synchronously and unconditionally, so any of those early exits strands the
189
- * pushed guidance in the queue to attach itself to whichever LATER, unrelated
190
- * prompt happens to reach the drain first.
191
- *
192
- * There is no public SDK seam to observe or cancel a pending nextTurn message
193
- * (`_pendingNextTurnMessages` is TS-`private` only — a plain runtime field, not
194
- * `#private`), so this reaches into that one field and removes EXACTLY the
195
- * entry this call pushed, identified by object identity via a locally-
196
- * generated marker riding the message's own `details` field (never surfaced to
197
- * the model, and irrelevant if the message WAS consumed normally — `details`
198
- * is just carried through like any other field once folded into a real turn).
199
- * Runs UNCONDITIONALLY after the human call settles, success or failure/early-
200
- * return alike — a "successful" `prompt()` promise can just as easily be an
201
- * early return that skipped the drain (an intercepted extension command, an
202
- * `input` transform, a streaming reroute) as a genuine consumed turn, so only
203
- * checking `.catch` is not enough.
204
- */
205
- export async function promptWithNextTurnGuidance(session, guidance, runPrompt) {
206
- // Must survive Pi's structured-clone step when the queued custom message is
207
- // folded into the turn. A Symbol reaches that step in `details` and aborts
208
- // the real prompt with "could not be cloned" before the provider runs.
209
- const marker = `crtr-memory-refs-pending:${randomUUID()}`;
210
- let queued = false;
211
- try {
212
- await session.sendCustomMessage({ customType: 'crtr-memory-refs', content: guidance, display: false, details: marker }, { deliverAs: 'nextTurn' });
213
- queued = true;
214
- }
215
- catch (err) {
216
- emitEvent({
217
- level: 'warn',
218
- event: 'broker.memory_refs.guidance_send_failed',
219
- error: err,
220
- });
221
- }
222
- try {
223
- await runPrompt();
224
- }
225
- finally {
226
- if (queued)
227
- clearOwnPendingNextTurnGuidance(session, marker);
228
- }
229
- }
230
- /** Splice out exactly the pending-nextTurn entry `marker` identifies (object-
231
- * identity match on `details`), if it is still present — a no-op (and safe)
232
- * if `session.prompt()` already drained it normally, or if the session/field
233
- * shape doesn't match (defensive against an engine without the field, e.g. a
234
- * future SDK bump or a structurally-different test double). */
235
- function clearOwnPendingNextTurnGuidance(session, marker) {
236
- const pending = session._pendingNextTurnMessages;
237
- if (!Array.isArray(pending))
238
- return;
239
- const idx = pending.findIndex((m) => m?.details === marker);
240
- if (idx !== -1)
241
- pending.splice(idx, 1);
242
- }
243
- /** True iff a parsed JSONL branch-entry line is a hidden (`display:false`)
244
- * custom message — the general display boundary crossed if left in an
245
- * exported branch (not just `crtr-memory-refs` guidance; any hidden custom
246
- * content). Covers both persisted shapes a branch entry can take: a
247
- * `type:'custom_message'` entry (display at the entry's own top level) and a
248
- * `type:'message'` entry wrapping a `role:'custom'` message (display nested
249
- * under `.message`). */
250
- export function isHiddenJsonlBranchEntry(entry) {
251
- if (entry === null || typeof entry !== 'object')
252
- return false;
253
- const e = entry;
254
- if (e.type === 'custom_message' && e.display === false)
255
- return true;
256
- if (e.type === 'message' && e.message?.role === 'custom' && e.message.display === false)
257
- return true;
258
- return false;
259
- }
260
- /** Rewrite an already-exported JSONL file in place, dropping every hidden
261
- * (`display:false`) branch entry while re-chaining `parentId` so the
262
- * remaining entries stay a single valid linear branch — applying the general
263
- * display:false boundary to `session.exportToJsonl`'s own output, which
264
- * serializes every entry verbatim regardless of display. The header line
265
- * (line 1) is copied through untouched. */
266
- export function stripHiddenEntriesFromJsonlExport(filePath) {
267
- const raw = readFileSync(filePath, 'utf8');
268
- const lines = raw.split('\n').filter((l) => l !== '');
269
- if (lines.length === 0)
270
- return;
271
- const [headerLine, ...entryLines] = lines;
272
- let prevId = null;
273
- const kept = [];
274
- for (const line of entryLines) {
275
- const entry = JSON.parse(line);
276
- if (isHiddenJsonlBranchEntry(entry))
277
- continue;
278
- kept.push(JSON.stringify({ ...entry, parentId: prevId }));
279
- if (typeof entry.id === 'string')
280
- prevId = entry.id;
281
- }
282
- writeFileSync(filePath, `${[headerLine, ...kept].join('\n')}\n`);
283
- }
1
+ import { createFrameReplies } from './frame-replies.js';
2
+ import { createEngineDriver } from './engine-drive.js';
3
+ import { createClientHandlers } from './frame-client.js';
4
+ import { createMemoryRefGuidance } from './frame-memory-refs.js';
5
+ import { createAuthReloadGate } from './auth-reload.js';
6
+ import { createEngineControlHandler } from './frame-engine-control.js';
7
+ import { createReadHandler } from './frame-read.js';
8
+ import { createSessionHandler } from './frame-session.js';
9
+ import { createKickoffDriver, createPromptDispatchTracker, createTurnAcceptanceGate, } from './turn-ignition.js';
284
10
  /** Owns frame-local input state while dispatching through explicit broker collaborators. */
285
11
  export function createFrameDispatchContext(deps) {
286
12
  const { nodeId, cfg, registry, toolGroups, projection, rebind, boundaryReviewId, pendingDialogs, sendWelcome, replayExtraPendingDialogsTo, reWelcomeAll, disposeAndExit, persistModelChoice, broadcastModelChanged, registryOf, formatModelSpec, formatExactModelSpec, resolveLaunchModel, } = deps;
287
13
  const currentSession = () => rebind.session();
288
14
  const currentServices = () => rebind.services();
289
- let kickoffPending;
290
- let kickoffInFlight = false;
291
- let reloadAuthInFlight = null;
292
- let authFaultRedriveInFlight = false;
293
- const turnAcceptedWaiters = new Set();
294
- const notifyTurnAccepted = () => {
295
- const waiters = [...turnAcceptedWaiters];
296
- turnAcceptedWaiters.clear();
297
- for (const waiter of waiters)
298
- waiter();
299
- };
300
- const sessionHasNoModel = () => currentSession().model === undefined ||
301
- isUnknownModel(currentSession().model);
302
- const drivePendingKickoff = () => {
303
- if (kickoffPending === undefined || kickoffInFlight || sessionHasNoModel())
304
- return;
305
- const prompt = kickoffPending;
306
- kickoffPending = undefined;
307
- kickoffInFlight = true;
308
- trackPromptDispatch(operationIdContext.fresh(() => currentSession()
309
- .prompt(prompt)
310
- .catch((err) => {
311
- emitEvent({
312
- level: 'error',
313
- event: 'broker.prompt.first_failed',
314
- error: err,
315
- });
316
- })
317
- .finally(() => {
318
- kickoffInFlight = false;
319
- })));
320
- };
321
- // Drive the engine on behalf of a writable client.
322
- // -------------------------------------------------------------------------
323
- // ---------------------------------------------------------------------------
324
- // Inline memory-reference inventory (design: inline memory references) — a
325
- // single broker-level memo shared by BOTH the `list_memory_refs` read-op
326
- // reply and per-submission guidance resolution below, so a burst of reads +
327
- // submissions between reloads never re-walks the corpus per call. Cleared
328
- // (work item 3) only on a SUCCESSFUL `currentSession().reload()` — a failed reload
329
- // leaves the prior memo intact, since nothing about the corpus is known to
330
- // have changed.
331
- // ---------------------------------------------------------------------------
332
- let memoizedRefInventory = null;
333
- const getRefInventory = () => {
334
- if (memoizedRefInventory === null)
335
- memoizedRefInventory = buildRefInventory();
336
- return memoizedRefInventory;
337
- };
338
- /** Resolve the hidden guidance text for a human frame's verbatim text, or
339
- * `null` when no guidance should be sent (a leading engine command/skill/
340
- * template, no resolvable refs, or a degraded inventory/guidance build —
341
- * work item 6: any failure here degrades to "no guidance", logged once as a
342
- * structured warning, and NEVER blocks/alters the human delivery below). */
343
- const resolveFrameGuidance = (text) => {
344
- try {
345
- if (isLeadingEngineCommand(text, currentSession()))
346
- return null;
347
- return buildGuidance(text, getRefInventory().names);
348
- }
349
- catch (err) {
350
- emitEvent({
351
- level: 'warn',
352
- event: 'broker.memory_refs.guidance_resolve_failed',
353
- error: err,
354
- });
355
- return null;
356
- }
357
- };
358
- // Prompt dispatches whose promise has not yet settled. `currentSession().prompt()`
359
- // does async setup (input transforms, model/auth validation, compaction)
360
- // BEFORE `_isAgentRunActive` flips, so for a short window after a routed
361
- // deliver the engine reads idle and `currentSession().abort()` is a silent no-op — an
362
- // id-bearing abort racing that window would miss the turn entirely (verified
363
- // live). The counter lets it distinguish "genuinely idle" from "turn still
364
- // igniting" and wait for the run to become abortable.
365
- let pendingPromptDispatches = 0;
366
- const trackPromptDispatch = (p) => {
367
- pendingPromptDispatches++;
368
- const tracked = Promise.resolve(p);
369
- void tracked.finally(() => {
370
- pendingPromptDispatches--;
371
- });
372
- return tracked;
373
- };
374
- /**
375
- * Drive the engine for one client frame. For a prompt/follow_up frame this
376
- * returns a promise that settles with the engine call's OUTCOME — `undefined`
377
- * when the call succeeded, the rejection value when it did not — so a caller
378
- * that must report success (the `deliver` ack) can read it instead of assuming
379
- * the dispatch worked. Every other frame type keeps the fire-and-forget shape
380
- * and returns `undefined`.
381
- */
382
- const driveEngine = (client, frame) => {
383
- // The engine call's failure, captured for the returned outcome. relayError
384
- // still relays the error frame; recording it is what lets the caller ack
385
- // from the outcome rather than from the fact that a call was made at all.
386
- let failure;
387
- const relayError = (err) => {
388
- failure = err;
389
- registry.sendFrame(client, {
390
- type: 'error',
391
- code: 'engine_error',
392
- message: String(err),
393
- });
394
- };
395
- // `prompt { interrupt: true }` is one atomic viewer intention: end whatever
396
- // is producing now, then start this as a new turn. A normal mid-stream
397
- // prompt deliberately steers into pi's queue; the whip must not.
398
- if (frame.type === 'prompt' && frame.interrupt === true) {
399
- return (async () => {
400
- if (currentSession().isBashRunning) {
401
- currentSession().abortBash();
402
- }
403
- else {
404
- // A prompt can be in pi's async setup before isStreaming flips. Match
405
- // the correlated interrupt's guard so a whip cannot silently miss it.
406
- if (!currentSession().isStreaming && pendingPromptDispatches > 0) {
407
- const deadline = Date.now() + 5_000;
408
- while (Date.now() < deadline &&
409
- !currentSession().isStreaming &&
410
- pendingPromptDispatches > 0) {
411
- await new Promise((resolve) => setTimeout(resolve, 25));
412
- }
413
- }
414
- if (currentSession().isStreaming)
415
- await currentSession().abort();
416
- }
417
- return driveEngine(client, {
418
- type: 'prompt',
419
- text: frame.text,
420
- ...(frame.images !== undefined ? { images: frame.images } : {}),
421
- });
422
- })().catch((err) => {
423
- relayError(err);
424
- return failure;
425
- });
426
- }
427
- // Hidden `crtr-memory-refs` guidance rides ALONGSIDE the human frame's own
428
- // engine call, never rewriting `frame.text` — see resolveFrameGuidance /
429
- // chooseGuidanceDeliveryMode above. A failed SEND (as opposed to a failed
430
- // resolve, handled above) is likewise logged-and-swallowed: it must never
431
- // reach `relayError`, which is reserved for the human call's own outcome.
432
- const sendGuidance = (guidance, deliverAs) => currentSession()
433
- .sendCustomMessage({ customType: 'crtr-memory-refs', content: guidance, display: false }, { deliverAs })
434
- .catch((err) => {
435
- emitEvent({
436
- level: 'warn',
437
- event: 'broker.memory_refs.guidance_send_failed',
438
- error: err,
439
- });
440
- });
441
- switch (frame.type) {
442
- case 'prompt':
443
- case 'follow_up': {
444
- // Settle on the engine call's outcome: `failure` is set by relayError
445
- // before the caught promise resolves, so reading it after the chain is
446
- // the call's verdict.
447
- const settle = (p) => p.then(() => failure);
448
- const dispatch = () => {
449
- const route = resolveEngineRoute(frame, currentSession().isStreaming);
450
- const guidance = resolveFrameGuidance(frame.text);
451
- if (guidance === null) {
452
- if (route.call === 'prompt') {
453
- // A routed prompt STARTS a turn — including an idle follow_up
454
- // escalated by m-C — so it gets a fresh operation id. A true
455
- // (mid-stream) followUp rides the current turn's operation, like
456
- // steer.
457
- return settle(trackPromptDispatch(operationIdContext.fresh(() => promptWithAdvertisedCommandInvocation(currentSession(), frame.text, () => currentSession().prompt(frame.text, route.options)).catch(relayError))));
458
- }
459
- // C1: pi's followUp() takes images as a POSITIONAL 2nd arg (`followUp(text, images?)`).
460
- return settle(currentSession()
461
- .followUp(frame.text, frame.images)
462
- .catch(relayError));
463
- }
464
- const deliverAs = chooseGuidanceDeliveryMode(frame.type, route.call, currentSession().isStreaming);
465
- if (route.call === 'prompt') {
466
- if (deliverAs === 'nextTurn') {
467
- // The ONLY ordering where guidance precedes the human call — pi's
468
- // prompt() reads+clears its pending-nextTurn queue synchronously
469
- // at call time, injecting it into the SAME turn as the new user
470
- // message. promptWithNextTurnGuidance queues the guidance and then
471
- // ATOMICALLY clears its own pending entry if prompt() settles
472
- // without ever reaching that drain (an early throw/return leaves
473
- // nothing stranded to leak into a later, unrelated turn).
474
- return settle(trackPromptDispatch(promptWithNextTurnGuidance(currentSession(), guidance, () => operationIdContext.fresh(() => promptWithAdvertisedCommandInvocation(currentSession(), frame.text, () => currentSession().prompt(frame.text, route.options)))).catch(relayError)));
475
- }
476
- return settle(trackPromptDispatch(operationIdContext.fresh(() => promptWithAdvertisedCommandInvocation(currentSession(), frame.text, () => currentSession().prompt(frame.text, route.options))
477
- .catch(relayError)
478
- .then(() => sendGuidance(guidance, deliverAs)))));
479
- }
480
- // deliverAs is always 'followUp' here per chooseGuidanceDeliveryMode's
481
- // contract (a followUp routeCall never yields 'nextTurn').
482
- return settle(currentSession()
483
- .followUp(frame.text, frame.images)
484
- .catch(relayError)
485
- .then(() => sendGuidance(guidance, deliverAs)));
486
- };
487
- // /login acknowledges before its asynchronous local refresh completes.
488
- // An idle turn must not overtake that refresh and launch on the stale
489
- // route; true mid-stream follow-ups keep their immediate queue behavior.
490
- const authReload = currentSession().isStreaming
491
- ? undefined
492
- : reloadAuthInFlight?.promise;
493
- if (authReload === undefined)
494
- return dispatch();
495
- const waitingGeneration = rebind.installedGeneration();
496
- const waitingSession = currentSession();
497
- return authReload.then(() => {
498
- if (rebind.installedGeneration() !== waitingGeneration ||
499
- currentSession() !== waitingSession) {
500
- relayError(new Error('currentSession() changed while prompt waited for authentication reload'));
501
- return failure;
502
- }
503
- return dispatch();
504
- });
505
- }
506
- case 'steer': {
507
- // A viewer submits ordinary text as `steer` while the agent is running,
508
- // including a slash command injected by the tmux prefix menu. pi's
509
- // steer() deliberately rejects registered extension commands because
510
- // they cannot be queued; prompt() performs the command preflight first
511
- // and executes it immediately even while streaming. It also expands a
512
- // matching skill/template before applying the same steer behavior.
513
- if (resolveSteerCall(frame.text, currentSession()) === 'prompt') {
514
- void promptWithAdvertisedCommandInvocation(currentSession(), frame.text, () => currentSession().prompt(frame.text, {
515
- images: frame.images,
516
- streamingBehavior: 'steer',
517
- })).catch(relayError);
518
- break;
519
- }
520
- const guidance = resolveFrameGuidance(frame.text);
521
- // C1: pi's steer() takes images as a POSITIONAL 2nd arg (`steer(text, images?)`).
522
- if (guidance === null) {
523
- void currentSession()
524
- .steer(frame.text, frame.images)
525
- .catch(relayError);
526
- break;
527
- }
528
- void currentSession()
529
- .steer(frame.text, frame.images)
530
- .catch(relayError)
531
- .then(() => sendGuidance(guidance, 'steer'));
532
- break;
533
- }
534
- case 'abort': {
535
- // esc/abort cancels whatever is live: a running `!` command (abortBash)
536
- // takes precedence over the agent turn, mirroring pi interactive mode.
537
- if (currentSession().isBashRunning) {
538
- currentSession().abortBash();
539
- if (frame.id !== undefined)
540
- ackTo(client, 'abort', true, 'bash', frame.id);
541
- break;
542
- }
543
- if (frame.id === undefined) {
544
- // A viewer's Esc — unchanged instant semantics, no ack.
545
- void currentSession().abort().catch(relayError);
546
- break;
547
- }
548
- // An id-bearing abort (a one-shot daemon interrupt) must close the
549
- // pre-turn window: a routed deliver whose prompt() is still in async
550
- // setup reads as idle and abort() would silently miss it. If a prompt
551
- // dispatch is pending, wait (bounded) for the run to become abortable
552
- // — or for every dispatch to settle without one (an early return) —
553
- // then abort and ack with what was actually interrupted.
554
- void (async () => {
555
- try {
556
- if (!currentSession().isStreaming && pendingPromptDispatches > 0) {
557
- const deadline = Date.now() + 5_000;
558
- while (Date.now() < deadline &&
559
- !currentSession().isStreaming &&
560
- pendingPromptDispatches > 0) {
561
- await new Promise((r) => setTimeout(r, 25));
562
- }
563
- }
564
- const detail = currentSession().isStreaming ? 'turn' : 'idle';
565
- await currentSession().abort();
566
- ackTo(client, 'abort', true, detail, frame.id);
567
- }
568
- catch (err) {
569
- engineErrorTo(client, frame.id)(err);
570
- }
571
- })();
572
- break;
573
- }
574
- case 'bash': {
575
- // `!`/`!!` bash: run via currentSession().executeBash (records a bashExecution
576
- // context message, starts NO agent turn) and relay its lifecycle to ALL
577
- // viewers as bash_start/bash_output/bash_end so each renders the live
578
- // component. Serialized like pi's interactive mode — reject a second `!`
579
- // while one is already running rather than racing _bashAbortController.
580
- const command = frame.command;
581
- if (command.trim() === '')
582
- break;
583
- if (currentSession().isBashRunning) {
584
- registry.sendFrame(client, {
585
- type: 'error',
586
- code: 'bash_busy',
587
- message: 'A bash command is already running',
588
- });
589
- break;
590
- }
591
- const exclude = frame.excludeFromContext === true;
592
- // Order before any held assistant message_update, so a `!` run mid-stream
593
- // never renders ahead of the update it follows.
594
- projection.flush();
595
- // The live bash component is a visible transcript boundary, even though
596
- // its persisted bashExecution message arrives later via the currentSession().
597
- toolGroups.finishOpenToolGroup();
598
- registry.broadcast({
599
- type: 'bash_start',
600
- command,
601
- excludeFromContext: exclude,
602
- });
603
- void currentSession()
604
- .executeBash(command, (chunk) => registry.broadcast({ type: 'bash_output', chunk }), {
605
- excludeFromContext: exclude,
606
- })
607
- .then((result) => {
608
- registry.broadcast({
609
- type: 'bash_end',
610
- exitCode: result.exitCode,
611
- cancelled: result.cancelled,
612
- truncated: result.truncated,
613
- fullOutputPath: result.fullOutputPath,
614
- });
615
- })
616
- .catch(relayError);
617
- break;
618
- }
619
- }
620
- };
621
- // -------------------------------------------------------------------------
622
- // Command-op helpers. The write guard is hoisted here and reused by all
623
- // mutating ops; the ack/error replies
624
- // and a few resolvers keep the per-op cases one-liners.
625
- // -------------------------------------------------------------------------
626
- /** Reject a read-only client for a mutating op. The decision reads this
627
- * client's OWN fixed role — there is no shared writer slot, so any number of
628
- * writable clients pass concurrently. Returns true when rejected (the caller
629
- * should `break`). */
630
- const notWritable = (client, what, id) => {
631
- if (client.role === 'controller')
632
- return false;
633
- registry.sendFrame(client, {
634
- type: 'error',
635
- code: 'read_only',
636
- message: `a read-only client may not ${what}`,
637
- // M1: echo a correlated request's id so its pending-by-id promise rejects
638
- // rather than hanging (e.g. a read-only `dequeue`).
639
- ...(id !== undefined ? { id } : {}),
640
- });
641
- return true;
642
- };
643
- const ackTo = (client, op, ok = true, detail, id) => registry.sendFrame(client, {
644
- type: 'ack',
645
- for: op,
646
- ok,
647
- ...(detail !== undefined ? { detail } : {}),
648
- ...(id !== undefined ? { id } : {}),
15
+ // The reply vocabulary every handler below answers a client with.
16
+ const replies = createFrameReplies(registry);
17
+ const { notWritable } = replies;
18
+ const memoryRefs = createMemoryRefGuidance(currentSession);
19
+ const dispatches = createPromptDispatchTracker(currentSession);
20
+ const turnAcceptance = createTurnAcceptanceGate();
21
+ const kickoffDriver = createKickoffDriver({
22
+ session: currentSession,
23
+ dispatches,
649
24
  });
650
- const engineErrorTo = (client, id) => (err) => registry.sendFrame(client, {
651
- type: 'error',
652
- code: 'engine_error',
653
- message: String(err),
654
- // M1: a correlated read-op / dequeue failure echoes its id so the viewer's
655
- // pending-by-id promise rejects instead of hanging; uncorrelated callers
656
- // pass no id (unchanged behavior).
657
- ...(id !== undefined ? { id } : {}),
25
+ const authReloadGate = createAuthReloadGate({
26
+ nodeId,
27
+ cfg,
28
+ replies,
29
+ rebind,
30
+ session: currentSession,
31
+ services: currentServices,
32
+ broadcastModelChanged,
33
+ formatModelSpec,
34
+ resolveLaunchModel,
35
+ driveKickoff: kickoffDriver.drivePending,
36
+ });
37
+ const driveEngine = createEngineDriver({
38
+ registry,
39
+ replies,
40
+ session: currentSession,
41
+ dispatches,
42
+ memoryRefs,
43
+ authReloadGate,
44
+ rebind,
45
+ projection,
46
+ toolGroups,
658
47
  });
659
- const isPreBoundaryReviewEntry = (entryId) => boundaryReviewId !== undefined &&
660
- !currentSession()
661
- .sessionManager.getBranch(entryId)
662
- .some((entry) => isReviewBoundary(entry, boundaryReviewId));
663
- const reviewBoundaryRefusalTo = (client, targetId) => {
664
- const refusal = new ReviewBoundaryNavigationError(targetId);
665
- registry.sendFrame(client, {
666
- type: 'error',
667
- code: refusal.code,
668
- message: refusal.message,
669
- });
670
- };
671
- /** Resolve a `provider/id` model spec against the LIVE currentServices() registry (which
672
- * carries any extension-registered providers). Pure: returns undefined on a
673
- * malformed spec or an unknown model. */
674
- const findModelSpec = (spec) => {
675
- const { modelSpec } = parseModelSpec(spec);
676
- const slash = modelSpec.indexOf('/');
677
- if (slash <= 0)
678
- return undefined;
679
- return registryOf(currentServices()).find(modelSpec.slice(0, slash), modelSpec.slice(slash + 1));
680
- };
681
- /** Resolve a model from a free-text query — `/model opus`, `/model fable`. Tries
682
- * the exact `provider/id` spec first; failing that, searches the registry the
683
- * way the picker's search box does (case-insensitive substring on the model id
684
- * and the `provider/id` key) and returns the BEST match, preferring authed/
685
- * available models. Returns undefined only when nothing matches. */
686
- const resolveModelQuery = (query) => {
687
- const exact = findModelSpec(query);
688
- if (exact)
689
- return exact;
690
- const q = query.trim().toLowerCase();
691
- if (q === '')
692
- return undefined;
693
- let all;
694
- try {
695
- all = registryOf(currentServices()).getAll();
696
- }
697
- catch {
698
- return undefined;
699
- }
700
- let availableKeys;
701
- try {
702
- availableKeys = new Set(registryOf(currentServices()).getAvailable().map(modelKey));
703
- }
704
- catch {
705
- availableKeys = new Set();
706
- }
707
- // Rank: exact id (100) > exact provider/id (90) > id prefix (80) > id
708
- // substring (60) > provider/id substring (40); +5 if the model is available.
709
- const rank = (m) => {
710
- const id = m.id.toLowerCase();
711
- const key = modelKey(m).toLowerCase();
712
- let base = -1;
713
- if (id === q)
714
- base = 100;
715
- else if (key === q)
716
- base = 90;
717
- else if (id.startsWith(q))
718
- base = 80;
719
- else if (id.includes(q))
720
- base = 60;
721
- else if (key.includes(q))
722
- base = 40;
723
- else
724
- return -1;
725
- return base + (availableKeys.has(modelKey(m)) ? 5 : 0);
726
- };
727
- let best;
728
- let bestRank = 0;
729
- for (const m of all) {
730
- const r = rank(m);
731
- if (r > bestRank) {
732
- bestRank = r;
733
- best = m;
734
- }
735
- }
736
- return best;
737
- };
738
- // Cache per-currentSession() wire info keyed by (path, mtime, size) so reopening the
739
- // picker does not re-read the whole corpus (see currentSession()-list-cache.ts).
740
- const sessionListCache = new SessionListCache(sessionListCachePath());
741
- sessionListCache.prewarm(currentSession().sessionManager.getSessionDir());
742
- const handleHelloFrame = (client, frame) => {
743
- // Admit at the REQUESTED role — unconditionally. `controller` is a
744
- // per-client write capability, not a singleton slot, so an arriving
745
- // writable client never demotes (or is demoted by) an already-attached
746
- // one: attached terminal panes are all writable at once.
747
- //
748
- // A repeated hello on an already-helloed socket must not RE-role it: the
749
- // role is fixed for the socket's lifetime, so every downstream gate and
750
- // every already-fanned-out dialog keep the authority the first welcome
751
- // stated. A duplicate hello still re-sends the catch-up snapshot.
752
- const firstHello = !client.helloed;
753
- client.id = frame.client_id;
754
- client.snapshotTail = normalizeSnapshotTail(frame.snapshot_tail);
755
- if (firstHello) {
756
- client.helloed = true;
757
- client.role = frame.role === 'controller' ? 'controller' : 'observer';
758
- client.attends = frame.attends !== false;
759
- }
760
- sendWelcome(client);
761
- if (firstHello)
762
- registry.persistAttachState(); // a helloed viewer arrived
763
- // welcome carried the FIRST pending dialog; replay any extras so a writable
764
- // client attaching mid-dialog can answer every in-flight dialog.
765
- if (client.role === 'controller')
766
- replayExtraPendingDialogsTo(client);
767
- };
768
48
  const handleDriveFrame = (client, frame) => {
769
49
  if (notWritable(client, 'drive the engine'))
770
50
  return;
771
51
  driveEngine(client, frame);
772
52
  };
773
- const handleDeliverFrame = (client, frame) => {
774
- // Interactive delivery from a one-shot server client (crtrd's
775
- // `POST /messages` `delivery:'interactive'` path). Routed HERE,
776
- // broker-side, because only the broker authoritatively knows streaming
777
- // state: the synthesized `prompt` frame reuses resolveEngineRoute's m-B
778
- // routing (idle → prompt(); mid-stream → prompt({streamingBehavior:
779
- // 'steer'}), pi's own Enter-while-streaming submit) plus the memory-refs
780
- // guidance path — byte-identical behavior to a viewer's Enter. Acked
781
- // once ACCEPTED (correlated by the frame's id) so the daemon returns
782
- // deterministically; `detail` reports which route was taken.
783
- //
784
- // The ack reports the engine call's OUTCOME, never merely the fact that
785
- // a call was made: `currentSession().prompt()` rejects outright when the model's
786
- // provider has no credential, and acking unconditionally reported
787
- // `delivered:true` for a message the engine had refused — the caller's
788
- // one-shot client then closed before the error frame arrived and the
789
- // message vanished with no trace. A prompt route therefore acks on the
790
- // race between acceptance (agent_start) and rejection; a steer route
791
- // joins a turn already running, so no new agent_start is coming and its
792
- // ack stays at routing time.
793
- if (notWritable(client, 'drive the engine', frame.id))
794
- return;
795
- const via = currentSession().isStreaming ? 'steer' : 'prompt';
796
- // Ordered context cards ahead of the body, inside this ONE frame handling,
797
- // so no other client's send can interleave between a card and its body.
798
- // Nothing is awaited: `sendCustomMessage` completes synchronously on both
799
- // branches that apply here — idle appends to the session and starts NO turn
800
- // (the body is the sole trigger), streaming queues on the same steer queue
801
- // the body's `prompt({streamingBehavior:'steer'})` uses, preserving order.
802
- for (const card of frame.cards ?? []) {
803
- void currentSession().sendCustomMessage({ customType: SITUATIONAL_CONTEXT_CUSTOM_TYPE, content: card, display: true }, via === 'steer' ? { deliverAs: 'steer' } : undefined).catch((error) => emitEvent({ level: 'error', event: 'broker.deliver.card_failed', error }));
804
- }
805
- let delivered = false;
806
- const ackAccepted = () => {
807
- if (delivered)
808
- return;
809
- delivered = true;
810
- ackTo(client, 'deliver', true, via, frame.id);
811
- };
812
- if (via === 'prompt')
813
- turnAcceptedWaiters.add(ackAccepted);
814
- const outcome = driveEngine(client, {
815
- type: 'prompt',
816
- text: frame.text,
817
- ...(frame.images !== undefined ? { images: frame.images } : {}),
818
- });
819
- if (via === 'steer' || outcome === undefined) {
820
- turnAcceptedWaiters.delete(ackAccepted);
821
- ackAccepted();
822
- return;
823
- }
824
- void outcome.then((failure) => {
825
- turnAcceptedWaiters.delete(ackAccepted);
826
- if (failure === undefined) {
827
- ackAccepted();
828
- return;
829
- }
830
- // The error frame already went to the client, but a one-shot deliver
831
- // client is gone by then — record it where it survives.
832
- emitEvent({
833
- level: 'error',
834
- event: 'broker.prompt.deliver_failed',
835
- error: failure,
836
- });
837
- if (delivered)
838
- return;
839
- delivered = true;
840
- ackTo(client, 'deliver', false, String(failure), frame.id);
841
- });
842
- };
843
- const handleDialogFrame = (client, frame) => {
844
- if (notWritable(client, 'answer dialogs'))
845
- return;
846
- // First response for this id wins: `resolve` settles the entry and deletes
847
- // it, so every later response from a peer that was also fanned this dialog
848
- // finds nothing here and is a silent no-op.
849
- pendingDialogs.get(frame.id)?.resolve(frame);
850
- };
851
- const handleEngineControlFrame = (client, frame) => {
852
- switch (frame.type) {
853
- // --- extended engine-command ops -------------------------------------
854
- case 'set_model': {
855
- if (notWritable(client, 'set the model'))
856
- break;
857
- const requested = parseModelSpec(frame.model);
858
- let model;
859
- let normalized;
860
- try {
861
- // Tier/alias specs (`opus`, `strong`, `anthropic/strong`, …) MUST resolve
862
- // through the config ladder before touching the registry: the fuzzy
863
- // registry search rank-ties `opus` between claude-3-opus-20240229 and
864
- // claude-opus-4-8, and the deprecated 2024 model wins on catalog order.
865
- // Concrete `provider/id` specs and genuine substrings (`fable`) pass
866
- // through normalizeModel unchanged and take the exact/search path below.
867
- normalized = parseModelSpec(normalizeModel(requested.modelSpec));
868
- model = resolveModelQuery(normalized.modelSpec);
869
- }
870
- catch (err) {
871
- // N2: registry.find should never throw on the real SDK, but a degenerate
872
- // engine must still get a reply rather than a silently-dropped frame.
873
- engineErrorTo(client)(err);
874
- break;
875
- }
876
- if (model === undefined) {
877
- registry.sendFrame(client, {
878
- type: 'error',
879
- code: 'engine_error',
880
- message: `no model matching '${frame.model}' in the registry`,
881
- });
882
- break;
883
- }
884
- void currentSession()
885
- .setModel(model)
886
- .then(async () => {
887
- // The caller's explicit `:thinking` wins; else the ladder cell's.
888
- const thinking = requested.thinkingLevel ?? normalized.thinkingLevel;
889
- if (thinking !== undefined)
890
- currentSession().setThinkingLevel(thinking);
891
- // frame.pinned, when the sender pre-normalized `frame.model` (e.g.
892
- // setModelLive), carries the RAW-token pin decision explicitly.
893
- // Otherwise frame.model itself IS the raw/explicit token (a
894
- // slash-command arg, or a concrete picker pick) and derives it.
895
- await broadcastModelChanged(true, frame.pinned !== undefined
896
- ? frame.pinned
897
- : isProviderPinnedModelToken(frame.model), true);
898
- ackTo(client, 'set_model');
899
- })
900
- .catch(engineErrorTo(client));
901
- break;
902
- }
903
- case 'deliver_custom_message': {
904
- if (notWritable(client, 'deliver a custom message'))
905
- break;
906
- // Never triggers a turn either way. `nextTurn` folds the message into
907
- // whatever turn comes next (situational context); omitting it pushes
908
- // straight onto the message list so it lands FIRST in a still-empty
909
- // currentSession() (a freshly-claimed spare's bearings). The inbox watcher's own
910
- // situational send uses `followUp` + triggerTurn because there its job
911
- // IS to wake the node.
912
- void currentSession()
913
- .sendCustomMessage({
914
- customType: frame.customType,
915
- content: frame.content,
916
- display: true,
917
- ...(frame.details !== undefined
918
- ? { details: frame.details }
919
- : {}),
920
- }, ...(frame.deliverAs !== undefined
921
- ? [{ deliverAs: frame.deliverAs }]
922
- : []))
923
- .then(() => ackTo(client, 'deliver_custom_message'))
924
- .catch(engineErrorTo(client));
925
- break;
926
- }
927
- case 'cycle_model': {
928
- if (notWritable(client, 'cycle the model'))
929
- break;
930
- void currentSession()
931
- .cycleModel(frame.direction)
932
- .then(async () => {
933
- await broadcastModelChanged(true, undefined, true);
934
- ackTo(client, 'cycle_model');
935
- })
936
- .catch(engineErrorTo(client));
937
- break;
938
- }
939
- case 'cycle_ladder': {
940
- if (notWritable(client, 'cycle the model ladder'))
941
- break;
942
- // Resolve the next interleaved-ladder rung from the current model+thinking
943
- // spec, then reuse the set_model path (registry resolve + thinking apply).
944
- // The cycle only offers signed-in provider columns — same rule as the
945
- // setup wizard's Models panel: pool occupancy for a managed provider
946
- // (read live, so a login that landed mid-currentSession() counts immediately),
947
- // else the registry's auth status. When nobody is signed in the helper
948
- // returns every provider, so the cycle stays walkable pre-login.
949
- const offeredProviders = (() => {
950
- try {
951
- const registry = registryOf(currentServices());
952
- return configuredLadderProviders((providerId) => (isManagedProvider(providerId) &&
953
- readSubscriptionPool(providerId).length > 0) ||
954
- registry.getProviderAuthStatus(providerId).configured);
955
- }
956
- catch {
957
- return undefined; // auth state unreadable — cycle the full ladder
958
- }
959
- })();
960
- const currentSpec = formatModelSpec(currentSession().model, currentSession().thinkingLevel) ?? '';
961
- const nextSpec = parseModelSpec(nextLadderModel(currentSpec, frame.direction === 'backward' ? -1 : 1, offeredProviders));
962
- let ladderModel;
963
- try {
964
- ladderModel = resolveModelQuery(nextSpec.modelSpec);
965
- }
966
- catch (err) {
967
- engineErrorTo(client)(err);
968
- break;
969
- }
970
- if (ladderModel === undefined) {
971
- registry.sendFrame(client, {
972
- type: 'error',
973
- code: 'engine_error',
974
- message: `ladder model '${nextSpec.modelSpec}' not in the registry`,
975
- });
976
- break;
977
- }
978
- void currentSession()
979
- .setModel(ladderModel)
980
- .then(async () => {
981
- if (nextSpec.thinkingLevel !== undefined)
982
- currentSession().setThinkingLevel(nextSpec.thinkingLevel);
983
- await broadcastModelChanged(true, undefined, true);
984
- ackTo(client, 'cycle_ladder');
985
- })
986
- .catch(engineErrorTo(client));
987
- break;
988
- }
989
- case 'cycle_thinking': {
990
- if (notWritable(client, 'cycle the thinking level'))
991
- break;
992
- void (async () => {
993
- currentSession().cycleThinkingLevel();
994
- await persistModelChoice(undefined, true);
995
- ackTo(client, 'cycle_thinking');
996
- })().catch(engineErrorTo(client));
997
- break;
998
- }
999
- case 'dequeue': {
1000
- if (notWritable(client, 'dequeue messages', frame.id))
1001
- break;
1002
- try {
1003
- const { steering, followUp } = currentSession().clearQueue();
1004
- registry.sendFrame(client, {
1005
- type: 'data',
1006
- id: frame.id,
1007
- kind: 'dequeue',
1008
- steering,
1009
- followUp,
1010
- });
1011
- }
1012
- catch (err) {
1013
- engineErrorTo(client, frame.id)(err);
1014
- }
1015
- break;
1016
- }
1017
- case 'set_thinking_level': {
1018
- if (notWritable(client, 'set the thinking level'))
1019
- break;
1020
- void (async () => {
1021
- currentSession().setThinkingLevel(frame.level);
1022
- await persistModelChoice(undefined, true);
1023
- ackTo(client, 'set_thinking_level');
1024
- })().catch(engineErrorTo(client));
1025
- break;
1026
- }
1027
- case 'set_auto_retry': {
1028
- if (notWritable(client, 'set auto-retry'))
1029
- break;
1030
- try {
1031
- currentSession().setAutoRetryEnabled(frame.enabled);
1032
- ackTo(client, 'set_auto_retry');
1033
- }
1034
- catch (err) {
1035
- engineErrorTo(client)(err);
1036
- }
1037
- break;
1038
- }
1039
- case 'set_auto_compaction': {
1040
- if (notWritable(client, 'set auto-compaction'))
1041
- break;
1042
- try {
1043
- currentSession().setAutoCompactionEnabled(frame.enabled);
1044
- ackTo(client, 'set_auto_compaction');
1045
- }
1046
- catch (err) {
1047
- engineErrorTo(client)(err);
1048
- }
1049
- break;
1050
- }
1051
- case 'compact': {
1052
- if (notWritable(client, 'compact the currentSession()'))
1053
- break;
1054
- void currentSession()
1055
- .compact(frame.instructions)
1056
- .then(() => ackTo(client, 'compact'))
1057
- .catch(engineErrorTo(client));
1058
- break;
1059
- }
1060
- case 'set_session_name': {
1061
- if (notWritable(client, 'rename the currentSession()'))
1062
- break;
1063
- try {
1064
- currentSession().setSessionName(frame.name);
1065
- ackTo(client, 'set_session_name');
1066
- }
1067
- catch (err) {
1068
- engineErrorTo(client)(err);
1069
- }
1070
- break;
1071
- }
1072
- }
1073
- };
1074
- const handleReadFrame = (client, frame) => {
1075
- switch (frame.type) {
1076
- case 'get_commands': {
1077
- // OBSERVERS may call this: the merged command inventory is static
1078
- // (extensions + templates + skills + builtins) and drives nothing in the
1079
- // engine, so it is not a controller-gated op. Terminal observer
1080
- // connections use it to populate the command palette.
1081
- // The merged command list rides in ack.detail as JSON (the foundation's
1082
- // AckFrame.detail field) — the viewer JSON.parses it when for ===
1083
- // 'get_commands'. Keeps every command op a uniform ack reply.
1084
- try {
1085
- ackTo(client, 'get_commands', true, JSON.stringify(buildCommandList(currentSession())));
1086
- }
1087
- catch (err) {
1088
- engineErrorTo(client)(err);
1089
- }
1090
- break;
1091
- }
1092
- // --- read-op picker data (§5 Unit A) ----------------------------------
1093
- // NOT controller-gated: read-only, like get_commands. Terminal observer
1094
- // connections populate pickers through these too.
1095
- case 'list_models': {
1096
- try {
1097
- registry.sendFrame(client, buildListModelsData(frame.id, currentSession()));
1098
- }
1099
- catch (err) {
1100
- engineErrorTo(client, frame.id)(err);
1101
- }
1102
- break;
1103
- }
1104
- case 'list_scoped_models': {
1105
- try {
1106
- registry.sendFrame(client, buildScopedModelsData(frame.id, currentSession()));
1107
- }
1108
- catch (err) {
1109
- engineErrorTo(client, frame.id)(err);
1110
- }
1111
- break;
1112
- }
1113
- case 'list_memory_refs': {
1114
- try {
1115
- registry.sendFrame(client, buildListMemoryRefsData(frame.id, getRefInventory().refs, engineLeadingCommandTokens(currentSession())));
1116
- }
1117
- catch (err) {
1118
- engineErrorTo(client, frame.id)(err);
1119
- }
1120
- break;
1121
- }
1122
- case 'get_tree': {
1123
- try {
1124
- registry.sendFrame(client, buildGetTreeData(frame.id, currentSession(), boundaryReviewId));
1125
- }
1126
- catch (err) {
1127
- engineErrorTo(client, frame.id)(err);
1128
- }
1129
- break;
1130
- }
1131
- case 'get_settings': {
1132
- try {
1133
- registry.sendFrame(client, buildSettingsData(frame.id, currentSession()));
1134
- }
1135
- catch (err) {
1136
- engineErrorTo(client, frame.id)(err);
1137
- }
1138
- break;
1139
- }
1140
- case 'list_sessions': {
1141
- // Session listing reads the currentSession() dir off disk — async; reply when it
1142
- // resolves (or relay the error, correlated by id so the viewer doesn't hang).
1143
- void buildSessionsData(frame.id, frame.scope ?? 'cwd', currentSession(), sessionListCache)
1144
- .then((d) => registry.sendFrame(client, d))
1145
- .catch(engineErrorTo(client, frame.id));
1146
- break;
1147
- }
1148
- case 'get_human_fork_coordinates': {
1149
- // NOT controller-gated: this is a pure observer read. Branch creation is
1150
- // intentionally delegated to a detached SDK manager in the ask publisher;
1151
- // calling createBranchedSession here corrupts this live manager's identity.
1152
- try {
1153
- const sm = currentSession().sessionManager;
1154
- const sourceFile = sm.getSessionFile();
1155
- const leafId = sm.getLeafId();
1156
- if (sourceFile === undefined ||
1157
- sourceFile === '' ||
1158
- leafId === null) {
1159
- engineErrorTo(client, frame.id)(new Error('no persisted source currentSession() leaf'));
1160
- break;
1161
- }
1162
- registry.sendFrame(client, {
1163
- type: 'data',
1164
- id: frame.id,
1165
- kind: 'human_fork_coordinates',
1166
- sourceFile,
1167
- sourceSessionId: sm.getSessionId(),
1168
- leafId,
1169
- modelSpec: formatExactModelSpec(currentSession().model, currentSession().thinkingLevel),
1170
- });
1171
- }
1172
- catch (err) {
1173
- engineErrorTo(client, frame.id)(err);
1174
- }
1175
- break;
1176
- }
1177
- }
1178
- };
1179
- const handleSessionFrame = (client, frame) => {
1180
- switch (frame.type) {
1181
- case 'navigate_tree': {
1182
- if (notWritable(client, 'navigate the currentSession() tree'))
1183
- break;
1184
- if (isPreBoundaryReviewEntry(frame.targetId)) {
1185
- reviewBoundaryRefusalTo(client, frame.targetId);
1186
- break;
1187
- }
1188
- // navigateTree rewinds IN-PLACE (same currentSession() file, new leaf) and emits no
1189
- // relayed event, so every viewer must be re-snapshotted onto the rewound
1190
- // transcript — same reWelcomeAll the currentSession()-replacing ops use. The ack's
1191
- // detail carries the navigated-to user message's text (pi parity: the
1192
- // interactive tree navigator restores it to the editor for re-editing).
1193
- void currentSession()
1194
- .navigateTree(frame.targetId, frame.options)
1195
- .then((r) => {
1196
- if (!r.cancelled)
1197
- reWelcomeAll();
1198
- ackTo(client, 'navigate_tree', !r.cancelled, r.editorText);
1199
- })
1200
- .catch(engineErrorTo(client));
1201
- break;
1202
- }
1203
- case 'reload': {
1204
- if (notWritable(client, 'reload'))
1205
- break;
1206
- // A SUCCESSFUL reload invalidates the memoized ref inventory (work item
1207
- // 3) so the next read-op/submission re-walks the corpus, AND clears the
1208
- // shared substrate parse cache buildRefInventory reads through —
1209
- // otherwise a fresh buildRefInventory() call would still see the stale
1210
- // cached docs. A FAILED reload (`.catch`) leaves both untouched: nothing
1211
- // about the corpus is known to have changed.
1212
- void currentSession()
1213
- .reload({ beforeSessionStart: () => captureSessionChatCapabilities(currentSession()) })
1214
- .then(() => {
1215
- // pi's reload() re-syncs queue modes from the shared settings.json
1216
- // (its ONE post-bind syncQueueModesFromSettings call site) —
1217
- // re-apply the broker's 'all' modes so a reload cannot silently
1218
- // restore one-at-a-time draining (see rebind.enqueueRebind).
1219
- const agent = currentSession().agent;
1220
- if (agent) {
1221
- agent.steeringMode = 'all';
1222
- agent.followUpMode = 'all';
1223
- }
1224
- memoizedRefInventory = null;
1225
- clearSessionCache();
1226
- registry.forEachHelloed((observer) => {
1227
- if (observer.role === 'observer')
1228
- registry.sendFrame(observer, { type: 'resources_reloaded' });
1229
- });
1230
- ackTo(client, 'reload');
1231
- })
1232
- .catch(engineErrorTo(client));
1233
- break;
1234
- }
1235
- case 'export': {
1236
- if (notWritable(client, 'export the currentSession()'))
1237
- break;
1238
- if (frame.format === 'jsonl') {
1239
- try {
1240
- const outPath = currentSession().exportToJsonl(frame.path);
1241
- stripHiddenEntriesFromJsonlExport(outPath);
1242
- ackTo(client, 'export');
1243
- }
1244
- catch (err) {
1245
- engineErrorTo(client)(err);
1246
- }
1247
- }
1248
- else {
1249
- void currentSession()
1250
- .exportToHtml(frame.path)
1251
- .then(() => ackTo(client, 'export'))
1252
- .catch(engineErrorTo(client));
1253
- }
1254
- break;
1255
- }
1256
- case 'new_session': {
1257
- if (notWritable(client, 'start a new currentSession()'))
1258
- break;
1259
- rebind.runReplacement(client, 'new_session', (rt) => rt.newSession());
1260
- break;
1261
- }
1262
- case 'switch_session': {
1263
- if (notWritable(client, 'switch sessions'))
1264
- break;
1265
- rebind.runReplacement(client, 'switch_session', (rt) => rt.switchSession(frame.path));
1266
- break;
1267
- }
1268
- case 'fork': {
1269
- if (notWritable(client, 'fork the currentSession()'))
1270
- break;
1271
- if (isPreBoundaryReviewEntry(frame.entryId)) {
1272
- reviewBoundaryRefusalTo(client, frame.entryId);
1273
- break;
1274
- }
1275
- rebind.runReplacement(client, 'fork', (rt) => rt.fork(frame.entryId));
1276
- break;
1277
- }
1278
- case 'clone': {
1279
- if (notWritable(client, 'clone the currentSession()'))
1280
- break;
1281
- rebind.runReplacement(client, 'clone', async (rt) => {
1282
- const sm = currentSession().sessionManager;
1283
- const leafId = sm.getLeafId();
1284
- if (leafId === null) {
1285
- return { cancelled: true, detail: 'no current leaf to clone from' };
1286
- }
1287
- const newPath = sm.createBranchedSession(leafId);
1288
- if (newPath === undefined) {
1289
- return {
1290
- cancelled: true,
1291
- detail: 'createBranchedSession returned no path',
1292
- };
1293
- }
1294
- return rt.switchSession(newPath);
1295
- });
1296
- break;
1297
- }
1298
- case 'share': {
1299
- if (notWritable(client, 'share the currentSession()'))
1300
- break;
1301
- const tmpPath = join(tmpdir(), `pi-share-${Date.now()}.html`);
1302
- void currentSession()
1303
- .exportToHtml(tmpPath)
1304
- .then(() => new Promise((resolve, reject) => {
1305
- execFile('gh', ['gist', 'create', '--secret', tmpPath], { timeout: 30_000, maxBuffer: 1024 * 1024 }, (err, stdout) => {
1306
- try {
1307
- if (existsSync(tmpPath))
1308
- unlinkSync(tmpPath);
1309
- }
1310
- catch {
1311
- /* cleanup */
1312
- }
1313
- if (err) {
1314
- reject(err);
1315
- return;
1316
- }
1317
- resolve(stdout.trim());
1318
- });
1319
- }))
1320
- .then((url) => {
1321
- ackTo(client, 'share', true, url);
1322
- })
1323
- .catch((err) => {
1324
- const msg = String(err?.message ?? err);
1325
- ackTo(client, 'share', false, msg.includes('not found') ||
1326
- msg.includes('gh: command not found') ||
1327
- msg.includes('ENOENT')
1328
- ? '`gh` CLI not found — install GitHub CLI and authenticate with `gh auth login`'
1329
- : `share failed: ${msg}`);
1330
- });
1331
- break;
1332
- }
1333
- }
1334
- };
1335
- const handleAuthenticationFrame = (client, frame) => {
1336
- // Open to any client, observers included: reload_auth is an idempotent
1337
- // local re-read of shared credentials + the runtime's availability
1338
- // snapshot. It does not steer the conversation, so the daemon's
1339
- // canvas-wide /login fan can ride an observer connection.
1340
- try {
1341
- ackTo(client, 'reload_auth');
1342
- const authGeneration = rebind.installedGeneration();
1343
- const existingAuthReload = reloadAuthInFlight;
1344
- let authReloadPromise;
1345
- if (existingAuthReload !== null &&
1346
- existingAuthReload.generation === authGeneration) {
1347
- authReloadPromise = existingAuthReload.promise;
1348
- }
1349
- if (authGeneration !== undefined && authReloadPromise === undefined) {
1350
- const authSession = currentSession();
1351
- const authServices = currentServices();
1352
- authReloadPromise = (async () => {
1353
- // Credentials are read fresh by PoolCredentialStore; only the
1354
- // ModelRuntime snapshot needs rebuilding. Authentication reloads
1355
- // never fetch provider catalogs over the network.
1356
- await authServices.modelRuntime.refresh({ allowNetwork: false });
1357
- if (rebind.installedGeneration() !== authGeneration ||
1358
- currentSession() !== authSession)
1359
- return;
1360
- const modelLess = authSession.model === undefined ||
1361
- isUnknownModel(authSession.model);
1362
- const reconcilesLogicalRoute = cfg.modelExact !== true &&
1363
- cfg.modelProviderPinned !== true &&
1364
- (process.env['CRTR_MODEL_INTENT'] ?? '').trim() !== '';
1365
- if (modelLess || reconcilesLogicalRoute) {
1366
- const resolved = await resolveLaunchModel(authServices, cfg);
1367
- if (rebind.installedGeneration() !== authGeneration ||
1368
- currentSession() !== authSession)
1369
- return;
1370
- if (resolved !== undefined) {
1371
- const currentSpec = formatModelSpec(authSession.model, authSession.thinkingLevel);
1372
- const resolvedSpec = formatModelSpec(resolved.model, resolved.thinkingLevel);
1373
- if (resolvedSpec !== null && resolvedSpec !== currentSpec) {
1374
- await authSession.setModel(resolved.model);
1375
- if (rebind.installedGeneration() !== authGeneration ||
1376
- currentSession() !== authSession)
1377
- return;
1378
- authSession.setThinkingLevel((resolved.thinkingLevel ?? 'off'));
1379
- }
1380
- }
1381
- }
1382
- if (rebind.installedGeneration() !== authGeneration ||
1383
- currentSession() !== authSession)
1384
- return;
1385
- // Persist an algorithmic route choice without clearing the logical
1386
- // intent that makes future reconciliation possible.
1387
- await broadcastModelChanged();
1388
- drivePendingKickoff();
1389
- })().catch((err) => {
1390
- emitEvent({
1391
- level: 'warn',
1392
- event: 'broker.auth_reload.reconcile_failed',
1393
- error: err,
1394
- });
1395
- });
1396
- const work = {
1397
- generation: authGeneration,
1398
- promise: authReloadPromise,
1399
- };
1400
- reloadAuthInFlight = work;
1401
- void authReloadPromise.finally(() => {
1402
- if (reloadAuthInFlight === work)
1403
- reloadAuthInFlight = null;
1404
- });
1405
- }
1406
- const authFault = readFault(nodeId);
1407
- const authFaultGeneration = rebind.installedGeneration();
1408
- const authFaultSession = currentSession();
1409
- if (authFault?.link === 'pi→provider' &&
1410
- authFault.kind === 'auth' &&
1411
- !authFaultSession.isStreaming &&
1412
- !authFaultRedriveInFlight) {
1413
- authFaultRedriveInFlight = true;
1414
- void (async () => {
1415
- try {
1416
- if (authReloadPromise !== undefined)
1417
- await authReloadPromise;
1418
- if (rebind.installedGeneration() !== authFaultGeneration ||
1419
- currentSession() !== authFaultSession)
1420
- return;
1421
- const anchorEntryId = authFault.anchorEntryId;
1422
- if (anchorEntryId !== undefined) {
1423
- try {
1424
- const leafId = authFaultSession.sessionManager.getLeafId?.() ?? null;
1425
- if (leafId !== anchorEntryId)
1426
- await authFaultSession.navigateTree(anchorEntryId);
1427
- if (rebind.installedGeneration() !== authFaultGeneration ||
1428
- currentSession() !== authFaultSession)
1429
- return;
1430
- }
1431
- catch (err) {
1432
- emitEvent({
1433
- level: 'warn',
1434
- event: 'broker.auth_fault.rewind_skipped',
1435
- error: err,
1436
- });
1437
- }
1438
- }
1439
- const fresh = readFault(nodeId);
1440
- if (rebind.installedGeneration() !== authFaultGeneration ||
1441
- currentSession() !== authFaultSession ||
1442
- authFaultSession.isStreaming ||
1443
- fresh?.link !== 'pi→provider' ||
1444
- fresh.kind !== 'auth' ||
1445
- fresh.since !== authFault.since)
1446
- return;
1447
- clearFault(nodeId, { link: 'pi→provider' });
1448
- await authFaultSession.prompt(formatCard('recovery', {}, AUTH_FAULT_RECOVERY_BODY));
1449
- }
1450
- catch (err) {
1451
- emitEvent({
1452
- level: 'error',
1453
- event: 'broker.auth_fault.redrive_failed',
1454
- error: err,
1455
- });
1456
- }
1457
- finally {
1458
- authFaultRedriveInFlight = false;
1459
- }
1460
- })();
1461
- }
1462
- }
1463
- catch (err) {
1464
- engineErrorTo(client)(err);
1465
- }
1466
- };
1467
- const handleByeFrame = (client) => {
1468
- client.socket.end();
1469
- };
1470
- const handleShutdownFrame = () => {
1471
- disposeAndExit('shutdown-frame');
1472
- };
53
+ const clientHandlers = createClientHandlers({
54
+ registry,
55
+ replies,
56
+ session: currentSession,
57
+ driveEngine,
58
+ turnAcceptance,
59
+ pendingDialogs,
60
+ sendWelcome,
61
+ replayExtraPendingDialogsTo,
62
+ disposeAndExit,
63
+ });
64
+ const handleEngineControlFrame = createEngineControlHandler({
65
+ registry,
66
+ replies,
67
+ session: currentSession,
68
+ services: currentServices,
69
+ registryOf,
70
+ persistModelChoice,
71
+ broadcastModelChanged,
72
+ formatModelSpec,
73
+ });
74
+ const handleReadFrame = createReadHandler({
75
+ registry,
76
+ replies,
77
+ session: currentSession,
78
+ boundaryReviewId,
79
+ memoryRefs,
80
+ formatExactModelSpec,
81
+ });
82
+ const handleSessionFrame = createSessionHandler({
83
+ registry,
84
+ replies,
85
+ rebind,
86
+ session: currentSession,
87
+ boundaryReviewId,
88
+ memoryRefs,
89
+ reWelcomeAll,
90
+ });
1473
91
  return {
1474
92
  handlers: {
1475
- hello: handleHelloFrame,
93
+ hello: clientHandlers.hello,
1476
94
  drive: handleDriveFrame,
1477
- deliver: handleDeliverFrame,
1478
- dialog: handleDialogFrame,
95
+ deliver: clientHandlers.deliver,
96
+ dialog: clientHandlers.dialog,
1479
97
  engineControl: handleEngineControlFrame,
1480
98
  read: handleReadFrame,
1481
99
  session: handleSessionFrame,
1482
- authentication: handleAuthenticationFrame,
1483
- bye: handleByeFrame,
1484
- shutdown: handleShutdownFrame,
1485
- },
1486
- kickoff(prompt) {
1487
- kickoffPending = prompt;
1488
- drivePendingKickoff();
1489
- },
1490
- notifyTurnAccepted,
1491
- pendingPromptDispatches: () => pendingPromptDispatches,
1492
- resetForSession: () => {
1493
- reloadAuthInFlight = null;
100
+ authentication: (client) => authReloadGate.handle(client),
101
+ bye: clientHandlers.bye,
102
+ shutdown: clientHandlers.shutdown,
1494
103
  },
104
+ kickoff: (prompt) => kickoffDriver.kickoff(prompt),
105
+ notifyTurnAccepted: turnAcceptance.notifyAccepted,
106
+ pendingPromptDispatches: dispatches.pending,
107
+ resetForSession: authReloadGate.resetForSession,
1495
108
  };
1496
109
  }
1497
110
  /** The socket listener calls this explicit dispatcher for every decoded client frame. */
@@ -1547,7 +160,7 @@ export function handleFrame(ctx, client, frame) {
1547
160
  ctx.handlers.session(client, frame);
1548
161
  return;
1549
162
  case 'reload_auth':
1550
- ctx.handlers.authentication(client, frame);
163
+ ctx.handlers.authentication(client);
1551
164
  return;
1552
165
  case 'bye':
1553
166
  ctx.handlers.bye(client);