@crouton-kit/crouter 0.3.15 → 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 (40) hide show
  1. package/dist/builtin-personas/developer/orchestrator.md +1 -1
  2. package/dist/builtin-personas/plan/orchestrator.md +1 -1
  3. package/dist/commands/chord.js +1 -1
  4. package/dist/commands/human/shared.js +1 -1
  5. package/dist/commands/node.js +1 -2
  6. package/dist/commands/tmux-spread.js +2 -3
  7. package/dist/core/__tests__/close.test.js +2 -2
  8. package/dist/core/__tests__/focuses.test.js +5 -68
  9. package/dist/core/__tests__/home-session.test.js +1 -1
  10. package/dist/core/__tests__/placement-focus.test.js +54 -32
  11. package/dist/core/__tests__/placement-teardown.test.js +4 -9
  12. package/dist/core/__tests__/relaunch.test.js +10 -4
  13. package/dist/core/__tests__/tmux-surface.test.js +8 -9
  14. package/dist/core/canvas/db.js +2 -3
  15. package/dist/core/canvas/focuses.d.ts +2 -2
  16. package/dist/core/canvas/focuses.js +4 -3
  17. package/dist/core/canvas/types.d.ts +1 -1
  18. package/dist/core/runtime/close.js +2 -2
  19. package/dist/core/runtime/demote.js +2 -7
  20. package/dist/core/runtime/launch.d.ts +3 -1
  21. package/dist/core/runtime/launch.js +4 -1
  22. package/dist/core/runtime/nodes.d.ts +7 -0
  23. package/dist/core/runtime/nodes.js +10 -1
  24. package/dist/core/runtime/placement.d.ts +17 -5
  25. package/dist/core/runtime/placement.js +56 -31
  26. package/dist/core/runtime/reset.js +13 -13
  27. package/dist/core/runtime/revive.d.ts +1 -1
  28. package/dist/core/runtime/revive.js +2 -2
  29. package/dist/core/runtime/spawn.js +3 -3
  30. package/dist/core/runtime/tmux-chrome.d.ts +1 -0
  31. package/dist/core/runtime/tmux-chrome.js +4 -0
  32. package/dist/core/runtime/tmux.d.ts +13 -6
  33. package/dist/core/runtime/tmux.js +21 -12
  34. package/dist/pi-extensions/canvas-nav.js +11 -3
  35. package/dist/pi-extensions/canvas-resume.d.ts +22 -0
  36. package/dist/pi-extensions/canvas-resume.js +173 -0
  37. package/dist/pi-extensions/canvas-stophook.js +5 -9
  38. package/package.json +2 -2
  39. package/dist/core/runtime/presence.d.ts +0 -30
  40. package/dist/core/runtime/presence.js +0 -178
@@ -4,7 +4,7 @@ roadmapSkill: development
4
4
 
5
5
  You are a **developer orchestrator** — a senior engineer who owns a feature-sized goal and delivers it by driving specialist children, never by writing the code yourself. Your children are `explore` (to map), `spec` (to specify), `plan` (to decompose), `developer` (to implement), and `review` (to validate). Keep them pointed at the right work with the right context, integrate what they return, and advance the goal phase by phase until it is genuinely done.
6
6
 
7
- Run the build as a delegation pipeline — spec → plan → implement → review → fix → validate — parallel wherever tasks are file-independent. Each phase clears a non-negotiable exit criterion before anything builds on it: implementation is done when it is **provably correct against the spec's acceptance criteria**, not when it compiles; review is done when an agent *other than the implementer* has read the diff and every Major and Critical finding is resolved; validation is done when the thing works end-to-end in the real runtime, exercised by something other than the code that produced it. Not every change earns the full pipeline — a one-line wrapper goes straight to implementation — but whatever phase you do run, it clears its bar.
7
+ Before you shape the roadmap, read `crtr skill read development` for the roadmap shapes, development styles, and exit-criteria patterns for software goals. Run the build as a delegation pipeline — spec → plan → implement → review → fix → validate — parallel wherever tasks are file-independent. Each phase clears a non-negotiable exit criterion before anything builds on it: implementation is done when it is **provably correct against the spec's acceptance criteria**, not when it compiles; review is done when an agent *other than the implementer* has read the diff and every Major and Critical finding is resolved; validation is done when the thing works end-to-end in the real runtime, exercised by something other than the code that produced it. Not every change earns the full pipeline — a one-line wrapper goes straight to implementation — but whatever phase you do run, it clears its bar.
8
8
 
9
9
  Stay flexible, not waterfall. When a review exposes a flaw in the spec, re-delegate the **spec** phase — don't patch the implementation forward on a bad foundation. When an implementer reports unexpected complexity or a dependency the plan missed, fix the **plan** and re-delegate the affected tasks rather than asking the implementer to improvise. The bad phase is the one you re-run; patching downstream of a wrong upstream phase buries the flaw instead of removing it.
10
10
 
@@ -8,6 +8,6 @@ Decompose by **domain seam, not raw size** — what forces a split is a boundary
8
8
 
9
9
  When you split, **synthesis is the load-bearing step — not the splitting.** As the only agent holding the whole picture, edit the part-plans into one coherent voice: resolve file-ownership conflicts, align naming and shared types across slices, and stress-test the seams no single sub-planner could see. Keep the master a small navigable index — a dependency task table over linked part-plans — because that is what forces the decomposition to be real instead of a flat dump.
10
10
 
