@crouton-kit/crouter 0.3.15 → 0.3.17

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 (101) hide show
  1. package/dist/builtin-personas/developer/orchestrator.md +1 -1
  2. package/dist/builtin-personas/orchestration-kernel.md +6 -6
  3. package/dist/builtin-personas/plan/base.md +1 -1
  4. package/dist/builtin-personas/plan/orchestrator.md +1 -1
  5. package/dist/builtin-personas/spec/base.md +1 -1
  6. package/dist/commands/canvas-browse.d.ts +2 -0
  7. package/dist/commands/canvas-browse.js +45 -0
  8. package/dist/commands/canvas-prune.js +11 -2
  9. package/dist/commands/canvas.js +3 -2
  10. package/dist/commands/chord.js +1 -1
  11. package/dist/commands/human/shared.js +1 -1
  12. package/dist/commands/node.js +14 -2
  13. package/dist/commands/skill/author.js +2 -2
  14. package/dist/commands/tmux-spread.js +2 -3
  15. package/dist/core/__tests__/cascade-close.test.js +199 -0
  16. package/dist/core/__tests__/close.test.js +2 -2
  17. package/dist/core/__tests__/daemon-boot.test.js +7 -0
  18. package/dist/core/__tests__/daemon-liveness.test.js +59 -4
  19. package/dist/core/__tests__/dead-pane-regression.test.js +151 -0
  20. package/dist/core/__tests__/fixtures/fake-pi-host.d.ts +2 -0
  21. package/dist/core/__tests__/fixtures/fake-pi-host.js +301 -0
  22. package/dist/core/__tests__/flagship-lifecycle.test.js +273 -0
  23. package/dist/core/__tests__/focuses.test.js +5 -68
  24. package/dist/core/__tests__/grace-clock.test.d.ts +1 -0
  25. package/dist/core/__tests__/grace-clock.test.js +115 -0
  26. package/dist/core/__tests__/helpers/harness.d.ts +78 -0
  27. package/dist/core/__tests__/helpers/harness.js +406 -0
  28. package/dist/core/__tests__/home-session.test.js +1 -1
  29. package/dist/core/__tests__/lifecycle.test.js +6 -13
  30. package/dist/core/__tests__/live-mutation.test.d.ts +1 -0
  31. package/dist/core/__tests__/live-mutation.test.js +341 -0
  32. package/dist/core/__tests__/placement-focus.test.js +106 -46
  33. package/dist/core/__tests__/placement-teardown.test.js +4 -9
  34. package/dist/core/__tests__/relaunch.test.js +22 -16
  35. package/dist/core/__tests__/reset.test.js +11 -6
  36. package/dist/core/__tests__/spike-harness.test.d.ts +1 -0
  37. package/dist/core/__tests__/spike-harness.test.js +241 -0
  38. package/dist/core/__tests__/subscription-delivery.test.d.ts +1 -0
  39. package/dist/core/__tests__/subscription-delivery.test.js +233 -0
  40. package/dist/core/__tests__/tmux-surface.test.js +8 -9
  41. package/dist/core/canvas/browse/__tests__/model.test.d.ts +1 -0
  42. package/dist/core/canvas/browse/__tests__/model.test.js +142 -0
  43. package/dist/core/canvas/browse/__tests__/render.test.d.ts +1 -0
  44. package/dist/core/canvas/browse/__tests__/render.test.js +102 -0
  45. package/dist/core/canvas/browse/app.d.ts +4 -0
  46. package/dist/core/canvas/browse/app.js +349 -0
  47. package/dist/core/canvas/browse/model.d.ts +97 -0
  48. package/dist/core/canvas/browse/model.js +258 -0
  49. package/dist/core/canvas/browse/render.d.ts +41 -0
  50. package/dist/core/canvas/browse/render.js +387 -0
  51. package/dist/core/canvas/browse/terminal.d.ts +23 -0
  52. package/dist/core/canvas/browse/terminal.js +100 -0
  53. package/dist/core/canvas/canvas.d.ts +9 -2
  54. package/dist/core/canvas/canvas.js +41 -3
  55. package/dist/core/canvas/db.js +2 -3
  56. package/dist/core/canvas/focuses.d.ts +2 -2
  57. package/dist/core/canvas/focuses.js +4 -3
  58. package/dist/core/canvas/render.d.ts +10 -0
  59. package/dist/core/canvas/render.js +25 -1
  60. package/dist/core/canvas/types.d.ts +1 -1
  61. package/dist/core/feed/inbox.d.ts +0 -3
  62. package/dist/core/feed/inbox.js +1 -5
  63. package/dist/core/runtime/busy.d.ts +8 -0
  64. package/dist/core/runtime/busy.js +46 -0
  65. package/dist/core/runtime/close.js +2 -2
  66. package/dist/core/runtime/demote.js +2 -7
  67. package/dist/core/runtime/launch.d.ts +3 -1
  68. package/dist/core/runtime/launch.js +4 -1
  69. package/dist/core/runtime/lifecycle.d.ts +1 -1
  70. package/dist/core/runtime/lifecycle.js +12 -4
  71. package/dist/core/runtime/naming.d.ts +3 -3
  72. package/dist/core/runtime/naming.js +6 -6
  73. package/dist/core/runtime/nodes.d.ts +7 -0
  74. package/dist/core/runtime/nodes.js +10 -1
  75. package/dist/core/runtime/placement.d.ts +39 -10
  76. package/dist/core/runtime/placement.js +100 -44
  77. package/dist/core/runtime/reset.d.ts +11 -8
  78. package/dist/core/runtime/reset.js +36 -31
  79. package/dist/core/runtime/revive.d.ts +1 -1
  80. package/dist/core/runtime/revive.js +2 -2
  81. package/dist/core/runtime/spawn.js +3 -3
  82. package/dist/core/runtime/tmux-chrome.d.ts +1 -0
  83. package/dist/core/runtime/tmux-chrome.js +4 -0
  84. package/dist/core/runtime/tmux.d.ts +13 -6
  85. package/dist/core/runtime/tmux.js +21 -12
  86. package/dist/daemon/crtrd.js +43 -21
  87. package/dist/pi-extensions/canvas-nav.js +40 -28
  88. package/dist/pi-extensions/canvas-resume.d.ts +21 -0
  89. package/dist/pi-extensions/canvas-resume.js +82 -0
  90. package/dist/pi-extensions/canvas-stophook.d.ts +1 -1
  91. package/dist/pi-extensions/canvas-stophook.js +21 -9
  92. package/dist/prompts/skill.js +6 -1
  93. package/package.json +2 -2
  94. package/dist/commands/__tests__/skill.test.js +0 -290
  95. package/dist/core/__tests__/pkg.test.js +0 -218
  96. package/dist/core/__tests__/sys.test.js +0 -208
  97. package/dist/core/runtime/presence.d.ts +0 -30
  98. package/dist/core/runtime/presence.js +0 -178
  99. /package/dist/{commands/__tests__/skill.test.d.ts → core/__tests__/cascade-close.test.d.ts} +0 -0
  100. /package/dist/core/__tests__/{pkg.test.d.ts → dead-pane-regression.test.d.ts} +0 -0
  101. /package/dist/core/__tests__/{sys.test.d.ts → flagship-lifecycle.test.d.ts} +0 -0
