@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
|
@@ -18,9 +18,15 @@
|
|
|
18
18
|
// alt+c is a tmux display-menu (not a pi key), so prefix chords (m/e/1-9/custom)
|
|
19
19
|
// are tmux menu items that route through `crtr canvas chord`.
|
|
20
20
|
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
21
|
+
// Selection / liveness signals:
|
|
22
|
+
// CURSOR (selected) = reverse-video bar (ESC[7m), full width — an attribute,
|
|
23
|
+
// not a colour, so it reads under NO_COLOR. Plus a ▸ caret.
|
|
24
|
+
// ACTIVE (running) = a coloured background bar (status 'active'); the dot
|
|
25
|
+
// glyph still carries the signal where colour is stripped.
|
|
26
|
+
// SELF = bold name — a quiet "you are here" marker.
|
|
27
|
+
//
|
|
28
|
+
// Folding is auto by default: a branch stays COLLAPSED unless its subtree holds
|
|
29
|
+
// a running ('active') agent or self. h/l override that per-node and persist.
|
|
24
30
|
//
|
|
25
31
|
// ⚑K pending-asks is PER-NODE, inline on each waiting node's own row (manager,
|
|
26
32
|
// reports, tree rows; self shows a trailing ⚑ line in BASE). ⤳M direct-children
|
|
@@ -45,10 +51,13 @@ let liveTimer;
|
|
|
45
51
|
* exactly one key tap exists (mirrors the liveTimer double-guard). */
|
|
46
52
|
let liveUnsub;
|
|
47
53
|
let view = 'base';
|
|
48
|
-
/**
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
|
|
54
|
+
/** Manual fold OVERRIDES in GRAPH, keyed by id (so a topology change can't
|
|
55
|
+
* corrupt them; stale ids are ignored). They override the default policy —
|
|
56
|
+
* collapsed UNLESS the subtree holds a running ('active') agent or self (see
|
|
57
|
+
* computeDefaultExpanded). `h` collapses → userCollapsed; `l` expands →
|
|
58
|
+
* userExpanded. Both survive renders AND BASE↔GRAPH toggles. */
|
|
59
|
+
const userCollapsed = new Set();
|
|
60
|
+
const userExpanded = new Set();
|
|
52
61
|
/** GRAPH cursor (a node id, not an index — indices shift as topology changes). */
|
|
53
62
|
let cursorId;
|
|
54
63
|
/** GRAPH viewport scroll offset (row index of the top visible row). */
|
|
@@ -69,16 +78,19 @@ const PI_MAX_WIDGET_LINES = 10;
|
|
|
69
78
|
const VIEWPORT_FALLBACK_ROWS = 30;
|
|
70
79
|
// ---------------------------------------------------------------------------
|
|
71
80
|
// ANSI styling. pi renders embedded escapes in widget lines and measures width
|
|
72
|
-
// ANSI-aware, so raw escapes are safe and need no pi-tui dependency.
|
|
73
|
-
// uses theme-agnostic
|
|
74
|
-
//
|
|
75
|
-
//
|
|
81
|
+
// ANSI-aware, so raw escapes are safe and need no pi-tui dependency. The cursor
|
|
82
|
+
// (selected row) uses a theme-agnostic ATTRIBUTE (reverse), so it reads under
|
|
83
|
+
// NO_COLOR; the active-row tint is a background COLOUR, but the differing dot
|
|
84
|
+
// glyph (●/○/✓/✗) keeps the running signal even where colour is stripped.
|
|
76
85
|
// ---------------------------------------------------------------------------
|
|
77
86
|
const ESC = '\x1b[';
|
|
78
87
|
const RESET = `${ESC}0m`;
|
|
79
88
|
const BOLD = `${ESC}1m`;
|
|
80
89
|
const DIM = `${ESC}2m`;
|
|
81
90
|
const REVERSE = `${ESC}7m`;
|
|
91
|
+
/** Dark-green background bar marking a running ('active') node — distinct from
|
|
92
|
+
* the cursor's reverse-video bar; chosen so default-fg text stays readable. */
|
|
93
|
+
const BG_ACTIVE = `${ESC}48;5;22m`;
|
|
82
94
|
const GREEN = `${ESC}32m`;
|
|
83
95
|
const RED = `${ESC}31m`;
|
|
84
96
|
const YELLOW = `${ESC}33m`;
|
|
@@ -137,15 +149,16 @@ function truncate(s, max = fillWidth()) {
|
|
|
137
149
|
function fillWidth() {
|
|
138
150
|
return Math.max(20, Math.min((process.stdout.columns ?? 80) - 2, 180));
|
|
139
151
|
}
|
|
140
|
-
/** Wrap `content` in a full-width
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
* closes with a real
|
|
144
|
-
|
|
152
|
+
/** Wrap `content` in a full-width background bar opened by `open` (REVERSE for
|
|
153
|
+
* the cursor, BG_ACTIVE for a running node). `open` is re-asserted after every
|
|
154
|
+
* embedded RESET so a coloured cell (the status dot) can't punch a hole in the
|
|
155
|
+
* bar; the visible width is padded out to `width`; the line closes with a real
|
|
156
|
+
* RESET so the style never bleeds into the editor below. */
|
|
157
|
+
function fillBar(content, width, open) {
|
|
145
158
|
const clipped = truncate(content, width);
|
|
146
|
-
const reasserted = clipped.replace(/\x1b\[0m/g, `${RESET}${
|
|
159
|
+
const reasserted = clipped.replace(/\x1b\[0m/g, `${RESET}${open}`);
|
|
147
160
|
const pad = Math.max(0, width - visibleWidth(clipped));
|
|
148
|
-
return `${
|
|
161
|
+
return `${open}${reasserted}${' '.repeat(pad)}${RESET}`;
|
|
149
162
|
}
|
|
150
163
|
function readTelemetry(nodeId) {
|
|
151
164
|
try {
|
|
@@ -198,28 +211,37 @@ function managerOf(id) {
|
|
|
198
211
|
return undefined;
|
|
199
212
|
}
|
|
200
213
|
}
|
|
201
|
-
/**
|
|
202
|
-
|
|
214
|
+
/** A kind:'human' node is a control-plane ASK (a humanloop deck on the human's
|
|
215
|
+
* screen), NOT a pi conversation — it has no session, so focusing/reviving it
|
|
216
|
+
* boots a confused blank "you have been revived" pi. Its pending-ask signal
|
|
217
|
+
* already rides the ⚑ badge on the ASKING node (attention.ts attributes asks by
|
|
218
|
+
* source.nodeId, never to the human node), so the row carries no signal of its
|
|
219
|
+
* own. Drop it from every navigable list (the tree, BASE reports, child counts,
|
|
220
|
+
* subtree expansion) so it can never be selected. */
|
|
221
|
+
function isHumanAsk(id) {
|
|
222
|
+
return getNode(id)?.kind === 'human';
|
|
223
|
+
}
|
|
224
|
+
/** A node's direct children that are navigable conversations — human-ask nodes
|
|
225
|
+
* dropped. The one place the nav chrome enumerates children. */
|
|
226
|
+
function convoChildIds(id) {
|
|
203
227
|
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
|
-
});
|
|
228
|
+
return subscriptionsOf(id).map((s) => s.node_id).filter((cid) => !isHumanAsk(cid));
|
|
210
229
|
}
|
|
211
230
|
catch {
|
|
212
231
|
return [];
|
|
213
232
|
}
|
|
214
233
|
}
|
|
215
|
-
/**
|
|
234
|
+
/** Live reports (active|idle) of a node — the DOWN set in BASE. */
|
|
235
|
+
function liveReports(id) {
|
|
236
|
+
return convoChildIds(id).filter((cid) => {
|
|
237
|
+
const st = getNode(cid)?.status;
|
|
238
|
+
return st === 'active' || st === 'idle';
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
/** Direct navigable children — used for the ⤳ badge and fold counts (human-ask
|
|
242
|
+
* nodes excluded, so the count matches what the tree actually shows). */
|
|
216
243
|
function childCount(id) {
|
|
217
|
-
|
|
218
|
-
return subscriptionsOf(id).length;
|
|
219
|
-
}
|
|
220
|
-
catch {
|
|
221
|
-
return 0;
|
|
222
|
-
}
|
|
244
|
+
return convoChildIds(id).length;
|
|
223
245
|
}
|
|
224
246
|
/** Climb first-manager edges from `self` to the ancestry root (cycle-guarded). */
|
|
225
247
|
function climbRoot(self) {
|
|
@@ -238,16 +260,16 @@ function climbRoot(self) {
|
|
|
238
260
|
function subtreeIds(root) {
|
|
239
261
|
const out = [];
|
|
240
262
|
const seen = new Set([root]);
|
|
241
|
-
const q =
|
|
263
|
+
const q = convoChildIds(root);
|
|
242
264
|
while (q.length > 0) {
|
|
243
265
|
const id = q.shift();
|
|
244
266
|
if (seen.has(id))
|
|
245
267
|
continue;
|
|
246
268
|
seen.add(id);
|
|
247
269
|
out.push(id);
|
|
248
|
-
for (const
|
|
249
|
-
if (!seen.has(
|
|
250
|
-
q.push(
|
|
270
|
+
for (const cid of convoChildIds(id))
|
|
271
|
+
if (!seen.has(cid))
|
|
272
|
+
q.push(cid);
|
|
251
273
|
}
|
|
252
274
|
return out;
|
|
253
275
|
}
|
|
@@ -283,28 +305,50 @@ function statusRank(id) {
|
|
|
283
305
|
* the tree and when stepping into a subtree (`l`). Array.sort is stable, so
|
|
284
306
|
* equal-status siblings keep their creation order. */
|
|
285
307
|
function sortedChildIds(id) {
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
308
|
+
return convoChildIds(id).sort((a, b) => statusRank(a) - statusRank(b));
|
|
309
|
+
}
|
|
310
|
+
/** Default fold policy: which nodes auto-EXPAND. A node expands only when one
|
|
311
|
+
* of its child subtrees holds a running ('active') agent or self — so the path
|
|
312
|
+
* to any live agent (and to you) is revealed while quiescent branches stay
|
|
313
|
+
* folded. One bottom-up O(N) pass from the ancestry root; cycle-guarded. */
|
|
314
|
+
function computeDefaultExpanded(root, self) {
|
|
315
|
+
const expand = new Set();
|
|
316
|
+
const seen = new Set();
|
|
317
|
+
// Returns whether subtree(id), INCLUDING id, holds an active node or self.
|
|
318
|
+
const visit = (id) => {
|
|
319
|
+
if (seen.has(id))
|
|
320
|
+
return id === self || getNode(id)?.status === 'active';
|
|
321
|
+
seen.add(id);
|
|
322
|
+
let childRevealing = false;
|
|
323
|
+
for (const c of convoChildIds(id))
|
|
324
|
+
if (visit(c))
|
|
325
|
+
childRevealing = true;
|
|
326
|
+
if (childRevealing)
|
|
327
|
+
expand.add(id); // a descendant is worth revealing → unfold id
|
|
328
|
+
return childRevealing || id === self || getNode(id)?.status === 'active';
|
|
329
|
+
};
|
|
330
|
+
visit(root);
|
|
331
|
+
return expand;
|
|
292
332
|
}
|
|
293
333
|
function buildGraphModel(self) {
|
|
294
334
|
const rootId = climbRoot(self);
|
|
335
|
+
const defaultExpanded = computeDefaultExpanded(rootId, self);
|
|
336
|
+
// userExpanded / userCollapsed override the auto policy; absent → policy decides.
|
|
337
|
+
const isFolded = (id) => userExpanded.has(id) ? false : userCollapsed.has(id) ? true : !defaultExpanded.has(id);
|
|
295
338
|
const rows = [];
|
|
296
339
|
const visited = new Set();
|
|
297
340
|
const walk = (id, prefix, isRoot, isLast) => {
|
|
298
341
|
if (visited.has(id)) {
|
|
299
342
|
const connector = isRoot ? '' : isLast ? '└─ ' : '├─ ';
|
|
300
|
-
rows.push({ id, hasKids: false, isSelf: id === self, branch: prefix + connector, cycle: true });
|
|
343
|
+
rows.push({ id, hasKids: false, isSelf: id === self, branch: prefix + connector, cycle: true, collapsed: false });
|
|
301
344
|
return;
|
|
302
345
|
}
|
|
303
346
|
visited.add(id);
|
|
304
347
|
const kids = sortedChildIds(id);
|
|
348
|
+
const folded = isFolded(id);
|
|
305
349
|
const connector = isRoot ? '' : isLast ? '└─ ' : '├─ ';
|
|
306
|
-
rows.push({ id, hasKids: kids.length > 0, isSelf: id === self, branch: prefix + connector, cycle: false });
|
|
307
|
-
if (
|
|
350
|
+
rows.push({ id, hasKids: kids.length > 0, isSelf: id === self, branch: prefix + connector, cycle: false, collapsed: folded });
|
|
351
|
+
if (folded)
|
|
308
352
|
return; // folded — don't descend
|
|
309
353
|
const childPrefix = isRoot ? '' : prefix + (isLast ? ' ' : '│ ');
|
|
310
354
|
for (let i = 0; i < kids.length; i++)
|
|
@@ -313,22 +357,27 @@ function buildGraphModel(self) {
|
|
|
313
357
|
walk(rootId, '', true, true);
|
|
314
358
|
return rows;
|
|
315
359
|
}
|
|
316
|
-
/** Render one GRAPH row.
|
|
360
|
+
/** Render one GRAPH row. CURSOR (selected) → reverse-video bar; an ACTIVE
|
|
361
|
+
* (running) node → a coloured background bar; SELF → bold name. The cursor
|
|
362
|
+
* outranks the active tint when both land on the same row. */
|
|
317
363
|
function renderGraphRow(r, isCursor) {
|
|
364
|
+
const wrap = (line, active) => isCursor ? fillBar(line, fillWidth(), REVERSE)
|
|
365
|
+
: active ? fillBar(line, fillWidth(), BG_ACTIVE)
|
|
366
|
+
: truncate(line);
|
|
318
367
|
if (r.cycle) {
|
|
319
368
|
const line = `${r.branch} ${DIM}↺ ${shortId(r.id)}${RESET}`;
|
|
320
|
-
return
|
|
369
|
+
return wrap(line, false);
|
|
321
370
|
}
|
|
322
371
|
const node = getNode(r.id);
|
|
323
372
|
const dot = coloredGlyph(node);
|
|
324
373
|
const rawName = node !== null ? fullName(node) : shortId(r.id);
|
|
325
|
-
const name =
|
|
374
|
+
const name = r.isSelf ? `${BOLD}${rawName}${RESET}` : rawName;
|
|
326
375
|
const kind = `${DIM}${node?.kind ?? ''}${RESET}`;
|
|
327
376
|
const tokens = `${DIM}${tokensCell(r.id)}${RESET}`;
|
|
328
377
|
const caret = isCursor ? `${BOLD}▸${RESET} ` : ' ';
|
|
329
|
-
const fold = r.hasKids &&
|
|
378
|
+
const fold = r.hasKids && r.collapsed ? ` ${DIM}[+${childCount(r.id)}]${RESET}` : '';
|
|
330
379
|
const line = `${r.branch}${caret}${dot} ${name} ${kind} ${tokens}${childBadge(node)}${fold}${askBadge(r.id)}`;
|
|
331
|
-
return
|
|
380
|
+
return wrap(line, node?.status === 'active');
|
|
332
381
|
}
|
|
333
382
|
/** Total lines the GRAPH widget may emit. pi hard-caps extension widgets at
|
|
334
383
|
* MAX_WIDGET_LINES — anything past that pi truncates itself, eating our own
|
|
@@ -673,8 +722,9 @@ export function registerCanvasNav(pi) {
|
|
|
673
722
|
return { consume: true };
|
|
674
723
|
}
|
|
675
724
|
if (isPlain(data, 'h')) {
|
|
676
|
-
if (cur !== undefined && cur.hasKids && !
|
|
677
|
-
|
|
725
|
+
if (cur !== undefined && cur.hasKids && !cur.collapsed) {
|
|
726
|
+
userCollapsed.add(cur.id);
|
|
727
|
+
userExpanded.delete(cur.id);
|
|
678
728
|
}
|
|
679
729
|
else {
|
|
680
730
|
const p = managerOf(cursorId ?? nodeId);
|
|
@@ -685,8 +735,9 @@ export function registerCanvasNav(pi) {
|
|
|
685
735
|
return { consume: true };
|
|
686
736
|
}
|
|
687
737
|
if (isPlain(data, 'l')) {
|
|
688
|
-
if (cur !== undefined && collapsed
|
|
689
|
-
|
|
738
|
+
if (cur !== undefined && cur.collapsed && cur.hasKids) {
|
|
739
|
+
userExpanded.add(cur.id);
|
|
740
|
+
userCollapsed.delete(cur.id);
|
|
690
741
|
}
|
|
691
742
|
else if (cur !== undefined && cur.hasKids) {
|
|
692
743
|
const c = sortedChildIds(cur.id)[0];
|
|
@@ -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
|
|