@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
@@ -20,11 +20,22 @@
20
20
  // tears the broker down, and boots a fresh BROKER root the pane re-attaches
21
21
  // to. LOCKED: the recycled node is finalized (done) and the fresh root is
22
22
  // broker-hosted, not tmux.
23
+ //
24
+ // (3) Gap 3 (tests-lens MAJOR-1) — the §A.4 viewer-DEDUP, the headline new
25
+ // behavior of the broker-universal cut, had ZERO end-to-end coverage: a
26
+ // SECOND `node focus` on a node must REUSE/move its one viewer, never STACK a
27
+ // second. Only the DB UNIQUE(node_id)/GC layer was tested; `focus()` itself
28
+ // (placement.ts) was unexercised. LOCKED here over a REAL broker viewer: two
29
+ // `node focus` calls leave EXACTLY ONE viewer pane + ONE focus row for the
30
+ // node, the second reused IN PLACE (in_place=true). A focus() that always
31
+ // opened a fresh pane would yield two tagged panes (or crash on the UNIQUE
32
+ // constraint) — either way this goes RED.
23
33
  import { test, before, after } from 'node:test';
24
34
  import assert from 'node:assert/strict';
25
35
  import { spawnSync } from 'node:child_process';
26
36
  import { createHarness, hasTmux } from '../helpers/harness.js';
27
37
  import { closeDb } from '../../canvas/db.js';
38
+ import { getFocusByNode, listFocuses } from '../../canvas/focuses.js';
28
39
  import { nodeInPane } from '../../../commands/node.js';
29
40
  import { recycleNode } from '../../runtime/recycle.js';
30
41
  let h;
