@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
@@ -1,92 +1,106 @@
1
- // placement.ts — the Placement MODEL layer (Steps 3–5).
1
+ // placement.ts — the VIEWER REGISTRY + the model-over-driver tmux front door.
2
2
  //
3
- // Above tmux.ts (the Surface/driver), below the daemon and the runtime ops. This
4
- // is the sanctioned model-over-driver under the §2.1 rule: "only placement.ts /
5
- // tmux-chrome.ts import the tmux driver" (enforced by the §5.1 import-lint). Every
6
- // other runtime/command module reaches the driver through placement's re-exports.
3
+ // After the broker-is-the-host cut the engine NEVER lives in a tmux pane: every
4
+ // node is a detached `headlessBrokerHost` process that binds `view.sock`. A tmux
5
+ // pane is only a `crtr attach` VIEWER of that broker, exactly like a web tab. So
6
+ // the whole engine-in-pane placement machinery (hot-swap, resume-into-focus,
7
+ // backstage relocation, remain-on-exit freeze, spread, node-row reconcile) is
8
+ // gone. What remains is:
7
9
  //
8
- // Responsibilities, all keyed on the durable tmux `%pane_id` (§1.2/§2.4, Q6):
9
- //
10
- // reconcile(nodeId) — resolve a node's CURRENT window/session from its
11
- // durable pane id and FOLLOW any manual move; null
12
- // the LOCATION when the pane is truly gone; lazily
13
- // backfill a legacy row's pane from its live window.
14
- // reconcileFocus(focusId) the focus-row analogue: follow a manual move of a
15
- // FOCUS pane so a resume-into-focus lands in its
16
- // CURRENT session (§2.4, Q4).
17
- // • isNodePaneAlive(row) — the primary, PURE liveness probe: pane-existence
18
- // (window-existence only as a legacy/no-pane fallback).
19
- // • reviveIntoPlacement Step 5 (§1.4): THE bug-kill. The single decision
20
- // (+ reviveTarget, pure) that replaces revive.ts's blind new-window into
21
- // `meta.tmux_session`: a node on a live focus resumes
22
- // IN PLACE in that pane; otherwise it opens a window
23
- // in its home_session ONLY (never a user session).
24
- //
25
- // The robustness contract: a manual `move-pane`/`join-pane`/`break-pane` must
26
- // NEVER read as a node death. Liveness is pane-existence, not window-existence,
27
- // and reconcile makes crtr follow a move instead of fighting it.
28
- import { spawnSync } from 'node:child_process';
10
+ // The VIEWER REGISTRY the `focuses` table, schema unchanged, now read as
11
+ // "node_id -> the node's one viewer %pane in `session`" (UNIQUE(node_id) =
12
+ // one viewer per node). focusOf/isFocused/focusByPane/focusedNodes/
13
+ // listFocuses read it (lazily GC'ing rows whose pane is gone or no longer
14
+ // carries this node's `@crtr_node` tag); registerViewerFocus writes it.
15
+ // focus(nodeId) ensure the broker engine is alive (injected reviver) +
16
+ // wait view.sock, then ensure the node's ONE viewer pane is forefront: reuse
17
+ // it (navigate) if it is in the caller's session, MOVE it (close + reopen
18
+ // beside the caller) if it is elsewhere, else open one beside the caller.
19
+ // • openViewerWindow / registerViewerFocus — the shared viewer-pane opener +
20
+ // row registrar used by BOTH spawn (a background viewer window) and focus.
21
+ // • tearDownNode / detachToBackground close a node's viewer pane + row.
22
+ // The chrome/viewer tmux re-exports (§2.1): placement stays the sanctioned
23
+ // model-over-driver so the §5.1 import-lint ("only placement.ts /
24
+ // tmux-chrome.ts import tmux.ts") holds every other module reaches the
25
+ // driver verbs through here.
26
+ import { Worker } from 'node:worker_threads';
29
27
  import { join } from 'node:path';
30
- import { getRow, getRowByPane, getNode, setPresence, openDb, openFocusRow, setFocusOccupant, closeFocusRow, getFocusByNode, getFocusByPane, getFocusById, setFocusPane, listFocuses as listFocusRows, view, } from '../canvas/index.js';
31
- import { paneExists, paneRunning, paneLocation, paneOfWindow, windowAlive, windowOfPane, ensureSession, openNodeWindow, respawnPaneSync, respawnPaneDetached, breakPaneToSession, splitWindow, swapPaneInPlace, setRemainOnExit, closePane, currentTmux, joinPane, selectLayout, setWindowOption, switchClient, selectWindow, } from './tmux.js';
32
- import { homeSessionOf, childBackstageOf, nodeSession, newNodeId, rootOfSpine } from './nodes.js';
28
+ import { getNode, openFocusRow, closeFocusRow, getFocusByNode, getFocusByPane, getFocusById, listFocuses as listFocusRows, view, } from '../canvas/index.js';
29
+ import { paneExists, paneLocation, ensureSession, openNodeWindow, splitWindow, closePane, currentTmux, switchClient, selectWindow, getPaneOption, } from './tmux.js';
30
+ import { newNodeId, rootOfSpine } from './nodes.js';
33
31
  import { nodeDir } from '../canvas/paths.js';
34
- import { isBusy } from './busy.js';
35
32
  import { isPidAlive } from '../canvas/pid.js';
36
- import { transition } from './lifecycle.js';
37
- // Re-export the durable REVIVE-HOME read so placement is the one front door for
38
- // "where does this node live."
39
- export { homeSessionOf, childBackstageOf };
40
33
  // Placement is the sanctioned model-over-driver (§2.1): non-placement runtime /
41
34
  // command modules that legitimately need a raw driver verb get it from here, so
42
35
  // the §5.1 lint can hold "only placement.ts / tmux-chrome.ts import tmux.ts".
43
- export { piCommand, paneLocation, currentTmux, inTmux, ensureSession, openNodeWindow, splitWindow, focusWindow, windowAlive, windowOfPane, respawnPane, respawnPaneSync, setPaneOption, getPaneOption, paneCurrentPath, listLivePanes, } from './tmux.js';
44
- export { nodeSession } from './nodes.js';
36
+ // These are the chrome + viewer verbs that survive the cut (the engine-in-pane
37
+ // verbs respawnPane, respawnPaneDetached, swapPaneInPlace, breakPaneToSession,
38
+ // joinPane, selectLayout, setWindowOption, setRemainOnExit — are deleted).
39
+ // `piCommand` + `paneCurrentPath` are KEPT: the sessionless `crtr view` system
40
+ // (view-cycle.ts / view-run.ts) builds its pane commands with them, and the §5.1
41
+ // import-lint forbids those commands importing tmux.ts directly — they must reach
42
+ // the driver through placement. (They are NOT engine-in-pane; the design's
43
+ // "nothing keeps" classification missed the view consumers.)
44
+ export { splitWindow, focusWindow, selectWindow, switchClient, openNodeWindow, ensureSession, paneLocation, paneExists, paneRunning, paneCurrentPath, currentTmux, inTmux, installMenuBinding, setPaneOption, getPaneOption, piCommand, respawnPaneSync, closePane, windowOfPane, renameWindow, paneOfWindow, windowAlive, listLivePanes, } from './tmux.js';
45
45
  // ---------------------------------------------------------------------------
46
- // Focus reads (Step 4) — COMPOSE over the canvas focuses table (§2.3/§4).
46
+ // Viewer registry reads — COMPOSE over the canvas focuses table (§2.3/§4).
47
47
  //
48
- // placement is the front door for "which nodes are on a viewport"; the SQL lives
49
- // in the canvas layer (canvas/focuses.ts) and placement just reads it, the same
50
- // way it composes setPresence. A node occupies at most one focus (UNIQUE
51
- // node_id, Q5), so focusOf returns a single row.
48
+ // placement is the front door for "which nodes have a viewer on screen"; the SQL
49
+ // lives in the canvas layer (canvas/focuses.ts) and placement just reads it. A
50
+ // node has at most one viewer (UNIQUE node_id), so focusOf returns a single row.
52
51
  //
53
- // The `focuses` table (canvas/focuses.ts) is the CANONICAL focus store there is
54
- // no focus.ptr file and no dual-write bridge. openFocus / retargetFocus /
55
- // registerRootFocus / handFocusToManager write focus rows directly; these reads
56
- // compose over them.
52
+ // GC is lazy on read: a row whose viewer pane no longer exists (the user closed
53
+ // the window, the attach client died) is pruned the next time it is read, so the
54
+ // registry self-heals without a sweeper. (graphSurfaceTarget stays pure — no
55
+ // tmux probe — by design; its caller liveness-checks the pane it returns.)
57
56
  // ---------------------------------------------------------------------------
58
- /** The focus a node occupies, or null. UNIQUE(node_id) at most one. */
57
+ /** Prune a viewer row whose pane is gone; return it iff its pane is still live. */
58
+ function liveOrPrune(f) {
59
+ if (f === null)
60
+ return null;
61
+ if (f.pane !== null && !paneExists(f.pane)) {
62
+ closeFocusRow(f.focus_id);
63
+ return null;
64
+ }
65
+ return f;
66
+ }
67
+ /** The viewer a node has on screen, or null. UNIQUE(node_id) ⇒ at most one. */
59
68
  export function focusOf(nodeId) {
60
- return getFocusByNode(nodeId);
69
+ return liveOrPrune(getFocusByNode(nodeId));
61
70
  }