@@ -14,11 +14,20 @@
14
14
  // is also recorded.
15
15
  import { randomBytes } from 'node:crypto';
16
16
  import { createNode, getNode, subscribe, recordSpawn, } from '../canvas/index.js';
17
- import { nodeSession } from './tmux.js';
18
17
  /** Generate a node id in the same shape as job ids (time-sortable + random). */
19
18
  export function newNodeId() {
20
19
  return `${Date.now().toString(36)}-${randomBytes(4).toString('hex')}`;
21
20
  }
21
+ /** The single, shared tmux session that ALL canvas node windows live in.
22
+ * Overridable with CRTR_NODE_SESSION (default `crtr`). Every root and every
23
+ * child opens a window here rather than cluttering the user's own working
24
+ * session — switch to it to browse the whole live graph, ignore it otherwise.
25
+ * Pure policy (env only, no tmux call), so it lives in the node layer, not the
26
+ * driver; the tmux driver imports it from here for installMenuBinding's use. */
27
+ export function nodeSession() {
28
+ const v = process.env['CRTR_NODE_SESSION'];
29
+ return v !== undefined && v !== '' ? v : 'crtr';
30
+ }
22
31
  // ---------------------------------------------------------------------------
23
32
  // REVIVE-HOME (home_session) — the durable session a node is (re)opened into
24
33
  // ---------------------------------------------------------------------------
@@ -2,6 +2,9 @@ import { type NodeRow, type FocusRow } from '../canvas/index.js';
2
2
  import { homeSessionOf } from './nodes.js';
3
3
  export { homeSessionOf };
4
4
  export type { FocusRow };
5
+ export { piCommand, paneLocation, currentTmux, inTmux, ensureSession, openNodeWindow, focusWindow, windowAlive, windowOfPane, respawnPane, } from './tmux.js';
6
+ export type { RespawnPaneOpts } from './tmux.js';
7
+ export { nodeSession } from './nodes.js';
5
8
  /** The focus a node occupies, or null. UNIQUE(node_id) ⇒ at most one. */
6
9
  export declare function focusOf(nodeId: string): FocusRow | null;
7
10
  /** Is this node on a viewport? */
@@ -168,16 +171,32 @@ export interface FocusResult {
168
171
  revived: boolean;
169
172
  }
170
173
  /** PURE disposition of a focus's outgoing occupant after a retarget swap (§2.5/
171
- * §1.3): a still-generating node moves to backstage (F2); a holder pane or a
172
- * done/dormant node has its (now-backstage) pane reaped (Invariant P: a
173
- * not-focused + not-generating node has NO pane). Unit-testable in isolation. */
174
+ * §1.3). Four signals decide one of three fates; unit-testable in isolation:
175
+ * - `kill` — a holder pane (no row) or a done/dead/canceled node: reap the
176
+ * (now-backstage) pane (Invariant P: not-focused + not-live
177
+ * no pane).
178
+ * - `backstage`— a human-driven RESIDENT node (editor/root/orchestrator — NEVER
179
+ * despawned on focus-away), or a terminal worker that is
180
+ * genuinely MID-TURN (Invariant F2 — keeps running off-screen).
181
+ * - `release` — a PARKED terminal viewer (live but not mid-turn): a node
182
+ * revived only for inspection. Despawn it back to dormant
183
+ * (transition `release` → idle/idle-release) and reap its pane;
184
+ * the daemon revives it on its inbox, or the user re-focuses.
185
+ * This is the bug fix: such a node was misclassified as
186
+ * generating and left stuck active forever.
187
+ * Order matters: `resident` is checked BEFORE `generating` so a resident node is
188
+ * always kept warm regardless of whether it happens to be mid-turn. */
174
189
  export type OutgoingAction = {
175
190
  kind: 'backstage';
176
191
  } | {
177
192
  kind: 'kill';
193
+ } | {
194
+ kind: 'release';
178
195
  };
179
196
  export declare function outgoingDisposition(o: {
180
197
  exists: boolean;
198
+ live: boolean;
199
+ resident: boolean;
181
200
  generating: boolean;
182
201
  }): OutgoingAction;
183
202
  /** Open a NEW viewport (§2.3, F4) — the ONLY path a new pane appears in a user
@@ -195,7 +214,7 @@ export declare function openFocus(callerPane: string, opts?: {
195
214
  * `remain-on-exit` so a clean exit FREEZES the pane rather than detaching the
196
215
  * terminal (F1). A background `--root` does NOT call this (§6): it stays a plain
197
216
  * window until the user `node focus`es it. No-op when the pane or this node is
198
- * already a focus. Mirrors focus.ptr via setFocus (the transitional bridge). */
217
+ * already a focus. The focus row IS the record — no pointer to mirror. */
199
218
  export declare function registerRootFocus(nodeId: string, pane: string, session: string | null, window: string | null): FocusRow | null;
