@crouton-kit/crouter 0.3.15 → 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.
Files changed (101) hide show
  1. package/dist/builtin-personas/developer/orchestrator.md +1 -1
  2. package/dist/builtin-personas/orchestration-kernel.md +6 -6
  3. package/dist/builtin-personas/plan/base.md +1 -1
  4. package/dist/builtin-personas/plan/orchestrator.md +1 -1
  5. package/dist/builtin-personas/spec/base.md +1 -1
  6. package/dist/commands/canvas-browse.d.ts +2 -0
  7. package/dist/commands/canvas-browse.js +45 -0
  8. package/dist/commands/canvas-prune.js +11 -2
  9. package/dist/commands/canvas.js +3 -2
  10. package/dist/commands/chord.js +1 -1
  11. package/dist/commands/human/shared.js +1 -1
  12. package/dist/commands/node.js +14 -2
  13. package/dist/commands/skill/author.js +2 -2
  14. package/dist/commands/tmux-spread.js +2 -3
  15. package/dist/core/__tests__/cascade-close.test.js +199 -0
  16. package/dist/core/__tests__/close.test.js +2 -2
  17. package/dist/core/__tests__/daemon-boot.test.js +7 -0
  18. package/dist/core/__tests__/daemon-liveness.test.js +59 -4
  19. package/dist/core/__tests__/dead-pane-regression.test.js +151 -0
  20. package/dist/core/__tests__/fixtures/fake-pi-host.d.ts +2 -0
  21. package/dist/core/__tests__/fixtures/fake-pi-host.js +301 -0
  22. package/dist/core/__tests__/flagship-lifecycle.test.js +273 -0
  23. package/dist/core/__tests__/focuses.test.js +5 -68
  24. package/dist/core/__tests__/grace-clock.test.d.ts +1 -0
  25. package/dist/core/__tests__/grace-clock.test.js +115 -0
  26. package/dist/core/__tests__/helpers/harness.d.ts +78 -0
  27. package/dist/core/__tests__/helpers/harness.js +406 -0
  28. package/dist/core/__tests__/home-session.test.js +1 -1
  29. package/dist/core/__tests__/lifecycle.test.js +6 -13
  30. package/dist/core/__tests__/live-mutation.test.d.ts +1 -0
  31. package/dist/core/__tests__/live-mutation.test.js +341 -0
  32. package/dist/core/__tests__/placement-focus.test.js +106 -46
  33. package/dist/core/__tests__/placement-teardown.test.js +4 -9
  34. package/dist/core/__tests__/relaunch.test.js +22 -16
  35. package/dist/core/__tests__/reset.test.js +11 -6
  36. package/dist/core/__tests__/spike-harness.test.d.ts +1 -0
  37. package/dist/core/__tests__/spike-harness.test.js +241 -0
  38. package/dist/core/__tests__/subscription-delivery.test.d.ts +1 -0
  39. package/dist/core/__tests__/subscription-delivery.test.js +233 -0
  40. package/dist/core/__tests__/tmux-surface.test.js +8 -9
  41. package/dist/core/canvas/browse/__tests__/model.test.d.ts +1 -0
  42. package/dist/core/canvas/browse/__tests__/model.test.js +142 -0
  43. package/dist/core/canvas/browse/__tests__/render.test.d.ts +1 -0
  44. package/dist/core/canvas/browse/__tests__/render.test.js +102 -0
  45. package/dist/core/canvas/browse/app.d.ts +4 -0
  46. package/dist/core/canvas/browse/app.js +349 -0
  47. package/dist/core/canvas/browse/model.d.ts +97 -0
  48. package/dist/core/canvas/browse/model.js +258 -0
  49. package/dist/core/canvas/browse/render.d.ts +41 -0
  50. package/dist/core/canvas/browse/render.js +387 -0
  51. package/dist/core/canvas/browse/terminal.d.ts +23 -0
  52. package/dist/core/canvas/browse/terminal.js +100 -0
  53. package/dist/core/canvas/canvas.d.ts +9 -2
  54. package/dist/core/canvas/canvas.js +41 -3
  55. package/dist/core/canvas/db.js +2 -3
  56. package/dist/core/canvas/focuses.d.ts +2 -2
  57. package/dist/core/canvas/focuses.js +4 -3
  58. package/dist/core/canvas/render.d.ts +10 -0
  59. package/dist/core/canvas/render.js +25 -1
  60. package/dist/core/canvas/types.d.ts +1 -1
  61. package/dist/core/feed/inbox.d.ts +0 -3
  62. package/dist/core/feed/inbox.js +1 -5
  63. package/dist/core/runtime/busy.d.ts +8 -0
  64. package/dist/core/runtime/busy.js +46 -0
  65. package/dist/core/runtime/close.js +2 -2
  66. package/dist/core/runtime/demote.js +2 -7
  67. package/dist/core/runtime/launch.d.ts +3 -1
  68. package/dist/core/runtime/launch.js +4 -1
  69. package/dist/core/runtime/lifecycle.d.ts +1 -1
  70. package/dist/core/runtime/lifecycle.js +12 -4
  71. package/dist/core/runtime/naming.d.ts +3 -3
  72. package/dist/core/runtime/naming.js +6 -6
  73. package/dist/core/runtime/nodes.d.ts +7 -0
  74. package/dist/core/runtime/nodes.js +10 -1
  75. package/dist/core/runtime/placement.d.ts +39 -10
  76. package/dist/core/runtime/placement.js +100 -44
  77. package/dist/core/runtime/reset.d.ts +11 -8
  78. package/dist/core/runtime/reset.js +36 -31
  79. package/dist/core/runtime/revive.d.ts +1 -1
  80. package/dist/core/runtime/revive.js +2 -2
  81. package/dist/core/runtime/spawn.js +3 -3
  82. package/dist/core/runtime/tmux-chrome.d.ts +1 -0
  83. package/dist/core/runtime/tmux-chrome.js +4 -0
  84. package/dist/core/runtime/tmux.d.ts +13 -6
  85. package/dist/core/runtime/tmux.js +21 -12
  86. package/dist/daemon/crtrd.js +43 -21
  87. package/dist/pi-extensions/canvas-nav.js +40 -28
  88. package/dist/pi-extensions/canvas-resume.d.ts +21 -0
  89. package/dist/pi-extensions/canvas-resume.js +82 -0
  90. package/dist/pi-extensions/canvas-stophook.d.ts +1 -1
  91. package/dist/pi-extensions/canvas-stophook.js +21 -9
  92. package/dist/prompts/skill.js +6 -1
  93. package/package.json +2 -2
  94. package/dist/commands/__tests__/skill.test.js +0 -290
  95. package/dist/core/__tests__/pkg.test.js +0 -218
  96. package/dist/core/__tests__/sys.test.js +0 -208
  97. package/dist/core/runtime/presence.d.ts +0 -30
  98. package/dist/core/runtime/presence.js +0 -178
  99. /package/dist/{commands/__tests__/skill.test.d.ts → core/__tests__/cascade-close.test.d.ts} +0 -0
  100. /package/dist/core/__tests__/{pkg.test.d.ts → dead-pane-regression.test.d.ts} +0 -0
  101. /package/dist/core/__tests__/{sys.test.d.ts → flagship-lifecycle.test.d.ts} +0 -0
