@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.
- package/dist/builtin-personas/design/{base.md → PERSONA.md} +4 -0
- package/dist/builtin-personas/developer/{base.md → PERSONA.md} +4 -0
- package/dist/builtin-personas/explore/{base.md → PERSONA.md} +4 -0
- package/dist/builtin-personas/general/{base.md → PERSONA.md} +4 -0
- package/dist/builtin-personas/orchestration-kernel.md +6 -6
- package/dist/builtin-personas/plan/{base.md → PERSONA.md} +5 -1
- package/dist/builtin-personas/plan/reviewers/architecture-fit/{base.md → PERSONA.md} +1 -1
- package/dist/builtin-personas/plan/reviewers/code-smells/{base.md → PERSONA.md} +1 -1
- package/dist/builtin-personas/plan/reviewers/pattern-consistency/{base.md → PERSONA.md} +1 -1
- package/dist/builtin-personas/plan/reviewers/requirements-coverage/{base.md → PERSONA.md} +1 -1
- package/dist/builtin-personas/plan/reviewers/security/{base.md → PERSONA.md} +1 -1
- package/dist/builtin-personas/review/{base.md → PERSONA.md} +4 -0
- package/dist/builtin-personas/spec/{base.md → PERSONA.md} +5 -1
- package/dist/builtin-skills/skills/crouter-development/personas/SKILL.md +24 -14
- package/dist/builtin-skills/skills/crouter-development/personas/base-prompt/SKILL.md +4 -4
- package/dist/commands/canvas-browse.d.ts +2 -0
- package/dist/commands/canvas-browse.js +45 -0
- package/dist/commands/canvas-prune.js +11 -2
- package/dist/commands/canvas.js +3 -2
- package/dist/commands/daemon.js +1 -1
- package/dist/commands/human/prompts.js +3 -9
- package/dist/commands/human/shared.d.ts +26 -1
- package/dist/commands/human/shared.js +48 -10
- package/dist/commands/node.js +66 -4
- package/dist/commands/skill/author.js +2 -2
- package/dist/core/__tests__/cascade-close.test.js +199 -0
- package/dist/core/__tests__/daemon-boot.test.js +7 -0
- package/dist/core/__tests__/daemon-liveness.test.js +59 -4
- package/dist/core/__tests__/dead-pane-regression.test.js +151 -0
- package/dist/core/__tests__/fixtures/fake-pi-host.d.ts +2 -0
- package/dist/core/__tests__/fixtures/fake-pi-host.js +301 -0
- package/dist/core/__tests__/flagship-lifecycle.test.js +273 -0
- package/dist/core/__tests__/grace-clock.test.d.ts +1 -0
- package/dist/core/__tests__/grace-clock.test.js +115 -0
- package/dist/core/__tests__/helpers/harness.d.ts +78 -0
- package/dist/core/__tests__/helpers/harness.js +406 -0
- package/dist/core/__tests__/human-surface-target.test.d.ts +1 -0
- package/dist/core/__tests__/human-surface-target.test.js +98 -0
- package/dist/core/__tests__/lifecycle.test.js +6 -13
- package/dist/core/__tests__/live-mutation.test.d.ts +1 -0
- package/dist/core/__tests__/live-mutation.test.js +341 -0
- package/dist/core/__tests__/persona-subkind.test.js +18 -15
- package/dist/core/__tests__/placement-focus.test.js +53 -15
- package/dist/core/__tests__/relaunch.test.js +12 -12
- package/dist/core/__tests__/reset.test.js +11 -6
- package/dist/core/__tests__/spike-harness.test.d.ts +1 -0
- package/dist/core/__tests__/spike-harness.test.js +241 -0
- package/dist/core/__tests__/subscription-delivery.test.d.ts +1 -0
- package/dist/core/__tests__/subscription-delivery.test.js +233 -0
- package/dist/core/canvas/browse/__tests__/model.test.d.ts +1 -0
- package/dist/core/canvas/browse/__tests__/model.test.js +142 -0
- package/dist/core/canvas/browse/__tests__/render.test.d.ts +1 -0
- package/dist/core/canvas/browse/__tests__/render.test.js +102 -0
- package/dist/core/canvas/browse/app.d.ts +4 -0
- package/dist/core/canvas/browse/app.js +349 -0
- package/dist/core/canvas/browse/model.d.ts +97 -0
- package/dist/core/canvas/browse/model.js +258 -0
- package/dist/core/canvas/browse/render.d.ts +41 -0
- package/dist/core/canvas/browse/render.js +387 -0
- package/dist/core/canvas/browse/terminal.d.ts +23 -0
- package/dist/core/canvas/browse/terminal.js +100 -0
- package/dist/core/canvas/canvas.d.ts +9 -2
- package/dist/core/canvas/canvas.js +41 -3
- package/dist/core/canvas/paths.d.ts +4 -1
- package/dist/core/canvas/paths.js +10 -4
- package/dist/core/canvas/render.d.ts +10 -0
- package/dist/core/canvas/render.js +25 -1
- package/dist/core/canvas/types.js +2 -2
- package/dist/core/feed/inbox.d.ts +0 -3
- package/dist/core/feed/inbox.js +1 -5
- package/dist/core/help.d.ts +6 -0
- package/dist/core/help.js +7 -0
- package/dist/core/personas/index.d.ts +4 -3
- package/dist/core/personas/index.js +3 -2
- package/dist/core/personas/loader.d.ts +34 -16
- package/dist/core/personas/loader.js +102 -29
- package/dist/core/personas/resolve.d.ts +4 -4
- package/dist/core/personas/resolve.js +16 -14
- package/dist/core/runtime/busy.d.ts +8 -0
- package/dist/core/runtime/busy.js +46 -0
- package/dist/core/runtime/lifecycle.d.ts +1 -1
- package/dist/core/runtime/lifecycle.js +12 -4
- package/dist/core/runtime/naming.d.ts +3 -3
- package/dist/core/runtime/naming.js +6 -6
- package/dist/core/runtime/placement.d.ts +32 -5
- package/dist/core/runtime/placement.js +81 -14
- package/dist/core/runtime/reset.d.ts +11 -8
- package/dist/core/runtime/reset.js +23 -18
- package/dist/core/spawn.d.ts +20 -1
- package/dist/core/spawn.js +52 -5
- package/dist/daemon/crtrd.js +43 -21
- package/dist/pi-extensions/canvas-nav.js +106 -55
- package/dist/pi-extensions/canvas-resume.d.ts +0 -1
- package/dist/pi-extensions/canvas-resume.js +35 -126
- package/dist/pi-extensions/canvas-stophook.d.ts +1 -1
- package/dist/pi-extensions/canvas-stophook.js +16 -0
- package/dist/prompts/skill.js +6 -1
- package/package.json +1 -1
- package/dist/commands/__tests__/skill.test.js +0 -290
- package/dist/core/__tests__/pkg.test.js +0 -218
- package/dist/core/__tests__/sys.test.js +0 -208
- /package/dist/{commands/__tests__/skill.test.d.ts → core/__tests__/cascade-close.test.d.ts} +0 -0
- /package/dist/core/__tests__/{pkg.test.d.ts → dead-pane-regression.test.d.ts} +0 -0
- /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
|
|
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
|
|
320
|
-
* (Invariant P). A holder or vanished node (row null) is
|
|
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
|
-
|
|
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
|
|
407
|
-
*
|
|
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
|
|
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
|
-
//
|
|
461
|
-
//
|
|
462
|
-
|
|
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 **
|
|
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 `
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
|
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
|
|
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 `
|
|
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 **
|
|
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 `
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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
|
-
// `
|
|
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, '
|
|
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
|
|
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 === '
|
|
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
|
|
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:
|
|
212
|
-
// it never claims the pane, but KEEP pi_session_id (resumable),
|
|
213
|
-
// parent=null, and all edges.
|
|
214
|
-
|
|
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
|
package/dist/core/spawn.d.ts
CHANGED
|
@@ -1,7 +1,21 @@
|
|
|
1
1
|
export declare function isInTmux(): boolean;
|
|
2
2
|
export declare function shellQuote(s: string): string;
|
|
3
|
-
/** Count panes in
|
|
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';
|
package/dist/core/spawn.js
CHANGED
|
@@ -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
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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);
|
package/dist/daemon/crtrd.js
CHANGED
|
@@ -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 →
|
|
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
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
//
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
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
|
}
|