@crouton-kit/crouter 0.3.16 → 0.3.18

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 (104) hide show
  1. package/dist/builtin-personas/design/{base.md → PERSONA.md} +4 -0
  2. package/dist/builtin-personas/developer/{base.md → PERSONA.md} +4 -0
  3. package/dist/builtin-personas/explore/{base.md → PERSONA.md} +4 -0
  4. package/dist/builtin-personas/general/{base.md → PERSONA.md} +4 -0
  5. package/dist/builtin-personas/orchestration-kernel.md +6 -6
  6. package/dist/builtin-personas/plan/{base.md → PERSONA.md} +5 -1
  7. package/dist/builtin-personas/plan/reviewers/architecture-fit/{base.md → PERSONA.md} +1 -1
  8. package/dist/builtin-personas/plan/reviewers/code-smells/{base.md → PERSONA.md} +1 -1
  9. package/dist/builtin-personas/plan/reviewers/pattern-consistency/{base.md → PERSONA.md} +1 -1
  10. package/dist/builtin-personas/plan/reviewers/requirements-coverage/{base.md → PERSONA.md} +1 -1
  11. package/dist/builtin-personas/plan/reviewers/security/{base.md → PERSONA.md} +1 -1
  12. package/dist/builtin-personas/review/{base.md → PERSONA.md} +4 -0
  13. package/dist/builtin-personas/spec/{base.md → PERSONA.md} +5 -1
  14. package/dist/builtin-skills/skills/crouter-development/personas/SKILL.md +24 -14
  15. package/dist/builtin-skills/skills/crouter-development/personas/base-prompt/SKILL.md +4 -4
  16. package/dist/commands/canvas-browse.d.ts +2 -0
  17. package/dist/commands/canvas-browse.js +45 -0
  18. package/dist/commands/canvas-prune.js +11 -2
  19. package/dist/commands/canvas.js +3 -2
  20. package/dist/commands/daemon.js +1 -1
  21. package/dist/commands/human/prompts.js +3 -9
  22. package/dist/commands/human/shared.d.ts +26 -1
  23. package/dist/commands/human/shared.js +48 -10
  24. package/dist/commands/node.js +66 -4
  25. package/dist/commands/skill/author.js +2 -2
  26. package/dist/core/__tests__/cascade-close.test.js +199 -0
  27. package/dist/core/__tests__/daemon-boot.test.js +7 -0
  28. package/dist/core/__tests__/daemon-liveness.test.js +59 -4
  29. package/dist/core/__tests__/dead-pane-regression.test.js +151 -0
  30. package/dist/core/__tests__/fixtures/fake-pi-host.d.ts +2 -0
  31. package/dist/core/__tests__/fixtures/fake-pi-host.js +301 -0
  32. package/dist/core/__tests__/flagship-lifecycle.test.js +273 -0
  33. package/dist/core/__tests__/grace-clock.test.d.ts +1 -0
  34. package/dist/core/__tests__/grace-clock.test.js +115 -0
  35. package/dist/core/__tests__/helpers/harness.d.ts +78 -0
  36. package/dist/core/__tests__/helpers/harness.js +406 -0
  37. package/dist/core/__tests__/human-surface-target.test.d.ts +1 -0
  38. package/dist/core/__tests__/human-surface-target.test.js +98 -0
  39. package/dist/core/__tests__/lifecycle.test.js +6 -13
  40. package/dist/core/__tests__/live-mutation.test.d.ts +1 -0
  41. package/dist/core/__tests__/live-mutation.test.js +341 -0
  42. package/dist/core/__tests__/persona-subkind.test.js +18 -15
  43. package/dist/core/__tests__/placement-focus.test.js +53 -15
  44. package/dist/core/__tests__/relaunch.test.js +12 -12
  45. package/dist/core/__tests__/reset.test.js +11 -6
  46. package/dist/core/__tests__/spike-harness.test.d.ts +1 -0
  47. package/dist/core/__tests__/spike-harness.test.js +241 -0
  48. package/dist/core/__tests__/subscription-delivery.test.d.ts +1 -0
  49. package/dist/core/__tests__/subscription-delivery.test.js +233 -0
  50. package/dist/core/canvas/browse/__tests__/model.test.d.ts +1 -0
  51. package/dist/core/canvas/browse/__tests__/model.test.js +142 -0
  52. package/dist/core/canvas/browse/__tests__/render.test.d.ts +1 -0
  53. package/dist/core/canvas/browse/__tests__/render.test.js +102 -0
  54. package/dist/core/canvas/browse/app.d.ts +4 -0
  55. package/dist/core/canvas/browse/app.js +349 -0
  56. package/dist/core/canvas/browse/model.d.ts +97 -0
  57. package/dist/core/canvas/browse/model.js +258 -0
  58. package/dist/core/canvas/browse/render.d.ts +41 -0
  59. package/dist/core/canvas/browse/render.js +387 -0
  60. package/dist/core/canvas/browse/terminal.d.ts +23 -0
  61. package/dist/core/canvas/browse/terminal.js +100 -0
  62. package/dist/core/canvas/canvas.d.ts +9 -2
  63. package/dist/core/canvas/canvas.js +41 -3
  64. package/dist/core/canvas/paths.d.ts +4 -1
  65. package/dist/core/canvas/paths.js +10 -4
  66. package/dist/core/canvas/render.d.ts +10 -0
  67. package/dist/core/canvas/render.js +25 -1
  68. package/dist/core/canvas/types.js +2 -2
  69. package/dist/core/feed/inbox.d.ts +0 -3
  70. package/dist/core/feed/inbox.js +1 -5
  71. package/dist/core/help.d.ts +6 -0
  72. package/dist/core/help.js +7 -0
  73. package/dist/core/personas/index.d.ts +4 -3
  74. package/dist/core/personas/index.js +3 -2
  75. package/dist/core/personas/loader.d.ts +34 -16
  76. package/dist/core/personas/loader.js +102 -29
  77. package/dist/core/personas/resolve.d.ts +4 -4
  78. package/dist/core/personas/resolve.js +16 -14
  79. package/dist/core/runtime/busy.d.ts +8 -0
  80. package/dist/core/runtime/busy.js +46 -0
  81. package/dist/core/runtime/lifecycle.d.ts +1 -1
  82. package/dist/core/runtime/lifecycle.js +12 -4
  83. package/dist/core/runtime/naming.d.ts +3 -3
  84. package/dist/core/runtime/naming.js +6 -6
  85. package/dist/core/runtime/placement.d.ts +32 -5
  86. package/dist/core/runtime/placement.js +81 -14
  87. package/dist/core/runtime/reset.d.ts +11 -8
  88. package/dist/core/runtime/reset.js +23 -18
  89. package/dist/core/spawn.d.ts +20 -1
  90. package/dist/core/spawn.js +52 -5
  91. package/dist/daemon/crtrd.js +43 -21
  92. package/dist/pi-extensions/canvas-nav.js +106 -55
  93. package/dist/pi-extensions/canvas-resume.d.ts +0 -1
  94. package/dist/pi-extensions/canvas-resume.js +35 -126
  95. package/dist/pi-extensions/canvas-stophook.d.ts +1 -1
  96. package/dist/pi-extensions/canvas-stophook.js +16 -0
  97. package/dist/prompts/skill.js +6 -1
  98. package/package.json +1 -1
  99. package/dist/commands/__tests__/skill.test.js +0 -290
  100. package/dist/core/__tests__/pkg.test.js +0 -218
  101. package/dist/core/__tests__/sys.test.js +0 -208
  102. /package/dist/{commands/__tests__/skill.test.d.ts → core/__tests__/cascade-close.test.d.ts} +0 -0
  103. /package/dist/core/__tests__/{pkg.test.d.ts → dead-pane-regression.test.d.ts} +0 -0
  104. /package/dist/core/__tests__/{sys.test.d.ts → flagship-lifecycle.test.d.ts} +0 -0
