@crouton-kit/crouter 0.3.14 → 0.3.15

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 (220) 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 +44 -66
  46. package/dist/commands/human.js +4 -14
  47. package/dist/commands/node.d.ts +11 -0
  48. package/dist/commands/node.js +221 -98
  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 +130 -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 +259 -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 +244 -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 +183 -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 +328 -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 +106 -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 +206 -4
  142. package/dist/core/canvas/focuses.d.ts +22 -0
  143. package/dist/core/canvas/focuses.js +80 -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 +27 -10
  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 +24 -12
  176. package/dist/core/runtime/launch.js +75 -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 +32 -1
  184. package/dist/core/runtime/nodes.js +60 -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 +287 -0
  188. package/dist/core/runtime/placement.js +663 -0
  189. package/dist/core/runtime/presence.d.ts +7 -15
  190. package/dist/core/runtime/presence.js +90 -66
  191. package/dist/core/runtime/promote.d.ts +14 -7
  192. package/dist/core/runtime/promote.js +57 -67
  193. package/dist/core/runtime/reset.d.ts +47 -4
  194. package/dist/core/runtime/reset.js +223 -52
  195. package/dist/core/runtime/revive.d.ts +26 -2
  196. package/dist/core/runtime/revive.js +166 -39
  197. package/dist/core/runtime/spawn.d.ts +20 -5
  198. package/dist/core/runtime/spawn.js +163 -43
  199. package/dist/core/runtime/stop-guard.d.ts +1 -1
  200. package/dist/core/runtime/stop-guard.js +18 -8
  201. package/dist/core/runtime/tmux.d.ts +100 -14
  202. package/dist/core/runtime/tmux.js +201 -28
  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 +586 -262
  216. package/dist/pi-extensions/canvas-stophook.d.ts +16 -0
  217. package/dist/pi-extensions/canvas-stophook.js +344 -228
  218. package/dist/types.d.ts +28 -0
  219. package/dist/types.js +16 -0
  220. package/package.json +1 -1