@@ -10,15 +10,15 @@
10
10
  // provenance via spawned_by) brought forefront for direct driving.
11
11
  import { spawnSync } from 'node:child_process';
12
12
  import { FRONT_DOOR_ENV } from './front-door.js';
13
- import { spawnNode, currentNodeContext, resolveBirthSession } from './nodes.js';
13
+ import { spawnNode, currentNodeContext, resolveBirthSession, nodeSession } from './nodes.js';
14
14
  import { buildLaunchSpec, buildPiArgv } from './launch.js';
15
15
  import { writeGoal } from './kickoff.js';
16
16
  import { hasRoadmap, seedRoadmap } from './roadmap.js';
17
17
  import { seedMemory, seedUserMemory, seedProjectMemory } from './memory.js';
18
18
  import { generateSessionName } from './naming.js';
19
- import { ensureSession, openNodeWindow, piCommand, currentTmux, inTmux, nodeSession, focusWindow, installMenuBinding, installNavBindings, } from './tmux.js';
19
+ import { installMenuBinding, installNavBindings } from './tmux-chrome.js';
20
20
  import { setPresence, updateNode, getNode, fullName } from '../canvas/index.js';
21
- import { registerRootFocus } from './placement.js';
21
+ import { registerRootFocus, ensureSession, openNodeWindow, piCommand, currentTmux, inTmux, focusWindow, } from './placement.js';
22
22
  import { transition } from './lifecycle.js';
