@crouton-kit/crouter 0.3.16 → 0.3.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/builtin-personas/orchestration-kernel.md +6 -6
- package/dist/builtin-personas/plan/base.md +1 -1
- package/dist/builtin-personas/spec/base.md +1 -1
- 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/node.js +13 -0
- 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__/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__/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/render.d.ts +10 -0
- package/dist/core/canvas/render.js +25 -1
- package/dist/core/feed/inbox.d.ts +0 -3
- package/dist/core/feed/inbox.js +1 -5
- 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 +22 -5
- package/dist/core/runtime/placement.js +44 -13
- package/dist/core/runtime/reset.d.ts +11 -8
- package/dist/core/runtime/reset.js +23 -18
- package/dist/daemon/crtrd.js +43 -21
- package/dist/pi-extensions/canvas-nav.js +29 -25
- 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
|
@@ -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/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
|
}
|
|
@@ -198,28 +198,37 @@ function managerOf(id) {
|
|
|
198
198
|
return undefined;
|
|
199
199
|
}
|
|
200
200
|
}
|
|
201
|
-
/**
|
|
202
|
-
|
|
201
|
+
/** A kind:'human' node is a control-plane ASK (a humanloop deck on the human's
|
|
202
|
+
* screen), NOT a pi conversation — it has no session, so focusing/reviving it
|
|
203
|
+
* boots a confused blank "you have been revived" pi. Its pending-ask signal
|
|
204
|
+
* already rides the ⚑ badge on the ASKING node (attention.ts attributes asks by
|
|
205
|
+
* source.nodeId, never to the human node), so the row carries no signal of its
|
|
206
|
+
* own. Drop it from every navigable list (the tree, BASE reports, child counts,
|
|
207
|
+
* subtree expansion) so it can never be selected. */
|
|
208
|
+
function isHumanAsk(id) {
|
|
209
|
+
return getNode(id)?.kind === 'human';
|
|
210
|
+
}
|
|
211
|
+
/** A node's direct children that are navigable conversations — human-ask nodes
|
|
212
|
+
* dropped. The one place the nav chrome enumerates children. */
|
|
213
|
+
function convoChildIds(id) {
|
|
203
214
|
try {
|
|
204
|
-
return subscriptionsOf(id)
|
|
205
|
-
.map((s) => s.node_id)
|
|
206
|
-
.filter((cid) => {
|
|
207
|
-
const st = getNode(cid)?.status;
|
|
208
|
-
return st === 'active' || st === 'idle';
|
|
209
|
-
});
|
|
215
|
+
return subscriptionsOf(id).map((s) => s.node_id).filter((cid) => !isHumanAsk(cid));
|
|
210
216
|
}
|
|
211
217
|
catch {
|
|
212
218
|
return [];
|
|
213
219
|
}
|
|
214
220
|
}
|
|
215
|
-
/**
|
|
221
|
+
/** Live reports (active|idle) of a node — the DOWN set in BASE. */
|
|
222
|
+
function liveReports(id) {
|
|
223
|
+
return convoChildIds(id).filter((cid) => {
|
|
224
|
+
const st = getNode(cid)?.status;
|
|
225
|
+
return st === 'active' || st === 'idle';
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
/** Direct navigable children — used for the ⤳ badge and fold counts (human-ask
|
|
229
|
+
* nodes excluded, so the count matches what the tree actually shows). */
|
|
216
230
|
function childCount(id) {
|
|
217
|
-
|
|
218
|
-
return subscriptionsOf(id).length;
|
|
219
|
-
}
|
|
220
|
-
catch {
|
|
221
|
-
return 0;
|
|
222
|
-
}
|
|
231
|
+
return convoChildIds(id).length;
|
|
223
232
|
}
|
|
224
233
|
/** Climb first-manager edges from `self` to the ancestry root (cycle-guarded). */
|
|
225
234
|
function climbRoot(self) {
|
|
@@ -238,16 +247,16 @@ function climbRoot(self) {
|
|
|
238
247
|
function subtreeIds(root) {
|
|
239
248
|
const out = [];
|
|
240
249
|
const seen = new Set([root]);
|
|
241
|
-
const q =
|
|
250
|
+
const q = convoChildIds(root);
|
|
242
251
|
while (q.length > 0) {
|
|
243
252
|
const id = q.shift();
|
|
244
253
|
if (seen.has(id))
|
|
245
254
|
continue;
|
|
246
255
|
seen.add(id);
|
|
247
256
|
out.push(id);
|
|
248
|
-
for (const
|
|
249
|
-
if (!seen.has(
|
|
250
|
-
q.push(
|
|
257
|
+
for (const cid of convoChildIds(id))
|
|
258
|
+
if (!seen.has(cid))
|
|
259
|
+
q.push(cid);
|
|
251
260
|
}
|
|
252
261
|
return out;
|
|
253
262
|
}
|
|
@@ -283,12 +292,7 @@ function statusRank(id) {
|
|
|
283
292
|
* the tree and when stepping into a subtree (`l`). Array.sort is stable, so
|
|
284
293
|
* equal-status siblings keep their creation order. */
|
|
285
294
|
function sortedChildIds(id) {
|
|
286
|
-
|
|
287
|
-
return subscriptionsOf(id).map((s) => s.node_id).sort((a, b) => statusRank(a) - statusRank(b));
|
|
288
|
-
}
|
|
289
|
-
catch {
|
|
290
|
-
return [];
|
|
291
|
-
}
|
|
295
|
+
return convoChildIds(id).sort((a, b) => statusRank(a) - statusRank(b));
|
|
292
296
|
}
|
|
293
297
|
function buildGraphModel(self) {
|
|
294
298
|
const rootId = climbRoot(self);
|
|
@@ -1,27 +1,25 @@
|
|
|
1
1
|
// canvas-resume.ts — pi extension registering the /resume-node canvas command.
|
|
2
2
|
//
|
|
3
|
-
// /resume-node — open
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// node
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
// and ALL statuses.
|
|
3
|
+
// /resume-node — open the full-screen canvas navigator (`crtr canvas browse`)
|
|
4
|
+
// as a tmux popup. The navigator owns the screen (tabs / auto-collapsed tree
|
|
5
|
+
// / `/` super-search / cwd scope / sort / preview) and, on Enter, focuses the
|
|
6
|
+
// chosen node back INTO this pane via `crtr node focus --pane`. The popup is
|
|
7
|
+
// scoped to THIS node's cwd by default (pass --cwd) so you see the nodes from
|
|
8
|
+
// the dir you're working in first; toggle to All dirs inside with `c`.
|
|
10
9
|
//
|
|
11
10
|
// The name is literally `resume-node`, NOT `resume`, to avoid clashing with
|
|
12
11
|
// pi's built-in /resume.
|
|
13
12
|
//
|
|
13
|
+
// crtr ONLY runs inside tmux (see crouter/CLAUDE.md) — there is no non-tmux
|
|
14
|
+
// fallback picker. Outside tmux the command notifies and no-ops.
|
|
15
|
+
//
|
|
14
16
|
// ⚠ DESYNC — why `crtr node focus` is the ONLY sanctioned open
|
|
15
|
-
// `crtr node focus <id>`
|
|
16
|
-
// revive.ts), the ONLY sanctioned launcher of
|
|
17
|
-
// CRTR_NODE_ID + the `-e` canvas extensions and
|
|
18
|
-
// A RAW `pi --session <file>` has NEITHER → every
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
// Worst case (idle + intent=idle-release) the daemon can't see the raw pi (no
|
|
22
|
-
// pi_pid) and DOUBLE-SPAWNS a second pi on the same .jsonl, corrupting the
|
|
23
|
-
// conversation. A UI must therefore NEVER spawn `pi --session` directly — it
|
|
24
|
-
// opens nodes via `crtr node focus` / `crtr canvas revive`.
|
|
17
|
+
// `crtr node focus <id>` (which `canvas browse` shells on Enter) routes through
|
|
18
|
+
// reviveNode() (src/core/runtime/revive.ts), the ONLY sanctioned launcher of
|
|
19
|
+
// `pi --session <file>`: it sets CRTR_NODE_ID + the `-e` canvas extensions and
|
|
20
|
+
// runs transition('revive'). A RAW `pi --session <file>` has NEITHER → every
|
|
21
|
+
// canvas hook is inert and the daemon can DOUBLE-SPAWN onto the same .jsonl.
|
|
22
|
+
// A UI must therefore NEVER spawn `pi --session` directly.
|
|
25
23
|
//
|
|
26
24
|
// INERT when CRTR_NODE_ID is absent (a plain pi session, not a canvas node).
|
|
27
25
|
//
|
|
@@ -29,80 +27,12 @@
|
|
|
29
27
|
// inside crouter's own tsc build without a dep on the pi packages (mirrors
|
|
30
28
|
// canvas-nav.ts / canvas-commands.ts).
|
|
31
29
|
import { execFile } from 'node:child_process';
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
// ---------------------------------------------------------------------------
|
|
38
|
-
const STATUS_GLYPH = {
|
|
39
|
-
active: '●',
|
|
40
|
-
idle: '○',
|
|
41
|
-
done: '✓',
|
|
42
|
-
dead: '✗',
|
|
43
|
-
canceled: '⊘',
|
|
44
|
-
};
|
|
45
|
-
function shortId(id) {
|
|
46
|
-
return id.slice(0, 8);
|
|
47
|
-
}
|
|
48
|
-
/** `<glyph> <status> <name> [<kind>/<mode>] (<shortid>)` — a status TAG + name
|
|
49
|
-
* + short id, prefixed with the tree branch. Best-effort on a missing meta. */
|
|
50
|
-
function nodeLabel(nodeId, branch) {
|
|
51
|
-
const node = getNode(nodeId);
|
|
52
|
-
if (node === null)
|
|
53
|
-
return `${branch}? <missing ${shortId(nodeId)}>`;
|
|
54
|
-
const glyph = STATUS_GLYPH[node.status] ?? '?';
|
|
55
|
-
return `${branch}${glyph} ${node.status} ${fullName(node)} [${node.kind}/${node.mode}] (${shortId(nodeId)})`;
|
|
56
|
-
}
|
|
57
|
-
/** Sort rank for roots — live first (active, then idle), dormant after. Keeps
|
|
58
|
-
* the picker oriented while still listing every dormant root. */
|
|
59
|
-
function statusRank(status) {
|
|
60
|
-
switch (status) {
|
|
61
|
-
case 'active': return 0;
|
|
62
|
-
case 'idle': return 1;
|
|
63
|
-
case 'done': return 2;
|
|
64
|
-
case 'canceled': return 3;
|
|
65
|
-
case 'dead': return 4;
|
|
66
|
-
default: return 5;
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
/** Recursively render the subscription subtree rooted at `nodeId` into the
|
|
70
|
-
* parallel lines/ids arrays. Mirrors render.ts walkTree but keeps lines and
|
|
71
|
-
* ids strictly 1:1 (a cycle back-ref still maps to its real node, so selecting
|
|
72
|
-
* it just focuses that node — harmless). Cycle-safe via `visited`. */
|
|
73
|
-
function walkSubtree(nodeId, indent, connector, visited, out) {
|
|
74
|
-
if (visited.has(nodeId)) {
|
|
75
|
-
out.lines.push(`${indent}${connector}↺ ${shortId(nodeId)} (cycle)`);
|
|
76
|
-
out.ids.push(nodeId);
|
|
77
|
-
return;
|
|
78
|
-
}
|
|
79
|
-
visited.add(nodeId);
|
|
80
|
-
out.lines.push(nodeLabel(nodeId, `${indent}${connector}`));
|
|
81
|
-
out.ids.push(nodeId);
|
|
82
|
-
const children = subscriptionsOf(nodeId);
|
|
83
|
-
// Root rows carry no connector; children of a last-child get clear space, of a
|
|
84
|
-
// mid-child a continued spine — exactly render.ts walkTree's prefix math.
|
|
85
|
-
const childIndent = indent + (connector === '' ? '' : connector === '└─ ' ? ' ' : '│ ');
|
|
86
|
-
for (let i = 0; i < children.length; i++) {
|
|
87
|
-
const isLast = i === children.length - 1;
|
|
88
|
-
walkSubtree(children[i].node_id, childIndent, isLast ? '└─ ' : '├─ ', visited, out);
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
/** The whole-canvas forest: EVERY root (parent === null, ANY status) and its
|
|
92
|
-
* subtree, flattened to parallel label/id arrays. */
|
|
93
|
-
function buildForest() {
|
|
94
|
-
const out = { lines: [], ids: [] };
|
|
95
|
-
const visited = new Set();
|
|
96
|
-
const roots = listNodes()
|
|
97
|
-
.filter((n) => n.parent === null)
|
|
98
|
-
.sort((a, b) => statusRank(a.status) - statusRank(b.status));
|
|
99
|
-
for (const r of roots)
|
|
100
|
-
walkSubtree(r.node_id, '', '', visited, out);
|
|
101
|
-
return out;
|
|
30
|
+
/** Single-quote a string for safe interpolation into a `sh -c` command line —
|
|
31
|
+
* tmux runs the display-popup trailing string through the shell, so a cwd with
|
|
32
|
+
* spaces or quotes must be escaped. */
|
|
33
|
+
function shellQuote(s) {
|
|
34
|
+
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
102
35
|
}
|
|
103
|
-
// ---------------------------------------------------------------------------
|
|
104
|
-
// Extension
|
|
105
|
-
// ---------------------------------------------------------------------------
|
|
106
36
|
/**
|
|
107
37
|
* Register the /resume-node command on `pi`.
|
|
108
38
|
*
|
|
@@ -116,9 +46,9 @@ export function registerCanvasResume(pi) {
|
|
|
116
46
|
if (typeof pi.registerCommand !== 'function')
|
|
117
47
|
return;
|
|
118
48
|
pi.registerCommand('resume-node', {
|
|
119
|
-
description: '
|
|
49
|
+
description: 'Open the canvas navigator (search/scope/sort/tree) and resume the chosen node',
|
|
120
50
|
handler: async (_args, ctx) => {
|
|
121
|
-
//
|
|
51
|
+
// The popup is terminal-only — guard the run mode before opening it.
|
|
122
52
|
if (ctx.mode !== 'tui') {
|
|
123
53
|
try {
|
|
124
54
|
ctx.ui.notify('/resume-node needs the interactive TUI', 'warning');
|
|
@@ -126,47 +56,26 @@ export function registerCanvasResume(pi) {
|
|
|
126
56
|
catch { /* best-effort */ }
|
|
127
57
|
return;
|
|
128
58
|
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
59
|
+
const origPane = process.env['TMUX_PANE'];
|
|
60
|
+
// crtr only runs in tmux: open the full-screen canvas navigator as a popup.
|
|
61
|
+
// It owns the screen and, on Enter, focuses the chosen node back INTO this
|
|
62
|
+
// pane via `crtr node focus --pane`. Fire-and-forget: tmux runs the trailing
|
|
63
|
+
// string through sh -c, and the popup closes itself when browse exits.
|
|
64
|
+
if (process.env['TMUX'] !== undefined && origPane !== undefined && origPane !== '') {
|
|
65
|
+
// Scope the navigator to this node's cwd by default (the dir pi runs in).
|
|
66
|
+
const cwd = shellQuote(process.cwd());
|
|
67
|
+
const cmd = `crtr canvas browse --return-pane ${origPane} --cwd ${cwd}`;
|
|
134
68
|
try {
|
|
135
|
-
|
|
69
|
+
execFile('tmux', ['display-popup', '-E', '-w', '90%', '-h', '85%', cmd], () => { });
|
|
136
70
|
}
|
|
137
71
|
catch { /* best-effort */ }
|
|
138
72
|
return;
|
|
139
73
|
}
|
|
140
|
-
|
|
141
|
-
try {
|
|
142
|
-
ctx.ui.notify('No nodes on the canvas to resume.', 'info');
|
|
143
|
-
}
|
|
144
|
-
catch { /* best-effort */ }
|
|
145
|
-
return;
|
|
146
|
-
}
|
|
147
|
-
const choice = await ctx.ui.select('Resume which node?', forest.lines);
|
|
148
|
-
if (choice === undefined)
|
|
149
|
-
return; // cancelled / timed out
|
|
150
|
-
const idx = forest.lines.indexOf(choice);
|
|
151
|
-
const targetId = idx >= 0 ? forest.ids[idx] : undefined;
|
|
152
|
-
if (targetId === undefined)
|
|
153
|
-
return;
|
|
154
|
-
// The ONLY sync-safe open: route through reviveNode via `crtr node focus`.
|
|
155
|
-
// Fire-and-forget — `node focus` swaps the target into THIS pane, replacing
|
|
156
|
-
// the current pi, so the callback may never run (best-effort notify only).
|
|
74
|
+
// Not in tmux → crtr is tmux-only, so there is nothing to fall back to.
|
|
157
75
|
try {
|
|
158
|
-
|
|
159
|
-
if (err != null) {
|
|
160
|
-
try {
|
|
161
|
-
ctx.ui.notify(`resume failed: focus ${shortId(targetId)}`, 'error');
|
|
162
|
-
}
|
|
163
|
-
catch { /* best-effort */ }
|
|
164
|
-
}
|
|
165
|
-
});
|
|
166
|
-
}
|
|
167
|
-
catch {
|
|
168
|
-
/* best-effort */
|
|
76
|
+
ctx.ui.notify('/resume-node needs tmux', 'warning');
|
|
169
77
|
}
|
|
78
|
+
catch { /* best-effort */ }
|
|
170
79
|
},
|
|
171
80
|
});
|
|
172
81
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
type PiEvents = 'turn_end' | 'agent_end' | 'session_shutdown' | 'session_start';
|
|
1
|
+
type PiEvents = 'agent_start' | 'turn_end' | 'agent_end' | 'session_shutdown' | 'session_start';
|
|
2
2
|
interface PiLike {
|
|
3
3
|
on: (event: PiEvents, handler: (event: any, ctx: any) => void | Promise<void>) => void;
|
|
4
4
|
sendUserMessage: (content: string, options?: {
|
|
@@ -30,6 +30,7 @@ import { writeFileSync, mkdirSync, existsSync, readFileSync } from 'node:fs';
|
|
|
30
30
|
import { join } from 'node:path';
|
|
31
31
|
import { getNode, jobDir, updateNode, recordPid, subscribersOf, setPresence } from '../core/canvas/index.js';
|
|
32
32
|
import { transition } from '../core/runtime/lifecycle.js';
|
|
33
|
+
import { markBusy, clearBusy } from '../core/runtime/busy.js';
|
|
33
34
|
import { evaluateStop } from '../core/runtime/stop-guard.js';
|
|
34
35
|
import { personaDrift, commitPersonaAck } from '../core/runtime/persona.js';
|
|
35
36
|
import { reviveInPlace } from '../core/runtime/revive.js';
|
|
@@ -282,6 +283,17 @@ export function registerCanvasStophook(pi) {
|
|
|
282
283
|
}
|
|
283
284
|
});
|
|
284
285
|
// ---------------------------------------------------------------------------
|
|
286
|
+
// agent_start — pi entered a turn. Mark the node mid-turn (busy) so a
|
|
287
|
+
// focus-away while it is genuinely working backstages it (Invariant F2)
|
|
288
|
+
// rather than reaping it. Cleared at the top of agent_end (turn over).
|
|
289
|
+
// ---------------------------------------------------------------------------
|
|
290
|
+
pi.on('agent_start', () => {
|
|
291
|
+
try {
|
|
292
|
+
markBusy(nodeId);
|
|
293
|
+
}
|
|
294
|
+
catch { /* best-effort */ }
|
|
295
|
+
});
|
|
296
|
+
// ---------------------------------------------------------------------------
|
|
285
297
|
// session_shutdown — clean exit → done.
|
|
286
298
|
//
|
|
287
299
|
// pi hands us a reason as a session tears down. Only 'quit' is a node-ending
|
|
@@ -296,6 +308,7 @@ export function registerCanvasStophook(pi) {
|
|
|
296
308
|
// ---------------------------------------------------------------------------
|
|
297
309
|
pi.on('session_shutdown', (event, _ctx) => {
|
|
298
310
|
try {
|
|
311
|
+
clearBusy(nodeId); // turn marker is meaningless once pi is exiting
|
|
299
312
|
// Clean /quit (reason='quit') resolves the node to done; if it held the
|
|
300
313
|
// user's viewport, Q1-close it (tearDownNode kills the frozen focus pane +
|
|
301
314
|
// closes the focus row → returns the user to a shell, §1.5/flow (e)). pi is
|
|
@@ -386,6 +399,9 @@ export function registerCanvasStophook(pi) {
|
|
|
386
399
|
// steering). The stop/yield auto-pushes that needed `await push(...)` were
|
|
387
400
|
// removed, so the handler no longer needs to be async — the node reaches its
|
|
388
401
|
// subscribers ONLY through its own explicit `crtr push` calls.
|
|
402
|
+
// The turn has ended regardless of how it routes below — clear the mid-turn
|
|
403
|
+
// marker FIRST so a focus-away from this now-parked node despawns it.
|
|
404
|
+
clearBusy(nodeId);
|
|
389
405
|
try {
|
|
390
406
|
const messages = Array.isArray(event?.messages) ? event.messages : [];
|
|
391
407
|
// Accumulate tokens from the final batch (edge case: a turn that fired
|
package/dist/prompts/skill.js
CHANGED
|
@@ -38,7 +38,12 @@ is still chosen by what the agent does after reading, not by the script.
|
|
|
38
38
|
assets; nested dirs are their own skills.
|
|
39
39
|
- Required frontmatter: \`name\`, \`type\`, \`description\`. \`name\` must equal the
|
|
40
40
|
dir path under \`skills/\` — slashes nest (\`web/frontend/design\`).
|
|
41
|
-
- \`description\`: one sentence, front-load "Use when…" — it
|
|
41
|
+
- \`description\`: one sentence, front-load "Use when…" — it is the **only**
|
|
42
|
+
text an agent reads before choosing the skill, so every "when to reach for
|
|
43
|
+
this" / selection cue lives *here*, never in the body. By the time anyone
|
|
44
|
+
reads the body they have already picked the skill; a body line about
|
|
45
|
+
when-to-use is wasted. The body is purely the workflow or knowledge they
|
|
46
|
+
came for.
|
|
42
47
|
- Budget ~150 lines per \`SKILL.md\`; spill deeper material into sibling files.
|
|
43
48
|
- \`crtr skill author scaffold <n> --type <t> --scope <s> --description "<d>"\` writes correct frontmatter for you; \`crtr sys doctor\` validates.
|
|
44
49
|
|