@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.
Files changed (104) hide show
  1. package/dist/builtin-personas/design/{base.md → PERSONA.md} +4 -0
  2. package/dist/builtin-personas/developer/{base.md → PERSONA.md} +4 -0
  3. package/dist/builtin-personas/explore/{base.md → PERSONA.md} +4 -0
  4. package/dist/builtin-personas/general/{base.md → PERSONA.md} +4 -0
  5. package/dist/builtin-personas/orchestration-kernel.md +6 -6
  6. package/dist/builtin-personas/plan/{base.md → PERSONA.md} +5 -1
  7. package/dist/builtin-personas/plan/reviewers/architecture-fit/{base.md → PERSONA.md} +1 -1
  8. package/dist/builtin-personas/plan/reviewers/code-smells/{base.md → PERSONA.md} +1 -1
  9. package/dist/builtin-personas/plan/reviewers/pattern-consistency/{base.md → PERSONA.md} +1 -1
  10. package/dist/builtin-personas/plan/reviewers/requirements-coverage/{base.md → PERSONA.md} +1 -1
  11. package/dist/builtin-personas/plan/reviewers/security/{base.md → PERSONA.md} +1 -1
  12. package/dist/builtin-personas/review/{base.md → PERSONA.md} +4 -0
  13. package/dist/builtin-personas/spec/{base.md → PERSONA.md} +5 -1
  14. package/dist/builtin-skills/skills/crouter-development/personas/SKILL.md +24 -14
  15. package/dist/builtin-skills/skills/crouter-development/personas/base-prompt/SKILL.md +4 -4
  16. package/dist/commands/canvas-browse.d.ts +2 -0
  17. package/dist/commands/canvas-browse.js +45 -0
  18. package/dist/commands/canvas-prune.js +11 -2
  19. package/dist/commands/canvas.js +3 -2
  20. package/dist/commands/daemon.js +1 -1
  21. package/dist/commands/human/prompts.js +3 -9
  22. package/dist/commands/human/shared.d.ts +26 -1
  23. package/dist/commands/human/shared.js +48 -10
  24. package/dist/commands/node.js +66 -4
  25. package/dist/commands/skill/author.js +2 -2
  26. package/dist/core/__tests__/cascade-close.test.js +199 -0
  27. package/dist/core/__tests__/daemon-boot.test.js +7 -0
  28. package/dist/core/__tests__/daemon-liveness.test.js +59 -4
  29. package/dist/core/__tests__/dead-pane-regression.test.js +151 -0
  30. package/dist/core/__tests__/fixtures/fake-pi-host.d.ts +2 -0
  31. package/dist/core/__tests__/fixtures/fake-pi-host.js +301 -0
  32. package/dist/core/__tests__/flagship-lifecycle.test.js +273 -0
  33. package/dist/core/__tests__/grace-clock.test.d.ts +1 -0
  34. package/dist/core/__tests__/grace-clock.test.js +115 -0
  35. package/dist/core/__tests__/helpers/harness.d.ts +78 -0
  36. package/dist/core/__tests__/helpers/harness.js +406 -0
  37. package/dist/core/__tests__/human-surface-target.test.d.ts +1 -0
  38. package/dist/core/__tests__/human-surface-target.test.js +98 -0
  39. package/dist/core/__tests__/lifecycle.test.js +6 -13
  40. package/dist/core/__tests__/live-mutation.test.d.ts +1 -0
  41. package/dist/core/__tests__/live-mutation.test.js +341 -0
  42. package/dist/core/__tests__/persona-subkind.test.js +18 -15
  43. package/dist/core/__tests__/placement-focus.test.js +53 -15
  44. package/dist/core/__tests__/relaunch.test.js +12 -12
  45. package/dist/core/__tests__/reset.test.js +11 -6
  46. package/dist/core/__tests__/spike-harness.test.d.ts +1 -0
  47. package/dist/core/__tests__/spike-harness.test.js +241 -0
  48. package/dist/core/__tests__/subscription-delivery.test.d.ts +1 -0
  49. package/dist/core/__tests__/subscription-delivery.test.js +233 -0
  50. package/dist/core/canvas/browse/__tests__/model.test.d.ts +1 -0
  51. package/dist/core/canvas/browse/__tests__/model.test.js +142 -0
  52. package/dist/core/canvas/browse/__tests__/render.test.d.ts +1 -0
  53. package/dist/core/canvas/browse/__tests__/render.test.js +102 -0
  54. package/dist/core/canvas/browse/app.d.ts +4 -0
  55. package/dist/core/canvas/browse/app.js +349 -0
  56. package/dist/core/canvas/browse/model.d.ts +97 -0
  57. package/dist/core/canvas/browse/model.js +258 -0
  58. package/dist/core/canvas/browse/render.d.ts +41 -0
  59. package/dist/core/canvas/browse/render.js +387 -0
  60. package/dist/core/canvas/browse/terminal.d.ts +23 -0
  61. package/dist/core/canvas/browse/terminal.js +100 -0
  62. package/dist/core/canvas/canvas.d.ts +9 -2
  63. package/dist/core/canvas/canvas.js +41 -3
  64. package/dist/core/canvas/paths.d.ts +4 -1
  65. package/dist/core/canvas/paths.js +10 -4
  66. package/dist/core/canvas/render.d.ts +10 -0
  67. package/dist/core/canvas/render.js +25 -1
  68. package/dist/core/canvas/types.js +2 -2
  69. package/dist/core/feed/inbox.d.ts +0 -3
  70. package/dist/core/feed/inbox.js +1 -5
  71. package/dist/core/help.d.ts +6 -0
  72. package/dist/core/help.js +7 -0
  73. package/dist/core/personas/index.d.ts +4 -3
  74. package/dist/core/personas/index.js +3 -2
  75. package/dist/core/personas/loader.d.ts +34 -16
  76. package/dist/core/personas/loader.js +102 -29
  77. package/dist/core/personas/resolve.d.ts +4 -4
  78. package/dist/core/personas/resolve.js +16 -14
  79. package/dist/core/runtime/busy.d.ts +8 -0
  80. package/dist/core/runtime/busy.js +46 -0
  81. package/dist/core/runtime/lifecycle.d.ts +1 -1
  82. package/dist/core/runtime/lifecycle.js +12 -4
  83. package/dist/core/runtime/naming.d.ts +3 -3
  84. package/dist/core/runtime/naming.js +6 -6
  85. package/dist/core/runtime/placement.d.ts +32 -5
  86. package/dist/core/runtime/placement.js +81 -14
  87. package/dist/core/runtime/reset.d.ts +11 -8
  88. package/dist/core/runtime/reset.js +23 -18
  89. package/dist/core/spawn.d.ts +20 -1
  90. package/dist/core/spawn.js +52 -5
  91. package/dist/daemon/crtrd.js +43 -21
  92. package/dist/pi-extensions/canvas-nav.js +106 -55
  93. package/dist/pi-extensions/canvas-resume.d.ts +0 -1
  94. package/dist/pi-extensions/canvas-resume.js +35 -126
  95. package/dist/pi-extensions/canvas-stophook.d.ts +1 -1
  96. package/dist/pi-extensions/canvas-stophook.js +16 -0
  97. package/dist/prompts/skill.js +6 -1
  98. package/package.json +1 -1
  99. package/dist/commands/__tests__/skill.test.js +0 -290
  100. package/dist/core/__tests__/pkg.test.js +0 -218
  101. package/dist/core/__tests__/sys.test.js +0 -208
  102. /package/dist/{commands/__tests__/skill.test.d.ts → core/__tests__/cascade-close.test.d.ts} +0 -0
  103. /package/dist/core/__tests__/{pkg.test.d.ts → dead-pane-regression.test.d.ts} +0 -0
  104. /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
