@crouton-kit/crouter 0.3.28 → 0.3.30

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 (324) hide show
  1. package/dist/build-root.d.ts +15 -7
  2. package/dist/build-root.js +48 -34
  3. package/dist/builtin-memory/crouter-development/marketplaces.md +7 -6
  4. package/dist/builtin-memory/crouter-development/personas/base-prompt.md +1 -1
  5. package/dist/builtin-memory/crouter-development/personas/orchestrator-prompt.md +1 -1
  6. package/dist/builtin-memory/crouter-development/personas.md +1 -1
  7. package/dist/builtin-memory/crouter-development/plugins.md +20 -18
  8. package/dist/builtin-memory/design.md +1 -1
  9. package/dist/builtin-memory/development.md +1 -1
  10. package/dist/builtin-memory/internal/INDEX.md +1 -1
  11. package/dist/builtin-memory/internal/examples/INDEX.md +1 -1
  12. package/dist/builtin-memory/internal/examples/imessage-assistant.md +2 -2
  13. package/dist/builtin-memory/internal/nodes-and-canvas.md +1 -1
  14. package/dist/builtin-memory/internal/storage-tiers.md +1 -1
  15. package/dist/builtin-memory/planning.md +1 -1
  16. package/dist/builtin-memory/spec.md +6 -4
  17. package/dist/builtin-personas/orchestration-kernel.md +4 -5
  18. package/dist/builtin-personas/runtime-base.md +5 -4
  19. package/dist/builtin-personas/spec/PERSONA.md +8 -4
  20. package/dist/builtin-personas/spec/orchestrator.md +5 -3
  21. package/dist/builtin-personas/spec/requirements/PERSONA.md +10 -0
  22. package/dist/builtin-views/canvas/core.mjs +586 -0
  23. package/dist/builtin-views/canvas/text.mjs +58 -0
  24. package/dist/builtin-views/canvas/tui.mjs +165 -0
  25. package/dist/builtin-views/canvas/web.jsx +120 -0
  26. package/dist/builtin-views/git-pr/core.mjs +673 -0
  27. package/dist/builtin-views/git-pr/text.mjs +84 -0
  28. package/dist/builtin-views/git-pr/tui.mjs +302 -0
  29. package/dist/builtin-views/git-pr/web.jsx +216 -0
  30. package/dist/builtin-views/inbox/_lib/render.mjs +1 -3
  31. package/dist/builtin-views/inbox/core.mjs +1273 -0
  32. package/dist/builtin-views/inbox/text.mjs +73 -0
  33. package/dist/builtin-views/inbox/tui.mjs +312 -0
  34. package/dist/builtin-views/inbox/web.jsx +188 -0
  35. package/dist/builtin-views/linkedin/core.mjs +906 -0
  36. package/dist/builtin-views/linkedin/text.mjs +69 -0
  37. package/dist/builtin-views/linkedin/tui.mjs +427 -0
  38. package/dist/builtin-views/linkedin/web.jsx +206 -0
  39. package/dist/builtin-views/workspace-sidebar/core.mjs +621 -0
  40. package/dist/builtin-views/workspace-sidebar/text.mjs +53 -0
  41. package/dist/builtin-views/workspace-sidebar/tui.mjs +142 -0
  42. package/dist/builtin-views/workspace-sidebar/web.jsx +109 -0
  43. package/dist/cli.js +22 -17
  44. package/dist/clients/attach/__tests__/action-parity.test.js +48 -0
  45. package/dist/clients/attach/__tests__/clipboard-image.test.js +76 -0
  46. package/dist/clients/attach/__tests__/editor-newline.test.js +57 -0
  47. package/dist/clients/attach/__tests__/git-info.test.js +31 -0
  48. package/dist/clients/attach/__tests__/onrequest-wired.test.js +20 -0
  49. package/dist/clients/attach/__tests__/slash-quit-copy.test.js +42 -0
  50. package/dist/clients/attach/__tests__/titled-editor.test.js +39 -0
  51. package/dist/clients/attach/attach-cmd.js +282 -46
  52. package/dist/clients/attach/auth-pickers.d.ts +9 -0
  53. package/dist/clients/attach/auth-pickers.js +194 -0
  54. package/dist/clients/attach/canvas-panels.d.ts +4 -2
  55. package/dist/clients/attach/canvas-panels.js +45 -17
  56. package/dist/clients/attach/chat-view.d.ts +27 -2
  57. package/dist/clients/attach/chat-view.js +98 -9
  58. package/dist/clients/attach/clipboard-image.d.ts +47 -8
  59. package/dist/clients/attach/clipboard-image.js +150 -26
  60. package/dist/clients/attach/clipboard-text.d.ts +3 -0
  61. package/dist/clients/attach/clipboard-text.js +39 -0
  62. package/dist/clients/attach/config-load.d.ts +54 -7
  63. package/dist/clients/attach/config-load.js +99 -3
  64. package/dist/clients/attach/context-message.d.ts +12 -0
  65. package/dist/clients/attach/context-message.js +72 -0
  66. package/dist/clients/attach/git-info.d.ts +20 -0
  67. package/dist/clients/attach/git-info.js +44 -0
  68. package/dist/clients/attach/graph-overlay.d.ts +13 -4
  69. package/dist/clients/attach/graph-overlay.js +59 -19
  70. package/dist/clients/attach/input-controller.d.ts +75 -13
  71. package/dist/clients/attach/input-controller.js +305 -67
  72. package/dist/clients/attach/pickers.d.ts +58 -0
  73. package/dist/clients/attach/pickers.js +171 -0
  74. package/dist/clients/attach/slash-commands.d.ts +22 -0
  75. package/dist/clients/attach/slash-commands.js +91 -25
  76. package/dist/clients/attach/titled-editor.d.ts +33 -0
  77. package/dist/clients/attach/titled-editor.js +84 -0
  78. package/dist/clients/attach/view-socket.d.ts +18 -1
  79. package/dist/clients/attach/view-socket.js +70 -1
  80. package/dist/clients/web/dev-server.d.ts +7 -0
  81. package/dist/clients/web/dev-server.js +59 -0
  82. package/dist/clients/web/events.d.ts +14 -0
  83. package/dist/clients/web/events.js +151 -0
  84. package/dist/clients/web/server.d.ts +18 -0
  85. package/dist/clients/web/server.js +450 -0
  86. package/dist/clients/web/web-cmd.d.ts +2 -0
  87. package/dist/clients/web/web-cmd.js +120 -0
  88. package/dist/commands/canvas.js +1 -2
  89. package/dist/commands/chord.js +4 -25
  90. package/dist/commands/dashboard.js +5 -1
  91. package/dist/commands/memory/__tests__/lint-schema.test.js +6 -6
  92. package/dist/commands/memory/lint.js +14 -2
  93. package/dist/commands/memory/read.js +2 -2
  94. package/dist/commands/memory/shared.d.ts +7 -1
  95. package/dist/commands/memory/shared.js +18 -3
  96. package/dist/commands/memory/write.js +8 -4
  97. package/dist/commands/memory.js +4 -4
  98. package/dist/commands/node.d.ts +1 -1
  99. package/dist/commands/node.js +64 -65
  100. package/dist/commands/pkg.js +1 -2
  101. package/dist/commands/revive.js +11 -3
  102. package/dist/commands/sys/sync.d.ts +1 -0
  103. package/dist/commands/sys/sync.js +187 -0
  104. package/dist/commands/sys.js +3 -2
  105. package/dist/commands/view-cycle.js +2 -2
  106. package/dist/commands/view-list.js +8 -8
  107. package/dist/commands/view-new.js +21 -17
  108. package/dist/commands/view-pick.js +1 -1
  109. package/dist/commands/view-run.js +35 -14
  110. package/dist/commands/view.js +8 -6
  111. package/dist/commands/workspace.d.ts +2 -0
  112. package/dist/commands/workspace.js +161 -0
  113. package/dist/core/__tests__/broker-double-spawn.test.d.ts +1 -0
  114. package/dist/core/__tests__/broker-double-spawn.test.js +143 -0
  115. package/dist/core/__tests__/broker-fork-seam.test.d.ts +1 -0
  116. package/dist/core/__tests__/broker-fork-seam.test.js +102 -0
  117. package/dist/core/__tests__/broker-sdk-wiring.test.js +2 -0
  118. package/dist/core/__tests__/child-death-wake.test.js +56 -111
  119. package/dist/core/__tests__/close.test.js +14 -20
  120. package/dist/core/__tests__/context-intro.test.js +19 -19
  121. package/dist/core/__tests__/daemon-boot.test.js +30 -23
  122. package/dist/core/__tests__/draw-style.test.js +1 -1
  123. package/dist/core/__tests__/error-stall-recycle.test.d.ts +1 -0
  124. package/dist/core/__tests__/error-stall-recycle.test.js +141 -0
  125. package/dist/core/__tests__/fixtures/fake-engine.d.ts +17 -1
  126. package/dist/core/__tests__/fixtures/fake-engine.js +22 -2
  127. package/dist/core/__tests__/fixtures/fake-pi-host.js +2 -2
  128. package/dist/core/__tests__/focuses.test.js +25 -13
  129. package/dist/core/__tests__/fork.test.js +22 -5
  130. package/dist/core/__tests__/full/broker-control-preempt.test.d.ts +1 -0
  131. package/dist/core/__tests__/full/broker-control-preempt.test.js +61 -0
  132. package/dist/core/__tests__/full/broker-model-changed-broadcast.test.d.ts +1 -0
  133. package/dist/core/__tests__/full/broker-model-changed-broadcast.test.js +57 -0
  134. package/dist/core/__tests__/full/broker-navigate-tree-rewelcome.test.d.ts +1 -0
  135. package/dist/core/__tests__/full/broker-navigate-tree-rewelcome.test.js +80 -0
  136. package/dist/core/__tests__/full/broker-pane-resolution.test.js +90 -0
  137. package/dist/core/__tests__/full/cascade-close.test.js +22 -8
  138. package/dist/core/__tests__/full/detach-focus.test.js +66 -169
  139. package/dist/core/__tests__/helpers/harness.d.ts +6 -9
  140. package/dist/core/__tests__/helpers/harness.js +19 -36
  141. package/dist/core/__tests__/human-node-not-supervised.test.d.ts +1 -0
  142. package/dist/core/__tests__/human-node-not-supervised.test.js +83 -0
  143. package/dist/core/__tests__/lifecycle.test.js +1 -19
  144. package/dist/core/__tests__/listing-completeness.test.js +2 -2
  145. package/dist/core/__tests__/live-mutation-verbs.test.js +5 -5
  146. package/dist/core/__tests__/memory-resolver.test.js +1 -1
  147. package/dist/core/__tests__/on-read-dedup-resume.test.js +2 -2
  148. package/dist/core/__tests__/refresh-stall-recycle.test.d.ts +1 -0
  149. package/dist/core/__tests__/refresh-stall-recycle.test.js +88 -0
  150. package/dist/core/__tests__/relaunch-root.test.d.ts +1 -0
  151. package/dist/core/__tests__/relaunch-root.test.js +114 -0
  152. package/dist/core/__tests__/reset.test.js +19 -79
  153. package/dist/core/__tests__/steer-note.test.js +9 -7
  154. package/dist/core/__tests__/tmux-surface.test.js +13 -7
  155. package/dist/core/bootstrap.d.ts +0 -3
  156. package/dist/core/bootstrap.js +1 -143
  157. package/dist/core/canvas/browse/__tests__/model.test.js +86 -1
  158. package/dist/core/canvas/browse/__tests__/render.test.js +2 -1
  159. package/dist/core/canvas/browse/app.js +131 -8
  160. package/dist/core/canvas/browse/model.d.ts +23 -5
  161. package/dist/core/canvas/browse/model.js +68 -13
  162. package/dist/core/canvas/browse/render.d.ts +7 -1
  163. package/dist/core/canvas/browse/render.js +178 -47
  164. package/dist/core/canvas/canvas.js +1 -1
  165. package/dist/core/canvas/focuses.d.ts +4 -4
  166. package/dist/core/canvas/focuses.js +7 -7
  167. package/dist/core/canvas/nav-model.js +8 -7
  168. package/dist/core/canvas/render.d.ts +68 -9
  169. package/dist/core/canvas/render.js +195 -61
  170. package/dist/core/canvas/types.d.ts +16 -23
  171. package/dist/core/config.js +1 -4
  172. package/dist/core/memory-resolver.js +43 -5
  173. package/dist/core/runtime/bearings.d.ts +1 -1
  174. package/dist/core/runtime/bearings.js +8 -8
  175. package/dist/core/runtime/branded-host.d.ts +16 -0
  176. package/dist/core/runtime/branded-host.js +127 -0
  177. package/dist/core/runtime/broker-protocol.d.ts +212 -6
  178. package/dist/core/runtime/broker.d.ts +4 -1
  179. package/dist/core/runtime/broker.js +494 -58
  180. package/dist/core/runtime/close.d.ts +12 -2
  181. package/dist/core/runtime/close.js +37 -16
  182. package/dist/core/runtime/front-door.js +6 -14
  183. package/dist/core/runtime/host.d.ts +14 -34
  184. package/dist/core/runtime/host.js +14 -50
  185. package/dist/core/runtime/launch.d.ts +12 -9
  186. package/dist/core/runtime/launch.js +27 -15
  187. package/dist/core/runtime/lifecycle.d.ts +1 -1
  188. package/dist/core/runtime/lifecycle.js +15 -19
  189. package/dist/core/runtime/nodes.d.ts +0 -55
  190. package/dist/core/runtime/nodes.js +6 -74
  191. package/dist/core/runtime/placement.d.ts +91 -335
  192. package/dist/core/runtime/placement.js +291 -858
  193. package/dist/core/runtime/promote.d.ts +2 -0
  194. package/dist/core/runtime/promote.js +24 -6
  195. package/dist/core/runtime/recap.d.ts +8 -0
  196. package/dist/core/runtime/recap.js +107 -0
  197. package/dist/core/runtime/recycle.js +25 -61
  198. package/dist/core/runtime/reset.d.ts +43 -37
  199. package/dist/core/runtime/reset.js +131 -218
  200. package/dist/core/runtime/revive.d.ts +9 -29
  201. package/dist/core/runtime/revive.js +40 -139
  202. package/dist/core/runtime/spawn.d.ts +15 -16
  203. package/dist/core/runtime/spawn.js +165 -194
  204. package/dist/core/runtime/surface-bg.d.ts +11 -0
  205. package/dist/core/runtime/surface-bg.js +68 -0
  206. package/dist/core/runtime/tmux.d.ts +15 -52
  207. package/dist/core/runtime/tmux.js +35 -99
  208. package/dist/core/scope.d.ts +1 -2
  209. package/dist/core/scope.js +1 -5
  210. package/dist/core/skill-sync/__tests__/dry-run-wrote-count.test.d.ts +1 -0
  211. package/dist/core/skill-sync/__tests__/dry-run-wrote-count.test.js +57 -0
  212. package/dist/core/skill-sync/builtins.d.ts +42 -0
  213. package/dist/core/skill-sync/builtins.js +112 -0
  214. package/dist/core/skill-sync/claude-plugins.d.ts +23 -0
  215. package/dist/core/skill-sync/claude-plugins.js +71 -0
  216. package/dist/core/skill-sync/engine.d.ts +42 -0
  217. package/dist/core/skill-sync/engine.js +633 -0
  218. package/dist/core/skill-sync/export.d.ts +23 -0
  219. package/dist/core/skill-sync/export.js +86 -0
  220. package/dist/core/skill-sync/manifest.d.ts +64 -0
  221. package/dist/core/skill-sync/manifest.js +181 -0
  222. package/dist/core/skill-sync/profile.d.ts +76 -0
  223. package/dist/core/skill-sync/profile.js +173 -0
  224. package/dist/core/skill-sync/snapshot.d.ts +57 -0
  225. package/dist/core/skill-sync/snapshot.js +120 -0
  226. package/dist/core/substrate/index.d.ts +1 -1
  227. package/dist/core/substrate/index.js +1 -1
  228. package/dist/core/substrate/injected-store.js +3 -3
  229. package/dist/core/substrate/on-read.js +68 -6
  230. package/dist/core/substrate/render.d.ts +8 -11
  231. package/dist/core/substrate/render.js +29 -43
  232. package/dist/core/substrate/schema.d.ts +10 -3
  233. package/dist/core/substrate/schema.js +6 -3
  234. package/dist/core/tui/host.d.ts +12 -4
  235. package/dist/core/tui/host.js +280 -149
  236. package/dist/core/view/bridge.d.ts +10 -0
  237. package/dist/core/view/bridge.js +31 -0
  238. package/dist/core/view/chrome.d.ts +9 -0
  239. package/dist/core/view/chrome.js +22 -0
  240. package/dist/core/view/contract.d.ts +171 -0
  241. package/dist/core/view/contract.js +23 -0
  242. package/dist/core/view/loader.d.ts +31 -0
  243. package/dist/core/view/loader.js +188 -0
  244. package/dist/core/view/transport-local.d.ts +7 -0
  245. package/dist/core/view/transport-local.js +70 -0
  246. package/dist/core/view/transport.d.ts +4 -0
  247. package/dist/core/view/transport.js +15 -0
  248. package/dist/daemon/crtrd.d.ts +38 -27
  249. package/dist/daemon/crtrd.js +303 -376
  250. package/dist/daemon/manage.js +6 -1
  251. package/dist/index.js +1 -1
  252. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +28 -20
  253. package/dist/pi-extensions/canvas-context-intro.js +4 -4
  254. package/dist/pi-extensions/canvas-doc-substrate.js +16 -16
  255. package/dist/pi-extensions/canvas-nav.js +5 -0
  256. package/dist/pi-extensions/canvas-recap.d.ts +37 -0
  257. package/dist/pi-extensions/canvas-recap.js +236 -0
  258. package/dist/pi-extensions/canvas-resume.js +2 -1
  259. package/dist/pi-extensions/canvas-stophook.d.ts +14 -12
  260. package/dist/pi-extensions/canvas-stophook.js +87 -101
  261. package/dist/pi-extensions/canvas-view.js +2 -1
  262. package/dist/pi-extensions/widget-order-bus.d.ts +6 -0
  263. package/dist/pi-extensions/widget-order-bus.js +34 -0
  264. package/dist/prompts/view.d.ts +2 -2
  265. package/dist/prompts/view.js +145 -73
  266. package/dist/types.d.ts +1 -6
  267. package/dist/types.js +1 -3
  268. package/dist/web/ViewChrome.d.ts +7 -0
  269. package/dist/web/ViewChrome.js +28 -0
  270. package/dist/web/ViewPane.d.ts +39 -0
  271. package/dist/web/ViewPane.js +48 -0
  272. package/dist/web/index.d.ts +6 -0
  273. package/dist/web/index.js +16 -0
  274. package/dist/web/runtime.d.ts +39 -0
  275. package/dist/web/runtime.js +133 -0
  276. package/dist/web/states.d.ts +24 -0
  277. package/dist/web/states.js +24 -0
  278. package/dist/web/transport-http.d.ts +5 -0
  279. package/dist/web/transport-http.js +28 -0
  280. package/dist/web-client/assets/index-BUvQb4hR.css +2 -0
  281. package/dist/web-client/assets/index-ClLQXYAE.js +10 -0
  282. package/dist/web-client/index.html +13 -0
  283. package/package.json +20 -6
  284. package/dist/builtin-views/canvas/client.mjs +0 -303
  285. package/dist/builtin-views/canvas/view.mjs +0 -576
  286. package/dist/builtin-views/git-pr/client.mjs +0 -440
  287. package/dist/builtin-views/git-pr/view.mjs +0 -675
  288. package/dist/builtin-views/inbox/sources/gmail.mjs +0 -965
  289. package/dist/builtin-views/inbox/sources/linkedin.mjs +0 -427
  290. package/dist/builtin-views/inbox/view.mjs +0 -889
  291. package/dist/builtin-views/linkedin/client.mjs +0 -610
  292. package/dist/builtin-views/linkedin/view.mjs +0 -1171
  293. package/dist/commands/pkg/bridge.d.ts +0 -1
  294. package/dist/commands/pkg/bridge.js +0 -137
  295. package/dist/commands/skill/author.d.ts +0 -3
  296. package/dist/commands/skill/author.js +0 -140
  297. package/dist/commands/skill/shared.d.ts +0 -3
  298. package/dist/commands/skill/shared.js +0 -19
  299. package/dist/commands/skill.d.ts +0 -2
  300. package/dist/commands/skill.js +0 -21
  301. package/dist/commands/tmux-spread.d.ts +0 -2
  302. package/dist/commands/tmux-spread.js +0 -144
  303. package/dist/core/__tests__/full/daemon-liveness-pane.full.test.js +0 -292
  304. package/dist/core/__tests__/full/placement-focus.test.js +0 -309
  305. package/dist/core/__tests__/full/placement-reconcile.test.js +0 -212
  306. package/dist/core/__tests__/full/placement-revive.test.js +0 -238
  307. package/dist/core/__tests__/full/placement-teardown.test.js +0 -297
  308. package/dist/core/__tests__/home-session.test.js +0 -186
  309. package/dist/core/__tests__/relaunch.test.js +0 -335
  310. package/dist/core/bridge-map.d.ts +0 -19
  311. package/dist/core/bridge-map.js +0 -73
  312. package/dist/core/tui/contract.d.ts +0 -83
  313. package/dist/core/tui/contract.js +0 -8
  314. package/dist/core/tui/loader.d.ts +0 -16
  315. package/dist/core/tui/loader.js +0 -94
  316. package/dist/prompts/skill.d.ts +0 -2
  317. package/dist/prompts/skill.js +0 -650
  318. /package/dist/{core/__tests__/full/daemon-liveness-pane.full.test.d.ts → clients/attach/__tests__/action-parity.test.d.ts} +0 -0
  319. /package/dist/{core/__tests__/full/placement-focus.test.d.ts → clients/attach/__tests__/clipboard-image.test.d.ts} +0 -0
  320. /package/dist/{core/__tests__/full/placement-reconcile.test.d.ts → clients/attach/__tests__/editor-newline.test.d.ts} +0 -0
  321. /package/dist/{core/__tests__/full/placement-revive.test.d.ts → clients/attach/__tests__/git-info.test.d.ts} +0 -0
  322. /package/dist/{core/__tests__/full/placement-teardown.test.d.ts → clients/attach/__tests__/onrequest-wired.test.d.ts} +0 -0
  323. /package/dist/{core/__tests__/home-session.test.d.ts → clients/attach/__tests__/slash-quit-copy.test.d.ts} +0 -0
  324. /package/dist/{core/__tests__/relaunch.test.d.ts → clients/attach/__tests__/titled-editor.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
+ });
@@ -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
  });