@@ -0,0 +1,107 @@
1
+ // Run: node --import tsx/esm --test src/core/__tests__/persona.test.ts
2
+ //
3
+ // The persona-transition mechanism: the two-axis model (mode × lifecycle) is
4
+ // switchable independently, and `personaDrift` detects when a node's live
5
+ // {mode,lifecycle} has diverged from the `persona_ack` it was last given
6
+ // guidance for (the central injector then delivers + commits).
7
+ import { test, before, after, beforeEach } from 'node:test';
8
+ import assert from 'node:assert/strict';
9
+ import { mkdtempSync, rmSync } from 'node:fs';
10
+ import { tmpdir } from 'node:os';
11
+ import { join } from 'node:path';
12
+ import { spawnNode } from '../runtime/nodes.js';
13
+ import { promote } from '../runtime/promote.js';
14
+ import { getNode, updateNode } from '../canvas/canvas.js';
15
+ import { closeDb } from '../canvas/db.js';
16
+ import { personaDrift, commitPersonaAck, transitionGuidance, } from '../runtime/persona.js';
17
+ let home;
18
+ before(() => {
19
+ home = mkdtempSync(join(tmpdir(), 'crtr-persona-'));
20
+ process.env['CRTR_HOME'] = home;
21
+ });
22
+ beforeEach(() => {
23
+ closeDb();
24
+ rmSync(home, { recursive: true, force: true });
25
+ });
26
+ after(() => {
27
+ closeDb();
28
+ rmSync(home, { recursive: true, force: true });
29
+ delete process.env['CRTR_HOME'];
30
+ });
31
+ // ---------------------------------------------------------------------------
32
+ // Birth: a fresh node is born acked to its own persona — no spurious drift.
33
+ // ---------------------------------------------------------------------------
34
+ test('a freshly spawned node has no persona drift (born acked to its own persona)', () => {
35
+ const meta = spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null, lifecycle: 'terminal' });
36
+ assert.deepEqual(meta.persona_ack, { mode: 'base', lifecycle: 'terminal' });
37
+ assert.equal(personaDrift(meta.node_id), null, 'no drift at birth');
38
+ });
39
+ // ---------------------------------------------------------------------------
40
+ // Fix 1: promote keeps lifecycle by default; --resident flips it.
41
+ // ---------------------------------------------------------------------------
42
+ test('promote flips mode→orchestrator and KEEPS lifecycle terminal by default', () => {
43
+ const meta = spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null, lifecycle: 'terminal' });
44
+ const res = promote(meta.node_id);
45
+ assert.equal(res.meta.mode, 'orchestrator');
46
+ assert.equal(res.meta.lifecycle, 'terminal', 'lifecycle is NOT forced to resident');
47
+ });
48
+ test('promote with resident:true ALSO flips lifecycle→resident', () => {
49
+ const meta = spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null, lifecycle: 'terminal' });
50
+ const res = promote(meta.node_id, { resident: true });
51
+ assert.equal(res.meta.mode, 'orchestrator');
52
+ assert.equal(res.meta.lifecycle, 'resident', 'resident flag flips lifecycle');
53
+ });
54
+ test('promote no longer returns a guidance field (injector is the single source)', () => {
55
+ const meta = spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null, lifecycle: 'terminal' });
56
+ const res = promote(meta.node_id);
57
+ assert.equal('guidance' in res, false, 'promote() returns facts only');
58
+ });
59
+ // ---------------------------------------------------------------------------
60
+ // personaDrift detect + commit.
61
+ // ---------------------------------------------------------------------------
62
+ test('personaDrift detects base→orchestrator after promote, then clears on commit', () => {
63
+ const meta = spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null, lifecycle: 'terminal' });
64
+ promote(meta.node_id); // mode→orchestrator, lifecycle stays terminal
65
+ const drift = personaDrift(meta.node_id);
66
+ assert.ok(drift !== null, 'a transition is detected');
67
+ assert.deepEqual(drift?.from, { mode: 'base', lifecycle: 'terminal' });
68
+ assert.deepEqual(drift?.to, { mode: 'orchestrator', lifecycle: 'terminal' });
69
+ assert.ok((drift?.guidance ?? '').length > 0, 'guidance is built');
70
+ // Caller commits the ack after delivery — drift then clears (idempotent).
71
+ commitPersonaAck(meta.node_id, drift.to);
72
+ assert.equal(personaDrift(meta.node_id), null, 'no drift after the ack is committed');
73
+ assert.deepEqual(getNode(meta.node_id)?.persona_ack, { mode: 'orchestrator', lifecycle: 'terminal' });
74
+ });
75
+ test('personaDrift detects a lifecycle-only flip (terminal→resident)', () => {
76
+ const meta = spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null, lifecycle: 'terminal' });
77
+ // Flip only the lifecycle axis — mode unchanged.
78
+ updateNode(meta.node_id, { lifecycle: 'resident' });
79
+ const drift = personaDrift(meta.node_id);
80
+ assert.ok(drift !== null);
81
+ assert.deepEqual(drift?.from, { mode: 'base', lifecycle: 'terminal' });
82
+ assert.deepEqual(drift?.to, { mode: 'base', lifecycle: 'resident' });
83
+ assert.match(drift?.guidance ?? '', /resident/i, 'guidance describes the resident state');
84
+ });
85
+ // ---------------------------------------------------------------------------
86
+ // transitionGuidance: each lifecycle case + both-axes concatenation.
87
+ // ---------------------------------------------------------------------------
88
+ test('transitionGuidance terminal→resident says it is never forced to submit a final', () => {
89
+ const meta = spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null, lifecycle: 'terminal' });
90
+ const g = transitionGuidance(meta.node_id, { mode: 'base', lifecycle: 'terminal' }, { mode: 'base', lifecycle: 'resident' });
91
+ assert.match(g, /resident/i);
92
+ assert.match(g, /never/i);
93
+ assert.doesNotMatch(g, /orchestrator/i, 'a lifecycle-only change carries no mode section');
94
+ });
95
+ test('transitionGuidance resident→terminal says it owes a final up the spine', () => {
96
+ const meta = spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null, lifecycle: 'resident' });
97
+ const g = transitionGuidance(meta.node_id, { mode: 'base', lifecycle: 'resident' }, { mode: 'base', lifecycle: 'terminal' });
98
+ assert.match(g, /terminal/i);
99
+ assert.match(g, /push final/i);
100
+ });
101
+ test('transitionGuidance concatenates BOTH sections when both axes change', () => {
102
+ const meta = spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null, lifecycle: 'terminal' });
103
+ promote(meta.node_id); // ensure roadmap/memory seeded for the orchestrator section
104
+ const g = transitionGuidance(meta.node_id, { mode: 'base', lifecycle: 'terminal' }, { mode: 'orchestrator', lifecycle: 'resident' });
105
+ assert.match(g, /orchestrator/i, 'carries the mode (base→orchestrator) section');
106
+ assert.match(g, /resident/i, 'carries the lifecycle (terminal→resident) section');
107
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,244 @@
1
+ // Run with: node --import tsx/esm --test src/core/__tests__/placement-focus.test.ts
2
+ //
3
+ // STEP 6 of the placement/focus migration: retargetFocus / openFocus / focus +
4
+ // remain-on-exit + root-boot focus #1 + the focus.ptr bridge staying consistent.
5
+ //
6
+ // Two proof tiers (mirrors placement-revive.test.ts):
7
+ // 1. PURE (no tmux): outgoingDisposition (backstage-vs-kill) and the focus.ptr
8
+ // dual-write bridge piggybacking on a real focus row WITHOUT clobbering it
9
+ // (the Step-6 bridge fix). Each is provably non-vacuous (a wrong impl fails).
10
+ // 2. Gated real-tmux: the hot-swap itself — screen position invariant (ZERO new
11
+ // user windows), the two post-swap LOCATIONs, outgoing backstaged (still
12
+ // generating) vs reaped (dormant), the Q5 vacate-old-focus path, openFocus
13
+ // splitting a holder viewport, and the front-door round-trip with UNIQUE
14
+ // node_id upheld. The swap is a real tmux op, so a faithful assertion needs
15
+ // a live server; gated {skip:!hasTmux()} like §5.2.
16
+ import { test, before, after, beforeEach } from 'node:test';
17
+ import assert from 'node:assert/strict';
18
+ import { mkdtempSync, rmSync, existsSync, unlinkSync } from 'node:fs';
19
+ import { tmpdir } from 'node:os';
20
+ import { join } from 'node:path';
21
+ import { spawnSync } from 'node:child_process';
22
+ import { createNode, getNode, setPresence } from '../canvas/canvas.js';
23
+ import { openFocusRow, getFocusByNode, getFocusById, listFocuses, } from '../canvas/focuses.js';
24
+ import { closeDb } from '../canvas/db.js';
25
+ import { crtrHome } from '../canvas/paths.js';
26
+ import { outgoingDisposition, retargetFocus, openFocus, focus as placementFocus, registerRootFocus, focusByPane, } from '../runtime/placement.js';
27
+ import { setFocus, getFocus } from '../runtime/presence.js';
28
+ let home;
29
+ let savedTmux;
30
+ function node(id, over = {}) {
31
+ return {
32
+ node_id: id,
33
+ name: id,
34
+ created: new Date().toISOString(),
35
+ cwd: '/tmp/work',
36
+ kind: 'developer',
37
+ mode: 'base',
38
+ lifecycle: 'terminal',
39
+ status: 'active',
40
+ ...over,
41
+ };
42
+ }
43
+ function hasTmux() {
44
+ return spawnSync('tmux', ['-V'], { stdio: 'ignore' }).status === 0;
45
+ }
46
+ before(() => {
47
+ home = mkdtempSync(join(tmpdir(), 'crtr-placement-focus-'));
48
+ process.env['CRTR_HOME'] = home;
49
+ savedTmux = process.env['TMUX'];
50
+ delete process.env['TMUX']; // pure tests exercise the deterministic no-tmux path
51
+ });
52
+ beforeEach(() => {
53
+ closeDb();
54
+ rmSync(home, { recursive: true, force: true });
55
+ });
56
+ after(() => {
57
+ closeDb();
58
+ rmSync(home, { recursive: true, force: true });
59
+ delete process.env['CRTR_HOME'];
60
+ if (savedTmux !== undefined)
61
+ process.env['TMUX'] = savedTmux;
62
+ });
63
+ // ---------------------------------------------------------------------------
64
+ // 1a. PURE: outgoingDisposition — backstage (still generating) vs kill.
65
+ // ---------------------------------------------------------------------------
66
+ test('outgoingDisposition: a still-generating node → BACKSTAGE (F2: keeps running off-screen)', () => {
67
+ assert.deepEqual(outgoingDisposition({ exists: true, generating: true }), { kind: 'backstage' });
68
+ });
69
+ test('outgoingDisposition: a dormant/done node → KILL (Invariant P: not-focused + not-generating ⇒ no pane)', () => {
70
+ assert.deepEqual(outgoingDisposition({ exists: true, generating: false }), { kind: 'kill' });
71
+ });
72
+ test('outgoingDisposition: a HOLDER / vanished node (no row) → KILL (never backstaged)', () => {
73
+ // A wrong impl that backstaged a holder would leak a sleep pane into crtr.
74
+ assert.deepEqual(outgoingDisposition({ exists: false, generating: true }), { kind: 'kill' });
75
+ assert.deepEqual(outgoingDisposition({ exists: false, generating: false }), { kind: 'kill' });
76
+ });
77
+ // ---------------------------------------------------------------------------
78
+ // 1b. PURE: the focus.ptr dual-write bridge PIGGYBACKS on a real focus row
79
+ // (Step-6 fix) — setFocus must NOT clobber the pane-correct row retargetFocus
80
+ // wrote. The OLD bridge closed `existing` unconditionally, replacing the real
81
+ // row with a `__focus_ptr__` row; asserting the focus_id survives fails it.
82
+ // ---------------------------------------------------------------------------
83
+ test('focus.ptr bridge: setFocus piggybacks on a REAL focus row (never clobbers it)', () => {
84
+ // Simulate a real row written by retargetFocus/openFocus (a non-bridge id).
85
+ openFocusRow('real-f', '%a', 'Suser', 'A');
86
+ setFocus('A'); // the consistency mirror retargetFocus calls at the end
87
+ assert.equal(getFocus(), 'A', 'focus.ptr names the node');
88
+ const row = getFocusByNode('A');
89
+ assert.ok(row, 'A still occupies a focus');
90
+ assert.equal(row?.focus_id, 'real-f', 'the REAL row survived (not replaced by a bridge row)');
91
+ assert.equal(row?.pane, '%a', 'the pane-correct row is intact');
92
+ assert.equal(listFocuses().length, 1, 'no duplicate bridge row was inserted (UNIQUE node_id)');
93
+ });
94
+ test('focus.ptr bridge: a plain setFocus (no real row) still creates the bridge row + getFocus reads it', () => {
95
+ setFocus('Z');
96
+ if (existsSync(join(crtrHome(), 'focus.ptr')))
97
+ unlinkSync(join(crtrHome(), 'focus.ptr'));
98
+ assert.equal(getFocus(), 'Z', 'getFocus falls back to the canonical bridge row when the ptr is gone');
99
+ });
100
+ // ---------------------------------------------------------------------------
101
+ // 2. Gated real-tmux: the hot-swap. Two isolated sessions: `user` (the user's
102
+ // terminal) + `back` (stand-in for the backstage `crtr`). Panes run `sleep`,
103
+ // never a real pi; node pi_pid is set explicitly to control "generating".
104
+ // ---------------------------------------------------------------------------
105
+ function tmuxOut(args) {
106
+ return (spawnSync('tmux', args, { encoding: 'utf8' }).stdout ?? '').trim();
107
+ }
108
+ function windowIds(session) {
109
+ return tmuxOut(['list-windows', '-t', session, '-F', '#{window_id}']).split('\n').filter((s) => s !== '');
110
+ }
111
+ function paneExistsReal(pane) {
112
+ return tmuxOut(['display-message', '-p', '-t', pane, '#{pane_id}']) === pane;
113
+ }
114
+ function paneSession(pane) {
115
+ return tmuxOut(['display-message', '-p', '-t', pane, '#{session_name}']);
116
+ }
117
+ /** A live extra pane in `session:window` running sleep; returns its `%pane_id`. */
118
+ function livePane(session, window) {
119
+ return tmuxOut(['split-window', '-d', '-P', '-F', '#{pane_id}', '-t', `${session}:${window}`, 'sleep 600']);
120
+ }
121
+ const NOREVIVE = () => {
122
+ throw new Error('reviver should not be called when the node has a live pin pane');
123
+ };
124
+ /** A reviver that opens a real backstage `sleep` window for the node and points
125
+ * its presence at it — the stand-in for reviveIntoPlacement's backstage branch. */
126
+ function backstageReviver(back) {
127
+ return (id) => {
128
+ const out = tmuxOut(['new-window', '-d', '-a', '-P', '-F', '#{window_id}\t#{pane_id}', '-t', `${back}:`, 'sleep 600']);
129
+ const [w, p] = out.split('\t');
130
+ setPresence(id, { pane: p, tmux_session: back, window: w });
131
+ };
132
+ }
133
+ async function withSessions(tag, fn) {
134
+ const user = `crtr-pf-user-${process.pid}-${tag}`;
135
+ const back = `crtr-pf-back-${process.pid}-${tag}`;
136
+ spawnSync('tmux', ['new-session', '-d', '-s', user, '-c', '/tmp', 'sleep 600']);
137
+ spawnSync('tmux', ['new-session', '-d', '-s', back, '-c', '/tmp', 'sleep 600']);
138
+ try {
139
+ await fn({ user, back, userWindow: windowIds(user)[0], backWindow: windowIds(back)[0] });
140
+ }
141
+ finally {
142
+ spawnSync('tmux', ['kill-session', '-t', user], { stdio: 'ignore' });
143
+ spawnSync('tmux', ['kill-session', '-t', back], { stdio: 'ignore' });
144
+ }
145
+ }
146
+ test('retargetFocus: outgoing GENERATING → backstaged; the viewport stays put (ZERO new user windows)', { skip: !hasTmux() }, async () => {
147
+ await withSessions('gen', async ({ user, back, userWindow, backWindow }) => {
148
+ const focusPane = livePane(user, userWindow); // R's focus pane (the viewport)
149
+ const backPane = livePane(back, backWindow); // A's live backstage pane
150
+ // R is the outgoing occupant, generating (a live pi_pid). A is incoming.
151
+ createNode(node('R', { pane: focusPane, tmux_session: user, window: userWindow, status: 'active', pi_pid: process.pid, home_session: back }));
152
+ createNode(node('A', { pane: backPane, tmux_session: back, window: backWindow, status: 'active', pi_pid: process.pid, home_session: back }));
153
+ openFocusRow('f1', focusPane, user, 'R');
154
+ const userBefore = windowIds(user).length;
155
+ const res = retargetFocus('f1', 'A', NOREVIVE);
156
+ assert.equal(res.focused, true);
157
+ assert.equal(res.revived, false, 'A had a live pin pane — no revive');
158
+ assert.equal(windowIds(user).length, userBefore, 'ZERO new windows in the user session (screen position invariant)');
159
+ // A took over the viewport: it keeps its pane id, now in the user session.
160
+ assert.equal(getFocusByNode('A')?.focus_id, 'f1', 'A now occupies the focus');
161
+ assert.equal(getFocusByNode('A')?.pane, backPane, 'the focus row anchors A\'s (now-in-viewport) pane');
162
+ assert.equal(getNode('A').pane, backPane, 'A\'s LOCATION pane id is unchanged (swap preserves %id)');
163
+ assert.equal(paneSession(backPane), user, 'A\'s pane physically moved to the user viewport');
164
+ assert.equal(getNode('A').tmux_session, user, 'A\'s LOCATION session is the viewport');
165
+ // R was backstaged (still generating), keeping its pane id, now in `back`.
166
+ assert.equal(getFocusByNode('R'), null, 'R no longer occupies any focus');
167
+ assert.equal(getNode('R').pane, focusPane, 'R kept its pane id');
168
+ assert.equal(paneExistsReal(focusPane), true, 'R\'s pane is alive (NOT reaped — it is still generating)');
169
+ assert.equal(paneSession(focusPane), back, 'R\'s pane physically moved to the backstage');
170
+ assert.equal(getNode('R').tmux_session, back, 'R\'s LOCATION session is the backstage');
171
+ assert.equal(getFocus(), 'A', 'focus.ptr followed the retarget');
172
+ });
173
+ });
174
+ test('retargetFocus: outgoing DORMANT (no live pi) → its now-backstage pane is REAPED (Invariant P)', { skip: !hasTmux() }, async () => {
175
+ await withSessions('kill', async ({ user, back, userWindow, backWindow }) => {
176
+ const focusPane = livePane(user, userWindow);
177
+ const backPane = livePane(back, backWindow);
178
+ // R is NOT generating (pi_pid null) → its pane must be reaped after the swap.
179
+ createNode(node('R', { pane: focusPane, tmux_session: user, window: userWindow, status: 'active', pi_pid: null, home_session: back }));
180
+ createNode(node('A', { pane: backPane, tmux_session: back, window: backWindow, status: 'active', pi_pid: process.pid, home_session: back }));
181
+ openFocusRow('f1', focusPane, user, 'R');
182
+ retargetFocus('f1', 'A', NOREVIVE);
183
+ assert.equal(getFocusByNode('A')?.focus_id, 'f1', 'A took the viewport');
184
+ assert.equal(paneSession(backPane), user, 'A\'s pane is in the viewport');
185
+ assert.equal(paneExistsReal(focusPane), false, 'R\'s now-backstage pane was KILLED (dormant ⇒ no pane)');
186
+ assert.equal(getNode('R').pane, null, 'R\'s LOCATION was nulled (Invariant P)');
187
+ assert.equal(getNode('R').tmux_session, null);
188
+ });
189
+ });
190
+ test('retargetFocus Q5: focusing a node already focused ELSEWHERE vacates its old focus (it MOVES, no dup)', { skip: !hasTmux() }, async () => {
191
+ await withSessions('q5', async ({ user, back, userWindow, backWindow }) => {
192
+ // R sits in focus f1 (the user viewport). M is focused in f2 (a second pane).
193
+ const focusPane = livePane(user, userWindow);
194
+ const mPane = livePane(back, backWindow); // M's current focus pane (live)
195
+ createNode(node('R', { pane: focusPane, tmux_session: user, window: userWindow, status: 'active', pi_pid: process.pid, home_session: back }));
196
+ createNode(node('M', { pane: mPane, tmux_session: back, window: backWindow, status: 'active', pi_pid: process.pid, home_session: back }));
197
+ openFocusRow('f1', focusPane, user, 'R');
198
+ openFocusRow('f2', mPane, back, 'M');
199
+ // Focus M into f1 (the user viewport): M occupies f2 ≠ f1 → vacate f2.
200
+ const res = retargetFocus('f1', 'M', backstageReviver(back));
201
+ assert.equal(res.focused, true);
202
+ assert.equal(getFocusById('f2'), null, 'the OLD focus f2 was vacated (closed)');
203
+ assert.equal(paneExistsReal(mPane), false, 'M\'s old focus pane was killed (the node moved)');
204
+ const mFocus = getFocusByNode('M');
205
+ assert.equal(mFocus?.focus_id, 'f1', 'M now occupies ONLY f1 (UNIQUE node_id upheld)');
206
+ assert.equal(listFocuses().filter((f) => f.node_id === 'M').length, 1, 'exactly one focus shows M');
207
+ assert.equal(res.revived, true, 'M was revived into the backstage after its old pane was killed');
208
+ });
209
+ });
210
+ test('openFocus: splits a NEW viewport pane beside the caller (a holder row), NOT a new window', { skip: !hasTmux() }, async () => {
211
+ await withSessions('open', async ({ user, userWindow }) => {
212
+ const callerPane = tmuxOut(['display-message', '-p', '-t', `${user}:${userWindow}`, '#{pane_id}']);
213
+ const userWinBefore = windowIds(user).length;
214
+ const f = openFocus(callerPane, {});
215
+ assert.ok(f, 'openFocus returned a focus row');
216
+ assert.equal(windowIds(user).length, userWinBefore, 'a SPLIT, not a new window (Q3 side-by-side)');
217
+ assert.ok(f.pane?.startsWith('%'), 'the row anchors the new split pane');
218
+ assert.equal(paneExistsReal(f.pane), true, 'the holder pane is live');
219
+ assert.ok(f.node_id.startsWith('__hold_'), 'occupied by a holder until a node is retargeted in');
220
+ // remain-on-exit is armed on the viewport window (F3).
221
+ const win = tmuxOut(['display-message', '-p', '-t', f.pane, '#{window_id}']);
222
+ assert.equal(tmuxOut(['show-window-options', '-t', win, 'remain-on-exit']), 'remain-on-exit on', 'remain-on-exit armed (F3)');
223
+ });
224
+ });
225
+ test('focus front-door: round-trip open(register #1) → retarget in place → the focus follows the viewport', { skip: !hasTmux() }, async () => {
226
+ await withSessions('frontdoor', async ({ user, back, userWindow, backWindow }) => {
227
+ const rootPane = tmuxOut(['display-message', '-p', '-t', `${user}:${userWindow}`, '#{pane_id}']);
228
+ const aPane = livePane(back, backWindow);
229
+ createNode(node('R', { pane: rootPane, tmux_session: user, window: userWindow, status: 'active', pi_pid: process.pid, home_session: user }));
230
+ createNode(node('A', { pane: aPane, tmux_session: back, window: backWindow, status: 'active', pi_pid: process.pid, home_session: back }));
231
+ // Root boot registers focus #1 on the root's own pane.
232
+ const f1 = registerRootFocus('R', rootPane, user, userWindow);
233
+ assert.ok(f1, 'focus #1 registered');
234
+ assert.equal(focusByPane(rootPane)?.node_id, 'R');
235
+ // node focus A from the root's pane → retarget IN PLACE (same focus row).
236
+ const res = placementFocus('A', { pane: rootPane, callerNode: 'R', revive: NOREVIVE });
237
+ assert.equal(res.inPlace, true);
238
+ assert.equal(getFocusByNode('A')?.focus_id, f1.focus_id, 'the SAME focus row now shows A (no new focus)');
239
+ assert.equal(listFocuses().length, 1, 'still exactly one focus (retarget, not open)');
240
+ assert.equal(getFocusByNode('R'), null, 'R yielded the viewport');
241
+ // The focus followed the viewport: focusByPane on A's (now-in-viewport) pane resolves it.
242
+ assert.equal(focusByPane(aPane)?.node_id, 'A', 'the focus row tracks A\'s pane');
243
+ });
244
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,212 @@
1
+ // Run with: node --import tsx/esm --test src/core/__tests__/placement-reconcile.test.ts
2
+ //
3
+ // STEP 3 of the placement/focus migration: pane-anchored reconciliation (Q6) —
4
+ // the robustness step. A manual move-pane/join-pane/break-pane must NEVER read
5
+ // as a node death; liveness becomes pane-existence, and reconcile makes crtr
6
+ // FOLLOW a move instead of fighting it. Covers:
7
+ // - reconcileDecision (PURE, no tmux): pane moved → cache FOLLOWS; pane gone →
8
+ // LOCATION nulled; never returns a stale window; legacy no-pane + live window
9
+ // → backfills the pane from paneOfWindow.
10
+ // - reconcile (impure shell over a real temp-db row, tmux absent): exercises
11
+ // the gone / no-op paths through setPresence.
12
+ // - isNodePaneAlive: pane present → paneExists path; pane null + window →
13
+ // windowAlive fallback (with a real, live tmux window when available).
14
+ import { test, before, after, beforeEach } from 'node:test';
15
+ import assert from 'node:assert/strict';
16
+ import { mkdtempSync, rmSync } from 'node:fs';
17
+ import { tmpdir } from 'node:os';
18
+ import { join } from 'node:path';
19
+ import { spawnSync } from 'node:child_process';
20
+ import { createNode, getNode, getRow } from '../canvas/canvas.js';
21
+ import { closeDb } from '../canvas/db.js';
22
+ import { reconcileDecision, reconcile, isNodePaneAlive, } from '../runtime/placement.js';
23
+ let home;
24
+ function node(id, over = {}) {
25
+ return {
26
+ node_id: id,
27
+ name: id,
28
+ created: new Date().toISOString(),
29
+ cwd: '/tmp/work',
30
+ kind: 'general',
31
+ mode: 'base',
32
+ lifecycle: 'terminal',
33
+ status: 'active',
34
+ ...over,
35
+ };
36
+ }
37
+ function hasTmux() {
38
+ return spawnSync('tmux', ['-V'], { stdio: 'ignore' }).status === 0;
39
+ }
40
+ before(() => {
41
+ home = mkdtempSync(join(tmpdir(), 'crtr-placement-'));
42
+ process.env['CRTR_HOME'] = home;
43
+ });
44
+ beforeEach(() => {
45
+ closeDb();
46
+ rmSync(home, { recursive: true, force: true });
47
+ });
48
+ after(() => {
49
+ closeDb();
50
+ rmSync(home, { recursive: true, force: true });
51
+ delete process.env['CRTR_HOME'];
52
+ });
53
+ // ---------------------------------------------------------------------------
54
+ // reconcileDecision — the PURE robustness core (§2.4, Q6). No tmux.
55
+ // ---------------------------------------------------------------------------
56
+ const cached = (over = {}) => ({
57
+ pane: '%5',
58
+ tmux_session: 'crtr',
59
+ window: '@1',
60
+ ...over,
61
+ });
62
+ const probe = (over = {}) => ({
63
+ paneLoc: null,
64
+ windowPane: null,
65
+ ...over,
66
+ });
67
+ test('reconcileDecision: pane MOVED → cache FOLLOWS to the new session/window (same pane id)', () => {
68
+ const d = reconcileDecision(cached({ pane: '%5', tmux_session: 'crtr', window: '@1' }), probe({ paneLoc: { session: 'user-sess', window: '@9' } }));
69
+ assert.deepEqual(d, { kind: 'follow', pane: '%5', tmux_session: 'user-sess', window: '@9' });
70
+ });
71
+ test('reconcileDecision: pane GONE (paneLocation null) → LOCATION nulled', () => {
72
+ const d = reconcileDecision(cached({ pane: '%5' }), probe({ paneLoc: null }));
73
+ assert.deepEqual(d, { kind: 'gone' });
74
+ });
75
+ test('reconcileDecision: pane present + cache already current → no-op (never a stale write)', () => {
76
+ const d = reconcileDecision(cached({ pane: '%5', tmux_session: 'crtr', window: '@1' }), probe({ paneLoc: { session: 'crtr', window: '@1' } }));
77
+ assert.deepEqual(d, { kind: 'none' });
78
+ });
79
+ test('reconcileDecision: NEVER returns a stale window — a follow always carries the LIVE location', () => {
80
+ // Window renumbered under the same pane (@1 → @4); follow must report @4, the
81
+ // live value, not the cached @1.
82
+ const d = reconcileDecision(cached({ pane: '%5', tmux_session: 'crtr', window: '@1' }), probe({ paneLoc: { session: 'crtr', window: '@4' } }));
83
+ assert.equal(d.kind, 'follow');
84
+ assert.equal(d.window, '@4', 'follow carries the live window, never the stale cache');
85
+ });
86
+ test('reconcileDecision: legacy no-pane + live window → BACKFILLS the pane from paneOfWindow', () => {
87
+ const d = reconcileDecision(cached({ pane: null, tmux_session: 'crtr', window: '@1' }), probe({ windowPane: '%7' }));
88
+ assert.deepEqual(d, { kind: 'backfill', pane: '%7', tmux_session: 'crtr', window: '@1' });
89
+ });
90
+ test('reconcileDecision: no pane + no live window pane → no-op (nothing to anchor on)', () => {
91
+ assert.deepEqual(reconcileDecision(cached({ pane: null, tmux_session: 'crtr', window: '@1' }), probe({ windowPane: null })), { kind: 'none' }, 'window has no resolvable active pane → nothing to backfill');
92
+ });
93
+ test('reconcileDecision: no pane + no window at all → no-op (a dormant/inline-root row)', () => {
94
+ assert.deepEqual(reconcileDecision(cached({ pane: null, tmux_session: null, window: null }), probe({ windowPane: '%7' })), { kind: 'none' }, 'no window to anchor a backfill on → no-op even if a stray windowPane was read');
95
+ });
96
+ // ---------------------------------------------------------------------------
97
+ // reconcile / isNodePaneAlive — the impure shells. These call the real tmux
98
+ // driver, so the machine may have a live tmux server with arbitrary panes; we
99
+ // must never assume a hardcoded pane/session id is dead. Two robust strategies:
100
+ // • "absent" assertions use a UNIQUELY-named session that cannot exist
101
+ // (windowAlive/paneOfWindow on it are deterministically empty — robust
102
+ // whether or not a server is running).
103
+ // • "present/dead-pane" assertions create a real session and a real pane,
104
+ // killing it to get a guaranteed-dead `%id`. Gated on hasTmux().
105
+ // ---------------------------------------------------------------------------
106
+ /** A session name guaranteed not to exist on any server. */
107
+ const ghostSession = `crtr-ghost-${process.pid}`;
108
+ /** Run `fn` with a fresh detached session; tear it down after. */
109
+ async function withSession(tag, fn) {
110
+ const session = `crtr-placement-${process.pid}-${tag}`;
111
+ spawnSync('tmux', ['new-session', '-d', '-s', session, '-c', '/tmp', 'sleep 600']);
112
+ try {
113
+ await fn(session);
114
+ }
115
+ finally {
116
+ spawnSync('tmux', ['kill-session', '-t', session], { stdio: 'ignore' });
117
+ }
118
+ }
119
+ function tmuxOut(args) {
120
+ return (spawnSync('tmux', args, { encoding: 'utf8' }).stdout ?? '').trim();
121
+ }
122
+ /** A guaranteed-DEAD `%pane_id` inside a still-live `window` of `session`: split
123
+ * a fresh pane, capture its id, kill it. The window survives via its original
124
+ * pane, so the id is dead while its old window is alive. */
125
+ function makeDeadPane(session, window) {
126
+ const dead = tmuxOut(['split-window', '-d', '-P', '-F', '#{pane_id}', '-t', `${session}:${window}`, 'sleep 600']);
127
+ spawnSync('tmux', ['kill-pane', '-t', dead], { stdio: 'ignore' });
128
+ return dead;
129
+ }
130
+ test('reconcile: an unknown node is a silent no-op', () => {
131
+ assert.doesNotThrow(() => reconcile('ghost'));
132
+ });
133
+ test('reconcile: no pane + a non-existent window → no-op (cache left intact, not nulled)', () => {
134
+ // A ghost session can never resolve an active pane → backfill cannot fire and
135
+ // reconcile leaves the (already pane-less) cache exactly as it was.
136
+ createNode(node('n', { pane: null, tmux_session: ghostSession, window: '@1' }));
137
+ reconcile('n');
138
+ const r = getRow('n');
139
+ assert.equal(r?.pane, null, 'still no pane');
140
+ assert.equal(r?.tmux_session, ghostSession, 'session left intact (no-op, not nulled)');
141
+ assert.equal(r?.window, '@1', 'window left intact');
142
+ });
143
+ test('reconcile: a DEAD pane → LOCATION nulled (gone branch)', { skip: !hasTmux() }, async () => {
144
+ await withSession('gone', async (session) => {
145
+ const window = tmuxOut(['display-message', '-p', '-t', `${session}:`, '#{window_id}']);
146
+ const dead = makeDeadPane(session, window);
147
+ createNode(node('n', { pane: dead, tmux_session: session, window }));
148
+ reconcile('n');
149
+ const r = getRow('n');
150
+ assert.equal(r?.pane, null, 'pane nulled');
151
+ assert.equal(r?.tmux_session, null, 'session nulled');
152
+ assert.equal(r?.window, null, 'window nulled');
153
+ });
154
+ });
155
+ // ---------------------------------------------------------------------------
156
+ // isNodePaneAlive — pane-existence primary, windowAlive fallback.
157
+ // ---------------------------------------------------------------------------
158
+ test('isNodePaneAlive: an unknown node id is not alive', () => {
159
+ assert.equal(isNodePaneAlive('ghost'), false);
160
+ });
161
+ test('isNodePaneAlive: pane null + a non-existent window → not alive (windowAlive fallback false)', () => {
162
+ createNode(node('legacy', { pane: null, tmux_session: ghostSession, window: '@1' }));
163
+ assert.equal(isNodePaneAlive('legacy'), false, 'no pane + dead window → fallback false');
164
+ });
165
+ test('isNodePaneAlive: a DEAD pane is NOT alive (paneExists path), id or row alike', { skip: !hasTmux() }, async () => {
166
+ await withSession('dead', async (session) => {
167
+ const window = tmuxOut(['display-message', '-p', '-t', `${session}:`, '#{window_id}']);
168
+ const dead = makeDeadPane(session, window);
169
+ createNode(node('n', { pane: dead, tmux_session: session, window }));
170
+ assert.equal(isNodePaneAlive('n'), false, 'a killed pane → not alive (by id)');
171
+ assert.equal(isNodePaneAlive(getRow('n')), false, 'same verdict passed a NodeRow directly');
172
+ });
173
+ });
174
+ // ---------------------------------------------------------------------------
175
+ // Real-tmux behavior (gated): the windowAlive fallback + lazy backfill, and a
176
+ // live pane FOLLOWING a manual move (the Q6 robustness core).
177
+ // ---------------------------------------------------------------------------
178
+ test('isNodePaneAlive: pane null + a LIVE window → alive via the windowAlive fallback (+ lazy backfill)', { skip: !hasTmux() }, async () => {
179
+ await withSession('alive', async (session) => {
180
+ const window = tmuxOut(['list-windows', '-t', session, '-F', '#{window_id}']).split('\n')[0];
181
+ createNode(node('legacy', { pane: null, tmux_session: session, window }));
182
+ assert.equal(isNodePaneAlive('legacy'), true, 'no pane but a live window → alive (legacy fallback)');
183
+ // reconcile lazily backfills that legacy row's pane from the live window.
184
+ reconcile('legacy');
185
+ const got = getNode('legacy');
186
+ assert.ok(got?.pane != null && got.pane.startsWith('%'), 'reconcile backfilled the pane from paneOfWindow');
187
+ assert.equal(got?.tmux_session, session, 'session preserved through the backfill');
188
+ assert.equal(got?.window, window, 'window preserved through the backfill');
189
+ });
190
+ });
191
+ test('isNodePaneAlive + reconcile: a LIVE pane is alive and FOLLOWS a join-pane move to another window', { skip: !hasTmux() }, async () => {
192
+ await withSession('move', async (session) => {
193
+ // Original window W0 and its active pane P.
194
+ const w0 = tmuxOut(['display-message', '-p', '-t', `${session}:`, '#{window_id}']);
195
+ const pane = tmuxOut(['display-message', '-p', '-t', `${session}:${w0}`, '#{pane_id}']);
196
+ // A second window W1 to move P into.
197
+ const w1 = tmuxOut(['new-window', '-d', '-P', '-F', '#{window_id}', '-t', `${session}:`, 'sleep 600']);
198
+ assert.ok(pane.startsWith('%') && w0.startsWith('@') && w1.startsWith('@') && w0 !== w1);
199
+ createNode(node('m', { pane, tmux_session: session, window: w0 }));
200
+ assert.equal(isNodePaneAlive('m'), true, 'a live pane reads alive');
201
+ // Manually join P into W1 — the Q6 robustness scenario. P's id survives; W0
202
+ // (now empty) closes. This must NOT read as a death.
203
+ spawnSync('tmux', ['join-pane', '-s', pane, '-t', `${session}:${w1}`], { stdio: 'ignore' });
204
+ assert.equal(isNodePaneAlive('m'), true, 'a manual join-pane is NOT a death — the pane still exists');
205
+ // reconcile FOLLOWS the move: same pane id, new window.
206
+ reconcile('m');
207
+ const got = getNode('m');
208
+ assert.equal(got?.pane, pane, 'pane id is invariant across the move');
209
+ assert.equal(got?.window, w1, 'window FOLLOWED the join-pane to W1');
210
+ assert.equal(got?.tmux_session, session, 'still the same session');
211
+ });
212
+ });
@@ -0,0 +1 @@
1
+ export {};