@crouton-kit/crouter 0.3.68 → 0.3.78

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 (176) hide show
  1. package/README.md +1 -1
  2. package/dist/builtin-memory/00-runtime-base.md +3 -10
  3. package/dist/builtin-memory/04-base-worker.md +18 -0
  4. package/dist/builtin-memory/04-orchestration-kernel.md +1 -1
  5. package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +13 -34
  6. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +639 -741
  7. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +241 -58
  8. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/statusline.ts +2 -9
  9. package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +365 -94
  10. package/dist/builtin-pi-packages/pi-crtr-extensions/package-lock.json +2 -2
  11. package/dist/builtin-pi-packages/pi-mode-switch/README.md +11 -39
  12. package/dist/builtin-pi-packages/pi-mode-switch/extensions/index.ts +20 -15
  13. package/dist/builtin-pi-packages/pi-mode-switch/package.json +1 -1
  14. package/dist/builtin-views/canvas/tui.mjs +8 -9
  15. package/dist/builtin-views/chat/tui.mjs +12 -12
  16. package/dist/builtin-views/git-pr/tui.mjs +7 -8
  17. package/dist/builtin-views/inbox/tui.mjs +27 -41
  18. package/dist/builtin-views/linkedin/tui.mjs +23 -37
  19. package/dist/builtin-views/prompt-review/tui.mjs +11 -11
  20. package/dist/builtin-views/settings/tui.mjs +11 -11
  21. package/dist/builtin-views/workspace-sidebar/tui.mjs +8 -9
  22. package/dist/cli.d.ts +1 -1
  23. package/dist/cli.js +1 -0
  24. package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +11 -7
  25. package/dist/clients/attach/__tests__/crtr-output-render.test.js +1 -1
  26. package/dist/clients/attach/__tests__/mermaid-render.test.d.ts +1 -0
  27. package/dist/clients/attach/__tests__/mermaid-render.test.js +20 -0
  28. package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.d.ts +1 -0
  29. package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.js +110 -0
  30. package/dist/clients/attach/attach-cmd.d.ts +1 -1
  31. package/dist/clients/attach/attach-cmd.js +733 -730
  32. package/dist/clients/attach/auth-pickers.d.ts +12 -0
  33. package/dist/clients/attach/auth-pickers.js +11 -0
  34. package/dist/clients/attach/canvas-panels.js +2 -3
  35. package/dist/clients/attach/chat-view.d.ts +7 -8
  36. package/dist/clients/attach/chat-view.js +119 -79
  37. package/dist/clients/attach/edit-diff-render.d.ts +6 -0
  38. package/dist/clients/attach/edit-diff-render.js +263 -0
  39. package/dist/clients/attach/input-controller.d.ts +28 -2
  40. package/dist/clients/attach/input-controller.js +38 -5
  41. package/dist/clients/attach/mermaid-render.js +5 -2
  42. package/dist/clients/attach/pickers.d.ts +8 -7
  43. package/dist/clients/attach/pickers.js +13 -17
  44. package/dist/clients/attach/slash-commands.d.ts +9 -0
  45. package/dist/clients/attach/slash-commands.js +128 -7
  46. package/dist/clients/attach/titled-editor.d.ts +12 -1
  47. package/dist/clients/attach/titled-editor.js +103 -8
  48. package/dist/commands/canvas-browse.js +2 -2
  49. package/dist/commands/memory/lint.js +39 -5
  50. package/dist/commands/memory/write.js +1 -0
  51. package/dist/commands/node.js +12 -5
  52. package/dist/commands/profile/new.js +30 -5
  53. package/dist/commands/surface-tmux-spread.js +1 -3
  54. package/dist/commands/sys/__tests__/config-keybindings.test.d.ts +1 -0
  55. package/dist/commands/sys/__tests__/config-keybindings.test.js +55 -0
  56. package/dist/commands/sys/__tests__/config-model-ladders.test.d.ts +1 -0
  57. package/dist/commands/sys/__tests__/config-model-ladders.test.js +121 -0
  58. package/dist/commands/sys/config.js +18 -21
  59. package/dist/commands/sys/setup-core.d.ts +12 -1
  60. package/dist/commands/sys/setup-core.js +25 -3
  61. package/dist/commands/sys/setup.js +166 -20
  62. package/dist/core/__tests__/base-worker-prompt.test.d.ts +1 -0
  63. package/dist/core/__tests__/base-worker-prompt.test.js +24 -0
  64. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +232 -1
  65. package/dist/core/__tests__/fault-classifier.test.js +15 -0
  66. package/dist/core/__tests__/full/broker-dialogs.test.js +7 -2
  67. package/dist/core/__tests__/full/broker-pane-resolution.test.js +9 -6
  68. package/dist/core/__tests__/full/cascade-close.test.js +5 -2
  69. package/dist/core/__tests__/full/dead-pane-regression.test.js +6 -3
  70. package/dist/core/__tests__/full/detach-focus.test.js +10 -5
  71. package/dist/core/__tests__/full/human-new-window-regression.test.js +6 -3
  72. package/dist/core/__tests__/full/review-render-pane-regression.test.js +2 -2
  73. package/dist/core/__tests__/helpers/harness.d.ts +1 -0
  74. package/dist/core/__tests__/helpers/harness.js +28 -4
  75. package/dist/core/__tests__/host-teardown-process-group.test.js +15 -4
  76. package/dist/core/__tests__/live-mutation-verbs.test.js +5 -4
  77. package/dist/core/__tests__/review-model-floor.test.js +1 -1
  78. package/dist/core/__tests__/stream-watchdog.test.d.ts +1 -0
  79. package/dist/core/__tests__/stream-watchdog.test.js +70 -0
  80. package/dist/core/__tests__/tmux-surface.test.js +72 -0
  81. package/dist/core/canvas/__tests__/remote-event-stream.test.js +11 -9
  82. package/dist/core/canvas/__tests__/render-remote.test.js +7 -4
  83. package/dist/core/canvas/browse/__tests__/model.test.js +23 -5
  84. package/dist/core/canvas/browse/__tests__/rebuild-coalescer.test.js +30 -13
  85. package/dist/core/canvas/browse/__tests__/render.test.js +1 -0
  86. package/dist/core/canvas/browse/app.js +164 -5
  87. package/dist/core/canvas/browse/model.d.ts +10 -6
  88. package/dist/core/canvas/browse/model.js +20 -12
  89. package/dist/core/canvas/browse/pins.d.ts +4 -0
  90. package/dist/core/canvas/browse/pins.js +29 -0
  91. package/dist/core/canvas/browse/render.d.ts +13 -1
  92. package/dist/core/canvas/browse/render.js +72 -15
  93. package/dist/core/canvas/nav-model.js +5 -11
  94. package/dist/core/canvas/paths.d.ts +5 -0
  95. package/dist/core/canvas/paths.js +7 -0
  96. package/dist/core/canvas/pid.d.ts +14 -10
  97. package/dist/core/canvas/pid.js +14 -10
  98. package/dist/core/config.d.ts +53 -2
  99. package/dist/core/config.js +228 -110
  100. package/dist/core/fault-classifier.js +1 -1
  101. package/dist/core/fs-utils.d.ts +1 -0
  102. package/dist/core/fs-utils.js +15 -1
  103. package/dist/core/keybindings/__tests__/resolve.test.d.ts +1 -0
  104. package/dist/core/keybindings/__tests__/resolve.test.js +224 -0
  105. package/dist/core/keybindings/catalog.d.ts +14 -0
  106. package/dist/core/keybindings/catalog.js +256 -0
  107. package/dist/core/keybindings/index.d.ts +5 -0
  108. package/dist/core/keybindings/index.js +4 -0
  109. package/dist/core/keybindings/match.d.ts +29 -0
  110. package/dist/core/keybindings/match.js +99 -0
  111. package/dist/core/keybindings/persistence.d.ts +14 -0
  112. package/dist/core/keybindings/persistence.js +31 -0
  113. package/dist/core/keybindings/resolve.d.ts +14 -0
  114. package/dist/core/keybindings/resolve.js +305 -0
  115. package/dist/core/keybindings/types.d.ts +37 -0
  116. package/dist/core/keybindings/types.js +1 -0
  117. package/dist/core/memory-resolver.d.ts +1 -1
  118. package/dist/core/memory-resolver.js +10 -3
  119. package/dist/core/predicate.d.ts +5 -3
  120. package/dist/core/predicate.js +5 -3
  121. package/dist/core/runtime/broker-protocol.d.ts +33 -5
  122. package/dist/core/runtime/broker.js +165 -14
  123. package/dist/core/runtime/launch.d.ts +39 -6
  124. package/dist/core/runtime/launch.js +79 -21
  125. package/dist/core/runtime/naming.js +3 -3
  126. package/dist/core/runtime/pi-cli.d.ts +6 -0
  127. package/dist/core/runtime/pi-cli.js +16 -2
  128. package/dist/core/runtime/placement.d.ts +2 -2
  129. package/dist/core/runtime/placement.js +4 -1
  130. package/dist/core/runtime/promote.js +4 -0
  131. package/dist/core/runtime/recap.d.ts +3 -3
  132. package/dist/core/runtime/recap.js +47 -44
  133. package/dist/core/runtime/recycle.js +6 -1
  134. package/dist/core/runtime/reset.js +5 -0
  135. package/dist/core/runtime/revive.js +28 -18
  136. package/dist/core/runtime/session-list-cache.d.ts +23 -0
  137. package/dist/core/runtime/session-list-cache.js +270 -0
  138. package/dist/core/runtime/spawn.js +51 -32
  139. package/dist/core/runtime/stream-watchdog.d.ts +26 -0
  140. package/dist/core/runtime/stream-watchdog.js +75 -0
  141. package/dist/core/runtime/tmux-chrome.d.ts +1 -1
  142. package/dist/core/runtime/tmux-chrome.js +2 -2
  143. package/dist/core/runtime/tmux.d.ts +18 -15
  144. package/dist/core/runtime/tmux.js +216 -110
  145. package/dist/core/tui/__tests__/host-keybindings.test.d.ts +1 -0
  146. package/dist/core/tui/__tests__/host-keybindings.test.js +112 -0
  147. package/dist/core/tui/host.d.ts +30 -1
  148. package/dist/core/tui/host.js +148 -34
  149. package/dist/core/view/__tests__/transport-remote.test.js +9 -6
  150. package/dist/core/view/contract.d.ts +17 -3
  151. package/dist/daemon/crtrd-cli.d.ts +1 -1
  152. package/dist/daemon/crtrd-cli.js +1 -0
  153. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +41 -0
  154. package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.d.ts +1 -0
  155. package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.js +121 -0
  156. package/dist/pi-extensions/canvas-inbox-watcher.d.ts +1 -1
  157. package/dist/pi-extensions/canvas-inbox-watcher.js +203 -25
  158. package/dist/pi-extensions/canvas-recap.d.ts +4 -0
  159. package/dist/pi-extensions/canvas-recap.js +38 -37
  160. package/dist/pi-extensions/canvas-stophook.d.ts +11 -0
  161. package/dist/pi-extensions/canvas-stophook.js +33 -4
  162. package/dist/suppress-experimental-warnings.d.ts +1 -0
  163. package/dist/suppress-experimental-warnings.js +15 -0
  164. package/dist/types.d.ts +3 -13
  165. package/dist/types.js +12 -36
  166. package/dist/web-client/assets/{index-CnF5r8ky.js → index--md2ylfi.js} +18 -17
  167. package/dist/web-client/assets/index-BUdm9s9s.css +2 -0
  168. package/dist/web-client/index.html +3 -3
  169. package/dist/web-client/sw.js +1 -1
  170. package/package.json +11 -8
  171. package/scripts/postinstall.mjs +1 -1
  172. package/dist/pi-extensions/canvas-nav.d.ts +0 -43
  173. package/dist/pi-extensions/canvas-nav.js +0 -640
  174. package/dist/pi-extensions/widget-order-bus.d.ts +0 -6
  175. package/dist/pi-extensions/widget-order-bus.js +0 -34
  176. package/dist/web-client/assets/index-BnmSLNLa.css +0 -2