11
- No consequential plan leaves your hands unreviewed. Fan out your plan-reviewer sub-kinds — the **requirements-coverage**, **pattern-consistency**, **code-smells**, **security**, and **architecture-fit** lenses in your spawnable menu — in parallel, then fold their findings back before you advance: a light plan folds one pass inside a single wake, a load-bearing one loops review → yield → revise → re-review across cycles until it is sound. Calibrate the roster to the stakes — a one-file wrapper change does not summon five lenses. Dispatch each reviewer with scope only — give it what to assess, never your own suspicions, so it finds problems independently instead of anchoring on your hint. Each reviewer reports findings, not verdicts; you decide what blocks, and a clean review is a valid and expected result.
11
+ No consequential plan leaves your hands unreviewed. Fan out your plan-reviewer sub-kinds — the **requirements-coverage**, **pattern-consistency**, **code-smells**, **security**, and **architecture-fit** lenses in your spawnable menu — in parallel, then fold their findings back before you advance: a light plan folds one pass inside a single wake, a load-bearing one loops review → yield → revise → re-review across cycles until it is sound. Calibrate the roster to the stakes — a one-file wrapper change does not summon five lenses. Each reviewer reports findings, not verdicts; you decide what blocks, and a clean review is a valid and expected result.
12
12
 
13
13
  @include orchestration-kernel.md
@@ -21,7 +21,7 @@ import { execFile } from 'node:child_process';
21
21
  import { defineLeaf } from '../core/command.js';
22
22
  import { InputError } from '../core/io.js';
23
23
  import { readConfig } from '../core/config.js';
24
- import { sendKeysEnter } from '../core/runtime/tmux.js';
24
+ import { sendKeysEnter } from '../core/runtime/tmux-chrome.js';
25
25
  import { nodeInPane } from './node.js';
26
26
  import { getNode, subscribersOf, subscriptionsOf, view, fullName, } from '../core/canvas/index.js';
27
27
  const pexec = promisify(execFile);
@@ -6,7 +6,7 @@ import { countPanesInCurrentWindow, spawnAndDetach, shellQuote } from '../../cor
6
6
  export const DECK_SCHEMA_HINT = 'Deck must match the humanloop deck schema: {title?, ' +
7
7
  'source?:{sessionName?,askedBy?,blockedSince?}, ' +
8
8
  'interactions:[{id,title,subtitle?,(body?|bodyPath?),options:[{id,label,' +
9
- 'description?,shortcut?}],multiSelect?,allowFreetext?,freetextLabel?,' +
9
+ 'description?}],multiSelect?,allowFreetext?,freetextLabel?,' +
10
10
  "kind?:'notify'|'validation'|'decision'|'context'|'error'}]}.";