@@ -9,8 +9,8 @@
9
9
  import { test } from 'node:test';
10
10
  import assert from 'node:assert/strict';
11
11
  import { buildRoot } from '../../build-root.js';
12
- test('every non-hidden listing child declares description + whenToUse', () => {
13
- const root = buildRoot();
12
+ test('every non-hidden listing child declares description + whenToUse', async () => {
13
+ const root = await buildRoot();
14
14
  const missing = [];
15
15
  const walk = (branch, path) => {
16
16
  for (const child of branch.help.listing ?? []) {
@@ -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();
@@ -56,7 +56,7 @@ test('on-read doc surfaces once, stays deduped across a revive(resume), re-surfa
56
56
  // A positional substrate doc in a `.crouter/memory/` ancestor of the read file.
57
57
  const memDir = join(work, '.crouter', 'memory');
58
58
  mkdirSync(memDir, { recursive: true });
59
- writeFileSync(join(memDir, 'onread-fixture.md'), '---\nkind: reference\n' +
59
+ writeFileSync(join(memDir, 'onread-fixture.md'), '---\nkind: knowledge\n' +
60
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
61
  'file-read-visibility: content\n---\n' +
62
62
  `${FIXTURE_BODY}\n`);
@@ -74,7 +74,7 @@ test('on-read doc surfaces once, stays deduped across a revive(resume), re-surfa
74
74
  const seenAfterRevive = loadInjectedDocs(node);
75
75
  assert.deepEqual(seenAfterRevive, seen, 'revive(resume) rehydrates the persisted dedup set');
76
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 / reviveInPlace) clears the set ---
77
+ // --- fresh launch (revive resume=false, e.g. a refresh-yield) clears the set ---
78
78
  clearInjectedDocs(node);
79
79
  const seenFresh = loadInjectedDocs(node);
80
80
  assert.ok(renderOnReadDocs(node, readFile, seenFresh).includes(FIXTURE_BODY), 'a fresh transcript surfaces the doc again');
@@ -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 {};
@@ -0,0 +1,114 @@
1
+ // Run with: node --import tsx/esm --test src/core/__tests__/relaunch-root.test.ts
2
+ //
3
+ // Bug regression (commit 07e704e, "broker becomes universal host"): `/new` on a
4
+ // ROOT used to do an in-place SAME-id reset (resetRoot reaped + wiped + re-pointed
5
+ // the same node id) instead of starting a genuinely new node. Model B fixes it:
6
+ // relaunchRoot parks the old root `done` (kept as history, NOT canceled) and mints
7
+ // a FRESH node id + broker, re-pointing the viewer pane. These tests lock in that
8
+ // behavior via the injectable deps seam, so they run tmux-free and broker-free.
9
+ import { test, before, after, beforeEach } from 'node:test';
10
+ import assert from 'node:assert/strict';
11
+ import { mkdtempSync, rmSync } from 'node:fs';
12
+ import { tmpdir } from 'node:os';
13
+ import { join } from 'node:path';
14
+ import { createNode, getNode, subscribe } from '../canvas/canvas.js';
15
+ import { closeDb } from '../canvas/db.js';
16
+ import { relaunchRoot } from '../runtime/reset.js';
17
+ let home;
18
+ function node(id, over = {}) {
19
+ return {
20
+ node_id: id,
21
+ name: id,
22
+ created: new Date().toISOString(),
23
+ cwd: '/tmp/work',
24
+ kind: 'general',
25
+ mode: 'base',
26
+ lifecycle: 'terminal',
27
+ status: 'active',
28
+ ...over,
29
+ };
30
+ }
31
+ /** A tmux-free / broker-free deps seam. `pid` controls the new broker launch:
32
+ * a number = success, null = a boot failure. Records the node ids launchBroker
33
+ * was called with, so the boot-failure test can find the half-born new node. */
34
+ function makeDeps(pid) {
35
+ const launched = [];
36
+ const deps = {
37
+ launchBroker: (nodeId) => {
38
+ launched.push(nodeId);
39
+ return { pid };
40
+ },
41
+ waitForViewSocket: () => true,
42
+ respawnViewer: () => true,
43
+ teardownBroker: () => { },
44
+ };
45
+ return { deps, launched };
46
+ }
47
+ before(() => {
48
+ home = mkdtempSync(join(tmpdir(), 'crtr-relaunch-'));
49
+ process.env['CRTR_HOME'] = home;
50
+ });
51
+ beforeEach(() => {
52
+ closeDb();
53
+ rmSync(home, { recursive: true, force: true });
54
+ });
55
+ after(() => {
56
+ closeDb();
57
+ rmSync(home, { recursive: true, force: true });
58
+ delete process.env['CRTR_HOME'];
59
+ });
60
+ test('relaunchRoot parks the old root done and mints a fresh active root, reaping descendants', () => {
61
+ // root → child → grandchild (a parent that subscribes to its workers).
62
+ createNode(node('root', { parent: null, lifecycle: 'resident', cwd: '/tmp/proj' }));
63
+ createNode(node('child', { parent: 'root' }));
64
+ createNode(node('grand', { parent: 'child' }));
65
+ subscribe('root', 'child', true);
66
+ subscribe('child', 'grand', true);
67
+ const { deps, launched } = makeDeps(4242);
68
+ const res = relaunchRoot('root', deps);
69
+ // A fresh, DIFFERENT node id was minted and booted.
70
+ assert.ok(res, 'relaunchRoot returns the new node id');
71
+ const newId = res.newNodeId;
72
+ assert.notEqual(newId, 'root', 'a genuinely NEW node id (not a same-id reset — the bug)');
73
+ assert.deepEqual(launched, [newId], 'the new node id is the one we booted a broker for');
74
+ // (1) Old root parked DONE — kept as history, NOT canceled (explicit CTO call).
75
+ assert.equal(getNode('root')?.status, 'done', 'old root parked done (kept as history)');
76
+ // (2) The new node is an active, top-level resident inheriting the old root's
77
+ // kind + cwd.
78
+ const fresh = getNode(newId);
79
+ assert.equal(fresh?.status, 'active', 'new node is active');
80
+ assert.equal(fresh?.parent, null, 'new node is a root (no spine parent)');
81
+ assert.equal(fresh?.kind, 'general', 'new node inherits the old root kind');
82
+ assert.equal(fresh?.cwd, '/tmp/proj', 'new node inherits the old root cwd');
83
+ assert.equal(fresh?.lifecycle, 'resident', 'a relaunched root is resident');
84
+ // (3) Descendants reaped → canceled (A5: an externally-reaped node did not
85
+ // finish its OWN work; done is reserved for finalize).
86
+ assert.equal(getNode('child')?.status, 'canceled', 'descendant child canceled');
87
+ assert.equal(getNode('grand')?.status, 'canceled', 'descendant grandchild canceled');
88
+ });
89
+ test('relaunchRoot on a boot failure leaves the old root + descendants fully intact', () => {
90
+ // The mint-and-boot-FIRST ordering: a launch failure (pid=null) must occur
91
+ // BEFORE the old root is parked or its descendants reaped.
92
+ createNode(node('root', { parent: null, lifecycle: 'resident' }));
93
+ createNode(node('child', { parent: 'root' }));
94
+ subscribe('root', 'child', true);
95
+ const { deps, launched } = makeDeps(null); // broker never starts
96
+ const res = relaunchRoot('root', deps);
97
+ assert.equal(res, null, 'a boot failure returns null');
98
+ // Old root + descendant untouched: still live, ready to keep running.
99
+ assert.equal(getNode('root')?.status, 'active', 'old root left active + untouched');
100
+ assert.equal(getNode('child')?.status, 'active', 'descendant left active + untouched');
101
+ // The half-born new node was crashed (so the daemon never watches a zombie).
102
+ assert.equal(launched.length, 1, 'one broker launch was attempted');
103
+ assert.equal(getNode(launched[0])?.status, 'dead', 'the half-born new node is crashed (dead)');
104
+ });
105
+ test('relaunchRoot is a no-op for a non-root child and an already-parked root', () => {
106
+ createNode(node('root', { parent: null, lifecycle: 'resident' }));
107
+ createNode(node('child', { parent: 'root' }));
108
+ createNode(node('done-root', { parent: null, status: 'done' }));
109
+ const { deps, launched } = makeDeps(1);
110
+ assert.equal(relaunchRoot('child', deps), null, 'a child is not a relaunchable root');
111
+ assert.equal(relaunchRoot('done-root', deps), null, 'a done root is not relaunched again (double /new)');
112
+ assert.equal(relaunchRoot('ghost', deps), null, 'an unknown node is a no-op');
113
+ assert.deepEqual(launched, [], 'no broker was booted for any of the no-op cases');
114
+ });