@crouton-kit/crouter 0.3.14 → 0.3.16

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 (224) hide show
  1. package/dist/build-root.d.ts +8 -0
  2. package/dist/build-root.js +30 -0
  3. package/dist/builtin-personas/design/base.md +3 -7
  4. package/dist/builtin-personas/design/orchestrator.md +4 -3
  5. package/dist/builtin-personas/developer/base.md +3 -7
  6. package/dist/builtin-personas/developer/orchestrator.md +5 -4
  7. package/dist/builtin-personas/explore/base.md +3 -7
  8. package/dist/builtin-personas/explore/orchestrator.md +1 -5
  9. package/dist/builtin-personas/general/base.md +2 -4
  10. package/dist/builtin-personas/general/orchestrator.md +2 -4
  11. package/dist/builtin-personas/lifecycle/resident.md +2 -0
  12. package/dist/builtin-personas/lifecycle/terminal.md +6 -0
  13. package/dist/builtin-personas/orchestration-kernel.md +42 -3
  14. package/dist/builtin-personas/plan/base.md +3 -5
  15. package/dist/builtin-personas/plan/orchestrator.md +5 -4
  16. package/dist/builtin-personas/plan/reviewers/architecture-fit/base.md +9 -0
  17. package/dist/builtin-personas/plan/reviewers/code-smells/base.md +9 -0
  18. package/dist/builtin-personas/plan/reviewers/pattern-consistency/base.md +9 -0
  19. package/dist/builtin-personas/plan/reviewers/requirements-coverage/base.md +9 -0
  20. package/dist/builtin-personas/plan/reviewers/security/base.md +9 -0
  21. package/dist/builtin-personas/review/base.md +3 -5
  22. package/dist/builtin-personas/review/orchestrator.md +2 -6
  23. package/dist/builtin-personas/runtime-base.md +3 -19
  24. package/dist/builtin-personas/spec/base.md +3 -5
  25. package/dist/builtin-personas/spec/orchestrator.md +4 -3
  26. package/dist/builtin-personas/spine/has-manager.md +10 -0
  27. package/dist/builtin-personas/spine/no-manager.md +2 -0
  28. package/dist/builtin-skills/skills/crouter-development/personas/SKILL.md +96 -0
  29. package/dist/builtin-skills/skills/crouter-development/personas/base-prompt/SKILL.md +49 -0
  30. package/dist/builtin-skills/skills/crouter-development/personas/orchestrator-prompt/SKILL.md +49 -0
  31. package/dist/builtin-skills/skills/planning/SKILL.md +1 -1
  32. package/dist/builtin-skills/skills/spec/SKILL.md +2 -2
  33. package/dist/cli.js +6 -29
  34. package/dist/commands/attention.js +76 -7
  35. package/dist/commands/canvas-prune.d.ts +2 -0
  36. package/dist/commands/canvas-prune.js +66 -0
  37. package/dist/commands/canvas.js +5 -8
  38. package/dist/commands/chord.d.ts +2 -0
  39. package/dist/commands/chord.js +143 -0
  40. package/dist/commands/daemon.js +8 -5
  41. package/dist/commands/dashboard.js +2 -0
  42. package/dist/commands/human/prompts.js +28 -27
  43. package/dist/commands/human/queue.js +30 -14
  44. package/dist/commands/human/shared.d.ts +26 -21
  45. package/dist/commands/human/shared.js +45 -67
  46. package/dist/commands/human.js +4 -14
  47. package/dist/commands/node.d.ts +11 -0
  48. package/dist/commands/node.js +221 -99
  49. package/dist/commands/pkg/market-inspect.js +6 -4
  50. package/dist/commands/pkg/market-manage.js +10 -6
  51. package/dist/commands/pkg/market.js +2 -4
  52. package/dist/commands/pkg/plugin-inspect.js +6 -4
  53. package/dist/commands/pkg/plugin-manage.js +12 -7
  54. package/dist/commands/pkg/plugin.js +2 -4
  55. package/dist/commands/pkg.js +0 -4
  56. package/dist/commands/push.js +178 -15
  57. package/dist/commands/revive.js +5 -3
  58. package/dist/commands/skill/author.js +6 -4
  59. package/dist/commands/skill/find.js +8 -5
  60. package/dist/commands/skill/read.js +2 -0
  61. package/dist/commands/skill/state.js +6 -4
  62. package/dist/commands/skill.js +0 -6
  63. package/dist/commands/sys/config.js +21 -7
  64. package/dist/commands/sys/doctor.js +2 -0
  65. package/dist/commands/sys/update.js +4 -0
  66. package/dist/commands/sys.js +0 -6
  67. package/dist/commands/tmux-spread.d.ts +2 -0
  68. package/dist/commands/tmux-spread.js +129 -0
  69. package/dist/core/__tests__/canvas-inbox-watcher.test.js +25 -0
  70. package/dist/core/__tests__/child-followup.test.d.ts +1 -0
  71. package/dist/core/__tests__/child-followup.test.js +83 -0
  72. package/dist/core/__tests__/close.test.d.ts +1 -0
  73. package/dist/core/__tests__/close.test.js +148 -0
  74. package/dist/core/__tests__/context-intro.test.d.ts +1 -0
  75. package/dist/core/__tests__/context-intro.test.js +196 -0
  76. package/dist/core/__tests__/daemon-boot.test.d.ts +1 -0
  77. package/dist/core/__tests__/daemon-boot.test.js +93 -0
  78. package/dist/core/__tests__/daemon-liveness.test.d.ts +1 -0
  79. package/dist/core/__tests__/daemon-liveness.test.js +223 -0
  80. package/dist/core/__tests__/focuses.test.d.ts +1 -0
  81. package/dist/core/__tests__/focuses.test.js +196 -0
  82. package/dist/core/__tests__/fork.test.d.ts +1 -0
  83. package/dist/core/__tests__/fork.test.js +91 -0
  84. package/dist/core/__tests__/home-session.test.d.ts +1 -0
  85. package/dist/core/__tests__/home-session.test.js +153 -0
  86. package/dist/core/__tests__/human-cancel-guard.test.d.ts +1 -0
  87. package/dist/core/__tests__/human-cancel-guard.test.js +49 -0
  88. package/dist/core/__tests__/keystone.test.d.ts +1 -0
  89. package/dist/core/__tests__/keystone.test.js +185 -0
  90. package/dist/core/__tests__/kickoff.test.d.ts +1 -0
  91. package/dist/core/__tests__/kickoff.test.js +89 -0
  92. package/dist/core/__tests__/lifecycle.test.d.ts +1 -0
  93. package/dist/core/__tests__/lifecycle.test.js +178 -0
  94. package/dist/core/__tests__/listing-completeness.test.d.ts +1 -0
  95. package/dist/core/__tests__/listing-completeness.test.js +31 -0
  96. package/dist/core/__tests__/memory.test.d.ts +1 -0
  97. package/dist/core/__tests__/memory.test.js +152 -0
  98. package/dist/core/__tests__/migration.test.d.ts +1 -0
  99. package/dist/core/__tests__/migration.test.js +238 -0
  100. package/dist/core/__tests__/pane-column.test.d.ts +1 -0
  101. package/dist/core/__tests__/pane-column.test.js +153 -0
  102. package/dist/core/__tests__/passive-subscription.test.js +24 -1
  103. package/dist/core/__tests__/persona-compose.test.d.ts +1 -0
  104. package/dist/core/__tests__/persona-compose.test.js +53 -0
  105. package/dist/core/__tests__/persona-subkind.test.d.ts +1 -0
  106. package/dist/core/__tests__/persona-subkind.test.js +62 -0
  107. package/dist/core/__tests__/persona.test.d.ts +1 -0
  108. package/dist/core/__tests__/persona.test.js +107 -0
  109. package/dist/core/__tests__/placement-focus.test.d.ts +1 -0
  110. package/dist/core/__tests__/placement-focus.test.js +266 -0
  111. package/dist/core/__tests__/placement-reconcile.test.d.ts +1 -0
  112. package/dist/core/__tests__/placement-reconcile.test.js +212 -0
  113. package/dist/core/__tests__/placement-revive.test.d.ts +1 -0
  114. package/dist/core/__tests__/placement-revive.test.js +238 -0
  115. package/dist/core/__tests__/placement-teardown.test.d.ts +1 -0
  116. package/dist/core/__tests__/placement-teardown.test.js +178 -0
  117. package/dist/core/__tests__/prune.test.d.ts +1 -0
  118. package/dist/core/__tests__/prune.test.js +116 -0
  119. package/dist/core/__tests__/push-final-guard.test.d.ts +1 -0
  120. package/dist/core/__tests__/push-final-guard.test.js +71 -0
  121. package/dist/core/__tests__/relaunch.test.d.ts +1 -0
  122. package/dist/core/__tests__/relaunch.test.js +334 -0
  123. package/dist/core/__tests__/reset.test.js +26 -7
  124. package/dist/core/__tests__/revive.test.d.ts +1 -0
  125. package/dist/core/__tests__/revive.test.js +217 -0
  126. package/dist/core/__tests__/spawn-root.test.d.ts +1 -0
  127. package/dist/core/__tests__/spawn-root.test.js +73 -0
  128. package/dist/core/__tests__/steer-note.test.d.ts +1 -0
  129. package/dist/core/__tests__/steer-note.test.js +39 -0
  130. package/dist/core/__tests__/stop-guard.test.d.ts +1 -0
  131. package/dist/core/__tests__/stop-guard.test.js +82 -0
  132. package/dist/core/__tests__/subcommand-tier.test.js +35 -33
  133. package/dist/core/__tests__/tmux-surface.test.d.ts +1 -0
  134. package/dist/core/__tests__/tmux-surface.test.js +105 -0
  135. package/dist/core/__tests__/unknown-path.test.js +8 -2
  136. package/dist/core/canvas/attention.d.ts +10 -0
  137. package/dist/core/canvas/attention.js +40 -0
  138. package/dist/core/canvas/canvas.d.ts +66 -7
  139. package/dist/core/canvas/canvas.js +209 -21
  140. package/dist/core/canvas/db.d.ts +8 -0
  141. package/dist/core/canvas/db.js +205 -4
  142. package/dist/core/canvas/focuses.d.ts +22 -0
  143. package/dist/core/canvas/focuses.js +81 -0
  144. package/dist/core/canvas/index.d.ts +3 -0
  145. package/dist/core/canvas/index.js +3 -0
  146. package/dist/core/canvas/labels.d.ts +27 -0
  147. package/dist/core/canvas/labels.js +36 -0
  148. package/dist/core/canvas/render.js +25 -10
  149. package/dist/core/canvas/telemetry.d.ts +14 -0
  150. package/dist/core/canvas/telemetry.js +35 -0
  151. package/dist/core/canvas/types.d.ts +115 -12
  152. package/dist/core/command.d.ts +25 -1
  153. package/dist/core/command.js +23 -15
  154. package/dist/core/config.js +36 -2
  155. package/dist/core/feed/feed.js +3 -3
  156. package/dist/core/feed/inbox.d.ts +3 -1
  157. package/dist/core/feed/inbox.js +45 -5
  158. package/dist/core/feed/passive.js +24 -11
  159. package/dist/core/help.d.ts +26 -13
  160. package/dist/core/help.js +44 -37
  161. package/dist/core/personas/index.d.ts +1 -1
  162. package/dist/core/personas/index.js +1 -1
  163. package/dist/core/personas/loader.d.ts +40 -1
  164. package/dist/core/personas/loader.js +63 -1
  165. package/dist/core/personas/resolve.d.ts +13 -6
  166. package/dist/core/personas/resolve.js +46 -34
  167. package/dist/core/runtime/bearings.d.ts +20 -0
  168. package/dist/core/runtime/bearings.js +92 -0
  169. package/dist/core/runtime/close.d.ts +14 -0
  170. package/dist/core/runtime/close.js +151 -0
  171. package/dist/core/runtime/demote.js +24 -12
  172. package/dist/core/runtime/front-door.js +1 -1
  173. package/dist/core/runtime/kickoff.d.ts +23 -6
  174. package/dist/core/runtime/kickoff.js +92 -36
  175. package/dist/core/runtime/launch.d.ts +26 -12
  176. package/dist/core/runtime/launch.js +78 -19
  177. package/dist/core/runtime/lifecycle.d.ts +13 -0
  178. package/dist/core/runtime/lifecycle.js +86 -0
  179. package/dist/core/runtime/memory.d.ts +43 -0
  180. package/dist/core/runtime/memory.js +165 -0
  181. package/dist/core/runtime/naming.d.ts +22 -0
  182. package/dist/core/runtime/naming.js +166 -0
  183. package/dist/core/runtime/nodes.d.ts +39 -1
  184. package/dist/core/runtime/nodes.js +69 -10
  185. package/dist/core/runtime/persona.d.ts +25 -0
  186. package/dist/core/runtime/persona.js +139 -0
  187. package/dist/core/runtime/placement.d.ts +299 -0
  188. package/dist/core/runtime/placement.js +688 -0
  189. package/dist/core/runtime/promote.d.ts +14 -7
  190. package/dist/core/runtime/promote.js +57 -67
  191. package/dist/core/runtime/reset.d.ts +47 -4
  192. package/dist/core/runtime/reset.js +223 -52
  193. package/dist/core/runtime/revive.d.ts +26 -2
  194. package/dist/core/runtime/revive.js +166 -39
  195. package/dist/core/runtime/spawn.d.ts +20 -5
  196. package/dist/core/runtime/spawn.js +163 -43
  197. package/dist/core/runtime/stop-guard.d.ts +1 -1
  198. package/dist/core/runtime/stop-guard.js +18 -8
  199. package/dist/core/runtime/tmux-chrome.d.ts +1 -0
  200. package/dist/core/runtime/tmux-chrome.js +4 -0
  201. package/dist/core/runtime/tmux.d.ts +113 -20
  202. package/dist/core/runtime/tmux.js +221 -39
  203. package/dist/core/spawn.js +15 -0
  204. package/dist/daemon/crtrd.d.ts +12 -1
  205. package/dist/daemon/crtrd.js +152 -34
  206. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.d.ts +1 -0
  207. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +266 -0
  208. package/dist/pi-extensions/canvas-commands.js +16 -13
  209. package/dist/pi-extensions/canvas-context-intro.d.ts +70 -0
  210. package/dist/pi-extensions/canvas-context-intro.js +164 -0
  211. package/dist/pi-extensions/canvas-goal-capture.d.ts +3 -0
  212. package/dist/pi-extensions/canvas-goal-capture.js +15 -1
  213. package/dist/pi-extensions/canvas-inbox-watcher.js +11 -0
  214. package/dist/pi-extensions/canvas-nav.d.ts +12 -4
  215. package/dist/pi-extensions/canvas-nav.js +594 -262
  216. package/dist/pi-extensions/canvas-resume.d.ts +22 -0
  217. package/dist/pi-extensions/canvas-resume.js +173 -0
  218. package/dist/pi-extensions/canvas-stophook.d.ts +16 -0
  219. package/dist/pi-extensions/canvas-stophook.js +340 -228
  220. package/dist/types.d.ts +28 -0
  221. package/dist/types.js +16 -0
  222. package/package.json +2 -2
  223. package/dist/core/runtime/presence.d.ts +0 -38
  224. package/dist/core/runtime/presence.js +0 -154
