@crouton-kit/crouter 0.3.27 → 0.3.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (343) hide show
  1. package/dist/build-root.js +4 -2
  2. package/dist/builtin-memory/crouter-development/marketplaces.md +7 -6
  3. package/dist/builtin-memory/crouter-development/personas/base-prompt.md +1 -1
  4. package/dist/builtin-memory/crouter-development/personas/orchestrator-prompt.md +1 -1
  5. package/dist/builtin-memory/crouter-development/personas.md +1 -1
  6. package/dist/builtin-memory/crouter-development/plugins.md +20 -18
  7. package/dist/builtin-memory/design.md +1 -1
  8. package/dist/builtin-memory/development.md +1 -1
  9. package/dist/builtin-memory/internal/INDEX.md +1 -1
  10. package/dist/builtin-memory/internal/examples/INDEX.md +1 -1
  11. package/dist/builtin-memory/internal/examples/imessage-assistant.md +2 -2
  12. package/dist/builtin-memory/internal/nodes-and-canvas.md +1 -1
  13. package/dist/builtin-memory/internal/storage-tiers.md +1 -1
  14. package/dist/builtin-memory/planning.md +1 -1
  15. package/dist/builtin-memory/spec.md +6 -4
  16. package/dist/builtin-personas/orchestration-kernel.md +4 -5
  17. package/dist/builtin-personas/runtime-base.md +8 -4
  18. package/dist/builtin-personas/spec/PERSONA.md +8 -4
  19. package/dist/builtin-personas/spec/orchestrator.md +5 -3
  20. package/dist/builtin-personas/spec/requirements/PERSONA.md +10 -0
  21. package/dist/builtin-views/canvas/core.mjs +586 -0
  22. package/dist/builtin-views/canvas/text.mjs +58 -0
  23. package/dist/builtin-views/canvas/tui.mjs +165 -0
  24. package/dist/builtin-views/canvas/web.jsx +120 -0
  25. package/dist/builtin-views/git-pr/core.mjs +673 -0
  26. package/dist/builtin-views/git-pr/text.mjs +84 -0
  27. package/dist/builtin-views/git-pr/tui.mjs +302 -0
  28. package/dist/builtin-views/git-pr/web.jsx +216 -0
  29. package/dist/builtin-views/inbox/_lib/render.mjs +1 -3
  30. package/dist/builtin-views/inbox/core.mjs +1273 -0
  31. package/dist/builtin-views/inbox/text.mjs +73 -0
  32. package/dist/builtin-views/inbox/tui.mjs +312 -0
  33. package/dist/builtin-views/inbox/web.jsx +188 -0
  34. package/dist/builtin-views/linkedin/core.mjs +906 -0
  35. package/dist/builtin-views/linkedin/text.mjs +69 -0
  36. package/dist/builtin-views/linkedin/tui.mjs +427 -0
  37. package/dist/builtin-views/linkedin/web.jsx +206 -0
  38. package/dist/builtin-views/workspace-sidebar/core.mjs +621 -0
  39. package/dist/builtin-views/workspace-sidebar/text.mjs +53 -0
  40. package/dist/builtin-views/workspace-sidebar/tui.mjs +142 -0
  41. package/dist/builtin-views/workspace-sidebar/web.jsx +109 -0
  42. package/dist/cli.js +3 -3
  43. package/dist/clients/attach/__tests__/action-parity.test.js +48 -0
  44. package/dist/clients/attach/__tests__/clipboard-image.test.js +76 -0
  45. package/dist/clients/attach/__tests__/editor-newline.test.js +57 -0
  46. package/dist/clients/attach/__tests__/git-info.test.js +31 -0
  47. package/dist/clients/attach/__tests__/onrequest-wired.test.js +20 -0
  48. package/dist/clients/attach/__tests__/reconnect-giveup.test.js +30 -0
  49. package/dist/clients/attach/__tests__/slash-quit-copy.test.js +42 -0
  50. package/dist/clients/attach/__tests__/titled-editor.test.d.ts +1 -0
  51. package/dist/clients/attach/__tests__/titled-editor.test.js +39 -0
  52. package/dist/clients/attach/attach-cmd.js +445 -41
  53. package/dist/clients/attach/auth-pickers.d.ts +9 -0
  54. package/dist/clients/attach/auth-pickers.js +194 -0
  55. package/dist/clients/attach/canvas-panels.d.ts +12 -0
  56. package/dist/clients/attach/canvas-panels.js +78 -0
  57. package/dist/clients/attach/chat-view.d.ts +27 -2
  58. package/dist/clients/attach/chat-view.js +98 -9
  59. package/dist/clients/attach/clipboard-image.d.ts +47 -8
  60. package/dist/clients/attach/clipboard-image.js +150 -26
  61. package/dist/clients/attach/clipboard-text.d.ts +3 -0
  62. package/dist/clients/attach/clipboard-text.js +39 -0
  63. package/dist/clients/attach/config-load.d.ts +54 -7
  64. package/dist/clients/attach/config-load.js +99 -3
  65. package/dist/clients/attach/context-message.d.ts +12 -0
  66. package/dist/clients/attach/context-message.js +72 -0
  67. package/dist/clients/attach/git-info.d.ts +20 -0
  68. package/dist/clients/attach/git-info.js +44 -0
  69. package/dist/clients/attach/graph-overlay.d.ts +43 -0
  70. package/dist/clients/attach/graph-overlay.js +306 -0
  71. package/dist/clients/attach/input-controller.d.ts +81 -13
  72. package/dist/clients/attach/input-controller.js +307 -67
  73. package/dist/clients/attach/pickers.d.ts +58 -0
  74. package/dist/clients/attach/pickers.js +171 -0
  75. package/dist/clients/attach/slash-commands.d.ts +44 -1
  76. package/dist/clients/attach/slash-commands.js +250 -27
  77. package/dist/clients/attach/titled-editor.d.ts +33 -0
  78. package/dist/clients/attach/titled-editor.js +84 -0
  79. package/dist/clients/attach/view-socket.d.ts +37 -2
  80. package/dist/clients/attach/view-socket.js +131 -7
  81. package/dist/clients/web/dev-server.d.ts +7 -0
  82. package/dist/clients/web/dev-server.js +59 -0
  83. package/dist/clients/web/events.d.ts +14 -0
  84. package/dist/clients/web/events.js +151 -0
  85. package/dist/clients/web/server.d.ts +18 -0
  86. package/dist/clients/web/server.js +450 -0
  87. package/dist/clients/web/web-cmd.d.ts +2 -0
  88. package/dist/clients/web/web-cmd.js +120 -0
  89. package/dist/commands/canvas.js +1 -2
  90. package/dist/commands/chord.js +1 -1
  91. package/dist/commands/dashboard.js +5 -1
  92. package/dist/commands/human/prompts.js +3 -3
  93. package/dist/commands/human/queue.d.ts +17 -0
  94. package/dist/commands/human/queue.js +111 -4
  95. package/dist/commands/memory/__tests__/lint-schema.test.js +27 -4
  96. package/dist/commands/memory/lint.d.ts +5 -4
  97. package/dist/commands/memory/lint.js +23 -7
  98. package/dist/commands/memory/read.js +2 -2
  99. package/dist/commands/memory/shared.d.ts +7 -1
  100. package/dist/commands/memory/shared.js +18 -3
  101. package/dist/commands/memory/write.js +20 -7
  102. package/dist/commands/memory.js +4 -4
  103. package/dist/commands/node.d.ts +1 -1
  104. package/dist/commands/node.js +64 -65
  105. package/dist/commands/pkg.js +1 -2
  106. package/dist/commands/revive.js +11 -3
  107. package/dist/commands/sys/feedback.d.ts +1 -0
  108. package/dist/commands/sys/feedback.js +163 -0
  109. package/dist/commands/sys/sync.d.ts +1 -0
  110. package/dist/commands/sys/sync.js +187 -0
  111. package/dist/commands/sys.js +4 -2
  112. package/dist/commands/view-cycle.js +2 -2
  113. package/dist/commands/view-list.js +8 -8
  114. package/dist/commands/view-new.js +21 -17
  115. package/dist/commands/view-pick.js +1 -1
  116. package/dist/commands/view-run.js +35 -14
  117. package/dist/commands/view.js +8 -6
  118. package/dist/commands/workspace.d.ts +2 -0
  119. package/dist/commands/workspace.js +161 -0
  120. package/dist/core/__tests__/broker-double-spawn.test.d.ts +1 -0
  121. package/dist/core/__tests__/broker-double-spawn.test.js +143 -0
  122. package/dist/core/__tests__/broker-fork-seam.test.d.ts +1 -0
  123. package/dist/core/__tests__/broker-fork-seam.test.js +102 -0
  124. package/dist/core/__tests__/broker-sdk-wiring.test.js +2 -0
  125. package/dist/core/__tests__/broker-snapshot-history.test.d.ts +1 -0
  126. package/dist/core/__tests__/broker-snapshot-history.test.js +105 -0
  127. package/dist/core/__tests__/child-death-wake.test.js +56 -111
  128. package/dist/core/__tests__/close.test.js +14 -20
  129. package/dist/core/__tests__/context-intro.test.js +19 -19
  130. package/dist/core/__tests__/daemon-boot.test.js +30 -23
  131. package/dist/core/__tests__/draw-style.test.js +1 -1
  132. package/dist/core/__tests__/error-stall-recycle.test.d.ts +1 -0
  133. package/dist/core/__tests__/error-stall-recycle.test.js +141 -0
  134. package/dist/core/__tests__/fixtures/fake-engine.d.ts +24 -1
  135. package/dist/core/__tests__/fixtures/fake-engine.js +32 -2
  136. package/dist/core/__tests__/fixtures/fake-pi-host.js +2 -2
  137. package/dist/core/__tests__/focuses.test.js +25 -13
  138. package/dist/core/__tests__/fork.test.js +22 -5
  139. package/dist/core/__tests__/full/broker-control-preempt.test.d.ts +1 -0
  140. package/dist/core/__tests__/full/broker-control-preempt.test.js +61 -0
  141. package/dist/core/__tests__/full/broker-model-changed-broadcast.test.d.ts +1 -0
  142. package/dist/core/__tests__/full/broker-model-changed-broadcast.test.js +57 -0
  143. package/dist/core/__tests__/full/broker-navigate-tree-rewelcome.test.d.ts +1 -0
  144. package/dist/core/__tests__/full/broker-navigate-tree-rewelcome.test.js +80 -0
  145. package/dist/core/__tests__/full/broker-pane-resolution.test.js +90 -0
  146. package/dist/core/__tests__/full/cascade-close.test.js +22 -8
  147. package/dist/core/__tests__/full/detach-focus.test.js +66 -169
  148. package/dist/core/__tests__/helpers/harness.d.ts +6 -9
  149. package/dist/core/__tests__/helpers/harness.js +19 -36
  150. package/dist/core/__tests__/human-node-not-supervised.test.d.ts +1 -0
  151. package/dist/core/__tests__/human-node-not-supervised.test.js +83 -0
  152. package/dist/core/__tests__/human-stranded-deliver.test.d.ts +1 -0
  153. package/dist/core/__tests__/human-stranded-deliver.test.js +108 -0
  154. package/dist/core/__tests__/lifecycle.test.js +1 -19
  155. package/dist/core/__tests__/live-mutation-verbs.test.js +5 -5
  156. package/dist/core/__tests__/memory-resolver.test.js +1 -1
  157. package/dist/core/__tests__/on-read-dedup-resume.test.d.ts +1 -0
  158. package/dist/core/__tests__/on-read-dedup-resume.test.js +81 -0
  159. package/dist/core/__tests__/refresh-stall-recycle.test.d.ts +1 -0
  160. package/dist/core/__tests__/refresh-stall-recycle.test.js +88 -0
  161. package/dist/core/__tests__/relaunch-root.test.d.ts +1 -0
  162. package/dist/core/__tests__/relaunch-root.test.js +114 -0
  163. package/dist/core/__tests__/reset.test.js +19 -79
  164. package/dist/core/__tests__/steer-note.test.js +9 -7
  165. package/dist/core/__tests__/tmux-surface.test.js +13 -7
  166. package/dist/core/bootstrap.d.ts +0 -3
  167. package/dist/core/bootstrap.js +1 -143
  168. package/dist/core/canvas/browse/__tests__/model.test.js +86 -1
  169. package/dist/core/canvas/browse/__tests__/render.test.js +2 -1
  170. package/dist/core/canvas/browse/app.js +131 -8
  171. package/dist/core/canvas/browse/model.d.ts +23 -5
  172. package/dist/core/canvas/browse/model.js +68 -13
  173. package/dist/core/canvas/browse/render.d.ts +7 -1
  174. package/dist/core/canvas/browse/render.js +178 -47
  175. package/dist/core/canvas/canvas.js +1 -1
  176. package/dist/core/canvas/focuses.d.ts +4 -4
  177. package/dist/core/canvas/focuses.js +7 -7
  178. package/dist/core/canvas/nav-model.d.ts +162 -0
  179. package/dist/core/canvas/nav-model.js +487 -0
  180. package/dist/core/canvas/paths.d.ts +7 -0
  181. package/dist/core/canvas/paths.js +9 -0
  182. package/dist/core/canvas/render.d.ts +68 -9
  183. package/dist/core/canvas/render.js +195 -61
  184. package/dist/core/canvas/types.d.ts +16 -23
  185. package/dist/core/config.js +1 -4
  186. package/dist/core/memory-resolver.js +43 -5
  187. package/dist/core/runtime/bearings.d.ts +1 -1
  188. package/dist/core/runtime/bearings.js +8 -8
  189. package/dist/core/runtime/branded-host.d.ts +16 -0
  190. package/dist/core/runtime/branded-host.js +127 -0
  191. package/dist/core/runtime/broker-protocol.d.ts +212 -6
  192. package/dist/core/runtime/broker-sdk.d.ts +0 -12
  193. package/dist/core/runtime/broker-sdk.js +77 -6
  194. package/dist/core/runtime/broker.d.ts +5 -1
  195. package/dist/core/runtime/broker.js +520 -59
  196. package/dist/core/runtime/close.d.ts +12 -2
  197. package/dist/core/runtime/close.js +37 -16
  198. package/dist/core/runtime/front-door.js +14 -7
  199. package/dist/core/runtime/host.d.ts +14 -34
  200. package/dist/core/runtime/host.js +14 -50
  201. package/dist/core/runtime/launch.d.ts +12 -9
  202. package/dist/core/runtime/launch.js +27 -15
  203. package/dist/core/runtime/lifecycle.d.ts +1 -1
  204. package/dist/core/runtime/lifecycle.js +15 -19
  205. package/dist/core/runtime/nodes.d.ts +0 -55
  206. package/dist/core/runtime/nodes.js +6 -74
  207. package/dist/core/runtime/placement.d.ts +91 -334
  208. package/dist/core/runtime/placement.js +262 -828
  209. package/dist/core/runtime/promote.d.ts +2 -0
  210. package/dist/core/runtime/promote.js +24 -6
  211. package/dist/core/runtime/recap.d.ts +8 -0
  212. package/dist/core/runtime/recap.js +107 -0
  213. package/dist/core/runtime/recycle.js +25 -61
  214. package/dist/core/runtime/reset.d.ts +43 -37
  215. package/dist/core/runtime/reset.js +131 -218
  216. package/dist/core/runtime/revive.d.ts +9 -29
  217. package/dist/core/runtime/revive.js +45 -135
  218. package/dist/core/runtime/spawn.d.ts +15 -11
  219. package/dist/core/runtime/spawn.js +168 -136
  220. package/dist/core/runtime/surface-bg.d.ts +11 -0
  221. package/dist/core/runtime/surface-bg.js +68 -0
  222. package/dist/core/runtime/tmux.d.ts +15 -43
  223. package/dist/core/runtime/tmux.js +38 -80
  224. package/dist/core/scope.d.ts +1 -2
  225. package/dist/core/scope.js +1 -5
  226. package/dist/core/skill-sync/__tests__/dry-run-wrote-count.test.d.ts +1 -0
  227. package/dist/core/skill-sync/__tests__/dry-run-wrote-count.test.js +57 -0
  228. package/dist/core/skill-sync/builtins.d.ts +42 -0
  229. package/dist/core/skill-sync/builtins.js +112 -0
  230. package/dist/core/skill-sync/claude-plugins.d.ts +23 -0
  231. package/dist/core/skill-sync/claude-plugins.js +71 -0
  232. package/dist/core/skill-sync/engine.d.ts +42 -0
  233. package/dist/core/skill-sync/engine.js +633 -0
  234. package/dist/core/skill-sync/export.d.ts +23 -0
  235. package/dist/core/skill-sync/export.js +86 -0
  236. package/dist/core/skill-sync/manifest.d.ts +64 -0
  237. package/dist/core/skill-sync/manifest.js +181 -0
  238. package/dist/core/skill-sync/profile.d.ts +76 -0
  239. package/dist/core/skill-sync/profile.js +173 -0
  240. package/dist/core/skill-sync/snapshot.d.ts +57 -0
  241. package/dist/core/skill-sync/snapshot.js +120 -0
  242. package/dist/core/spawn.d.ts +14 -0
  243. package/dist/core/spawn.js +29 -9
  244. package/dist/core/substrate/index.d.ts +2 -2
  245. package/dist/core/substrate/index.js +7 -7
  246. package/dist/core/substrate/injected-store.d.ts +10 -0
  247. package/dist/core/substrate/injected-store.js +55 -0
  248. package/dist/core/substrate/on-read.js +68 -6
  249. package/dist/core/substrate/render.d.ts +8 -11
  250. package/dist/core/substrate/render.js +29 -43
  251. package/dist/core/substrate/schema.d.ts +16 -11
  252. package/dist/core/substrate/schema.js +32 -31
  253. package/dist/core/tui/host.d.ts +12 -4
  254. package/dist/core/tui/host.js +280 -149
  255. package/dist/core/view/bridge.d.ts +10 -0
  256. package/dist/core/view/bridge.js +31 -0
  257. package/dist/core/view/chrome.d.ts +9 -0
  258. package/dist/core/view/chrome.js +22 -0
  259. package/dist/core/view/contract.d.ts +171 -0
  260. package/dist/core/view/contract.js +23 -0
  261. package/dist/core/view/loader.d.ts +31 -0
  262. package/dist/core/view/loader.js +188 -0
  263. package/dist/core/view/transport-local.d.ts +7 -0
  264. package/dist/core/view/transport-local.js +70 -0
  265. package/dist/core/view/transport.d.ts +4 -0
  266. package/dist/core/view/transport.js +15 -0
  267. package/dist/daemon/crtrd.d.ts +38 -27
  268. package/dist/daemon/crtrd.js +303 -376
  269. package/dist/daemon/manage.js +6 -1
  270. package/dist/index.js +1 -1
  271. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +28 -20
  272. package/dist/pi-extensions/canvas-context-intro.js +4 -4
  273. package/dist/pi-extensions/canvas-doc-substrate.js +29 -20
  274. package/dist/pi-extensions/canvas-nav.js +35 -385
  275. package/dist/pi-extensions/canvas-recap.d.ts +37 -0
  276. package/dist/pi-extensions/canvas-recap.js +236 -0
  277. package/dist/pi-extensions/canvas-resume.js +2 -1
  278. package/dist/pi-extensions/canvas-stophook.d.ts +15 -13
  279. package/dist/pi-extensions/canvas-stophook.js +119 -103
  280. package/dist/pi-extensions/canvas-view.js +2 -1
  281. package/dist/pi-extensions/widget-order-bus.d.ts +6 -0
  282. package/dist/pi-extensions/widget-order-bus.js +34 -0
  283. package/dist/prompts/view.d.ts +2 -2
  284. package/dist/prompts/view.js +145 -73
  285. package/dist/types.d.ts +1 -6
  286. package/dist/types.js +1 -3
  287. package/dist/web/ViewChrome.d.ts +7 -0
  288. package/dist/web/ViewChrome.js +28 -0
  289. package/dist/web/ViewPane.d.ts +39 -0
  290. package/dist/web/ViewPane.js +48 -0
  291. package/dist/web/index.d.ts +6 -0
  292. package/dist/web/index.js +16 -0
  293. package/dist/web/runtime.d.ts +39 -0
  294. package/dist/web/runtime.js +133 -0
  295. package/dist/web/states.d.ts +24 -0
  296. package/dist/web/states.js +24 -0
  297. package/dist/web/transport-http.d.ts +5 -0
  298. package/dist/web/transport-http.js +28 -0
  299. package/dist/web-client/assets/index-BUvQb4hR.css +2 -0
  300. package/dist/web-client/assets/index-ClLQXYAE.js +10 -0
  301. package/dist/web-client/index.html +13 -0
  302. package/package.json +20 -6
  303. package/dist/builtin-views/canvas/client.mjs +0 -303
  304. package/dist/builtin-views/canvas/view.mjs +0 -576
  305. package/dist/builtin-views/git-pr/client.mjs +0 -440
  306. package/dist/builtin-views/git-pr/view.mjs +0 -675
  307. package/dist/builtin-views/inbox/sources/gmail.mjs +0 -965
  308. package/dist/builtin-views/inbox/sources/linkedin.mjs +0 -427
  309. package/dist/builtin-views/inbox/view.mjs +0 -889
  310. package/dist/builtin-views/linkedin/client.mjs +0 -610
  311. package/dist/builtin-views/linkedin/view.mjs +0 -1171
  312. package/dist/commands/pkg/bridge.d.ts +0 -1
  313. package/dist/commands/pkg/bridge.js +0 -137
  314. package/dist/commands/skill/author.d.ts +0 -3
  315. package/dist/commands/skill/author.js +0 -140
  316. package/dist/commands/skill/shared.d.ts +0 -3
  317. package/dist/commands/skill/shared.js +0 -19
  318. package/dist/commands/skill.d.ts +0 -2
  319. package/dist/commands/skill.js +0 -21
  320. package/dist/commands/tmux-spread.d.ts +0 -2
  321. package/dist/commands/tmux-spread.js +0 -144
  322. package/dist/core/__tests__/full/daemon-liveness-pane.full.test.js +0 -292
  323. package/dist/core/__tests__/full/placement-focus.test.js +0 -309
  324. package/dist/core/__tests__/full/placement-reconcile.test.js +0 -212
  325. package/dist/core/__tests__/full/placement-revive.test.js +0 -238
  326. package/dist/core/__tests__/full/placement-teardown.test.js +0 -221
  327. package/dist/core/__tests__/home-session.test.js +0 -186
  328. package/dist/core/__tests__/relaunch.test.js +0 -335
  329. package/dist/core/bridge-map.d.ts +0 -19
  330. package/dist/core/bridge-map.js +0 -73
  331. package/dist/core/tui/contract.d.ts +0 -83
  332. package/dist/core/tui/contract.js +0 -8
  333. package/dist/core/tui/loader.d.ts +0 -16
  334. package/dist/core/tui/loader.js +0 -94
  335. package/dist/prompts/skill.d.ts +0 -2
  336. package/dist/prompts/skill.js +0 -650
  337. /package/dist/{core/__tests__/full/daemon-liveness-pane.full.test.d.ts → clients/attach/__tests__/action-parity.test.d.ts} +0 -0
  338. /package/dist/{core/__tests__/full/placement-focus.test.d.ts → clients/attach/__tests__/clipboard-image.test.d.ts} +0 -0
  339. /package/dist/{core/__tests__/full/placement-reconcile.test.d.ts → clients/attach/__tests__/editor-newline.test.d.ts} +0 -0
  340. /package/dist/{core/__tests__/full/placement-revive.test.d.ts → clients/attach/__tests__/git-info.test.d.ts} +0 -0
  341. /package/dist/{core/__tests__/full/placement-teardown.test.d.ts → clients/attach/__tests__/onrequest-wired.test.d.ts} +0 -0
  342. /package/dist/{core/__tests__/home-session.test.d.ts → clients/attach/__tests__/reconnect-giveup.test.d.ts} +0 -0
  343. /package/dist/{core/__tests__/relaunch.test.d.ts → clients/attach/__tests__/slash-quit-copy.test.d.ts} +0 -0