23
23
  import { ensureDaemon } from '../../daemon/manage.js';
24
24
  /** Create a root node and bring up its pi. Returns the node; for 'inline' this
@@ -0,0 +1 @@
1
+ export { installMenuBinding, installNavBindings, sendKeysEnter } from './tmux.js';
@@ -0,0 +1,4 @@
1
+ // tmux-chrome.ts — chrome seam (§2.1): stateless keybind/input verbs.
2
+ // The ONLY non-placement module allowed to import the tmux driver, per the
3
+ // §5.1 lint. Re-exports the menu/nav/send-keys verbs callers (spawn, chord) need.
4
+ export { installMenuBinding, installNavBindings, sendKeysEnter } from './tmux.js';
@@ -1,11 +1,6 @@
1
1
  /** POSIX single-quote escaping for one shell word. */
2
2
  export declare function shellQuote(s: string): string;
3
3
  export declare function inTmux(): boolean;
4
- /** The single, shared tmux session that ALL canvas node windows live in.
5
- * Overridable with CRTR_NODE_SESSION (default `crtr`). Every root and every
6
- * child opens a window here rather than cluttering the user's own working
7
- * session — switch to it to browse the whole live graph, ignore it otherwise. */
8
- export declare function nodeSession(): string;
9
4
  export interface TmuxLocation {
10
5
  session: string;
11
6
  window: string;
@@ -139,7 +134,19 @@ export declare function respawnPaneSync(opts: RespawnPaneOpts): boolean;
139
134
  * callers stay green while the placement layer migrates onto the explicit
140
135
  * sync/detached split. */
141
136
  export declare function respawnPane(opts: RespawnPaneOpts): boolean;
142
- /** Turn a pi argv array into a single shell command string. */
137
+ /** Turn a pi argv array into a single shell command string.
138
+ *
139
+ * The binary defaults to `CRTR_PI_BINARY` when that env var is set, else the
140
+ * literal `pi`. This is a TEST-ONLY substitution seam: when CRTR_PI_BINARY is
141
+ * unset (every production path) the behavior is byte-identical to exec'ing
142
+ * `pi`. The integration-test harness points it at a deterministic fake-pi
143
+ * vehicle so a real `crtr node new` reaches the fake instead of the LLM `pi`,
144
+ * without any dependence on tmux/shell PATH inheritance — the substitution is
145
+ * baked into the command string at build time, in the process that calls
146
+ * piCommand. An explicit `binary` arg still overrides the env (no caller passes
147
+ * one today). The value may be a multi-word launcher (e.g. `node --import
148
+ * tsx/esm host.ts`); only the argv entries are shell-quoted, so a multi-word
149
+ * binary is spliced verbatim ahead of them. */
143
150
  export declare function piCommand(argv: string[], binary?: string): string;
144
151
  /** List all window ids present in `session`. Returns [] if the session does
145
152
  * not exist or tmux fails for any reason. Each entry is the raw window id
@@ -9,6 +9,7 @@
9
9
  // their window; reviving opens a fresh one.
10
10
  import { spawn, spawnSync } from 'node:child_process';
11
11
  import { readConfig } from '../config.js';
12
+ import { nodeSession } from './nodes.js';
12
13
  // ---------------------------------------------------------------------------
13
14
  // Shell quoting + tmux invocation
14
15
  // ---------------------------------------------------------------------------
@@ -27,14 +28,6 @@ function tmux(args) {
27
28
  export function inTmux() {
28
29
  return process.env['TMUX'] !== undefined && process.env['TMUX'] !== '';
29
30
  }
30
- /** The single, shared tmux session that ALL canvas node windows live in.
31
- * Overridable with CRTR_NODE_SESSION (default `crtr`). Every root and every
32
- * child opens a window here rather than cluttering the user's own working
33
- * session — switch to it to browse the whole live graph, ignore it otherwise. */
34
- export function nodeSession() {
35
- const v = process.env['CRTR_NODE_SESSION'];
36
- return v !== undefined && v !== '' ? v : 'crtr';
37
- }
38
31
  /** Where the caller currently is, or null if not inside tmux. */
39
32
  export function currentTmux() {
40
33
  if (!inTmux())
@@ -270,8 +263,20 @@ export function respawnPane(opts) {
270
263
  // ---------------------------------------------------------------------------
271
264
  // pi command assembly
272
265
  // ---------------------------------------------------------------------------
273
- /** Turn a pi argv array into a single shell command string. */
274
- export function piCommand(argv, binary = 'pi') {
266
+ /** Turn a pi argv array into a single shell command string.
267
+ *
268
+ * The binary defaults to `CRTR_PI_BINARY` when that env var is set, else the
269
+ * literal `pi`. This is a TEST-ONLY substitution seam: when CRTR_PI_BINARY is
270
+ * unset (every production path) the behavior is byte-identical to exec'ing
271
+ * `pi`. The integration-test harness points it at a deterministic fake-pi
272
+ * vehicle so a real `crtr node new` reaches the fake instead of the LLM `pi`,
273
+ * without any dependence on tmux/shell PATH inheritance — the substitution is
274
+ * baked into the command string at build time, in the process that calls
275
+ * piCommand. An explicit `binary` arg still overrides the env (no caller passes
276
+ * one today). The value may be a multi-word launcher (e.g. `node --import
277
+ * tsx/esm host.ts`); only the argv entries are shell-quoted, so a multi-word
278
+ * binary is spliced verbatim ahead of them. */
279
+ export function piCommand(argv, binary = process.env['CRTR_PI_BINARY'] ?? 'pi') {
275
280
  return [binary, ...argv.map(shellQuote)].join(' ');
276
281
  }
277
282
  // ---------------------------------------------------------------------------
@@ -295,7 +300,7 @@ export function windowAlive(session, window) {
295
300
  return listWindowIds(session).includes(window);
296
301
  }
297
302
  // ---------------------------------------------------------------------------
298
- // Focus helpers (used by the presence layer)
303
+ // Focus helpers (used by the placement layer)
299
304
  // ---------------------------------------------------------------------------
300
305
  /** Activate a window within its session (same-session navigation). Equivalent
301
306
  * to `tmux select-window -t <session>:<window>`. Best-effort; never throws. */
@@ -351,7 +356,7 @@ export function sendKeysEnter(pane, text) {
351
356
  // ---------------------------------------------------------------------------
352
357
  /** Reserved mnemonic keys owned by the built-in menu items below — a custom
353
358
  * `prefixBind` may not claim these (the built-in item wins). */
354
- const RESERVED_MENU_KEYS = new Set(['o', 'd', 'D', 'x', 'b']);
359
+ const RESERVED_MENU_KEYS = new Set(['o', 'r', 'd', 'D', 'x', 'b']);
355
360
  /** Bind Alt+C to the crouter action menu. Best-effort; false if tmux fails.
356
361
  * The built-in items (promote/demote/detach/close/browse) are static; the canvas-nav
357
362
  * chords (graph/manager/expand/report-N + any custom prefixBind) are appended
@@ -366,6 +371,10 @@ export function installMenuBinding() {
366
371
  // the slash command delivers the orchestration guidance into the node's
367
372
  // context, which a bare `run-shell` (output discarded) could not.
368
373
  { name: 'promote to orchestrator', key: 'o', cmd: `send-keys -t '#{pane_id}' '/promote' Enter` },
374
+ // Resume types `/resume-node` into the agent's pane: the slash command opens
375
+ // a whole-canvas picker (incl. dormant nodes) and revives the choice via
376
+ // `crtr node focus` — the only sync-safe open (routes through reviveNode).
377
+ { name: 'resume node', key: 'r', cmd: `send-keys -t '#{pane_id}' '/resume-node' Enter` },
369
378
  // `d` demotes the agent to TERMINAL in place: no finalize, no kill — it keeps
370
379
  // running where it is, and because it is now terminal it is forced to push a
371
380
  // final up the spine when it finishes. `D` ALSO detaches it to the background
@@ -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 → crash: mark 'dead'.
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 completing or refreshing. Split the
205
- // two ways that happens: a vehicle that NEVER BOOTED (pi exited before
206
- // its first session_start, so pi_session_id is still null) versus a
207
- // genuine mid-run CRASH (it had booted, so pi_session_id is set). Both
208
- // are dead, but a never-booted node is a spawn failure the parent was
209
- // never told aboutsurface it up the spine instead of dying quietly.
210
- transition(row.node_id, 'crash');
211
- // Boot-failed vs crashed turns on pi_session_id, an IDENTITY field — the
212
- // one place this pass still reads meta. surfaceBootFailure also wants the
213
- // full meta (name/kind) for its message.
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
- if (meta !== null && meta.pi_session_id == null) {
216
- process.stderr.write(`[crtrd] boot-failed ${row.node_id} (pane gone before pi ever started)\n`);
217
- try {
218
- await surfaceBootFailure(meta);
219
- }
220
- catch (err) {
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
- process.stderr.write(`[crtrd] dead ${row.node_id} (pane gone, intent=${String(row.intent)})\n`);
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
- /** Live reports (active|idle) of a node the DOWN set in BASE. */
202
- function liveReports(id) {
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
- /** All direct children (edges) used for the badge and fold counts. */
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
- try {
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 = subscriptionsOf(root).map((s) => s.node_id);
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 s of subscriptionsOf(id))
249
- if (!seen.has(s.node_id))
250
- q.push(s.node_id);
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
- try {
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);
@@ -502,11 +506,19 @@ export function registerCanvasNav(pi) {
502
506
  // Budget WITHIN pi's widget cap (see graphWidgetBudget): reserve 1 line for
503
507
  // the footer hint, up to 2 for the ↑/↓ "more" indicators, the rest for tree
504
508
  // rows. The window then tracks the cursor, so j/k scrolls through the WHOLE
505
- // list rather than hitting pi's hard truncation. Two passes settle the
506
- // mutual dependency between "how many rows fit" and "are indicators shown".
509
+ // list rather than hitting pi's hard truncation. The passes settle the
510
+ // mutual dependency between "how many rows fit" and "are indicators shown":
511
+ // each ↑/↓ indicator steals a tree row, which can push the cursor out of
512
+ // view, which moves the window, which changes whether an indicator shows.
513
+ // This needs up to 3 passes to converge (an indicator appearing shrinks the
514
+ // window, the smaller window re-homes scrollTop, that re-home can toggle the
515
+ // *other* indicator). Bailing early (the old 2-pass cap) left the cursor one
516
+ // row off-screen for a single keypress near the bottom — the arrow vanished
517
+ // and only the NEXT press scrolled. 4 passes always settles to a stable,
518
+ // cursor-visible window.
507
519
  const treeArea = Math.max(2, graphWidgetBudget() - 1);
508
520
  let viewportH = treeArea;
509
- for (let pass = 0; pass < 2; pass++) {
521
+ for (let pass = 0; pass < 4; pass++) {
510
522
  if (cursorIdx < scrollTop)
511
523
  scrollTop = cursorIdx;
512
524
  if (cursorIdx >= scrollTop + viewportH)
@@ -0,0 +1,21 @@
1
+ interface CommandUI {
2
+ notify(message: string, type?: 'info' | 'warning' | 'error'): void;
3
+ }
4
+ interface CommandCtx {
5
+ mode: string;
6
+ ui: CommandUI;
7
+ }
8
+ interface PiLike {
9
+ registerCommand?(name: string, options: {
10
+ description?: string;
11
+ handler: (args: string, ctx: CommandCtx) => void | Promise<void>;
12
+ }): void;
13
+ }
14
+ /**
15
+ * Register the /resume-node command on `pi`.
16
+ *
17
+ * Returns immediately when CRTR_NODE_ID is absent — the extension is fully
18
+ * inert in a non-canvas pi session.
19
+ */
20
+ export declare function registerCanvasResume(pi: PiLike): void;
21
+ export default registerCanvasResume;
@@ -0,0 +1,82 @@
1
+ // canvas-resume.ts — pi extension registering the /resume-node canvas command.
2
+ //
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`.
9
+ //
10
+ // The name is literally `resume-node`, NOT `resume`, to avoid clashing with
11
+ // pi's built-in /resume.
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
+ //
16
+ // ⚠ DESYNC — why `crtr node focus` is the ONLY sanctioned open
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.
23
+ //
24
+ // INERT when CRTR_NODE_ID is absent (a plain pi session, not a canvas node).
25
+ //
26
+ // Plain TS-with-types — no imports from @earendil-works/* so this compiles
27
+ // inside crouter's own tsc build without a dep on the pi packages (mirrors
28
+ // canvas-nav.ts / canvas-commands.ts).
29
+ import { execFile } from 'node:child_process';
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, `'\\''`)}'`;
35
+ }
36
+ /**
37
+ * Register the /resume-node command on `pi`.
38
+ *
39
+ * Returns immediately when CRTR_NODE_ID is absent — the extension is fully
40
+ * inert in a non-canvas pi session.
41
+ */
42
+ export function registerCanvasResume(pi) {
43
+ const nodeId = process.env['CRTR_NODE_ID'];
44
+ if (nodeId === undefined || nodeId.trim() === '')
45
+ return; // not a canvas node
46
+ if (typeof pi.registerCommand !== 'function')
47
+ return;
48
+ pi.registerCommand('resume-node', {
49
+ description: 'Open the canvas navigator (search/scope/sort/tree) and resume the chosen node',
50
+ handler: async (_args, ctx) => {
51
+ // The popup is terminal-only — guard the run mode before opening it.
52
+ if (ctx.mode !== 'tui') {
53
+ try {
54
+ ctx.ui.notify('/resume-node needs the interactive TUI', 'warning');
55
+ }
56
+ catch { /* best-effort */ }
57
+ return;
58
+ }
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}`;
68
+ try {
69
+ execFile('tmux', ['display-popup', '-E', '-w', '90%', '-h', '85%', cmd], () => { });
70
+ }
71
+ catch { /* best-effort */ }
72
+ return;
73
+ }
74
+ // Not in tmux → crtr is tmux-only, so there is nothing to fall back to.
75
+ try {
76
+ ctx.ui.notify('/resume-node needs tmux', 'warning');
77
+ }
78
+ catch { /* best-effort */ }
79
+ },
80
+ });
81
+ }
82
+ export default registerCanvasResume;
@@ -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?: {
@@ -28,15 +28,14 @@
28
28
  // crouter's own tsc build without a dep on the pi packages.
29
29
  import { writeFileSync, mkdirSync, existsSync, readFileSync } from 'node:fs';
30
30
  import { join } from 'node:path';
31
- import { getNode, jobDir, updateNode, recordPid, subscribersOf, closeFocusRow, setPresence } from '../core/canvas/index.js';
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';
36
37
  import { handleNewSession, markCleanExitDone } from '../core/runtime/reset.js';
37
- import { setFocus } from '../core/runtime/presence.js';
38
- import { focusOf, handFocusToManager, tearDownNode } from '../core/runtime/placement.js';
39
- import { setRemainOnExit } from '../core/runtime/tmux.js';
38
+ import { focusOf, handFocusToManager, tearDownNode, closeFocusToShell } from '../core/runtime/placement.js';
40
39
  /**
41
40
  * Merge accumulated token counts into nodes/<nodeId>/job/telemetry.json.
42
41
  * Creates the directory when it doesn't yet exist. Best-effort; never throws.
@@ -284,6 +283,17 @@ export function registerCanvasStophook(pi) {
284
283
  }
285
284
  });
286
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
+ // ---------------------------------------------------------------------------
287
297
  // session_shutdown — clean exit → done.
288
298
  //
289
299
  // pi hands us a reason as a session tears down. Only 'quit' is a node-ending
@@ -298,6 +308,7 @@ export function registerCanvasStophook(pi) {
298
308
  // ---------------------------------------------------------------------------
299
309
  pi.on('session_shutdown', (event, _ctx) => {
300
310
  try {
311
+ clearBusy(nodeId); // turn marker is meaningless once pi is exiting
301
312
  // Clean /quit (reason='quit') resolves the node to done; if it held the
302
313
  // user's viewport, Q1-close it (tearDownNode kills the frozen focus pane +
303
314
  // closes the focus row → returns the user to a shell, §1.5/flow (e)). pi is
@@ -388,6 +399,9 @@ export function registerCanvasStophook(pi) {
388
399
  // steering). The stop/yield auto-pushes that needed `await push(...)` were
389
400
  // removed, so the handler no longer needs to be async — the node reaches its
390
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);
391
405
  try {
392
406
  const messages = Array.isArray(event?.messages) ? event.messages : [];
393
407
  // Accumulate tokens from the final batch (edge case: a turn that fired
@@ -423,11 +437,9 @@ export function registerCanvasStophook(pi) {
423
437
  if (f !== null) {
424
438
  const managerId = node.parent ?? subscribersOf(nodeId)[0]?.node_id ?? null;
425
439
  if (!handFocusToManager(f.focus_id, managerId)) {
426
- closeFocusRow(f.focus_id);
427
- setFocus('');
428
- const win = getNode(nodeId)?.window; // %m's window
429
- if (win)
430
- setRemainOnExit(win, false); // Q1 return-to-shell
440
+ // Q1 return-to-shell, self-saw-safe: close the focus row + disarm the
441
+ // pane's freeze so it reaps on exit (we can't closePane our own pane).
442
+ closeFocusToShell(f.focus_id, nodeId);
431
443
  }
432
444
  }
433
445
  setPresence(nodeId, { pane: null, tmux_session: null, window: null }); // M done → owns no pane
@@ -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.15",
3
+ "version": "0.3.17",
4
4
  "description": "crtr — fast access to skills, plugins, and marketplaces",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -36,7 +36,7 @@
36
36
  },
37
37
  "license": "MIT",
38
38
  "dependencies": {
39
- "@crouton-kit/humanloop": "^0.3.14",
39
+ "@crouton-kit/humanloop": "^0.3.15",
40
40
  "commander": "^13.0.0"
41
41
  },
42
42
  "devDependencies": {