@@ -0,0 +1,688 @@
1
+ // placement.ts — the Placement MODEL layer (Steps 3–5).
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.
7
+ //
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 { getRow, getRowByPane, getNode, setPresence, openDb, openFocusRow, setFocusOccupant, closeFocusRow, getFocusByNode, getFocusByPane, getFocusById, setFocusPane, listFocuses as listFocusRows, } from '../canvas/index.js';
29
+ import { paneExists, paneLocation, paneOfWindow, windowAlive, windowOfPane, ensureSession, openNodeWindow, respawnPaneSync, respawnPaneDetached, breakPaneToSession, splitWindow, swapPaneInPlace, setRemainOnExit, closePane, currentTmux, joinPane, selectLayout, setWindowOption, switchClient, selectWindow, } from './tmux.js';
30
+ import { homeSessionOf, nodeSession, newNodeId } from './nodes.js';
31
+ // Re-export the durable REVIVE-HOME read so placement is the one front door for
32
+ // "where does this node live."
33
+ export { homeSessionOf };
34
+ // Placement is the sanctioned model-over-driver (§2.1): non-placement runtime /
35
+ // command modules that legitimately need a raw driver verb get it from here, so
36
+ // the §5.1 lint can hold "only placement.ts / tmux-chrome.ts import tmux.ts".
37
+ export { piCommand, paneLocation, currentTmux, inTmux, ensureSession, openNodeWindow, focusWindow, windowAlive, windowOfPane, respawnPane, } from './tmux.js';
38
+ export { nodeSession } from './nodes.js';
39
+ // ---------------------------------------------------------------------------
40
+ // Focus reads (Step 4) — COMPOSE over the canvas focuses table (§2.3/§4).
41
+ //
42
+ // placement is the front door for "which nodes are on a viewport"; the SQL lives
43
+ // in the canvas layer (canvas/focuses.ts) and placement just reads it, the same
44
+ // way it composes setPresence. A node occupies at most one focus (UNIQUE
45
+ // node_id, Q5), so focusOf returns a single row.
46
+ //
47
+ // The `focuses` table (canvas/focuses.ts) is the CANONICAL focus store — there is
48
+ // no focus.ptr file and no dual-write bridge. openFocus / retargetFocus /
49
+ // registerRootFocus / handFocusToManager write focus rows directly; these reads
50
+ // compose over them.
51
+ // ---------------------------------------------------------------------------
52
+ /** The focus a node occupies, or null. UNIQUE(node_id) ⇒ at most one. */
53
+ export function focusOf(nodeId) {
54
+ return getFocusByNode(nodeId);
55
+ }
56
+ /** Is this node on a viewport? */
57
+ export function isFocused(nodeId) {
58
+ return getFocusByNode(nodeId) !== null;
59
+ }
60
+ /** The focus realized by a given pane (`%id`), or null. */
61
+ export function focusByPane(pane) {
62
+ return getFocusByPane(pane);
63
+ }
64
+ /** The set of node ids currently on some focus. */
65
+ export function focusedNodes() {
66
+ return new Set(listFocusRows().map((f) => f.node_id));
67
+ }
68
+ /** Every focus row (every live viewport). */
69
+ export function listFocuses() {
70
+ return listFocusRows();
71
+ }
72
+ /** PURE reconciliation decision (§2.4) — unit-testable without a live tmux.
73
+ * Given the cached row LOCATION and what tmux currently reports, decide the
74
+ * presence patch. Mirrors the pure-core/impure-shell split (cf. `livenessVerdict`
75
+ * vs `handleLiveWindow`): this is the decision, `reconcile` wires it to the
76
+ * driver reads + `setPresence`. */
77
+ export function reconcileDecision(cached, live) {
78
+ if (cached.pane == null) {
79
+ // Legacy / no-pane row: lazily backfill the durable pane from the live
80
+ // window's active pane. Requires a complete, live window to anchor on.
81
+ if (cached.tmux_session != null && cached.window != null && live.windowPane != null) {
82
+ return {
83
+ kind: 'backfill',
84
+ pane: live.windowPane,
85
+ tmux_session: cached.tmux_session,
86
+ window: cached.window,
87
+ };
88
+ }
89
+ return { kind: 'none' };
90
+ }
91
+ // Pane-anchored row: resolve the pane's CURRENT location.
92
+ if (live.paneLoc === null) {
93
+ // The pane itself is gone — the node's pane truly closed. Null the LOCATION.
94
+ return { kind: 'gone' };
95
+ }
96
+ if (live.paneLoc.session !== cached.tmux_session || live.paneLoc.window !== cached.window) {
97
+ // The pane drifted (a manual move-pane/join-pane/break-pane). FOLLOW it:
98
+ // same pane id, new derived window/session.
99
+ return {
100
+ kind: 'follow',
101
+ pane: cached.pane,
102
+ tmux_session: live.paneLoc.session,
103
+ window: live.paneLoc.window,
104
+ };
105
+ }
106
+ // Cache already matches the pane's reality.
107
+ return { kind: 'none' };
108
+ }
109
+ /** Reconcile a node's LOCATION against tmux reality (§2.4) — the impure shell.
110
+ * Reads `row.pane`, resolves its CURRENT session/window via the driver, and
111
+ * writes the resulting presence patch through `setPresence` (never a raw UPDATE):
112
+ * - pane moved → FOLLOW (re-point window/session, keep the pane id)
113
+ * - pane gone → null the whole LOCATION
114
+ * - legacy/no pane + live window → backfill the pane from `paneOfWindow`
115
+ * A no-op when there's nothing to resolve (genuinely no pane, or the cache is
116
+ * already current). Call this before any swap/kill/focus/revive so the act lands
117
+ * on the pane's current window, never a stale one. */
118
+ export function reconcile(nodeId) {
119
+ const row = getRow(nodeId);
120
+ if (row === null)
121
+ return;
122
+ const cached = {
123
+ pane: row.pane,
124
+ tmux_session: row.tmux_session,
125
+ window: row.window,
126
+ };
127
+ // Only the read the decision needs: paneLocation when anchored on a pane, else
128
+ // paneOfWindow for the legacy backfill. Skip the driver call that can't apply.
129
+ const paneLoc = cached.pane != null ? paneLocation(cached.pane) : null;
130
+ const windowPane = cached.pane == null && cached.tmux_session != null && cached.window != null
131
+ ? paneOfWindow(cached.tmux_session, cached.window)
132
+ : null;
133
+ const decision = reconcileDecision(cached, { paneLoc, windowPane });
134
+ switch (decision.kind) {
135
+ case 'none':
136
+ return;
137
+ case 'gone':
138
+ setPresence(nodeId, { pane: null, tmux_session: null, window: null });
139
+ return;
140
+ case 'follow':
141
+ case 'backfill':
142
+ setPresence(nodeId, {
143
+ pane: decision.pane,
144
+ tmux_session: decision.tmux_session,
145
+ window: decision.window,
146
+ });
147
+ return;
148
+ }
149
+ }
150
+ // ---------------------------------------------------------------------------
151
+ // Focus reconciliation — follow a manual move of a FOCUS pane (§2.4, Q4)
152
+ // ---------------------------------------------------------------------------
153
+ /** Reconcile a FOCUS's derived `session` cache against tmux reality (§2.4, Q4) —
154
+ * the focus-row analogue of `reconcile`. A focus is anchored on its durable
155
+ * `%pane_id`; `session` is a derived cache. If the user moved the focus pane to
156
+ * another session, re-point the cache so a resume-into-focus lands in the pane's
157
+ * CURRENT session. A no-op when the focus has no pane, the cache is already
158
+ * current, or the pane is GONE — in the gone case reconcileFocus does NOT null
159
+ * the row; the caller (reviveIntoPlacement) instead falls to the backstage
160
+ * branch via `paneExists(pane)` being false. */
161
+ export function reconcileFocus(focusId) {
162
+ const f = getFocusById(focusId);
163
+ if (f === null || f.pane === null)
164
+ return;
165
+ const live = paneLocation(f.pane);
166
+ if (live === null)
167
+ return; // pane gone — backstage fall-through handles it
168
+ if (live.session !== f.session) {
169
+ setFocusPane(f.focus_id, f.pane, live.session);
170
+ }
171
+ }
172
+ // ---------------------------------------------------------------------------
173
+ // Liveness — pane-existence (§1.2, Q6)
174
+ // ---------------------------------------------------------------------------
175
+ /** Is this node's pane (its LOCATION) alive? The v3 PRIMARY liveness probe,
176
+ * PURE / non-mutating so the daemon can gate on it without side effects:
177
+ * - `pane != null` → `paneExists(pane)` (display-message on the `%id`), so a
178
+ * user moving the pane to another window/session never reads as "gone".
179
+ * - `pane == null` → window-keyed FALLBACK (`windowAlive`) for legacy/no-pane
180
+ * rows that haven't been backfilled yet.
181
+ * Accepts a node id (re-reads the row) or a `NodeRow` already in hand. */
182
+ export function isNodePaneAlive(node) {
183
+ const row = typeof node === 'string' ? getRow(node) : node;
184
+ if (row === null)
185
+ return false;
186
+ return row.pane != null
187
+ ? paneExists(row.pane)
188
+ : windowAlive(row.tmux_session, row.window);
189
+ }
190
+ export function reviveTarget(focus, focusPaneAlive, homeSession) {
191
+ if (focus !== null && focus.pane !== null && focusPaneAlive) {
192
+ return { kind: 'focus-pane', pane: focus.pane, session: focus.session ?? homeSession };
193
+ }
194
+ return { kind: 'backstage', session: homeSession };
195
+ }
196
+ /** Place a reviving node into its CORRECT location (§1.4) — the single decision
197
+ * that replaces revive.ts's old `session = meta.tmux_session ?? nodeSession()` +
198
+ * `openNodeWindow`. Reconcile first (§2.4), then dispatch on `reviveTarget`:
199
+ * - the node occupies a LIVE focus → `reconcileFocus` (resolve the pane's
200
+ * CURRENT session, Q4) and `respawn-pane -k` the pi INTO that focus pane —
201
+ * no new window (F3 resume-in-place).
202
+ * - otherwise → the node is NOT focused (or its focus pane already collapsed,
203
+ * the Step-5 limitation: remain-on-exit lands in Step 6), so it may ONLY
204
+ * (re)appear in its durable REVIVE-HOME: a fresh window in `homeSession`.
205
+ * **There is NO code path here by which a non-focused node's new-window
206
+ * targets a user session** — `openNodeWindow`'s session is `homeSession` and
207
+ * nothing else. That is the structural bug-kill.
208
+ *
209
+ * `setPresence` (the one atomic LOCATION write) records where the node landed.
210
+ * CRTR_ROOT_SESSION is forced to `homeSession` in BOTH branches so the node's
211
+ * children always flow to the backstage, never into the focus session. */
212
+ export function reviveIntoPlacement(nodeId, launch) {
213
+ // §2.4 — follow any manual pane move before acting.
214
+ reconcile(nodeId);
215
+ const focus = focusOf(nodeId);
216
+ const focusPaneAlive = focus !== null && focus.pane !== null && paneExists(focus.pane);
217
+ const homeSession = homeSessionOf(nodeId);
218
+ const decision = reviveTarget(focus, focusPaneAlive, homeSession);
219
+ // The node's children always spawn into the backstage (homeSession), never the
220
+ // focus session — force it regardless of which branch the node itself takes.
221
+ const env = { ...launch.env, CRTR_ROOT_SESSION: homeSession };
222
+ if (decision.kind === 'focus-pane') {
223
+ // F3: resume the pi INTO the live focus pane, in its CURRENT session (Q4 —
224
+ // reconcileFocus follows a user move of the focus pane). No new window.
225
+ reconcileFocus(focus.focus_id);
226
+ const f = focusOf(nodeId) ?? focus;
227
+ const pane = f.pane;
228
+ respawnPaneSync({ pane, cwd: launch.cwd, env, command: launch.command });
229
+ const window = windowOfPane(pane);
230
+ const session = f.session ?? homeSession;
231
+ setPresence(nodeId, { pane, tmux_session: session, window });
232
+ return { window, session, pane };
233
+ }
234
+ // Backstage branch — the ONLY new-window target is `homeSession` (the
235
+ // backstage `crtr` for a child). A non-focused node has NO path to a user
236
+ // session here: the bug is structurally unreachable.
237
+ const session = decision.session; // === homeSession
238
+ ensureSession(session, launch.cwd);
239
+ const opened = openNodeWindow({
240
+ session,
241
+ name: launch.name,
242
+ cwd: launch.cwd,
243
+ env,
244
+ command: launch.command,
245
+ });
246
+ const window = opened?.window ?? null;
247
+ const pane = opened?.pane ?? null;
248
+ setPresence(nodeId, { pane, window, tmux_session: session });
249
+ return { window, session, pane };
250
+ }
251
+ // ---------------------------------------------------------------------------
252
+ // Detach to background — send a still-running agent off the foreground pane into
253
+ // the backstage `crtr` session WITHOUT ending it (the `node lifecycle --detach`
254
+ // / Alt+C → D half).
255
+ // ---------------------------------------------------------------------------
256
+ /** Relocate a node's still-running agent to the background `crtr` session,
257
+ * freeing the foreground pane WITHOUT killing the pi. `break-pane` moves the
258
+ * pane out of the foreground window into a fresh window in the shared backstage
259
+ * (the pi keeps generating); the node becomes a background window — switchable
260
+ * but not rendered, like any other node. Reconcile first (act on the pane's
261
+ * CURRENT location, §2.4) and again after (presence FOLLOWS the move). No-op
262
+ * (false) when there is no live pane to relocate or tmux refuses the break.
263
+ * `pane` is the authoritative node pane the caller acts on (the Alt+C menu's
264
+ * `#{pane_id}`); falls back to the node's durable handle. */
265
+ export function detachToBackground(nodeId, pane) {
266
+ reconcile(nodeId);
267
+ const row = getRow(nodeId);
268
+ if (row === null)
269
+ return false;
270
+ const target = pane ?? row.pane;
271
+ if (target === null || !paneExists(target))
272
+ return false;
273
+ // Anchor the durable handle on the pane we relocate so the post-move reconcile
274
+ // follows the right pane.
275
+ if (row.pane !== target)
276
+ setPresence(nodeId, { pane: target });
277
+ const session = nodeSession();
278
+ ensureSession(session, row.cwd);
279
+ const ok = breakPaneToSession(target, session);
280
+ if (ok) {
281
+ // The node left its viewport for the backstage: it is now generating but
282
+ // UNFOCUSED (Invariant P / §1.3 "evicted from a focus … generating →
283
+ // backstage, no focus"). Close any focus row it held so it does not linger
284
+ // as a phantom viewport — the pane %id survives the break, so the row would
285
+ // otherwise keep resolving (`focusByPane`/`listFocuses`). A pure detach has
286
+ // no successor, so it closes the row (cf. `demote`, which hands it off).
287
+ const f = focusOf(nodeId);
288
+ if (f !== null)
289
+ closeFocusRow(f.focus_id);
290
+ }
291
+ reconcile(nodeId); // presence now points at the crtr window
292
+ return ok;
293
+ }
294
+ /** A reserved, non-node occupant for a freshly-opened viewport that has no node
295
+ * yet: openFocus splits a HOLDER pane, but `node_id` is NOT NULL, so the row
296
+ * needs a placeholder until retargetFocus swaps a real node in. retargetFocus
297
+ * REAPS a holder pane (getRow(holder) === null ⇒ not generating ⇒ kill) instead
298
+ * of backstaging it. */
299
+ function holderId(focusId) {
300
+ return `__hold_${focusId}__`;
301
+ }
302
+ function newFocusId() {
303
+ return `f-${newNodeId()}`;
304
+ }
305
+ /** signal-0 liveness probe for a pi pid (mirrors the daemon's isPidAlive). */
306
+ function pidAlive(pid) {
307
+ if (pid == null)
308
+ return false;
309
+ try {
310
+ process.kill(pid, 0);
311
+ return true;
312
+ }
313
+ catch (e) {
314
+ return e.code === 'EPERM';
315
+ }
316
+ }
317
+ /** Is a focus's OUTGOING occupant still GENERATING (a live pi doing work)? A
318
+ * still-generating node is moved to backstage by a retarget (F2 — it keeps
319
+ * running off-screen); a holder / done / dormant node has its pane reaped
320
+ * (Invariant P). A holder or vanished node (row null) is never generating. */
321
+ function isGenerating(nodeId) {
322
+ const row = getRow(nodeId);
323
+ if (row === null)
324
+ return false;
325
+ if (row.status !== 'active' && row.status !== 'idle')
326
+ return false;
327
+ return pidAlive(row.pi_pid);
328
+ }
329
+ export function outgoingDisposition(o) {
330
+ if (!o.exists)
331
+ return { kind: 'kill' };
332
+ return o.generating ? { kind: 'backstage' } : { kind: 'kill' };
333
+ }
334
+ /** The node's pane iff it is a LIVE pane (a generating-unfocused backstage pane,
335
+ * or a still-live focus pane), else null. The retarget swaps THIS pane into the
336
+ * viewport; null means the node is dormant and must be revived first. */
337
+ function livePinPane(nodeId) {
338
+ const row = getRow(nodeId);
339
+ return row?.pane != null && paneExists(row.pane) ? row.pane : null;
340
+ }
341
+ /** remain-on-exit on a focus's viewport window (F3 freeze/resume) — best-effort. */
342
+ function armRemainOnExit(window) {
343
+ if (window != null && window !== '')
344
+ setRemainOnExit(window, true);
345
+ }
346
+ /** Open a NEW viewport (§2.3, F4) — the ONLY path a new pane appears in a user
347
+ * session. Default: `splitWindow(callerPane)` beside (Q3); `newWindow` opens a
348
+ * fresh window in the caller pane's session instead. Arms `remain-on-exit` on
349
+ * the new pane's window (F3) and inserts a focuses row anchored on it, occupied
350
+ * by a HOLDER until retargetFocus swaps a real node in. A benign long-sleep
351
+ * holds the pane open until the swap; retargetFocus reaps it. Returns the row,
352
+ * or null if tmux failed. */
353
+ export function openFocus(callerPane, opts = {}) {
354
+ const HOLD = 'sleep 2147483647';
355
+ let pane;
356
+ let session;
357
+ if (opts.newWindow === true) {
358
+ const sess = paneLocation(callerPane)?.session;
359
+ if (sess === undefined)
360
+ return null;
361
+ const opened = openNodeWindow({ session: sess, name: 'focus', cwd: process.cwd(), env: {}, command: HOLD });
362
+ if (opened === null)
363
+ return null;
364
+ pane = opened.pane;
365
+ session = sess;
366
+ }
367
+ else {
368
+ pane = splitWindow(callerPane, { cwd: process.cwd(), env: {}, command: HOLD });
369
+ if (pane === null)
370
+ return null;
371
+ session = paneLocation(pane)?.session ?? null;
372
+ }
373
+ armRemainOnExit(paneLocation(pane)?.window);
374
+ const focusId = newFocusId();
375
+ openFocusRow(focusId, pane, session, holderId(focusId));
376
+ return getFocusById(focusId);
377
+ }
378
+ /** Register the FOREGROUND root's pane as focus #1 at boot (§2.6). The inline
379
+ * root owns the user's viewport, so its own pane becomes a durable focus — with
380
+ * `remain-on-exit` so a clean exit FREEZES the pane rather than detaching the
381
+ * terminal (F1). A background `--root` does NOT call this (§6): it stays a plain
382
+ * window until the user `node focus`es it. No-op when the pane or this node is
383
+ * already a focus. The focus row IS the record — no pointer to mirror. */
384
+ export function registerRootFocus(nodeId, pane, session, window) {
385
+ const byPane = getFocusByPane(pane);
386
+ if (byPane !== null)
387
+ return byPane;
388
+ const byNode = getFocusByNode(nodeId);
389
+ if (byNode !== null)
390
+ return byNode;
391
+ const focusId = newFocusId();
392
+ openFocusRow(focusId, pane, session, nodeId);
393
+ armRemainOnExit(window);
394
+ return getFocusById(focusId);
395
+ }
396
+ /** retargetFocus — the unified hot-swap (§2.5, Invariant P + Q5). Swap `incoming`
397
+ * onto focus `focusId`'s viewport, keeping the screen position invariant (no new
398
+ * window). One sqlite txn updates the focus row + BOTH nodes' presence:
399
+ * - Q5: if `incoming` already occupies ANOTHER focus, VACATE it first (close
400
+ * its row + kill its pane — the node MOVES here, no auto-retarget).
401
+ * - resolve `incoming`'s live pin pane (a backstage pane), else `revive` it
402
+ * into the backstage and read back its pane.
403
+ * - `swapPaneInPlace(pin, focusPane)`: incoming → the viewport slot; the
404
+ * outgoing occupant → incoming's old (backstage) slot, %ids preserved
405
+ * (cross-session swap confirmed by the spike).
406
+ * - outgoing still generating → backstage (F2); else reap its now-backstage
407
+ * pane (Invariant P). A holder occupant (no node row) is always reaped.
408
+ * Arms remain-on-exit on the viewport (F3); the focus row is the record. */
409
+ export function retargetFocus(focusId, incoming, revive) {
410
+ let f = getFocusById(focusId);
411
+ if (f === null)
412
+ return { focused: false, session: null, inPlace: false, revived: false };
413
+ reconcileFocus(f.focus_id);
414
+ reconcile(incoming);
415
+ f = getFocusById(focusId) ?? f;
416
+ const outgoing = f.node_id;
417
+ // Already showing this node — a no-op (focusing yourself / the live occupant).
418
+ if (outgoing === incoming) {
419
+ return { focused: true, session: f.session, inPlace: true, revived: false };
420
+ }
421
+ // Q5 vacate: incoming occupies a DIFFERENT focus — close it + kill its pane;
422
+ // the node moves here. reconcile then nulls its now-dead LOCATION.
423
+ const other = getFocusByNode(incoming);
424
+ if (other !== null && other.focus_id !== f.focus_id) {
425
+ if (other.pane !== null)
426
+ closePane(other.pane);
427
+ closeFocusRow(other.focus_id);
428
+ reconcile(incoming);
429
+ }
430
+ // Resolve incoming's live pin pane; revive into backstage if dormant.
431
+ let revived = false;
432
+ let pin = livePinPane(incoming);
433
+ if (pin === null) {
434
+ revive(incoming);
435
+ revived = true;
436
+ reconcile(incoming);
437
+ pin = livePinPane(incoming);
438
+ }
439
+ if (pin === null) {
440
+ return { focused: false, session: f.session, inPlace: false, revived };
441
+ }
442
+ const focusPane = f.pane;
443
+ // The focus has no physical pane yet (an unplaced/bridge row) OR incoming is
444
+ // already in it — adopt pin directly, no swap.
445
+ if (focusPane === null || focusPane === pin) {
446
+ const loc = paneLocation(pin);
447
+ commitFocusTxn(f.focus_id, incoming, pin, loc, outgoing, { kind: 'kill' }, null, null);
448
+ armRemainOnExit(loc?.window);
449
+ return { focused: true, session: loc?.session ?? f.session, inPlace: true, revived };
450
+ }
451
+ // The hot-swap: incoming's pane → the viewport slot; outgoing's pane →
452
+ // incoming's old (backstage) slot. %ids survive (spike-confirmed).
453
+ if (!swapPaneInPlace(pin, focusPane)) {
454
+ return { focused: false, session: f.session, inPlace: false, revived };
455
+ }
456
+ const pinLoc = paneLocation(pin); // now the viewport
457
+ const outLoc = paneLocation(focusPane); // now backstage (outgoing's new home)
458
+ const action = outgoingDisposition({ exists: getRow(outgoing) !== null, generating: isGenerating(outgoing) });
459
+ commitFocusTxn(f.focus_id, incoming, pin, pinLoc, outgoing, action, outLoc, focusPane);
460
+ // Reap the outgoing/holder pane (now backstage) when not generating — AFTER
461
+ // commit (a tmux side effect, outside the txn).
462
+ if (action.kind === 'kill')
463
+ closePane(focusPane);
464
+ armRemainOnExit(pinLoc?.window);
465
+ return { focused: true, session: pinLoc?.session ?? f.session, inPlace: true, revived };
466
+ }
467
+ /** The ONE atomic txn (§2.5): point the focus row at `pin`, set its occupant to
468
+ * `incoming`, and write BOTH nodes' presence — incoming into the viewport, the
469
+ * outgoing either backstaged (still generating) or null (its pane is reaped by
470
+ * the caller). A holder/vanished outgoing (no row) gets no presence write. */
471
+ function commitFocusTxn(focusId, incoming, pin, pinLoc, outgoing, action, outLoc, outgoingPane) {
472
+ const db = openDb();
473
+ db.exec('BEGIN');
474
+ try {
475
+ setFocusPane(focusId, pin, pinLoc?.session ?? null);
476
+ setFocusOccupant(focusId, incoming);
477
+ setPresence(incoming, { pane: pin, tmux_session: pinLoc?.session ?? null, window: pinLoc?.window ?? null });
478
+ if (getRow(outgoing) !== null) {
479
+ if (action.kind === 'backstage') {
480
+ // The outgoing pi kept its pane id (`outgoingPane`), now in the backstage.
481
+ setPresence(outgoing, { pane: outgoingPane, tmux_session: outLoc?.session ?? null, window: outLoc?.window ?? null });
482
+ }
483
+ else {
484
+ setPresence(outgoing, { pane: null, tmux_session: null, window: null });
485
+ }
486
+ }
487
+ db.exec('COMMIT');
488
+ }
489
+ catch (e) {
490
+ db.exec('ROLLBACK');
491
+ throw e;
492
+ }
493
+ }
494
+ /** The front door for `node focus` / `node cycle` (§2.3): resolve which focus the
495
+ * caller's pane acts on, then retarget `nodeId` onto it.
496
+ * - `newPane` → `openFocus` a fresh viewport beside the caller (F4), then
497
+ * retarget into it.
498
+ * - else → retarget the caller pane's focus IN PLACE (`focusByPane`); if the
499
+ * caller's pane is not yet a viewport, adopt it as one (occupied by whatever
500
+ * node sits there now — `callerNode`, else resolved by pane).
501
+ * - no caller pane (not in tmux) → best-effort: reconcile + report status,
502
+ * not-in-place (no viewport to swap into). */
503
+ export function focus(nodeId, opts) {
504
+ const meta = getNode(nodeId);
505
+ if (meta === null)
506
+ return { focused: false, session: null, inPlace: false, revived: false };
507
+ const callerPane = opts.pane ?? process.env['TMUX_PANE'] ?? currentTmux()?.pane;
508
+ if (callerPane === undefined || callerPane === '') {
509
+ // Not in tmux — no viewport to swap into. Reconcile and report status.
510
+ reconcile(nodeId);
511
+ return { focused: isNodePaneAlive(nodeId), session: meta.tmux_session ?? null, inPlace: false, revived: false };
512
+ }
513
+ if (opts.newPane === true) {
514
+ const opened = openFocus(callerPane, {});
515
+ if (opened === null)
516
+ return { focused: false, session: null, inPlace: false, revived: false };
517
+ const res = retargetFocus(opened.focus_id, nodeId, opts.revive);
518
+ // Failed to place the incoming node — reap the just-opened HOLDER pane + its
519
+ // focus row so a failed `--new-pane` leaves no orphan viewport (F4 / Invariant
520
+ // P). The success path already reaps the holder via the hot-swap.
521
+ if (!res.focused) {
522
+ if (opened.pane !== null)
523
+ closePane(opened.pane);
524
+ closeFocusRow(opened.focus_id);
525
+ }
526
+ return res;
527
+ }
528
+ let f = focusByPane(callerPane);
529
+ if (f === null)
530
+ f = ensureFocusAtPane(callerPane, opts.callerNode);
531
+ if (f === null) {
532
+ return { focused: false, session: meta.tmux_session ?? null, inPlace: false, revived: false };
533
+ }
534
+ return retargetFocus(f.focus_id, nodeId, opts.revive);
535
+ }
536
+ /** Register the caller's CURRENT pane as a focus so a `node focus`/`cycle` from a
537
+ * pane that isn't yet a viewport retargets IN PLACE. Occupied by whatever node
538
+ * sits in the pane now (`callerNode`, else resolved by pane→row), or a HOLDER
539
+ * when none is resolvable / it is already focused elsewhere (UNIQUE node_id). */
540
+ function ensureFocusAtPane(pane, callerNode) {
541
+ const existing = getFocusByPane(pane);
542
+ if (existing !== null)
543
+ return existing;
544
+ const loc = paneLocation(pane);
545
+ const focusId = newFocusId();
546
+ const resolved = callerNode ?? getRowByPane(pane)?.node_id;
547
+ const occupant = resolved !== undefined && resolved !== '' && getFocusByNode(resolved) === null
548
+ ? resolved
549
+ : holderId(focusId);
550
+ openFocusRow(focusId, pane, loc?.session ?? null, occupant);
551
+ armRemainOnExit(loc?.window);
552
+ return getFocusById(focusId);
553
+ }
554
+ // ---------------------------------------------------------------------------
555
+ // Teardown / recycle / lifecycle-successor verbs (§2.3/§1.6, Step 7) — the
556
+ // close/demote/reset entry points + the truly-done focus successor.
557
+ // ---------------------------------------------------------------------------
558
+ /** Tear a node off its placement (close/reset teardown, §2.3, flow (e)).
559
+ * Reconcile first (follow a manual move / backfill a legacy pane), close the
560
+ * focus row it occupies (if any), kill its pane (pane-keyed via the durable
561
+ * `%id` — the window collapses once its last pane goes), and null its LOCATION.
562
+ * The focus row close is the record. Best-effort tmux; the
563
+ * DB writes always land. The pane kill is the sole teardown unit (Q1/§6: a
564
+ * split-pane focus returns its space to the surviving split; a standalone-window
565
+ * focus closes the window). */
566
+ export function tearDownNode(nodeId) {
567
+ reconcile(nodeId);
568
+ const f = focusOf(nodeId);
569
+ if (f !== null)
570
+ closeFocusRow(f.focus_id);
571
+ const row = getRow(nodeId);
572
+ const pane = row?.pane ?? f?.pane ?? null;
573
+ if (pane !== null && paneExists(pane))
574
+ closePane(pane);
575
+ setPresence(nodeId, { pane: null, tmux_session: null, window: null });
576
+ }
577
+ /** Demote's in-pane relaunch (§2.3, flow (e)): respawn `nodeId`'s launch into an
578
+ * EXISTING `pane`, keeping the durable `%id` (respawn-pane -k), and record its
579
+ * presence keyed on that pane. The session/window are DERIVED from the pane
580
+ * itself (paneLocation), so the recycled node's LOCATION follows the pane it was
581
+ * recycled into. `launch.env` is passed through verbatim — the caller (demote)
582
+ * already sets CRTR_ROOT_SESSION (children → backstage) + FRONT_DOOR. Detached
583
+ * respawn, since the pane is often the caller's own. Returns whether the respawn
584
+ * dispatched. */
585
+ export function recycleFocusPane(nodeId, pane, launch) {
586
+ reconcile(nodeId);
587
+ const loc = paneLocation(pane);
588
+ const session = loc?.session ?? homeSessionOf(nodeId);
589
+ const ok = respawnPaneDetached({ pane, cwd: launch.cwd, env: launch.env, command: launch.command });
590
+ if (ok)
591
+ setPresence(nodeId, { pane, tmux_session: session, window: loc?.window ?? windowOfPane(pane) });
592
+ return ok;
593
+ }
594
+ /** §1.6 lifecycle successor — hand a truly-done focused node's viewport to its
595
+ * manager. Repoints the focus row `focusId` to `managerId` (a DB swap of the
596
+ * occupant). Two takeover realizations, split on the manager's liveness:
597
+ * - DORMANT manager (dead pi): the row repoint is all this does; the manager,
598
+ * woken by the finished node's `push final` landing in its inbox, is revived
599
+ * by the external daemon INTO this node's now-frozen focus pane
600
+ * (remain-on-exit), where reviveIntoPlacement's focus-pane branch resumes it
601
+ * in place — no new window, no taint. (UNCHANGED — the canonical takeover.)
602
+ * - LIVE manager (pi alive in the backstage, the normal multi-child state):
603
+ * the daemon never revives it (it only respawns dead-pi nodes), so we must
604
+ * bring it into the viewport SYNCHRONOUSLY here — swap its backstage pane
605
+ * into the focus slot (MAJOR 1). Otherwise the manager runs off-screen
606
+ * forever while %m sits orphaned in the viewport and the focus row lies
607
+ * about LOCATION.
608
+ * Returns false — the caller closes the focus (Q1) — when there is no manager,
609
+ * the manager IS this node, or the manager already occupies another viewport
610
+ * (UNIQUE node_id: do NOT move it, §1.6 edge).
611
+ *
612
+ * Why the live swap is NOT the forbidden self-saw: `swap-pane -d` only EXCHANGES
613
+ * two panes' slot positions; it never respawns or kills the finishing node's own
614
+ * pi. The forbidden move is a synchronous `respawn-pane -k %m` from inside %m —
615
+ * we never do that here. After the swap, %m (the dying node's pane) sits in the
616
+ * manager's old backstage slot; the caller nulls this node's presence so nothing
617
+ * tracks the corpse. */
618
+ export function handFocusToManager(focusId, managerId) {
619
+ if (managerId === null)
620
+ return false;
621
+ const f = getFocusById(focusId);
622
+ if (f === null || managerId === f.node_id)
623
+ return false;
624
+ if (getFocusByNode(managerId) !== null)
625
+ return false; // manager already focused elsewhere
626
+ setFocusOccupant(focusId, managerId);
627
+ // MAJOR 1 — LIVE backstage manager → swap it into the focus slot now. DORMANT
628
+ // managers (no live pane / dead pi) fall through unchanged: the daemon revives
629
+ // them into the frozen %m async.
630
+ const mgr = getRow(managerId);
631
+ if (mgr !== null && mgr.pane != null && isNodePaneAlive(mgr) && pidAlive(mgr.pi_pid) && f.pane != null) {
632
+ const focusLoc = paneLocation(f.pane); // F2's window/session — the slot mgr swaps INTO (%m is currently there)
633
+ if (swapPaneInPlace(mgr.pane, f.pane) && focusLoc !== null) {
634
+ setFocusPane(f.focus_id, mgr.pane, focusLoc.session); // re-anchor the focus row to mgr's pane (now in F2)
635
+ setPresence(managerId, { pane: mgr.pane, tmux_session: focusLoc.session, window: focusLoc.window });
636
+ }
637
+ }
638
+ return true; // still "took focus" — caller doesn't close
639
+ }
640
+ /** Q1 close-to-shell for a truly-done focused node with no successor (§1.6 /
641
+ * flow (b)): close its focus row and DISARM the pane's
642
+ * freeze (`remain-on-exit` off) so it reaps when the finishing pi exits. The
643
+ * stophook calls this instead of `tearDownNode` because it runs INSIDE the pane
644
+ * it is closing: it cannot `closePane` its own pane (self-saw), but it is still
645
+ * alive to disarm the freeze, so the pane closes on exit (return-to-shell)
646
+ * rather than freezing into an orphan. (Keeps the stophook off the tmux driver,
647
+ * §2.1.) */
648
+ export function closeFocusToShell(focusId, nodeId) {
649
+ closeFocusRow(focusId);
650
+ const win = getNode(nodeId)?.window;
651
+ if (win != null && win !== '')
652
+ setRemainOnExit(win, false);
653
+ }
654
+ /** Join each of `childIds`' live panes into `targetId`'s window, lay them out
655
+ * (target wide on the left, children stacked right), and focus it. Reconcile
656
+ * drives both the target resolution and the per-join fix-up (a joined pane keeps
657
+ * its `%id` but changes window, so its LOCATION must FOLLOW — else the daemon
658
+ * reads it dormant). Caller revives dormant nodes first so they have live panes.
659
+ * No-op result when the target has no live pane. */
660
+ export function spreadNode(targetId, childIds, opts = {}) {
661
+ reconcile(targetId);
662
+ const trow = getRow(targetId);
663
+ if (trow === null || trow.pane === null || !paneExists(trow.pane)) {
664
+ return { window: null, session: null, joined: [], focused: false };
665
+ }
666
+ const tloc = paneLocation(trow.pane);
667
+ if (tloc === null)
668
+ return { window: null, session: null, joined: [], focused: false };
669
+ const { window: targetWindow, session: targetSession } = tloc;
670
+ const targetPane = trow.pane;
671
+ const joined = [];
672
+ for (const cid of childIds) {
673
+ reconcile(cid);
674
+ const crow = getRow(cid);
675
+ if (crow === null || crow.pane === null || !paneExists(crow.pane) || crow.pane === targetPane)
676
+ continue;
677
+ if (!joinPane(crow.pane, targetWindow))
678
+ continue;
679
+ reconcile(cid); // fix-up: presence FOLLOWS the joined pane to the target window
680
+ joined.push(cid);
681
+ }
682
+ if (joined.length > 0) {
683
+ setWindowOption(targetWindow, 'main-pane-width', opts.mainPaneWidth ?? '60%');
684
+ selectLayout(targetWindow, 'main-vertical');
685
+ }
686
+ const focused = switchClient(targetSession) && selectWindow(targetSession, targetWindow);
687
+ return { window: targetWindow, session: targetSession, joined, focused };
688
+ }