@@ -25,9 +25,11 @@
25
25
  // The robustness contract: a manual `move-pane`/`join-pane`/`break-pane` must
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
- import { getRow, getRowByPane, getNode, setPresence, openDb, openFocusRow, setFocusOccupant, closeFocusRow, getFocusByNode, getFocusByPane, getFocusById, setFocusPane, listFocuses as listFocusRows, } from '../canvas/index.js';
28
+ import { getRow, getRowByPane, getNode, setPresence, openDb, openFocusRow, setFocusOccupant, closeFocusRow, getFocusByNode, getFocusByPane, getFocusById, setFocusPane, listFocuses as listFocusRows, view, } from '../canvas/index.js';
29
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
30
  import { homeSessionOf, nodeSession, newNodeId } from './nodes.js';
31
+ import { isBusy } from './busy.js';
32
+ import { transition } from './lifecycle.js';
31
33
  // Re-export the durable REVIVE-HOME read so placement is the one front door for
32
34
  // "where does this node live."
33
35
  export { homeSessionOf };
@@ -69,6 +71,42 @@ export function focusedNodes() {
69
71
  export function listFocuses() {
70
72
  return listFocusRows();
71
73
  }
74
+ // ---------------------------------------------------------------------------
75
+ // Graph → focus routing (for surfacing human-in-the-loop prompts)
76
+ // ---------------------------------------------------------------------------
77
+ /** The root of a node's spine: walk the `parent` column up to `parent == null`.
78
+ * Cycle-guarded (parents must not cycle, but never loop forever). */
79
+ function rootOfSpine(nodeId) {
80
+ let cur = nodeId;
81
+ const seen = new Set();
82
+ for (;;) {
83
+ if (seen.has(cur))
84
+ return cur;
85
+ seen.add(cur);
86
+ const row = getRow(cur);
87
+ if (row === null || row.parent == null)
88
+ return cur;
89
+ cur = row.parent;
90
+ }
91
+ }
92
+ /** The on-screen viewport a human-in-the-loop prompt raised by `nodeId` should
93
+ * surface into: the HIGHEST FOCUSED node of nodeId's graph — the focused node
94
+ * closest to the graph root, i.e. the session/window the user is actually
95
+ * watching this work in. Walks nodeId's spine to its root, enumerates the whole
96
+ * tree root-first (`view` is BFS ⇒ shallowest first), and returns the focus row
97
+ * of the first node that occupies a viewport. null when nothing in the graph is
98
+ * on screen — the caller then surfaces in the user's attached pane rather than
99
+ * the backstage node session. PURE (db reads only): no tmux probe, so the pane
100
+ * may be stale; the caller liveness-checks before targeting it. */
101
+ export function graphSurfaceTarget(nodeId) {
102
+ const root = rootOfSpine(nodeId);
103
+ for (const id of [root, ...view(root)]) {
104
+ const f = getFocusByNode(id);
105
+ if (f !== null && f.pane !== null)
106
+ return f;
107
+ }
108
+ return null;
109
+ }
72
110
  /** PURE reconciliation decision (§2.4) — unit-testable without a live tmux.
73
111
  * Given the cached row LOCATION and what tmux currently reports, decide the
74
112
  * presence patch. Mirrors the pure-core/impure-shell split (cf. `livenessVerdict`
@@ -314,22 +352,37 @@ function pidAlive(pid) {
314
352
  return e.code === 'EPERM';
315
353
  }
316
354
  }
317
- /** Is a focus's OUTGOING occupant still GENERATING (a live pi doing work)? A
318
- * still-generating node is moved to backstage by a retarget (F2 — it keeps
319
- * running off-screen); a holder / done / dormant node has its pane reaped
320
- * (Invariant P). A holder or vanished node (row null) is never generating. */
355
+ /** Is a focus's OUTGOING occupant still GENERATING (a live pi actually MID-TURN)?
356
+ * A still-generating node is moved to backstage by a retarget (F2 — it keeps
357
+ * running off-screen); a holder / done / dormant / merely-parked node has its
358
+ * pane reaped or released (Invariant P). A holder or vanished node (row null) is
359
+ * never generating.
360
+ *
361
+ * The signal is the mid-turn `busy` marker AND a live pid — NOT pid-alive
362
+ * alone. A node revived only for VIEWING is parked at its prompt with a live
363
+ * pid between turns; pid-alive would misclassify it as "doing work" and leave
364
+ * it stuck backstaged-active forever. `isBusy` is true only inside a turn, so a
365
+ * parked viewer reads as not-generating and is released to dormant on
366
+ * focus-away. The AND with `pidAlive` makes a stale marker (a pi that crashed
367
+ * mid-turn) harmless. */
321
368
  function isGenerating(nodeId) {
322
369
  const row = getRow(nodeId);
323
370
  if (row === null)
324
371
  return false;
325
372
  if (row.status !== 'active' && row.status !== 'idle')
326
373
  return false;
327
- return pidAlive(row.pi_pid);
374
+ return isBusy(nodeId) && pidAlive(row.pi_pid);
328
375
  }
329
376
  export function outgoingDisposition(o) {
330
377
  if (!o.exists)
331
- return { kind: 'kill' };
332
- return o.generating ? { kind: 'backstage' } : { kind: 'kill' };
378
+ return { kind: 'kill' }; // holder pane
379
+ if (!o.live)
380
+ return { kind: 'kill' }; // done/dead/canceled — reap the pane
381
+ if (o.resident)
382
+ return { kind: 'backstage' }; // human-driven node: keep warm
383
+ if (o.generating)
384
+ return { kind: 'backstage' }; // mid-turn terminal worker (F2)
385
+ return { kind: 'release' }; // parked terminal viewer → dormant
333
386
  }
334
387
  /** The node's pane iff it is a LIVE pane (a generating-unfocused backstage pane,
335
388
  * or a still-live focus pane), else null. The retarget swaps THIS pane into the
@@ -403,8 +456,9 @@ export function registerRootFocus(nodeId, pane, session, window) {
403
456
  * - `swapPaneInPlace(pin, focusPane)`: incoming → the viewport slot; the
404
457
  * outgoing occupant → incoming's old (backstage) slot, %ids preserved
405
458
  * (cross-session swap confirmed by the spike).
406
- * - outgoing still generating → backstage (F2); else reap its now-backstage
407
- * pane (Invariant P). A holder occupant (no node row) is always reaped.
459
+ * - outgoing resident OR still mid-turn → backstage (kept warm / F2); a parked
460
+ * terminal viewer RELEASE (status idle, pane reaped); a holder or
461
+ * done/dormant occupant → reap its now-backstage pane (Invariant P).
408
462
  * Arms remain-on-exit on the viewport (F3); the focus row is the record. */
409
463
  export function retargetFocus(focusId, incoming, revive) {
410
464
  let f = getFocusById(focusId);
@@ -455,11 +509,23 @@ export function retargetFocus(focusId, incoming, revive) {
455
509
  }
456
510
  const pinLoc = paneLocation(pin); // now the viewport
457
511
  const outLoc = paneLocation(focusPane); // now backstage (outgoing's new home)
458
- const action = outgoingDisposition({ exists: getRow(outgoing) !== null, generating: isGenerating(outgoing) });
512
+ const oRow = getRow(outgoing);
513
+ const action = outgoingDisposition({
514
+ exists: oRow !== null,
515
+ live: oRow?.status === 'active' || oRow?.status === 'idle',
516
+ resident: oRow?.lifecycle === 'resident',
517
+ generating: isGenerating(outgoing),
518
+ });
459
519
  commitFocusTxn(f.focus_id, incoming, pin, pinLoc, outgoing, action, outLoc, focusPane);
460
- // Reap the outgoing/holder pane (now backstage) when not generating — AFTER
461
- // commit (a tmux side effect, outside the txn).
462
- if (action.kind === 'kill')
520
+ // Crash-safety: flip a released (parked terminal viewer) node to dormant
521
+ // (idle + intent='idle-release') BEFORE reaping its pane, so the daemon never
522
+ // sees a window-gone live node and races to revive it. Then reap the
523
+ // outgoing/holder pane (now backstage) for both kill (done/dormant/holder) and
524
+ // release (parked viewer) — AFTER commit (a tmux side effect, outside the txn).
525
+ // A still-generating worker or a resident node is backstaged, untouched here.
526
+ if (action.kind === 'release')
527
+ transition(outgoing, 'release');
528
+ if (action.kind === 'kill' || action.kind === 'release')
463
529
  closePane(focusPane);
464
530
  armRemainOnExit(pinLoc?.window);
465
531
  return { focused: true, session: pinLoc?.session ?? f.session, inPlace: true, revived };
@@ -481,6 +547,7 @@ function commitFocusTxn(focusId, incoming, pin, pinLoc, outgoing, action, outLoc
481
547
  setPresence(outgoing, { pane: outgoingPane, tmux_session: outLoc?.session ?? null, window: outLoc?.window ?? null });
482
548
  }
483
549
  else {
550
+ // kill | release — the pane is reaped by the caller; null the LOCATION.
484
551
  setPresence(outgoing, { pane: null, tmux_session: null, window: null });
485
552
  }
486
553
  }
@@ -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,7 +15,8 @@
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';
@@ -29,26 +30,29 @@ import { relaunchRootInPane } from './revive.js';
29
30
  // ---------------------------------------------------------------------------
30
31
  // reapDescendants — tear down a root's descendant sub-DAG (shared helper)
31
32
  // ---------------------------------------------------------------------------
32
- /** Reap the descendant sub-DAG of `rootId`: mark each **done** (the user moved
33
- * 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
34
35
  * window (closes the daemon revive race). Edges are LEFT INTACT — descendants
35
36
  * keep parent=rootId. No wipe. Returns the reaped ids.
36
37
  *
37
- * Why `done`, and why marking is STILL explicit: a `closeWindow`/`respawn-pane
38
- * -k` kill is abrupt and fires NO clean `session_shutdown`, so the general
39
- * quit→done rule does NOT auto-resolve a force-killed descendant we mark it
40
- * `done` here. Shared by relaunchRoot (option C) and resetRoot's in-place
41
- * 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`. */
42
46
  export function reapDescendants(rootId) {
43
47
  const reaped = [];
44
48
  for (const id of view(rootId)) {
45
49
  try {
46
50
  // Reap BEFORE tearing down the placement (the crash-safety invariant the
47
- // `reap` event encodes): a non-supervised status + cleared intent first, so
51
+ // `cancel` event encodes): a non-supervised status + cleared intent first, so
48
52
  // the daemon can't revive a descendant mid-teardown. tearDownNode then
49
53
  // closes any focus row it held, kills its pane (pane-keyed), and nulls its
50
54
  // LOCATION.
51
- transition(id, 'reap');
55
+ transition(id, 'cancel');
52
56
  tearDownNode(id);
53
57
  reaped.push(id);
54
58
  }
@@ -77,7 +81,7 @@ export function resetRoot(nodeId, newSessionId, newSessionFile) {
77
81
  }
78
82
  return { reaped: [], detached: [], reset: false };
79
83
  }
80
- // 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).
81
85
  const reaped = reapDescendants(nodeId);
82
86
  // 2) Detach the root's own subscriptions so its view is empty.
83
87
  const detached = [];
@@ -164,7 +168,7 @@ export function relaunchRoot(oldId, pane, deps = {}) {
164
168
  const oldMeta = getNode(oldId);
165
169
  if (oldMeta === null || oldMeta.parent != null)
166
170
  return null; // defensive: not a root
167
- if (oldMeta.status === 'done')
171
+ if (oldMeta.status === 'canceled')
168
172
  return null; // defensive: already parked (rapid double /new)
169
173
  const respawn = deps.relaunchRootInPane ?? relaunchRootInPane;
170
174
  // Resolve where the new pi will live (pane authoritative; fall back to old
@@ -186,7 +190,7 @@ export function relaunchRoot(oldId, pane, deps = {}) {
186
190
  const db = openDb();
187
191
  db.exec('BEGIN');
188
192
  try {
189
- // 1) Reap descendants (mark done + kill windows, keep edges, no wipe).
193
+ // 1) Reap descendants (mark canceled + kill windows, keep edges, no wipe).
190
194
  reapDescendants(oldId);
191
195
  // 2) Create the fresh root node (new id, empty context dir via
192
196
  // ensureNodeDirs) seeded active; `yield` adds the refresh safety net so
@@ -208,10 +212,11 @@ export function relaunchRoot(oldId, pane, deps = {}) {
208
212
  // of the pane it is respawned into (same pane-recycle rule as demote).
209
213
  updateNode(newId, { home_session: loc.session ?? nodeSession() });
210
214
  clearPid(newId); // no pi yet → daemon 'leave' until boot records the pid
211
- // 3) Park the old root: reap (done + intent cleared) and detach its window so
212
- // it never claims the pane, but KEEP pi_session_id (resumable),
213
- // parent=null, and all edges.
214
- 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');
215
220
  setPresence(oldId, { window: null, tmux_session: null });
216
221
  // 4) Focus follows content: repoint the old root's focus row to the new root
217
222
  // (same pane — respawn-pane -k below keeps the %id). Inside the txn → the
@@ -1,7 +1,21 @@
1
1
  export declare function isInTmux(): boolean;
2
2
  export declare function shellQuote(s: string): string;
3
- /** Count panes in the current tmux window (0 outside tmux / on error). */
3
+ /** Count panes in a tmux window (0 outside tmux / on error). With `targetPane`,
4
+ * counts the window THAT pane lives in (the placement decision must reflect the
5
+ * window the new pane will actually open into, not the caller's backstage one);
6
+ * without it, the caller's current window. */
7
+ export declare function countPanesInWindow(targetPane?: string): number;
8
+ /** Back-compat alias: panes in the caller's current window. */
4
9
  export declare function countPanesInCurrentWindow(): number;
10
+ /** Does this tmux pane id still exist? `display-message` EXITS 0 with EMPTY
11
+ * output on an unresolvable pane, so test for non-empty stdout, not just `.ok`.
12
+ * False outside tmux / on error. */
13
+ export declare function paneAlive(pane: string): boolean;
14
+ /** The active pane of the user's attached tmux client — where they are looking
15
+ * right now. `list-clients` first attached client, then its current pane. Used
16
+ * to surface a human prompt in the user's view when nothing in the asking
17
+ * node's graph is focused. null outside tmux / no client / on error. */
18
+ export declare function attachedClientPane(): string | null;
5
19
  /**
6
20
  * Schedule a kill-pane on the *current* tmux pane after `delaySeconds`, detached
7
21
  * so the caller can return normally before the pane dies. No-op outside tmux,
@@ -23,6 +37,11 @@ export interface DetachOptions {
23
37
  * uses the attached client's currently-focused pane — which drifts if the
24
38
  * user switches windows between kickoff and spawn. */
25
39
  targetPane?: string;
40
+ /** Pass tmux `-d` to new-window so CREATING the window never switches the
41
+ * attached client to it (split-window already leaves the client's view put).
42
+ * The prompt lands in the target session/window without jumping the user out
43
+ * of what they are looking at. No effect on split-h/split-v. */
44
+ detached?: boolean;
26
45
  }
27
46
  export interface DetachResult {
28
47
  status: 'spawned' | 'spawn-failed' | 'not-in-tmux';
@@ -13,15 +13,60 @@ export function isInTmux() {
13
13
  export function shellQuote(s) {
14
14
  return `'${s.replace(/'/g, "'\\''")}'`;
15
15
  }
16
- /** Count panes in the current tmux window (0 outside tmux / on error). */
17
- export function countPanesInCurrentWindow() {
18
- const result = spawnSync('tmux', ['list-panes', '-F', '#{pane_id}'], {
19
- encoding: 'utf8',
20
- });
16
+ /** Count panes in a tmux window (0 outside tmux / on error). With `targetPane`,
17
+ * counts the window THAT pane lives in (the placement decision must reflect the
18
+ * window the new pane will actually open into, not the caller's backstage one);
19
+ * without it, the caller's current window. */
20
+ export function countPanesInWindow(targetPane) {
21
+ const args = targetPane !== undefined && targetPane !== ''
22
+ ? ['list-panes', '-t', targetPane, '-F', '#{pane_id}']
23
+ : ['list-panes', '-F', '#{pane_id}'];
24
+ const result = spawnSync('tmux', args, { encoding: 'utf8' });
21
25
  if (result.status !== 0)
22
26
  return 0;
23
27
  return result.stdout.split('\n').filter((line) => line.trim() !== '').length;
24
28
  }
29
+ /** Back-compat alias: panes in the caller's current window. */
30
+ export function countPanesInCurrentWindow() {
31
+ return countPanesInWindow();
32
+ }
33
+ /** Does this tmux pane id still exist? `display-message` EXITS 0 with EMPTY
34
+ * output on an unresolvable pane, so test for non-empty stdout, not just `.ok`.
35
+ * False outside tmux / on error. */
36
+ export function paneAlive(pane) {
37
+ if (!isInTmux() || !/^%\d+$/.test(pane))
38
+ return false;
39
+ const r = spawnSync('tmux', ['display-message', '-p', '-t', pane, '#{pane_id}'], {
40
+ encoding: 'utf8',
41
+ });
42
+ return r.status === 0 && r.stdout.trim() !== '';
43
+ }
44
+ /** The active pane of the user's attached tmux client — where they are looking
45
+ * right now. `list-clients` first attached client, then its current pane. Used
46
+ * to surface a human prompt in the user's view when nothing in the asking
47
+ * node's graph is focused. null outside tmux / no client / on error. */
48
+ export function attachedClientPane() {
49
+ if (!isInTmux())
50
+ return null;
51
+ const clients = spawnSync('tmux', ['list-clients', '-F', '#{client_name}'], {
52
+ encoding: 'utf8',
53
+ });
54
+ if (clients.status !== 0)
55
+ return null;
56
+ const name = clients.stdout
57
+ .split('\n')
58
+ .map((l) => l.trim())
59
+ .find((l) => l !== '');
60
+ if (name === undefined)
61
+ return null;
62
+ const pane = spawnSync('tmux', ['display-message', '-p', '-c', name, '#{pane_id}'], {
63
+ encoding: 'utf8',
64
+ });
65
+ if (pane.status !== 0)
66
+ return null;
67
+ const id = pane.stdout.trim();
68
+ return id !== '' ? id : null;
69
+ }
25
70
  /**
26
71
  * Schedule a kill-pane on the *current* tmux pane after `delaySeconds`, detached
27
72
  * so the caller can return normally before the pane dies. No-op outside tmux,
@@ -53,6 +98,8 @@ export function spawnAndDetach(opts) {
53
98
  const splitArgs = [];
54
99
  if (opts.placement === 'new-window') {
55
100
  splitArgs.push('new-window');
101
+ if (opts.detached === true)
102
+ splitArgs.push('-d'); // don't switch the client to it
56
103
  if (opts.targetPane !== undefined && opts.targetPane !== '') {
57
104
  // -a = insert after target window; -t <pane> resolves to that pane's window.
58
105
  splitArgs.push('-a', '-t', opts.targetPane);
@@ -14,7 +14,11 @@
14
14
  // • Pane gone + intent==='idle-release' → node freed its own pane while
15
15
  // dormant; clear the stale window ref and revive (resume) when its inbox
16
16
  // gains an unseen entry.
17
- // • Pane gone + any other intent → crash: mark 'dead'.
17
+ // • Pane gone + any other intent → route on what the node was doing:
18
+ // - never-booted (pi_session_id null) → crash ('dead') + surface boot fail
19
+ // - mid-generation (busy marker present) → crash ('dead')
20
+ // - finished its turn, still awaiting a live child → crash ('dead'), for now
21
+ // - finished its turn, awaiting nothing live → finalize ('done')
18
22
  // • Nodes with no tmux placement (inline roots) are skipped.
19
23
  //
20
24
  // Single-instance guarantee
@@ -24,8 +28,9 @@
24
28
  import { writeFileSync, readFileSync, rmSync, existsSync, mkdirSync, } from 'node:fs';
25
29
  import { join } from 'node:path';
26
30
  import { crtrHome } from '../core/canvas/paths.js';
27
- import { listNodes, getRow, setPresence, getNode, } from '../core/canvas/index.js';
31
+ import { listNodes, getRow, setPresence, getNode, hasActiveLiveSubscription, } from '../core/canvas/index.js';
28
32
  import { transition } from '../core/runtime/lifecycle.js';
33
+ import { isBusy } from '../core/runtime/busy.js';
29
34
  import { isNodePaneAlive, reconcile } from '../core/runtime/placement.js';
30
35
  import { reviveNode } from '../core/runtime/revive.js';
31
36
  import { pushUrgent } from '../core/feed/feed.js';
@@ -201,28 +206,45 @@ export async function superviseTick(now = Date.now()) {
201
206
  setPresence(row.node_id, { tmux_session: row.tmux_session, window: null });
202
207
  }
203
208
  else {
204
- // The pane vanished without the node completing or refreshing. Split the
205
- // two ways that happens: a vehicle that NEVER BOOTED (pi exited before
206
- // its first session_start, so pi_session_id is still null) versus a
207
- // genuine mid-run CRASH (it had booted, so pi_session_id is set). Both
208
- // are dead, but a never-booted node is a spawn failure the parent was
209
- // never told aboutsurface it up the spine instead of dying quietly.
210
- transition(row.node_id, 'crash');
211
- // Boot-failed vs crashed turns on pi_session_id, an IDENTITY field — the
212
- // one place this pass still reads meta. surfaceBootFailure also wants the
213
- // full meta (name/kind) for its message.
209
+ // The pane vanished without the node yielding or releasing. Route on what
210
+ // the node was DOING at pane-kill time not every gone pane is a death:
211
+ // never-booted (pi_session_id null) crash + surface boot failure.
212
+ // A spawn failure the parent was never told about it had no turn to
213
+ // finish, so it can never be a finalize. (Boot-failed vs crashed turns
214
+ // on pi_session_id, an IDENTITY field — the one place this pass still
215
+ // reads meta; surfaceBootFailure also wants name/kind for its message.)
216
+ // MID-GENERATION (busy marker present) crash (→dead). agent_start
217
+ // touched the marker and agent_end never cleared it the pane was
218
+ // killed inside a turn: a genuine mid-run death. (The pane is gone, so
219
+ // pi is dead; we read isBusy WITHOUT the usual AND-pidAlive guard on
220
+ // purpose — here a stale marker IS the proof it died mid-turn.)
221
+ // • finished its turn (busy ABSENT) but still awaiting a LIVE child →
222
+ // crash (→dead) for now. (This waiting-on-a-live-child case may later
223
+ // route to a revivable-idle instead of a hard death.)
224
+ // • finished its turn AND awaiting nothing live → finalize (→done): it
225
+ // did its own work and the pane was closed to dismiss it.
214
226
  const meta = getNode(row.node_id);
215
- if (meta !== null && meta.pi_session_id == null) {
216
- process.stderr.write(`[crtrd] boot-failed ${row.node_id} (pane gone before pi ever started)\n`);
217
- try {
218
- await surfaceBootFailure(meta);
219
- }
220
- catch (err) {
221
- process.stderr.write(`[crtrd] surfaceBootFailure ${row.node_id} error: ${err.message}\n`);
222
- }
227
+ const neverBooted = meta !== null && meta.pi_session_id == null;
228
+ if (!neverBooted &&
229
+ !isBusy(row.node_id) &&
230
+ !hasActiveLiveSubscription(row.node_id)) {
231
+ transition(row.node_id, 'finalize');
232
+ process.stderr.write(`[crtrd] done ${row.node_id} (pane gone after turn end, no live child)\n`);
223
233
  }
224
234
  else {
225
- process.stderr.write(`[crtrd] dead ${row.node_id} (pane gone, intent=${String(row.intent)})\n`);
235
+ transition(row.node_id, 'crash');
236
+ if (neverBooted) {
237
+ process.stderr.write(`[crtrd] boot-failed ${row.node_id} (pane gone before pi ever started)\n`);
238
+ try {
239
+ await surfaceBootFailure(meta);
240
+ }
241
+ catch (err) {
242
+ process.stderr.write(`[crtrd] surfaceBootFailure ${row.node_id} error: ${err.message}\n`);
243
+ }
244
+ }
245
+ else {
246
+ process.stderr.write(`[crtrd] dead ${row.node_id} (pane gone, intent=${String(row.intent)})\n`);
247
+ }
226
248
  }
227
249
  }
228
250
  }