@@ -0,0 +1,141 @@
1
+ // Run with: node --import tsx/esm --test src/core/__tests__/error-stall-recycle.test.ts
2
+ //
3
+ // BUG-REGRESSION (§I error-stall; gh issue #4 — node mqaeqzdu-0746b3b7, a 9h
4
+ // zombie). A provider outage exhausted the engine's SDK retry budget mid-task:
5
+ // pi appended trailing stopReason:'error' assistant messages to the session
6
+ // .jsonl and PARKED (the stophook's agent_end returns early on 'error' — "stay
7
+ // alive for re-steering"). A headless node has no human to re-steer it, so the
8
+ // broker stayed alive, status stayed 'active', and the daemon's pid-only
9
+ // liveness check read it healthy for 9 hours while its parent believed it was
10
+ // working. The daemon is the authority: a live, not-busy, intent-null engine
11
+ // whose session file has been quiet past ERROR_STALL_QUIET_MS and whose last
12
+ // assistant message stopped with 'error' is force-killed, so the ordinary
13
+ // dead-pid crash path grace-revives RESUME on the saved session — the exact
14
+ // manual recovery (kill broker + canvas revive) that fixed the zombie by hand.
15
+ //
16
+ // Three legs: the pure truth table (E1 — errorStallVerdict), the tail-signature
17
+ // parser (E2 — trailingEngineError), and the daemon integration over a real
18
+ // live pid the daemon must SIGTERM (E3 — superviseTick with an injected clock).
19
+ // Regression check: drop §I and E3's "engine killed" assert goes RED (the
20
+ // parked error engine lives forever, never recovered).
21
+ import { test } from 'node:test';
22
+ import assert from 'node:assert/strict';
23
+ import { spawn } from 'node:child_process';
24
+ import { writeFileSync, utimesSync, mkdtempSync, rmSync } from 'node:fs';
25
+ import { join } from 'node:path';
26
+ import { tmpdir } from 'node:os';
27
+ import { createHeadlessHarness } from './helpers/harness.js';
28
+ import { errorStallVerdict, trailingEngineError, ERROR_STALL_QUIET_MS, isPidAlive, } from '../../daemon/crtrd.js';
29
+ // ---------------------------------------------------------------------------
30
+ // Session-jsonl line builders (the shape pi writes: {type:'message', message}).
31
+ // ---------------------------------------------------------------------------
32
+ function line(message) {
33
+ return JSON.stringify({ type: 'message', id: 'x', timestamp: new Date().toISOString(), message });
34
+ }
35
+ const assistant = (stopReason, errorMessage) => line({ role: 'assistant', stopReason, ...(errorMessage === undefined ? {} : { errorMessage }) });
36
+ const toolResult = () => line({ role: 'toolResult' });
37
+ const user = () => line({ role: 'user' });
38
+ // ---------------------------------------------------------------------------
39
+ // E1 — the pure error-stall truth table.
40
+ // ---------------------------------------------------------------------------
41
+ test('E1 errorStallVerdict: kill ONLY a live, not-busy, intent-null engine, quiet past grace, with a trailing error', () => {
42
+ const G = ERROR_STALL_QUIET_MS;
43
+ // The one 'kill' case.
44
+ assert.equal(errorStallVerdict(true, null, false, G, true), 'kill', 'alive+no-intent+!busy+quiet>=grace+error → kill');
45
+ assert.equal(errorStallVerdict(true, null, false, G + 60_000, true), 'kill', 'well past grace → kill');
46
+ // Recent session activity → leave (something is still making progress).
47
+ assert.equal(errorStallVerdict(true, null, false, G - 1, true), 'leave', 'within quiet grace → leave');
48
+ assert.equal(errorStallVerdict(true, null, false, null, true), 'leave', 'unknown quiet span → leave');
49
+ // No trailing error → a healthy park (attended root, awaiting node) — leave.
50
+ assert.equal(errorStallVerdict(true, null, false, G + 60_000, false), 'leave', 'last turn not an error → leave');
51
+ // A working engine is NEVER killed (wedged-on-bash is recovered by killing the
52
+ // subprocess, not the node).
53
+ assert.equal(errorStallVerdict(true, null, true, G + 60_000, true), 'leave', 'busy (mid-turn) → leave');
54
+ // A pending intent belongs to other paths (§H refresh; idle-release dormancy).
55
+ assert.equal(errorStallVerdict(true, 'refresh', false, G + 60_000, true), 'leave', 'intent=refresh → leave (§H owns it)');
56
+ assert.equal(errorStallVerdict(true, 'idle-release', false, G + 60_000, true), 'leave', 'intent=idle-release → leave');
57
+ // A dead/unknown pid is the ordinary revive path's job.
58
+ assert.equal(errorStallVerdict(false, null, false, G + 60_000, true), 'leave', 'dead pid → leave');
59
+ assert.equal(errorStallVerdict(null, null, false, G + 60_000, true), 'leave', 'unknown pid → leave');
60
+ });
61
+ // ---------------------------------------------------------------------------
62
+ // E2 — the tail-signature parser.
63
+ // ---------------------------------------------------------------------------
64
+ test('E2 trailingEngineError: only a trailing error assistant turn matches', () => {
65
+ // The observed zombie shape: trailing zero-token error assistant messages.
66
+ assert.equal(trailingEngineError([assistant('toolUse'), toolResult(), assistant('error', 'Connection error.')].join('\n')), 'Connection error.', 'trailing error turn → its errorMessage');
67
+ // Trailing non-assistant lines (toolResults, an injected user message that
68
+ // never got a response) are walked past to the last assistant turn.
69
+ assert.equal(trailingEngineError([assistant('error', 'Connection error.'), user()].join('\n')), 'Connection error.', 'trailing injected user line is walked past');
70
+ // A healthy park / human abort is NOT a wedge.
71
+ assert.equal(trailingEngineError([assistant('error', 'x'), toolResult(), assistant('stop')].join('\n')), null, 'recovered after an earlier error → null');
72
+ assert.equal(trailingEngineError(assistant('aborted')), null, 'human Esc → null');
73
+ // A tail cut mid-line (the 64KB boundary) is skipped, not fatal.
74
+ assert.equal(trailingEngineError(['ut"}}garbage-fragment', assistant('error', 'boom')].join('\n')), 'boom', 'cut first fragment is skipped');
75
+ // An error turn with no errorMessage still reads as a wedge.
76
+ assert.notEqual(trailingEngineError(assistant('error')), null, 'error with no errorMessage still matches');
77
+ assert.equal(trailingEngineError(null), null, 'no tail → null');
78
+ assert.equal(trailingEngineError(''), null, 'empty tail → null');
79
+ });
80
+ // ---------------------------------------------------------------------------
81
+ // E3 — daemon integration: a parked trailing-error engine is force-recycled.
82
+ // ---------------------------------------------------------------------------
83
+ const T0 = 9_000_000_000;
84
+ test('E3 daemon force-kills a wedged engine: live, not-busy, intent-null, session quiet past grace with a trailing error turn', { timeout: 30_000 }, async () => {
85
+ const h = await createHeadlessHarness({ sessionPrefix: 'crtr-e3' });
86
+ const dir = mkdtempSync(join(tmpdir(), 'crtr-e3-'));
87
+ // Real live subprocesses standing in for the parked engines; `sleep` dies on
88
+ // SIGTERM, so the daemon's force-kill is directly observable.
89
+ const wedged = spawn('sleep', ['300'], { stdio: 'ignore' });
90
+ const parked = spawn('sleep', ['300'], { stdio: 'ignore' });
91
+ try {
92
+ // The WEDGED node: session ends in an error turn, quiet for > the grace.
93
+ const wedgedFile = join(dir, 'wedged.jsonl');
94
+ writeFileSync(wedgedFile, [assistant('toolUse'), toolResult(), assistant('error', 'Connection error.')].join('\n') + '\n');
95
+ const staleSec = (T0 - ERROR_STALL_QUIET_MS - 1) / 1000;
96
+ utimesSync(wedgedFile, staleSec, staleSec);
97
+ const wedgedId = h.fabricateBrokerNode({
98
+ kind: 'developer',
99
+ status: 'active',
100
+ pi_pid: wedged.pid,
101
+ pi_session_id: 'sess-e3-wedged',
102
+ pi_session_file: wedgedFile,
103
+ });
104
+ // The CONTROL node: equally old and quiet, but its last turn is a healthy
105
+ // 'stop' (an attended root overnight) — must NEVER be killed.
106
+ const parkedFile = join(dir, 'parked.jsonl');
107
+ writeFileSync(parkedFile, [assistant('toolUse'), toolResult(), assistant('stop')].join('\n') + '\n');
108
+ utimesSync(parkedFile, staleSec, staleSec);
109
+ h.fabricateBrokerNode({
110
+ kind: 'developer',
111
+ status: 'active',
112
+ pi_pid: parked.pid,
113
+ pi_session_id: 'sess-e3-parked',
114
+ pi_session_file: parkedFile,
115
+ });
116
+ assert.equal(isPidAlive(wedged.pid), true, 'precondition: the wedged engine is alive');
117
+ // --- TICK @ T0: the session has already been quiet past the grace (the
118
+ // file mtime is the clock — no arming tick), so the daemon SIGTERMs
119
+ // the wedged engine on sight. ---
120
+ await h.tick(T0);
121
+ await h.waitFor(() => (isPidAlive(wedged.pid) ? null : true), {
122
+ label: 'daemon SIGTERM killed the wedged trailing-error engine (§I)',
123
+ timeoutMs: 10_000,
124
+ });
125
+ assert.equal(isPidAlive(wedged.pid), false, 'the wedged engine was force-killed');
126
+ assert.equal(isPidAlive(parked.pid), true, 'the healthily-parked engine was NOT touched');
127
+ assert.equal(h.node(wedgedId).intent ?? null, null, 'intent stays null — the dead-pid CRASH branch owns the revive (resume)');
128
+ }
129
+ finally {
130
+ for (const c of [wedged, parked]) {
131
+ try {
132
+ c.kill('SIGKILL');
133
+ }
134
+ catch {
135
+ /* already gone */
136
+ }
137
+ }
138
+ rmSync(dir, { recursive: true, force: true });
139
+ await h.dispose();
140
+ }
141
+ });
@@ -80,11 +80,22 @@ declare class FakeSession {
80
80
  private readonly messageLog;
81
81
  constructor(sm: SessionManager, loader: ResourceLoader | undefined);
82
82
  get messages(): unknown[];
83
+ get sessionManager(): {
84
+ buildSessionContext: () => {
85
+ messages: unknown[];
86
+ thinkingLevel: string;
87
+ model: null;
88
+ };
89
+ };
83
90
  get sessionId(): string;
84
91
  get sessionFile(): string | undefined;
85
92
  get model(): {
86
93
  id: string;
87
94
  } | undefined;
95
+ private currentModel;
96
+ setModel(model: {
97
+ id: string;
98
+ }): Promise<void>;
88
99
  get isStreaming(): boolean;
89
100
  getSessionStats(): Record<string, unknown>;
90
101
  setSessionName(_name: string): void;
@@ -92,6 +103,15 @@ declare class FakeSession {
92
103
  steer(_text: string): Promise<void>;
93
104
  followUp(_text: string): Promise<void>;
94
105
  abort(): Promise<void>;
106
+ /** In-place tree rewind (broker `navigate_tree`). Mirrors the real SDK's
107
+ * contract: the session file is unchanged, the live history is truncated,
108
+ * and the navigated-to user message's text comes back as `editorText`. The
109
+ * fake's "rewind" drops the last accrued turn so a re-welcome snapshot is
110
+ * observably shorter (the navigate-tree-rewelcome regression lock). */
111
+ navigateTree(targetId: string, _options?: unknown): Promise<{
112
+ editorText?: string;
113
+ cancelled: boolean;
114
+ }>;
95
115
  subscribe(listener: (event: unknown) => void): () => void;
96
116
  /** Fan one typed AgentSessionEvent out to every broker subscriber. A throwing
97
117
  * listener is recorded, never propagated (mirrors the broker's m7 try/catch). */
@@ -117,7 +137,10 @@ declare class FakeSession {
117
137
  interface FakeServices {
118
138
  resourceLoader: ResourceLoader;
119
139
  modelRegistry: {
120
- find: (provider: string, id: string) => undefined;
140
+ find: (provider: string, id: string) => {
141
+ provider: string;
142
+ id: string;
143
+ } | undefined;
121
144
  };
122
145
  }
123
146
  export declare function createAgentSessionServices(options: {
@@ -167,6 +167,16 @@ class FakeSession {
167
167
  get messages() {
168
168
  return this.messageLog;
169
169
  }
170
+ // The broker's catch-up snapshot reconstructs history from the persisted
171
+ // session tree (sessionManager.buildSessionContext) — the single source of
172
+ // truth, since `session.messages` can be sliced by pi's retry/overflow/
173
+ // compaction recovery. Mirror that surface so the fake's snapshot still serves
174
+ // the accrued history (G2/G3). The fake's persisted analog IS messageLog.
175
+ get sessionManager() {
176
+ return {
177
+ buildSessionContext: () => ({ messages: this.messageLog, thinkingLevel: 'off', model: null }),
178
+ };
179
+ }
170
180
  get sessionId() {
171
181
  return this.sm.getSessionId();
172
182
  }
@@ -174,7 +184,15 @@ class FakeSession {
174
184
  return this.sm.getSessionFile();
175
185
  }
176
186
  get model() {
177
- return undefined;
187
+ return this.currentModel;
188
+ }
189
+ // set_model regression surface: the broker resolves the spec via the services
190
+ // registry stub below, calls setModel, then broadcasts `model_changed` off
191
+ // this getter — store whatever it hands us.
192
+ currentModel;
193
+ setModel(model) {
194
+ this.currentModel = model;
195
+ return Promise.resolve();
178
196
  }
179
197
  get isStreaming() {
180
198
  return this.streaming;
@@ -205,6 +223,15 @@ class FakeSession {
205
223
  abort() {
206
224
  return Promise.resolve();
207
225
  }
226
+ /** In-place tree rewind (broker `navigate_tree`). Mirrors the real SDK's
227
+ * contract: the session file is unchanged, the live history is truncated,
228
+ * and the navigated-to user message's text comes back as `editorText`. The
229
+ * fake's "rewind" drops the last accrued turn so a re-welcome snapshot is
230
+ * observably shorter (the navigate-tree-rewelcome regression lock). */
231
+ navigateTree(targetId, _options) {
232
+ this.messageLog.pop();
233
+ return Promise.resolve({ cancelled: false, editorText: `rewound-to:${targetId}` });
234
+ }
208
235
  // --- the single engine event stream the broker fans out (broker.ts subscribes
209
236
  // here and broadcasts each event VERBATIM to every attached viewer) --------
210
237
  subscribe(listener) {
@@ -571,7 +598,10 @@ export async function createAgentSessionServices(options) {
571
598
  await loader.reload();
572
599
  return {
573
600
  resourceLoader: loader,
574
- modelRegistry: { find: () => undefined },
601
+ // A minimal model stub for any spec: the lifecycle harness passes no boot
602
+ // model (so this is never hit at boot), but the set_model regression test
603
+ // drives the broker's findModelSpec → session.setModel path through it.
604
+ modelRegistry: { find: (provider, id) => ({ provider, id }) },
575
605
  };
576
606
  }
577
607
  export async function createAgentSessionFromServices(options) {
@@ -129,7 +129,7 @@ let eventSeq = 0;
129
129
  // Append a durable record of every fired event BEFORE its handlers run. This is
130
130
  // the harness's robust "the host received my command and is dispatching it"
131
131
  // signal — it survives even when a handler tears the process down mid-flight
132
- // (e.g. agent_end→reviveInPlace does respawn-pane -k on our own pane), which is
132
+ // (e.g. agent_end on a refresh/done node drives the broker shutdown), which is
133
133
  // exactly the case where an after-the-fact ack would be lost.
134
134
  function recordEvent(event, ev) {
135
135
  try {
@@ -219,7 +219,7 @@ const boot = {
219
219
  };
220
220
  writeFileSync(join(nodeDir, 'fake-pi.boot.json'), JSON.stringify(boot, null, 2));
221
221
  // Append-only boot log so the harness can count re-boots (a resume after an
222
- // idle-release wake, or a fresh pi after a refresh-yield reviveInPlace).
222
+ // idle-release wake, or a fresh pi after a refresh-yield).
223
223
  try {
224
224
  appendFileSync(join(nodeDir, 'fake-pi.boots.jsonl'), JSON.stringify(boot) + '\n');
225
225
  }
@@ -11,7 +11,8 @@
11
11
  // is rejected (upholds "a node occupies <=1 focus", Q5)
12
12
  // - independent focus rows don't contend
13
13
  // - placement focusOf / isFocused / focusByPane / focusedNodes / listFocuses
14
- // agree with the rows
14
+ // GC dead-pane viewer rows on read (liveOrPrune), and pass null-pane rows
15
+ // through (broker-host cut: placement reads self-heal the viewer registry)
15
16
  import { test, before, beforeEach, after } from 'node:test';
16
17
  import assert from 'node:assert/strict';
17
18
  import { mkdtempSync, rmSync } from 'node:fs';
@@ -136,7 +137,7 @@ test('open / setOccupant / close round-trip with the reads', () => {
136
137
  setFocusOccupant('f1', 'B');
137
138
  assert.equal(getFocusByNode('A'), null, 'A no longer occupies the focus');
138
139
  assert.deepEqual(getFocusByNode('B'), { focus_id: 'f1', pane: '%a', session: 'Sa', node_id: 'B' });
139
- // setFocusPane re-points the pane + session cache (reconcileFocus, Step 6).
140
+ // setFocusPane re-points the pane + session cache after a viewer move.
140
141
  setFocusPane('f1', '%a2', 'Sa2');
141
142
  assert.deepEqual(getFocusById('f1'), { focus_id: 'f1', pane: '%a2', session: 'Sa2', node_id: 'B' });
142
143
  assert.equal(getFocusByPane('%a'), null, 'the old pane no longer resolves');
@@ -161,7 +162,7 @@ test('UNIQUE(node_id): hot-swapping an occupant onto an already-focused node is
161
162
  openFocusRow('f1', '%a', 'Sa', 'A');
162
163
  openFocusRow('f2', '%b', 'Sb', 'B');
163
164
  // B already occupies f2 — moving it onto f1 via setFocusOccupant must throw
164
- // (the Q5 vacate-first is retargetFocus's job, Step 6, not this setter's).
165
+ // (the Q5 vacate-first is placement.focus()'s job, not this setter's).
165
166
  assert.throws(() => setFocusOccupant('f1', 'B'), /UNIQUE|constraint/i);
166
167
  assert.deepEqual(getFocusByNode('A'), { focus_id: 'f1', pane: '%a', session: 'Sa', node_id: 'A' });
167
168
  assert.deepEqual(getFocusByNode('B'), { focus_id: 'f2', pane: '%b', session: 'Sb', node_id: 'B' });
@@ -179,18 +180,29 @@ test('independent focus rows do not contend', () => {
179
180
  assert.deepEqual(listFocuses().map((f) => f.focus_id), ['f2']);
180
181
  });
181
182
  // ---------------------------------------------------------------------------
182
- // Placement reads — focusOf / isFocused / focusByPane / focusedNodes / listFocuses
183
- // agree with the rows.
183
+ // Placement reads — focusOf / isFocused / focusByPane / focusedNodes /
184
+ // listFocuses GC dead-pane viewer rows on read (broker-host cut: liveOrPrune).
185
+ // A row whose viewer pane no longer exists is pruned the next time it is read,
186
+ // so the registry self-heals without a sweeper; a row with a null pane (a
187
+ // registered-but-not-yet-realized viewer) is passed through. A live tmux pane
188
+ // can't be fabricated in the fast tier, so we cover the prune + null-pane paths
189
+ // here; the live-pane read is covered in the full-tier broker lifecycle suite.
184
190
  // ---------------------------------------------------------------------------
185
- test('placement focus reads agree with the focus rows', () => {
191
+ test('placement reads GC dead-pane viewer rows and keep null-pane rows', () => {
186
192
  openDb();
187
- openFocusRow('f1', '%a', 'Sa', 'A');
188
- openFocusRow('f2', '%b', 'Sb', 'B');
189
- assert.deepEqual(focusOf('A'), { focus_id: 'f1', pane: '%a', session: 'Sa', node_id: 'A' });
193
+ openFocusRow('f1', '%a', 'Sa', 'A'); // %a is not a live tmux pane → pruned on read
194
+ openFocusRow('f2', null, 'Sb', 'B'); // null pane (not realized yet) → kept
195
+ // The dead-pane row is GC'd on read: placement returns null AND the row is
196
+ // removed from the canvas table (self-healing registry).
197
+ assert.equal(focusOf('A'), null, 'a viewer whose pane is gone is pruned on read');
198
+ assert.equal(getFocusByNode('A'), null, 'the pruned row is closed in the db');
199
+ assert.equal(isFocused('A'), false);
200
+ assert.equal(focusByPane('%a'), null, 'focusByPane prunes the dead pane too');
201
+ // The null-pane row survives (liveOrPrune only prunes a non-null dead pane).
202
+ assert.deepEqual(focusOf('B'), { focus_id: 'f2', pane: null, session: 'Sb', node_id: 'B' });
203
+ assert.equal(isFocused('B'), true);
190
204
  assert.equal(focusOf('Z'), null, 'an unfocused node has no focus');
191
- assert.equal(isFocused('A'), true);
192
205
  assert.equal(isFocused('Z'), false);
193
- assert.deepEqual(focusByPane('%b'), { focus_id: 'f2', pane: '%b', session: 'Sb', node_id: 'B' });
194
- assert.deepEqual(focusedNodes(), new Set(['A', 'B']));
195
- assert.deepEqual(placementListFocuses().map((f) => f.node_id), ['A', 'B']);
206
+ assert.deepEqual(focusedNodes(), new Set(['B']));
207
+ assert.deepEqual(placementListFocuses().map((f) => f.node_id), ['B']);
196
208
  });
@@ -72,19 +72,36 @@ test('resolveForkSource resolves a node id to its absolute session FILE', () =>
72
72
  createNode(node('src', { pi_session_id: 'uuid-1', pi_session_file: '/abs/src.jsonl' }));
73
73
  assert.equal(resolveForkSource('src'), '/abs/src.jsonl');
74
74
  });
75
- test('resolveForkSource falls back to the bare session id when no file captured', () => {
75
+ test('resolveForkSource THROWS when a node has a session id but no captured FILE', () => {
76
+ // Broker-cut contract (U6/§B.broker): the broker forks an absolute .jsonl PATH,
77
+ // never a bare session id. A node row carrying only pi_session_id (no
78
+ // pi_session_file) can no longer be forked — it fails loudly pre-launch.
76
79
  createNode(node('src', { pi_session_id: 'uuid-1' }));
77
- assert.equal(resolveForkSource('src'), 'uuid-1');
80
+ assert.throws(() => resolveForkSource('src'), /no pi session yet|session FILE/);
78
81
  });
79
82
  test('resolveForkSource throws when the node has no pi session to fork yet', () => {
80
83
  createNode(node('fresh'));
81
84
  assert.throws(() => resolveForkSource('fresh'), /no pi session yet/);
82
85
  });
83
- test('resolveForkSource passes a path straight through', () => {
86
+ test('resolveForkSource passes an absolute path straight through', () => {
84
87
  assert.equal(resolveForkSource('/some/where/sess.jsonl'), '/some/where/sess.jsonl');
85
88
  });
86
- test('resolveForkSource passes an unknown bare/partial uuid through to pi', () => {
87
- assert.equal(resolveForkSource('019e8ce3-322e'), '019e8ce3-322e');
89
+ test('resolveForkSource makes a relative path absolute', () => {
90
+ assert.equal(resolveForkSource('sub/sess.jsonl'), join(process.cwd(), 'sub/sess.jsonl'));
91
+ });
92
+ test('resolveForkSource THROWS on a bare/partial uuid that resolves to no session', () => {
93
+ // Broker-cut contract (U6): an unknown bare/partial uuid is scanned against
94
+ // pi's sessions store and fails loudly when it matches nothing — it is no
95
+ // longer passed through verbatim (the broker fork won't resolve a uuid).
96
+ const sessRoot = mkdtempSync(join(tmpdir(), 'crtr-pi-agent-'));
97
+ process.env['PI_CODING_AGENT_DIR'] = sessRoot;
98
+ try {
99
+ assert.throws(() => resolveForkSource('019e8ce3-322e'), /no pi session/);
100
+ }
101
+ finally {
102
+ delete process.env['PI_CODING_AGENT_DIR'];
103
+ rmSync(sessRoot, { recursive: true, force: true });
104
+ }
88
105
  });
89
106
  test('resolveForkSource rejects an empty reference', () => {
90
107
  assert.throws(() => resolveForkSource(' '), /requires a node id/);
@@ -0,0 +1,61 @@
1
+ // Run with: node --import tsx/esm --test src/core/__tests__/full/broker-control-preempt.test.ts
2
+ // (FULL tier — boots a real isolated tmux session + a real headless broker.)
3
+ //
4
+ // BUG-REGRESSION (broker-universal-host cut, design §D preemptive handoff; U1
5
+ // report mq8ftenr-5d55313d). Before the cut, `request_control` granted control
6
+ // ONLY when controllerId===null and otherwise returned a `control_held` error —
7
+ // so a second client could NEVER preempt a live (and usually idle/abandoned)
8
+ // controller. With every node now broker-hosted, a tmux pane and a web tab are
9
+ // true peers: either must be able to take control of a node the other currently
10
+ // drives. The fix makes request_control preemptive last-requester-wins — it
11
+ // ALWAYS succeeds, reassigning control to the requester and broadcasting a
12
+ // control_changed that demotes the prior controller. There is no `control_held`
13
+ // error frame anymore.
14
+ //
15
+ // Drive: controller A attaches and holds control; observer B then requests
16
+ // control. Assert control reassigns to B, a control_changed broadcasts demoting
17
+ // A, and B got NO error frame back (the old `control_held` reject is gone).
18
+ // Regression check: restore the controllerId!==null reject and B's request is
19
+ // refused (controller_id stays A / B receives an error) → these asserts go RED.
20
+ import { test, before, after, afterEach } from 'node:test';
21
+ import assert from 'node:assert/strict';
22
+ import { createHeadlessHarness } from '../helpers/harness.js';
23
+ import { createAttachKit } from '../helpers/broker-clients.js';
24
+ let h;
25
+ let id;
26
+ const kit = createAttachKit(() => h);
27
+ const { attach, attachUntil } = kit;
28
+ before(async () => {
29
+ h = await createHeadlessHarness({ sessionPrefix: 'crtr-ctrlpreempt' });
30
+ const root = h.spawnRoot('control-preempt suite root');
31
+ id = await h.spawnHeadlessChild(root, 'headless worker — control preemption');
32
+ });
33
+ after(async () => {
34
+ if (h !== undefined)
35
+ await h.dispose();
36
+ });
37
+ afterEach(() => {
38
+ kit.closeAll();
39
+ });
40
+ test('D3 — a second client preempts the live controller (last-requester-wins); the prior controller is demoted, no control_held reject', async () => {
41
+ // A attaches and is granted control (controllerId was null). attachUntil
42
+ // suffixes the client_id per retry, so the broker-assigned id is the welcome's.
43
+ const bId = 'd3-B';
44
+ const a = await attachUntil(id, 'controller', 'd3-A', (x) => x.welcome.role === 'controller', 'A admitted controller');
45
+ const aId = a.welcome.controller_id;
46
+ assert.ok(aId !== null, 'A holds control after attach');
47
+ // B attaches as an observer while A holds control (its welcome reflects A).
48
+ const b = await attach(id, 'observer', bId);
49
+ assert.equal(b.welcome.role, 'observer', 'B starts as an observer (A holds control)');
50
+ assert.equal(b.welcome.controller_id, aId, 'B sees A as the current controller');
51
+ // B preempts. Under the OLD model this returned a control_held error and B
52
+ // never became controller; under §D it ALWAYS succeeds.
53
+ b.send({ type: 'request_control' });
54
+ // A is demoted via a broadcast control_changed naming B as the new controller.
55
+ const changed = await a.waitFrame((f) => f.type === 'control_changed' && f.controller_id === bId, 'A receives control_changed demoting it to B');
56
+ assert.equal(changed.controller_id, bId, 'control reassigned to B (last-requester-wins)');
57
+ // B sees itself as the controller too (same broadcast).
58
+ await b.waitFrame((f) => f.type === 'control_changed' && f.controller_id === bId, 'B observes itself become controller');
59
+ // The old `control_held` reject is gone: B's request drew NO error frame.
60
+ assert.ok(!b.frames.some((f) => f.type === 'error'), 'B received NO error frame — there is no control_held reject in the preemptive model');
61
+ });
@@ -0,0 +1,57 @@
1
+ // Run with: node --import tsx/esm --test src/core/__tests__/full/broker-model-changed-broadcast.test.ts
2
+ //
3
+ // FULL TIER (real-boot-bound): tmux-free, but it boots a REAL broker process,
4
+ // so it lives in full/ (CI), not the fast local loop.
5
+ //
6
+ // BUG-REGRESSION (real, observed 2026-06-11 in `crtr attach`): switching the
7
+ // model (/model <provider/id> or the picker) visibly did nothing — the viewer's
8
+ // status bar kept showing the OLD model until some unrelated event happened to
9
+ // arrive.
10
+ //
11
+ // 3-PART HEADER:
12
+ // (1) BUG IT LOCKS — pi emits no AgentSessionEvent for a model switch, and the
13
+ // broker's `set_model`/`cycle_model` handlers replied with only a bare ack
14
+ // to the REQUESTING client. The new model reached no viewer at all: the
15
+ // broker now broadcasts a `model_changed` frame (mirroring
16
+ // snapshot.state.model) to every client after the engine accepts the switch.
17
+ // (2) WHY BROKER/SOCKET-LEVEL, NOT PANE — the regression is pure frame-plumbing
18
+ // on view.sock: a controller `set_model` in, an ack + `model_changed`
19
+ // fan-out. No tmux pane or TUI is involved, so the lock holds headlessly
20
+ // (createHeadlessHarness + the fake engine, whose registry stub resolves
21
+ // any `provider/id` and whose setModel stores what it is handed).
22
+ // (3) HOW IT FAILS ON REGRESSION — pre-fix, NO `model_changed` frame ever
23
+ // arrives at either the controller or the observer (both waits time out);
24
+ // the ack alone still passing would not satisfy the broadcast asserts.
25
+ import { test, before, after, afterEach } from 'node:test';
26
+ import assert from 'node:assert/strict';
27
+ import { createHeadlessHarness } from '../helpers/harness.js';
28
+ import { createAttachKit } from '../helpers/broker-clients.js';
29
+ let h;
30
+ let id;
31
+ const kit = createAttachKit(() => h);
32
+ const { attachUntil } = kit;
33
+ before(async () => {
34
+ h = await createHeadlessHarness({ sessionPrefix: 'crtr-brkmodel' });
35
+ const root = h.spawnRoot('model-changed-broadcast suite root');
36
+ id = await h.spawnHeadlessChild(root, 'headless worker — model_changed broadcast gate');
37
+ });
38
+ after(async () => {
39
+ if (h !== undefined)
40
+ await h.dispose();
41
+ });
42
+ afterEach(() => {
43
+ kit.closeAll();
44
+ });
45
+ test('set_model acks the requester and broadcasts model_changed to every viewer', async () => {
46
+ const c = await attachUntil(id, 'controller', 'model-ctrl', (a) => a.welcome.role === 'controller', 'model-ctrl admitted controller');
47
+ const o = await attachUntil(id, 'observer', 'model-obs', (a) => a.welcome.role === 'observer', 'model-obs admitted observer');
48
+ c.send({ type: 'set_model', model: 'anthropic/fake-fable' });
49
+ const ack = await c.waitFrame((f) => f.type === 'ack' && f.for === 'set_model', 'set_model ack');
50
+ assert.ok(ack.type === 'ack' && ack.ok, 'set_model acked ok');
51
+ // BOTH the requester and the observer hear the switch. Pre-fix: no
52
+ // model_changed frame exists, so neither wait ever resolves.
53
+ const cm = await c.waitFrame((f) => f.type === 'model_changed', 'controller receives model_changed');
54
+ assert.ok(cm.type === 'model_changed' && cm.model === 'fake-fable', 'controller sees the new model id');
55
+ const om = await o.waitFrame((f) => f.type === 'model_changed', 'observer receives model_changed');
56
+ assert.ok(om.type === 'model_changed' && om.model === 'fake-fable', 'observer sees the new model id');
57
+ });
@@ -0,0 +1,80 @@
1
+ // Run with: node --import tsx/esm --test src/core/__tests__/full/broker-navigate-tree-rewelcome.test.ts
2
+ //
3
+ // FULL TIER (real-boot-bound): tmux-free, but it boots a REAL broker process,
4
+ // so it lives in full/ (CI), not the fast local loop.
5
+ //
6
+ // BUG-REGRESSION (real, observed 2026-06-11 in `crtr attach`): double-Esc (or
7
+ // /tree) opened the session-tree picker, but SELECTING an entry visibly did
8
+ // nothing — the viewer stayed on the old transcript instead of traveling back
9
+ // to the chosen point in time.
10
+ //
11
+ // 3-PART HEADER:
12
+ // (1) BUG IT LOCKS — the broker's `navigate_tree` handler called
13
+ // session.navigateTree() (which rewinds IN-PLACE: same session file, new
14
+ // leaf, NO relayed AgentSessionEvent) and then only acked. Unlike the
15
+ // session-replacing ops (new_session/switch_session/fork → runReplacement
16
+ // → reWelcomeAll), it never re-snapshotted the viewers, so every attached
17
+ // client kept rendering the abandoned branch. The navigated-to user
18
+ // message's `editorText` (pi parity: the tree navigator restores it to the
19
+ // editor) was dropped on the floor too.
20
+ // (2) WHY BROKER/SOCKET-LEVEL, NOT PANE — the regression is pure frame-plumbing
21
+ // on view.sock: a controller `navigate_tree` in, a re-`welcome` fan-out +
22
+ // ack.detail out. No tmux pane or TUI is involved, so the lock holds
23
+ // headlessly (createHeadlessHarness + the fake engine, whose navigateTree
24
+ // pops the last accrued turn and returns `rewound-to:<targetId>`).
25
+ // (3) HOW IT FAILS ON REGRESSION — pre-fix, NO second welcome ever arrives
26
+ // (the re-welcome waits time out) and the navigate_tree ack carries no
27
+ // detail; the rewound-history assert (the turn token gone from the new
28
+ // snapshot) also goes red if the re-welcome serves a stale snapshot.
29
+ import { test, before, after, afterEach } from 'node:test';
30
+ import assert from 'node:assert/strict';
31
+ import { createHeadlessHarness } from '../helpers/harness.js';
32
+ import { createAttachKit, frameHas, tok } from '../helpers/broker-clients.js';
33
+ let h;
34
+ let id;
35
+ const kit = createAttachKit(() => h);
36
+ const { attachUntil } = kit;
37
+ before(async () => {
38
+ h = await createHeadlessHarness({ sessionPrefix: 'crtr-brknav' });
39
+ const root = h.spawnRoot('navigate-tree-rewelcome suite root');
40
+ id = await h.spawnHeadlessChild(root, 'headless worker — navigate-tree rewelcome gate');
41
+ });
42
+ after(async () => {
43
+ if (h !== undefined)
44
+ await h.dispose();
45
+ });
46
+ afterEach(() => {
47
+ kit.closeAll();
48
+ });
49
+ const welcomes = (frames) => frames.filter((f) => f.type === 'welcome');
50
+ test('navigate_tree re-welcomes every viewer onto the rewound transcript and acks the editor text', async () => {
51
+ const c = await attachUntil(id, 'controller', 'nav-ctrl', (a) => a.welcome.role === 'controller', 'nav-ctrl admitted controller');
52
+ // Accrue one observable turn so the rewind has something to drop.
53
+ const token = tok('NAV-TURN');
54
+ c.send({ type: 'prompt', text: token });
55
+ await c.waitFrame((f) => f.type === 'agent_end' && frameHas(f, token), 'turn relayed before navigate');
56
+ // The observer attaches AFTER the turn so its first welcome deterministically
57
+ // holds the token (the pre-navigate baseline) — same accrual trick as G3.
58
+ const o = await attachUntil(id, 'observer', 'nav-obs', (a) => frameHas(a.welcome, token), 'observer welcome holds the pre-navigate turn');
59
+ c.send({ type: 'navigate_tree', targetId: 'entry-x' });
60
+ // The ack carries the navigated-to user message's text in detail (the viewer
61
+ // restores it to the editor). Pre-fix: ack had no detail.
62
+ const ack = await c.waitFrame((f) => f.type === 'ack' && f.for === 'navigate_tree', 'navigate_tree ack');
63
+ assert.ok(ack.type === 'ack' && ack.ok, 'navigate_tree acked ok');
64
+ assert.equal(ack.type === 'ack' ? ack.detail : undefined, 'rewound-to:entry-x', 'ack.detail carries the rewound-to editor text');
65
+ // BOTH viewers get re-welcomed onto the rewound history. Pre-fix: no second
66
+ // welcome ever arrives (navigate_tree only acked).
67
+ await h.waitFor(() => (welcomes(c.frames).length >= 2 ? true : null), {
68
+ label: 'controller re-welcomed after navigate_tree',
69
+ });
70
+ await h.waitFor(() => (welcomes(o.frames).length >= 2 ? true : null), {
71
+ label: 'observer re-welcomed after navigate_tree',
72
+ });
73
+ // The re-welcome serves the REWOUND transcript: the fake's navigateTree popped
74
+ // the last accrued turn, so the token present in the observer's first welcome
75
+ // must be gone from its second.
76
+ const [first, second] = welcomes(o.frames);
77
+ assert.ok(frameHas(first, token), 'pre-navigate welcome holds the turn');
78
+ assert.ok(!frameHas(second, token), 're-welcome serves the rewound transcript (turn dropped)');
79
+ assert.equal(second.snapshot.messages.length, first.snapshot.messages.length - 1, 're-welcome history is exactly one turn shorter');
80
+ });