@@ -37,6 +37,8 @@ export const CANVAS_SLASH_COMMANDS = [
37
37
  { name: 'resume-node', description: 'Open the canvas navigator (search/scope/sort/tree) and resume the chosen node' },
38
38
  { name: 'view', description: 'Open a view in a popup — bare for the picker, or /view <name> to open that view directly' },
39
39
  { name: 'context', description: 'Browse this node + its subscriptions’ context dirs in a read-only nvim popup' },
40
+ { name: 'rename', description: 'Rename this node everywhere — node row, viewer window, session chip, tmux pane: /rename <name>' },
41
+ { name: 'color', description: 'Color this node — tmux pane outline + name chip: /color <color> (red, blue, #rrggbb, …) or /color none' },
40
42
  ];
41
43
  const CANVAS_NAMES = new Set(CANVAS_SLASH_COMMANDS.map((c) => c.name));
42
44
  /** Native canvas commands that shell a LOCAL `crtr` mutation or read against
@@ -46,7 +48,15 @@ const CANVAS_NAMES = new Set(CANVAS_SLASH_COMMANDS.map((c) => c.name));
46
48
  * local node, `/context` opens local `crtr node inspect context`. `/graph`
47
49
  * (the in-process GRAPH overlay, already remote-safe — see graph-overlay.ts)
48
50
  * and `/view` (a self-contained popup unrelated to `nodeId`) stay available. */
49
- const REMOTE_UNSAFE_CANVAS_NAMES = new Set(['promote', 'resume-node', 'context']);
51
+ const REMOTE_UNSAFE_CANVAS_NAMES = new Set(['promote', 'resume-node', 'context', 'rename']);
52
+ /** tmux named colours accepted by `/color` (plus `colourN`/`#rrggbb`, validated
53
+ * by pattern). Mirrors tmux's colour-name table so a typo is rejected with the
54
+ * options rather than silently ignored by tmux. */
55
+ const TMUX_NAMED_COLORS = new Set([
56
+ 'black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white',
57
+ 'brightred', 'brightgreen', 'brightyellow', 'brightblue', 'brightmagenta', 'brightcyan',
58
+ 'orange', 'grey', 'gray', 'default',
59
+ ]);
50
60
  /** Builtins with no Phase-4 engine method — scoped out (review m2). */
51
61
  const SCOPED_OUT = new Set(['trust']);
