@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
@@ -33,12 +33,14 @@ export const CANVAS_GOAL_CAPTURE_PATH = resolveExtension('canvas-goal-capture');
33
33
  export const CANVAS_PASSIVE_CONTEXT_PATH = resolveExtension('canvas-passive-context');
34
34
  export const CANVAS_CONTEXT_INTRO_PATH = resolveExtension('canvas-context-intro');
35
35
  export const CANVAS_COMMANDS_PATH = resolveExtension('canvas-commands');
36
+ export const CANVAS_RESUME_PATH = resolveExtension('canvas-resume');
36
37
  /** The canvas extensions every node loads, in order: stophook (routing +
37
38
  * telemetry + session-id capture), inbox-watcher (wake), nav (in-editor
38
39
  * graph chrome), goal-capture (persist the first user message as the goal),
39
40
  * passive-context (drain passive backlog as pre-text on the next message),
40
41
  * context-intro (inject the <crtr-context> bearings block as its own session
41
- * message, once per brand-new chat), commands (the /promote slash-command).
42
+ * message, once per brand-new chat), commands (the /promote slash-command),
43
+ * resume (the /resume-node whole-canvas picker → `crtr node focus`).
42
44
  * All self-gate on CRTR_NODE_ID. goal-capture precedes passive-context so it
43
45
  * reads the raw user text. */