@@ -47,6 +58,43 @@ function makePane() {
47
58
  function tagPane(pane, value) {
48
59
  spawnSync('tmux', ['set-option', '-p', '-t', pane, '@crtr_node', value], { stdio: 'ignore' });
49
60
  }
61
+ /** Parse `crtr`'s human-list stdout (`- key: value` lines) into a map. */
62
+ function parseList(stdout) {
63
+ const out = {};
64
+ for (const line of stdout.split('\n')) {
65
+ const m = /^- ([^:]+):\s*(.*)$/.exec(line.trim());
66
+ if (m)
67
+ out[m[1].trim()] = m[2].trim();
68
+ }
69
+ return out;
70
+ }
71
+ /** Read a pane's `@crtr_node` option ('' when unset / pane gone). */
72
+ function paneTag(pane) {
73
+ const r = spawnSync('tmux', ['show-options', '-p', '-v', '-t', pane, '@crtr_node'], { encoding: 'utf8' });
74
+ return (r.stdout ?? '').trim();
75
+ }
76
+ /** Every pane in the harness session whose `@crtr_node` tag names `id` — the live
77
+ * on-screen viewer panes for that node. A correct dedup keeps this at exactly 1. */
78
+ function taggedViewerPanes(session, id) {
79
+ const r = spawnSync('tmux', ['list-panes', '-s', '-t', session, '-F', '#{pane_id} #{@crtr_node}'], { encoding: 'utf8' });
80
+ return (r.stdout ?? '')
81
+ .split('\n')
82
+ .map((l) => l.trim())
83
+ .filter((l) => l.endsWith(` ${id}`))
84
+ .map((l) => l.split(' ')[0]);
85
+ }
86
+ /** Wait until the attach client running in `pane` has self-tagged it `@crtr_node=id`
87
+ * (the tag is set on CONNECT, not synchronously by openViewerWindow — the second
88
+ * focus's reuse branch keys on it, so the test must let attach connect first). */
89
+ async function waitForTag(pane, id, timeoutMs = 15_000) {
90
+ const deadline = Date.now() + timeoutMs;
91
+ while (Date.now() < deadline) {
92
+ if (paneTag(pane) === id)
93
+ return;
94
+ await new Promise((r) => setTimeout(r, 150));
95
+ }
96
+ throw new Error(`viewer pane ${pane} never tagged @crtr_node=${id} within ${timeoutMs}ms`);
97
+ }
50
98
  test('Gap 1 — nodeInPane resolves a broker node from its tagged viewer pane (undefined when untagged)', async (t) => {
51
99
  if (!hasTmux())
52
100
  return t.skip('tmux unavailable');
@@ -82,3 +130,45 @@ test('Gap 2 — recycle preserves the broker host: finalizes the node, boots a f
82
130
  assert.equal(fresh.host_kind, 'broker', 'fresh root PRESERVES the broker host (not a tmux pi root)');
83
131
  assert.equal(fresh.parent ?? null, null, 'fresh root is a root (parent=null)');
84
132
  });
133
+ test('Gap 3 — a second `node focus` REUSES the one viewer (in place), never stacks a second pane/row', async (t) => {
134
+ if (!hasTmux())
135
+ return t.skip('tmux unavailable');
136
+ const broker = await h.spawnHeadlessChild(root, 'broker to focus twice');
137
+ closeDb();
138
+ assert.equal(h.node(broker).host_kind, 'broker', 'precondition: broker-hosted node');
139
+ // A caller pane in the harness session to focus BESIDE (the user's pane the
140
+ // viewer splits next to). The viewer lands in this same session, so the SECOND
141
+ // focus's reuse takes the in-session NAVIGATE path (in_place), not the
142
+ // cross-session MOVE path.
143
+ const caller = makePane();
144
+ // FOCUS #1 — a managed child boots with NO viewer (spawn no longer auto-opens
145
+ // one), so this OPENS the broker's single viewer beside the caller.
146
+ const f1 = h.cli(broker, ['node', 'focus', broker, '--pane', caller]);
147
+ assert.equal(f1.code, 0, `first focus exit 0\n${f1.stderr}`);
148
+ assert.equal(parseList(f1.stdout).focused, 'true', 'first focus brought the viewer on screen');
149
+ closeDb();
150
+ const row1 = getFocusByNode(broker);
151
+ assert.ok(row1 !== null && row1.pane !== null, 'focus #1 registered one viewer focus row');
152
+ const pane1 = row1.pane;
153
+ assert.equal(listFocuses().filter((f) => f.node_id === broker).length, 1, 'exactly ONE focus row for the node after focus #1');
154
+ // Let the attach client connect + self-tag the pane BEFORE the second focus —
155
+ // the reuse branch keys on `@crtr_node`; without the tag focus() would (correctly)
156
+ // treat the row as stale and open fresh. This wait is a test-timing concern, not
157
+ // a product gap (in real use the second focus is a much-later user action).
158
+ await waitForTag(pane1, broker);
159
+ assert.deepEqual(taggedViewerPanes(h.session, broker), [pane1], 'exactly ONE tagged viewer pane on screen after focus #1');
160
+ // FOCUS #2 — the headline dedup: the node already has a live, tagged viewer in
161
+ // the caller's session, so focus NAVIGATES to it (no new pane), never stacks.
162
+ const f2 = h.cli(broker, ['node', 'focus', broker, '--pane', caller]);
163
+ assert.equal(f2.code, 0, `second focus exit 0\n${f2.stderr}`);
164
+ const f2out = parseList(f2.stdout);
165
+ assert.equal(f2out.focused, 'true', 'second focus also on screen');
166
+ assert.equal(f2out.in_place, 'true', 'second focus REUSED the viewer in place (navigate, no new pane)');
167
+ // The invariant: STILL exactly one viewer pane + one focus row, the SAME pane.
168
+ closeDb();
169
+ const row2 = getFocusByNode(broker);
170
+ assert.ok(row2 !== null, 'the node still has its viewer focus row after focus #2');
171
+ assert.equal(row2.pane, pane1, 'focus #2 reused the SAME viewer pane (not a fresh one)');
172
+ assert.equal(listFocuses().filter((f) => f.node_id === broker).length, 1, 'STILL exactly ONE focus row for the node after focus #2 (never stacked a second)');
173
+ assert.deepEqual(taggedViewerPanes(h.session, broker), [pane1], 'STILL exactly ONE tagged viewer pane after focus #2 (reused, not stacked)');
174
+ });
@@ -14,7 +14,13 @@
14
14
  // MIDDLE node with a live sideways-sibling subtree. This test fills exactly that
15
15
  // gap: it builds a real tree, closes the middle, and proves the reap is EXACTLY
16
16
  // the closed node's own subtree — nothing above (the ancestor) or sideways (a
17
- // sibling subtree) is touched, and real panes actually die.
17
+ // sibling subtree) is touched, and the real broker engines actually die.
18
+ //
19
+ // VIEWER MODEL (a09b71f): a managed child boots as a detached headless BROKER
20
+ // with NO viewer pane on spawn (a viewer is opened on demand via `node focus`).
21
+ // So "the node is up" is broker-engine liveness — its recorded `pi_pid` is alive
22
+ // — NOT pane presence. close tears that broker engine down (headlessBrokerHost
23
+ // .teardown over view.sock), so we assert pid liveness, not panes.
18
24
  //
19
25
  // The tree (covers BOTH required shapes in one topology):
20
26
  //
@@ -47,6 +53,7 @@ import { test } from 'node:test';
47
53
  import assert from 'node:assert/strict';
48
54
  import { spawnSync } from 'node:child_process';
49
55
  import { createHarness, hasTmux } from '../helpers/harness.js';
56
+ import { isPidAlive } from '../../canvas/pid.js';
50
57
  function sessionExists(session) {
51
58
  return spawnSync('tmux', ['has-session', '-t', session], { stdio: 'ignore' }).status === 0;
52
59
  }
@@ -72,7 +79,8 @@ test('cascade close: middle-node close reaps EXACTLY its subtree (canceled), anc
72
79
  {
73
80
  for (const id of [B, C, D, E, S, G]) {
74
81
  assert.equal(h.status(id), 'active', `${id} active before close`);
75
- assert.equal(h.paneAlive(id), true, `${id} has a live pane before close`);
82
+ const pid = h.node(id).pi_pid;
83
+ assert.ok(pid != null && isPidAlive(pid), `${id} broker engine alive before close`);
76
84
  }
77
85
  assert.equal(h.status(A), 'active', 'A (root) active before close');
78
86
  // Spine: A→{B,S}, B→{C,D}, C→{E}, S→{G} (all active spawn-seed edges).
@@ -124,12 +132,17 @@ test('cascade close: middle-node close reaps EXACTLY its subtree (canceled), anc
124
132
  assert.deepEqual({ status: e.status, intent: e.intent ?? null }, { status: 'canceled', intent: null }, "deepest reaped descendant E: (status,intent) === ('canceled', null)");
125
133
  }
126
134
  // ===================================================================
127
- // ASSERT 2 — real panes destroyed for the ENTIRE subtree (invariant 12-ish:
128
- // a torn-down node owns no pane). tearDownNode killed each real tmux pane.
135
+ // ASSERT 2 — broker ENGINE torn down for the ENTIRE subtree (the real
136
+ // "node is up" invariant now: a closed node owns no live broker process).
137
+ // close → headlessBrokerHost.teardown shut each broker down over view.sock.
129
138
  // ===================================================================
130
139
  for (const id of subtree) {
131
- await h.waitForPaneGone(id);
132
- assert.equal(h.paneAlive(id), false, `${id} real pane destroyed by close`);
140
+ await h.waitFor(() => {
141
+ const pid = h.node(id)?.pi_pid ?? null;
142
+ return pid === null || !isPidAlive(pid);
143
+ }, { label: `${id} broker engine torn down by close` });
144
+ const pid = h.node(id)?.pi_pid ?? null;
145
+ assert.ok(pid === null || !isPidAlive(pid), `${id} broker engine destroyed by close`);
133
146
  }
134
147
  // ===================================================================
135
148
  // ASSERT 3 — EXCLUSIVE-SUBTREE OWNERSHIP: nothing ABOVE (A) and nothing
@@ -153,10 +166,11 @@ test('cascade close: middle-node close reaps EXACTLY its subtree (canceled), anc
153
166
  assert.equal(added[0].from, B, 'the new entry is from the closed child B');
154
167
  assert.match(added[0].label, /closed/i, 'the new entry tells A its child B was closed');
155
168
  }
156
- // Sideways subtree S→G — wholly alive: status active AND real panes alive.
169
+ // Sideways subtree S→G — wholly alive: status active AND broker engine alive.
157
170
  for (const id of sideways) {
158
171
  assert.equal(h.status(id), 'active', `${id} (sideways) still active — not reaped`);
159
- assert.equal(h.paneAlive(id), true, `${id} (sideways) real pane still alive`);
172
+ const pid = h.node(id).pi_pid;
173
+ assert.ok(pid != null && isPidAlive(pid), `${id} (sideways) broker engine still alive`);
160
174
  }
161
175
  // The strong "EXACTLY its own subtree" closure: every node in the graph is
162
176
  // either in B's subtree (canceled) or untouched (active) — no over-reach.
@@ -1,204 +1,101 @@
1
- // Run with: node --import tsx/esm --test src/core/__tests__/detach-focus.test.ts
1
+ // Run with: node --import tsx/esm --test src/core/__tests__/full/detach-focus.test.ts
2
2
  //
3
- // GAP CLOSE (flagship-lifecycle.test.ts coverage boundary): "node demote
4
- // --detach (A3: orphaned-focus-row hazard)". An END-TO-END test that drives the
5
- // REAL `crtr node demote --detach` verb (subprocess, AS the node) — the actual
6
- // Alt+C → D menu path — against a live fake-pi node genuinely FOCUSED in a user
7
- // viewport, and
8
- // proves the three things the A3 hazard is about:
9
- // (a) lifecycle flips terminal IN PLACE,
10
- // (b) the still-running pi's pane is RELOCATED to the backstage crtr session
11
- // (the pi keeps generating off-screen, not killed),
12
- // (c) the focus row it occupied is CLOSED — no orphaned/phantom viewport row
13
- // lingering on the relocated %pane_id (Invariant P / Invariant F5).
3
+ // `crtr node demote --detach` on a node with a live viewer (Alt+C → D).
14
4
  //
15
- // This is the harness-driven counterpart to placement-focus.test.ts's
16
- // `detachToBackground` unit (which calls the function directly): here the FLIP
17
- // and the detach both go through the real CLI leaf (node.ts nodeDemote
18
- // setLifecycle), so the whole `crtr node demote --detach` wiring the Alt+C D
19
- // menu path is exercised. Modeled on live-mutation.test.ts (harness +
20
- // `crtr node demote` + firstPaneOf) and
21
- // placement-focus.test.ts (user/back sessions + focus-row asserts).
5
+ // BROKER-CUT ADAPTATION (taste/broker-is-the-host, 2026-06-10): every node is a
6
+ // detached headless broker. After a09b71f a managed child opens NO viewer on
7
+ // spawn a viewer is opened on demand via `node focus`, registering its
8
+ // `crtr attach` VIEWER pane in the focuses table. The engine is NOT in a pane, so the
9
+ // pre-cut "relocate the still-running pi pane to the backstage" behavior —
10
+ // detachToBackground's engine break-pane/release path, and its isGenerating()
11
+ // gate that released a non-generating node to dormant — is DELETED. The Bug-1
12
+ // non-generating-release test that locked that gate is deleted with it (the
13
+ // behavior no longer exists; nothing to invent coverage for).
22
14
  //
23
- // NOTE the SIBLING focused-finish→manager-TAKEOVER path is NOT added here: the
24
- // harness mints its root via in-process createNode (never a real bootRoot see
25
- // flagship S1), so a manager is always a paneless, never-booted row. Driving B
26
- // through agent_end's done-branch (h.finish) with such a manager would only ever
27
- // hit handFocusToManager's "live-but-paneless inline root false" guard
28
- // (closeFocusToShell), never a genuine takeover; a LIVE-backstage or dormant
29
- // idle-release manager swap is unreachable through the harness. That swap is
30
- // unit-covered in placement-teardown.test.ts (MAJOR 1 + the dormant idle-release
31
- // case), so it is deliberately not re-driven here.
15
+ // What SURVIVES and is locked here: `node demote --detach` flips the node
16
+ // terminal IN PLACE and CLOSES its viewer pane + focus row, leaving the broker
17
+ // ENGINE running untouched (reconnectable by a later `focus`). This drives the
18
+ // REAL verb (subprocess, AS the node) the actual Alt+C D menu path — against
19
+ // a live broker node genuinely focused in a viewer pane, and proves:
20
+ // (a) lifecycle flips terminal in place,
21
+ // (b) the broker engine keeps running (pi_pid alive, unchanged NOT killed),
22
+ // (c) the viewer pane is closed + its focus row dropped (no orphaned/phantom
23
+ // viewport row Invariant P / F5).
32
24
  import { test } from 'node:test';
33
25
  import assert from 'node:assert/strict';
34
26
  import { spawnSync } from 'node:child_process';
35
27
  import { createHarness, hasTmux } from '../helpers/harness.js';
36
- import { setPresence, getNode } from '../../canvas/canvas.js';
37
- import { openFocusRow, getFocusByNode, getFocusByPane, listFocuses } from '../../canvas/focuses.js';
28
+ import { getNode } from '../../canvas/canvas.js';
29
+ import { getFocusByNode, getFocusByPane, listFocuses } from '../../canvas/focuses.js';
38
30
  import { closeDb } from '../../canvas/db.js';
39
- import { markBusy } from '../../runtime/busy.js';
31
+ import { isPidAlive } from '../../canvas/pid.js';
40
32
  const SKIP = !hasTmux() ? 'tmux unavailable' : false;
41
33
  function sessionExists(session) {
42
34
  return spawnSync('tmux', ['has-session', '-t', session], { stdio: 'ignore' }).status === 0;
43
35
  }
44
- function tmuxOut(args) {
45
- return (spawnSync('tmux', args, { encoding: 'utf8' }).stdout ?? '').trim();
46
- }
47
- /** The first %pane_id of a tmux window (spawn records window+session, not pane). */
48
- function firstPaneOf(window) {
49
- const r = spawnSync('tmux', ['list-panes', '-t', window, '-F', '#{pane_id}'], { encoding: 'utf8' });
50
- if (r.status !== 0)
51
- return null;
52
- return r.stdout.split('\n').map((s) => s.trim()).filter(Boolean)[0] ?? null;
53
- }
54
- /** A pane's CURRENT session/window (display-message on its durable %id). */
55
- function paneLoc(pane) {
56
- const out = tmuxOut(['display-message', '-p', '-t', pane, '#{session_name}\t#{window_id}']);
57
- const [session, window] = out.split('\t');
58
- if (!session || !window)
59
- return null;
60
- return { session, window };
61
- }
62
- function paneSessionReal(pane) {
63
- return tmuxOut(['display-message', '-p', '-t', pane, '#{session_name}']);
64
- }
65
36
  function paneExistsReal(pane) {
66
- return tmuxOut(['display-message', '-p', '-t', pane, '#{pane_id}']) === pane;
37
+ return ((spawnSync('tmux', ['display-message', '-p', '-t', pane, '#{pane_id}'], { encoding: 'utf8' }).stdout ?? '').trim() ===
38
+ pane);
39
+ }
40
+ // Split a throwaway pane in the harness session; return its %id. The caller pane
41
+ // `node focus` opens the viewer beside (a09b71f: spawn no longer auto-opens one).
42
+ function makePane(session) {
43
+ const r = spawnSync('tmux', ['split-window', '-d', '-t', session, '-P', '-F', '#{pane_id}', 'sleep 100000'], { encoding: 'utf8' });
44
+ const pane = (r.stdout ?? '').trim();
45
+ assert.ok(pane.startsWith('%'), `expected a %pane_id, got "${pane}" (stderr: ${r.stderr})`);
46
+ return pane;
67
47
  }
68
48
  // ===========================================================================
69
- // `crtr node demote --detach` on a FOCUSED node (A3 gap close; Alt+C → D).
49
+ // `crtr node demote --detach` on a node with a live viewer (Alt+C → D).
70
50
  // ===========================================================================
71
- test('node demote --detach on a FOCUSED node: flips terminal, relocates the pane to the backstage, CLOSES the focus row (A3 no orphaned focus row)', { skip: SKIP, timeout: 120_000 }, async () => {
51
+ test('node demote --detach: flips terminal in place, CLOSES the viewer pane + focus row, broker engine keeps running', { skip: SKIP, timeout: 120_000 }, async () => {
72
52
  const h = await createHarness({ sessionPrefix: 'crtr-detach-focus' });
73
- const user = `crtr-detach-user-${process.pid}-${Date.now().toString(36)}`;
74
53
  try {
75
- // A resident root + a live terminal child B (born into the backstage =
76
- // h.session, the harness's CRTR_NODE_SESSION). B holds an active live sub
77
- // to nothing extra — it just needs to be a live, focusable terminal node.
54
+ // A resident root + a live terminal child B. spawnChild launches B's broker
55
+ // but opens NO viewer (a09b71f) — so B has no focus row yet.
78
56
  const A = h.spawnRoot('resident root');
79
57
  const B = await h.spawnChild(A, 'do the work', { kind: 'developer' });
80
58
  const b0 = h.node(B);
81
59
  assert.equal(b0.lifecycle, 'terminal', 'B born terminal');
82
60
  assert.equal(b0.status, 'active', 'B active after boot');
83
- // Resolve B's live %pane_id from its window (the spawn path records only
84
- // window+session; pane is null until a reconcile/focus).
85
- const bPane = firstPaneOf(b0.window);
86
- assert.ok(typeof bPane === 'string' && bPane !== '', 'B has a live pane');
87
- // --- Put B into a USER viewport, FOCUSED. Create a separate user session
88
- // and break B's live pi pane out into it (the pi keeps running; the
89
- // %id survives the break), then anchor its presence + open a focus row.
90
- // This is the genuine "focused in a user pane" precondition — distinct
91
- // from the backstage (h.session) the detach will relocate it back to.
92
- spawnSync('tmux', ['new-session', '-d', '-s', user, '-c', '/tmp', 'sleep 600'], { stdio: 'ignore' });
93
- assert.equal(spawnSync('tmux', ['break-pane', '-d', '-a', '-s', bPane, '-t', `${user}:`]).status, 0, 'B pane broke out into the user viewport (pi kept alive)');
94
- const moved = paneLoc(bPane);
95
- assert.equal(moved?.session, user, 'B pane physically in the user session now');
96
- assert.equal(paneExistsReal(bPane), true, "B's pi is still alive after the move");
61
+ const brokerPid = b0.pi_pid;
62
+ assert.ok(brokerPid != null && isPidAlive(brokerPid), 'B broker engine alive before detach');
63
+ // Put B genuinely "on screen": `node focus` opens its one `crtr attach`
64
+ // VIEWER pane beside a caller pane in the harness session, registering the
65
+ // viewer focus row (§A.3) the on-demand viewer the detach then tears down.
66
+ const caller = makePane(h.session);
67
+ const focusRes = h.cli(B, ['node', 'focus', B, '--pane', caller]);
68
+ assert.equal(focusRes.code, 0, `node focus B exit 0\n${focusRes.stderr}`);
69
+ // The viewer pane is the node's on-screen presence (the focuses row `focus`
70
+ // registered) NOT a node-row pane (a broker carries null placement).
97
71
  closeDb();
98
- setPresence(B, { pane: bPane, tmux_session: user, window: moved.window });
99
- openFocusRow('f-detach', bPane, user, B);
100
- assert.equal(getFocusByNode(B)?.focus_id, 'f-detach', 'precondition: B is FOCUSED in the user viewport');
101
- // B must read as GENERATING for the backstage-park path: isGenerating now
102
- // gates the break-pane (Bug 1 / Invariant P) on the mid-turn busy marker
103
- // AND a live pi_pid. The boot only recorded pi_pid (session_start); no turn
104
- // fired, so set the busy marker to model an agent genuinely mid-turn —
105
- // otherwise the new gate would RELEASE B (the non-generating path below).
106
- markBusy(B);
107
- // --- Drive the REAL verb on the FOCUSED node. The subprocess inherits
108
- // CRTR_NODE_SESSION = h.session (the backstage), so detachToBackground
109
- // breaks the pane back into h.session.
110
- const res = h.cli(B, ['node', 'demote', '--node', B, '--pane', bPane, '--detach']);
72
+ const viewerRow = getFocusByNode(B);
73
+ assert.ok(viewerRow !== null && viewerRow.pane !== null, 'B has a registered viewer focus row');
74
+ const viewerPane = viewerRow.pane;
75
+ assert.equal(paneExistsReal(viewerPane), true, 'B viewer pane is live on screen');
76
+ // --- Drive the REAL verb on the focused node (the Alt+C D menu path). ---
77
+ const res = h.cli(B, ['node', 'demote', '--node', B, '--pane', viewerPane, '--detach']);
111
78
  assert.equal(res.code, 0, `node demote --detach exit 0\n${res.stderr}`);
112
- assert.match(res.stdout, /relocated to the background/, `the agent was detached\n${res.stdout}`);
79
+ assert.match(res.stdout, /viewer closed \(broker still running off-screen\)/, `the viewer was closed\n${res.stdout}`);
113
80
  closeDb();
114
81
  const b = getNode(B);
115
- // (a) lifecycle flipped to terminal IN PLACE.
82
+ // (a) lifecycle flipped to terminal IN PLACE (it was already terminal; the
83
+ // demote is idempotent on lifecycle — the point is it does not end B).
116
84
  assert.equal(b.lifecycle, 'terminal', '(a) B lifecycle=terminal after the flip');
117
- assert.equal(b.status, 'active', "(a) detach does NOT end the pi — B stays active");
118
- // (b) the still-running pane was RELOCATED to the backstage crtr session
119
- // (NOT killed): the pi keeps generating off-screen.
120
- assert.equal(paneExistsReal(bPane), true, "(b) B's pi keeps generating (pane alive, relocated not killed)");
121
- assert.equal(paneSessionReal(bPane), h.session, '(b) B pane relocated to the backstage crtr session');
122
- assert.equal(b.tmux_session, h.session, "(b) B's LOCATION followed the pane to the backstage");
123
- // (c) the focus row is CLOSED — no orphaned/phantom viewport (A3 hazard).
85
+ assert.equal(b.status, 'active', '(a) detach does NOT end the node — B stays active');
86
+ // (b) the broker ENGINE keeps running detach closes only the viewer, never
87
+ // the engine (the one-host invariant: the engine was never in a pane).
88
+ assert.equal(b.pi_pid, brokerPid, "(b) B's broker pid is UNCHANGED (engine not killed/respawned)");
89
+ assert.equal(isPidAlive(brokerPid), true, '(b) B broker engine still alive off-screen');
90
+ // (c) the viewer pane is CLOSED and its focus row dropped — no orphaned /
91
+ // phantom viewport (A3 hazard / Invariant P).
92
+ await h.waitForPaneGone(B);
93
+ assert.equal(paneExistsReal(viewerPane), false, '(c) B viewer pane closed');
124
94
  assert.equal(getFocusByNode(B), null, '(c) B no longer occupies any focus (row CLOSED — Invariant P)');
125
- assert.equal(getFocusByPane(bPane), null, '(c) NO phantom focus resolves on the relocated %id');
95
+ assert.equal(getFocusByPane(viewerPane), null, '(c) NO phantom focus resolves on the closed viewer %id');
126
96
  assert.equal(listFocuses().length, 0, '(c) no dangling focus rows remain');
127
97
  }
128
98
  finally {
129
- spawnSync('tmux', ['kill-session', '-t', user], { stdio: 'ignore' });
130
- const session = h.session;
131
- await h.dispose();
132
- assert.equal(sessionExists(session), false, 'isolated session killed — no stray');
133
- }
134
- });
135
- // ===========================================================================
136
- // Bug 1 (Invariant P / detach-idle-node): `crtr node demote --detach` on a
137
- // NON-GENERATING focused node must NOT park it alive in the backstage — it must
138
- // be RELEASED to dormant and its pane reaped.
139
- //
140
- // The backstage holds ONLY generating-but-unfocused nodes (Invariant P).
141
- // detachToBackground used to break-pane to the backstage UNCONDITIONALLY, with no
142
- // generating-ness check: a resident root parked idle between turns (pi alive,
143
- // idle, NOT mid-turn) detached via Alt+C → D became unfocused + pi-alive-idle +
144
- // holding a backstage pane forever — nothing re-enters agent_end to release it
145
- // and, with no live subscription, nothing wakes it → a leaked live-idle pi that
146
- // never reaps. The fix gates the break-pane on isGenerating() and RELEASES a
147
- // non-generating node to dormant instead (mirrors retargetFocus's parked-viewer
148
- // release on focus-away).
149
- //
150
- // This drives the SAME real verb as the happy-path test above, but on a node
151
- // with NO busy marker (not mid-turn). isGenerating() = busy && pidAlive, so a
152
- // freshly-booted node (pi_pid recorded at session_start, no turn fired) reads as
153
- // NOT generating.
154
- // ===========================================================================
155
- test('node demote --detach on a NON-GENERATING focused node: RELEASES to dormant (idle + idle-release), reaps the pane, closes the focus row — NOT parked in the backstage (Bug 1 — Invariant P)', { skip: SKIP, timeout: 120_000 }, async () => {
156
- const h = await createHarness({ sessionPrefix: 'crtr-detach-idle' });
157
- const user = `crtr-detach-idle-user-${process.pid}-${Date.now().toString(36)}`;
158
- try {
159
- // A resident root + a live terminal child B. B is freshly booted: pi alive
160
- // (pi_pid recorded at session_start), status active, and — critically — NO
161
- // busy marker, since no turn ever fired. It is parked idle, not mid-turn:
162
- // exactly the non-generating occupant Bug 1 is about.
163
- const A = h.spawnRoot('resident root');
164
- const B = await h.spawnChild(A, 'do the work', { kind: 'developer' });
165
- const b0 = h.node(B);
166
- assert.equal(b0.status, 'active', 'B active after boot');
167
- const bPane = firstPaneOf(b0.window);
168
- assert.ok(typeof bPane === 'string' && bPane !== '', 'B has a live pane');
169
- // --- Put B into a USER viewport, FOCUSED (same precondition as the happy
170
- // path), but DO NOT mark it busy: it stays NOT generating.
171
- spawnSync('tmux', ['new-session', '-d', '-s', user, '-c', '/tmp', 'sleep 600'], { stdio: 'ignore' });
172
- assert.equal(spawnSync('tmux', ['break-pane', '-d', '-a', '-s', bPane, '-t', `${user}:`]).status, 0, 'B pane broke out into the user viewport (pi kept alive)');
173
- const moved = paneLoc(bPane);
174
- assert.equal(moved?.session, user, 'B pane physically in the user session now');
175
- assert.equal(paneExistsReal(bPane), true, "B's pi is alive (parked idle, not mid-turn)");
176
- closeDb();
177
- setPresence(B, { pane: bPane, tmux_session: user, window: moved.window });
178
- openFocusRow('f-detach-idle', bPane, user, B);
179
- assert.equal(getFocusByNode(B)?.focus_id, 'f-detach-idle', 'precondition: B is FOCUSED, NON-generating');
180
- // --- Drive the REAL verb on the non-generating focused node.
181
- const res = h.cli(B, ['node', 'demote', '--node', B, '--pane', bPane, '--detach']);
182
- assert.equal(res.code, 0, `node demote --detach exit 0\n${res.stderr}`);
183
- assert.match(res.stdout, /relocated to the background/, `the agent was detached\n${res.stdout}`);
184
- closeDb();
185
- const b = getNode(B);
186
- // (b) RELEASED to dormant — idle + intent='idle-release' (revivable via inbox
187
- // / re-focus), NOT left active in the backstage.
188
- assert.equal(b.status, 'idle', '(b) B released to dormant (status idle)');
189
- assert.equal(b.intent, 'idle-release', "(b) B's intent is idle-release (daemon revives on inbox)");
190
- // (c) the pane is REAPED / presence nulled — NOT relocated to the backstage
191
- // crtr session. The leaked live-idle pi must be gone.
192
- assert.equal(paneExistsReal(bPane), false, '(c) B pane reaped (NOT parked alive in the backstage)');
193
- assert.equal(b.pane, null, "(c) B's presence pane is nulled");
194
- assert.equal(b.tmux_session, null, "(c) B's presence session is nulled (NOT the backstage crtr session)");
195
- // (a) the focus row is CLOSED — no orphaned/phantom viewport.
196
- assert.equal(getFocusByNode(B), null, '(a) B no longer occupies any focus (row CLOSED — Invariant P)');
197
- assert.equal(getFocusByPane(bPane), null, '(a) NO phantom focus resolves on the reaped %id');
198
- assert.equal(listFocuses().length, 0, '(a) no dangling focus rows remain');
199
- }
200
- finally {
201
- spawnSync('tmux', ['kill-session', '-t', user], { stdio: 'ignore' });
202
99
  const session = h.session;
203
100
  await h.dispose();
204
101
  assert.equal(sessionExists(session), false, 'isolated session killed — no stray');
@@ -33,14 +33,11 @@ export interface HarnessOpts {
33
33
  /** HEADLESS mode: drive ONLY broker-hosted (paneless) nodes. When set the
34
34
  * harness does NOT gate on tmux, does NOT eagerly create the isolated tmux
35
35
  * session, and skips the per-pane `set-environment` seeding — nothing in a
36
- * headless run opens a window. CRTR_NODE_SESSION is still set to the isolated
37
- * name (and dispose still kill-sessions it) because `spawnChild` in spawn.ts
38
- * calls `ensureSession()` UNCONDITIONALLY to record each node's durable
39
- * `home_session` revive target even a broker birth, which then ignores it.
40
- * So `node new --headless` may LAZILY create that one isolated session as a
41
- * side effect, but never a window/pane; the broker boots detached, supervised
42
- * by pid. Headless tests must use `spawnHeadlessChild`, never `spawnChild`
43
- * (which opens a real window). See createHeadlessHarness(). */
36
+ * headless run opens a window. CRTR_NODE_SESSION/CRTR_ROOT_SESSION are still
37
+ * scrubbed/restored for env hygiene, but no longer route placement: a managed
38
+ * child opens NO viewer (it boots a detached broker, supervised by pid), and a
39
+ * --root opens one viewer in the caller's current session. Headless tests must
40
+ * use `spawnHeadlessChild`, never `spawnChild`. See createHeadlessHarness(). */
44
41
  headless?: boolean;
45
42
  }
46
43
  export interface SpawnOpts {
@@ -66,6 +63,7 @@ export interface FabricateOpts {
66
63
  intent?: ExitIntent;
67
64
  pi_pid?: number | null;
68
65
  pi_session_id?: string | null;
66
+ pi_session_file?: string | null;
69
67
  id?: string;
70
68
  }
71
69
  export interface Harness {
@@ -80,7 +78,6 @@ export interface Harness {
80
78
  turn(nodeId: string, text?: string): Promise<void>;
81
79
  stop(nodeId: string, reason?: 'stop' | 'length' | 'aborted' | 'error'): Promise<void>;
82
80
  finish(nodeId: string, finalText: string): Promise<void>;
83
- yieldNode(nodeId: string, note: string): Promise<void>;
84
81
  tick(now?: number): Promise<void>;
85
82
  awaitBoot(nodeId: string, o?: {
86
83
  minCount?: number;