- // Two selection signals, both NO_COLOR-safe:
22
- // SELF row = reverse video (ESC[7m), full width — an attribute, not a color.
23
- // CURSOR = + bold on the row. Status stays on the colored dot.
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
- /** Fold state node ids whose children are hidden in GRAPH. Survives renders
49
- * AND BASE↔GRAPH toggles. Keyed by id so a topology change never corrupts it;
50
- * stale ids are harmless (ignored when absent). */
51
- const collapsed = new Set();
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. Selection
73
- // uses theme-agnostic ATTRIBUTES (reverse / bold), never colour alone, so it
74
- // reads under NO_COLOR and on any background; status uses the standard 8 colors
75
- // on the dot only.
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 reverse-video bar. REVERSE is re-asserted
141
- * after every embedded RESET so a colored cell (the status dot) doesn't punch
142
- * a hole in the bar; the visible width is padded out to `width`; the line
143
- * closes with a real RESET. */
144
- function reverseFill(content, width) {
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}${REVERSE}`);
159
+ const reasserted = clipped.replace(/\x1b\[0m/g, `${RESET}${open}`);
147
160
  const pad = Math.max(0, width - visibleWidth(clipped));
148
- return `${REVERSE}${reasserted}${' '.repeat(pad)}${RESET}`;
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
- /** Live reports (active|idle) of a node the DOWN set in BASE. */
202
- function liveReports(id) {
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
- /** All direct children (edges) used for the badge and fold counts. */
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
- try {
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 = subscriptionsOf(root).map((s) => s.node_id);
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 s of subscriptionsOf(id))
249
- if (!seen.has(s.node_id))
250
- q.push(s.node_id);
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
- try {
287
- return subscriptionsOf(id).map((s) => s.node_id).sort((a, b) => statusRank(a) - statusRank(b));
288
- }
289
- catch {
290
- return [];
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 (collapsed.has(id))
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. SELF → reverse fill; CURSOR → ▸ + bold caret/name. */
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 r.isSelf ? reverseFill(line, fillWidth()) : truncate(line);
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 = isCursor ? `${BOLD}${rawName}${RESET}` : rawName;
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 && collapsed.has(r.id) ? ` ${DIM}[+${childCount(r.id)}]${RESET}` : '';
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 r.isSelf ? reverseFill(line, fillWidth()) : truncate(line);
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 && !collapsed.has(cur.id)) {
677
- collapsed.add(cur.id);
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.has(cur.id)) {
689
- collapsed.delete(cur.id);
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,5 +1,4 @@
1
1
  interface CommandUI {
2
- select(title: string, options: string[]): Promise<string | undefined>;
3
2
  notify(message: string, type?: 'info' | 'warning' | 'error'): void;
4
3
  }
5
4
  interface CommandCtx {
@@ -1,27 +1,25 @@
1
1
  // canvas-resume.ts — pi extension registering the /resume-node canvas command.
2
2
  //
3
- // /resume-node — open a TREE-SHAPED picker over the WHOLE canvas (every root,
4
- // INCLUDING DORMANT nodes: done / idle / dead / canceled) rendered with tree
5
- // glyphs (├─ / └─) + a status tag + name + short id, then revive the chosen
6
- // node by shelling `crtr node focus <id>` (fire-and-forget). Reviving dormant
7
- // nodes is the entire point, so unlike the BASE/GRAPH chrome and
8
- // renderForest()'s live-only (active|idle) filter this walks ALL roots
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>` routes through reviveNode() (src/core/runtime/
16
- // revive.ts), the ONLY sanctioned launcher of `pi --session <file>`: it sets
17
- // CRTR_NODE_ID + the `-e` canvas extensions and runs transition('revive').
18
- // A RAW `pi --session <file>` has NEITHER → every canvas hook is inert: the
19
- // stophook never records pi_pid / clears intent / marks done, no inbox-watcher
20
- // wakes it, and transition('revive') never runs so the row stays dormant.
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
- import { getNode, listNodes, subscriptionsOf, fullName } from '../core/canvas/index.js';
33
- // ---------------------------------------------------------------------------
34
- // Forest rendering one line per node across the WHOLE canvas, with a parallel
35
- // ids[] array so the chosen line maps back to its node_id. Plain unicode glyphs
36
- // (no ANSI) so the line renders cleanly inside pi's select dialog.
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: 'Resume a node — pick from the whole canvas (incl. dormant) and revive it',
49
+ description: 'Open the canvas navigator (search/scope/sort/tree) and resume the chosen node',
120
50
  handler: async (_args, ctx) => {
121
- // select() is a terminal-only dialog — guard the run mode before it.
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
- let forest;
130
- try {
131
- forest = buildForest();
132
- }
133
- catch {
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
- ctx.ui.notify('resume: could not read the canvas', 'error');
69
+ execFile('tmux', ['display-popup', '-E', '-w', '90%', '-h', '85%', cmd], () => { });
136
70
  }
137
71
  catch { /* best-effort */ }
138
72
  return;
139
73
  }
140
- if (forest.lines.length === 0) {
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
- execFile('crtr', ['node', 'focus', targetId], (err) => {
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
@@ -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 drives discovery.
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crouton-kit/crouter",
3
- "version": "0.3.16",
3
+ "version": "0.3.18",
4
4
  "description": "crtr — fast access to skills, plugins, and marketplaces",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",