@crouton-kit/crouter 0.3.70 → 0.3.79

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 (193) hide show
  1. package/README.md +1 -1
  2. package/dist/build-root.d.ts +12 -4
  3. package/dist/build-root.js +25 -6
  4. package/dist/builtin-memory/00-runtime-base.md +3 -10
  5. package/dist/builtin-memory/04-base-worker.md +18 -0
  6. package/dist/builtin-memory/04-orchestration-kernel.md +1 -1
  7. package/dist/builtin-memory/crouter-development/plugins.md +82 -5
  8. package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +13 -34
  9. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +1115 -1
  10. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +234 -71
  11. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/statusline.ts +2 -9
  12. package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +2 -516
  13. package/dist/builtin-pi-packages/pi-crtr-extensions/package-lock.json +2 -2
  14. package/dist/builtin-pi-packages/pi-mode-switch/README.md +11 -39
  15. package/dist/builtin-pi-packages/pi-mode-switch/extensions/index.ts +20 -15
  16. package/dist/builtin-pi-packages/pi-mode-switch/package.json +1 -1
  17. package/dist/builtin-views/canvas/tui.mjs +8 -9
  18. package/dist/builtin-views/chat/tui.mjs +12 -12
  19. package/dist/builtin-views/git-pr/tui.mjs +7 -8
  20. package/dist/builtin-views/inbox/tui.mjs +27 -41
  21. package/dist/builtin-views/linkedin/tui.mjs +23 -37
  22. package/dist/builtin-views/prompt-review/tui.mjs +11 -11
  23. package/dist/builtin-views/settings/tui.mjs +11 -11
  24. package/dist/builtin-views/workspace-sidebar/tui.mjs +8 -9
  25. package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +11 -6
  26. package/dist/clients/attach/__tests__/attach-keybindings.test.d.ts +1 -0
  27. package/dist/clients/attach/__tests__/attach-keybindings.test.js +113 -0
  28. package/dist/clients/attach/__tests__/crtr-output-render.test.js +1 -1
  29. package/dist/clients/attach/__tests__/mermaid-render.test.d.ts +1 -0
  30. package/dist/clients/attach/__tests__/mermaid-render.test.js +28 -0
  31. package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.d.ts +1 -0
  32. package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.js +110 -0
  33. package/dist/clients/attach/attach-cmd.d.ts +9 -1
  34. package/dist/clients/attach/attach-cmd.js +841 -792
  35. package/dist/clients/attach/auth-pickers.d.ts +0 -12
  36. package/dist/clients/attach/auth-pickers.js +64 -15
  37. package/dist/clients/attach/canvas-panels.js +2 -3
  38. package/dist/clients/attach/chat-view.d.ts +7 -8
  39. package/dist/clients/attach/chat-view.js +119 -79
  40. package/dist/clients/attach/edit-diff-render.d.ts +6 -0
  41. package/dist/clients/attach/edit-diff-render.js +263 -0
  42. package/dist/clients/attach/graph-overlay.d.ts +12 -2
  43. package/dist/clients/attach/graph-overlay.js +83 -33
  44. package/dist/clients/attach/input-controller.d.ts +28 -2
  45. package/dist/clients/attach/input-controller.js +38 -5
  46. package/dist/clients/attach/mermaid-render.js +5 -2
  47. package/dist/clients/attach/pickers.d.ts +8 -7
  48. package/dist/clients/attach/pickers.js +11 -17
  49. package/dist/clients/attach/slash-commands.d.ts +9 -0
  50. package/dist/clients/attach/slash-commands.js +127 -7
  51. package/dist/clients/attach/titled-editor.d.ts +12 -1
  52. package/dist/clients/attach/titled-editor.js +103 -8
  53. package/dist/commands/canvas-browse.js +2 -2
  54. package/dist/commands/human/queue.js +3 -4
  55. package/dist/commands/memory/lint.js +39 -5
  56. package/dist/commands/memory/write.js +1 -0
  57. package/dist/commands/node.js +9 -2
  58. package/dist/commands/pkg/plugin-inspect.js +22 -1
  59. package/dist/commands/pkg/plugin-manage.js +31 -9
  60. package/dist/commands/surface-tmux-spread.js +1 -3
  61. package/dist/commands/sys/__tests__/config-keybindings.test.d.ts +1 -0
  62. package/dist/commands/sys/__tests__/config-keybindings.test.js +55 -0
  63. package/dist/commands/sys/__tests__/config-model-ladders.test.d.ts +1 -0
  64. package/dist/commands/sys/__tests__/config-model-ladders.test.js +121 -0
  65. package/dist/commands/sys/__tests__/setup-core.test.js +158 -1
  66. package/dist/commands/sys/config.js +18 -21
  67. package/dist/commands/sys/doctor.js +42 -4
  68. package/dist/commands/sys/setup-core.d.ts +49 -1
  69. package/dist/commands/sys/setup-core.js +161 -4
  70. package/dist/commands/sys/setup.d.ts +88 -0
  71. package/dist/commands/sys/setup.js +943 -169
  72. package/dist/commands/view-pick.d.ts +4 -0
  73. package/dist/commands/view-pick.js +17 -7
  74. package/dist/core/__tests__/base-worker-prompt.test.d.ts +1 -0
  75. package/dist/core/__tests__/base-worker-prompt.test.js +24 -0
  76. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +232 -1
  77. package/dist/core/__tests__/canvas-inbox-watcher.test.js +34 -9
  78. package/dist/core/__tests__/command-plugins-surfaces.test.d.ts +1 -0
  79. package/dist/core/__tests__/command-plugins-surfaces.test.js +298 -0
  80. package/dist/core/__tests__/command-plugins.test.d.ts +1 -0
  81. package/dist/core/__tests__/command-plugins.test.js +444 -0
  82. package/dist/core/__tests__/fault-classifier.test.js +15 -0
  83. package/dist/core/__tests__/fixtures/fake-engine.d.ts +6 -0
  84. package/dist/core/__tests__/fixtures/fake-engine.js +9 -1
  85. package/dist/core/__tests__/full/broker-dialogs.test.js +7 -2
  86. package/dist/core/__tests__/host-teardown-process-group.test.js +15 -4
  87. package/dist/core/__tests__/preview-registry-sync.test.js +30 -1
  88. package/dist/core/__tests__/scope-crouter-home-fence.test.d.ts +1 -0
  89. package/dist/core/__tests__/scope-crouter-home-fence.test.js +55 -0
  90. package/dist/core/__tests__/stream-watchdog.test.d.ts +1 -0
  91. package/dist/core/__tests__/stream-watchdog.test.js +70 -0
  92. package/dist/core/__tests__/tmux-surface.test.js +72 -0
  93. package/dist/core/canvas/browse/__tests__/model.test.js +23 -5
  94. package/dist/core/canvas/browse/__tests__/render.test.js +1 -0
  95. package/dist/core/canvas/browse/app.d.ts +6 -0
  96. package/dist/core/canvas/browse/app.js +230 -41
  97. package/dist/core/canvas/browse/model.d.ts +10 -6
  98. package/dist/core/canvas/browse/model.js +20 -12
  99. package/dist/core/canvas/browse/pins.d.ts +4 -0
  100. package/dist/core/canvas/browse/pins.js +29 -0
  101. package/dist/core/canvas/browse/render.d.ts +13 -1
  102. package/dist/core/canvas/browse/render.js +72 -15
  103. package/dist/core/canvas/nav-model.js +5 -11
  104. package/dist/core/canvas/paths.d.ts +5 -0
  105. package/dist/core/canvas/paths.js +7 -0
  106. package/dist/core/command-plugins/adapter.d.ts +15 -0
  107. package/dist/core/command-plugins/adapter.js +145 -0
  108. package/dist/core/command-plugins/compose.d.ts +5 -0
  109. package/dist/core/command-plugins/compose.js +56 -0
  110. package/dist/core/command-plugins/discovery.d.ts +104 -0
  111. package/dist/core/command-plugins/discovery.js +565 -0
  112. package/dist/core/config.d.ts +48 -0
  113. package/dist/core/config.js +228 -110
  114. package/dist/core/fault-classifier.js +1 -1
  115. package/dist/core/fs-utils.d.ts +1 -0
  116. package/dist/core/fs-utils.js +15 -1
  117. package/dist/core/keybindings/__tests__/bespoke-consumers.test.d.ts +1 -0
  118. package/dist/core/keybindings/__tests__/bespoke-consumers.test.js +40 -0
  119. package/dist/core/keybindings/__tests__/resolve.test.d.ts +1 -0
  120. package/dist/core/keybindings/__tests__/resolve.test.js +224 -0
  121. package/dist/core/keybindings/catalog.d.ts +14 -0
  122. package/dist/core/keybindings/catalog.js +257 -0
  123. package/dist/core/keybindings/index.d.ts +5 -0
  124. package/dist/core/keybindings/index.js +4 -0
  125. package/dist/core/keybindings/match.d.ts +29 -0
  126. package/dist/core/keybindings/match.js +99 -0
  127. package/dist/core/keybindings/persistence.d.ts +14 -0
  128. package/dist/core/keybindings/persistence.js +31 -0
  129. package/dist/core/keybindings/resolve.d.ts +14 -0
  130. package/dist/core/keybindings/resolve.js +305 -0
  131. package/dist/core/keybindings/types.d.ts +37 -0
  132. package/dist/core/keybindings/types.js +1 -0
  133. package/dist/core/memory-resolver.d.ts +1 -1
  134. package/dist/core/memory-resolver.js +10 -3
  135. package/dist/core/predicate.d.ts +5 -3
  136. package/dist/core/predicate.js +5 -3
  137. package/dist/core/profiles/select.d.ts +6 -0
  138. package/dist/core/profiles/select.js +86 -52
  139. package/dist/core/provider-management.d.ts +12 -0
  140. package/dist/core/provider-management.js +24 -0
  141. package/dist/core/runtime/broker-protocol.d.ts +33 -5
  142. package/dist/core/runtime/broker.js +164 -14
  143. package/dist/core/runtime/launch.d.ts +39 -6
  144. package/dist/core/runtime/launch.js +78 -20
  145. package/dist/core/runtime/naming.js +3 -3
  146. package/dist/core/runtime/pi-cli.d.ts +6 -0
  147. package/dist/core/runtime/pi-cli.js +16 -2
  148. package/dist/core/runtime/pi-vendored.d.ts +8 -0
  149. package/dist/core/runtime/pi-vendored.js +14 -0
  150. package/dist/core/runtime/placement.d.ts +2 -2
  151. package/dist/core/runtime/placement.js +4 -1
  152. package/dist/core/runtime/promote.js +4 -0
  153. package/dist/core/runtime/recap.d.ts +3 -3
  154. package/dist/core/runtime/recap.js +75 -47
  155. package/dist/core/runtime/recycle.js +6 -1
  156. package/dist/core/runtime/reset.js +5 -0
  157. package/dist/core/runtime/session-list-cache.d.ts +33 -0
  158. package/dist/core/runtime/session-list-cache.js +338 -0
  159. package/dist/core/runtime/spawn.js +52 -45
  160. package/dist/core/runtime/stream-watchdog.d.ts +26 -0
  161. package/dist/core/runtime/stream-watchdog.js +75 -0
  162. package/dist/core/runtime/tmux-chrome.d.ts +1 -1
  163. package/dist/core/runtime/tmux-chrome.js +2 -2
  164. package/dist/core/runtime/tmux.d.ts +18 -15
  165. package/dist/core/runtime/tmux.js +217 -110
  166. package/dist/core/scope.js +27 -4
  167. package/dist/core/subscription-state.d.ts +90 -0
  168. package/dist/core/subscription-state.js +762 -0
  169. package/dist/core/tui/__tests__/host-keybindings.test.d.ts +1 -0
  170. package/dist/core/tui/__tests__/host-keybindings.test.js +112 -0
  171. package/dist/core/tui/host.d.ts +30 -1
  172. package/dist/core/tui/host.js +148 -34
  173. package/dist/core/view/contract.d.ts +17 -3
  174. package/dist/daemon/crtrd.js +253 -12
  175. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +41 -0
  176. package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.d.ts +1 -0
  177. package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.js +121 -0
  178. package/dist/pi-extensions/canvas-inbox-watcher.d.ts +1 -1
  179. package/dist/pi-extensions/canvas-inbox-watcher.js +203 -25
  180. package/dist/pi-extensions/canvas-recap.d.ts +4 -0
  181. package/dist/pi-extensions/canvas-recap.js +73 -46
  182. package/dist/pi-extensions/canvas-stophook.d.ts +11 -0
  183. package/dist/pi-extensions/canvas-stophook.js +33 -4
  184. package/dist/types.d.ts +9 -26
  185. package/dist/types.js +1 -28
  186. package/dist/web-client/assets/{index-DiFuLcp6.js → index--md2ylfi.js} +1 -1
  187. package/dist/web-client/index.html +1 -1
  188. package/dist/web-client/sw.js +1 -1
  189. package/package.json +8 -4
  190. package/dist/pi-extensions/canvas-nav.d.ts +0 -43
  191. package/dist/pi-extensions/canvas-nav.js +0 -640
  192. package/dist/pi-extensions/widget-order-bus.d.ts +0 -6
  193. package/dist/pi-extensions/widget-order-bus.js +0 -34
