@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
@@ -28,7 +28,7 @@ import { fileURLToPath } from 'node:url';
28
28
  import { createRequire } from 'node:module';
29
29
  import { createNode, getNode, subscribersOf, subscriptionsOf, } from '../../canvas/canvas.js';
30
30
  import { closeDb } from '../../canvas/db.js';
31
- import { isNodePaneAlive } from '../../runtime/placement.js';
31
+ import { isFocused } from '../../runtime/placement.js';
32
32
  import { superviseTick } from '../../../daemon/crtrd.js';
33
33
  import { readInboxSince } from '../../feed/inbox.js';
34
34
  // --- locations --------------------------------------------------------------
@@ -85,8 +85,7 @@ function cleanBaseEnv() {
85
85
  // ~/.crouter / ~/.claude / ~/.pi, NOT under CRTR_HOME — HOME is contained too).
86
86
  e['CRTR_NO_BOOTSTRAP'] = '1';
87
87
  e['CRTR_NO_AUTO_UPDATE'] = '1';
88
- e['CRTR_NO_BOOT_SKILL'] = '1';
89
- e['CRTR_NO_MODE_CMDS'] = '1';
88
+ e['CRTR_NO_EXPORTS'] = '1';
90
89
  e['CRTR_NO_AUTO_INIT'] = '1';
91
90
  return e;
92
91
  }
