@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.
- package/dist/builtin-personas/developer/orchestrator.md +1 -1
- package/dist/builtin-personas/plan/orchestrator.md +1 -1
- package/dist/commands/chord.js +1 -1
- package/dist/commands/human/shared.js +1 -1
- package/dist/commands/node.js +1 -2
- package/dist/commands/tmux-spread.js +2 -3
- package/dist/core/__tests__/close.test.js +2 -2
- package/dist/core/__tests__/focuses.test.js +5 -68
- package/dist/core/__tests__/home-session.test.js +1 -1
- package/dist/core/__tests__/placement-focus.test.js +54 -32
- package/dist/core/__tests__/placement-teardown.test.js +4 -9
- package/dist/core/__tests__/relaunch.test.js +10 -4
- package/dist/core/__tests__/tmux-surface.test.js +8 -9
- package/dist/core/canvas/db.js +2 -3
- package/dist/core/canvas/focuses.d.ts +2 -2
- package/dist/core/canvas/focuses.js +4 -3
- package/dist/core/canvas/types.d.ts +1 -1
- package/dist/core/runtime/close.js +2 -2
- package/dist/core/runtime/demote.js +2 -7
- package/dist/core/runtime/launch.d.ts +3 -1
- package/dist/core/runtime/launch.js +4 -1
- package/dist/core/runtime/nodes.d.ts +7 -0
- package/dist/core/runtime/nodes.js +10 -1
- package/dist/core/runtime/placement.d.ts +17 -5
- package/dist/core/runtime/placement.js +56 -31
- package/dist/core/runtime/reset.js +13 -13
- package/dist/core/runtime/revive.d.ts +1 -1
- package/dist/core/runtime/revive.js +2 -2
- package/dist/core/runtime/spawn.js +3 -3
- package/dist/core/runtime/tmux-chrome.d.ts +1 -0
- package/dist/core/runtime/tmux-chrome.js +4 -0
- package/dist/core/runtime/tmux.d.ts +13 -6
- package/dist/core/runtime/tmux.js +21 -12
- package/dist/pi-extensions/canvas-nav.js +11 -3
- package/dist/pi-extensions/canvas-resume.d.ts +22 -0
- package/dist/pi-extensions/canvas-resume.js +173 -0
- package/dist/pi-extensions/canvas-stophook.js +5 -9
- package/package.json +2 -2
- package/dist/core/runtime/presence.d.ts +0 -30
- 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.
|
|
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)
|
|
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:
|
|
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
|
-
*
|
|
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
|
|
5
|
-
// import the tmux driver
|
|
6
|
-
// other
|
|
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,
|
|
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."
|
|
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
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
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.
|
|
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)
|
|
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:
|
|
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.
|
|
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
|
-
|
|
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
|
-
*
|
|
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,
|
|
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.
|
|
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
|
|
219
|
-
|
|
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
|
-
|
|
241
|
-
|
|
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 './
|
|
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,
|
|
23
|
-
import {
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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.
|
|
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 <
|
|
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;
|