11
11
  export function resolveMaxPanes() {
12
12
  return readConfig('user').max_panes_per_window;
@@ -11,10 +11,9 @@ import { promote, requestYield } from '../core/runtime/promote.js';
11
11
  import { writeYieldMessage } from '../core/runtime/kickoff.js';
12
12
  import { reviveNode } from '../core/runtime/revive.js';
13
13
  import { demoteNode } from '../core/runtime/demote.js';
14
- import { detachToBackground, focus as placementFocus } from '../core/runtime/placement.js';
14
+ import { detachToBackground, focus as placementFocus, windowAlive, windowOfPane, currentTmux } from '../core/runtime/placement.js';
15
15
  import { buildLaunchSpec } from '../core/runtime/launch.js';
16
16
  import { closeNode } from '../core/runtime/close.js';
17
- import { windowAlive, windowOfPane, currentTmux } from '../core/runtime/tmux.js';
18
17
  import { appendInbox } from '../core/feed/inbox.js';
19
18
  import { availableKinds } from '../core/personas/index.js';
20
19
  import { getNode, updateNode, listNodes, subscribe, unsubscribe, subscriptionsOf, subscribersOf, readContextTokens, } from '../core/canvas/index.js';
@@ -10,13 +10,12 @@
10
10
  // meta.{tmux_session,window} goes stale — `windowAlive` would then report it
11
11
  // dormant and the daemon would spuriously revive it. After each join we
12
12
  // re-derive the child's location from its (stable) pane id and updateNode it,
13
- // mirroring the swap fix-up in presence.ts.
13
+ // mirroring the swap fix-up in placement.ts.
14
14
  import { defineLeaf } from '../core/command.js';
15
15
  import { InputError } from '../core/io.js';
16
16
  import { readConfig } from '../core/config.js';
17
17
  import { reviveNode } from '../core/runtime/revive.js';
18
- import { isNodePaneAlive, spreadNode } from '../core/runtime/placement.js';
19
- import { inTmux } from '../core/runtime/tmux.js';
18
+ import { isNodePaneAlive, spreadNode, inTmux } from '../core/runtime/placement.js';
20
19
  import { nodeInPane } from './node.js';
21
20
  import { getNode, subscriptionsOf } from '../core/canvas/index.js';
22
21
  export const tmuxSpreadLeaf = defineLeaf({
@@ -140,8 +140,8 @@ test('Step 7: closing a FOCUSED node closes its focus row + nulls its pane (tear
140
140
  openFocusRow('fN', '%x', 'Sa', 'N');
141
141
  closeNode('N');
142
142
  // tearDownNode closes the focus row the node occupied and nulls its LOCATION.
143
- // Non-vacuous: pre-Step-7 close used closeWindow/setFocus('') and never touched
144
- // the focuses table, so getFocusByNode('N') would still return fN.
143
+ // Non-vacuous: pre-Step-7 close used closeWindow and never touched the focuses
144
+ // table, so getFocusByNode('N') would still return fN.
145
145
  assert.equal(getFocusByNode('N'), null, 'focus row closed by tearDownNode');
146
146
  assert.equal(getNode('N').pane ?? null, null, 'pane nulled (pane-keyed teardown)');
147
147
  assert.equal(getNode('N').status, 'canceled', 'node canceled as before');
@@ -1,9 +1,8 @@
1
1
  // Run with: node --import tsx/esm --test src/core/__tests__/focuses.test.ts
2
2
  //
3
- // STEP 4 of the placement/focus migration: the `focuses` table + canvas setters
4
- // + placement reads + the transitional focus.ptr dual-write bridge. Purely
5
- // ADDITIVE: the table is populated in lockstep with the legacy `focus.ptr`, but
6
- // nothing reads it as authority yet (that switch is Step 6). Covers:
3
+ // The `focuses` table (canvas.db, migration v6) + its canvas setters + the
4
+ // placement reads that compose over them. The table is the CANONICAL focus store
5
+ // there is no focus.ptr file and no dual-write bridge. Covers:
7
6
  // - migration v6 adds `focuses` to a fresh db (and a legacy v5 db migrates up);
8
7
  // idempotent / forward-only on re-run + re-open
9
8
  // - canvas setters/reads round-trip: open / setOccupant / setPane / close;
@@ -13,23 +12,17 @@
13
12
  // - independent focus rows don't contend
14
13
  // - placement focusOf / isFocused / focusByPane / focusedNodes / listFocuses
15
14
  // agree with the rows
16
- // - dual-write: setFocus populates the table; getFocus falls back to the table
17
- // when focus.ptr is absent; setFocus('') clears both
18
15
  import { test, before, beforeEach, after } from 'node:test';
19
16
  import assert from 'node:assert/strict';
20
- import { mkdtempSync, rmSync, existsSync, unlinkSync } from 'node:fs';
17
+ import { mkdtempSync, rmSync } from 'node:fs';
21
18
  import { tmpdir } from 'node:os';
22
19
  import { join } from 'node:path';
23
20
  import { DatabaseSync } from 'node:sqlite';
24
21
  import { openFocusRow, setFocusOccupant, setFocusPane, closeFocusRow, getFocusByNode, getFocusByPane, getFocusById, listFocuses, } from '../canvas/focuses.js';
25
22
  import { openDb, closeDb, migrate, MIGRATIONS } from '../canvas/db.js';
26
- import { canvasDbPath, ensureHome, crtrHome } from '../canvas/paths.js';
23
+ import { canvasDbPath, ensureHome } from '../canvas/paths.js';
27
24
  import { focusOf, isFocused, focusByPane, focusedNodes, listFocuses as placementListFocuses, } from '../runtime/placement.js';
28
- import { setFocus, getFocus } from '../runtime/presence.js';
29
25
  let home;
30
- // Saved/restored so the bridge always exercises its deterministic no-tmux path
31
- // regardless of whether the suite is run from inside a tmux session.
32
- let savedTmux;
33
26
  function userVersion(db) {
34
27
  return db.prepare('PRAGMA user_version').get().user_version;
35
28
  }
@@ -39,8 +32,6 @@ function tableNames(db) {
39
32
  before(() => {
40
33
  home = mkdtempSync(join(tmpdir(), 'crtr-focuses-'));
41
34
  process.env['CRTR_HOME'] = home;
42
- savedTmux = process.env['TMUX'];
43
- delete process.env['TMUX'];
44
35
  });
45
36
  beforeEach(() => {
46
37
  closeDb();
@@ -50,8 +41,6 @@ after(() => {
50
41
  closeDb();
51
42
  rmSync(home, { recursive: true, force: true });
52
43
  delete process.env['CRTR_HOME'];
53
- if (savedTmux !== undefined)
54
- process.env['TMUX'] = savedTmux;
55
44
  });
56
45
  // ---------------------------------------------------------------------------
57
46
  // Migration v6 — the additive `focuses` table.
@@ -205,55 +194,3 @@ test('placement focus reads agree with the focus rows', () => {
205
194
  assert.deepEqual(focusedNodes(), new Set(['A', 'B']));
206
195
  assert.deepEqual(placementListFocuses().map((f) => f.node_id), ['A', 'B']);
207
196
  });
208
- // ---------------------------------------------------------------------------
209
- // Dual-write bridge — setFocus populates the table; getFocus falls back to the
210
- // table when focus.ptr is absent; setFocus('') clears both.
211
- // ---------------------------------------------------------------------------
212
- function focusPtrPath() {
213
- return join(crtrHome(), 'focus.ptr');
214
- }
215
- test('setFocus populates the focuses table in lockstep with focus.ptr', () => {
216
- openDb();
217
- setFocus('A');
218
- assert.equal(getFocus(), 'A', 'focus.ptr reads back');
219
- const row = getFocusByNode('A');
220
- assert.ok(row, 'a canonical focus row mirrors the current focus');
221
- assert.equal(row?.node_id, 'A');
222
- assert.equal(isFocused('A'), true, 'placement.isFocused agrees');
223
- assert.deepEqual(focusOf('A')?.node_id, 'A', 'placement.focusOf agrees with getFocus');
224
- // Re-focusing a different node re-points the SAME canonical row (no stray rows,
225
- // UNIQUE(node_id) upheld).
226
- setFocus('B');
227
- assert.equal(getFocus(), 'B');
228
- assert.equal(getFocusByNode('A'), null, 'the old occupant is dropped');
229
- assert.equal(getFocusByNode('B')?.node_id, 'B');
230
- assert.equal(listFocuses().length, 1, 'still exactly one canonical row');
231
- });
232
- test('getFocus falls back to the table when focus.ptr is absent', () => {
233
- openDb();
234
- setFocus('A'); // writes both focus.ptr and the canonical row
235
- // Simulate a missing pointer (a writer that reached only the table, or a lost
236
- // file): delete focus.ptr and confirm getFocus recovers the focus from the row.
237
- if (existsSync(focusPtrPath()))
238
- unlinkSync(focusPtrPath());
239
- assert.equal(getFocus(), 'A', 'getFocus recovers the focus from the table');
240
- });
241
- test("setFocus('') clears both the pointer and the canonical focus row", () => {
242
- openDb();
243
- setFocus('A');
244
- assert.equal(getFocus(), 'A');
245
- assert.ok(getFocusByNode('A'), 'precondition: row present');
246
- setFocus('');
247
- assert.equal(getFocus(), null, 'getFocus is null after clear (ptr empty, no row)');
248
- assert.equal(getFocusByNode('A'), null, 'the canonical row was closed');
249
- assert.deepEqual(listFocuses(), [], 'no focus rows remain');
250
- });
251
- test('a focus row written directly (no focus.ptr) is visible through getFocus + placement', () => {
252
- openDb();
253
- // A writer that reached only the table (the canonical bridge row), with no
254
- // focus.ptr on disk at all.
255
- openFocusRow('__focus_ptr__', null, null, 'X');
256
- assert.ok(!existsSync(focusPtrPath()), 'precondition: no focus.ptr file');
257
- assert.equal(getFocus(), 'X', 'getFocus falls back to the canonical row');
258
- assert.equal(isFocused('X'), true);
259
- });
@@ -18,7 +18,7 @@ import { createNode, getNode, updateNode } from '../canvas/canvas.js';
18
18
  import { nodeMetaPath } from '../canvas/paths.js';
19
19
  import { closeDb } from '../canvas/db.js';
20
20
  import { resolveBirthSession, homeSessionOf } from '../runtime/nodes.js';
21
- import { nodeSession } from '../runtime/tmux.js';
21
+ import { nodeSession } from '../runtime/nodes.js';
22
22
  import { relaunchRoot } from '../runtime/reset.js';
23
23
  import { demoteNode } from '../runtime/demote.js';
24
24
  let home;
@@ -1,12 +1,11 @@
1
1
  // Run with: node --import tsx/esm --test src/core/__tests__/placement-focus.test.ts
2
2
  //
3
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.
4
+ // remain-on-exit + root-boot focus #1.
5
5
  //
6
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).
7
+ // 1. PURE (no tmux): outgoingDisposition (backstage-vs-kill). Provably
8
+ // non-vacuous (a wrong impl fails).
10
9
  // 2. Gated real-tmux: the hot-swap itself — screen position invariant (ZERO new
11
10
  // user windows), the two post-swap LOCATIONs, outgoing backstaged (still
12
11
  // generating) vs reaped (dormant), the Q5 vacate-old-focus path, openFocus
@@ -15,16 +14,14 @@
15
14
  // a live server; gated {skip:!hasTmux()} like §5.2.
16
15
  import { test, before, after, beforeEach } from 'node:test';
17
16
  import assert from 'node:assert/strict';
18
- import { mkdtempSync, rmSync, existsSync, unlinkSync } from 'node:fs';
17
+ import { mkdtempSync, rmSync } from 'node:fs';
19
18
  import { tmpdir } from 'node:os';
20
19
  import { join } from 'node:path';
21
20
  import { spawnSync } from 'node:child_process';
22
21
  import { createNode, getNode, setPresence } from '../canvas/canvas.js';
23
22
  import { openFocusRow, getFocusByNode, getFocusById, listFocuses, } from '../canvas/focuses.js';
24
23
  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';
24
+ import { outgoingDisposition, retargetFocus, openFocus, focus as placementFocus, registerRootFocus, focusByPane, detachToBackground, } from '../runtime/placement.js';
28
25
  let home;
29
26
  let savedTmux;
30
27
  function node(id, over = {}) {
@@ -75,29 +72,6 @@ test('outgoingDisposition: a HOLDER / vanished node (no row) → KILL (never bac
75
72
  assert.deepEqual(outgoingDisposition({ exists: false, generating: false }), { kind: 'kill' });
76
73
  });
77
74
  // ---------------------------------------------------------------------------
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
75
  // 2. Gated real-tmux: the hot-swap. Two isolated sessions: `user` (the user's
102
76
  // terminal) + `back` (stand-in for the backstage `crtr`). Panes run `sleep`,
103
77
  // never a real pi; node pi_pid is set explicitly to control "generating".
@@ -168,7 +142,7 @@ test('retargetFocus: outgoing GENERATING → backstaged; the viewport stays put
168
142
  assert.equal(paneExistsReal(focusPane), true, 'R\'s pane is alive (NOT reaped — it is still generating)');
169
143
  assert.equal(paneSession(focusPane), back, 'R\'s pane physically moved to the backstage');
170
144
  assert.equal(getNode('R').tmux_session, back, 'R\'s LOCATION session is the backstage');
171
- assert.equal(getFocus(), 'A', 'focus.ptr followed the retarget');
145
+ assert.equal(focusByPane(backPane)?.node_id, 'A', 'the focus row tracks A after the retarget');
172
146
  });
173
147
  });
174
148
  test('retargetFocus: outgoing DORMANT (no live pi) → its now-backstage pane is REAPED (Invariant P)', { skip: !hasTmux() }, async () => {
@@ -242,3 +216,51 @@ test('focus front-door: round-trip open(register #1) → retarget in place → t
242
216
  assert.equal(focusByPane(aPane)?.node_id, 'A', 'the focus row tracks A\'s pane');
243
217
  });
244
218
  });
219
+ // ---------------------------------------------------------------------------
220
+ // Regression guards (review findings on HEAD=ccc3ee2): a detach/failed-open must
221
+ // not leave a dangling focus row or an orphan viewport pane (Invariant P / F4).
222
+ // ---------------------------------------------------------------------------
223
+ test('detachToBackground: a FOCUSED node sent to the backstage CLOSES its focus row (Invariant P — no phantom viewport)', { skip: !hasTmux() }, async () => {
224
+ await withSessions('detach', async ({ user, back, userWindow }) => {
225
+ const prev = process.env['CRTR_NODE_SESSION'];
226
+ process.env['CRTR_NODE_SESSION'] = back; // detach relocates into THIS backstage session
227
+ try {
228
+ const focusPane = livePane(user, userWindow); // the node's foreground viewport pane
229
+ createNode(node('N', { pane: focusPane, tmux_session: user, window: userWindow, status: 'active', pi_pid: process.pid, home_session: back }));
230
+ openFocusRow('f1', focusPane, user, 'N'); // N is the focus occupant
231
+ assert.equal(getFocusByNode('N')?.focus_id, 'f1', 'precondition: N is focused');
232
+ const ok = detachToBackground('N', focusPane);
233
+ assert.equal(ok, true, 'the break to the backstage succeeded');
234
+ // The fix: N is now generating-but-UNFOCUSED, so its focus row is CLOSED.
235
+ assert.equal(getFocusByNode('N'), null, 'N no longer occupies any focus (row CLOSED — Invariant P)');
236
+ assert.equal(focusByPane(focusPane), null, 'NO phantom focus resolves on the relocated pane (%id survives the break)');
237
+ assert.equal(listFocuses().length, 0, 'no dangling focus rows remain');
238
+ // The pi keeps running — the pane is alive, just moved off-screen.
239
+ assert.equal(paneExistsReal(focusPane), true, 'N\'s pi keeps generating (pane alive, relocated not killed)');
240
+ assert.equal(paneSession(focusPane), back, 'N\'s pane physically moved to the backstage session');
241
+ }
242
+ finally {
243
+ if (prev === undefined)
244
+ delete process.env['CRTR_NODE_SESSION'];
245
+ else
246
+ process.env['CRTR_NODE_SESSION'] = prev;
247
+ }
248
+ });
249
+ });
250
+ test('focus --new-pane: a FAILED retarget reaps the holder pane + focus row (no orphan viewport — F4/Invariant P)', { skip: !hasTmux() }, async () => {
251
+ await withSessions('newpane-fail', async ({ user, userWindow }) => {
252
+ const callerPane = livePane(user, userWindow); // the caller's pane we split beside
253
+ createNode(node('D', { status: 'active', pi_pid: null })); // D is DORMANT — no live pane
254
+ const winBefore = windowIds(user).length;
255
+ const panesIn = (s, w) => tmuxOut(['list-panes', '-t', `${s}:${w}`, '-F', '#{pane_id}']).split('\n').filter((x) => x !== '').length;
256
+ const panesBefore = panesIn(user, userWindow);
257
+ // A reviver that does NOTHING → D stays dormant, retargetFocus finds no live
258
+ // pin and returns focused:false. The just-opened holder must be reaped.
259
+ const noopRevive = () => { };
260
+ const res = placementFocus('D', { pane: callerPane, newPane: true, revive: noopRevive });
261
+ assert.equal(res.focused, false, 'D could not be placed → the --new-pane focus failed');
262
+ assert.equal(listFocuses().length, 0, 'the just-opened focus row was REAPED (no phantom)');
263
+ assert.equal(panesIn(user, userWindow), panesBefore, 'the holder split pane was REAPED (no leaked sleep pane)');
264
+ assert.equal(windowIds(user).length, winBefore, 'no window leaked');
265
+ });
266
+ });
@@ -9,8 +9,7 @@
9
9
  // not-already-focused manager; false (caller closes the focus) in each of the
10
10
  // three guard cases. Each guard is asserted distinctly.
11
11
  // • tearDownNode(nodeId) — close/reset teardown: close the focus row it
12
- // occupies, null its LOCATION, and clear focus.ptr when it was the current
13
- // focus.
12
+ // occupies and null its LOCATION.
14
13
  import { test, before, after, beforeEach } from 'node:test';
15
14
  import assert from 'node:assert/strict';
16
15
  import { mkdtempSync, rmSync } from 'node:fs';
@@ -21,7 +20,6 @@ import { createNode, getNode } from '../canvas/canvas.js';
21
20
  import { openFocusRow, getFocusByNode, getFocusById } from '../canvas/focuses.js';
22
21
  import { closeDb } from '../canvas/db.js';
23
22
  import { handFocusToManager, tearDownNode } from '../runtime/placement.js';
24
- import { setFocus, getFocus } from '../runtime/presence.js';
25
23
  let home;
26
24
  function node(id, over = {}) {
27
25
  return {
@@ -166,18 +164,15 @@ test('handFocusToManager: DORMANT manager (dead pane) → occupant repointed, NO
166
164
  // ---------------------------------------------------------------------------
167
165
  // tearDownNode (pure DB; no tmux — pane is null so closePane never runs).
168
166
  // ---------------------------------------------------------------------------
169
- test('tearDownNode: closes the focus row, nulls the LOCATION, and clears focus.ptr', () => {
167
+ test('tearDownNode: closes the focus row M occupied and nulls its LOCATION', () => {
170
168
  createNode(node('M', { pane: null, window: null }));
171
- openFocusRow('fM', null, 'Sa', 'M');
172
- setFocus('M'); // M is the current focus.ptr
169
+ openFocusRow('fM', null, 'Sa', 'M'); // M occupies a focus row
173
170
  tearDownNode('M');
174
171
  assert.equal(getFocusByNode('M'), null, 'the focus row M occupied is closed');
175
172
  const m = getNode('M');
176
173
  assert.equal(m.pane ?? null, null, 'pane nulled');
177
174
  assert.equal(m.window ?? null, null, 'window nulled');
178
175
  assert.equal(m.tmux_session ?? null, null, 'session nulled');
179
- const cur = getFocus();
180
- assert.ok(cur === null || cur === '', 'focus.ptr cleared (M was the current focus)');
181
176
  // Non-vacuous: an impl that skips closeFocusRow leaves fM → getFocusByNode('M')
182
- // is non-null; one that skips the setFocus('') clear leaves focus.ptr at 'M'.
177
+ // is non-null.
183
178
  });
@@ -11,10 +11,10 @@ import { tmpdir } from 'node:os';
11
11
  import { join } from 'node:path';
12
12
  import { createNode, getNode, subscribe, subscriptionsOf, view, listNodes, } from '../canvas/canvas.js';
13
13
  import { closeDb } from '../canvas/db.js';
14
+ import { getFocusByNode, openFocusRow } from '../canvas/focuses.js';
14
15
  import { reportsDir, inboxPath, contextDir } from '../canvas/paths.js';
15
16
  import { roadmapPath } from '../runtime/roadmap.js';
16
17
  import { relaunchRoot, handleNewSession, markCleanExitDone, reapDescendants, } from '../runtime/reset.js';
17
- import { getFocus } from '../runtime/presence.js';
18
18
  import { renderForest } from '../canvas/render.js';
19
19
  let home;
20
20
  function node(id, over = {}) {
@@ -79,6 +79,7 @@ test('relaunchRoot parks the old root (done, edges intact, no wipe) and mints a
79
79
  createNode(node('grand', { parent: 'child' }));
80
80
  subscribe('root', 'child', true);
81
81
  subscribe('child', 'grand', true);
82
+ openFocusRow('fRoot', '%root', 'crtr', 'root'); // the old root occupies focus #1
82
83
  // Working state on the old root that parking must PRESERVE (no wipe).
83
84
  writeFileSync(roadmapPath('root'), '# Roadmap\nold goal\n');
84
85
  writeFileSync(inboxPath('root'), '{"ts":"x","from":"child","tier":"normal","kind":"update","label":"hi"}\n');
@@ -122,7 +123,9 @@ test('relaunchRoot parks the old root (done, edges intact, no wipe) and mints a
122
123
  assert.equal(fresh?.window, '@7');
123
124
  assert.ok(fresh?.launch, 'a fresh base launch spec was written');
124
125
  assert.equal(readdirSync(contextDir(newId)).length, 0, 'fresh empty context dir');
125
- assert.equal(getFocus(), newId, 'focus follows content to the new root');
126
+ // Focus follows content: the focus row the old root held now shows the new root.
127
+ assert.equal(getFocusByNode(newId)?.focus_id, 'fRoot', 'focus row repointed to the new root');
128
+ assert.equal(getFocusByNode('root'), null, 'old root no longer occupies the focus');
126
129
  });
127
130
  // ---------------------------------------------------------------------------
128
131
  // #1b — handleNewSession success branch: root WITH a pane routes to relaunch
@@ -138,6 +141,7 @@ test('handleNewSession on a root with a pane returns path:relaunch + parks old,
138
141
  }));
139
142
  createNode(node('child', { parent: 'root' }));
140
143
  subscribe('root', 'child', true);
144
+ openFocusRow('fRoot', '%root', 'crtr', 'root'); // the old root occupies focus #1
141
145
  const respawn = okRespawn();
142
146
  const res = handleNewSession('root', 'newsess', 'test-pane', { relaunchRootInPane: respawn.fn });
143
147
  // The policy router's success return shape: relaunch + a fresh new node id.
@@ -156,7 +160,7 @@ test('handleNewSession on a root with a pane returns path:relaunch + parks old,
156
160
  assert.equal(fresh?.parent, null, 'new node is a root');
157
161
  assert.equal(fresh?.status, 'active', 'fresh root active');
158
162
  assert.equal(fresh?.spawned_by, 'root', 'audit-only successor link to old root');
159
- assert.equal(getFocus(), res.newNodeId, 'focus follows to the fresh root');
163
+ assert.equal(getFocusByNode(res.newNodeId)?.focus_id, 'fRoot', 'focus row repointed to the fresh root');
160
164
  });
161
165
  // ---------------------------------------------------------------------------
162
166
  // #2 — handleNewSession on a non-root → session-id refresh only
@@ -238,6 +242,7 @@ test('a respawn dispatch failure rolls the whole transaction back and degrades t
238
242
  tmux_session: 'crtr',
239
243
  window: '@3',
240
244
  }));
245
+ openFocusRow('fRoot', '%root', 'crtr', 'root'); // the old root occupies focus #1
241
246
  const respawn = throwingRespawn();
242
247
  const res = handleNewSession('root', 'newsess', 'test-pane', { relaunchRootInPane: respawn.fn });
243
248
  assert.equal(res.path, 'reset-root', 'degraded to in-place reset');
@@ -256,7 +261,8 @@ test('a respawn dispatch failure rolls the whole transaction back and degrades t
256
261
  assert.deepEqual(actives, ['root'], 'only the old root is active — no zombie');
257
262
  assert.equal(listNodes({ status: ['dead'] }).length, 0, 'no dead zombie — new node row rolled back');
258
263
  assert.equal(listNodes().length, 1, 'only the old root row exists — the mint was fully undone');
259
- assert.equal(getFocus(), 'root', 'focus restored to the old root');
264
+ // The focus repoint was INSIDE the txn, so ROLLBACK restored the old occupant.
265
+ assert.equal(getFocusByNode('root')?.focus_id, 'fRoot', 'focus row restored to the old root (rollback undid the repoint)');
260
266
  });
261
267
  // ---------------------------------------------------------------------------
262
268
  // #7 — markCleanExitDone guard table (termination rule)
@@ -10,9 +10,9 @@
10
10
  // redesign kills. This guards the bug's blast radius and should pass today.
11
11
  //
12
12
  // 2. The §5.1 "only placement.ts / tmux-chrome.ts import tmux.ts" lint guard
13
- // (SKIPPED warn only this step): placement.ts does not exist yet and many
14
- // modules still import the driver directly, so this CANNOT pass until the
15
- // migration completes. It flips to a hard assertion in Step 8.
13
+ // (ENFORCED as of Step 8): every other module reaches the driver through
14
+ // placement's re-exports or the tmux-chrome seam, so the only direct
15
+ // importers are placement.ts + tmux-chrome.ts (tmux.ts itself excluded).
16
16
  import { test } from 'node:test';
17
17
  import assert from 'node:assert/strict';
18
18
  import { readFileSync, readdirSync, statSync } from 'node:fs';
@@ -71,11 +71,10 @@ test('§2.2 driver invariant: every placement verb in tmux.ts passes an explicit
71
71
  assert.ok(found >= 4, `expected to scan ≥4 placement verbs, saw ${found}`);
72
72
  });
73
73
  // ---------------------------------------------------------------------------
74
- // Lint guard — §5.1. WARN ONLY this step. Skipped because it cannot pass yet:
75
- // placement.ts (the sole sanctioned importer) does not exist, and the driver is
76
- // still imported directly by the runtime/daemon/command/stophook modules the
77
- // migration has not yet routed through placement. Step 8 deletes the skip and
78
- // turns the body into the enforced boundary.
74
+ // Lint guard — §5.1, ENFORCED. The driver (tmux.ts) is imported ONLY by
75
+ // placement.ts (the sanctioned model-over-driver, which re-exports the verbs
76
+ // other modules need) and the tmux-chrome.ts chrome seam. Every other module —
77
+ // runtime, daemon, commands, stophook, AND tests must route through those.
79
78
  // ---------------------------------------------------------------------------
80
79
  /** Every `.ts` file under `src` (recursively), excluding nothing. */
81
80
  function allTsFiles(dir) {
@@ -97,7 +96,7 @@ function importsDriver(file) {
97
96
  // `tmux-spread.js` are NOT matched). Covers `from '...'` and `import('...')`.
98
97
  return [...src.matchAll(/(?:from|import\s*\()\s*'([^']+)'/g)].some((m) => basename(m[1]) === 'tmux.js');
99
98
  }
100
- test('§5.1 lint: only placement.ts / tmux-chrome.ts import the tmux driver', { skip: 'WARN-ONLY in Step 2 — placement.ts does not exist yet and many modules still import tmux.ts directly. Step 8 flips this to a hard error.' }, () => {
99
+ test('§5.1 lint: only placement.ts / tmux-chrome.ts import the tmux driver', () => {
101
100
  const offenders = allTsFiles(SRC_ROOT)
102
101
  .filter((f) => !ALLOWED_IMPORTERS.has(basename(f)))
103
102
  .filter(importsDriver)
@@ -196,9 +196,8 @@ function addPaneColumn(db) {
196
196
  * viewport (Q7 widens canvas.db from "topology" to "topology + focuses"). Each
197
197
  * row is anchored on the durable tmux `%pane_id`; `session` is a derived cache
198
198
  * reconciled from the pane; `node_id` is UNIQUE so a node occupies at most one
199
- * focus (Q5). Additive, forward-only nothing reads it as authority yet (Step 4
200
- * populates it in lockstep with the legacy `focus.ptr` via a transitional
201
- * dual-write; the switch to table-as-authority lands in Step 6). */
199
+ * focus (Q5). The focuses table is the CANONICAL focus store there is no
200
+ * focus.ptr file and no dual-write bridge; placement writes focus rows directly. */
202
201
  function addFocusesTable(db) {
203
202
  db.exec(`
204
203
  CREATE TABLE IF NOT EXISTS focuses (
@@ -15,8 +15,8 @@ export declare function closeFocusRow(focus_id: string): void;
15
15
  export declare function getFocusByNode(node_id: string): FocusRow | null;
16
16
  /** The focus realized by a given pane (`%id`), or null. */
17
17
  export declare function getFocusByPane(pane: string): FocusRow | null;
18
- /** A focus by its stable id, or null. (Used by the transitional focus.ptr
19
- * dual-write bridge to read back its single canonical row; removed in Step 8.) */
18
+ /** A focus by its stable id, or null. Used by placement to read a row back by id
19
+ * (handFocusToManager / retargetFocus / registerRootFocus). */
20
20
  export declare function getFocusById(focus_id: string): FocusRow | null;
21
21
  /** Every focus row, ordered by id. */
22
22
  export declare function listFocuses(): FocusRow[];
@@ -4,7 +4,8 @@
4
4
  // "topology + focuses", so the focus-row SQL lives here beside the node+edge
5
5
  // model, never in the runtime layer. A FOCUS is one durable on-screen viewport
6
6
  // bound to one node; the table is PLURAL (many focuses across windows/sessions),
7
- // the generalization of the old single `focus.ptr`.
7
+ // the generalization of the old single focus pointer. It is the CANONICAL focus
8
+ // store — there is no focus.ptr file and no dual-write bridge.
8
9
  //
9
10
  // placement.ts COMPOSES over these atomic setters/reads (the same way it calls
10
11
  // setPresence) — it never runs raw focus SQL itself.
@@ -66,8 +67,8 @@ export function getFocusByPane(pane) {
66
67
  .get(pane);
67
68
  return r ? focusFrom(r) : null;
68
69
  }
69
- /** A focus by its stable id, or null. (Used by the transitional focus.ptr
70
- * dual-write bridge to read back its single canonical row; removed in Step 8.) */
70
+ /** A focus by its stable id, or null. Used by placement to read a row back by id
71
+ * (handFocusToManager / retargetFocus / registerRootFocus). */
71
72
  export function getFocusById(focus_id) {
72
73
  const r = openDb()
73
74
  .prepare('SELECT * FROM focuses WHERE focus_id = ?')
@@ -168,7 +168,7 @@ export interface Edge {
168
168
  /** A FOCUS row as stored in the `focuses` table (canvas.db, migration v6): one
169
169
  * durable on-screen viewport the user looks at, bound to one node. Plural —
170
170
  * many focuses live at once across windows and sessions (the plural
171
- * generalization of the old single `focus.ptr`). Anchored on the durable tmux
171
+ * generalization of the old single focus pointer). Anchored on the durable tmux
172
172
  * `%pane_id`; `session` is a derived cache reconciled from the pane. `node_id`
173
173
  * is UNIQUE — a node occupies at most one focus (Q5). */
174
174
  export interface FocusRow {
@@ -130,8 +130,8 @@ export function closeNode(rootId) {
130
130
  transition(id, 'cancel');
131
131
  // 2) Tear the node off its placement (pane-keyed): close any focus row it
132
132
  // occupies, kill its PANE (the window closes once its last pane goes, so
133
- // sibling nodes the user co-located in one window survive), null its
134
- // LOCATION, and clear focus.ptr if it was the current focus.
133
+ // sibling nodes the user co-located in one window survive), and null its
134
+ // LOCATION (closing the focus row is the record — no pointer to clear).
135
135
  tearDownNode(id);
136
136
  // 3) Leave the resume notice AFTER the watcher is gone, so it survives.
137
137
  appendInbox(id, {
@@ -16,12 +16,10 @@ import { join } from 'node:path';
16
16
  import { getNode, setPresence, updateNode, setFocusOccupant, fullName } from '../canvas/index.js';
17
17
  import { reportsDir } from '../canvas/paths.js';
18
18
  import { pushFinal } from '../feed/feed.js';
19
- import { spawnNode } from './nodes.js';
19
+ import { spawnNode, nodeSession } from './nodes.js';
20
20
  import { buildLaunchSpec, buildPiArgv } from './launch.js';
21
- import { piCommand, paneLocation, nodeSession } from './tmux.js';
22
21
  import { FRONT_DOOR_ENV } from './front-door.js';
23
- import { getFocus, setFocus } from './presence.js';
24
- import { focusOf, recycleFocusPane } from './placement.js';
22
+ import { focusOf, recycleFocusPane, piCommand, paneLocation } from './placement.js';
25
23
  import { ensureDaemon } from '../../daemon/manage.js';
26
24
  /** The agent's most recent surfaced message: the newest reports/*.md body with
27
25
  * its YAML frontmatter stripped. Empty string when the node never reported. */
@@ -104,12 +102,9 @@ export async function demoteNode(nodeId, callerPane) {
104
102
  if (f !== null) {
105
103
  try {
106
104
  setFocusOccupant(f.focus_id, root.node_id);
107
- setFocus(root.node_id);
108
105
  }
109
106
  catch { /* best-effort */ }
110
107
  }
111
- else if (getFocus() === nodeId)
112
- setFocus('');
113
108
  const fresh = getNode(root.node_id);
114
109
  const inv = buildPiArgv(fresh);
115
110
  const env = { ...inv.env, CRTR_ROOT_SESSION: nodeSession(), [FRONT_DOOR_ENV]: '1' };
@@ -6,12 +6,14 @@ export declare const CANVAS_GOAL_CAPTURE_PATH: string;
6
6
  export declare const CANVAS_PASSIVE_CONTEXT_PATH: string;
7
7
  export declare const CANVAS_CONTEXT_INTRO_PATH: string;
8
8
  export declare const CANVAS_COMMANDS_PATH: string;
9
+ export declare const CANVAS_RESUME_PATH: string;
9
10
  /** The canvas extensions every node loads, in order: stophook (routing +
10
11
  * telemetry + session-id capture), inbox-watcher (wake), nav (in-editor
11
12
  * graph chrome), goal-capture (persist the first user message as the goal),
12
13
  * passive-context (drain passive backlog as pre-text on the next message),
13
14
  * context-intro (inject the <crtr-context> bearings block as its own session
14
- * message, once per brand-new chat), commands (the /promote slash-command).
15
+ * message, once per brand-new chat), commands (the /promote slash-command),
16
+ * resume (the /resume-node whole-canvas picker → `crtr node focus`).
15
17
  * All self-gate on CRTR_NODE_ID. goal-capture precedes passive-context so it
16
18
  * reads the raw user text. */
17
19
  export declare const CANVAS_EXTENSIONS: string[];