@@ -161,7 +160,7 @@ export async function createHarness(opts = {}) {
161
160
  // home-session.test.ts.) home/ is rmSync'd in dispose.
162
161
  writeFileSync(join(home, 'crtrd.pid'), String(process.pid), 'utf8');
163
162
  // Pre-create the isolated session on the DEFAULT server so teardown always
164
- // has a target and `node new`'s ensureSession no-ops.
163
+ // has a kill-sessions target.
165
164
  // ISOLATION ASSUMPTION (see header + MINOR-6): isolation is by SESSION NAME on
166
165
  // the DEFAULT tmux server only. The runtime CLI shells `tmux` with no `-L`, so
167
166
  // a custom-socket server (`tmux -L foo`) would be invisible to it; this harness
@@ -169,16 +168,14 @@ export async function createHarness(opts = {}) {
169
168
  // server.
170
169
  // HEADLESS: skip ALL eager tmux setup. No node in a headless run opens a
171
170
  // window, so there is no pane to pre-seed an env into and no session to pre-
172
- // create. (`node new --headless` may still lazily ensureSession() the isolated
173
- // name for home_session — dispose kill-sessions it regardless.)
171
+ // create.
174
172
  if (!headless) {
175
173
  spawnSync('tmux', ['new-session', '-d', '-s', session, '-c', CROUTER, 'sleep 100000'], {
176
174
  stdio: 'ignore',
177
175
  });
178
- // Put CRTR_PI_BINARY in the SESSION environment so EVERY pane spawned in this
179
- // session inherits it — critically the fake-pi's OWN process, so when its real
180
- // stophook fires reviveInPlace (respawn-pane -k on its own pane, the refresh-
181
- // yield path) the in-process piCommand there substitutes the fake-pi too.
176
+ // Put CRTR_PI_BINARY in the SESSION environment so EVERY pane/process spawned
177
+ // in this session inherits it — so any pi the runtime launches (boot, revive)
178
+ // resolves to the fake-pi vehicle, not a real pi.
182
179
  spawnSync('tmux', ['set-environment', '-t', session, 'CRTR_PI_BINARY', FAKE_PI_BINARY], {
183
180
  stdio: 'ignore',
184
181
  });
@@ -310,6 +307,7 @@ export async function createHarness(opts = {}) {
310
307
  lifecycle: o.lifecycle ?? 'terminal',
311
308
  parent: o.parent ?? null,
312
309
  pi_session_id: o.pi_session_id ?? null,
310
+ pi_session_file: o.pi_session_file ?? null,
313
311
  status: o.status ?? 'active',
314
312
  intent: o.intent ?? null,
315
313
  pi_pid: o.pi_pid ?? null,
@@ -339,7 +337,13 @@ export async function createHarness(opts = {}) {
339
337
  },
340
338
  async spawnHeadlessChild(parentId, task, o = {}) {
341
339
  const before = new Set(nodeDirs());
342
- const args = ['node', 'new', task, '--parent', parentId, '--cwd', CROUTER, '--headless'];
340
+ // POST-CUT: `--headless` is gone EVERY node is a broker. spawnChild may
341
+ // still open a background `crtr attach` viewer WINDOW (the §A.3 spawn UX),
342
+ // but that viewer connects to the broker over view.sock as a SEPARATE
343
+ // process and never touches the engine — so the broker-socket gates here
344
+ // (zero-viewer dialogs, controller arbitration, one-writer) are unaffected
345
+ // by it. The node itself is supervised by pid alone.
346
+ const args = ['node', 'new', task, '--parent', parentId, '--cwd', CROUTER];
343
347
  if (o.kind)
344
348
  args.push('--kind', o.kind);
345
349
  if (o.mode)
@@ -358,7 +362,7 @@ export async function createHarness(opts = {}) {
358
362
  },
359
363
  async spawnHeadlessChildNoBoot(parentId, task, o = {}) {
360
364
  const before = new Set(nodeDirs());
361
- const args = ['node', 'new', task, '--parent', parentId, '--cwd', CROUTER, '--headless'];
365
+ const args = ['node', 'new', task, '--parent', parentId, '--cwd', CROUTER];
362
366
  if (o.kind)
363
367
  args.push('--kind', o.kind);
364
368
  if (o.mode)
@@ -396,27 +400,6 @@ export async function createHarness(opts = {}) {
396
400
  await awaitAgentEnd(nodeId, base, `finish agent_end for ${nodeId}`);
397
401
  await harness.waitForPaneGone(nodeId);
398
402
  },
399
- async yieldNode(nodeId, note) {
400
- const res = cli(nodeId, ['node', 'yield', note]);
401
- if (res.code !== 0) {
402
- throw new Error(`yieldNode(${nodeId}): node yield failed (code ${res.code})\n${res.stderr}`);
403
- }
404
- // node yield set intent=refresh (active, kept). Fire agent_end so the real
405
- // (b') branch runs reviveInPlace (respawn-pane -k) IN the fake-pi's pane —
406
- // a FRESH fake-pi boots (resume); its session_start clears intent=refresh.
407
- const baseBoots = bootCount(nodeId);
408
- sendCmd(nodeId, { cmd: 'stop', id: `yield-${Date.now()}` });
409
- await waitFor(() => bootCount(nodeId) > baseBoots, {
410
- timeoutMs: 30_000,
411
- label: `fresh boot after yield for ${nodeId}`,
412
- });
413
- await waitFor(() => {
414
- closeDb();
415
- const n = getNode(nodeId);
416
- return n?.intent == null && n?.status === 'active';
417
- }, { timeoutMs: 20_000, label: `intent=refresh cleared after yield for ${nodeId}` });
418
- await harness.awaitBoot(nodeId, { minCount: baseBoots + 1 });
419
- },
420
403
  async tick(now) {
421
404
  closeDb();
422
405
  await superviseTick(now);
@@ -461,8 +444,8 @@ export async function createHarness(opts = {}) {
461
444
  async waitForPaneGone(nodeId, timeoutMs = 20_000) {
462
445
  await waitFor(() => {
463
446
  closeDb();
464
- return !isNodePaneAlive(nodeId);
465
- }, { timeoutMs, label: `pane gone for ${nodeId}` });
447
+ return !isFocused(nodeId);
448
+ }, { timeoutMs, label: `viewer gone for ${nodeId}` });
466
449
  },
467
450
  waitFor,
468
451
  node(nodeId) {
@@ -475,7 +458,7 @@ export async function createHarness(opts = {}) {
475
458
  },
476
459
  paneAlive(nodeId) {
477
460
  closeDb();
478
- return isNodePaneAlive(nodeId);
461
+ return isFocused(nodeId);
479
462
  },
480
463
  inbox(nodeId) {
481
464
  closeDb();
@@ -0,0 +1,83 @@
1
+ // Run with: node --import tsx/esm --test src/core/__tests__/human-node-not-supervised.test.ts
2
+ //
3
+ // BUG REGRESSION (issue #2 — "Headless node can't reach human-ask: human-kind
4
+ // TUI dies at boot"): a headless orchestrator's every `crtr human ask` node died
5
+ // at boot with "pi vehicle exited before the session came up".
6
+ //
7
+ // ROOT CAUSE: a kind:'human' row is NOT a broker-hosted agent node — it is a
8
+ // bookkeeping row for the `crtr human` bridge. Its decision/review TUI runs in a
9
+ // detached `crtr human _run` pane (NOT a pi engine), and the row's lifecycle is
10
+ // driven entirely by that worker's `pushFinal` (or `human cancel`). So it never
11
+ // records a `pi_pid` or `pi_session_id`. After the headless hard-cut made the
12
+ // broker the universal node host, superviseTick supervised EVERY active|idle row
13
+ // as a broker engine: it read the human row (pid==null, session==null) as a
14
+ // never-booted broker, waited out the boot grace, then crash'd it and fired
15
+ // surfaceBootFailure up the spine — killing the human prompt before the person
16
+ // could ever answer.
17
+ //
18
+ // THE FIX: superviseTick drops kind:'human' rows from all three passes. This
19
+ // drives the REAL daemon decision pass against a human row fabricated directly in
20
+ // an isolated home — two ticks spanning well past the boot grace, which is
21
+ // exactly what crashed it before the fix.
22
+ import { test, before, after, beforeEach } from 'node:test';
23
+ import assert from 'node:assert/strict';
24
+ import { mkdtempSync, rmSync } from 'node:fs';
25
+ import { tmpdir } from 'node:os';
26
+ import { join } from 'node:path';
27
+ import { createNode, getNode, subscribe } from '../canvas/canvas.js';
28
+ import { closeDb } from '../canvas/db.js';
29
+ import { readInboxSince } from '../feed/inbox.js';
30
+ import { superviseTick } from '../../daemon/crtrd.js';
31
+ let home;
32
+ function node(id, over = {}) {
33
+ return {
34
+ node_id: id,
35
+ name: id,
36
+ created: new Date().toISOString(),
37
+ cwd: '/tmp/work',
38
+ kind: 'general',
39
+ mode: 'base',
40
+ lifecycle: 'terminal',
41
+ status: 'active',
42
+ ...over,
43
+ };
44
+ }
45
+ before(() => {
46
+ home = mkdtempSync(join(tmpdir(), 'crtr-human-not-supervised-'));
47
+ process.env['CRTR_HOME'] = home;
48
+ });
49
+ beforeEach(() => {
50
+ closeDb();
51
+ rmSync(home, { recursive: true, force: true });
52
+ });
53
+ after(() => {
54
+ closeDb();
55
+ rmSync(home, { recursive: true, force: true });
56
+ delete process.env['CRTR_HOME'];
57
+ });
58
+ test('a kind:"human" bridge row is NEVER boot-failed by the daemon (it has no broker engine)', async () => {
59
+ // The exact shape spawnNode({kind:'human'}) mints: active, host_kind broker,
60
+ // but NO pi engine ever booted — so pi_pid and pi_session_id stay null.
61
+ createNode(node('HUMAN', {
62
+ kind: 'human',
63
+ lifecycle: 'terminal',
64
+ host_kind: 'broker',
65
+ pi_pid: null,
66
+ pi_session_id: null,
67
+ intent: null,
68
+ }));
69
+ // The asking node is auto-subscribed to its human bridge node at spawn, so a
70
+ // surfaceBootFailure (pushUrgent) would land in ASKER's inbox. Give it a booted
71
+ // session so the daemon leaves IT alone — it is only here to catch a push.
72
+ createNode(node('ASKER', { status: 'active', pi_session_id: 'booted' }));
73
+ subscribe('ASKER', 'HUMAN', true);
74
+ // Two ticks spanning WELL past the boot grace (REVIVE_GRACE_MS): tick 1 would
75
+ // arm the boot-grace clock, tick 2 (far in the future) would fire the crash +
76
+ // surfaceBootFailure. This is the precise sequence that killed every human-ask
77
+ // node before the fix.
78
+ const t0 = Date.now();
79
+ await superviseTick(t0);
80
+ await superviseTick(t0 + 10_000_000);
81
+ assert.equal(getNode('HUMAN').status, 'active', 'human bridge row stays alive — never crash-reaped as a dead broker');
82
+ assert.equal(readInboxSince('ASKER').length, 0, 'no surfaceBootFailure urgent push — the asker is never told its human prompt "never started"');
83
+ });
@@ -0,0 +1,108 @@
1
+ // Run with: node --import tsx/esm --test src/core/__tests__/human-stranded-deliver.test.ts
2
+ //
3
+ // BUG REGRESSION (human-ask broker round-trip): a headless-broker asker strips
4
+ // $TMUX, so the detached `crtr human _run` worker — whose `pushFinal` is the SOLE
5
+ // deliver-back + reap step — never spawned. Worse, the `crtr human inbox` drain
6
+ // path writes response.json directly (humanloop's inbox()) and ALSO never calls
7
+ // pushFinal, so an answered deck strands on disk: the asker never learns the
8
+ // answer and the bridge node leaks active forever.
9
+ //
10
+ // This locks the SECONDARY heal: finalizeResolvedInteraction must, for a still-
11
+ // LIVE bridge node whose interaction is resolved on disk, deliver the answer to
12
+ // the asker (parent auto-subscribed) and reap the bridge node — exactly what the
13
+ // _run worker's pushFinal would have done. Idempotent (no double-deliver once
14
+ // done); a canceled-on-disk response reaps without delivering a result.
15
+ import { test, before, after, beforeEach } from 'node:test';
16
+ import assert from 'node:assert/strict';
17
+ import { mkdtempSync, rmSync, mkdirSync } from 'node:fs';
18
+ import { tmpdir } from 'node:os';
19
+ import { join } from 'node:path';
20
+ import { createNode, subscribe, getNode } from '../canvas/canvas.js';
21
+ import { closeDb } from '../canvas/db.js';
22
+ import { readInboxSince } from '../feed/inbox.js';
23
+ import { interactionDir, interactionsRoot } from '../artifact.js';
24
+ import { atomicWriteJson, deckPath } from '@crouton-kit/humanloop';
25
+ import { finalizeResolvedInteraction } from '../../commands/human/queue.js';
26
+ let home;
27
+ // A unique cwd so interactionDir (keyed off the REAL homedir + mangled cwd, NOT
28
+ // CRTR_HOME) lands in an isolated, cleanable subtree.
29
+ let workCwd;
30
+ function node(id, parent) {
31
+ return {
32
+ node_id: id,
33
+ name: id,
34
+ created: new Date().toISOString(),
35
+ cwd: workCwd,
36
+ kind: 'developer',
37
+ mode: 'base',
38
+ lifecycle: 'terminal',
39
+ status: 'active',
40
+ parent,
41
+ };
42
+ }
43
+ /** Seed an interaction dir on disk the way the kickoff + a resolution would. */
44
+ function seed(jobId, run, response) {
45
+ const idir = interactionDir(jobId, workCwd);
46
+ mkdirSync(idir, { recursive: true });
47
+ atomicWriteJson(deckPath(idir), { interactions: [] });
48
+ atomicWriteJson(join(idir, 'run.json'), run);
49
+ atomicWriteJson(join(idir, 'response.json'), response);
50
+ }
51
+ before(() => {
52
+ home = mkdtempSync(join(tmpdir(), 'crtr-stranded-'));
53
+ process.env['CRTR_HOME'] = home;
54
+ workCwd = mkdtempSync(join(tmpdir(), 'crtr-stranded-cwd-'));
55
+ });
56
+ beforeEach(() => {
57
+ closeDb();
58
+ rmSync(home, { recursive: true, force: true });
59
+ rmSync(interactionsRoot(workCwd), { recursive: true, force: true });
60
+ });
61
+ after(() => {
62
+ closeDb();
63
+ rmSync(home, { recursive: true, force: true });
64
+ rmSync(interactionsRoot(workCwd), { recursive: true, force: true });
65
+ rmSync(workCwd, { recursive: true, force: true });
66
+ delete process.env['CRTR_HOME'];
67
+ });
68
+ test('answered-but-undelivered ask delivers back to the asker and reaps the bridge', async () => {
69
+ // Asker A, bridge B under A (parent auto-subscribes to its child bridge).
70
+ createNode(node('A', null));
71
+ createNode(node('B', 'A'));
72
+ subscribe('A', 'B');
73
+ seed('B', { mode: 'ask', job_id: 'B' }, { responses: [{ id: 'q', selectedOptionId: 'resync', freetext: 'go ahead' }], completedAt: '2026-06-10T22:27:07.000Z' });
74
+ const acted = await finalizeResolvedInteraction('B');
75
+ assert.equal(acted, true, 'should have delivered + reaped');
76
+ // Bridge reaped.
77
+ assert.equal(getNode('B')?.status, 'done', 'bridge node must be reaped to done');
78
+ // Answer fanned into the asker's inbox, carrying the choice.
79
+ const inbox = readInboxSince('A');
80
+ assert.equal(inbox.length >= 1, true, 'asker inbox must receive the answer pointer');
81
+ const blob = JSON.stringify(inbox);
82
+ assert.match(blob, /resync/, 'delivered answer should carry the human selection');
83
+ // Idempotent: a second sweep is a no-op (node already done — no double-deliver).
84
+ const again = await finalizeResolvedInteraction('B');
85
+ assert.equal(again, false, 'must not re-deliver an already-finalized interaction');
86
+ });
87
+ test('a canceled-on-disk response reaps the bridge without delivering a result', async () => {
88
+ createNode(node('A', null));
89
+ createNode(node('B', 'A'));
90
+ subscribe('A', 'B');
91
+ seed('B', { mode: 'ask', job_id: 'B' }, { canceled: true, canceledAt: '2026-06-10T22:42:04.000Z', reason: 'stale' });
92
+ const acted = await finalizeResolvedInteraction('B');
93
+ assert.equal(acted, true);
94
+ assert.equal(getNode('B')?.status, 'done', 'a canceled stranded bridge must still be reaped');
95
+ // The asker gets a quiet "no answer is coming" note, not a result.
96
+ const blob = JSON.stringify(readInboxSince('A'));
97
+ assert.match(blob, /no answer is coming/);
98
+ });
99
+ test('an unresolved (no response.json) live bridge is left untouched', async () => {
100
+ createNode(node('A', null));
101
+ createNode(node('B', 'A'));
102
+ subscribe('A', 'B');
103
+ const idir = interactionDir('B', workCwd);
104
+ mkdirSync(idir, { recursive: true });
105
+ atomicWriteJson(join(idir, 'run.json'), { mode: 'ask', job_id: 'B' });
106
+ assert.equal(await finalizeResolvedInteraction('B'), false);
107
+ assert.equal(getNode('B')?.status, 'active', 'still-pending bridge must stay live');
108
+ });
@@ -144,28 +144,10 @@ test('revive: any status → active + intent cleared', () => {
144
144
  }
145
145
  });
146
146
  // ---------------------------------------------------------------------------
147
- // boot → active, intent UNCHANGED (reviveInPlace keeps the refresh safety net),
148
- // legal only from active|idle
149
- // ---------------------------------------------------------------------------
150
- test('boot: active|idle → active, intent preserved (refresh net survives)', () => {
151
- for (const from of LIVE) {
152
- mk(`n_${from}`, from, 'refresh');
153
- const m = transition(`n_${from}`, 'boot');
154
- assert.equal(m.status, 'active', `boot from ${from}`);
155
- assert.equal(m.intent, 'refresh', `boot from ${from} keeps intent (proof-of-boot net)`);
156
- }
157
- });
158
- test('boot: illegal from done|dead|canceled → throws', () => {
159
- for (const from of TERMINAL) {
160
- mk(`n_${from}`, from);
161
- assert.throws(() => transition(`n_${from}`, 'boot'), /illegal lifecycle transition/);
162
- }
163
- });
164
- // ---------------------------------------------------------------------------
165
147
  // unknown node → throws for every event
166
148
  // ---------------------------------------------------------------------------
167
149
  test('transition on an unknown node throws', () => {
168
- for (const ev of ['finalize', 'cancel', 'crash', 'yield', 'release', 'revive', 'boot']) {
150
+ for (const ev of ['finalize', 'cancel', 'crash', 'yield', 'release', 'revive']) {
169
151
  assert.throws(() => transition('ghost', ev), /unknown node/);
170
152
  }
171
153
  });
@@ -14,8 +14,8 @@
14
14
  // mints a DIFFERENT fresh general/base/resident root.
15
15
  // • A4: a base→orchestrator yield auto-promotes (mode→orchestrator) and sets
16
16
  // intent=refresh but NEVER commits persona_ack — the drift is left pending
17
- // and, because a yield's agent_end goes straight to reviveInPlace with NO
18
- // preceding turn_end, the only steer-delivery site is bypassed.
17
+ // and, because a yield's agent_end goes straight to the refresh-revive with
18
+ // NO preceding turn_end, the only steer-delivery site is bypassed.
19
19
  // (2) WHY MODEL-LEVEL, NOT PANE/WINDOW — demote writes lifecycle on the row
20
20
  // (setLifecycle→updateNode); recycle's FINISH+MINT half is pushFinal +
21
21
  // spawnNode (pure model) — only its pane-respawn is tmux (a deliberately-
@@ -29,7 +29,7 @@
29
29
  // / pending-drift asserts go RED.
30
30
  //
31
31
  // STOPHOOK CAVEAT — split (option b). The original drove a LIVE fake-pi so the
32
- // REAL stophook enacted recycle's pane teardown and A4's reviveInPlace ack-drain
32
+ // REAL stophook enacted recycle's viewer teardown and A4's refresh-revive ack-drain
33
33
  // at agent_end. Fabrication cannot run those hooks. So: recycle is driven through
34
34
  // the real recycleNode (its FINISH+MINT model half runs fully; the pane respawn
35
35
  // is the only tmux artifact, intentionally skipped → recycled:false). For A4 the
@@ -117,7 +117,7 @@ test('node demote flips lifecycle→terminal IN PLACE; node recycle is FINISH+RE
117
117
  // ===========================================================================
118
118
  // (b) A4 BOUNDARY — promote-then-yield emits a steer that is discarded. A
119
119
  // `node yield` on a base node auto-promotes (mode→orchestrator, ack NOT
120
- // committed) and its agent_end goes STRAIGHT to reviveInPlace (b') with NO
120
+ // committed) and its agent_end goes STRAIGHT to the refresh-revive with NO
121
121
  // preceding turn_end — so the only steer-delivery site (turn_end) is
122
122
  // BYPASSED. The deterministic, host-independent facts the yield verb leaves
123
123
  // behind pin the boundary:
@@ -125,7 +125,7 @@ test('node demote flips lifecycle→terminal IN PLACE; node recycle is FINISH+RE
125
125
  // (2) intent=refresh set.
126
126
  // (3) persona_ack STILL base — the verb never commits it (only an injector
127
127
  // or the refresh drain does); the drift is left PENDING.
128
- // The ack's silent advance at the refresh drain (reviveInPlace→drainBearings
128
+ // The ack's silent advance at the refresh drain (refresh-revive→drainBearings
129
129
  // →commitPersonaAck) is locked by persona.test.ts; because no turn_end ever
130
130
  // fires on the yield path, that ack moves WITHOUT the orchestration steer
131
131
  // ever being delivered — the A4 LOSS.
@@ -35,7 +35,7 @@ let projectDir;
35
35
  let prevCwd;
36
36
  const PLUGIN = 'fixplug';
37
37
  function doc(name) {
38
- return `---\nkind: skill\nwhen-and-why-to-read: fixture doc ${name}\n---\n\n# ${name}\n`;
38
+ return `---\nkind: knowledge\nwhen-and-why-to-read: fixture doc ${name}\n---\n\n# ${name}\n`;
39
39
  }
40
40
  before(() => {
41
41
  prevCwd = process.cwd();
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,81 @@
1
+ // Bug-regression: on-read doc injection "fires a second time per session".
2
+ //
3
+ // The on-read substrate hook dedups so a doc surfaces at most once per
4
+ // conversation. That dedup set USED to live only in the pi process heap (cleared
5
+ // on session_start). But a node's logical session — the .jsonl transcript —
6
+ // spans MULTIPLE pi processes: a dormancy → revive(resume) cycle exits the old
7
+ // process and launches a fresh `pi --session` that REUSES the same transcript.
8
+ // The fresh process started with an empty set, so any doc already injected
9
+ // before dormancy got injected AGAIN on the next read.
10
+ //
11
+ // The fix persists the set to nodes/<id>/injected-docs.json: a resume rehydrates
12
+ // it (dedup holds), and only the FRESH-transcript launch paths clear it. These
13
+ // tests lock in both halves: the store round-trip, and the dedup surviving a
14
+ // simulated revive(resume) while re-surfacing after a fresh launch.
15
+ //
16
+ // Run: node --import tsx/esm --test src/core/__tests__/on-read-dedup-resume.test.ts
17
+ import { test, before, beforeEach, after } from 'node:test';
18
+ import assert from 'node:assert/strict';
19
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync } from 'node:fs';
20
+ import { tmpdir } from 'node:os';
21
+ import { join } from 'node:path';
22
+ import { closeDb } from '../canvas/db.js';
23
+ import { injectedDocsPath } from '../canvas/paths.js';
24
+ import { spawnNode } from '../runtime/nodes.js';
25
+ import { renderOnReadDocs } from '../substrate/on-read.js';
26
+ import { loadInjectedDocs, saveInjectedDocs, clearInjectedDocs, } from '../substrate/injected-store.js';
27
+ const FIXTURE_BODY = 'Fixture body that must surface exactly once.';
28
+ let home;
29
+ let work;
30
+ before(() => {
31
+ home = mkdtempSync(join(tmpdir(), 'crtr-onread-home-'));
32
+ process.env['CRTR_HOME'] = home;
33
+ });
34
+ beforeEach(() => {
35
+ closeDb();
36
+ rmSync(home, { recursive: true, force: true });
37
+ work = mkdtempSync(join(tmpdir(), 'crtr-onread-work-'));
38
+ });
39
+ after(() => {
40
+ closeDb();
41
+ rmSync(home, { recursive: true, force: true });
42
+ rmSync(work, { recursive: true, force: true });
43
+ delete process.env['CRTR_HOME'];
44
+ });
45
+ test('injected-store round-trips and clears', () => {
46
+ const node = spawnNode({ kind: 'general', cwd: work, parent: null }).node_id;
47
+ assert.deepEqual([...loadInjectedDocs(node)], [], 'cold load is empty');
48
+ saveInjectedDocs(node, new Set(['/a', '/b']));
49
+ assert.deepEqual([...loadInjectedDocs(node)].sort(), ['/a', '/b'], 'save then load round-trips');
50
+ clearInjectedDocs(node);
51
+ assert.ok(!existsSync(injectedDocsPath(node)), 'clear removes the file');
52
+ assert.deepEqual([...loadInjectedDocs(node)], [], 'load after clear is empty');
53
+ });
54
+ test('on-read doc surfaces once, stays deduped across a revive(resume), re-surfaces after a fresh launch', () => {
55
+ const node = spawnNode({ kind: 'general', cwd: work, parent: null }).node_id;
56
+ // A positional substrate doc in a `.crouter/memory/` ancestor of the read file.
57
+ const memDir = join(work, '.crouter', 'memory');
58
+ mkdirSync(memDir, { recursive: true });
59
+ writeFileSync(join(memDir, 'onread-fixture.md'), '---\nkind: knowledge\n' +
60
+ 'when-and-why-to-read: When reading work files, this reference should be read because it is the on-read regression fixture\n' +
61
+ 'file-read-visibility: content\n---\n' +
62
+ `${FIXTURE_BODY}\n`);
63
+ const readFile = join(work, 'src', 'file.ts');
64
+ mkdirSync(join(work, 'src'), { recursive: true });
65
+ writeFileSync(readFile, 'export const x = 1;\n');
66
+ // --- pi process #1: cold load; first read surfaces the doc ---
67
+ const seen = loadInjectedDocs(node);
68
+ const first = renderOnReadDocs(node, readFile, seen);
69
+ assert.match(first, new RegExp(FIXTURE_BODY.replace(/[.]/g, '\\.')), 'first read surfaces the doc body');
70
+ saveInjectedDocs(node, seen);
71
+ // Same process, repeat read of the same file → deduped in-memory.
72
+ assert.ok(!renderOnReadDocs(node, readFile, seen).includes(FIXTURE_BODY), 'same-process repeat read is deduped');
73
+ // --- dormancy → revive(resume): NEW process rehydrates the set from disk ---
74
+ const seenAfterRevive = loadInjectedDocs(node);
75
+ assert.deepEqual(seenAfterRevive, seen, 'revive(resume) rehydrates the persisted dedup set');
76
+ assert.ok(!renderOnReadDocs(node, readFile, seenAfterRevive).includes(FIXTURE_BODY), 'resumed process does NOT re-inject the doc (the bug)');
77
+ // --- fresh launch (revive resume=false, e.g. a refresh-yield) clears the set ---
78
+ clearInjectedDocs(node);
79
+ const seenFresh = loadInjectedDocs(node);
80
+ assert.ok(renderOnReadDocs(node, readFile, seenFresh).includes(FIXTURE_BODY), 'a fresh transcript surfaces the doc again');
81
+ });
@@ -0,0 +1,88 @@
1
+ // Run with: node --import tsx/esm --test src/core/__tests__/refresh-stall-recycle.test.ts
2
+ //
3
+ // BUG-REGRESSION (broker-universal-host cut, design §H refresh-authority; U7
4
+ // report mq8gq8we-4cadd8e6; stuck-refresh, parent mq7s5o93). A `node yield` /
5
+ // refresh records intent='refresh' and relies on the in-process stophook to call
6
+ // shutdown at stop. But the engine can be left ALIVE forever — it hangs at a huge
7
+ // context, OR a STALE in-process stophook (pi extensions never reload, so a
8
+ // days-old resident carries an old hook) ignores intent='refresh' and never calls
9
+ // shutdown. Engine alive + turn over + intent pending forever. The daemon only
10
+ // revives DEAD engines, so without §H the node is permanently stuck. The DAEMON
11
+ // is the authority: when intent='refresh' persists on a LIVE engine whose turn is
12
+ // over, past YIELD_STALL_GRACE_MS, it force-kills the engine (SIGTERM) so the
13
+ // dead-pid refresh path then revives it fresh.
14
+ //
15
+ // Two legs: the pure truth table (D4a — yieldStallVerdict) and the daemon
16
+ // integration over a real live pid the daemon must SIGTERM (D4b — superviseTick
17
+ // with an injected clock). Regression check: drop §H and D4b's "engine killed"
18
+ // assert goes RED (the live refresh pid lives forever, never recycled).
19
+ import { test } from 'node:test';
20
+ import assert from 'node:assert/strict';
21
+ import { spawn } from 'node:child_process';
22
+ import { createHeadlessHarness } from './helpers/harness.js';
23
+ import { yieldStallVerdict, YIELD_STALL_GRACE_MS, isPidAlive } from '../../daemon/crtrd.js';
24
+ // ---------------------------------------------------------------------------
25
+ // D4a — the pure refresh-stall truth table.
26
+ // ---------------------------------------------------------------------------
27
+ test('D4a yieldStallVerdict: kill ONLY a live, not-busy, refresh engine past the grace', () => {
28
+ const G = YIELD_STALL_GRACE_MS;
29
+ // The one 'kill' case: engine alive, intent=refresh, turn over (not busy), past grace.
30
+ assert.equal(yieldStallVerdict(true, 'refresh', false, G), 'kill', 'alive+refresh+!busy+>=grace → kill');
31
+ assert.equal(yieldStallVerdict(true, 'refresh', false, G + 60_000), 'kill', 'well past grace → kill');
32
+ // Within the grace → pending (the healthy stophook window).
33
+ assert.equal(yieldStallVerdict(true, 'refresh', false, null), 'pending', 'first observation → pending');
34
+ assert.equal(yieldStallVerdict(true, 'refresh', false, G - 1), 'pending', 'within grace → pending');
35
+ // A working engine is NEVER killed, even past the grace.
36
+ assert.equal(yieldStallVerdict(true, 'refresh', true, G + 60_000), 'leave', 'busy (mid-turn) → leave');
37
+ // Not a refresh → not our concern.
38
+ assert.equal(yieldStallVerdict(true, 'idle-release', false, G + 60_000), 'leave', 'intent≠refresh → leave');
39
+ assert.equal(yieldStallVerdict(true, null, false, G + 60_000), 'leave', 'no pending intent → leave');
40
+ // A dead/unknown pid is the ordinary revive path's job, not this one.
41
+ assert.equal(yieldStallVerdict(false, 'refresh', false, G + 60_000), 'leave', 'dead pid → leave (dead-pid path owns it)');
42
+ assert.equal(yieldStallVerdict(null, 'refresh', false, G + 60_000), 'leave', 'unknown pid → leave');
43
+ });
44
+ // ---------------------------------------------------------------------------
45
+ // D4b — daemon integration: a stuck refresh on a LIVE engine is force-recycled.
46
+ // ---------------------------------------------------------------------------
47
+ const T0 = 7_000_000;
48
+ const T_KILL = T0 + YIELD_STALL_GRACE_MS + 1; // one ms past the grace from t0
49
+ test('D4b daemon force-recycles a stuck refresh: a live, not-busy refresh engine past the grace is SIGTERM\'d', { timeout: 30_000 }, async () => {
50
+ const h = await createHeadlessHarness({ sessionPrefix: 'crtr-d4b' });
51
+ // A real live subprocess standing in for the hung/stale-hook engine. `sleep`
52
+ // dies on SIGTERM, so the daemon's force-kill is directly observable.
53
+ const child = spawn('sleep', ['300'], { stdio: 'ignore' });
54
+ const enginePid = child.pid;
55
+ try {
56
+ assert.equal(isPidAlive(enginePid), true, 'precondition: the refresh engine is alive');
57
+ // intent='refresh' + LIVE pid + turn over (no busy marker by default) →
58
+ // the §H stall state the daemon must break.
59
+ const id = h.fabricateBrokerNode({
60
+ kind: 'developer',
61
+ status: 'active',
62
+ intent: 'refresh',
63
+ pi_pid: enginePid,
64
+ pi_session_id: 'sess-d4b',
65
+ });
66
+ assert.equal(h.node(id).intent, 'refresh', 'precondition: intent=refresh pending');
67
+ // --- TICK @ t0: arms the stall clock (yieldStallSince). No kill yet. ---
68
+ await h.tick(T0);
69
+ assert.equal(isPidAlive(enginePid), true, 'within grace: the engine is NOT killed (clock just armed)');
70
+ // --- TICK past the grace: the daemon concludes the refresh stalled and
71
+ // SIGTERMs the engine itself (§H force-recycle). ---
72
+ await h.tick(T_KILL);
73
+ await h.waitFor(() => (isPidAlive(enginePid) ? null : true), {
74
+ label: 'daemon SIGTERM killed the stuck refresh engine',
75
+ timeoutMs: 10_000,
76
+ });
77
+ assert.equal(isPidAlive(enginePid), false, 'past grace: the daemon force-killed the stuck refresh engine (§H)');
78
+ }
79
+ finally {
80
+ try {
81
+ child.kill('SIGKILL');
82
+ }
83
+ catch {
84
+ /* already gone */
85
+ }
86
+ await h.dispose();
87
+ }
88
+ });
@@ -0,0 +1 @@
1
+ export {};