52
62
  /** Valid `/settings thinking` levels — the `SetThinkingLevelFrame['level']`
@@ -62,6 +72,7 @@ const THINKING_LEVELS = new Set(Object.keys({
62
72
  medium: true,
63
73
  high: true,
64
74
  xhigh: true,
75
+ max: true,
65
76
  }));
66
77
  const BUILTIN_NAMES = new Set(BUILTIN_SLASH_COMMANDS.map((c) => c.name));
67
78
  /** Builtin slash commands safe to run against a REMOTE attach (`--canvas`) —
@@ -152,6 +163,7 @@ export function dispatchSlashCommand(text, ctx) {
152
163
  if (!arg)
153
164
  return openOrHint(ctx.openModelPicker, ctx, 'model', '/model <provider/id>');
154
165
  ctx.send({ type: 'set_model', model: arg });
166
+ ctx.onExplicitModelSet?.(arg);
155
167
  return true;
156
168
  case 'resume':
157
169
  if (!arg)
@@ -231,7 +243,7 @@ export function dispatchSlashCommand(text, ctx) {
231
243
  }
232
244
  /** Append the native canvas commands to a command list, skipping any already
233
245
  * present (so they survive whatever list Unit Q feeds in — builtins, or the
234
- * broker's `get_commands` result). The four canvas commands are viewer-owned,
246
+ * broker's `get_commands` result). The canvas commands are viewer-owned,
235
247
  * so they are always surfaced in autocomplete regardless of the source list —
236
248
  * EXCEPT `REMOTE_UNSAFE_CANVAS_NAMES` when `remote`, which are structurally
237
249
  * omitted rather than offered-then-blocked, so a remote attach's autocomplete
@@ -255,7 +267,7 @@ export function commandAutocompleteItems(commands = BUILTIN_SLASH_COMMANDS, remo
255
267
  // ---------------------------------------------------------------------------
256
268
  // Canvas commands — native reimplementations of the canvas chrome extensions.
257
269
  // ---------------------------------------------------------------------------
258
- /** Dispatch one of the four native canvas commands. Always returns `true` (the
270
+ /** Dispatch one of the native canvas commands. Always returns `true` (the
259
271
  * command is viewer-owned — never forwarded to the engine as a prompt). */
260
272
  function dispatchCanvasCommand(name, arg, ctx) {
261
273
  // Structural omission (withCanvasCommands) keeps these out of remote-mode
@@ -268,10 +280,8 @@ function dispatchCanvasCommand(name, arg, ctx) {
268
280
  }
269
281
  switch (name) {
270
282
  case 'graph':
271
- // `/graph` is inherently the in-process GRAPH overlay (canvas-nav.ts
272
- // `toggleGraph`), NOT a tmux popup. The overlay is Unit C/Q's surface; this
273
- // unit registers the command + autocomplete entry and triggers the overlay
274
- // via the `onGraph` hook the integration unit wires in.
283
+ // `/graph` opens the attach viewer's in-process GRAPH overlay, not a tmux
284
+ // popup. Dispatch reaches it through the viewer integration's `onGraph` hook.
275
285
  if (ctx.onGraph)
276
286
  ctx.onGraph();
277
287
  else
@@ -292,6 +302,11 @@ function dispatchCanvasCommand(name, arg, ctx) {
292
302
  // A read-only nvim explorer over this node's + its subscriptions' context
293
303
  // dirs, as a self-contained popup.
294
304
  return openContext(ctx);
305
+ case 'rename':
306
+ renameNode(arg, ctx);
307
+ return true;
308
+ case 'color':
309
+ return colorNode(arg, ctx);
295
310
  default:
296
311
  ctx.notify(`/${name} is not available in attach`);
297
312
  return true;
@@ -350,6 +365,112 @@ function promoteNode(arg, ctx) {
350
365
  });
351
366
  });
352
367
  }
368
+ /** `/rename <name>` — rename this node EVERYWHERE the name shows (à la Claude
369
+ * Code's rename): (1) the pi session name, which is what the editor's title
370
+ * chip ("bubble") paints — sent as a `set_session_name` frame so the chip
371
+ * updates on the session_info_changed round-trip; (2) the tmux pane title
372
+ * (visible with pane-border-status / terminal titles); (3) the canvas node row
373
+ * via `crtr node config --name`, which also renames the live viewer window. */
374
+ function renameNode(arg, ctx) {
375
+ const nodeId = (ctx.nodeId ?? process.env['CRTR_NODE_ID'] ?? '').trim();
376
+ if (nodeId === '') {
377
+ ctx.notify('/rename: no node to rename (viewer has no node id)');
378
+ return;
379
+ }
380
+ if (arg === '') {
381
+ ctx.notify('Usage: /rename <name>');
382
+ return;
383
+ }
384
+ // 1. Session name → the editor title chip repaints when the state echoes back.
385
+ ctx.send({ type: 'set_session_name', name: arg });
386
+ // 2. tmux pane title (best-effort; no-op outside tmux).
387
+ const pane = process.env['TMUX_PANE'];
388
+ if (process.env['TMUX'] !== undefined && pane !== undefined && pane !== '') {
389
+ execFile('tmux', ['select-pane', '-t', pane, '-T', arg], () => {
390
+ /* best-effort */
391
+ });
392
+ }
393
+ // 3. Canvas node row + live viewer window.
394
+ execFile('crtr', ['node', 'config', '--node', nodeId, '--name', arg, '--json'], { timeout: 15_000, maxBuffer: 4 * 1024 * 1024 }, (err, stdout) => {
395
+ if (err) {
396
+ // On a non-zero exit crtr still prints the structured error to stdout.
397
+ let msg = null;
398
+ try {
399
+ const payload = JSON.parse(stdout);
400
+ msg = typeof payload.message === 'string' ? payload.message : null;
401
+ }
402
+ catch {
403
+ msg = null;
404
+ }
405
+ ctx.notify(`rename failed: ${msg ?? err.message}`);
406
+ return;
407
+ }
408
+ ctx.notify(`Renamed node → ${arg}`);
409
+ });
410
+ }
411
+ /** Promisified one-shot tmux call — resolves stdout ('' on error; best-effort). */
412
+ function tmuxCall(args) {
413
+ return new Promise((resolve) => {
414
+ execFile('tmux', args, { timeout: 5_000 }, (err, stdout) => resolve(err ? '' : stdout));
415
+ });
416
+ }
417
+ /** Paint (or clear) THIS pane's border color. `pane-border-style` is a WINDOW
418
+ * option — tmux silently hoists a `set-option -p` to window scope, painting
419
+ * every pane — so per-pane color rides a per-pane user option instead: the
420
+ * color lands in `@crtr_color` on this pane only, and the window's border
421
+ * styles are set ONCE to a format tmux expands per pane (`fg=#{@crtr_color}`
422
+ * when set, else the style that was in effect before — baked in at install
423
+ * time so uncolored panes keep the user's theme). Clearing just unsets
424
+ * `@crtr_color`; the format's fallback branch restores the original look. */
425
+ async function paintPaneBorder(pane, value, clear) {
426
+ if (clear) {
427
+ await tmuxCall(['set-option', '-p', '-u', '-t', pane, '@crtr_color']);
428
+ return;
429
+ }
430
+ await tmuxCall(['set-option', '-p', '-t', pane, '@crtr_color', value]);
431
+ for (const opt of ['pane-border-style', 'pane-active-border-style']) {
432
+ const cur = (await tmuxCall(['show-options', '-wqv', '-t', pane, opt])).trim();
433
+ if (cur.includes('@crtr_color'))
434
+ continue; // our format is already installed on this window
435
+ // Bake the pre-existing effective style into the fallback branch (window
436
+ // value, else global, else tmux's defaults) — commas escaped so they don't
437
+ // split the #{?,,} conditional.
438
+ let base = cur !== '' ? cur : (await tmuxCall(['show-options', '-gqv', opt])).trim();
439
+ if (base === '')
440
+ base = opt === 'pane-active-border-style' ? 'fg=green' : 'default';
441
+ const fmt = `#{?#{!=:#{@crtr_color},},fg=#{@crtr_color},${base.replace(/,/g, '#,')}}`;
442
+ await tmuxCall(['set-option', '-w', '-t', pane, opt, fmt]);
443
+ }
444
+ }
445
+ /** `/color <color>` — color this node's two identity surfaces in one stroke:
446
+ * the tmux PANE OUTLINE (via `paintPaneBorder` above — per-pane, not the whole
447
+ * window) and the editor's NAME CHIP (the bubble `/rename` names), via the
448
+ * `onColor` hook attach-cmd wires. Ephemeral by design: the border rides the
449
+ * tmux pane and the chip lives in this viewer process. `/color none` clears
450
+ * both. Remote-SAFE: both surfaces are local to this viewer. */
451
+ function colorNode(arg, ctx) {
452
+ const value = arg.trim().toLowerCase();
453
+ if (value === '') {
454
+ ctx.notify('Usage: /color <color> — a name (red, blue, orange…), colourN, or #rrggbb; /color none to clear');
455
+ return true;
456
+ }
457
+ const clear = value === 'none' || value === 'clear';
458
+ const valid = clear || TMUX_NAMED_COLORS.has(value) || /^colour\d{1,3}$/.test(value) || /^#[0-9a-f]{6}$/.test(value);
459
+ if (!valid) {
460
+ ctx.notify(`Invalid color "${value}" — use a tmux colour name (${['red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'orange'].join(', ')}, …), colourN, or #rrggbb`);
461
+ return true;
462
+ }
463
+ // Pane outline — per-pane @crtr_color + a per-pane-expanded window format,
464
+ // so only THIS node's pane recolors (fire-and-forget; best-effort).
465
+ const pane = process.env['TMUX_PANE'];
466
+ const inTmux = process.env['TMUX'] !== undefined && pane !== undefined && pane !== '';
467
+ if (inTmux)
468
+ void paintPaneBorder(pane, value, clear);
469
+ // Name chip — the viewer owns the editor, so this rides the onColor hook.
470
+ ctx.onColor?.(clear ? null : value);
471
+ ctx.notify(clear ? 'Node color cleared' : `Node colored ${value}${inTmux ? '' : ' (chip only — no tmux pane)'}`);
472
+ return true;
473
+ }
353
474
  /** `/resume-node`. Opens `crtr canvas browse` as a tmux popup,
354
475
  * scoped to this node's cwd; on Enter it focuses the chosen node back into this
355
476
  * pane via `crtr node focus --pane` (which `canvas browse` shells). */