62
- /** Is this node on a viewport? */
71
+ /** Does this node have a live viewer on screen? */
63
72
  export function isFocused(nodeId) {
64
- return getFocusByNode(nodeId) !== null;
73
+ return focusOf(nodeId) !== null;
65
74
  }
66
- /** The focus realized by a given pane (`%id`), or null. */
75
+ /** The viewer realized by a given pane (`%id`), or null. */
67
76
  export function focusByPane(pane) {
68
- return getFocusByPane(pane);
77
+ return liveOrPrune(getFocusByPane(pane));
69
78
  }
70
- /** The set of node ids currently on some focus. */
79
+ /** The set of node ids that currently have a live viewer. */
71
80
  export function focusedNodes() {
72
- return new Set(listFocusRows().map((f) => f.node_id));
81
+ return new Set(listFocuses().map((f) => f.node_id));
73
82
  }
74
- /** Every focus row (every live viewport). */
83
+ /** Every live viewer row (GC'ing any whose pane has gone). */
75
84
  export function listFocuses() {
76
- return listFocusRows();
85
+ const live = [];
86
+ for (const f of listFocusRows()) {
87
+ if (liveOrPrune(f) !== null)
88
+ live.push(f);
89
+ }
90
+ return live;
77
91
  }
78
92
  // ---------------------------------------------------------------------------
79
- // Graph → focus routing (for surfacing human-in-the-loop prompts)
93
+ // Graph → viewer routing (for surfacing human-in-the-loop prompts)
80
94
  // ---------------------------------------------------------------------------