@@ -8,8 +8,7 @@
8
8
  // root) or switch-client + select-window (across roots). done/dead nodes close
9
9
  // their window; reviving opens a fresh one.
10
10
  import { spawnSync } from 'node:child_process';
11
- import { readConfig } from '../config.js';
12
- import { defaultCanvasNavConfig } from '../../types.js';
11
+ import { resolveUserKeybindings, } from '../keybindings/index.js';
13
12
  import { bundledPiCommandForShell } from './pi-cli.js';
14
13
  import { surfaceTmuxStyleArgs } from './surface-bg.js';
15
14
  // ---------------------------------------------------------------------------
@@ -72,13 +71,22 @@ function ensureBracketedPaste() {
72
71
  // broken `Enbp=E[?2004h` (no ESC) and silently disables bracketed paste.
73
72
  tmux(['set-option', '-sga', 'terminal-overrides', '*:Enbp=\\E[?2004h:Dsbp=\\E[?2004l']);
74
73
  }
74
+ /** Server-level tmux prerequisites for crtr's viewer surfaces, applied once a
75
+ * server exists (callers invoke via ensureSession). Currently: bracketed paste
76
+ * capability (above) and `focus-events on`, which forwards terminal focus
77
+ * in/out (DECSET 1004) to the program in each pane — the attach viewer uses it
78
+ * to hollow out the editor cursor in blurred panes. */
79
+ function ensureServerOptions() {
80
+ ensureBracketedPaste();
81
+ tmux(['set-option', '-s', 'focus-events', 'on']);
82
+ }
75
83
  /** Create a detached session rooted at `cwd` if it doesn't exist. The session
76
84
  * name is a root's tmux home; every node under that root is a window in it. */