200
219
  /** retargetFocus — the unified hot-swap (§2.5, Invariant P + Q5). Swap `incoming`
201
220
  * onto focus `focusId`'s viewport, keeping the screen position invariant (no new
@@ -207,9 +226,10 @@ export declare function registerRootFocus(nodeId: string, pane: string, session:
207
226
  * - `swapPaneInPlace(pin, focusPane)`: incoming → the viewport slot; the
208
227
  * outgoing occupant → incoming's old (backstage) slot, %ids preserved
209
228
  * (cross-session swap confirmed by the spike).
210
- * - outgoing still generating → backstage (F2); else reap its now-backstage
211
- * pane (Invariant P). A holder occupant (no node row) is always reaped.
212
- * Arms remain-on-exit on the viewport (F3) and mirrors focus.ptr (setFocus). */
229
+ * - outgoing resident OR still mid-turn → backstage (kept warm / F2); a parked
230
+ * terminal viewer RELEASE (status idle, pane reaped); a holder or
231
+ * done/dormant occupant reap its now-backstage pane (Invariant P).
232
+ * Arms remain-on-exit on the viewport (F3); the focus row is the record. */
213
233
  export declare function retargetFocus(focusId: string, incoming: string, revive: Reviver): FocusResult;
214
234
  /** The front door for `node focus` / `node cycle` (§2.3): resolve which focus the
215
235
  * caller's pane acts on, then retarget `nodeId` onto it.
@@ -218,8 +238,8 @@ export declare function retargetFocus(focusId: string, incoming: string, revive:
218
238
  * - else → retarget the caller pane's focus IN PLACE (`focusByPane`); if the
219
239
  * caller's pane is not yet a viewport, adopt it as one (occupied by whatever
220
240
  * node sits there now — `callerNode`, else resolved by pane).
221
- * - no caller pane (not in tmux) → best-effort: mirror focus.ptr, report
222
- * not-in-place. */
241
+ * - no caller pane (not in tmux) → best-effort: reconcile + report status,
242
+ * not-in-place (no viewport to swap into). */
223
243
  export declare function focus(nodeId: string, opts: {
224
244
  pane?: string;
225
245
  newPane?: boolean;
@@ -230,7 +250,7 @@ export declare function focus(nodeId: string, opts: {
230
250
  * Reconcile first (follow a manual move / backfill a legacy pane), close the
231
251
  * focus row it occupies (if any), kill its pane (pane-keyed via the durable
232
252
  * `%id` — the window collapses once its last pane goes), and null its LOCATION.
233
- * Mirrors focus.ptr when this node was the current focus. Best-effort tmux; the
253
+ * The focus row close is the record. Best-effort tmux; the
234
254
  * DB writes always land. The pane kill is the sole teardown unit (Q1/§6: a
235
255
  * split-pane focus returns its space to the surviving split; a standalone-window
236
256
  * focus closes the window). */
@@ -269,6 +289,15 @@ export declare function recycleFocusPane(nodeId: string, pane: string, launch: R
269
289
  * manager's old backstage slot; the caller nulls this node's presence so nothing
270
290
  * tracks the corpse. */
271
291
  export declare function handFocusToManager(focusId: string, managerId: string | null): boolean;
292
+ /** Q1 close-to-shell for a truly-done focused node with no successor (§1.6 /
293
+ * flow (b)): close its focus row and DISARM the pane's
294
+ * freeze (`remain-on-exit` off) so it reaps when the finishing pi exits. The
295
+ * stophook calls this instead of `tearDownNode` because it runs INSIDE the pane
296
+ * it is closing: it cannot `closePane` its own pane (self-saw), but it is still
297
+ * alive to disarm the freeze, so the pane closes on exit (return-to-shell)
298
+ * rather than freezing into an orphan. (Keeps the stophook off the tmux driver,
299
+ * §2.1.) */
300
+ export declare function closeFocusToShell(focusId: string, nodeId: string): void;
272
301
  export interface SpreadResult {
273
302
  window: string | null;
274
303
  session: string | null;
@@ -1,9 +1,9 @@
1
1
  // placement.ts — the Placement MODEL layer (Steps 3–5).
2
2
  //
3
3
  // Above tmux.ts (the Surface/driver), below the daemon and the runtime ops. This
4
- // is the first module under the §2.1 rule: "only placement.ts / tmux-chrome.ts
5
- // import the tmux driver." (The import-lint is warn-only until Step 8, so the
6
- // other direct importers staying is fine for now.)
4
+ // is the sanctioned model-over-driver under the §2.1 rule: "only placement.ts /
5
+ // tmux-chrome.ts import the tmux driver" (enforced by the §5.1 import-lint). Every
6
+ // other runtime/command module reaches the driver through placement's re-exports.
7
7
  //
8
8
  // Responsibilities, all keyed on the durable tmux `%pane_id` (§1.2/§2.4, Q6):
9
9
  //
@@ -26,14 +26,18 @@
26
26
  // NEVER read as a node death. Liveness is pane-existence, not window-existence,
27
27
  // and reconcile makes crtr follow a move instead of fighting it.
28
28
  import { getRow, getRowByPane, getNode, setPresence, openDb, openFocusRow, setFocusOccupant, closeFocusRow, getFocusByNode, getFocusByPane, getFocusById, setFocusPane, listFocuses as listFocusRows, } from '../canvas/index.js';
29
- import { paneExists, paneLocation, paneOfWindow, windowAlive, windowOfPane, ensureSession, openNodeWindow, respawnPaneSync, respawnPaneDetached, breakPaneToSession, nodeSession, splitWindow, swapPaneInPlace, setRemainOnExit, closePane, currentTmux, joinPane, selectLayout, setWindowOption, switchClient, selectWindow, } from './tmux.js';
30
- import { homeSessionOf, newNodeId } from './nodes.js';
31
- import { setFocus, getFocus } from './presence.js';
29
+ import { paneExists, paneLocation, paneOfWindow, windowAlive, windowOfPane, ensureSession, openNodeWindow, respawnPaneSync, respawnPaneDetached, breakPaneToSession, splitWindow, swapPaneInPlace, setRemainOnExit, closePane, currentTmux, joinPane, selectLayout, setWindowOption, switchClient, selectWindow, } from './tmux.js';
30
+ import { homeSessionOf, nodeSession, newNodeId } from './nodes.js';
31
+ import { isBusy } from './busy.js';
32
+ import { transition } from './lifecycle.js';
32
33
  // Re-export the durable REVIVE-HOME read so placement is the one front door for
33
- // "where does this node live." Step 1 put the implementation in nodes.ts; the
34
- // presence.ts→placement.ts consolidation (Steps 6/8) is where it physically
35
- // moves — until then placement just re-exports it (no churn).
34
+ // "where does this node live."
36
35
  export { homeSessionOf };
36
+ // Placement is the sanctioned model-over-driver (§2.1): non-placement runtime /
37
+ // command modules that legitimately need a raw driver verb get it from here, so
38
+ // the §5.1 lint can hold "only placement.ts / tmux-chrome.ts import tmux.ts".
39
+ export { piCommand, paneLocation, currentTmux, inTmux, ensureSession, openNodeWindow, focusWindow, windowAlive, windowOfPane, respawnPane, } from './tmux.js';
40
+ export { nodeSession } from './nodes.js';
37
41
  // ---------------------------------------------------------------------------
38
42
  // Focus reads (Step 4) — COMPOSE over the canvas focuses table (§2.3/§4).
39
43
  //
@@ -42,12 +46,10 @@ export { homeSessionOf };
42
46
  // way it composes setPresence. A node occupies at most one focus (UNIQUE
43
47
  // node_id, Q5), so focusOf returns a single row.
44
48
  //
45
- // NOTE on openFocus: §2.3/§4 list `openFocus` (split-window + setRemainOnExit +
46
- // openFocusRow) under Step 4, but it is NOT CALLED until Step 6 (root-boot focus
47
- // #1 + `node focus --new-pane`). The tmux-composing half is therefore DEFERRED
48
- // to Step 6; Step 4 ships only the canvas setter `openFocusRow` + these reads +
49
- // the focus.ptr dual-write bridge (presence.ts). retargetFocus /
50
- // reviveIntoPlacement are likewise Steps 5/6, not here.
49
+ // The `focuses` table (canvas/focuses.ts) is the CANONICAL focus store — there is
50
+ // no focus.ptr file and no dual-write bridge. openFocus / retargetFocus /
51
+ // registerRootFocus / handFocusToManager write focus rows directly; these reads
52
+ // compose over them.
51
53
  // ---------------------------------------------------------------------------
52
54
  /** The focus a node occupies, or null. UNIQUE(node_id) ⇒ at most one. */
53
55
  export function focusOf(nodeId) {
@@ -277,6 +279,17 @@ export function detachToBackground(nodeId, pane) {
277
279
  const session = nodeSession();
278
280
  ensureSession(session, row.cwd);
279
281
  const ok = breakPaneToSession(target, session);
282
+ if (ok) {
283
+ // The node left its viewport for the backstage: it is now generating but
284
+ // UNFOCUSED (Invariant P / §1.3 "evicted from a focus … generating →
285
+ // backstage, no focus"). Close any focus row it held so it does not linger
286
+ // as a phantom viewport — the pane %id survives the break, so the row would
287
+ // otherwise keep resolving (`focusByPane`/`listFocuses`). A pure detach has
288
+ // no successor, so it closes the row (cf. `demote`, which hands it off).
289
+ const f = focusOf(nodeId);
290
+ if (f !== null)
291
+ closeFocusRow(f.focus_id);
292
+ }
280
293
  reconcile(nodeId); // presence now points at the crtr window
281
294
  return ok;
282
295
  }
@@ -303,22 +316,37 @@ function pidAlive(pid) {
303
316
  return e.code === 'EPERM';
304
317
  }
305
318
  }
306
- /** Is a focus's OUTGOING occupant still GENERATING (a live pi doing work)? A
307
- * still-generating node is moved to backstage by a retarget (F2 — it keeps
308
- * running off-screen); a holder / done / dormant node has its pane reaped
309
- * (Invariant P). A holder or vanished node (row null) is never generating. */
319
+ /** Is a focus's OUTGOING occupant still GENERATING (a live pi actually MID-TURN)?
320
+ * A still-generating node is moved to backstage by a retarget (F2 — it keeps
321
+ * running off-screen); a holder / done / dormant / merely-parked node has its
322
+ * pane reaped or released (Invariant P). A holder or vanished node (row null) is
323
+ * never generating.
324
+ *
325
+ * The signal is the mid-turn `busy` marker AND a live pid — NOT pid-alive
326
+ * alone. A node revived only for VIEWING is parked at its prompt with a live
327
+ * pid between turns; pid-alive would misclassify it as "doing work" and leave
328
+ * it stuck backstaged-active forever. `isBusy` is true only inside a turn, so a
329
+ * parked viewer reads as not-generating and is released to dormant on
330
+ * focus-away. The AND with `pidAlive` makes a stale marker (a pi that crashed
331
+ * mid-turn) harmless. */
310
332
  function isGenerating(nodeId) {
311
333
  const row = getRow(nodeId);
312
334
  if (row === null)
313
335
  return false;
314
336
  if (row.status !== 'active' && row.status !== 'idle')
315
337
  return false;
316
- return pidAlive(row.pi_pid);
338
+ return isBusy(nodeId) && pidAlive(row.pi_pid);
317
339
  }
318
340
  export function outgoingDisposition(o) {
319
341
  if (!o.exists)
320
- return { kind: 'kill' };
321
- return o.generating ? { kind: 'backstage' } : { kind: 'kill' };
342
+ return { kind: 'kill' }; // holder pane
343
+ if (!o.live)
344
+ return { kind: 'kill' }; // done/dead/canceled — reap the pane
345
+ if (o.resident)
346
+ return { kind: 'backstage' }; // human-driven node: keep warm
347
+ if (o.generating)
348
+ return { kind: 'backstage' }; // mid-turn terminal worker (F2)
349
+ return { kind: 'release' }; // parked terminal viewer → dormant
322
350
  }
323
351
  /** The node's pane iff it is a LIVE pane (a generating-unfocused backstage pane,
324
352
  * or a still-live focus pane), else null. The retarget swaps THIS pane into the
@@ -369,7 +397,7 @@ export function openFocus(callerPane, opts = {}) {
369
397
  * `remain-on-exit` so a clean exit FREEZES the pane rather than detaching the
370
398
  * terminal (F1). A background `--root` does NOT call this (§6): it stays a plain
371
399
  * window until the user `node focus`es it. No-op when the pane or this node is
372
- * already a focus. Mirrors focus.ptr via setFocus (the transitional bridge). */
400
+ * already a focus. The focus row IS the record — no pointer to mirror. */
373
401
  export function registerRootFocus(nodeId, pane, session, window) {
374
402
  const byPane = getFocusByPane(pane);
375
403
  if (byPane !== null)
@@ -380,7 +408,6 @@ export function registerRootFocus(nodeId, pane, session, window) {
380
408
  const focusId = newFocusId();
381
409
  openFocusRow(focusId, pane, session, nodeId);
382
410
  armRemainOnExit(window);
383
- setFocus(nodeId);
384
411
  return getFocusById(focusId);
385
412
  }
386
413
  /** retargetFocus — the unified hot-swap (§2.5, Invariant P + Q5). Swap `incoming`
@@ -393,9 +420,10 @@ export function registerRootFocus(nodeId, pane, session, window) {
393
420
  * - `swapPaneInPlace(pin, focusPane)`: incoming → the viewport slot; the
394
421
  * outgoing occupant → incoming's old (backstage) slot, %ids preserved
395
422
  * (cross-session swap confirmed by the spike).
396
- * - outgoing still generating → backstage (F2); else reap its now-backstage
397
- * pane (Invariant P). A holder occupant (no node row) is always reaped.
398
- * Arms remain-on-exit on the viewport (F3) and mirrors focus.ptr (setFocus). */
423
+ * - outgoing resident OR still mid-turn → backstage (kept warm / F2); a parked
424
+ * terminal viewer RELEASE (status idle, pane reaped); a holder or
425
+ * done/dormant occupant reap its now-backstage pane (Invariant P).
426
+ * Arms remain-on-exit on the viewport (F3); the focus row is the record. */
399
427
  export function retargetFocus(focusId, incoming, revive) {
400
428
  let f = getFocusById(focusId);
401
429
  if (f === null)
@@ -436,7 +464,6 @@ export function retargetFocus(focusId, incoming, revive) {
436
464
  const loc = paneLocation(pin);
437
465
  commitFocusTxn(f.focus_id, incoming, pin, loc, outgoing, { kind: 'kill' }, null, null);
438
466
  armRemainOnExit(loc?.window);
439
- setFocus(incoming);
440
467
  return { focused: true, session: loc?.session ?? f.session, inPlace: true, revived };
441
468
  }
442
469
  // The hot-swap: incoming's pane → the viewport slot; outgoing's pane →
@@ -446,14 +473,25 @@ export function retargetFocus(focusId, incoming, revive) {
446
473
  }
447
474
  const pinLoc = paneLocation(pin); // now the viewport
448
475
  const outLoc = paneLocation(focusPane); // now backstage (outgoing's new home)
449
- const action = outgoingDisposition({ exists: getRow(outgoing) !== null, generating: isGenerating(outgoing) });
476
+ const oRow = getRow(outgoing);
477
+ const action = outgoingDisposition({
478
+ exists: oRow !== null,
479
+ live: oRow?.status === 'active' || oRow?.status === 'idle',
480
+ resident: oRow?.lifecycle === 'resident',
481
+ generating: isGenerating(outgoing),
482
+ });
450
483
  commitFocusTxn(f.focus_id, incoming, pin, pinLoc, outgoing, action, outLoc, focusPane);
451
- // Reap the outgoing/holder pane (now backstage) when not generating — AFTER
452
- // commit (a tmux side effect, outside the txn).
453
- if (action.kind === 'kill')
484
+ // Crash-safety: flip a released (parked terminal viewer) node to dormant
485
+ // (idle + intent='idle-release') BEFORE reaping its pane, so the daemon never
486
+ // sees a window-gone live node and races to revive it. Then reap the
487
+ // outgoing/holder pane (now backstage) for both kill (done/dormant/holder) and
488
+ // release (parked viewer) — AFTER commit (a tmux side effect, outside the txn).
489
+ // A still-generating worker or a resident node is backstaged, untouched here.
490
+ if (action.kind === 'release')
491
+ transition(outgoing, 'release');
492
+ if (action.kind === 'kill' || action.kind === 'release')
454
493
  closePane(focusPane);
455
494
  armRemainOnExit(pinLoc?.window);
456
- setFocus(incoming);
457
495
  return { focused: true, session: pinLoc?.session ?? f.session, inPlace: true, revived };
458
496
  }
459
497
  /** The ONE atomic txn (§2.5): point the focus row at `pin`, set its occupant to
@@ -473,6 +511,7 @@ function commitFocusTxn(focusId, incoming, pin, pinLoc, outgoing, action, outLoc
473
511
  setPresence(outgoing, { pane: outgoingPane, tmux_session: outLoc?.session ?? null, window: outLoc?.window ?? null });
474
512
  }
475
513
  else {
514
+ // kill | release — the pane is reaped by the caller; null the LOCATION.
476
515
  setPresence(outgoing, { pane: null, tmux_session: null, window: null });
477
516
  }
478
517
  }
@@ -490,16 +529,15 @@ function commitFocusTxn(focusId, incoming, pin, pinLoc, outgoing, action, outLoc
490
529
  * - else → retarget the caller pane's focus IN PLACE (`focusByPane`); if the
491
530
  * caller's pane is not yet a viewport, adopt it as one (occupied by whatever
492
531
  * node sits there now — `callerNode`, else resolved by pane).
493
- * - no caller pane (not in tmux) → best-effort: mirror focus.ptr, report
494
- * not-in-place. */
532
+ * - no caller pane (not in tmux) → best-effort: reconcile + report status,
533
+ * not-in-place (no viewport to swap into). */
495
534
  export function focus(nodeId, opts) {
496
535
  const meta = getNode(nodeId);
497
536
  if (meta === null)
498
537
  return { focused: false, session: null, inPlace: false, revived: false };
499
538
  const callerPane = opts.pane ?? process.env['TMUX_PANE'] ?? currentTmux()?.pane;
500
539
  if (callerPane === undefined || callerPane === '') {
501
- // Not in tmux — no viewport to swap into. Mirror the pointer; report status.
502
- setFocus(nodeId);
540
+ // Not in tmux — no viewport to swap into. Reconcile and report status.
503
541
  reconcile(nodeId);
504
542
  return { focused: isNodePaneAlive(nodeId), session: meta.tmux_session ?? null, inPlace: false, revived: false };
505
543
  }
@@ -507,13 +545,21 @@ export function focus(nodeId, opts) {
507
545
  const opened = openFocus(callerPane, {});
508
546
  if (opened === null)
509
547
  return { focused: false, session: null, inPlace: false, revived: false };
510
- return retargetFocus(opened.focus_id, nodeId, opts.revive);
548
+ const res = retargetFocus(opened.focus_id, nodeId, opts.revive);
549
+ // Failed to place the incoming node — reap the just-opened HOLDER pane + its
550
+ // focus row so a failed `--new-pane` leaves no orphan viewport (F4 / Invariant
551
+ // P). The success path already reaps the holder via the hot-swap.
552
+ if (!res.focused) {
553
+ if (opened.pane !== null)
554
+ closePane(opened.pane);
555
+ closeFocusRow(opened.focus_id);
556
+ }
557
+ return res;
511
558
  }
512
559
  let f = focusByPane(callerPane);
513
560
  if (f === null)
514
561
  f = ensureFocusAtPane(callerPane, opts.callerNode);
515
562
  if (f === null) {
516
- setFocus(nodeId);
517
563
  return { focused: false, session: meta.tmux_session ?? null, inPlace: false, revived: false };
518
564
  }
519
565
  return retargetFocus(f.focus_id, nodeId, opts.revive);
@@ -544,7 +590,7 @@ function ensureFocusAtPane(pane, callerNode) {
544
590
  * Reconcile first (follow a manual move / backfill a legacy pane), close the
545
591
  * focus row it occupies (if any), kill its pane (pane-keyed via the durable
546
592
  * `%id` — the window collapses once its last pane goes), and null its LOCATION.
547
- * Mirrors focus.ptr when this node was the current focus. Best-effort tmux; the
593
+ * The focus row close is the record. Best-effort tmux; the
548
594
  * DB writes always land. The pane kill is the sole teardown unit (Q1/§6: a
549
595
  * split-pane focus returns its space to the surviving split; a standalone-window
550
596
  * focus closes the window). */
@@ -558,8 +604,6 @@ export function tearDownNode(nodeId) {
558
604
  if (pane !== null && paneExists(pane))
559
605
  closePane(pane);
560
606
  setPresence(nodeId, { pane: null, tmux_session: null, window: null });
561
- if (getFocus() === nodeId)
562
- setFocus('');
563
607
  }
564
608
  /** Demote's in-pane relaunch (§2.3, flow (e)): respawn `nodeId`'s launch into an
565
609
  * EXISTING `pane`, keeping the durable `%id` (respawn-pane -k), and record its
@@ -611,7 +655,6 @@ export function handFocusToManager(focusId, managerId) {
611
655
  if (getFocusByNode(managerId) !== null)
612
656
  return false; // manager already focused elsewhere
613
657
  setFocusOccupant(focusId, managerId);
614
- setFocus(managerId);
615
658
  // MAJOR 1 — LIVE backstage manager → swap it into the focus slot now. DORMANT
616
659
  // managers (no live pane / dead pi) fall through unchanged: the daemon revives
617
660
  // them into the frozen %m async.
@@ -625,6 +668,20 @@ export function handFocusToManager(focusId, managerId) {
625
668
  }
626
669
  return true; // still "took focus" — caller doesn't close
627
670
  }
671
+ /** Q1 close-to-shell for a truly-done focused node with no successor (§1.6 /
672
+ * flow (b)): close its focus row and DISARM the pane's
673
+ * freeze (`remain-on-exit` off) so it reaps when the finishing pi exits. The
674
+ * stophook calls this instead of `tearDownNode` because it runs INSIDE the pane
675
+ * it is closing: it cannot `closePane` its own pane (self-saw), but it is still
676
+ * alive to disarm the freeze, so the pane closes on exit (return-to-shell)
677
+ * rather than freezing into an orphan. (Keeps the stophook off the tmux driver,
678
+ * §2.1.) */
679
+ export function closeFocusToShell(focusId, nodeId) {
680
+ closeFocusRow(focusId);
681
+ const win = getNode(nodeId)?.window;
682
+ if (win != null && win !== '')
683
+ setRemainOnExit(win, false);
684
+ }
628
685
  /** Join each of `childIds`' live panes into `targetId`'s window, lay them out
629
686
  * (target wide on the left, children stacked right), and focus it. Reconcile
630
687
  * drives both the target resolution and the per-join fix-up (a joined pane keeps
@@ -658,6 +715,5 @@ export function spreadNode(targetId, childIds, opts = {}) {
658
715
  selectLayout(targetWindow, 'main-vertical');
659
716
  }
660
717
  const focused = switchClient(targetSession) && selectWindow(targetSession, targetWindow);
661
- setFocus(targetId);
662
718
  return { window: targetWindow, session: targetSession, joined, focused };
663
719
  }
@@ -1,16 +1,19 @@
1
- /** Reap the descendant sub-DAG of `rootId`: mark each **done** (the user moved
2
- * on — a clean teardown, NOT a fault) + clear intent FIRST, then kill its
1
+ /** Reap the descendant sub-DAG of `rootId`: mark each **canceled** (the user
2
+ * moved on — a clean teardown, NOT a fault) + clear intent FIRST, then kill its
3
3
  * window (closes the daemon revive race). Edges are LEFT INTACT — descendants
4
4
  * keep parent=rootId. No wipe. Returns the reaped ids.
5
5
  *
6
- * Why `done`, and why marking is STILL explicit: a `closeWindow`/`respawn-pane
7
- * -k` kill is abrupt and fires NO clean `session_shutdown`, so the general
8
- * quit→done rule does NOT auto-resolve a force-killed descendant we mark it
9
- * `done` here. Shared by relaunchRoot (option C) and resetRoot's in-place
10
- * fallback, so both leave their descendants `done`. */
6
+ * Why `canceled` (A5, human-confirmed 2026-06-06): an externally-reaped node
7
+ * whether via `node close` OR a root reset/relaunch did not finish its OWN
8
+ * work, so it unifies on `canceled`; `done` is reserved for finalize. Why
9
+ * marking is STILL explicit: a `closeWindow`/`respawn-pane -k` kill is abrupt
10
+ * and fires NO clean `session_shutdown`, so the general quit→done rule does NOT
11
+ * auto-resolve a force-killed descendant — we mark it `canceled` here via the
12
+ * same `cancel` event the close cascade uses. Shared by relaunchRoot (option C)
13
+ * and resetRoot's in-place fallback, so both leave their descendants `canceled`. */
11
14
  export declare function reapDescendants(rootId: string): string[];
12
15
  export interface ResetRootResult {
13
- /** Descendant node ids torn down (window killed + marked done). */
16
+ /** Descendant node ids torn down (window killed + marked canceled). */
14
17
  reaped: string[];
15
18
  /** Direct subscriptions dropped off the root. */
16
19
  detached: string[];
@@ -6,7 +6,7 @@
6
6
  // behave like re-running `crtr` we have two strategies:
7
7
  //
8
8
  // • relaunchRoot (option C) — for a ROOT in a tmux pane: PARK the old root
9
- // (mark done, keep its id/edges/pi_session_id intact as history), mint a
9
+ // (mark canceled, keep its id/edges/pi_session_id intact as history), mint a
10
10
  // FRESH node id, and re-exec pi in the current pane bound to the new id.
11
11
  // The old id never changes meaning; external refs stay valid.
12
12
  // • resetRoot (fallback) — for a non-root child (session-id refresh only) or
@@ -15,42 +15,44 @@
15
15
  // Termination semantics: a pi that ends cleanly resolves its node to `done`
16
16
  // (markCleanExitDone); only a true crash leaves it `dead`. A force-kill
17
17
  // (closeWindow / respawn-pane -k) fires NO clean session_shutdown, so reaped
18
- // descendants are marked `done` explicitly here.
18
+ // descendants are marked `canceled` explicitly here (A5: an externally-reaped
19
+ // node did not finish its own work — done is reserved for finalize).
19
20
  //
20
21
  // Best-effort throughout: a tmux/fs failure on one node never aborts the reset.
21
22
  import { existsSync, rmSync } from 'node:fs';
22
- import { getNode, updateNode, setPresence, clearPid, subscriptionsOf, unsubscribe, view, reportsDir, inboxPath, nodeDir, openDb, } from '../canvas/index.js';
23
+ import { getNode, updateNode, setPresence, clearPid, setFocusOccupant, subscriptionsOf, unsubscribe, view, reportsDir, inboxPath, nodeDir, openDb, } from '../canvas/index.js';
23
24
  import { transition } from './lifecycle.js';
24
- import { paneLocation, nodeSession } from './tmux.js';
25
- import { tearDownNode } from './placement.js';
25
+ import { paneLocation, tearDownNode, focusOf } from './placement.js';
26
26
  import { buildLaunchSpec } from './launch.js';
27
27
  import { roadmapPath } from './roadmap.js';
28
- import { spawnNode, newNodeId } from './nodes.js';
29
- import { setFocus } from './presence.js';
28
+ import { spawnNode, newNodeId, nodeSession } from './nodes.js';
30
29
  import { relaunchRootInPane } from './revive.js';
31
30
  // ---------------------------------------------------------------------------
32
31
  // reapDescendants — tear down a root's descendant sub-DAG (shared helper)
33
32
  // ---------------------------------------------------------------------------
34
- /** Reap the descendant sub-DAG of `rootId`: mark each **done** (the user moved
35
- * on — a clean teardown, NOT a fault) + clear intent FIRST, then kill its
33
+ /** Reap the descendant sub-DAG of `rootId`: mark each **canceled** (the user
34
+ * moved on — a clean teardown, NOT a fault) + clear intent FIRST, then kill its
36
35
  * window (closes the daemon revive race). Edges are LEFT INTACT — descendants
37
36
  * keep parent=rootId. No wipe. Returns the reaped ids.
38
37
  *
39
- * Why `done`, and why marking is STILL explicit: a `closeWindow`/`respawn-pane
40
- * -k` kill is abrupt and fires NO clean `session_shutdown`, so the general
41
- * quit→done rule does NOT auto-resolve a force-killed descendant we mark it
42
- * `done` here. Shared by relaunchRoot (option C) and resetRoot's in-place
43
- * fallback, so both leave their descendants `done`. */
38
+ * Why `canceled` (A5, human-confirmed 2026-06-06): an externally-reaped node
39
+ * whether via `node close` OR a root reset/relaunch did not finish its OWN
40
+ * work, so it unifies on `canceled`; `done` is reserved for finalize. Why
41
+ * marking is STILL explicit: a `closeWindow`/`respawn-pane -k` kill is abrupt
42
+ * and fires NO clean `session_shutdown`, so the general quit→done rule does NOT
43
+ * auto-resolve a force-killed descendant — we mark it `canceled` here via the
44
+ * same `cancel` event the close cascade uses. Shared by relaunchRoot (option C)
45
+ * and resetRoot's in-place fallback, so both leave their descendants `canceled`. */
44
46
  export function reapDescendants(rootId) {
45
47
  const reaped = [];
46
48
  for (const id of view(rootId)) {
47
49
  try {
48
50
  // Reap BEFORE tearing down the placement (the crash-safety invariant the
49
- // `reap` event encodes): a non-supervised status + cleared intent first, so
51
+ // `cancel` event encodes): a non-supervised status + cleared intent first, so
50
52
  // the daemon can't revive a descendant mid-teardown. tearDownNode then
51
53
  // closes any focus row it held, kills its pane (pane-keyed), and nulls its
52
54
  // LOCATION.
53
- transition(id, 'reap');
55
+ transition(id, 'cancel');
54
56
  tearDownNode(id);
55
57
  reaped.push(id);
56
58
  }
@@ -79,7 +81,7 @@ export function resetRoot(nodeId, newSessionId, newSessionFile) {
79
81
  }
80
82
  return { reaped: [], detached: [], reset: false };
81
83
  }
82
- // 1) Reap the descendant sub-DAG (mark done + kill windows; shared helper).
84
+ // 1) Reap the descendant sub-DAG (mark canceled + kill windows; shared helper).
83
85
  const reaped = reapDescendants(nodeId);
84
86
  // 2) Detach the root's own subscriptions so its view is empty.
85
87
  const detached = [];
@@ -153,7 +155,6 @@ export function handleNewSession(nodeId, newSessionId, pane, deps = {}, newSessi
153
155
  return { path: 'relaunch', newNodeId: result.newNodeId };
154
156
  }
155
157
  catch {
156
- setFocus(nodeId);
157
158
  resetRoot(nodeId, newSessionId, newSessionFile);
158
159
  return { path: 'reset-root' };
159
160
  }
@@ -167,7 +168,7 @@ export function relaunchRoot(oldId, pane, deps = {}) {
167
168
  const oldMeta = getNode(oldId);
168
169
  if (oldMeta === null || oldMeta.parent != null)
169
170
  return null; // defensive: not a root
170
- if (oldMeta.status === 'done')
171
+ if (oldMeta.status === 'canceled')
171
172
  return null; // defensive: already parked (rapid double /new)
172
173
  const respawn = deps.relaunchRootInPane ?? relaunchRootInPane;
173
174
  // Resolve where the new pi will live (pane authoritative; fall back to old
@@ -184,11 +185,12 @@ export function relaunchRoot(oldId, pane, deps = {}) {
184
185
  // back, leaving the old root EXACTLY as it was (no hand-rolled compensation).
185
186
  // Only the *detached* respawn (the async pane kill) lands outside the txn — it
186
187
  // must, since it kills this caller, and by then COMMIT has made the new state
187
- // durable. setFocus is a file write, not in the txn; the catch restores it.
188
+ // durable. The focus repoint (step 4) is INSIDE the txn, so a ROLLBACK undoes
189
+ // it automatically — there is no file to restore.
188
190
  const db = openDb();
189
191
  db.exec('BEGIN');
190
192
  try {
191
- // 1) Reap descendants (mark done + kill windows, keep edges, no wipe).
193
+ // 1) Reap descendants (mark canceled + kill windows, keep edges, no wipe).
192
194
  reapDescendants(oldId);
193
195
  // 2) Create the fresh root node (new id, empty context dir via
194
196
  // ensureNodeDirs) seeded active; `yield` adds the refresh safety net so
@@ -210,13 +212,18 @@ export function relaunchRoot(oldId, pane, deps = {}) {
210
212
  // of the pane it is respawned into (same pane-recycle rule as demote).
211
213
  updateNode(newId, { home_session: loc.session ?? nodeSession() });
212
214
  clearPid(newId); // no pi yet → daemon 'leave' until boot records the pid
213
- // 3) Park the old root: reap (done + intent cleared) and detach its window so
214
- // it never claims the pane, but KEEP pi_session_id (resumable),
215
- // parent=null, and all edges.
216
- transition(oldId, 'reap');
215
+ // 3) Park the old root: cancel (canceled + intent cleared) and detach its
216
+ // window so it never claims the pane, but KEEP pi_session_id (resumable),
217
+ // parent=null, and all edges. A5: a superseded old root did not finish its
218
+ // own work — it unifies on `canceled` like every other external reap.
219
+ transition(oldId, 'cancel');
217
220
  setPresence(oldId, { window: null, tmux_session: null });
218
- // 4) Focus follows content (file write restored by the catch on rollback).
219
- setFocus(newId);
221
+ // 4) Focus follows content: repoint the old root's focus row to the new root
222
+ // (same pane — respawn-pane -k below keeps the %id). Inside the txn → the
223
+ // ROLLBACK path restores the old occupant automatically (no file to restore).
224
+ const oldFocus = focusOf(oldId);
225
+ if (oldFocus !== null)
226
+ setFocusOccupant(oldFocus.focus_id, newId);
220
227
  // 5) Re-exec pi in this pane bound to newId; the dispatch is the LAST thing
221
228
  // inside the txn. If it throws the txn rolls back (old root untouched); on
222
229
  // success we COMMIT and the async detached kill of this pane lands after.
@@ -237,10 +244,8 @@ export function relaunchRoot(oldId, pane, deps = {}) {
237
244
  rmSync(nodeDir(newId), { recursive: true, force: true });
238
245
  }
239
246
  catch { /* */ }
240
- try {
241
- setFocus(oldId);
242
- }
243
- catch { /* */ } // focus is a file op, outside the txn
247
+ // The focus repoint was inside the txn; ROLLBACK already restored the old
248
+ // occupant — nothing to undo here.
244
249
  throw err instanceof Error ? err : new Error(String(err));
245
250
  }
246
251
  return { newNodeId: newId };
@@ -1,5 +1,5 @@
1
1
  import { type NodeMeta } from '../canvas/index.js';
2
- import { type RespawnPaneOpts } from './tmux.js';
2
+ import { type RespawnPaneOpts } from './placement.js';
3
3
  /** Pick the `--session` source for a revive. resume=true prefers the absolute
4
4
  * session-file path (immune to cwd; pi opens it directly) and keeps the bare
5
5
  * session id as the fallback for older nodes booted before pi_session_file was
@@ -19,8 +19,8 @@ import { transition } from './lifecycle.js';
19
19
  import { buildPiArgv } from './launch.js';
20
20
  import { buildReviveKickoff, drainBearings } from './kickoff.js';
21
21
  import { FRONT_DOOR_ENV } from './front-door.js';
22
- import { piCommand, respawnPane, nodeSession } from './tmux.js';
23
- import { reviveIntoPlacement, reconcile, isNodePaneAlive, homeSessionOf } from './placement.js';
22
+ import { reviveIntoPlacement, reconcile, isNodePaneAlive, homeSessionOf, piCommand, respawnPane, } from './placement.js';
23
+ import { nodeSession } from './nodes.js';
24
24
  /** signal-0 liveness probe for a pi pid (mirrors the daemon's isPidAlive). A
25
25
  * null pid (legacy / never-booted) reads dead. */
26
26
  function pidAlive(pid) {