@@ -1,7 +1,12 @@
1
1
  import { CustomEditor } from '@earendil-works/pi-coding-agent';
2
2
  import { type EditorOptions, type EditorTheme, type TUI } from '@earendil-works/pi-tui';
3
3
  /** Thinking levels pi cycles through (shift+tab), lowest → highest budget. */
4
- export type ThinkingLevel = 'off' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
4
+ export type ThinkingLevel = 'off' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max';
5
+ /** Build a title-chip styler for a `/color` token (a tmux colour name, `#rrggbb`,
6
+ * or `colourN`): the colour as the chip BACKGROUND with contrast-picked bold
7
+ * text, matching the thinking-chip treatment. `undefined` for an unrenderable
8
+ * token (e.g. `default`) — the caller keeps its fallback style. */
9
+ export declare function colorChipStyle(token: string): ((s: string) => string) | undefined;
5
10
  /** The default (thinking `off`) title chip: reverse video + a space of padding
6
11
  * each side, so the name reads as a label sitting on the border rule. Used as
7
12
  * the fallback when no thinking color applies. */
@@ -20,6 +25,7 @@ export declare function thinkingTitleStyle(level: string | undefined, fallback:
20
25
  * and truncates when it only partially fits. Exported pure for the overflow
21
26
  * regression test. */
22
27
  export declare function composeTopBorder(width: number, title: string, info: string, titleStyle: (s: string) => string, borderColor: (s: string) => string): string;
28
+ export declare function outlineCursorLine(line: string): string | undefined;
23
29
  export declare class TitledEditor extends CustomEditor {
24
30
  /** crtr's OWN keybindings manager — the same one CustomEditor matches `app.*`
25
31
  * against. We keep a reference so `handleInput` can resolve the newline chord
@@ -51,5 +57,10 @@ export declare class TitledEditor extends CustomEditor {
51
57
  titleStyle: (s: string) => string;
52
58
  /** Mode badge colorizer, matched to the current mode type. */
53
59
  modeStyle: (s: string) => string;
60
+ /** Whether the hosting tmux pane / terminal window has FOCUS (terminal focus
61
+ * reporting, DECSET 1004 — attach-cmd flips this on `\x1b[I` / `\x1b[O`).
62
+ * Distinct from TUI component focus: the editor keeps component focus while
63
+ * its pane is blurred. False → the fake cursor renders as an outline. */
64
+ paneFocused: boolean;
54
65
  render(width: number): string[];
55
66
  }
@@ -6,16 +6,16 @@
6
6
  // on each state update), mirroring pi's `getThinkingBorderColor`.
7
7
  // Both are pure render-layer chrome; nothing here touches the socket or session.
8
8
  import { CustomEditor } from '@earendil-works/pi-coding-agent';
9
- import { truncateToWidth, visibleWidth, } from '@earendil-works/pi-tui';
10
- /** The thinking color scale is a deliberate cool→warm ramp between a blue and
11
- * its orange complement, evenly spaced across the five active levels so each
12
- * step stays readable without jumping through unrelated hues. `off` falls
13
- * back to the caller's default border color. The endpoints are deliberately
14
- * deep (not bright) hues: the same colors double as the title-chip BACKGROUND
15
- * under bold white text, so every step must clear ~4.5:1 white-on-color
16
- * contrast — a lighter blue/orange washes the label out. */
9
+ import { CURSOR_MARKER, truncateToWidth, visibleWidth, } from '@earendil-works/pi-tui';
10
+ /** The standard thinking levels form a deliberate cool→warm ramp between blue
11
+ * and its orange complement. `max` is a distinct, intense cyan endpoint so it
12
+ * never falls back to the neutral default border. `off` alone falls back to
13
+ * the caller's default border color. The colors double as title-chip
14
+ * BACKGROUNDS under bold white text, so each must remain dark enough for the
15
+ * label to read clearly. */
17
16
  const THINKING_START = [46, 92, 190]; // deep blue
18
17
  const THINKING_END = [166, 98, 24]; // deep amber
18
+ const THINKING_MAX = [0, 145, 170]; // intense cyan
19
19
  function interpolateRgb(start, end, t) {
20
20
  return [
21
21
  Math.round(start[0] + (end[0] - start[0]) * t),
@@ -30,7 +30,55 @@ const THINKING_RGB = {
30
30
  medium: interpolateRgb(THINKING_START, THINKING_END, 2 / 4),
31
31
  high: interpolateRgb(THINKING_START, THINKING_END, 3 / 4),
32
32
  xhigh: interpolateRgb(THINKING_START, THINKING_END, 4 / 4),
33
+ max: THINKING_MAX,
33
34
  };
35
+ /** Named colours `/color` accepts, as RGB — mirrors the tmux colour-name table
36
+ * (so the chip and the pane border read as the same hue). */
37
+ const NAMED_RGB = {
38
+ black: [0, 0, 0],
39
+ red: [205, 49, 49],
40
+ green: [13, 188, 121],
41
+ yellow: [229, 229, 16],
42
+ blue: [36, 114, 200],
43
+ magenta: [188, 63, 188],
44
+ cyan: [17, 168, 205],
45
+ white: [229, 229, 229],
46
+ orange: [228, 152, 68],
47
+ grey: [128, 128, 128],
48
+ gray: [128, 128, 128],
49
+ brightred: [241, 76, 76],
50
+ brightgreen: [35, 209, 139],
51
+ brightyellow: [245, 245, 67],
52
+ brightblue: [59, 142, 234],
53
+ brightmagenta: [214, 112, 214],
54
+ brightcyan: [41, 184, 219],
55
+ };
56
+ /** Build a title-chip styler for a `/color` token (a tmux colour name, `#rrggbb`,
57
+ * or `colourN`): the colour as the chip BACKGROUND with contrast-picked bold
58
+ * text, matching the thinking-chip treatment. `undefined` for an unrenderable
59
+ * token (e.g. `default`) — the caller keeps its fallback style. */
60
+ export function colorChipStyle(token) {
61
+ const t = token.trim().toLowerCase();
62
+ const c256 = /^colour(\d{1,3})$/.exec(t);
63
+ if (c256 !== null)
64
+ return (s) => `\x1b[48;5;${c256[1]}m\x1b[97m\x1b[1m ${s} \x1b[0m`;
65
+ let rgb;
66
+ const hex = /^#([0-9a-f]{6})$/.exec(t);
67
+ if (hex !== null) {
68
+ const h = hex[1];
69
+ rgb = [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
70
+ }
71
+ else {
72
+ rgb = NAMED_RGB[t];
73
+ }
74
+ if (rgb === undefined)
75
+ return undefined;
76
+ const [r, g, b] = rgb;
77
+ // Pick black-on-light / white-on-dark so the name stays readable on any hue.
78
+ const luma = 0.299 * r + 0.587 * g + 0.114 * b;
79
+ const fg = luma > 160 ? '\x1b[30m' : '\x1b[97m';
80
+ return (s) => `\x1b[48;2;${r};${g};${b}m${fg}\x1b[1m ${s} \x1b[0m`;
81
+ }
34
82
  /** The default (thinking `off`) title chip: reverse video + a space of padding
35
83
  * each side, so the name reads as a label sitting on the border rule. Used as
36
84
  * the fallback when no thinking color applies. */
@@ -71,6 +119,39 @@ export function composeTopBorder(width, title, info, titleStyle, borderColor) {
71
119
  const fill = Math.max(0, width - chipW - visibleWidth(infoChip));
72
120
  return chip + borderColor('─'.repeat(fill)) + infoChip;
73
121
  }
122
+ /** The stock pi-tui editor paints its fake cursor as a solid reverse-video
123
+ * block (`\x1b[7m…\x1b[0m`) unconditionally — in a wall of attach panes every
124
+ * pane shows an identical solid white cursor, and nothing tells you which one
125
+ * keys actually go to. When the hosting pane/terminal is UNFOCUSED we rewrite
126
+ * that cell into a MUTED cursor, mirroring how real terminals de-emphasize
127
+ * the hardware cursor on blur. A pixel-true full-cell outline isn't possible
128
+ * in text cells, and hollow-box glyphs (▯/⍘/□) render undersized, so the
129
+ * muted form is a full-size block in a dim gray background — same footprint
130
+ * as the focused cursor, clearly not the bright active one (ruling: tried the
131
+ * glyphs, Silas preferred the dim block). The rewrite anchors on pi-tui's
132
+ * zero-width CURSOR_MARKER (emitted right before the fake cursor while the
133
+ * editor component has TUI focus) so it can never touch reverse video
134
+ * elsewhere in the line. Returns undefined when the line carries no cursor.
135
+ * Exported pure for tests. */
136
+ const SOLID_CURSOR_PREFIX = '\x1b[7m';
137
+ const SOLID_CURSOR_RESET = '\x1b[0m';
138
+ /** Dim gray bg, default fg — the char under the cursor stays readable. */
139
+ const MUTED_CURSOR_ON = '\x1b[48;5;240m';
140
+ export function outlineCursorLine(line) {
141
+ const m = line.indexOf(CURSOR_MARKER);
142
+ if (m === -1)
143
+ return undefined;
144
+ const start = m + CURSOR_MARKER.length;
145
+ if (!line.startsWith(SOLID_CURSOR_PREFIX, start))
146
+ return undefined;
147
+ const gStart = start + SOLID_CURSOR_PREFIX.length;
148
+ const end = line.indexOf(SOLID_CURSOR_RESET, gStart);
149
+ if (end === -1)
150
+ return undefined;
151
+ const grapheme = line.slice(gStart, end);
152
+ const replacement = `${MUTED_CURSOR_ON}${grapheme}\x1b[0m`;
153
+ return line.slice(0, start) + replacement + line.slice(end + SOLID_CURSOR_RESET.length);
154
+ }
74
155
  export class TitledEditor extends CustomEditor {
75
156
  /** crtr's OWN keybindings manager — the same one CustomEditor matches `app.*`
76
157
  * against. We keep a reference so `handleInput` can resolve the newline chord
@@ -114,8 +195,22 @@ export class TitledEditor extends CustomEditor {
114
195
  titleStyle = defaultTitleStyle;
115
196
  /** Mode badge colorizer, matched to the current mode type. */
116
197
  modeStyle = defaultTitleStyle;
198
+ /** Whether the hosting tmux pane / terminal window has FOCUS (terminal focus
199
+ * reporting, DECSET 1004 — attach-cmd flips this on `\x1b[I` / `\x1b[O`).
200
+ * Distinct from TUI component focus: the editor keeps component focus while
201
+ * its pane is blurred. False → the fake cursor renders as an outline. */
202
+ paneFocused = true;
117
203
  render(width) {
118
204
  const lines = super.render(width);
205
+ if (!this.paneFocused) {
206
+ for (let i = 0; i < lines.length; i++) {
207
+ const hollowed = outlineCursorLine(lines[i]);
208
+ if (hollowed !== undefined) {
209
+ lines[i] = hollowed;
210
+ break;
211
+ }
212
+ }
213
+ }
119
214
  if ((this.title || this.info) && lines.length > 0) {
120
215
  // Replace the stock top border; dashes in the current (thinking-aware)
121
216
  // border color.
@@ -1,6 +1,6 @@
1
1
  // `crtr canvas browse` — the interactive full-screen canvas navigator.
2
2
  //
3
- // A raw-mode TUI over the WHOLE canvas: tabs (All/Live/Dormant/Flagged), an
3
+ // A raw-mode TUI over the WHOLE canvas: tabs (All/Live/Dormant/Attention/Pinned), an
4
4
  // auto-collapsed tree, and `/` fuzzy search. Enter resumes the chosen node via
5
5
  // `crtr node focus` (the ONLY sanctioned open — reviveNode, never `pi --session`).
6
6
  // Owns the screen, so it returns void and writes nothing to stdout itself.
@@ -14,7 +14,7 @@ import { readState } from '../core/config.js';
14
14
  export const browseLeaf = defineLeaf({
15
15
  name: 'browse',
16
16
  description: 'open the interactive canvas navigator (tabs/tree/search)',
17
- whenToUse: 'you want to VIEW and NAVIGATE the whole canvas interactively — a full-screen TUI with tabs (All/Live/Dormant/Flagged), an expandable tree (children auto-collapsed; → to expand), and `/` fuzzy search that auto-expands ancestors of matches; Enter resumes the chosen node. Use this to find your way around a large canvas. Use `canvas dashboard` instead for a one-shot ASCII tree you can pipe, and `node inspect list` for a flat machine-readable roster',
17
+ whenToUse: 'you want to VIEW and NAVIGATE the whole canvas interactively — a full-screen TUI with tabs (All/Live/Dormant/Attention/Pinned), an expandable tree (children auto-collapsed; → to expand), `/` fuzzy search, `f` to pin, `m` to message and `R` to rename a node in place, and `?` for the full keymap; Enter resumes the chosen node. Use this to find your way around a large canvas. Use `canvas dashboard` instead for a one-shot ASCII tree you can pipe, and `node inspect list` for a flat machine-readable roster',
18
18
  help: {
19
19
  name: 'canvas browse',
20
20
  summary: 'interactive full-screen canvas navigator — tabs, an auto-collapsed tree, and `/` fuzzy search; Enter resumes the chosen node via `crtr node focus`. Outside a TTY it prints the static forest and exits',
@@ -11,6 +11,8 @@ import { pathExists, readText, walkFiles } from '../../core/fs-utils.js';
11
11
  import { parseFrontmatterGeneric } from '../../core/frontmatter.js';
12
12
  import { listInstalledPlugins, listInstalledPluginsInRoot } from '../../core/resolver.js';
13
13
  import { pluginMemoryDir, projectScopeRoots, scopeMemoryDir } from '../../core/scope.js';
14
+ import { loadProfileManifest, profileMemoryDir } from '../../core/profiles/manifest.js';
15
+ import { getDefaultProfileId } from '../../core/profiles/default-binding.js';
14
16
  import { isDocKind, RUNGS } from '../../core/substrate/index.js';
15
17
  /** The parser normalizes the `always` alias to `content`, so it lints valid. */
16
18
  const VALID_RUNGS = [...RUNGS, 'always'];
@@ -101,14 +103,14 @@ function lintFile(file, substrateStore, findings) {
101
103
  export const lintLeaf = defineLeaf({
102
104
  name: 'lint',
103
105
  description: 'validate frontmatter across the whole bounded document corpus',
104
- whenToUse: 'you authored or migrated documents and want the authoring-time gate: strict-parse every doc in the bounded corpus (the substrate memory stores) and fail loudly on any invalid YAML or substrate schema violation. Run it before shipping doc changes; CI-friendly (non-zero exit on any finding).',
106
+ whenToUse: 'you authored or migrated documents and want the authoring-time gate: strict-parse every doc in the bounded corpus (the substrate memory stores) and fail loudly on any invalid YAML or substrate schema violation; also flags any dir the selected profile manages that lacks an AGENTS.md operating guide. Run it before shipping doc changes; CI-friendly (non-zero exit on any finding).',
105
107
  help: {
106
108
  name: 'memory lint',
107
109
  summary: 'strict-parse frontmatter across the bounded corpus; non-zero exit on any finding',
108
110
  params: [],
109
111
  output: [
110
112
  { name: 'checked', type: 'number', required: true, constraint: 'Files linted across all corpora.' },
111
- { name: 'corpora', type: 'object', required: true, constraint: 'Per-corpus file counts: {memory_stores}.' },
113
+ { name: 'corpora', type: 'object', required: true, constraint: 'Per-corpus counts: {memory_stores (files), profile_projects (managed dirs checked for AGENTS.md)}.' },
112
114
  { name: 'findings', type: 'object[]', required: true, constraint: 'One row per failure: {path, error}. Empty when green.' },
113
115
  ],
114
116
  outputKind: 'object',
@@ -121,10 +123,14 @@ export const lintLeaf = defineLeaf({
121
123
  // A source's corpus is its NATIVE memory dir plus each enabled plugin's memory dir —
122
124
  // plugin docs are substrate docs and lint through the same schema gate.
123
125
  // MEMORY.md index files are not substrate docs — YAML-parse only.
126
+ // Hidden dirs are skipped to mirror doc enumeration (a dot-segment
127
+ // path never yields a doc name, so e.g. the maintainer store shipped
128
+ // at builtin-memory/.crouter can never register) — lint must not
129
+ // flag files the substrate can never load.
124
130
  const lintDir = (dir) => {
125
131
  if (!dir || !pathExists(dir))
126
132
  return;
127
- for (const file of walkFiles(dir, (n) => n.endsWith('.md'))) {
133
+ for (const file of walkFiles(dir, (n) => n.endsWith('.md'), (d) => d.startsWith('.'))) {
128
134
  if (!relative(dir, file).split(sep).join('/'))
129
135
  continue;
130
136
  memoryCount += 1;
@@ -145,6 +151,34 @@ export const lintLeaf = defineLeaf({
145
151
  lintDir(pluginMemoryDir(plugin));
146
152
  }
147
153
  }
154
+ // Profile coverage: the selected profile's manifest names the dirs the
155
+ // operator manages. Lint the profile's own store, and require every
156
+ // managed project dir to carry an always-loaded operating guide
157
+ // (.crouter/memory/AGENTS.md) — a managed dir without one boots every
158
+ // agent there blind, so its absence is an authoring finding like any
159
+ // schema violation. (The managed dirs' stores themselves already lint
160
+ // above: projectScopeRoots folds the profile's project pointers in.)
161
+ let profileProjects = 0;
162
+ const profileIdOrName = process.env['CRTR_PROFILE_ID'] || getDefaultProfileId(process.cwd());
163
+ if (profileIdOrName) {
164
+ try {
165
+ const { profileId, manifest } = loadProfileManifest(profileIdOrName);
166
+ lintDir(profileMemoryDir(profileId));
167
+ for (const dir of manifest.projects) {
168
+ profileProjects += 1;
169
+ if (!pathExists(join(dir, '.crouter', 'memory', 'AGENTS.md'))) {
170
+ findings.push({
171
+ path: dir,
172
+ error: `profile "${manifest.name}" manages this dir but it has no .crouter/memory/AGENTS.md — author its always-loaded operating guide (name the doc AGENTS, target the dir with --dir; run \`crtr memory write -h\` first)`,
173
+ });
174
+ }
175
+ }
176
+ }
177
+ catch {
178
+ // Unresolvable profile (deleted/corrupt manifest): profile coverage
179
+ // just doesn't apply — the file corpus above already linted fully.
180
+ }
181
+ }
148
182
  const checked = memoryCount;
149
183
  if (findings.length > 0) {
150
184
  // Human/agent path renders only the message — surface every offender as
@@ -154,12 +188,12 @@ export const lintLeaf = defineLeaf({
154
188
  throw general(`memory lint: ${findings.length} finding(s) across ${checked} files`, {
155
189
  checked,
156
190
  findings: findings.map((f) => ({ path: f.path, error: f.error })),
157
- next: 'Fix each doc (quote YAML values containing `: `; use a valid kind/rung/gate), then re-run `crtr memory lint`.',
191
+ next: 'Fix each doc (quote YAML values containing `: `; use a valid kind/rung/gate); for a profile-managed dir missing AGENTS.md, author one — run `crtr memory write -h` — then re-run `crtr memory lint`.',
158
192
  });
159
193
  }
160
194
  return {
161
195
  checked,
162
- corpora: { memory_stores: memoryCount },
196
+ corpora: { memory_stores: memoryCount, profile_projects: profileProjects },
163
197
  findings: [],
164
198
  follow_up: 'Corpus green — zero invalid frontmatter docs.',
165
199
  };
@@ -15,6 +15,7 @@ export const writeLeaf = defineLeaf({
15
15
  'Store reusable current truth, not session notes. Useful memories are non-obvious procedures, gotchas, durable preferences, cross-repo conventions, and amendments to plans/specs that future agents must honor. Put plan/spec amendments under path names like `projects/<topic>/...`; add `projects/<topic>/INDEX.md` with `name` visibility so the topic is discoverable without loading the whole body. Do not store chat summaries, implementation history, or facts already recorded in the repo.\n\n' +
16
16
  'Set both visibility rungs explicitly on create. There is no kind default. `none`, `name`, `preview`, and `content` move from least to most loaded: `none` keeps the doc out of auto-load and on-read surfaces, `name` is the bare doc tag only, `preview` is the name + envelope + routing line (`when-and-why-to-read`), and `content` inlines the whole body when the body is short enough to justify it. Each axis is independent; usually one carries a real rung and the other is `none`. When a doc is short enough to state in a single sentence — a one-line preference, or a knowledge fact that fits in a sentence — skip `preview` and use `name` or `content` directly: the routing line would run longer than the doc itself, so a `preview` rung just adds words. Sentence-length `content` docs are not just acceptable, they are correct. Never pad a memory to be more verbose than the rule or fact it carries.\n\n' +
17
17
  'Choose the scope. `project` is for facts any agent in one repo needs. `user` is for person-wide facts and preferences that should follow the user everywhere. `profile` is for the profile’s bundle of dirs: cross-repo conventions, how the pieces relate, or the user’s stance toward that body of work. `node` is the narrowest — the this-node store (`nodes/<CRTR_NODE_ID>/context/memory/`), scratch memory only this running node sees; it rides straight into this node’s knowledge block and dies with the node. When unsure, choose the narrowest scope that will still reach the next agent who needs it.\n\n' +
18
+ 'AGENTS.md is the one special doc: a store’s always-loaded operating guide, and every dir a profile manages should carry one (`crtr memory lint` flags the gaps). Name the doc AGENTS, target the exact dir with --dir, and set system-prompt-visibility to `content` so every agent working there boots with it. It is a front door, not a manual — constraints and gotchas, the key commands, a few sentences of architecture orientation, and only the conventions that differ from defaults. Every line is paid by every session in that dir forever: prefer a pointer (“when X, run Y -h / read Z, because W”) over inlined how-to, and cut any line that would not change an agent’s behavior.\n\n' +
18
19
  'Choose the hook — boot vs file-read. Behavior and how-to procedure surface at boot. Knowledge about code belongs next to the code: put the file in that directory’s .crouter/memory/ (`--dir <project-dir>` targets it exactly, from anywhere) and it fires positionally when files there are read, costing nothing at boot. A knowledge doc about a person or process has no code directory to anchor to, so on-read triggering is meaningless — route it through boot instead and set the boot rung to `preview` when you want the routing line to surface.\n\n' +
19
20
  'Write the routing line (--when-and-why-to-read) first, before storing anything: "When <circumstance>, this <kind> should be read because <payoff>." The test is whether a stranger mid-task can decide from that one line alone whether to spend the read. If you cannot name the concrete situation that triggers it, you do not yet understand the memory — ask the user one sharp question instead of improvising. Never paraphrase the advice in the routing line; keep it about when to read and why the read is worth it.\n\n' +
20
21
  'Gate and read-when share the same predicate language: a field map is AND-ed across fields; dotted fields resolve nested values; field matchers may be scalar, array, or object. Scalar matchers do exact equality, with arrays matching any element. Array matchers do membership or intersection. Object matchers accept `eq`, `ne`, `in`, `nin`, `exists`, `contains`, `containsAll`, `containsAny`, `matches`, `imatches`, `gt`, `gte`, `lt`, and `lte`. Combinators are `all`, `any`, and `not`; sibling field matchers next to them are AND-ed in. An empty condition is inert. An unknown op never matches.\n\n' +
@@ -137,7 +137,7 @@ const nodeNew = defineLeaf({
137
137
  { kind: 'flag', name: 'root', type: 'bool', required: false, constraint: 'Spawn an INDEPENDENT root instead of a managed child: no parent (top-level on the canvas), NO subscription back to you (you are NOT woken by it), resident lifecycle. It records spawned_by=you for provenance and is brought forefront so it can be driven directly. Use for a node you hand off and do not manage (e.g. a sub-orchestrator a human will discuss with). Mutually exclusive with --worktree.' },
138
138
  { kind: 'flag', name: 'worktree', type: 'bool', required: false, constraint: 'Use when the child will COMMIT to the repo while you or other agents may also be editing it — the worktree isolates its work so nothing collides; skip it for read-only work or when the child is the sole writer. Creates a crouter-managed git worktree for the spawned child, keyed by the child node id, at `~/.crouter/canvas/worktrees/<node-id>/` on branch `crtr/<node-id>` based on local `main`; the child\'s cwd is pinned there, and it lands the work serially onto local main later with `crtr node worktree close`. Mutually exclusive with --root.' },
139
139
  { kind: 'flag', name: 'fork-from', type: 'string', required: false, constraint: 'FORK the new node from an existing pi conversation instead of starting it fresh: pass a node id (forks from that node\'s session), an absolute session `.jsonl` path, or a partial pi session uuid. pi copies that whole history into a NEW session for the child (the source is untouched), then the prompt is delivered as the next message — i.e. the child wakes up as a continuation of that conversation. Use to branch exploratory work off a node that already built up the context you need, instead of re-deriving it. One-shot at birth: the fork resumes its own session thereafter.' },
140
- { kind: 'flag', name: 'model', type: 'string', required: false, constraint: 'Override the model the node runs on: exact `provider/id` (e.g. openai-codex/gpt-5.5), a `provider/tier` (anthropic|openai + ultra|strong|medium|light, e.g. openai/ultra — pin a specific model family, useful for spawning cross-family children), a bare capability TIER (ultra|strong|medium|light), or a family alias (opus|sonnet|haiku). Must resolve to a concrete model OFFLINE — unlike `node config --model`, no live broker exists yet at spawn to free-text substring-search a registry. Omit to use the kind\'s persona default (advisor=ultra, explore=light, most other builtins=strong). The override is durable — it survives revives and polymorphs (promote/demote).' },
140
+ { kind: 'flag', name: 'model', type: 'string', required: false, constraint: 'Override the model the node runs on: a `provider/tier` (anthropic|openai + ultra|strong|medium|light, e.g. openai/ultra — pin a specific model family, useful for spawning cross-family children), a bare capability TIER (ultra|strong|medium|light), a family alias (opus|sonnet|haiku), or an exact `provider/id` for a concrete model. Must resolve to a concrete model OFFLINE — unlike `node config --model`, no live broker exists yet at spawn to free-text substring-search a registry. Omit to use the kind\'s persona default (advisor=ultra, explore=light, most other builtins=strong). The override is durable — it survives revives and polymorphs (promote/demote).' },
141
141
  { kind: 'flag', name: 'profile', type: 'string', required: false, constraint: 'Select the profile this node runs under — an exact profile id or a unique manifest name (see the <profiles> list below, or `crtr profile list`). Omit to INHERIT the calling node\'s current profile (which may be none, e.g. a historical null-profile parent). A shell/no-spawner root with no profile coverage instead runs the startup selector and can persist the stable root profile. --root does NOT reset this — it only means top-level, still inherits/uses the selected profile unless this overrides it.' },
142
142
  { kind: 'flag', name: 'situational-context', type: 'string', required: false, constraint: 'Hidden ambient context — current situation (e.g. which applet/page spawned this), NOT the visible prompt. Appended as a `<situational-context>` sibling block in the injected bearings, never as visible chat. Preserved unchanged into a deferred --at/--every/--when node_birth recipe.' },
143
143
  { kind: 'flag', name: 'output-schema', type: 'string', required: false, constraint: 'Path to a JSON Schema file, or inline JSON beginning with `{`. The spawned node gets a `submit` tool shaped by this schema, must call it to finish, and the submitted JSON is written to the final report and context/result.json. Rejected with trigger flags in this version.' },
@@ -188,7 +188,7 @@ const nodeNew = defineLeaf({
188
188
  throw new InputError({ error: 'empty_spec', message: 'a model spec is required', field: 'model', next: 'Pass a `provider/id`, a `provider/tier` (e.g. openai/ultra), a tier (ultra|strong|medium|light), or a family alias (opus|sonnet|haiku).' });
189
189
  }
190
190
  if (!normalizeModel(modelSpec).includes('/')) {
191
- throw new InputError({ error: 'unresolvable_model', message: `'${modelSpec}' does not resolve to a concrete model`, field: 'model', next: 'Pass an exact `provider/id` (e.g. openai-codex/gpt-5.5), a `provider/tier` (e.g. openai/ultra), a tier (ultra|strong|medium|light), or a family alias (opus|sonnet|haiku) — a live-broker substring search (like `node config --model`) is not available at spawn.' });
191
+ throw new InputError({ error: 'unresolvable_model', message: `'${modelSpec}' does not resolve to a concrete model`, field: 'model', next: 'Pass a `provider/tier` (e.g. openai/ultra), a tier (ultra|strong|medium|light), a family alias (opus|sonnet|haiku), or an exact `provider/id` — a live-broker substring search (like `node config --model`) is not available at spawn.' });
192
192
  }
193
193
  }
194
194
  const model = modelSpec;
@@ -869,7 +869,7 @@ const nodeConfig = defineLeaf({
869
869
  else {
870
870
  const normalized = normalizeModel(modelSpec);
871
871
  if (!normalized.includes('/')) {
872
- throw new InputError({ error: 'unresolvable_dormant', message: `'${modelSpec}' does not resolve to a concrete model for a dormant node`, field: 'model', next: 'Pass an exact `provider/id` (e.g. openai-codex/gpt-5.5) or a tier/alias — the live registry search is only available while the node\'s broker is up.' });
872
+ throw new InputError({ error: 'unresolvable_dormant', message: `'${modelSpec}' does not resolve to a concrete model for a dormant node`, field: 'model', next: 'Pass a `provider/tier` (e.g. openai/ultra) or a tier/alias — the live registry search is only available while the node\'s broker is up.' });
873
873
  }
874
874
  persistDormantModel(nodeId, normalized);
875
875
  resolved = normalized;
@@ -911,6 +911,10 @@ const nodeConfig = defineLeaf({
911
911
  lifecycle: newLifecycle,
912
912
  hasManager: meta.parent !== null,
913
913
  model: meta.model_override ?? undefined,
914
+ // The TARGET node's cwd + profile — profile-scope kind defaults must
915
+ // resolve for the node being reconfigured, not the calling process.
916
+ cwd: meta.cwd,
917
+ profileId: meta.profile_id,
914
918
  });
915
919
  meta = updateNode(nodeId, {
916
920
  ...(kindSpec !== undefined ? { kind: newKind } : {}),
@@ -1655,6 +1659,8 @@ function setLifecycle(id, value, opts) {
1655
1659
  lifecycle: value,
1656
1660
  hasManager: target.parent !== null,
1657
1661
  model: target.model_override ?? undefined,
1662
+ cwd: target.cwd,
1663
+ profileId: target.profile_id,
1658
1664
  });
1659
1665
  const meta = updateNode(id, { lifecycle: value, launch });
1660
1666
  // --detach: stop foregrounding the agent by closing its viewer pane (the
@@ -1723,6 +1729,7 @@ const nodeYield = defineLeaf({
1723
1729
  help: {
1724
1730
  name: 'node yield',
1725
1731
  summary: 'request a context refresh — you will be respawned fresh against your roadmap on your next stop (a base node auto-promotes to orchestrator first); optionally change your kind or model as you refresh',
1732
+ guide: 'A yield discards everything that lives only in this window, so externalize first: checkpoint context/roadmap.md, and if you are holding a question for the human you have not actually posed, post it via `crtr human ask` before yielding — an in-flight ask survives the refresh and its answer wakes your fresh revive — then note the pending ask in your yield message so the fresh window knows what the answer settles.',
1726
1733
  params: [
1727
1734
  { kind: 'flag', name: 'kind', type: 'string', required: false, constraint: 'Respecialize as you refresh — change your kind to this (developer, review, spec, design, plan, explore, general); a base node also auto-promotes into an orchestrator of this kind. Defaults to your current kind.' },
1728
1735
  { kind: 'flag', name: 'model', type: 'enum', choices: ['ultra', 'strong'], required: false, constraint: 'Change the model your fresh revive runs on, by capability tier: `ultra` (frontier — reserve for high-taste judgment or enormous work: speccing, planning something large, e2e-testing something hard to test) or `strong` (opus — regular dev work). Omit to keep your current model. A node steering work is never weaker than opus, so these are the only two choices; the change is durable across future revives.' },
@@ -1976,13 +1983,13 @@ export function registerNode() {
1976
1983
  rootEntry: {
1977
1984
  concept: 'a unit of the agent runtime — an agent on the canvas with its own context dir and pi vehicle. Spawning nodes is how work gets done here, not an optimization you reach for late',
1978
1985
  desc: 'spawn, inspect, and navigate nodes on the canvas',
1979
- useWhen: 'delegating work, or walking the subscription graph. CRITICAL, and your default reflex — spawning a child is the first move on almost any task, not a fallback. Your own context window is the scarce resource: before you read files into it to understand, implement, debug, or change code you do not already know, hand that to a fresh node and get back just the conclusion, matching the node kind to the work. Keep inline only a fact you already know. Fan out independent units as CONCURRENT children — a wake with idle workers is wasted; serialize only true dependencies, and never let two live children edit the same files. Once you delegate a unit, do not also run it yourself: you auto-subscribe on spawn, so its finish wakes you. Use the help surface for the exact node contract before invoking it.'
1986
+ useWhen: 'delegating a genuinely independent subtask, or walking the subscription graph. Base nodes work hands-on; orchestrators fan out. Give each child a bounded outcome distinct from your whole assignment so delegation moves work toward completion instead of recursively passing it down. Once delegated, do not duplicate the child’s work; its finish wakes you. Use the help surface for the exact node contract before invoking it.'
1980
1987
  },
1981
1988
  help: {
1982
1989
  name: 'node',
1983
1990
  summary: 'spawn and navigate canvas nodes',
1984
1991
  model: 'Every agent is a node in one directed graph (the canvas); `subscribes_to` is the spine — spawn a child and you auto-subscribe (active) to it, so its finish wakes you.\n\n' +
1985
- 'WHEN TO DELEGATE: hand any self-contained unit of work to a child instead of doing it inline it keeps your own context window (your scarce resource) free for steering. Spawn independent units in PARALLEL; a wake with idle workers is wasted. Serialize only true dependencies, and never let two live children edit the same files.\n\n' +
1992
+ 'WHEN TO DELEGATE: base nodes work hands-on and spawn only genuinely independent subtasks; orchestrators decompose and fan out. A child owns a bounded outcome distinct from the caller’s whole assignment. Spawn independent units in PARALLEL, serialize true dependencies, and never let two live children edit the same files.\n\n' +
1986
1993
  'HOW: the new leaf returns a node id immediately and runs the worker as a detached headless broker; no viewer opens unless a surface asks for one. Match the kind to the work and read the leaf schema before invoking. You are woken when a child finishes — the wake message ALREADY IS the coalesced digest, so dereference the report paths in that digest that matter instead of acting on a one-line label. Integrate, then either delegate the next units or finish.\n\n' +
1987
1994
  'FINISH: a worker ends its own work with a final push, which writes the canonical result, marks done, and closes the window; stopping without it is not finishing. For a job too big for one context window, promote to an orchestrator; when context fills, yield to refresh against that roadmap.',
1988
1995
  },