44
46
  export const CANVAS_EXTENSIONS = [
@@ -49,6 +51,7 @@ export const CANVAS_EXTENSIONS = [
49
51
  CANVAS_PASSIVE_CONTEXT_PATH,
50
52
  CANVAS_CONTEXT_INTRO_PATH,
51
53
  CANVAS_COMMANDS_PATH,
54
+ CANVAS_RESUME_PATH,
52
55
  ];
53
56
  /** Bare model aliases resolve to the anthropic provider under pi (avoids the
54
57
  * bedrock default). Anything with a `/` or an unknown name passes through. */
@@ -1,6 +1,13 @@
1
1
  import { type NodeMeta, type Mode, type Lifecycle, type LaunchSpec } from '../canvas/index.js';
2
2
  /** Generate a node id in the same shape as job ids (time-sortable + random). */
3
3
  export declare function newNodeId(): string;
4
+ /** The single, shared tmux session that ALL canvas node windows live in.
5
+ * Overridable with CRTR_NODE_SESSION (default `crtr`). Every root and every
6
+ * child opens a window here rather than cluttering the user's own working
7
+ * session — switch to it to browse the whole live graph, ignore it otherwise.
8
+ * Pure policy (env only, no tmux call), so it lives in the node layer, not the
9
+ * driver; the tmux driver imports it from here for installMenuBinding's use. */
10
+ export declare function nodeSession(): string;
4
11
  /** Resolve the tmux session a freshly-born node's window/pane opens into — and
5
12
  * thus its durable REVIVE-HOME (`home_session`). Pure so the birth decision is
6
13
  * unit-testable without a live tmux:
@@ -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? */
@@ -195,7 +198,7 @@ export declare function openFocus(callerPane: string, opts?: {
195
198
  * `remain-on-exit` so a clean exit FREEZES the pane rather than detaching the
196
199
  * terminal (F1). A background `--root` does NOT call this (§6): it stays a plain
197
200
  * 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). */
201
+ * already a focus. The focus row IS the record — no pointer to mirror. */
199
202
  export declare function registerRootFocus(nodeId: string, pane: string, session: string | null, window: string | null): FocusRow | null;
200
203
  /** retargetFocus — the unified hot-swap (§2.5, Invariant P + Q5). Swap `incoming`
201
204
  * onto focus `focusId`'s viewport, keeping the screen position invariant (no new
@@ -209,7 +212,7 @@ export declare function registerRootFocus(nodeId: string, pane: string, session:
209
212
  * (cross-session swap confirmed by the spike).
210
213
  * - outgoing still generating → backstage (F2); else reap its now-backstage
211
214
  * 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). */
215
+ * Arms remain-on-exit on the viewport (F3); the focus row is the record. */
213
216
  export declare function retargetFocus(focusId: string, incoming: string, revive: Reviver): FocusResult;
214
217
  /** The front door for `node focus` / `node cycle` (§2.3): resolve which focus the
215
218
  * caller's pane acts on, then retarget `nodeId` onto it.
@@ -218,8 +221,8 @@ export declare function retargetFocus(focusId: string, incoming: string, revive:
218
221
  * - else → retarget the caller pane's focus IN PLACE (`focusByPane`); if the
219
222
  * caller's pane is not yet a viewport, adopt it as one (occupied by whatever
220
223
  * 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. */
224
+ * - no caller pane (not in tmux) → best-effort: reconcile + report status,
225
+ * not-in-place (no viewport to swap into). */
223
226
  export declare function focus(nodeId: string, opts: {
224
227
  pane?: string;
225
228
  newPane?: boolean;
@@ -230,7 +233,7 @@ export declare function focus(nodeId: string, opts: {
230
233
  * Reconcile first (follow a manual move / backfill a legacy pane), close the
231
234
  * focus row it occupies (if any), kill its pane (pane-keyed via the durable
232
235
  * `%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
236
+ * The focus row close is the record. Best-effort tmux; the
234
237
  * DB writes always land. The pane kill is the sole teardown unit (Q1/§6: a
235
238
  * split-pane focus returns its space to the surviving split; a standalone-window
236
239
  * focus closes the window). */
@@ -269,6 +272,15 @@ export declare function recycleFocusPane(nodeId: string, pane: string, launch: R
269
272
  * manager's old backstage slot; the caller nulls this node's presence so nothing
270
273
  * tracks the corpse. */
271
274
  export declare function handFocusToManager(focusId: string, managerId: string | null): boolean;
275
+ /** Q1 close-to-shell for a truly-done focused node with no successor (§1.6 /
276
+ * flow (b)): close its focus row and DISARM the pane's
277
+ * freeze (`remain-on-exit` off) so it reaps when the finishing pi exits. The
278
+ * stophook calls this instead of `tearDownNode` because it runs INSIDE the pane
279
+ * it is closing: it cannot `closePane` its own pane (self-saw), but it is still
280
+ * alive to disarm the freeze, so the pane closes on exit (return-to-shell)
281
+ * rather than freezing into an orphan. (Keeps the stophook off the tmux driver,
282
+ * §2.1.) */
283
+ export declare function closeFocusToShell(focusId: string, nodeId: string): void;
272
284
  export interface SpreadResult {
273
285
  window: string | null;
274
286
  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,16 @@
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';
32
31
  // 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).
32
+ // "where does this node live."
36
33
  export { homeSessionOf };
34
+ // Placement is the sanctioned model-over-driver (§2.1): non-placement runtime /
35
+ // command modules that legitimately need a raw driver verb get it from here, so
36
+ // the §5.1 lint can hold "only placement.ts / tmux-chrome.ts import tmux.ts".
37
+ export { piCommand, paneLocation, currentTmux, inTmux, ensureSession, openNodeWindow, focusWindow, windowAlive, windowOfPane, respawnPane, } from './tmux.js';
38
+ export { nodeSession } from './nodes.js';
37
39
  // ---------------------------------------------------------------------------
38
40
  // Focus reads (Step 4) — COMPOSE over the canvas focuses table (§2.3/§4).
39
41
  //
@@ -42,12 +44,10 @@ export { homeSessionOf };
42
44
  // way it composes setPresence. A node occupies at most one focus (UNIQUE
43
45
  // node_id, Q5), so focusOf returns a single row.
44
46
  //
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.
47
+ // The `focuses` table (canvas/focuses.ts) is the CANONICAL focus store — there is
48
+ // no focus.ptr file and no dual-write bridge. openFocus / retargetFocus /
49
+ // registerRootFocus / handFocusToManager write focus rows directly; these reads
50
+ // compose over them.
51
51
  // ---------------------------------------------------------------------------
52
52
  /** The focus a node occupies, or null. UNIQUE(node_id) ⇒ at most one. */
53
53
  export function focusOf(nodeId) {
@@ -277,6 +277,17 @@ export function detachToBackground(nodeId, pane) {
277
277
  const session = nodeSession();
278
278
  ensureSession(session, row.cwd);
279
279
  const ok = breakPaneToSession(target, session);
280
+ if (ok) {
281
+ // The node left its viewport for the backstage: it is now generating but
282
+ // UNFOCUSED (Invariant P / §1.3 "evicted from a focus … generating →
283
+ // backstage, no focus"). Close any focus row it held so it does not linger
284
+ // as a phantom viewport — the pane %id survives the break, so the row would
285
+ // otherwise keep resolving (`focusByPane`/`listFocuses`). A pure detach has
286
+ // no successor, so it closes the row (cf. `demote`, which hands it off).
287
+ const f = focusOf(nodeId);
288
+ if (f !== null)
289
+ closeFocusRow(f.focus_id);
290
+ }
280
291
  reconcile(nodeId); // presence now points at the crtr window
281
292
  return ok;
282
293
  }
@@ -369,7 +380,7 @@ export function openFocus(callerPane, opts = {}) {
369
380
  * `remain-on-exit` so a clean exit FREEZES the pane rather than detaching the
370
381
  * terminal (F1). A background `--root` does NOT call this (§6): it stays a plain
371
382
  * 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). */
383
+ * already a focus. The focus row IS the record — no pointer to mirror. */
373
384
  export function registerRootFocus(nodeId, pane, session, window) {
374
385
  const byPane = getFocusByPane(pane);
375
386
  if (byPane !== null)
@@ -380,7 +391,6 @@ export function registerRootFocus(nodeId, pane, session, window) {
380
391
  const focusId = newFocusId();
381
392
  openFocusRow(focusId, pane, session, nodeId);
382
393
  armRemainOnExit(window);
383
- setFocus(nodeId);
384
394
  return getFocusById(focusId);
385
395
  }
386
396
  /** retargetFocus — the unified hot-swap (§2.5, Invariant P + Q5). Swap `incoming`
@@ -395,7 +405,7 @@ export function registerRootFocus(nodeId, pane, session, window) {
395
405
  * (cross-session swap confirmed by the spike).
396
406
  * - outgoing still generating → backstage (F2); else reap its now-backstage
397
407
  * 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). */
408
+ * Arms remain-on-exit on the viewport (F3); the focus row is the record. */
399
409
  export function retargetFocus(focusId, incoming, revive) {
400
410
  let f = getFocusById(focusId);
401
411
  if (f === null)
@@ -436,7 +446,6 @@ export function retargetFocus(focusId, incoming, revive) {
436
446
  const loc = paneLocation(pin);
437
447
  commitFocusTxn(f.focus_id, incoming, pin, loc, outgoing, { kind: 'kill' }, null, null);
438
448
  armRemainOnExit(loc?.window);
439
- setFocus(incoming);
440
449
  return { focused: true, session: loc?.session ?? f.session, inPlace: true, revived };
441
450
  }
442
451
  // The hot-swap: incoming's pane → the viewport slot; outgoing's pane →
@@ -453,7 +462,6 @@ export function retargetFocus(focusId, incoming, revive) {
453
462
  if (action.kind === 'kill')
454
463
  closePane(focusPane);
455
464
  armRemainOnExit(pinLoc?.window);
456
- setFocus(incoming);
457
465
  return { focused: true, session: pinLoc?.session ?? f.session, inPlace: true, revived };
458
466
  }
459
467
  /** The ONE atomic txn (§2.5): point the focus row at `pin`, set its occupant to
@@ -490,16 +498,15 @@ function commitFocusTxn(focusId, incoming, pin, pinLoc, outgoing, action, outLoc
490
498
  * - else → retarget the caller pane's focus IN PLACE (`focusByPane`); if the
491
499
  * caller's pane is not yet a viewport, adopt it as one (occupied by whatever
492
500
  * 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. */
501
+ * - no caller pane (not in tmux) → best-effort: reconcile + report status,
502
+ * not-in-place (no viewport to swap into). */
495
503
  export function focus(nodeId, opts) {
496
504
  const meta = getNode(nodeId);
497
505
  if (meta === null)
498
506
  return { focused: false, session: null, inPlace: false, revived: false };
499
507
  const callerPane = opts.pane ?? process.env['TMUX_PANE'] ?? currentTmux()?.pane;
500
508
  if (callerPane === undefined || callerPane === '') {
501
- // Not in tmux — no viewport to swap into. Mirror the pointer; report status.
502
- setFocus(nodeId);
509
+ // Not in tmux — no viewport to swap into. Reconcile and report status.
503
510
  reconcile(nodeId);
504
511
  return { focused: isNodePaneAlive(nodeId), session: meta.tmux_session ?? null, inPlace: false, revived: false };
505
512
  }
@@ -507,13 +514,21 @@ export function focus(nodeId, opts) {
507
514
  const opened = openFocus(callerPane, {});
508
515
  if (opened === null)
509
516
  return { focused: false, session: null, inPlace: false, revived: false };
510
- return retargetFocus(opened.focus_id, nodeId, opts.revive);
517
+ const res = retargetFocus(opened.focus_id, nodeId, opts.revive);
518
+ // Failed to place the incoming node — reap the just-opened HOLDER pane + its
519
+ // focus row so a failed `--new-pane` leaves no orphan viewport (F4 / Invariant
520
+ // P). The success path already reaps the holder via the hot-swap.
521
+ if (!res.focused) {
522
+ if (opened.pane !== null)
523
+ closePane(opened.pane);
524
+ closeFocusRow(opened.focus_id);
525
+ }
526
+ return res;
511
527
  }
512
528
  let f = focusByPane(callerPane);
513
529
  if (f === null)
514
530
  f = ensureFocusAtPane(callerPane, opts.callerNode);
515
531
  if (f === null) {
516
- setFocus(nodeId);
517
532
  return { focused: false, session: meta.tmux_session ?? null, inPlace: false, revived: false };
518
533
  }
519
534
  return retargetFocus(f.focus_id, nodeId, opts.revive);
@@ -544,7 +559,7 @@ function ensureFocusAtPane(pane, callerNode) {
544
559
  * Reconcile first (follow a manual move / backfill a legacy pane), close the
545
560
  * focus row it occupies (if any), kill its pane (pane-keyed via the durable
546
561
  * `%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
562
+ * The focus row close is the record. Best-effort tmux; the
548
563
  * DB writes always land. The pane kill is the sole teardown unit (Q1/§6: a
549
564
  * split-pane focus returns its space to the surviving split; a standalone-window
550
565
  * focus closes the window). */
@@ -558,8 +573,6 @@ export function tearDownNode(nodeId) {
558
573
  if (pane !== null && paneExists(pane))
559
574
  closePane(pane);
560
575
  setPresence(nodeId, { pane: null, tmux_session: null, window: null });
561
- if (getFocus() === nodeId)
562
- setFocus('');
563
576
  }
564
577
  /** Demote's in-pane relaunch (§2.3, flow (e)): respawn `nodeId`'s launch into an
565
578
  * EXISTING `pane`, keeping the durable `%id` (respawn-pane -k), and record its
@@ -611,7 +624,6 @@ export function handFocusToManager(focusId, managerId) {
611
624
  if (getFocusByNode(managerId) !== null)
612
625
  return false; // manager already focused elsewhere
613
626
  setFocusOccupant(focusId, managerId);
614
- setFocus(managerId);
615
627
  // MAJOR 1 — LIVE backstage manager → swap it into the focus slot now. DORMANT
616
628
  // managers (no live pane / dead pi) fall through unchanged: the daemon revives
617
629
  // them into the frozen %m async.
@@ -625,6 +637,20 @@ export function handFocusToManager(focusId, managerId) {
625
637
  }
626
638
  return true; // still "took focus" — caller doesn't close
627
639
  }
640
+ /** Q1 close-to-shell for a truly-done focused node with no successor (§1.6 /
641
+ * flow (b)): close its focus row and DISARM the pane's
642
+ * freeze (`remain-on-exit` off) so it reaps when the finishing pi exits. The
643
+ * stophook calls this instead of `tearDownNode` because it runs INSIDE the pane
644
+ * it is closing: it cannot `closePane` its own pane (self-saw), but it is still
645
+ * alive to disarm the freeze, so the pane closes on exit (return-to-shell)
646
+ * rather than freezing into an orphan. (Keeps the stophook off the tmux driver,
647
+ * §2.1.) */
648
+ export function closeFocusToShell(focusId, nodeId) {
649
+ closeFocusRow(focusId);
650
+ const win = getNode(nodeId)?.window;
651
+ if (win != null && win !== '')
652
+ setRemainOnExit(win, false);
653
+ }
628
654
  /** Join each of `childIds`' live panes into `targetId`'s window, lay them out
629
655
  * (target wide on the left, children stacked right), and focus it. Reconcile
630
656
  * drives both the target resolution and the per-join fix-up (a joined pane keeps
@@ -658,6 +684,5 @@ export function spreadNode(targetId, childIds, opts = {}) {
658
684
  selectLayout(targetWindow, 'main-vertical');
659
685
  }
660
686
  const focused = switchClient(targetSession) && selectWindow(targetSession, targetWindow);
661
- setFocus(targetId);
662
687
  return { window: targetWindow, session: targetSession, joined, focused };
663
688
  }
@@ -19,14 +19,12 @@
19
19
  //
20
20
  // Best-effort throughout: a tmux/fs failure on one node never aborts the reset.
21
21
  import { existsSync, rmSync } from 'node:fs';
22
- import { getNode, updateNode, setPresence, clearPid, subscriptionsOf, unsubscribe, view, reportsDir, inboxPath, nodeDir, openDb, } from '../canvas/index.js';
22
+ import { getNode, updateNode, setPresence, clearPid, setFocusOccupant, subscriptionsOf, unsubscribe, view, reportsDir, inboxPath, nodeDir, openDb, } from '../canvas/index.js';
23
23
  import { transition } from './lifecycle.js';
24
- import { paneLocation, nodeSession } from './tmux.js';
25
- import { tearDownNode } from './placement.js';
24
+ import { paneLocation, tearDownNode, focusOf } from './placement.js';
26
25
  import { buildLaunchSpec } from './launch.js';
27
26
  import { roadmapPath } from './roadmap.js';
28
- import { spawnNode, newNodeId } from './nodes.js';
29
- import { setFocus } from './presence.js';
27
+ import { spawnNode, newNodeId, nodeSession } from './nodes.js';
30
28
  import { relaunchRootInPane } from './revive.js';
31
29
  // ---------------------------------------------------------------------------
32
30
  // reapDescendants — tear down a root's descendant sub-DAG (shared helper)
@@ -153,7 +151,6 @@ export function handleNewSession(nodeId, newSessionId, pane, deps = {}, newSessi
153
151
  return { path: 'relaunch', newNodeId: result.newNodeId };
154
152
  }
155
153
  catch {
156
- setFocus(nodeId);
157
154
  resetRoot(nodeId, newSessionId, newSessionFile);
158
155
  return { path: 'reset-root' };
159
156
  }
@@ -184,7 +181,8 @@ export function relaunchRoot(oldId, pane, deps = {}) {
184
181
  // back, leaving the old root EXACTLY as it was (no hand-rolled compensation).
185
182
  // Only the *detached* respawn (the async pane kill) lands outside the txn — it
186
183
  // 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.
184
+ // durable. The focus repoint (step 4) is INSIDE the txn, so a ROLLBACK undoes
185
+ // it automatically — there is no file to restore.
188
186
  const db = openDb();
189
187
  db.exec('BEGIN');
190
188
  try {
@@ -215,8 +213,12 @@ export function relaunchRoot(oldId, pane, deps = {}) {
215
213
  // parent=null, and all edges.
216
214
  transition(oldId, 'reap');
217
215
  setPresence(oldId, { window: null, tmux_session: null });
218
- // 4) Focus follows content (file write restored by the catch on rollback).
219
- setFocus(newId);
216
+ // 4) Focus follows content: repoint the old root's focus row to the new root
217
+ // (same pane — respawn-pane -k below keeps the %id). Inside the txn → the
218
+ // ROLLBACK path restores the old occupant automatically (no file to restore).
219
+ const oldFocus = focusOf(oldId);
220
+ if (oldFocus !== null)
221
+ setFocusOccupant(oldFocus.focus_id, newId);
220
222
  // 5) Re-exec pi in this pane bound to newId; the dispatch is the LAST thing
221
223
  // inside the txn. If it throws the txn rolls back (old root untouched); on
222
224
  // success we COMMIT and the async detached kill of this pane lands after.
@@ -237,10 +239,8 @@ export function relaunchRoot(oldId, pane, deps = {}) {
237
239
  rmSync(nodeDir(newId), { recursive: true, force: true });
238
240
  }
239
241
  catch { /* */ }
240
- try {
241
- setFocus(oldId);
242
- }
243
- catch { /* */ } // focus is a file op, outside the txn
242
+ // The focus repoint was inside the txn; ROLLBACK already restored the old
243
+ // occupant — nothing to undo here.
244
244
  throw err instanceof Error ? err : new Error(String(err));
245
245
  }
246
246
  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) {
@@ -10,15 +10,15 @@
10
10
  // provenance via spawned_by) brought forefront for direct driving.
11
11
  import { spawnSync } from 'node:child_process';
12
12
  import { FRONT_DOOR_ENV } from './front-door.js';
13
- import { spawnNode, currentNodeContext, resolveBirthSession } from './nodes.js';
13
+ import { spawnNode, currentNodeContext, resolveBirthSession, nodeSession } from './nodes.js';
14
14
  import { buildLaunchSpec, buildPiArgv } from './launch.js';
15
15
  import { writeGoal } from './kickoff.js';
16
16
  import { hasRoadmap, seedRoadmap } from './roadmap.js';
17
17
  import { seedMemory, seedUserMemory, seedProjectMemory } from './memory.js';
18
18
  import { generateSessionName } from './naming.js';
19
- import { ensureSession, openNodeWindow, piCommand, currentTmux, inTmux, nodeSession, focusWindow, installMenuBinding, installNavBindings, } from './tmux.js';
19
+ import { installMenuBinding, installNavBindings } from './tmux-chrome.js';
20
20
  import { setPresence, updateNode, getNode, fullName } from '../canvas/index.js';
21
- import { registerRootFocus } from './placement.js';
21
+ import { registerRootFocus, ensureSession, openNodeWindow, piCommand, currentTmux, inTmux, focusWindow, } from './placement.js';
22
22
  import { transition } from './lifecycle.js';
23
23
  import { ensureDaemon } from '../../daemon/manage.js';
24
24
  /** Create a root node and bring up its pi. Returns the node; for 'inline' this
@@ -0,0 +1 @@
1
+ export { installMenuBinding, installNavBindings, sendKeysEnter } from './tmux.js';
@@ -0,0 +1,4 @@
1
+ // tmux-chrome.ts — chrome seam (§2.1): stateless keybind/input verbs.
2
+ // The ONLY non-placement module allowed to import the tmux driver, per the
3
+ // §5.1 lint. Re-exports the menu/nav/send-keys verbs callers (spawn, chord) need.
4
+ export { installMenuBinding, installNavBindings, sendKeysEnter } from './tmux.js';
@@ -1,11 +1,6 @@
1
1
  /** POSIX single-quote escaping for one shell word. */
2
2
  export declare function shellQuote(s: string): string;
3
3
  export declare function inTmux(): boolean;
4
- /** The single, shared tmux session that ALL canvas node windows live in.
5
- * Overridable with CRTR_NODE_SESSION (default `crtr`). Every root and every
6
- * child opens a window here rather than cluttering the user's own working
7
- * session — switch to it to browse the whole live graph, ignore it otherwise. */
8
- export declare function nodeSession(): string;
9
4
  export interface TmuxLocation {
10
5
  session: string;
11
6
  window: string;
@@ -139,7 +134,19 @@ export declare function respawnPaneSync(opts: RespawnPaneOpts): boolean;
139
134
  * callers stay green while the placement layer migrates onto the explicit
140
135
  * sync/detached split. */
141
136
  export declare function respawnPane(opts: RespawnPaneOpts): boolean;
142
- /** Turn a pi argv array into a single shell command string. */
137
+ /** Turn a pi argv array into a single shell command string.
138
+ *
139
+ * The binary defaults to `CRTR_PI_BINARY` when that env var is set, else the
140
+ * literal `pi`. This is a TEST-ONLY substitution seam: when CRTR_PI_BINARY is
141
+ * unset (every production path) the behavior is byte-identical to exec'ing
142
+ * `pi`. The integration-test harness points it at a deterministic fake-pi
143
+ * vehicle so a real `crtr node new` reaches the fake instead of the LLM `pi`,
144
+ * without any dependence on tmux/shell PATH inheritance — the substitution is
145
+ * baked into the command string at build time, in the process that calls
146
+ * piCommand. An explicit `binary` arg still overrides the env (no caller passes
147
+ * one today). The value may be a multi-word launcher (e.g. `node --import
148
+ * tsx/esm host.ts`); only the argv entries are shell-quoted, so a multi-word
149
+ * binary is spliced verbatim ahead of them. */
143
150
  export declare function piCommand(argv: string[], binary?: string): string;
144
151
  /** List all window ids present in `session`. Returns [] if the session does
145
152
  * not exist or tmux fails for any reason. Each entry is the raw window id
@@ -9,6 +9,7 @@
9
9
  // their window; reviving opens a fresh one.
10
10
  import { spawn, spawnSync } from 'node:child_process';
11
11
  import { readConfig } from '../config.js';
12
+ import { nodeSession } from './nodes.js';
12
13
  // ---------------------------------------------------------------------------
13
14
  // Shell quoting + tmux invocation
14
15
  // ---------------------------------------------------------------------------
@@ -27,14 +28,6 @@ function tmux(args) {
27
28
  export function inTmux() {
28
29
  return process.env['TMUX'] !== undefined && process.env['TMUX'] !== '';
29
30
  }
30
- /** The single, shared tmux session that ALL canvas node windows live in.
31
- * Overridable with CRTR_NODE_SESSION (default `crtr`). Every root and every
32
- * child opens a window here rather than cluttering the user's own working
33
- * session — switch to it to browse the whole live graph, ignore it otherwise. */
34
- export function nodeSession() {
35
- const v = process.env['CRTR_NODE_SESSION'];
36
- return v !== undefined && v !== '' ? v : 'crtr';
37
- }
38
31
  /** Where the caller currently is, or null if not inside tmux. */
39
32
  export function currentTmux() {
40
33
  if (!inTmux())
@@ -270,8 +263,20 @@ export function respawnPane(opts) {
270
263
  // ---------------------------------------------------------------------------
271
264
  // pi command assembly
272
265
  // ---------------------------------------------------------------------------
273
- /** Turn a pi argv array into a single shell command string. */
274
- export function piCommand(argv, binary = 'pi') {
266
+ /** Turn a pi argv array into a single shell command string.
267
+ *
268
+ * The binary defaults to `CRTR_PI_BINARY` when that env var is set, else the
269
+ * literal `pi`. This is a TEST-ONLY substitution seam: when CRTR_PI_BINARY is
270
+ * unset (every production path) the behavior is byte-identical to exec'ing
271
+ * `pi`. The integration-test harness points it at a deterministic fake-pi
272
+ * vehicle so a real `crtr node new` reaches the fake instead of the LLM `pi`,
273
+ * without any dependence on tmux/shell PATH inheritance — the substitution is
274
+ * baked into the command string at build time, in the process that calls
275
+ * piCommand. An explicit `binary` arg still overrides the env (no caller passes
276
+ * one today). The value may be a multi-word launcher (e.g. `node --import
277
+ * tsx/esm host.ts`); only the argv entries are shell-quoted, so a multi-word
278
+ * binary is spliced verbatim ahead of them. */
279
+ export function piCommand(argv, binary = process.env['CRTR_PI_BINARY'] ?? 'pi') {
275
280
  return [binary, ...argv.map(shellQuote)].join(' ');
276
281
  }
277
282
  // ---------------------------------------------------------------------------
@@ -295,7 +300,7 @@ export function windowAlive(session, window) {
295
300
  return listWindowIds(session).includes(window);
296
301
  }
297
302
  // ---------------------------------------------------------------------------
298
- // Focus helpers (used by the presence layer)
303
+ // Focus helpers (used by the placement layer)
299
304
  // ---------------------------------------------------------------------------
300
305
  /** Activate a window within its session (same-session navigation). Equivalent
301
306
  * to `tmux select-window -t <session>:<window>`. Best-effort; never throws. */
@@ -351,7 +356,7 @@ export function sendKeysEnter(pane, text) {
351
356
  // ---------------------------------------------------------------------------
352
357
  /** Reserved mnemonic keys owned by the built-in menu items below — a custom
353
358
  * `prefixBind` may not claim these (the built-in item wins). */
354
- const RESERVED_MENU_KEYS = new Set(['o', 'd', 'D', 'x', 'b']);
359
+ const RESERVED_MENU_KEYS = new Set(['o', 'r', 'd', 'D', 'x', 'b']);
355
360
  /** Bind Alt+C to the crouter action menu. Best-effort; false if tmux fails.
356
361
  * The built-in items (promote/demote/detach/close/browse) are static; the canvas-nav
357
362
  * chords (graph/manager/expand/report-N + any custom prefixBind) are appended
@@ -366,6 +371,10 @@ export function installMenuBinding() {
366
371
  // the slash command delivers the orchestration guidance into the node's
367
372
  // context, which a bare `run-shell` (output discarded) could not.
368
373
  { name: 'promote to orchestrator', key: 'o', cmd: `send-keys -t '#{pane_id}' '/promote' Enter` },
374
+ // Resume types `/resume-node` into the agent's pane: the slash command opens
375
+ // a whole-canvas picker (incl. dormant nodes) and revives the choice via
376
+ // `crtr node focus` — the only sync-safe open (routes through reviveNode).
377
+ { name: 'resume node', key: 'r', cmd: `send-keys -t '#{pane_id}' '/resume-node' Enter` },
369
378
  // `d` demotes the agent to TERMINAL in place: no finalize, no kill — it keeps
370
379
  // running where it is, and because it is now terminal it is forced to push a
371
380
  // final up the spine when it finishes. `D` ALSO detaches it to the background
@@ -502,11 +502,19 @@ export function registerCanvasNav(pi) {
502
502
  // Budget WITHIN pi's widget cap (see graphWidgetBudget): reserve 1 line for
503
503
  // the footer hint, up to 2 for the ↑/↓ "more" indicators, the rest for tree
504
504
  // rows. The window then tracks the cursor, so j/k scrolls through the WHOLE
505
- // list rather than hitting pi's hard truncation. Two passes settle the
506
- // mutual dependency between "how many rows fit" and "are indicators shown".
505
+ // list rather than hitting pi's hard truncation. The passes settle the
506
+ // mutual dependency between "how many rows fit" and "are indicators shown":
507
+ // each ↑/↓ indicator steals a tree row, which can push the cursor out of
508
+ // view, which moves the window, which changes whether an indicator shows.
509
+ // This needs up to 3 passes to converge (an indicator appearing shrinks the
510
+ // window, the smaller window re-homes scrollTop, that re-home can toggle the
511
+ // *other* indicator). Bailing early (the old 2-pass cap) left the cursor one
512
+ // row off-screen for a single keypress near the bottom — the arrow vanished
513
+ // and only the NEXT press scrolled. 4 passes always settles to a stable,
514
+ // cursor-visible window.
507
515
  const treeArea = Math.max(2, graphWidgetBudget() - 1);
508
516
  let viewportH = treeArea;
509
- for (let pass = 0; pass < 2; pass++) {
517
+ for (let pass = 0; pass < 4; pass++) {
510
518
  if (cursorIdx < scrollTop)
511
519
  scrollTop = cursorIdx;
512
520
  if (cursorIdx >= scrollTop + viewportH)
@@ -0,0 +1,22 @@
1
+ interface CommandUI {
2
+ select(title: string, options: string[]): Promise<string | undefined>;
3
+ notify(message: string, type?: 'info' | 'warning' | 'error'): void;
4
+ }
5
+ interface CommandCtx {
6
+ mode: string;
7
+ ui: CommandUI;
8
+ }
9
+ interface PiLike {
10
+ registerCommand?(name: string, options: {
11
+ description?: string;
12
+ handler: (args: string, ctx: CommandCtx) => void | Promise<void>;
13
+ }): void;
14
+ }
15
+ /**
16
+ * Register the /resume-node command on `pi`.
17
+ *
18
+ * Returns immediately when CRTR_NODE_ID is absent — the extension is fully
19
+ * inert in a non-canvas pi session.
20
+ */
21
+ export declare function registerCanvasResume(pi: PiLike): void;
22
+ export default registerCanvasResume;