77
85
  export function ensureSession(name, cwd) {
78
86
  if (!sessionExists(name)) {
79
87
  tmux(['new-session', '-d', '-s', name, '-c', cwd]);
80
88
  }
81
- ensureBracketedPaste();
89
+ ensureServerOptions();
82
90
  }
83
91
  function envFlags(env) {
84
92
  const out = [];
@@ -400,135 +408,234 @@ export function sendKeysEnter(pane, text) {
400
408
  return tmux(['send-keys', '-t', pane, text, 'Enter']).ok;
401
409
  }
402
410
  // ---------------------------------------------------------------------------
403
- // Prefix menu — Alt+C opens a which-key-style tmux display-menu of crouter
404
- // actions. Installed on the running server at root boot; idempotent (a re-bind
405
- // overwrites the previous one). Menu entries are data-driven: tmux renders the
406
- // action tree, and every `crtr` / `popup` action routes through `crtr canvas
407
- // chord` so node-relative vars resolve in one safe place.
411
+ // Crouter-owned tmux bindings
408
412
  // ---------------------------------------------------------------------------
413
+ const TMUX_BINDING_MANIFEST = '@crtr_keybindings_manifest';
414
+ const ROOT_TABLE = 'root';
415
+ const MENU_ACTIONS = {
416
+ 'crtr.tmux.menu.promote': { description: 'promote to orchestrator', action: { kind: 'keys', keys: '/promote' } },
417
+ 'crtr.tmux.menu.resume': { description: 'resume node', action: { kind: 'keys', keys: '/resume-node' } },
418
+ 'crtr.tmux.menu.demote': { description: 'demote to terminal', action: { kind: 'command', run: 'node lifecycle demote --pane {pane}' } },
419
+ 'crtr.tmux.menu.detach': { description: 'detach to background', action: { kind: 'command', run: 'node lifecycle demote --pane {pane} --detach' } },
420
+ 'crtr.tmux.menu.close-subtree': { description: 'close agent + subtree', action: { kind: 'command', run: 'node lifecycle close --pane {pane}' } },
421
+ 'crtr.tmux.menu.context': { description: 'browse context dirs', action: { kind: 'keys', keys: '/context' } },
422
+ 'crtr.tmux.menu.providers': { description: 'manage providers', action: { kind: 'popup', run: 'sys setup' } },
423
+ 'crtr.tmux.menu.graph': { description: 'graph view', action: { kind: 'keys', keys: '/graph' } },
424
+ 'crtr.tmux.menu.focus-manager': { description: 'focus manager', action: { kind: 'command', run: 'node focus {manager}' } },
425
+ 'crtr.tmux.menu.open': undefined,
426
+ 'crtr.tmux.menu.issues': undefined,
427
+ 'crtr.tmux.menu.issue.idea': undefined,
428
+ 'crtr.tmux.menu.issue.bug': undefined,
429
+ 'crtr.tmux.node.next': undefined,
430
+ 'crtr.tmux.node.previous': undefined,
431
+ 'crtr.tmux.view.next': undefined,
432
+ 'crtr.tmux.view.previous': undefined,
433
+ };
409
434
  function escapeTmuxFormats(value) {
410
435
  return value.replace(/#/g, '##');
411
436
  }
412
- function runShellCmd(shellCmd) {
413
- return `run-shell ${shellQuote(shellCmd)}`;
414
- }
415
- function tmuxCmd(args) {
437
+ function tmuxCommand(args) {
416
438
  return args.map(shellQuote).join(' ');
417
439
  }
418
- function chordShellCmd(run) {
419
- return runShellCmd(`crtr canvas chord --pane '#{pane_id}' --run ${shellQuote(escapeTmuxFormats(run))} >/dev/null 2>&1`);
420
- }
421
- function popupCmd(run) {
422
- return `display-popup -E -w 60% -h 50% -d "#{pane_current_path}" ${shellQuote(`crtr canvas chord --pane '#{pane_id}' --run ${shellQuote(escapeTmuxFormats(run))} --interactive`)}`;
423
- }
424
- function keysCmd(keys) {
425
- return `send-keys -t '#{pane_id}' ${shellQuote(escapeTmuxFormats(keys))} Enter`;
440
+ function chordCommand(run, interactive = false) {
441
+ const command = `crtr canvas chord --pane '#{pane_id}' --run ${shellQuote(escapeTmuxFormats(run))}${interactive ? ' --interactive' : ''}`;
442
+ return interactive
443
+ ? `display-popup -E -w 60% -h 50% -d "#{pane_current_path}" ${shellQuote(command)}`
444
+ : `run-shell ${shellQuote(`${command} >/dev/null 2>&1`)}`;
445
+ }
446
+ function renderMenuAction(action) {
447
+ switch (action.kind) {
448
+ case 'keys': return `send-keys -t '#{pane_id}' ${shellQuote(escapeTmuxFormats(action.keys))} Enter`;
449
+ case 'command': return chordCommand(action.run);
450
+ case 'popup': return chordCommand(action.run, true);
451
+ case 'submenu': return tmuxCommand(buildMenuArgs(action.title, action.items));
452
+ }
426
453
  }
427
454
  function buildMenuArgs(title, items) {
428
455
  const safeTitle = escapeTmuxFormats(title);
456
+ const widestItem = items.reduce((width, item) => Math.max(width, escapeTmuxFormats(item.description).length), 0);
429
457
  const args = ['display-menu', '-T', `#[align=centre]${safeTitle}`, ...surfaceTmuxStyleArgs()];
430
- const boxW = Math.max(safeTitle.length, ...items.map((i) => escapeTmuxFormats(i.desc).length)) + 6;
431
- const nudgeX = 1;
432
- const nudgeY = 3;
433
- args.push('-x', `#{e|-:#{pane_right},${boxW + nudgeX}}`, '-y', `#{e|+:#{pane_top},${nudgeY}}`);
458
+ args.push('-x', `#{e|-:#{pane_right},${Math.max(safeTitle.length, widestItem) + 7}}`, '-y', '#{e|+:#{pane_top},3}');
434
459
  for (const item of items) {
435
- args.push(escapeTmuxFormats(item.desc), escapeTmuxFormats(item.key), renderAction(item.action, item.desc));
460
+ args.push(escapeTmuxFormats(item.description), item.selector, renderMenuAction(item.action));
436
461
  }
437
462
  args.push('-esc / q to close', '', '');
438
463
  return args;
439
464
  }
440
- function renderAction(action, label) {
441
- const kind = action.kind ?? 'crtr';
442
- switch (kind) {
443
- case 'keys':
444
- return keysCmd(action.keys);
445
- case 'popup':
446
- return popupCmd(action.run);
447
- case 'menu':
448
- return tmuxCmd(buildMenuArgs(label, action.items));
449
- case 'crtr':
450
- return chordShellCmd(action.run);
451
- default:
452
- throw new Error(`unknown canvas action kind: ${kind}`);
465
+ const TMUX_NAMED_KEYS = {
466
+ enter: 'Enter', escape: 'Escape', tab: 'Tab', backspace: 'BSpace', delete: 'DC',
467
+ space: 'Space', up: 'Up', down: 'Down', left: 'Left', right: 'Right',
468
+ pageup: 'PPage', pagedown: 'NPage', home: 'Home', end: 'End',
469
+ };
470
+ function tmuxKey(stroke) {
471
+ const parts = stroke.split('+');
472
+ let key = parts.pop();
473
+ const modifiers = new Set(parts);
474
+ if (modifiers.has('shift') && key.length === 1 && /[a-z]/.test(key)) {
475
+ key = key.toUpperCase();
476
+ modifiers.delete('shift');
477
+ }
478
+ key = TMUX_NAMED_KEYS[key] ?? key;
479
+ const prefix = [
480
+ modifiers.has('ctrl') ? 'C-' : '',
481
+ modifiers.has('alt') ? 'M-' : '',
482
+ modifiers.has('shift') ? 'S-' : '',
483
+ ].join('');
484
+ return `${prefix}${key}`;
485
+ }
486
+ function menuSelector(stroke) {
487
+ const parts = stroke.split('+');
488
+ const key = parts.at(-1);
489
+ return parts.includes('shift') && /[a-z]/.test(key) ? key.toUpperCase() : key === 'space' ? ' ' : key;
490
+ }
491
+ function menuItems(bindings) {
492
+ const issueItems = [];
493
+ for (const [id, description, type] of [
494
+ ['crtr.tmux.menu.issue.idea', 'idea', 'idea'],
495
+ ['crtr.tmux.menu.issue.bug', 'bug', 'bug'],
496
+ ]) {
497
+ for (const gesture of bindings.gestures(id)) {
498
+ issueItems.push({ description, selector: menuSelector(gesture), action: { kind: 'popup', run: `canvas issue new --type ${type}` } });
499
+ }
453
500
  }
501
+ const items = [];
502
+ for (const id of [
503
+ 'crtr.tmux.menu.promote', 'crtr.tmux.menu.resume', 'crtr.tmux.menu.demote',
504
+ 'crtr.tmux.menu.detach', 'crtr.tmux.menu.close-subtree', 'crtr.tmux.menu.context',
505
+ 'crtr.tmux.menu.providers', 'crtr.tmux.menu.graph', 'crtr.tmux.menu.focus-manager', 'crtr.tmux.menu.issues',
506
+ ]) {
507
+ const entry = id === 'crtr.tmux.menu.issues'
508
+ ? { description: 'new issue / todo', action: { kind: 'submenu', title: 'new issue / todo', items: issueItems } }
509
+ : MENU_ACTIONS[id];
510
+ if (id === 'crtr.tmux.menu.issues' && issueItems.length === 0)
511
+ continue;
512
+ for (const gesture of bindings.gestures(id)) {
513
+ items.push({ description: entry.description, selector: menuSelector(gesture), action: entry.action });
514
+ }
515
+ }
516
+ return items;
517
+ }
518
+ function bindingAt(table, key) {
519
+ const result = tmux(['list-keys', '-T', table]);
520
+ if (!result.ok)
521
+ return null;
522
+ return result.stdout.split('\n').find((line) => {
523
+ const match = line.match(/^bind-key\s+(?:-r\s+)?-T\s+\S+\s+(\S+)\s+/);
524
+ return match?.[1] === key;
525
+ }) ?? null;
454
526
  }
455
- /** Bind Alt+C to the crouter action menu. Best-effort; false if tmux fails. */
456
- export function installMenuBinding() {
457
- let prefixBinds;
527
+ function sequenceTable(prefix) {
528
+ return `crtr-${Buffer.from(prefix).toString('hex')}`;
529
+ }
530
+ function readManifest() {
531
+ const option = tmux(['show-options', '-s', '-q', TMUX_BINDING_MANIFEST]);
532
+ if (!option.ok)
533
+ return null;
534
+ if (option.stdout === '')
535
+ return { pairs: [], existed: false };
536
+ const result = tmux(['show-options', '-s', '-qv', TMUX_BINDING_MANIFEST]);
537
+ if (!result.ok)
538
+ return null;
458
539
  try {
459
- prefixBinds = readConfig('user').canvasNav.prefixBinds;
540
+ const value = JSON.parse(result.stdout);
541
+ if (!Array.isArray(value) || value.some((pair) => typeof pair !== 'object' || pair === null || typeof pair.table !== 'string' || typeof pair.key !== 'string'))
542
+ return null;
543
+ return { pairs: value, existed: true };
460
544
  }
461
545
  catch {
462
- prefixBinds = defaultCanvasNavConfig().prefixBinds;
546
+ return null;
463
547
  }
464
- const items = [];
465
- for (const [key, bind] of Object.entries(prefixBinds)) {
466
- if (key.length !== 1)
467
- continue;
468
- const desc = bind.desc ?? `chord ${key}`;
469
- items.push({ key, desc, action: bind });
548
+ }
549
+ const LEGACY_ROOT_BINDINGS = [
550
+ { key: 'M-c', markers: ['display-menu', 'promote to orchestrator', 'crtr canvas chord', 'new issue / todo'] },
551
+ { key: 'M-]', markers: ['run-shell', 'crtr node cycle --dir next', "--pane '#{pane_id}'"] },
552
+ { key: 'M-[', markers: ['run-shell', 'crtr node cycle --dir prev', "--pane '#{pane_id}'"] },
553
+ { key: 'M-v', markers: ['switch-client', '-T crtr-view'] },
554
+ ];
555
+ function adoptLegacyBindings() {
556
+ for (const legacy of LEGACY_ROOT_BINDINGS) {
557
+ const existing = bindingAt(ROOT_TABLE, legacy.key);
558
+ if (existing !== null && legacy.markers.every((marker) => existing.includes(marker))) {
559
+ tmux(['unbind-key', '-T', ROOT_TABLE, legacy.key]);
560
+ }
470
561
  }
471
- return tmux(['bind-key', '-n', 'M-c', ...buildMenuArgs(' crtr ', items)]).ok;
472
562
  }
473
- // ---------------------------------------------------------------------------
474
- // Nav bindings — Alt+] / Alt+[ DFS-walk the canvas one window at a time. Each
475
- // key shells out to `crtr node cycle`, passing the active pane so the walk is
476
- // relative to the agent in front of you; cycle then swaps the next/prev node
477
- // into that pane (like `node focus`). Output is discarded so the keypress never
478
- // pops a results view. Installed at root boot alongside the Alt+C menu.
479
- // ---------------------------------------------------------------------------
480
- /** Bind Alt+] (forward) and Alt+[ (back) to the DFS canvas walk. Best-effort;
481
- * false if either bind fails. NOTE: Alt+[ is only delivered cleanly when the
482
- * terminal/tmux disambiguate it from a raw CSI introducer (`extended-keys on`).
483
- */
484
- export function installNavBindings() {
485
- const next = tmux([
486
- 'bind-key', '-n', 'M-]', 'run-shell',
487
- `crtr node cycle --dir next --pane '#{pane_id}' >/dev/null 2>&1`,
488
- ]).ok;
489
- const prev = tmux([
490
- 'bind-key', '-n', 'M-[', 'run-shell',
491
- `crtr node cycle --dir prev --pane '#{pane_id}' >/dev/null 2>&1`,
492
- ]).ok;
493
- return next && prev;
563
+ function actionCommand(id) {
564
+ switch (id) {
565
+ case 'crtr.tmux.node.next': return ['run-shell', `crtr node cycle --dir next --pane '#{pane_id}' >/dev/null 2>&1`];
566
+ case 'crtr.tmux.node.previous': return ['run-shell', `crtr node cycle --dir prev --pane '#{pane_id}' >/dev/null 2>&1`];
567
+ case 'crtr.tmux.view.next': return ['run-shell', `crtr surface view cycle --dir next --pane '#{pane_id}' >/dev/null 2>&1`];
568
+ case 'crtr.tmux.view.previous': return ['run-shell', `crtr surface view cycle --dir prev --pane '#{pane_id}' >/dev/null 2>&1`];
569
+ default: throw new Error(`no tmux command for ${id}`);
570
+ }
494
571
  }
495
- // ---------------------------------------------------------------------------
496
- // View-nav bindings Alt+V then ] / [ cycle the view hosted in a MONITOR pane
497
- // to the next/prev available view, in place. A VIEW-PREFIXED CHORD, not a bare
498
- // Alt pair: Alt+V switches into a private one-shot `crtr-view` key table, then
499
- // ] (next) / [ (prev) fire the cycle — mirroring node cycle's bracket DIRECTION
500
- // grammar (Alt+] next / Alt+[ prev) while NAMESPACING the brackets so they can
501
- // never shadow root bindings. This is collision-proof: only ONE root key (M-v)
502
- // is claimed, and bare Alt pairs are a minefield in real configs (e.g. Alt+,/.
503
- // and Alt+]/.[ commonly bound to window/pane nav). The bracket lives in the
504
- // private table, so it also sidesteps the M-[ vs CSI-introducer ambiguity that
505
- // dogs installNavBindings' Alt+[. Each key shells surface-view cycle, passing the
506
- // active pane; cycle reads the pane's @crtr_view tag and respawns it on the
507
- // next/prev view. Output discarded so the keypress never pops a results view.
508
- // Installed at root boot alongside the node nav + Alt+C menu.
509
- //
510
- // NOTE: `M-v` is a GLOBAL root binding — it intercepts Alt+V in every pane/app
511
- // (e.g. swallowed from the pi editor), the same tradeoff crtr already takes for
512
- // M-c / M-] / M-[. Intended: crtr owns a small set of Alt chords server-wide.
513
- // ---------------------------------------------------------------------------
514
- /** Bind Alt+V → (], [) to the view-monitor cycle. `M-v` enters the private
515
- * `crtr-view` key table (switch-client -T), then ] cycles next / [ cycles prev.
516
- * Best-effort; false if any of the three binds fail. Deliberately distinct from
517
- * installNavBindings' bare Alt+]/Alt+[ (node cycle): the two cycles coexist on
518
- * the same server — brackets alone walk the node graph, Alt+V-then-bracket flips
519
- * view monitors. The bracket keys are bound in the private table, so they NEVER
520
- * shadow the user's root ]/[ and carry no CSI-introducer ambiguity. */
521
- export function installViewNavBindings() {
522
- const enter = tmux([
523
- 'bind-key', '-n', 'M-v', 'switch-client', '-T', 'crtr-view',
524
- ]).ok;
525
- const next = tmux([
526
- 'bind-key', '-T', 'crtr-view', ']', 'run-shell',
527
- `crtr surface view cycle --dir next --pane '#{pane_id}' >/dev/null 2>&1`,
528
- ]).ok;
529
- const prev = tmux([
530
- 'bind-key', '-T', 'crtr-view', '[', 'run-shell',
531
- `crtr surface view cycle --dir prev --pane '#{pane_id}' >/dev/null 2>&1`,
532
- ]).ok;
533
- return enter && next && prev;
572
+ /** Reconcile the tmux server with one immutable crouter binding snapshot. */
573
+ export function installTmuxBindings(bindings = resolveUserKeybindings()) {
574
+ const diagnostics = [];
575
+ const installed = [];
576
+ const manifest = readManifest();
577
+ if (manifest === null) {
578
+ diagnostics.push({ code: 'tmux-error', message: `tmux is unavailable or ${TMUX_BINDING_MANIFEST} is malformed` });
579
+ return { ok: false, installed, diagnostics };
580
+ }
581
+ if (!manifest.existed)
582
+ adoptLegacyBindings();
583
+ for (const pair of manifest.pairs) {
584
+ const result = tmux(['unbind-key', '-T', pair.table, pair.key]);
585
+ if (!result.ok && bindingAt(pair.table, pair.key) !== null) {
586
+ diagnostics.push({ code: 'tmux-error', message: `could not remove crouter binding ${pair.table}:${pair.key}: ${result.stderr}` });
587
+ }
588
+ }
589
+ if (diagnostics.some((diagnostic) => diagnostic.code === 'tmux-error')) {
590
+ return { ok: false, installed, diagnostics };
591
+ }
592
+ const install = (table, key, command, id, gesture) => {
593
+ if (bindingAt(table, key) !== null) {
594
+ diagnostics.push({ code: 'occupied', bindingId: id, gesture, message: `${table}:${key} is already bound by tmux; ${id} was not installed` });
595
+ return false;
596
+ }
597
+ const result = tmux(['bind-key', '-T', table, key, ...command]);
598
+ if (!result.ok) {
599
+ diagnostics.push({ code: 'tmux-error', bindingId: id, gesture, message: `could not install ${id}: ${result.stderr}` });
600
+ return false;
601
+ }
602
+ installed.push({ table, key });
603
+ return true;
604
+ };
605
+ const menuCommand = buildMenuArgs(' crtr ', menuItems(bindings));
606
+ for (const gesture of bindings.gestures('crtr.tmux.menu.open')) {
607
+ install(ROOT_TABLE, tmuxKey(gesture), menuCommand, 'crtr.tmux.menu.open', gesture);
608
+ }
609
+ for (const id of ['crtr.tmux.node.next', 'crtr.tmux.node.previous']) {
610
+ for (const gesture of bindings.gestures(id))
611
+ install(ROOT_TABLE, tmuxKey(gesture), actionCommand(id), id, gesture);
612
+ }
613
+ const sequences = new Map();
614
+ for (const id of ['crtr.tmux.view.next', 'crtr.tmux.view.previous']) {
615
+ for (const gesture of bindings.gestures(id)) {
616
+ const [first, second] = gesture.split(' > ');
617
+ const prefix = tmuxKey(first);
618
+ const entries = sequences.get(prefix) ?? [];
619
+ entries.push({ id, gesture, second: tmuxKey(second) });
620
+ sequences.set(prefix, entries);
621
+ }
622
+ }
623
+ for (const [prefix, entries] of sequences) {
624
+ if (bindingAt(ROOT_TABLE, prefix) !== null) {
625
+ for (const entry of entries)
626
+ diagnostics.push({ code: 'occupied', bindingId: entry.id, gesture: entry.gesture, message: `${ROOT_TABLE}:${prefix} is already bound by tmux; ${entry.id} was not installed` });
627
+ continue;
628
+ }
629
+ const table = sequenceTable(prefix);
630
+ let children = 0;
631
+ for (const entry of entries)
632
+ if (install(table, entry.second, actionCommand(entry.id), entry.id, entry.gesture))
633
+ children++;
634
+ if (children > 0)
635
+ install(ROOT_TABLE, prefix, ['switch-client', '-T', table], entries[0].id, entries[0].gesture);
636
+ }
637
+ const save = tmux(['set-option', '-s', TMUX_BINDING_MANIFEST, JSON.stringify(installed)]);
638
+ if (!save.ok)
639
+ diagnostics.push({ code: 'tmux-error', message: `could not save crouter tmux ownership manifest: ${save.stderr}` });
640
+ return { ok: diagnostics.every((diagnostic) => diagnostic.code !== 'tmux-error'), installed, diagnostics };
534
641
  }
@@ -1,5 +1,5 @@
1
1
  import { homedir } from 'node:os';
2
- import { existsSync, statSync } from 'node:fs';
2
+ import { existsSync, statSync, realpathSync } from 'node:fs';
3
3
  import { join, resolve, dirname } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { CRTR_DIR_NAME } from '../types.js';
@@ -48,6 +48,14 @@ export function builtinPiPackageDir(name) {
48
48
  export function userScopeRoot() {
49
49
  return join(homedir(), CRTR_DIR_NAME);
50
50
  }
51
+ function realpathOrSelf(p) {
52
+ try {
53
+ return realpathSync(p);
54
+ }
55
+ catch {
56
+ return p;
57
+ }
58
+ }
51
59
  function isProjectScopeDir(candidate, userRoot) {
52
60
  if (candidate === userRoot || !existsSync(candidate))
53
61
  return false;
@@ -61,16 +69,31 @@ function isProjectScopeDir(candidate, userRoot) {
61
69
  export function findProjectScopeRoot(startDir = process.cwd()) {
62
70
  return findProjectScopeRoots(startDir)[0] ?? null;
63
71
  }
64
- /** Walk `startDir` upward to the filesystem root, collecting every ancestor
65
- * `.crouter/` dir (nearest first). The user-global `~/.crouter/` is excluded
66
- * because it is the separate user scope, not a project ancestor. */
72
+ /** Walk `startDir` upward collecting every ancestor `.crouter/` dir (nearest
73
+ * first). The user-global `~/.crouter/` is excluded because it is the separate
74
+ * user scope, not a project ancestor.
75
+ *
76
+ * The walk is FENCED at `homedir()`: it must never climb at or above HOME.
77
+ * Home's own `.crouter/` is the user scope (already excluded), and nothing at
78
+ * or above HOME is a project ancestor. Without this fence, a relocated HOME
79
+ * nested under a directory tree that itself contains a `.crouter/` (e.g. a
80
+ * mock-VM home under the real host home) would sweep the host's `~/.crouter`
81
+ * (and any other above-HOME `.crouter`) in as a bogus project ancestor,
82
+ * leaking one machine's user-global memory into a nested-HOME node. Mirrors
83
+ * the on-read positional-walk fence (src/core/substrate/on-read.ts). A read
84
+ * whose start dir is outside HOME (e.g. a production VM with HOME=/root and no
85
+ * ancestor `.crouter`) never reaches home and walks to the filesystem root, as
86
+ * before. */
67
87
  function collectAncestorScopeRoots(startDir, userRoot) {
68
88
  const roots = [];
89
+ const home = realpathOrSelf(homedir());
69
90
  let dir = resolve(startDir);
70
91
  while (true) {
71
92
  const candidate = join(dir, CRTR_DIR_NAME);
72
93
  if (isProjectScopeDir(candidate, userRoot))
73
94
  roots.push(candidate);
95
+ if (realpathOrSelf(dir) === home)
96
+ return roots;
74
97
  const parent = dirname(dir);
75
98
  if (parent === dir)
76
99
  return roots;
@@ -0,0 +1,90 @@
1
+ import type { ThinkingLevel } from "@earendil-works/pi-ai";
2
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ export declare const ANTHROPIC_PROVIDER_ID = "anthropic";
4
+ export declare const OPENAI_CODEX_PROVIDER_ID = "openai-codex";
5
+ export type ManagedProviderId = typeof ANTHROPIC_PROVIDER_ID | typeof OPENAI_CODEX_PROVIDER_ID;
6
+ export type LadderProviderId = "anthropic" | "openai";
7
+ export type ModelStrength = "ultra" | "strong" | "medium" | "light";
8
+ export type SubscriptionCredential = {
9
+ label: string;
10
+ refresh?: string;
11
+ access?: string;
12
+ expires?: number;
13
+ rateLimitedUntil: number;
14
+ lastAttemptAt: number;
15
+ lastRateLimitedAt: number;
16
+ accountId?: string;
17
+ };
18
+ export type ModelRef = {
19
+ providerId: ManagedProviderId;
20
+ modelId: string;
21
+ };
22
+ export type RotationConfig = {
23
+ defaultFallbackStrength?: ModelStrength;
24
+ revertWhenAvailable?: boolean;
25
+ preferredModel?: ModelRef;
26
+ };
27
+ type RotationConfigUpdate = {
28
+ defaultFallbackStrength?: ModelStrength;
29
+ revertWhenAvailable?: boolean;
30
+ preferredModel?: ModelRef | null;
31
+ };
32
+ export type FallbackTarget = {
33
+ providerId: ManagedProviderId;
34
+ modelId: string;
35
+ label: string;
36
+ strength: ModelStrength;
37
+ thinkingLevel?: ThinkingLevel;
38
+ };
39
+ export declare function isManagedProvider(providerId: string | undefined): providerId is ManagedProviderId;
40
+ /** The `modelLadders` CONFIG key for a runtime provider id -- `openai-codex`
41
+ * (the runtime/auth id) maps to the `openai` ladder key used in
42
+ * ~/.crouter/config.json. Callers building a repair command that a user can
43
+ * paste into `crtr sys config set modelLadders.<key>...` must use THIS key,
44
+ * not the runtime provider id. */
45
+ export declare function getLadderProviderId(providerId: ManagedProviderId): LadderProviderId;
46
+ export declare function getProviderLabel(providerId: ManagedProviderId): string;
47
+ export declare function mutateSubscriptionPool(providerId: ManagedProviderId, fn: (pool: SubscriptionCredential[]) => SubscriptionCredential[] | undefined): SubscriptionCredential[];
48
+ export declare function readSubscriptionPool(providerId: ManagedProviderId): SubscriptionCredential[];
49
+ export declare function writeSubscriptionPool(providerId: ManagedProviderId, next: SubscriptionCredential[]): SubscriptionCredential[];
50
+ export declare function upsertSubscription(providerId: ManagedProviderId, next: SubscriptionCredential): SubscriptionCredential[];
51
+ export declare class DuplicateSubscriptionError extends Error {
52
+ constructor(message: string);
53
+ }
54
+ export declare function addSubscription(providerId: ManagedProviderId, entry: SubscriptionCredential): SubscriptionCredential[];
55
+ export declare function upsertSubscriptionWithUniqueAccount(providerId: ManagedProviderId, next: SubscriptionCredential): SubscriptionCredential[];
56
+ export type DefaultSlotLogin = {
57
+ refresh: string;
58
+ access: string;
59
+ expires: number;
60
+ accountId?: string;
61
+ lastRateLimitedAt: number;
62
+ };
63
+ export declare function commitDefaultIdentity(providerId: ManagedProviderId, login: DefaultSlotLogin): SubscriptionCredential[];
64
+ export declare function renameSubscription(providerId: ManagedProviderId, ref: string, nextLabel: string): SubscriptionCredential[];
65
+ export declare function removeManagedAccount(providerId: ManagedProviderId, ref: string): SubscriptionCredential[];
66
+ export declare function commitManagedLogin(providerId: ManagedProviderId, label: string, credential: {
67
+ refresh: string;
68
+ access: string;
69
+ expires: number;
70
+ accountId?: string;
71
+ defaultLastRateLimitedAt: number;
72
+ }): SubscriptionCredential[];
73
+ export declare function removeManagedProvider(providerId: ManagedProviderId, label?: string): SubscriptionCredential[];
74
+ export declare function removeSubscription(providerId: ManagedProviderId, ref: string): SubscriptionCredential[];
75
+ export declare function promoteSubscription(providerId: ManagedProviderId, ref: string): SubscriptionCredential[];
76
+ export declare function markSubscriptionAttempt(providerId: ManagedProviderId, ref: string, attemptAt?: number): SubscriptionCredential[];
77
+ export declare function markSubscriptionRateLimited(providerId: ManagedProviderId, ref: string, retryAfterMs: number, attemptAt?: number, rateLimitedAt?: number): SubscriptionCredential[];
78
+ export declare function markSubscriptionSuccess(providerId: ManagedProviderId, ref: string, attemptAt?: number): SubscriptionCredential[];
79
+ export declare function findAvailableSubscription(providerId: ManagedProviderId, now?: number): SubscriptionCredential | undefined;
80
+ export declare function parseRetryAfterHeader(value: string | undefined, now?: number): number | undefined;
81
+ export declare function readRotationConfig(): RotationConfig;
82
+ export declare function writeRotationConfig(next: RotationConfigUpdate): RotationConfig;
83
+ export declare function clearPreferredModel(): RotationConfig;
84
+ export declare function rememberPreferredModel(model: ModelRef): RotationConfig;
85
+ export declare function resolveFallbackTarget(currentProviderId: ManagedProviderId, currentModelId?: string, config?: RotationConfig, currentThinkingLevel?: string): FallbackTarget | undefined;
86
+ export declare function shouldRestorePreferredModel(config?: RotationConfig, now?: number): boolean;
87
+ export declare function restorePreferredModelIfPossible(piApi: ExtensionAPI, ctx: Pick<ExtensionContext, "model" | "modelRegistry">): Promise<boolean>;
88
+ export declare function switchToFallbackIfPossible(piApi: ExtensionAPI, ctx: Pick<ExtensionContext, "model" | "modelRegistry">, preferredModel?: ModelRef | undefined): Promise<FallbackTarget | undefined>;
89
+ export declare function formatStatusLine(providerId: ManagedProviderId, now?: number): string;
90
+ export {};