81
- /** The on-screen viewport a human-in-the-loop prompt raised by `nodeId` should
82
- * surface into: the HIGHEST FOCUSED node of nodeId's graph — the focused node
83
- * closest to the graph root, i.e. the session/window the user is actually
95
+ /** The on-screen viewer a human-in-the-loop prompt raised by `nodeId` should
96
+ * surface into: the HIGHEST node of nodeId's graph that has a viewer — the
97
+ * viewer closest to the graph root, i.e. the session/window the user is actually
84
98
  * watching this work in. Walks nodeId's spine to its root, enumerates the whole
85
- * tree root-first (`view` is BFS ⇒ shallowest first), and returns the focus row
86
- * of the first node that occupies a viewport. null when nothing in the graph is
87
- * on screen — the caller then surfaces in the user's attached pane rather than
88
- * the backstage node session. PURE (db reads only): no tmux probe, so the pane
89
- * may be stale; the caller liveness-checks before targeting it. */
99
+ * tree root-first (`view` is BFS ⇒ shallowest first), and returns the viewer row
100
+ * of the first node that has one. null when nothing in the graph is on screen —
101
+ * the caller then surfaces in the user's attached pane. PURE (db reads only): no
102
+ * tmux probe, so the pane may be stale; the caller liveness-checks before
103
+ * targeting it. */
90
104
  export function graphSurfaceTarget(nodeId) {
91
105
  const root = rootOfSpine(nodeId);
92
106
  for (const id of [root, ...view(root)]) {
@@ -96,561 +110,22 @@ export function graphSurfaceTarget(nodeId) {
96
110
  }
97
111
  return null;
98
112
  }
99
- /** PURE reconciliation decision (§2.4) — unit-testable without a live tmux.
100
- * Given the cached row LOCATION and what tmux currently reports, decide the
101
- * presence patch. Mirrors the pure-core/impure-shell split (cf. `livenessVerdict`
102
- * vs `handleLiveWindow`): this is the decision, `reconcile` wires it to the
103
- * driver reads + `setPresence`. */
104
- export function reconcileDecision(cached, live) {
105
- if (cached.pane == null) {
106
- // Legacy / no-pane row: lazily backfill the durable pane from the live
107
- // window's active pane. Requires a complete, live window to anchor on.
108
- if (cached.tmux_session != null && cached.window != null && live.windowPane != null) {
109
- return {
110
- kind: 'backfill',
111
- pane: live.windowPane,
112
- tmux_session: cached.tmux_session,
113
- window: cached.window,
114
- };
115
- }
116
- return { kind: 'none' };
117
- }
118
- // Pane-anchored row: resolve the pane's CURRENT location.
119
- if (live.paneLoc === null) {
120
- // The pane itself is gone — the node's pane truly closed. Null the LOCATION.
121
- return { kind: 'gone' };
122
- }
123
- if (live.paneLoc.session !== cached.tmux_session || live.paneLoc.window !== cached.window) {
124
- // The pane drifted (a manual move-pane/join-pane/break-pane). FOLLOW it:
125
- // same pane id, new derived window/session.
126
- return {
127
- kind: 'follow',
128
- pane: cached.pane,
129
- tmux_session: live.paneLoc.session,
130
- window: live.paneLoc.window,
131
- };
132
- }
133
- // Cache already matches the pane's reality.
134
- return { kind: 'none' };
135
- }
136
- /** Reconcile a node's LOCATION against tmux reality (§2.4) — the impure shell.
137
- * Reads `row.pane`, resolves its CURRENT session/window via the driver, and
138
- * writes the resulting presence patch through `setPresence` (never a raw UPDATE):
139
- * - pane moved → FOLLOW (re-point window/session, keep the pane id)
140
- * - pane gone → null the whole LOCATION
141
- * - legacy/no pane + live window → backfill the pane from `paneOfWindow`
142
- * A no-op when there's nothing to resolve (genuinely no pane, or the cache is
143
- * already current). Call this before any swap/kill/focus/revive so the act lands
144
- * on the pane's current window, never a stale one. */
145
- export function reconcile(nodeId) {
146
- const row = getRow(nodeId);
147
- if (row === null)
148
- return;
149
- const cached = {
150
- pane: row.pane,
151
- tmux_session: row.tmux_session,
152
- window: row.window,
153
- };
154
- // Only the read the decision needs: paneLocation when anchored on a pane, else
155
- // paneOfWindow for the legacy backfill. Skip the driver call that can't apply.
156
- const paneLoc = cached.pane != null ? paneLocation(cached.pane) : null;
157
- const windowPane = cached.pane == null && cached.tmux_session != null && cached.window != null
158
- ? paneOfWindow(cached.tmux_session, cached.window)
159
- : null;
160
- const decision = reconcileDecision(cached, { paneLoc, windowPane });
161
- switch (decision.kind) {
162
- case 'none':
163
- return;
164
- case 'gone':
165
- setPresence(nodeId, { pane: null, tmux_session: null, window: null });
166
- return;
167
- case 'follow':
168
- case 'backfill':
169
- setPresence(nodeId, {
170
- pane: decision.pane,
171
- tmux_session: decision.tmux_session,
172
- window: decision.window,
173
- });
174
- return;
175
- }
176
- }
177
113
  // ---------------------------------------------------------------------------
178
- // Focus reconciliationfollow a manual move of a FOCUS pane (§2.4, Q4)
179
- // ---------------------------------------------------------------------------
180
- /** Reconcile a FOCUS's derived `session` cache against tmux reality (§2.4, Q4) —
181
- * the focus-row analogue of `reconcile`. A focus is anchored on its durable
182
- * `%pane_id`; `session` is a derived cache. If the user moved the focus pane to
183
- * another session, re-point the cache so a resume-into-focus lands in the pane's
184
- * CURRENT session. A no-op when the focus has no pane, the cache is already
185
- * current, or the pane is GONE — in the gone case reconcileFocus does NOT null
186
- * the row; the caller (reviveIntoPlacement) instead falls to the backstage
187
- * branch via `paneExists(pane)` being false. */
188
- export function reconcileFocus(focusId) {
189
- const f = getFocusById(focusId);
190
- if (f === null || f.pane === null)
191
- return;
192
- const live = paneLocation(f.pane);
193
- if (live === null)
194
- return; // pane gone — backstage fall-through handles it
195
- if (live.session !== f.session) {
196
- setFocusPane(f.focus_id, f.pane, live.session);
197
- }
198
- }
114
+ // view.sock readinessthe one shared broker-cold-start primitive
199
115
  // ---------------------------------------------------------------------------
200
- // Liveness — pane-existence (§1.2, Q6)
201
- // ---------------------------------------------------------------------------
202
- /** Is this node's pane (its LOCATION) alive? The v3 PRIMARY liveness probe,
203
- * PURE / non-mutating so the daemon can gate on it without side effects:
204
- * - `pane != null` → `paneExists(pane)` (display-message on the `%id`), so a
205
- * user moving the pane to another window/session never reads as "gone".
206
- * - `pane == null` → window-keyed FALLBACK (`windowAlive`) for legacy/no-pane
207
- * rows that haven't been backfilled yet.
208
- * Accepts a node id (re-reads the row) or a `NodeRow` already in hand. */
209
- export function isNodePaneAlive(node) {
210
- const row = typeof node === 'string' ? getRow(node) : node;
211
- if (row === null)
212
- return false;
213
- return row.pane != null
214
- ? paneExists(row.pane)
215
- : windowAlive(row.tmux_session, row.window);
216
- }
217
- export function reviveTarget(focus, focusPaneAlive, homeSession) {
218
- if (focus !== null && focus.pane !== null && focusPaneAlive) {
219
- return { kind: 'focus-pane', pane: focus.pane, session: focus.session ?? homeSession };
220
- }
221
- return { kind: 'backstage', session: homeSession };
222
- }
223
- /** Place a reviving node into its CORRECT location (§1.4) — the single decision
224
- * that replaces revive.ts's old `session = meta.tmux_session ?? nodeSession()` +
225
- * `openNodeWindow`. Reconcile first (§2.4), then dispatch on `reviveTarget`:
226
- * - the node occupies a LIVE focus → `reconcileFocus` (resolve the pane's
227
- * CURRENT session, Q4) and `respawn-pane -k` the pi INTO that focus pane —
228
- * no new window (F3 resume-in-place).
229
- * - otherwise → the node is NOT focused (or its focus pane already collapsed,
230
- * the Step-5 limitation: remain-on-exit lands in Step 6), so it may ONLY
231
- * (re)appear in its durable REVIVE-HOME: a fresh window in `homeSession`.
232
- * **There is NO code path here by which a non-focused node's new-window
233
- * targets a user session** — `openNodeWindow`'s session is `homeSession` and
234
- * nothing else. That is the structural bug-kill.
235
- *
236
- * `setPresence` (the one atomic LOCATION write) records where the node landed.
237
- * CRTR_ROOT_SESSION is forced to the node's children-backstage (childBackstageOf)
238
- * in BOTH branches so the node's children always flow to the backstage, never
239
- * into the focus session — and, for a front-door ROOT, never into the user
240
- * session its own pane adopted. */
241
- export function reviveIntoPlacement(nodeId, launch) {
242
- // §2.4 — follow any manual pane move before acting.
243
- reconcile(nodeId);
244
- const focus = focusOf(nodeId);
245
- const focusPaneAlive = focus !== null && focus.pane !== null && paneExists(focus.pane);
246
- const homeSession = homeSessionOf(nodeId);
247
- const decision = reviveTarget(focus, focusPaneAlive, homeSession);
248
- // The node's children always spawn into the backstage, never the focus session
249
- // — force it regardless of which branch the node itself takes. childBackstageOf
250
- // is home_session for a managed child but `nodeSession()` for a root (whose
251
- // home_session may be a user session it adopted), so a refreshed front-door
252
- // root never re-points its subtree into the user's session.
253
- const env = { ...launch.env, CRTR_ROOT_SESSION: childBackstageOf(nodeId), CRTR_SUBTREE: rootOfSpine(nodeId) };
254
- if (decision.kind === 'focus-pane') {
255
- // F3: resume the pi INTO the live focus pane, in its CURRENT session (Q4 —
256
- // reconcileFocus follows a user move of the focus pane). No new window.
257
- reconcileFocus(focus.focus_id);
258
- const f = focusOf(nodeId) ?? focus;
259
- const pane = f.pane;
260
- respawnPaneSync({ pane, cwd: launch.cwd, env, command: launch.command });
261
- const window = windowOfPane(pane);
262
- const session = f.session ?? homeSession;
263
- setPresence(nodeId, { pane, tmux_session: session, window });
264
- return { window, session, pane };
265
- }
266
- // Backstage branch — the ONLY new-window target is `homeSession` (the
267
- // backstage `crtr` for a child). A non-focused node has NO path to a user
268
- // session here: the bug is structurally unreachable.
269
- const session = decision.session; // === homeSession
270
- ensureSession(session, launch.cwd);
271
- const opened = openNodeWindow({
272
- session,
273
- name: launch.name,
274
- cwd: launch.cwd,
275
- env,
276
- command: launch.command,
277
- });
278
- const window = opened?.window ?? null;
279
- const pane = opened?.pane ?? null;
280
- setPresence(nodeId, { pane, window, tmux_session: session });
281
- return { window, session, pane };
282
- }
283
- // ---------------------------------------------------------------------------
284
- // Detach to background — send a still-running agent off the foreground pane into
285
- // the backstage `crtr` session WITHOUT ending it (the `node lifecycle --detach`
286
- // / Alt+C → D half).
287
- // ---------------------------------------------------------------------------
288
- /** Relocate a node's still-running agent to the background `crtr` session,
289
- * freeing the foreground pane WITHOUT killing the pi. `break-pane` moves the
290
- * pane out of the foreground window into a fresh window in the shared backstage
291
- * (the pi keeps generating); the node becomes a background window — switchable
292
- * but not rendered, like any other node. Reconcile first (act on the pane's
293
- * CURRENT location, §2.4) and again after (presence FOLLOWS the move). No-op
294
- * (false) when there is no live pane to relocate or tmux refuses the break.
295
- * `pane` is the authoritative node pane the caller acts on (the Alt+C menu's
296
- * `#{pane_id}`); falls back to the node's durable handle.
297
- *
298
- * Invariant P gate (Bug 1): the backstage holds ONLY generating-but-unfocused
299
- * nodes. A node that is NOT mid-turn (e.g. a resident root parked idle between
300
- * turns, detached via Alt+C → D) must never be parked there as a live-idle pi —
301
- * nothing would re-enter agent_end to release it and, with no live subscription,
302
- * nothing would wake it, so it would hold a backstage pane forever, reaped by
303
- * nothing. Such a node is RELEASED to dormant instead (mirrors the parked-viewer
304
- * release on focus-away in retargetFocus). */
305
- export function detachToBackground(nodeId, pane) {
306
- reconcile(nodeId);
307
- const row = getRow(nodeId);
308
- if (row === null)
309
- return false;
310
- // A broker node has NO engine pane — it is ALREADY headless, so the only thing
311
- // on screen is a `crtr attach` VIEWER pane. "Detach" (the Alt+C → D let-go)
312
- // therefore means "stop foregrounding it": close the viewer pane and leave the
313
- // broker running untouched. The engine-in-pane relocate/release below would
314
- // mis-anchor the live engine row to the viewer pane and (when idle) flip a
315
- // LIVE broker to idle-release — corrupting state. Never reach it for a broker.
316
- if (row.host_kind === 'broker') {
317
- const viewer = pane ?? null;
318
- if (viewer !== null && paneExists(viewer))
319
- return closePane(viewer);
320
- return false;
321
- }
322
- const target = pane ?? row.pane;
323
- if (target === null || !paneExists(target))
324
- return false;
325
- // Anchor the durable handle on the pane we relocate so the post-move reconcile
326
- // follows the right pane.
327
- if (row.pane !== target)
328
- setPresence(nodeId, { pane: target });
329
- // Invariant P (Bug 1): a node that is NOT mid-turn must never be parked in the
330
- // backstage as a live-idle pi (it would hold a backstage pane forever, reaped
331
- // by nothing). Detaching such a node RELEASES it to dormant instead: close its
332
- // focus, then — crash-safety, as in retargetFocus's release path — flip it to
333
- // dormant (idle + intent='idle-release', revivable via inbox / re-focus) and
334
- // null its LOCATION BEFORE reaping the pane, so a daemon tick can never catch a
335
- // window-gone live node mid-reap and race to revive it. Unlike retargetFocus's
336
- // outgoingDisposition (which keeps a RESIDENT node warm on focus-away), detach
337
- // is a deliberate let-go: a non-generating node is released regardless of
338
- // lifecycle. The status guard skips the transition for a done/dead/canceled
339
- // node ('release' is legal only from active|idle) — it still reaps + nulls.
340
- if (!isGenerating(nodeId)) {
341
- const f = focusOf(nodeId);
342
- if (f !== null)
343
- closeFocusRow(f.focus_id);
344
- if (row.status === 'active' || row.status === 'idle')
345
- transition(nodeId, 'release');
346
- setPresence(nodeId, { pane: null, tmux_session: null, window: null });
347
- closePane(target);
348
- return true;
349
- }
350
- // Generating → park in the backstage, keep running off-screen.
351
- const session = nodeSession();
352
- ensureSession(session, row.cwd);
353
- const ok = breakPaneToSession(target, session);
354
- if (ok) {
355
- // The node left its viewport for the backstage: it is now generating but
356
- // UNFOCUSED (Invariant P / §1.3 "evicted from a focus … generating →
357
- // backstage, no focus"). Close any focus row it held so it does not linger
358
- // as a phantom viewport — the pane %id survives the break, so the row would
359
- // otherwise keep resolving (`focusByPane`/`listFocuses`). A pure detach has
360
- // no successor, so it closes the row (cf. `demote`, which hands it off).
361
- const f = focusOf(nodeId);
362
- if (f !== null)
363
- closeFocusRow(f.focus_id);
364
- }
365
- reconcile(nodeId); // presence now points at the crtr window
366
- return ok;
367
- }
368
- /** A reserved, non-node occupant for a freshly-opened viewport that has no node
369
- * yet: openFocus splits a HOLDER pane, but `node_id` is NOT NULL, so the row
370
- * needs a placeholder until retargetFocus swaps a real node in. retargetFocus
371
- * REAPS a holder pane (getRow(holder) === null ⇒ not generating ⇒ kill) instead
372
- * of backstaging it. */
373
- function holderId(focusId) {
374
- return `__hold_${focusId}__`;
375
- }
376
- function newFocusId() {
377
- return `f-${newNodeId()}`;
378
- }
379
- /** Is a focus's OUTGOING occupant still GENERATING (a live pi actually MID-TURN)?
380
- * A still-generating node is moved to backstage by a retarget (F2 — it keeps
381
- * running off-screen); a holder / done / dormant / merely-parked node has its
382
- * pane reaped or released (Invariant P). A holder or vanished node (row null) is
383
- * never generating.
384
- *
385
- * The signal is the mid-turn `busy` marker AND a live pid — NOT pid-alive
386
- * alone. A node revived only for VIEWING is parked at its prompt with a live
387
- * pid between turns; pid-alive would misclassify it as "doing work" and leave
388
- * it stuck backstaged-active forever. `isBusy` is true only inside a turn, so a
389
- * parked viewer reads as not-generating and is released to dormant on
390
- * focus-away. The AND with `isPidAlive` makes a stale marker (a pi that crashed
391
- * mid-turn) harmless. */
392
- function isGenerating(nodeId) {
393
- const row = getRow(nodeId);
394
- if (row === null)
395
- return false;
396
- if (row.status !== 'active' && row.status !== 'idle')
397
- return false;
398
- return isBusy(nodeId) && isPidAlive(row.pi_pid);
399
- }
400
- export function outgoingDisposition(o) {
401
- if (!o.exists)
402
- return { kind: 'kill' }; // holder pane
403
- if (!o.live)
404
- return { kind: 'kill' }; // done/dead/canceled — reap the pane
405
- if (o.resident)
406
- return { kind: 'backstage' }; // human-driven node: keep warm
407
- if (o.generating)
408
- return { kind: 'backstage' }; // mid-turn terminal worker (F2)
409
- return { kind: 'release' }; // parked terminal viewer → dormant
410
- }
411
- /** The node's pane iff it is a LIVE pane (a generating-unfocused backstage pane,
412
- * or a still-live focus pane), else null. The retarget swaps THIS pane into the
413
- * viewport; null means the node is dormant and must be revived first. */
414
- function livePinPane(nodeId) {
415
- const row = getRow(nodeId);
416
- return row?.pane != null && paneExists(row.pane) ? row.pane : null;
417
- }
418
- /** remain-on-exit on a focus's viewport window (F3 freeze/resume) — best-effort. */
419
- function armRemainOnExit(window) {
420
- if (window != null && window !== '')
421
- setRemainOnExit(window, true);
422
- }
423
- /** Open a NEW viewport (§2.3, F4) — the ONLY path a new pane appears in a user
424
- * session. Default: `splitWindow(callerPane)` beside (Q3); `newWindow` opens a
425
- * fresh window in the caller pane's session instead. Arms `remain-on-exit` on
426
- * the new pane's window (F3) and inserts a focuses row anchored on it, occupied
427
- * by a HOLDER until retargetFocus swaps a real node in. A benign long-sleep
428
- * holds the pane open until the swap; retargetFocus reaps it. Returns the row,
429
- * or null if tmux failed. */
430
- export function openFocus(callerPane, opts = {}) {
431
- const HOLD = 'sleep 2147483647';
432
- let pane;
433
- let session;
434
- if (opts.newWindow === true) {
435
- const sess = paneLocation(callerPane)?.session;
436
- if (sess === undefined)
437
- return null;
438
- const opened = openNodeWindow({ session: sess, name: 'focus', cwd: process.cwd(), env: {}, command: HOLD });
439
- if (opened === null)
440
- return null;
441
- pane = opened.pane;
442
- session = sess;
443
- }
444
- else {
445
- pane = splitWindow(callerPane, { cwd: process.cwd(), env: {}, command: HOLD });
446
- if (pane === null)
447
- return null;
448
- session = paneLocation(pane)?.session ?? null;
449
- }
450
- armRemainOnExit(paneLocation(pane)?.window);
451
- const focusId = newFocusId();
452
- openFocusRow(focusId, pane, session, holderId(focusId));
453
- return getFocusById(focusId);
454
- }
455
- /** Register the FOREGROUND root's pane as focus #1 at boot (§2.6). The inline
456
- * root owns the user's viewport, so its own pane becomes a durable focus — with
457
- * `remain-on-exit` so a clean exit FREEZES the pane rather than detaching the
458
- * terminal (F1). A background `--root` does NOT call this (§6): it stays a plain
459
- * window until the user `node focus`es it. No-op when the pane or this node is
460
- * already a focus. The focus row IS the record — no pointer to mirror. */
461
- export function registerRootFocus(nodeId, pane, session, window) {
462
- const byPane = getFocusByPane(pane);
463
- if (byPane !== null)
464
- return byPane;
465
- const byNode = getFocusByNode(nodeId);
466
- if (byNode !== null)
467
- return byNode;
468
- const focusId = newFocusId();
469
- openFocusRow(focusId, pane, session, nodeId);
470
- armRemainOnExit(window);
471
- return getFocusById(focusId);
472
- }
473
- /** retargetFocus — the unified hot-swap (§2.5, Invariant P + Q5). Swap `incoming`
474
- * onto focus `focusId`'s viewport, keeping the screen position invariant (no new
475
- * window). One sqlite txn updates the focus row + BOTH nodes' presence:
476
- * - Q5: if `incoming` already occupies ANOTHER focus, VACATE it first (close
477
- * its row + kill its pane — the node MOVES here, no auto-retarget).
478
- * - resolve `incoming`'s live pin pane (a backstage pane), else `revive` it
479
- * into the backstage and read back its pane.
480
- * - `swapPaneInPlace(pin, focusPane)`: incoming → the viewport slot; the
481
- * outgoing occupant → incoming's old (backstage) slot, %ids preserved
482
- * (cross-session swap confirmed by the spike).
483
- * - outgoing resident OR still mid-turn → backstage (kept warm / F2); a parked
484
- * terminal viewer → RELEASE (status → idle, pane reaped); a holder or
485
- * done/dormant occupant → reap its now-backstage pane (Invariant P).
486
- * Arms remain-on-exit on the viewport (F3); the focus row is the record. */
487
- export function retargetFocus(focusId, incoming, revive) {
488
- let f = getFocusById(focusId);
489
- if (f === null)
490
- return { focused: false, session: null, inPlace: false, revived: false };
491
- reconcileFocus(f.focus_id);
492
- reconcile(incoming);
493
- f = getFocusById(focusId) ?? f;
494
- const outgoing = f.node_id;
495
- // Already showing this node — a no-op (focusing yourself / the live occupant).
496
- if (outgoing === incoming) {
497
- return { focused: true, session: f.session, inPlace: true, revived: false };
498
- }
499
- // Q5 vacate: incoming occupies a DIFFERENT focus — close it + kill its pane;
500
- // the node moves here. reconcile then nulls its now-dead LOCATION.
501
- const other = getFocusByNode(incoming);
502
- if (other !== null && other.focus_id !== f.focus_id) {
503
- if (other.pane !== null)
504
- closePane(other.pane);
505
- closeFocusRow(other.focus_id);
506
- reconcile(incoming);
507
- }
508
- // Resolve incoming's live pin pane; revive into backstage if dormant.
509
- let revived = false;
510
- let pin = livePinPane(incoming);
511
- if (pin === null) {
512
- revive(incoming);
513
- revived = true;
514
- reconcile(incoming);
515
- pin = livePinPane(incoming);
516
- }
517
- if (pin === null) {
518
- return { focused: false, session: f.session, inPlace: false, revived };
519
- }
520
- const focusPane = f.pane;
521
- // The focus has no physical pane yet (an unplaced/bridge row) OR incoming is
522
- // already in it — adopt pin directly, no swap.
523
- if (focusPane === null || focusPane === pin) {
524
- const loc = paneLocation(pin);
525
- commitFocusTxn(f.focus_id, incoming, pin, loc, outgoing, { kind: 'kill' }, null, null);
526
- armRemainOnExit(loc?.window);
527
- return { focused: true, session: loc?.session ?? f.session, inPlace: true, revived };
528
- }
529
- // The hot-swap: incoming's pane → the viewport slot; outgoing's pane →
530
- // incoming's old (backstage) slot. %ids survive (spike-confirmed).
531
- if (!swapPaneInPlace(pin, focusPane)) {
532
- return { focused: false, session: f.session, inPlace: false, revived };
533
- }
534
- const pinLoc = paneLocation(pin); // now the viewport
535
- const outLoc = paneLocation(focusPane); // now backstage (outgoing's new home)
536
- const oRow = getRow(outgoing);
537
- const action = outgoingDisposition({
538
- exists: oRow !== null,
539
- live: oRow?.status === 'active' || oRow?.status === 'idle',
540
- resident: oRow?.lifecycle === 'resident',
541
- generating: isGenerating(outgoing),
542
- });
543
- commitFocusTxn(f.focus_id, incoming, pin, pinLoc, outgoing, action, outLoc, focusPane);
544
- // Crash-safety: flip a released (parked terminal viewer) node to dormant
545
- // (idle + intent='idle-release') BEFORE reaping its pane, so the daemon never
546
- // sees a window-gone live node and races to revive it. Then reap the
547
- // outgoing/holder pane (now backstage) for both kill (done/dormant/holder) and
548
- // release (parked viewer) — AFTER commit (a tmux side effect, outside the txn).
549
- // A still-generating worker or a resident node is backstaged, untouched here.
550
- if (action.kind === 'release')
551
- transition(outgoing, 'release');
552
- if (action.kind === 'kill' || action.kind === 'release')
553
- closePane(focusPane);
554
- armRemainOnExit(pinLoc?.window);
555
- return { focused: true, session: pinLoc?.session ?? f.session, inPlace: true, revived };
556
- }
557
- /** The ONE atomic txn (§2.5): point the focus row at `pin`, set its occupant to
558
- * `incoming`, and write BOTH nodes' presence — incoming into the viewport, the
559
- * outgoing either backstaged (still generating) or null (its pane is reaped by
560
- * the caller). A holder/vanished outgoing (no row) gets no presence write. */
561
- function commitFocusTxn(focusId, incoming, pin, pinLoc, outgoing, action, outLoc, outgoingPane) {
562
- const db = openDb();
563
- db.exec('BEGIN');
564
- try {
565
- setFocusPane(focusId, pin, pinLoc?.session ?? null);
566
- setFocusOccupant(focusId, incoming);
567
- setPresence(incoming, { pane: pin, tmux_session: pinLoc?.session ?? null, window: pinLoc?.window ?? null });
568
- if (getRow(outgoing) !== null) {
569
- if (action.kind === 'backstage') {
570
- // The outgoing pi kept its pane id (`outgoingPane`), now in the backstage.
571
- setPresence(outgoing, { pane: outgoingPane, tmux_session: outLoc?.session ?? null, window: outLoc?.window ?? null });
572
- }
573
- else {
574
- // kill | release — the pane is reaped by the caller; null the LOCATION.
575
- setPresence(outgoing, { pane: null, tmux_session: null, window: null });
576
- }
577
- }
578
- db.exec('COMMIT');
579
- }
580
- catch (e) {
581
- db.exec('ROLLBACK');
582
- throw e;
583
- }
584
- }
585
- /** The front door for `node focus` / `node cycle` (§2.3): resolve which focus the
586
- * caller's pane acts on, then retarget `nodeId` onto it.
587
- * - `newPane` → `openFocus` a fresh viewport beside the caller (F4), then
588
- * retarget into it.
589
- * - else → retarget the caller pane's focus IN PLACE (`focusByPane`); if the
590
- * caller's pane is not yet a viewport, adopt it as one (occupied by whatever
591
- * node sits there now — `callerNode`, else resolved by pane).
592
- * - no caller pane (not in tmux) → best-effort: reconcile + report status,
593
- * not-in-place (no viewport to swap into). */
594
- export function focus(nodeId, opts) {
595
- const meta = getNode(nodeId);
596
- if (meta === null)
597
- return { focused: false, session: null, inPlace: false, revived: false };
598
- // ── Phase-4 broker-focus opt-in (headless-broker model) ────────────────────
599
- // A broker node has NO engine pane: headlessBrokerHost.launch spawns a
600
- // DETACHED broker that hosts the single engine + binds view.sock, opening no
601
- // tmux window. So focus cannot swap an engine pane into the caller's viewport
602
- // — there is none. It instead (a) ensures the broker/engine is ALIVE via the
603
- // sanctioned reviver, then (b) opens a tmux pane running the VIEWER
604
- // `crtr attach to <id>`, which connects to view.sock. The viewer NEVER
605
- // launches the engine (the one-writer invariant) — placement does, here. This
606
- // is an ISOLATED opt-in BESIDE the engine-in-pane path: it fires ONLY for
607
- // host_kind==='broker', so the tmux-host placement below stays byte-identical.
608
- if (meta.host_kind === 'broker')
609
- return focusBroker(nodeId, meta, opts);
610
- const callerPane = opts.pane ?? process.env['TMUX_PANE'] ?? currentTmux()?.pane;
611
- if (callerPane === undefined || callerPane === '') {
612
- // Not in tmux — no viewport to swap into. Reconcile and report status.
613
- reconcile(nodeId);
614
- return { focused: isNodePaneAlive(nodeId), session: meta.tmux_session ?? null, inPlace: false, revived: false };
615
- }
616
- if (opts.newPane === true) {
617
- const opened = openFocus(callerPane, {});
618
- if (opened === null)
619
- return { focused: false, session: null, inPlace: false, revived: false };
620
- const res = retargetFocus(opened.focus_id, nodeId, opts.revive);
621
- // Failed to place the incoming node — reap the just-opened HOLDER pane + its
622
- // focus row so a failed `--new-pane` leaves no orphan viewport (F4 / Invariant
623
- // P). The success path already reaps the holder via the hot-swap.
624
- if (!res.focused) {
625
- if (opened.pane !== null)
626
- closePane(opened.pane);
627
- closeFocusRow(opened.focus_id);
628
- }
629
- return res;
630
- }
631
- let f = focusByPane(callerPane);
632
- if (f === null)
633
- f = ensureFocusAtPane(callerPane, opts.callerNode);
634
- if (f === null) {
635
- return { focused: false, session: meta.tmux_session ?? null, inPlace: false, revived: false };
636
- }
637
- return retargetFocus(f.focus_id, nodeId, opts.revive);
638
- }
639
116
  const BROKER_FOCUS_SOCKET_WAIT_MS = 30_000;
640
117
  const BROKER_FOCUS_SOCKET_RETRY_MS = 100;
641
- /** Synchronously wait until a broker's view.sock accepts a connection. `focus()`
642
- * is sync today (the command layer calls it directly), so the readiness probe
643
- * lives in a short child Node process that can use async net events while this
644
- * process blocks in `spawnSync`. Success proves more than file existence: it is
645
- * robust to a stale leftover socket that the launching broker has not unlinked
646
- * yet, because only an accepting listener exits 0. */
647
- export function waitForBrokerViewSocket(nodeId) {
648
- const sockPath = join(nodeDir(nodeId), 'view.sock');
649
- const probe = `
118
+ // The probe runs inside a worker thread (see waitForBrokerViewSocket). Async net
119
+ // events drive the same poll loop; the worker reports its verdict into a shared
120
+ // Int32 (0 = pending, 1 = accepted, 2 = gave up) and Atomics.notify wakes the
121
+ // blocked main thread. eval:true CommonJS, so `require` and workerData apply.
122
+ const VIEW_SOCKET_PROBE_WORKER = `
650
123
  const net = require('node:net');
651
- const sockPath = process.argv[1];
652
- const deadline = Date.now() + Number(process.argv[2]);
653
- const delay = Number(process.argv[3]);
124
+ const { workerData, parentPort } = require('node:worker_threads');
125
+ const { buffer, sockPath, waitMs, retryMs } = workerData;
126
+ const result = new Int32Array(buffer);
127
+ const deadline = Date.now() + waitMs;
128
+ const report = (code) => { Atomics.store(result, 0, code); Atomics.notify(result, 0); };
654
129
  function attempt() {
655
130
  let socket;
656
131
  let settled = false;
@@ -658,9 +133,9 @@ function attempt() {
658
133
  if (settled) return;
659
134
  settled = true;
660
135
  if (socket !== undefined) socket.destroy();
661
- if (ok) process.exit(0);
662
- if (Date.now() >= deadline) process.exit(1);
663
- setTimeout(attempt, delay);
136
+ if (ok) { report(1); return; }
137
+ if (Date.now() >= deadline) { report(2); return; }
138
+ setTimeout(attempt, retryMs);
664
139
  };
665
140
  try {
666
141
  socket = net.createConnection(sockPath);
@@ -670,268 +145,226 @@ function attempt() {
670
145
  }
671
146
  socket.once('connect', () => finish(true));
672
147
  socket.once('error', () => finish(false));
673
- socket.setTimeout(delay, () => finish(false));
148
+ socket.setTimeout(retryMs, () => finish(false));
674
149
  }
675
150
  attempt();
151
+ void parentPort;
676
152
  `;
677
- const r = spawnSync(process.execPath, ['--input-type=commonjs', '-e', probe, sockPath, String(BROKER_FOCUS_SOCKET_WAIT_MS), String(BROKER_FOCUS_SOCKET_RETRY_MS)], {
678
- stdio: 'ignore',
679
- timeout: BROKER_FOCUS_SOCKET_WAIT_MS + 1_000,
680
- // Keep the probe deterministic: NODE_OPTIONS like --input-type=module or
681
- // --inspect-brk can break/hang a tiny `node -e` readiness check.
682
- env: { ...process.env, NODE_OPTIONS: '' },
153
+ /** Synchronously wait until a broker's view.sock accepts a connection. The spawn
154
+ * and focus flows are sync (the command layer calls them directly), so the async
155
+ * net poll runs in a worker THREAD while this thread blocks on `Atomics.wait` —
156
+ * in-process, no child Node cold-start. Success proves more than file existence:
157
+ * it is robust to a stale leftover socket the launching broker has not unlinked
158
+ * yet, because only an accepting listener yields a `connect`. */
159
+ export function waitForBrokerViewSocket(nodeId) {
160
+ const sockPath = join(nodeDir(nodeId), 'view.sock');
161
+ const buffer = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT);
162
+ const result = new Int32Array(buffer);
163
+ const worker = new Worker(VIEW_SOCKET_PROBE_WORKER, {
164
+ eval: true,
165
+ workerData: { buffer, sockPath, waitMs: BROKER_FOCUS_SOCKET_WAIT_MS, retryMs: BROKER_FOCUS_SOCKET_RETRY_MS },
683
166
  });
684
- return r.status === 0;
167
+ worker.unref();
168
+ // Block until the worker reports (or a hard timeout matching the old
169
+ // spawnSync). 'not-equal' covers the worker finishing before we wait.
170
+ Atomics.wait(result, 0, 0, BROKER_FOCUS_SOCKET_WAIT_MS + 1_000);
171
+ void worker.terminate();
172
+ return Atomics.load(result, 0) === 1;
173
+ }
174
+ function newFocusId() {
175
+ return `f-${newNodeId()}`;
176
+ }
177
+ /** Env for a `crtr attach` VIEWER pane (the focus split, the spawn viewer
178
+ * window, AND recycle's re-attach respawn): propagate CRTR_HOME so the viewer
179
+ * resolves the SAME canvas home — and thus the right view.sock — under a
180
+ * non-default override; otherwise an empty env (the pane inherits the tmux
181
+ * server env, like every other crtr chrome pane). One helper so the viewer-pane
182
+ * sites can't drift. */
183
+ export function viewerSplitEnv() {
184
+ const crtrHome = process.env['CRTR_HOME'];
185
+ return crtrHome !== undefined ? { CRTR_HOME: crtrHome } : {};
186
+ }
187
+ /** Register a `crtr attach` VIEWER pane as the node's one viewer focus row.
188
+ * UNIQUE(node_id) ⇒ one viewer per node: returns
189
+ * the existing row (no insert) when the pane or the node is already registered.
190
+ * `window` is accepted for the call contract (spawn passes it) but not stored —
191
+ * the focuses table keys on pane + session only. The attach client self-tags the
192
+ * pane `@crtr_node` on connect, so `nodeInPane` resolves it either way. */
193
+ export function registerViewerFocus(nodeId, pane, session, window) {
194
+ void window;
195
+ const byPane = getFocusByPane(pane);
196
+ if (byPane !== null)
197
+ return byPane;
198
+ const byNode = getFocusByNode(nodeId);
199
+ if (byNode !== null)
200
+ return byNode;
201
+ const focusId = newFocusId();
202
+ openFocusRow(focusId, pane, session, nodeId);
203
+ return getFocusById(focusId);
204
+ }
205
+ /** Open a `crtr attach` VIEWER for `nodeId` and register its viewer focus row —
206
+ * the shared opener used by `focus` (a split beside the caller) and by a --root
207
+ * spawn (a fresh window in the caller's current session). Wraps `ensureSession`
208
+ * + `openNodeWindow`/`splitWindow` + `registerViewerFocus`. The attach client
209
+ * connects to the node's `view.sock` and renders/drives the live broker engine
210
+ * — it NEVER launches the engine (the one-writer invariant; the caller ensures
211
+ * the broker is alive first). Returns the registered viewer row, or null if tmux
212
+ * refused.
213
+ * - default → a fresh WINDOW in `session` (the --root spawn-viewer path).
214
+ * - `besidePane` → a SPLIT beside that pane in its own session (the focus
215
+ * open-beside-the-caller path); `session` is then only a fallback. */
216
+ export function openViewerWindow(nodeId, session, opts = {}) {
217
+ const command = `crtr attach to ${nodeId}`;
218
+ const env = viewerSplitEnv();
219
+ const cwd = opts.cwd ?? process.cwd();
220
+ let pane;
221
+ let sess;
222
+ let window;
223
+ if (opts.besidePane !== undefined) {
224
+ pane = splitWindow(opts.besidePane, { cwd, env, command });
225
+ if (pane === null)
226
+ return null;
227
+ const loc = paneLocation(pane);
228
+ sess = loc?.session ?? session;
229
+ window = loc?.window ?? null;
230
+ }
231
+ else {
232
+ ensureSession(session, cwd);
233
+ const opened = openNodeWindow({ session, name: opts.name ?? nodeId, cwd, env, command });
234
+ if (opened === null)
235
+ return null;
236
+ pane = opened.pane;
237
+ window = opened.window;
238
+ sess = session;
239
+ }
240
+ return registerViewerFocus(nodeId, pane, sess, window);
685
241
  }
686
- /** Phase-4 broker-focus (headless-broker model) — open an attached VIEWER pane
687
- * for a node whose engine runs in a DETACHED broker (host_kind==='broker'), the
688
- * small opt-in beside the engine-in-pane path. Two steps:
689
- * (a) ENSURE THE BROKER (engine) IS ALIVE via the sanctioned launcher — the
690
- * injected reviver (reviveNode headlessBrokerHost.launch). A broker's
691
- * liveness IS its recorded pid (headlessBrokerHost.isAlive ===
692
- * isPidAlive(pi_pid)); reviveNode's double-revive guard makes this
693
- * idempotent: already-alive no-op, dead launch ONE detached broker
694
- * never a second engine. The VIEWER never does this; placement does.
695
- * (b) OPEN THE VIEWER PANE after a bounded view.sock readiness wait (closes
696
- * the cold-start race against attach's single connect), split a pane
697
- * beside the caller running exactly the VIEWER command `crtr attach to
698
- * <id>`, which connects to view.sock and renders/drives the live engine.
242
+ // ---------------------------------------------------------------------------
243
+ // focus — the front door for `node focus` / `node cycle` / nav (§A.4)
244
+ // ---------------------------------------------------------------------------
245
+ /** Bring a node's ONE viewer pane forefront (§A.4). Every node is a detached
246
+ * broker, so there is no engine pane to swap into the caller's viewport — focus
247
+ * instead ensures the node's single `crtr attach` viewer is on screen beside (or
248
+ * navigated to) the caller:
249
+ * (a) ensure the broker ENGINE is alive (idempotent injected reviver) and its
250
+ * view.sock is ACCEPTING (closes the cold-start race against attach's
251
+ * single connect) — without this the freshly-opened viewer would exit
252
+ * "no broker".
253
+ * (b) `--new-pane` always a fresh viewer beside the caller (e.g. two
254
+ * different nodes side by side); drop any prior viewer first (UNIQUE).
255
+ * (c) the node already has a live viewer:
256
+ * - in the caller's session ⇒ navigate to it (switchClient+selectWindow),
257
+ * no new pane (inPlace).
258
+ * - elsewhere (e.g. a --root's viewer in another session) ⇒ MOVE it: close the old
259
+ * viewer pane (the broker runs on; the fresh viewer replays full
260
+ * scrollback from the broker `welcome` snapshot), drop its row, reopen
261
+ * beside the caller.
262
+ * (d) no live viewer ⇒ open one beside the caller and register the row.
699
263
  *
700
- * No FOCUS ROW is registered: a broker viewer pane is NOT an engine pane (the
701
- * engine lives in the detached broker; pi_pid is the broker's), so it stays out
702
- * of the focuses table / reconcile / retargetFocus, which track engine-in-pane
703
- * swap semantics. (Repeated focus thus stacks viewers — accepted for the Phase-4
704
- * opt-in; the Phase-5 default-swap owns any viewer dedup.) Reaches no engine
705
- * launcher directly (placement must not import revive.ts/host.ts a cycle):
706
- * step (a) goes through the injected `revive`, step (b) through the existing
707
- * splitWindow driver verb. `--new-pane` is moot — a broker focus is ALWAYS a
708
- * fresh viewer pane — so the flag is ignored. */
709
- function focusBroker(nodeId, meta, opts) {
264
+ * crtr is tmux-only: with no caller pane there is no viewport to open and no
265
+ * non-tmux fallback report not-focused. */
266
+ export function focus(nodeId, opts) {
267
+ const meta = getNode(nodeId);
268
+ if (meta === null)
269
+ return { focused: false, session: null, inPlace: false, revived: false };
710
270
  const callerPane = opts.pane ?? process.env['TMUX_PANE'] ?? currentTmux()?.pane;
711
271
  if (callerPane === undefined || callerPane === '') {
712
- // crtr is tmux-only: no viewport to open and no non-tmux fallback.
713
272
  return { focused: false, session: null, inPlace: false, revived: false };
714
273
  }
715
- // (a) Ensure the broker is alive (idempotent). Capture prior liveness so the
716
- // FocusResult reports whether THIS focus had to launch it.
717
- const wasAlive = isPidAlive(getNode(nodeId)?.pi_pid ?? null);
274
+ // (a) Ensure the broker engine is alive + its view.sock accepts. Capture prior
275
+ // liveness so the result reports whether THIS focus had to launch it.
276
+ const wasAlive = isPidAlive(meta.pi_pid ?? null);
718
277
  opts.revive(nodeId);
719
278
  const revived = !wasAlive;
720
- // Close the cold-start race: a freshly launched broker records pid during
721
- // extension bind, then opens view.sock later; attach connects once and exits
722
- // "no broker" if we split before listen(). Probe for an ACCEPTING socket (not
723
- // mere path existence) before opening the viewer pane.
724
279
  if (!waitForBrokerViewSocket(nodeId)) {
725
280
  return { focused: false, session: null, inPlace: false, revived };
726
281
  }
727
- // Node ids are shell-safe identifiers (base36-ts + hex); no quoting needed.
728
- const pane = splitWindow(callerPane, { cwd: meta.cwd, env: viewerSplitEnv(), command: `crtr attach to ${nodeId}` });
729
- if (pane === null)
282
+ // focus always opens BESIDE the caller pane, so `fallbackSession` is only the
283
+ // session name openViewerWindow falls back to if it cannot read the freshly
284
+ // split pane's location — the caller's own session.
285
+ const callerSession = paneLocation(callerPane)?.session ?? null;
286
+ const fallbackSession = callerSession ?? '';
287
+ // (b) --new-pane → always a fresh viewer beside the caller. Drop any prior
288
+ // viewer this node holds elsewhere (UNIQUE(node_id): one viewer per node).
289
+ if (opts.newPane === true) {
290
+ const prior = focusOf(nodeId);
291
+ if (prior !== null) {
292
+ if (prior.pane !== null && paneExists(prior.pane))
293
+ closePane(prior.pane);
294
+ closeFocusRow(prior.focus_id);
295
+ }
296
+ const opened = openViewerWindow(nodeId, fallbackSession, { cwd: meta.cwd, besidePane: callerPane });
297
+ if (opened === null)
298
+ return { focused: false, session: null, inPlace: false, revived };
299
+ return { focused: true, session: opened.session, inPlace: false, revived };
300
+ }
301
+ // (c) Reuse / move the node's one live viewer — but ONLY a pane that still
302
+ // carries THIS node's `@crtr_node` tag. `paneExists` is not enough: the
303
+ // front-door root registers the user's own terminal as its viewer pane and
304
+ // runs `crtr attach` inline; on a clean detach the pane returns to the
305
+ // user's shell (attach clears the tag) yet the pane — and the row — survive,
306
+ // and a reused pane carries some OTHER node's tag. Navigating to / closing
307
+ // such a pane would strand the user on (or kill) their shell. So verify the
308
+ // tag names this node; on mismatch the row is stale → prune it and fall
309
+ // through to open a fresh viewer beside the caller.
310
+ const existing = focusOf(nodeId);
311
+ if (existing !== null && existing.pane !== null) {
312
+ if (getPaneOption(existing.pane, '@crtr_node') === nodeId) {
313
+ const loc = paneLocation(existing.pane);
314
+ if (loc !== null && loc.session === callerSession) {
315
+ // Viewer is in the caller's session — just navigate to it.
316
+ switchClient(loc.session);
317
+ selectWindow(loc.session, loc.window);
318
+ return { focused: true, session: loc.session, inPlace: true, revived };
319
+ }
320
+ // Viewer lives elsewhere — MOVE it: close the old pane (broker runs on; the
321
+ // fresh viewer replays scrollback from the welcome snapshot), drop the row,
322
+ // then fall through to open one beside the caller.
323
+ closePane(existing.pane);
324
+ closeFocusRow(existing.focus_id);
325
+ }
326
+ else {
327
+ // Pane survived but no longer hosts this node's viewer (clean detach left
328
+ // the user's shell, or the pane was reused) — drop the stale row only; do
329
+ // NOT closePane (it is the user's shell / another node's viewer).
330
+ closeFocusRow(existing.focus_id);
331
+ }
332
+ }
333
+ // (d) No live viewer → open one beside the caller and register the row.
334
+ const opened = openViewerWindow(nodeId, fallbackSession, { cwd: meta.cwd, besidePane: callerPane });
335
+ if (opened === null)
730
336
  return { focused: false, session: null, inPlace: false, revived };
731
- return { focused: true, session: paneLocation(pane)?.session ?? null, inPlace: false, revived };
732
- }
733
- /** Env for a `crtr attach` VIEWER pane (the focusBroker split AND recycle's
734
- * re-attach respawn): propagate CRTR_HOME so the viewer resolves the SAME canvas
735
- * home — and thus the right view.sock — under a non-default override; otherwise
736
- * an empty env (the pane inherits the tmux server env, like every other crtr
737
- * chrome pane). One helper so the two viewer-pane sites can't drift. */
738
- export function viewerSplitEnv() {
739
- const crtrHome = process.env['CRTR_HOME'];
740
- return crtrHome !== undefined ? { CRTR_HOME: crtrHome } : {};
741
- }
742
- /** Register the caller's CURRENT pane as a focus so a `node focus`/`cycle` from a
743
- * pane that isn't yet a viewport retargets IN PLACE. Occupied by whatever node
744
- * sits in the pane now (`callerNode`, else resolved by pane→row), or a HOLDER
745
- * when none is resolvable / it is already focused elsewhere (UNIQUE node_id). */
746
- function ensureFocusAtPane(pane, callerNode) {
747
- const existing = getFocusByPane(pane);
748
- if (existing !== null)
749
- return existing;
750
- const loc = paneLocation(pane);
751
- const focusId = newFocusId();
752
- const resolved = callerNode ?? getRowByPane(pane)?.node_id;
753
- const occupant = resolved !== undefined && resolved !== '' && getFocusByNode(resolved) === null
754
- ? resolved
755
- : holderId(focusId);
756
- openFocusRow(focusId, pane, loc?.session ?? null, occupant);
757
- armRemainOnExit(loc?.window);
758
- return getFocusById(focusId);
337
+ return { focused: true, session: opened.session, inPlace: false, revived };
759
338
  }
760
339
  // ---------------------------------------------------------------------------
761
- // Teardown / recycle / lifecycle-successor verbs (§2.3/§1.6, Step 7) the
762
- // close/demote/reset entry points + the truly-done focus successor.
340
+ // Teardown / detach close a node's viewer pane + row
763
341
  // ---------------------------------------------------------------------------
764
- /** Tear a node off its placement (close/reset teardown, §2.3, flow (e)).
765
- * Reconcile first (follow a manual move / backfill a legacy pane), close the
766
- * focus row it occupies (if any), kill its pane (pane-keyed via the durable
767
- * `%id` the window collapses once its last pane goes), and null its LOCATION.
768
- * The focus row close is the record. Best-effort tmux; the
769
- * DB writes always land. The pane kill is the sole teardown unit (Q1/§6: a
770
- * split-pane focus returns its space to the surviving split; a standalone-window
771
- * focus closes the window). */
342
+ /** Tear a node off its viewer (close/reset/cancel teardown). The broker engine
343
+ * is killed by its own host teardown over view.sock this only closes the
344
+ * on-screen viewer: kill its pane (the window collapses once its last pane goes)
345
+ * and drop its registry row. Best-effort tmux; the DB write always lands. No-op
346
+ * when the node has no viewer. */
772
347
  export function tearDownNode(nodeId) {
773
- reconcile(nodeId);
774
348
  const f = focusOf(nodeId);
349
+ if (f === null)
350
+ return; // no viewer (focusOf already GC'd a gone one)
351
+ if (f.pane !== null && paneExists(f.pane))
352
+ closePane(f.pane);
353
+ closeFocusRow(f.focus_id);
354
+ }
355
+ /** Detach a node from the foreground (the `node lifecycle --detach` / Alt+C → D
356
+ * half). Every node is a headless broker — the only thing on screen is its
357
+ * `crtr attach` VIEWER pane — so "detach" means STOP FOREGROUNDING it: close the
358
+ * viewer pane (and drop its row) and leave the broker engine running untouched,
359
+ * reconnectable by a later `focus`. `pane` is the authoritative pane the caller
360
+ * acts on (the Alt+C menu's `#{pane_id}`); falls back to the node's registered
361
+ * viewer pane. No-op (false) when there is no live viewer pane to close. */
362
+ export function detachToBackground(nodeId, pane) {
363
+ const viewer = pane ?? focusOf(nodeId)?.pane ?? null;
364
+ if (viewer === null || !paneExists(viewer))
365
+ return false;
366
+ const f = focusByPane(viewer);
775
367
  if (f !== null)
776
368
  closeFocusRow(f.focus_id);
777
- const row = getRow(nodeId);
778
- const pane = row?.pane ?? f?.pane ?? null;
779
- if (pane !== null && paneExists(pane))
780
- closePane(pane);
781
- setPresence(nodeId, { pane: null, tmux_session: null, window: null });
782
- }
783
- /** Demote's in-pane relaunch (§2.3, flow (e)): respawn `nodeId`'s launch into an
784
- * EXISTING `pane`, keeping the durable `%id` (respawn-pane -k), and record its
785
- * presence keyed on that pane. The session/window are DERIVED from the pane
786
- * itself (paneLocation), so the recycled node's LOCATION follows the pane it was
787
- * recycled into. `launch.env` is passed through verbatim — the caller (demote)
788
- * already sets CRTR_ROOT_SESSION (children → backstage) + FRONT_DOOR. Detached
789
- * respawn, since the pane is often the caller's own. Returns whether the respawn
790
- * dispatched. */
791
- export function recycleFocusPane(nodeId, pane, launch) {
792
- reconcile(nodeId);
793
- const loc = paneLocation(pane);
794
- const session = loc?.session ?? homeSessionOf(nodeId);
795
- const ok = respawnPaneDetached({ pane, cwd: launch.cwd, env: launch.env, command: launch.command });
796
- if (ok)
797
- setPresence(nodeId, { pane, tmux_session: session, window: loc?.window ?? windowOfPane(pane) });
798
- return ok;
799
- }
800
- /** §1.6 lifecycle successor — hand a truly-done focused node's viewport to its
801
- * manager. Repoints the focus row `focusId` to `managerId` (a DB swap of the
802
- * occupant). Two takeover realizations, split on the manager's liveness:
803
- * - DORMANT manager (dead pi) — ONLY when it is idle + idle-release: the row
804
- * repoint is all this does; the manager, woken by the finished node's `push
805
- * final` landing in its inbox, is revived by the external daemon INTO this
806
- * node's now-frozen focus pane (remain-on-exit), where reviveIntoPlacement's
807
- * focus-pane branch resumes it in place — no new window, no taint. A dormant
808
- * manager that is NOT idle-release (done/dead/canceled, or idle with another
809
- * intent) is one the daemon will NEVER revive, so this returns false WITHOUT
810
- * repointing — the caller then disarms the freeze (see below).
811
- * - RUNNING backstage pane (a live pi — the normal multi-child state — OR a
812
- * pi still BOOTING because the daemon already revived the manager off the
813
- * `push final` before this handoff ran): the daemon never (re)revives it,
814
- * so we must bring it into the viewport SYNCHRONOUSLY here — swap its
815
- * backstage pane into the focus slot (MAJOR 1). Otherwise the manager runs
816
- * off-screen forever while %m sits orphaned in the viewport and the focus
817
- * row lies about LOCATION.
818
- * Returns false — the caller closes the focus (Q1: closeFocusToShell disarms
819
- * remain-on-exit so the pane REAPS on exit) — whenever NO successor will claim
820
- * the frozen pane: no manager, the manager IS this node, the manager already
821
- * occupies another viewport (UNIQUE node_id: do NOT move it, §1.6 edge), the
822
- * manager row is missing, or it is a dormant manager the daemon won't revive
823
- * (not idle-release) / a live-but-paneless inline root. It does NOT repoint the
824
- * occupant on any false path — repointing while returning false would strand the
825
- * pane frozen forever with no reaper (the dead-focus-pane bug).
826
- *
827
- * Why the live swap is NOT the forbidden self-saw: `swap-pane -d` only EXCHANGES
828
- * two panes' slot positions; it never respawns or kills the finishing node's own
829
- * pi. The forbidden move is a synchronous `respawn-pane -k %m` from inside %m —
830
- * we never do that here. After the swap, %m (the dying node's pane) sits in the
831
- * manager's old backstage slot; the caller nulls this node's presence so nothing
832
- * tracks the corpse. */
833
- export function handFocusToManager(focusId, managerId) {
834
- if (managerId === null)
835
- return false;
836
- const f = getFocusById(focusId);
837
- if (f === null || managerId === f.node_id)
838
- return false;
839
- if (getFocusByNode(managerId) !== null)
840
- return false; // manager already focused elsewhere
841
- const mgr = getRow(managerId);
842
- if (mgr === null)
843
- return false; // no row to hand to
844
- // MAJOR 1 — manager with a RUNNING backstage pane → swap it into the focus
845
- // slot NOW. The daemon never revives a live node (it only respawns dead-pi
846
- // nodes), so this synchronous swap is the ONLY way it claims the frozen %m.
847
- // Commit the occupant repoint only here, on a path that genuinely takes the
848
- // pane. The gate is the PANE (`paneRunning`: exists + command running, i.e.
849
- // not a remain-on-exit corpse), NOT `isPidAlive(mgr.pi_pid)` — the recorded
850
- // pid is a STALE proxy in the lost-pane race: the finishing child's `push
851
- // final` seeds the manager's inbox mid-turn, the daemon's second pass revives
852
- // the still-unfocused manager BACKSTAGE before this agent_end runs, and the
853
- // fresh pi records its pid only at session_start, seconds into boot. Gating on
854
- // the old dead pid made this branch miss the booting manager, the dormant
855
- // branch miss it too (revive already flipped it active), and the caller reap
856
- // the user's viewport while the manager ran on invisibly backstage. A booting
857
- // pane is as swappable as a live one — swap it in; only a frozen corpse
858
- // (pane_dead=1) is excluded, preserving the dead-focus-pane guarantees.
859
- if (mgr.pane != null && paneRunning(mgr.pane) && f.pane != null) {
860
- setFocusOccupant(focusId, managerId);
861
- const focusLoc = paneLocation(f.pane); // F2's window/session — the slot mgr swaps INTO (%m is currently there)
862
- if (swapPaneInPlace(mgr.pane, f.pane) && focusLoc !== null) {
863
- setFocusPane(f.focus_id, mgr.pane, focusLoc.session); // re-anchor the focus row to mgr's pane (now in F2)
864
- setPresence(managerId, { pane: mgr.pane, tmux_session: focusLoc.session, window: focusLoc.window });
865
- }
866
- return true; // took focus (live swap) — caller doesn't disarm
867
- }
868
- // DORMANT manager → it can only enter the frozen %m via crtrd.superviseTick's
869
- // SECOND PASS, which revives a node ONLY when status==='idle' && intent===
870
- // 'idle-release' (AND pi dead + unseen inbox). We mirror just status+intent: the
871
- // other two are guaranteed by lifecycle invariants — every transition(_,
872
- // 'release') site kills pi immediately, so idle-release ⟹ pi-dead; and the
873
- // `push --final` that drove us into this done-branch just seeded the manager's
874
- // inbox. (If a future idle-release path ever leaves pi LIVE, revisit this.)
875
- // Mirror that predicate EXACTLY: a done/dead/
876
- // canceled, an idle-but-not-idle-release, or a live-but-paneless (inline root)
877
- // manager will NEVER be brought into this pane. Repointing for one of those
878
- // makes the caller SKIP closeFocusToShell ⇒ remain-on-exit stays ON ⇒ the pane
879
- // freezes forever with no reaper (the dead-focus-pane bug). So ONLY repoint +
880
- // return true when the daemon will genuinely revive it; otherwise return false
881
- // WITHOUT repointing so the caller disarms the freeze and the pane reaps on exit
882
- // (§1.6 flow (b), close-out-completely).
883
- const daemonWillRevive = mgr.status === 'idle' && mgr.intent === 'idle-release';
884
- if (!daemonWillRevive)
885
- return false;
886
- setFocusOccupant(focusId, managerId); // repoint only; the daemon revives mgr INTO the frozen %m
887
- return true;
888
- }
889
- /** Q1 close-to-shell for a truly-done focused node with no successor (§1.6 /
890
- * flow (b)): close its focus row and DISARM the pane's
891
- * freeze (`remain-on-exit` off) so it reaps when the finishing pi exits. The
892
- * stophook calls this instead of `tearDownNode` because it runs INSIDE the pane
893
- * it is closing: it cannot `closePane` its own pane (self-saw), but it is still
894
- * alive to disarm the freeze, so the pane closes on exit (return-to-shell)
895
- * rather than freezing into an orphan. (Keeps the stophook off the tmux driver,
896
- * §2.1.) */
897
- export function closeFocusToShell(focusId, nodeId) {
898
- closeFocusRow(focusId);
899
- const win = getNode(nodeId)?.window;
900
- if (win != null && win !== '')
901
- setRemainOnExit(win, false);
902
- }
903
- /** Join each of `childIds`' live panes into `targetId`'s window, lay them out
904
- * (target wide on the left, children stacked right), and focus it. Reconcile
905
- * drives both the target resolution and the per-join fix-up (a joined pane keeps
906
- * its `%id` but changes window, so its LOCATION must FOLLOW — else the daemon
907
- * reads it dormant). Caller revives dormant nodes first so they have live panes.
908
- * No-op result when the target has no live pane. */
909
- export function spreadNode(targetId, childIds, opts = {}) {
910
- reconcile(targetId);
911
- const trow = getRow(targetId);
912
- if (trow === null || trow.pane === null || !paneExists(trow.pane)) {
913
- return { window: null, session: null, joined: [], focused: false };
914
- }
915
- const tloc = paneLocation(trow.pane);
916
- if (tloc === null)
917
- return { window: null, session: null, joined: [], focused: false };
918
- const { window: targetWindow, session: targetSession } = tloc;
919
- const targetPane = trow.pane;
920
- const joined = [];
921
- for (const cid of childIds) {
922
- reconcile(cid);
923
- const crow = getRow(cid);
924
- if (crow === null || crow.pane === null || !paneExists(crow.pane) || crow.pane === targetPane)
925
- continue;
926
- if (!joinPane(crow.pane, targetWindow))
927
- continue;
928
- reconcile(cid); // fix-up: presence FOLLOWS the joined pane to the target window
929
- joined.push(cid);
930
- }
931
- if (joined.length > 0) {
932
- setWindowOption(targetWindow, 'main-pane-width', opts.mainPaneWidth ?? '60%');
933
- selectLayout(targetWindow, 'main-vertical');
934
- }
935
- const focused = switchClient(targetSession) && selectWindow(targetSession, targetWindow);
936
- return { window: targetWindow, session: targetSession, joined, focused };
369
+ return closePane(viewer);
937
370
  }