@crouton-kit/crouter 0.3.78 → 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 (72) hide show
  1. package/dist/build-root.d.ts +12 -4
  2. package/dist/build-root.js +25 -6
  3. package/dist/builtin-memory/crouter-development/plugins.md +82 -5
  4. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +1228 -12
  5. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +3 -3
  6. package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +2 -787
  7. package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +8 -3
  8. package/dist/clients/attach/__tests__/attach-keybindings.test.d.ts +1 -0
  9. package/dist/clients/attach/__tests__/attach-keybindings.test.js +113 -0
  10. package/dist/clients/attach/__tests__/mermaid-render.test.js +9 -1
  11. package/dist/clients/attach/attach-cmd.d.ts +9 -1
  12. package/dist/clients/attach/attach-cmd.js +849 -803
  13. package/dist/clients/attach/auth-pickers.d.ts +0 -12
  14. package/dist/clients/attach/auth-pickers.js +64 -15
  15. package/dist/clients/attach/graph-overlay.d.ts +12 -2
  16. package/dist/clients/attach/graph-overlay.js +83 -33
  17. package/dist/commands/human/queue.js +3 -4
  18. package/dist/commands/pkg/plugin-inspect.js +22 -1
  19. package/dist/commands/pkg/plugin-manage.js +31 -9
  20. package/dist/commands/sys/__tests__/setup-core.test.js +158 -1
  21. package/dist/commands/sys/doctor.js +42 -4
  22. package/dist/commands/sys/setup-core.d.ts +37 -0
  23. package/dist/commands/sys/setup-core.js +138 -1
  24. package/dist/commands/sys/setup.d.ts +88 -0
  25. package/dist/commands/sys/setup.js +915 -171
  26. package/dist/commands/view-pick.d.ts +4 -0
  27. package/dist/commands/view-pick.js +17 -7
  28. package/dist/core/__tests__/canvas-inbox-watcher.test.js +34 -9
  29. package/dist/core/__tests__/command-plugins-surfaces.test.d.ts +1 -0
  30. package/dist/core/__tests__/command-plugins-surfaces.test.js +298 -0
  31. package/dist/core/__tests__/command-plugins.test.d.ts +1 -0
  32. package/dist/core/__tests__/command-plugins.test.js +444 -0
  33. package/dist/core/__tests__/fixtures/fake-engine.d.ts +6 -0
  34. package/dist/core/__tests__/fixtures/fake-engine.js +9 -1
  35. package/dist/core/__tests__/preview-registry-sync.test.js +30 -1
  36. package/dist/core/__tests__/scope-crouter-home-fence.test.d.ts +1 -0
  37. package/dist/core/__tests__/scope-crouter-home-fence.test.js +55 -0
  38. package/dist/core/canvas/browse/app.d.ts +6 -0
  39. package/dist/core/canvas/browse/app.js +71 -41
  40. package/dist/core/command-plugins/adapter.d.ts +15 -0
  41. package/dist/core/command-plugins/adapter.js +145 -0
  42. package/dist/core/command-plugins/compose.d.ts +5 -0
  43. package/dist/core/command-plugins/compose.js +56 -0
  44. package/dist/core/command-plugins/discovery.d.ts +104 -0
  45. package/dist/core/command-plugins/discovery.js +565 -0
  46. package/dist/core/config.d.ts +2 -5
  47. package/dist/core/keybindings/__tests__/bespoke-consumers.test.d.ts +1 -0
  48. package/dist/core/keybindings/__tests__/bespoke-consumers.test.js +40 -0
  49. package/dist/core/keybindings/__tests__/resolve.test.js +2 -2
  50. package/dist/core/keybindings/catalog.d.ts +3 -3
  51. package/dist/core/keybindings/catalog.js +2 -1
  52. package/dist/core/profiles/select.d.ts +6 -0
  53. package/dist/core/profiles/select.js +86 -52
  54. package/dist/core/provider-management.d.ts +12 -0
  55. package/dist/core/provider-management.js +24 -0
  56. package/dist/core/runtime/broker.js +7 -6
  57. package/dist/core/runtime/pi-vendored.d.ts +8 -0
  58. package/dist/core/runtime/pi-vendored.js +14 -0
  59. package/dist/core/runtime/recap.d.ts +1 -1
  60. package/dist/core/runtime/recap.js +50 -25
  61. package/dist/core/runtime/session-list-cache.d.ts +10 -0
  62. package/dist/core/runtime/session-list-cache.js +94 -26
  63. package/dist/core/runtime/spawn.js +1 -13
  64. package/dist/core/runtime/tmux.js +2 -1
  65. package/dist/core/scope.js +27 -4
  66. package/dist/core/subscription-state.d.ts +90 -0
  67. package/dist/core/subscription-state.js +762 -0
  68. package/dist/daemon/crtrd.js +253 -12
  69. package/dist/pi-extensions/canvas-recap.js +43 -17
  70. package/dist/types.d.ts +6 -13
  71. package/dist/types.js +0 -3
  72. package/package.json +7 -3
@@ -1,17 +1,5 @@
1
1
  import type { TUI } from '@earendil-works/pi-tui';
2
2
  import type { Picker, PickerControls } from './pickers.js';
3
- /** A compact auth summary for a provider — used by non-picker surfaces (e.g.
4
- * the `crtr sys setup` login tab) to show which providers are already
5
- * configured without mounting the full selector. */
6
- export interface ProviderAuthSummary {
7
- id: string;
8
- name: string;
9
- configured: boolean;
10
- }
11
- /** Summarize which providers have a stored credential in `agentDir`'s auth.json.
12
- * Mirrors the OAuth selector's provider list; `configured` is true when the
13
- * provider has an explicit stored credential (OAuth token or API key). */
14
- export declare function summarizeProviderAuth(agentDir: string): ProviderAuthSummary[];
15
3
  /** `/login` — show the provider selector, then run pi's native login flow in
16
4
  * the viewer. On success, notifies the broker to reload auth via reload_auth.
17
5
  * Returns a Picker (OAuthSelectorComponent as the overlay). */
@@ -10,6 +10,8 @@
10
10
  import { join } from 'node:path';
11
11
  import { execFile } from 'node:child_process';
12
12
  import { AuthStorage, ModelRegistry, OAuthSelectorComponent, LoginDialogComponent, } from '@earendil-works/pi-coding-agent';
13
+ import { loginAnthropic, loginOpenAICodex } from '@earendil-works/pi-ai/oauth';
14
+ import { ANTHROPIC_PROVIDER_ID, commitManagedLogin, isManagedProvider, readSubscriptionPool, removeManagedProvider, } from '../../core/subscription-state.js';
13
15
  /** Build an AuthStorage + ModelRegistry from the broker's agentDir (or pi's
14
16
  * default). Both point at the SAME auth.json the broker uses — the viewer and
15
17
  * broker share the filesystem (tmux-local invariant). */
@@ -47,17 +49,6 @@ function buildProviderList(authStorage, registry) {
47
49
  }
48
50
  return providers;
49
51
  }
50
- /** Summarize which providers have a stored credential in `agentDir`'s auth.json.
51
- * Mirrors the OAuth selector's provider list; `configured` is true when the
52
- * provider has an explicit stored credential (OAuth token or API key). */
53
- export function summarizeProviderAuth(agentDir) {
54
- const { authStorage, registry } = buildLocalAuth(agentDir);
55
- return buildProviderList(authStorage, registry).map((p) => ({
56
- id: p.id,
57
- name: p.name,
58
- configured: authStorage.get(p.id) !== undefined,
59
- }));
60
- }
61
52
  /** Open the browser with a URL (best-effort; platform-specific). */
62
53
  function openBrowser(url) {
63
54
  const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
@@ -112,6 +103,52 @@ export function buildLoginPicker(tui, agentDir, onAuthReloaded, notify, controls
112
103
  // inline under the editor (same slot as the model picker), not as a centered
113
104
  // overlay. `replace` focuses the dialog; `close()` (via finish) unmounts it.
114
105
  replace(dialog, dialog);
106
+ if (isManagedProvider(providerId)) {
107
+ // Managed provider (anthropic / openai-codex): drive pi's OAuth flow ourselves so we hold
108
+ // the credential, then persist it through the coordinated writer (commitManagedLogin ->
109
+ // commitDefaultIdentity for the default slot) rather than authStorage.login(). That keeps
110
+ // auth.json + the rotation pool identity in lockstep and runs the account-uniqueness gate
111
+ // the raw path skipped. /login here manages only the DEFAULT slot (label === providerId).
112
+ const runLogin = providerId === ANTHROPIC_PROVIDER_ID ? loginAnthropic : loginOpenAICodex;
113
+ // Snapshot the default slot's rate-limit timestamp BEFORE OAuth begins: a strictly newer
114
+ // concurrent 429 recorded during the login must survive a same-account re-login.
115
+ const defaultLastRateLimitedAt = readSubscriptionPool(providerId).find((entry) => entry.label === providerId)?.lastRateLimitedAt ?? 0;
116
+ runLogin({
117
+ onAuth: (info) => {
118
+ dialog.showAuth(info.url, info.instructions);
119
+ openBrowser(info.url);
120
+ },
121
+ onPrompt: (prompt) => dialog.showPrompt(prompt.message, prompt.placeholder),
122
+ onProgress: (message) => {
123
+ dialog.showProgress(message);
124
+ },
125
+ onManualCodeInput: () => dialog.showManualInput('Paste the code or URL:'),
126
+ })
127
+ .then((credential) => {
128
+ if (settled)
129
+ return;
130
+ const accountId = typeof credential.accountId === 'string' && credential.accountId.trim()
131
+ ? credential.accountId.trim()
132
+ : undefined;
133
+ commitManagedLogin(providerId, providerId, {
134
+ refresh: credential.refresh,
135
+ access: credential.access,
136
+ expires: credential.expires,
137
+ accountId,
138
+ defaultLastRateLimitedAt,
139
+ });
140
+ notify(`Logged in to ${provider.name}`);
141
+ onAuthReloaded();
142
+ finish();
143
+ })
144
+ .catch((err) => {
145
+ if (err?.name !== 'AbortError') {
146
+ notify(`Login error: ${String(err?.message ?? err)}`);
147
+ }
148
+ finish();
149
+ });
150
+ return;
151
+ }
115
152
  if (provider.authType === 'oauth') {
116
153
  const isKnownOAuth = authStorage.getOAuthProviders().some((p) => p.id === providerId);
117
154
  if (!isKnownOAuth) {
@@ -195,10 +232,22 @@ export function buildLogoutPicker(agentDir, onAuthReloaded, notify, close) {
195
232
  const getAuthStatus = (id) => authStorage.getAuthStatus(id);
196
233
  const onSelect = (providerId) => {
197
234
  const provider = providers.find((p) => p.id === providerId);
198
- authStorage.logout(providerId);
199
- close();
200
- notify(`Logged out of ${provider?.name ?? providerId}`);
201
- onAuthReloaded();
235
+ try {
236
+ // Managed providers tear down the WHOLE provider (every rotation account + the auth.json
237
+ // default record) through the coordinated service, never a raw authStorage.logout() that
238
+ // would strip only auth.json and orphan the pool.
239
+ if (isManagedProvider(providerId))
240
+ removeManagedProvider(providerId);
241
+ else
242
+ authStorage.logout(providerId);
243
+ close();
244
+ notify(`Logged out of ${provider?.name ?? providerId}`);
245
+ onAuthReloaded();
246
+ }
247
+ catch (error) {
248
+ close();
249
+ notify(`Logout error: ${error instanceof Error ? error.message : String(error)}`);
250
+ }
202
251
  };
203
252
  const component = new OAuthSelectorComponent('logout', authStorage, providers, onSelect, () => close(), getAuthStatus);
204
253
  return { component, focus: component };
@@ -1,4 +1,5 @@
1
1
  import { type Component, type TUI } from '@earendil-works/pi-tui';
2
+ import { type BindingResolution, type BindingId } from '../../core/keybindings/index.js';
2
3
  import { type CanvasSource } from '../../core/canvas/source.js';
3
4
  import type { AttachPalette } from './config-load.js';
4
5
  export declare class GraphOverlay implements Component {
@@ -6,6 +7,7 @@ export declare class GraphOverlay implements Component {
6
7
  private readonly self;
7
8
  private readonly getAsks;
8
9
  private readonly palette;
10
+ private bindings;
9
11
  private readonly source;
10
12
  private handle;
11
13
  /** Manual fold OVERRIDES (h collapses → userCollapsed, l expands → userExpanded);
@@ -17,7 +19,7 @@ export declare class GraphOverlay implements Component {
17
19
  private cursorId;
18
20
  /** Viewport scroll offset (row index of the top visible tree row). */
19
21
  private scrollTop;
20
- /** Transient y/n confirm gate (kill). */
22
+ /** Transient close confirmation gate. */
21
23
  private pendingConfirm;
22
24
  private snapshot;
23
25
  private snapshotSeq;
@@ -26,7 +28,15 @@ export declare class GraphOverlay implements Component {
26
28
  * default and makes Enter/m/x no-op instead of shelling local focus/kill
27
29
  * commands against a remote graph row. See the file header. */
28
30
  private readonly remote;
29
- constructor(tui: TUI, self: string, getAsks: () => Record<string, number>, palette: AttachPalette, source?: CanvasSource);
31
+ constructor(tui: TUI, self: string, getAsks: () => Record<string, number>, palette: AttachPalette, bindings: BindingResolution<BindingId>, source?: CanvasSource);
32
+ setBindings(bindings: BindingResolution<BindingId>): void;
33
+ private matches;
34
+ private hint;
35
+ /** Compose footer hints in priority order, dropping WHOLE entries that would
36
+ * overflow `budget` visible columns rather than letting the border mid-clip
37
+ * the tail — so the prioritized head (dismiss + remapped local actions) stays
38
+ * discoverable even in a narrow overlay. */
39
+ private fitHints;
30
40
  isOpen(): boolean;
31
41
  open(): void;
32
42
  close(): void;
@@ -1,4 +1,4 @@
1
- // graph-overlay.ts — the alt+g GRAPH navigator overlay for `crtr surface attach` (Unit Q).
1
+ // graph-overlay.ts — the configurable GRAPH navigator overlay for `crtr surface attach`.
2
2
  //
3
3
  // Native reimplementation of canvas-nav.ts's GRAPH modal as a pi-tui OVERLAY
4
4
  // (the viewer has no pi extension host). `tui.showOverlay(this, …)` mounts this
@@ -6,12 +6,9 @@
6
6
  // CAPTURES keyboard focus (mirrors extension-dialogs.ts);
7
7
  // `OverlayHandle.hide()` tears it down and restores focus to the editor. While
8
8
  // shown, every key routes to handleInput() — a fold-aware NERDTree of the
9
- // active CanvasSource subscription graph with the canvas-nav keymap:
10
- // j/k move · h/l fold/ascend-descend · g/G top/bottom · focus (swap into
11
- // this pane) · m focus manager · x kill (y/n confirm) · esc close.
12
- // `e` is a tmux-menu prefixBind, not a GRAPH key, so it is swallowed (per
13
- // canvas-nav). Model/render uses the CanvasSource snapshot built for this
14
- // overlay; keys are decoded with pi-tui's canonical matchesKey (kitty/CSI-u aware).
9
+ // active CanvasSource subscription graph. Actions and footer hints resolve from
10
+ // the crouter binding catalog; input is decoded with pi-tui's canonical matcher
11
+ // (kitty/CSI-u aware).
15
12
  //
16
13
  // Enter/m/x SHELL `crtr` out-of-process (node focus / node lifecycle close) — the viewer
17
14
  // itself never spawns pi or opens a session (the §0 one-writer invariant). It
@@ -30,6 +27,7 @@
30
27
  // graph structure + node metadata ONLY.
31
28
  import { execFile } from 'node:child_process';
32
29
  import { matchesKey, truncateToWidth } from '@earendil-works/pi-tui';
30
+ import { formatBinding, matchesPiTuiInput, } from '../../core/keybindings/index.js';
33
31
  import { fullName } from '../../core/canvas/index.js';
34
32
  import { activeFaultForDisplay } from '../../core/canvas/render.js';
35
33
  import { readFault } from '../../core/runtime/fault.js';
@@ -43,18 +41,34 @@ import { beginFrame, navLabel, nodeGlyph, tokensCell, cycleBadge, askBadge, acti
43
41
  // clipped. margin keeps it off the screen edges.
44
42
  const HEIGHT_PCT = 0.72;
45
43
  const OVERLAY_OPTIONS = { anchor: 'center', width: '72%', minWidth: 48, maxHeight: '72%', margin: 1 };
46
- // Viewer-specific hint: nav-model's shared GRAPH_HINT advertises "e expand",
47
- // but that key is the tmux pane-expand prefix-bind canvas-nav installs in its
48
- // host pane — the attach overlay has no such bind and swallows `e`, so the key
49
- // would do nothing here. Drop it.
50
- const GRAPH_HINT = `${DIM}jk move · hl fold · ↵ focus · x kill · m mgr · esc${RESET}`;
51
- // REMOTE mode drops focus/kill/mgr — Enter/m/x no-op there (see the file header).
52
- const GRAPH_HINT_REMOTE = `${DIM}jk move · hl fold · esc${RESET}`;
44
+ // The footer is width-budgeted (fitHints drops WHOLE trailing entries that
45
+ // won't fit rather than mid-clipping the tail). List the keys a user most needs
46
+ // to discover first dismiss (the only way out) then the remapped local
47
+ // actions so they survive at the narrowest overlay width; routine navigation
48
+ // trails and is the first to be dropped.
49
+ const GRAPH_PRIMARY_LOCAL_HINTS = [
50
+ ['crtr.graph.dismiss', 'dismiss'],
51
+ ['crtr.graph.focus', 'focus'],
52
+ ['crtr.graph.close-subtree', 'close'],
53
+ ['crtr.graph.focus-manager', 'manager'],
54
+ ];
55
+ const GRAPH_PRIMARY_REMOTE_HINTS = [
56
+ ['crtr.graph.dismiss', 'dismiss'],
57
+ ];
58
+ const GRAPH_NAV_HINTS = [
59
+ ['crtr.graph.down', 'down'],
60
+ ['crtr.graph.up', 'up'],
61
+ ['crtr.graph.first', 'first'],
62
+ ['crtr.graph.last', 'last'],
63
+ ['crtr.graph.collapse-or-manager', 'fold/manager'],
64
+ ['crtr.graph.expand-or-child', 'expand/child'],
65
+ ];
53
66
  export class GraphOverlay {
54
67
  tui;
55
68
  self;
56
69
  getAsks;
57
70
  palette;
71
+ bindings;
58
72
  source;
59
73
  handle;
60
74
  /** Manual fold OVERRIDES (h collapses → userCollapsed, l expands → userExpanded);
@@ -66,7 +80,7 @@ export class GraphOverlay {
66
80
  cursorId;
67
81
  /** Viewport scroll offset (row index of the top visible tree row). */
68
82
  scrollTop = 0;
69
- /** Transient y/n confirm gate (kill). */
83
+ /** Transient close confirmation gate. */
70
84
  pendingConfirm;
71
85
  snapshot;
72
86
  snapshotSeq = 0;
@@ -75,14 +89,43 @@ export class GraphOverlay {
75
89
  * default and makes Enter/m/x no-op instead of shelling local focus/kill
76
90
  * commands against a remote graph row. See the file header. */
77
91
  remote;
78
- constructor(tui, self, getAsks, palette, source = localCanvasSource) {
92
+ constructor(tui, self, getAsks, palette, bindings, source = localCanvasSource) {
79
93
  this.tui = tui;
80
94
  this.self = self;
81
95
  this.getAsks = getAsks;
82
96
  this.palette = palette;
97
+ this.bindings = bindings;
83
98
  this.source = source;
84
99
  this.remote = source instanceof RemoteCanvasSource;
85
100
  }
101
+ setBindings(bindings) {
102
+ this.bindings = bindings;
103
+ if (this.handle !== undefined)
104
+ this.tui.requestRender();
105
+ }
106
+ matches(id, data) {
107
+ return matchesPiTuiInput(this.bindings, id, data, (input, gesture) => matchesKey(input, gesture));
108
+ }
109
+ hint(entries) {
110
+ return entries.flatMap(([id, label]) => this.bindings.gestures(id).length === 0 ? [] : [`${formatBinding(this.bindings, id)} ${label}`]);
111
+ }
112
+ /** Compose footer hints in priority order, dropping WHOLE entries that would
113
+ * overflow `budget` visible columns rather than letting the border mid-clip
114
+ * the tail — so the prioritized head (dismiss + remapped local actions) stays
115
+ * discoverable even in a narrow overlay. */
116
+ fitHints(entries, budget) {
117
+ const SEP = 3; // visible width of ' · '
118
+ const out = [];
119
+ let used = 0;
120
+ for (const piece of this.hint(entries)) {
121
+ const add = (out.length === 0 ? 0 : SEP) + visibleWidth(piece);
122
+ if (used + add > budget)
123
+ break;
124
+ out.push(piece);
125
+ used += add;
126
+ }
127
+ return out.join(' · ');
128
+ }
86
129
  isOpen() {
87
130
  return this.handle !== undefined;
88
131
  }
@@ -341,8 +384,14 @@ export class GraphOverlay {
341
384
  ? `${BOLD}⌗ canvas graph${RESET} ${DIM}(${cursorIdx + 1}/${rows.length})${RESET}`
342
385
  : `${BOLD}⌗ canvas graph${RESET} ${DIM}(loading)${RESET}`;
343
386
  const hint = this.pendingConfirm !== undefined
344
- ? `${YELLOW}${this.pendingConfirm.label} ${BOLD}y/n${RESET}`
345
- : this.remote ? GRAPH_HINT_REMOTE : GRAPH_HINT;
387
+ ? `${YELLOW}${this.pendingConfirm.label} ${BOLD}${this.hint([
388
+ ['crtr.graph.confirm.accept', 'accept'],
389
+ ['crtr.graph.confirm.cancel', 'cancel'],
390
+ ]).join(' · ')}${RESET}`
391
+ : `${DIM}${this.fitHints([
392
+ ...(this.remote ? GRAPH_PRIMARY_REMOTE_HINTS : GRAPH_PRIMARY_LOCAL_HINTS),
393
+ ...GRAPH_NAV_HINTS,
394
+ ], Math.max(1, width - 5))}${RESET}`; // budget matches borderRow's span
346
395
  // Frame the panel: title in the top border, hint in the bottom border, tree
347
396
  // rows boxed with themed side bars (truncate/pad to the inner width so the
348
397
  // right edge stays flush and opaque).
@@ -385,18 +434,19 @@ export class GraphOverlay {
385
434
  dispatch(data) {
386
435
  // y/n confirm gate takes precedence over everything.
387
436
  if (this.pendingConfirm !== undefined) {
388
- if (matchesKey(data, 'y')) {
437
+ if (this.matches('crtr.graph.confirm.accept', data)) {
389
438
  const act = this.pendingConfirm.action;
390
439
  this.pendingConfirm = undefined;
391
440
  act();
392
441
  }
393
442
  else {
394
- this.pendingConfirm = undefined; // any other key cancels
443
+ // Preserve GRAPH's modal fallback: any non-accept key cancels close.
444
+ this.pendingConfirm = undefined;
395
445
  }
396
446
  this.tui.requestRender();
397
447
  return;
398
448
  }
399
- if (matchesKey(data, 'escape')) {
449
+ if (this.matches('crtr.graph.dismiss', data)) {
400
450
  this.close();
401
451
  return;
402
452
  }
@@ -427,29 +477,29 @@ export class GraphOverlay {
427
477
  }
428
478
  return idx;
429
479
  };
430
- if (matchesKey(data, 'j')) {
480
+ if (this.matches('crtr.graph.down', data)) {
431
481
  idx = step(idx, 1);
432
482
  this.cursorId = rows[idx]?.id ?? this.cursorId;
433
483
  this.tui.requestRender();
434
484
  return;
435
485
  }
436
- if (matchesKey(data, 'k')) {
486
+ if (this.matches('crtr.graph.up', data)) {
437
487
  idx = step(idx, -1);
438
488
  this.cursorId = rows[idx]?.id ?? this.cursorId;
439
489
  this.tui.requestRender();
440
490
  return;
441
491
  }
442
- if (matchesKey(data, 'g')) {
492
+ if (this.matches('crtr.graph.first', data)) {
443
493
  this.cursorId = rows[edge(1)]?.id ?? this.cursorId;
444
494
  this.tui.requestRender();
445
495
  return;
446
496
  }
447
- if (matchesKey(data, 'shift+g')) {
497
+ if (this.matches('crtr.graph.last', data)) {
448
498
  this.cursorId = rows[edge(-1)]?.id ?? this.cursorId;
449
499
  this.tui.requestRender();
450
500
  return;
451
501
  }
452
- if (matchesKey(data, 'h')) {
502
+ if (this.matches('crtr.graph.collapse-or-manager', data)) {
453
503
  if (cur !== undefined && cur.hasKids && !cur.collapsed) {
454
504
  this.userCollapsed.add(cur.id);
455
505
  this.userExpanded.delete(cur.id);
@@ -463,7 +513,7 @@ export class GraphOverlay {
463
513
  }
464
514
  return;
465
515
  }
466
- if (matchesKey(data, 'l')) {
516
+ if (this.matches('crtr.graph.expand-or-child', data)) {
467
517
  if (cur !== undefined && cur.collapsed && cur.hasKids) {
468
518
  this.userExpanded.add(cur.id);
469
519
  this.userCollapsed.delete(cur.id);
@@ -477,12 +527,12 @@ export class GraphOverlay {
477
527
  }
478
528
  return;
479
529
  }
480
- // Enter/m/x drive local lifecycle/focus (`crtr node focus` / `crtr node
530
+ // Focus/manager/close actions drive local lifecycle/focus (`crtr node focus` / `crtr node
481
531
  // lifecycle close`) against `this.cursorId` — a REMOTE graph row's id has no
482
532
  // relationship to this machine's canvas.db (or, worse, collides with one
483
533
  // that does), so all three no-op in remote mode instead of shelling a local
484
- // mutation command against a remote id. GRAPH_HINT_REMOTE drops their hints.
485
- if (matchesKey(data, 'enter')) {
534
+ // mutation command against a remote id. Their hints are omitted remotely.
535
+ if (this.matches('crtr.graph.focus', data)) {
486
536
  if (this.remote)
487
537
  return;
488
538
  if (this.cursorId !== undefined)
@@ -490,7 +540,7 @@ export class GraphOverlay {
490
540
  this.close();
491
541
  return;
492
542
  }
493
- if (matchesKey(data, 'm')) {
543
+ if (this.matches('crtr.graph.focus-manager', data)) {
494
544
  if (this.remote)
495
545
  return;
496
546
  const mgr = snapshot?.managerOf.get(this.self);
@@ -500,7 +550,7 @@ export class GraphOverlay {
500
550
  }
501
551
  return;
502
552
  }
503
- if (matchesKey(data, 'x')) {
553
+ if (this.matches('crtr.graph.close-subtree', data)) {
504
554
  if (this.remote)
505
555
  return;
506
556
  const target = this.cursorId ?? this.self;
@@ -510,7 +560,7 @@ export class GraphOverlay {
510
560
  this.tui.requestRender();
511
561
  return;
512
562
  }
513
- // Modal: swallow everything else (incl. the `e` tmux-menu bind) so a stray
563
+ // Modal: swallow everything else so a stray
514
564
  // key never reaches the editor underneath.
515
565
  }
516
566
  // -------------------------------------------------------------------------
@@ -9,7 +9,7 @@ import { appendInbox } from '../../core/feed/inbox.js';
9
9
  import { closeSync, existsSync, linkSync, openSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs';
10
10
  import { basename, join } from 'node:path';
11
11
  import { spawnSync } from 'node:child_process';
12
- import { inbox, scanInbox, parseDeck, deckPath, responsePath, progressPath, isResolved, atomicWriteJson, ask, launchReview, readJson, display, } from '@crouton-kit/humanloop';
12
+ import { openInbox, scanInbox, parseDeck, deckPath, responsePath, progressPath, isResolved, atomicWriteJson, ask, launchReview, readJson, display, } from '@crouton-kit/humanloop';
13
13
  import { killPane } from './shared.js';
14
14
  // ---------------------------------------------------------------------------
15
15
  // stranded-answer healing
@@ -158,8 +158,8 @@ export const humanInbox = defineLeaf({
158
158
  ],
159
159
  },
160
160
  run: async () => {
161
- await inbox([interactionsRoot(process.cwd())]);
162
- // humanloop's inbox() writes response.json but never calls pushFinal — so a
161
+ await openInbox({ roots: [interactionsRoot(process.cwd())] });
162
+ // humanloop's openInbox() writes response.json but never calls pushFinal — so a
163
163
  // deck drained here would strand (no answer-back, leaked bridge node). Heal
164
164
  // every resolved-but-live interaction: deliver its answer to the asker + reap.
165
165
  const delivered = await healStrandedInteractions(process.cwd());
@@ -766,7 +766,6 @@ export const humanRun = defineLeaf({
766
766
  try {
767
767
  const res = await launchReview(rc.file, {
768
768
  output: rc.output,
769
- noTmux: true,
770
769
  });
771
770
  await pushFinal(rc.job_id, JSON.stringify(res));
772
771
  }
@@ -3,6 +3,8 @@ import { notFound } from '../../core/errors.js';
3
3
  import { paginate } from '../../core/pagination.js';
4
4
  import { listInstalledPlugins, findPluginByName, } from '../../core/resolver.js';
5
5
  import { listAllMemoryDocs } from '../../core/memory-resolver.js';
6
+ import { validatePluginCommands, validateEffectiveCommandPlugins } from '../../core/command-plugins/discovery.js';
7
+ import { SUBTREE_NAMES } from '../../build-root.js';
6
8
  import { resolveScopeArg, projectScopeRoot } from '../../core/scope.js';
7
9
  export const pluginList = defineLeaf({
8
10
  name: 'list',
@@ -89,9 +91,10 @@ export const pluginShow = defineLeaf({
89
91
  { name: 'enabled', type: 'boolean', required: true, constraint: 'Whether the plugin is active.' },
90
92
  { name: 'manifest', type: 'object', required: true, constraint: 'Full plugin.json contents.' },
91
93
  { name: 'docs', type: 'object[]', required: true, constraint: 'Each: {name, path}. Memory docs provided by the plugin (its `<pluginName>/` memory subtree).' },
94
+ { name: 'commands', type: 'object', required: false, constraint: 'Present only when the plugin declares a command manifest (manifest.commands). {manifestPath: string, executable: string | null (null when path/exec-bit validation failed), mounts: string[] (accepted top-level command names), issues: object[] ({code, path?, message, received, expected, next})}. Validated statically — the plugin binary is never executed.' },
92
95
  ],
93
96
  outputKind: 'object',
94
- effects: ['None. Read-only.'],
97
+ effects: ['None. Read-only. Command manifests are validated statically; the plugin executable is never run.'],
95
98
  },
96
99
  run: async (input) => {
97
100
  const name = input['name'];
@@ -114,6 +117,23 @@ export const pluginShow = defineLeaf({
114
117
  }
115
118
  const prefix = `${found.name}/`;
116
119
  const docs = listAllMemoryDocs().filter((d) => d.name.startsWith(prefix));
120
+ let commands;
121
+ if (typeof found.manifest.commands === 'string' && found.manifest.commands.length > 0) {
122
+ const reserved = new Set(SUBTREE_NAMES);
123
+ // Validate against the whole effective set so cross-plugin top-level
124
+ // name collisions show up in this plugin's issues (a collided command is
125
+ // NOT mounted). A copy outside the effective set (disabled, or shadowed
126
+ // by a higher-precedence copy) cannot collide, so the per-plugin
127
+ // validator is exact for it.
128
+ const v = validateEffectiveCommandPlugins(reserved).find((x) => x.plugin.root === found.root) ??
129
+ validatePluginCommands(found, reserved);
130
+ commands = {
131
+ manifestPath: v.manifestPath,
132
+ executable: v.executable ?? null,
133
+ mounts: v.contributions.map((c) => c.node.name),
134
+ issues: v.issues,
135
+ };
136
+ }
117
137
  return {
118
138
  name: found.name,
119
139
  scope: found.scope,
@@ -124,6 +144,7 @@ export const pluginShow = defineLeaf({
124
144
  name: d.name,
125
145
  path: d.path,
126
146
  })),
147
+ ...(commands !== undefined ? { commands } : {}),
127
148
  };
128
149
  },
129
150
  });
@@ -8,7 +8,25 @@ import { updateConfig, updateState, ensureScopeInitialized } from '../../core/co
8
8
  import { pathExists, ensureDir, removePath, nowIso, linkOrCopy, isSymlink } from '../../core/fs-utils.js';
9
9
  import { clone, pull, deriveNameFromUrl, currentSha, isGitRepo } from '../../core/git.js';
10
10
  import { readMarketplaceManifest, readPluginManifest } from '../../core/manifest.js';
11
+ import { validatePluginCommands, validateEffectiveCommandPlugins } from '../../core/command-plugins/discovery.js';
12
+ import { SUBTREE_NAMES } from '../../build-root.js';
11
13
  import { isGitUrl, setPluginEnabled, resolveInstallScope } from './shared.js';
14
+ /** Validate a just-installed/updated plugin's command manifest, if it declares
15
+ * one. Returns undefined for plugins without a `commands` pointer. Never
16
+ * executes the plugin binary — validation is fully static. */
17
+ function commandReport(name, scope) {
18
+ const plugin = findPluginByName(name, scope);
19
+ if (plugin === null || typeof plugin.manifest.commands !== 'string' || plugin.manifest.commands.length === 0) {
20
+ return undefined;
21
+ }
22
+ const reserved = new Set(SUBTREE_NAMES);
23
+ // Report from the effective set so a cross-plugin top-level collision is
24
+ // visible at install time (a collided command is NOT mounted). A copy
25
+ // outside the effective set (disabled, or shadowed) cannot collide.
26
+ const v = validateEffectiveCommandPlugins(reserved).find((x) => x.plugin.root === plugin.root) ??
27
+ validatePluginCommands(plugin, reserved);
28
+ return { mounts: v.contributions.map((c) => c.node.name), issues: v.issues };
29
+ }
12
30
  function classifyInstallRef(raw) {
13
31
  if (raw.startsWith('./') || raw.startsWith('../') || raw.startsWith('/')) {
14
32
  return { kind: 'local', value: raw };
@@ -203,10 +221,12 @@ function updateOnePlugin(plugin, marketplaceCache, opts) {
203
221
  }
204
222
  const manifest = readPluginManifest(plugin.root);
205
223
  updatePluginVersion(plugin.scope, plugin.name, marketplaceVersion ?? manifest?.version);
224
+ const commands = commandReport(plugin.name, plugin.scope);
206
225
  return {
207
226
  name: plugin.name,
208
227
  updated: contentUpdate.updated,
209
228
  sha: contentUpdate.sha,
229
+ ...(commands !== undefined ? { commands } : {}),
210
230
  };
211
231
  }
212
232
  export const pluginInstall = defineLeaf({
@@ -225,9 +245,13 @@ export const pluginInstall = defineLeaf({
225
245
  { name: 'name', type: 'string', required: true, constraint: 'Plugin name as installed.' },
226
246
  { name: 'scope', type: 'string', required: true, constraint: 'Scope the plugin was installed into.' },
227
247
  { name: 'path', type: 'string', required: true, constraint: 'Absolute path to the installed plugin directory.' },
248
+ { name: 'commands', type: 'object', required: false, constraint: 'Present only when the plugin declares a command manifest. {mounts: string[] (accepted top-level command names now live for the next invocation), issues: object[] (typed validation issues that rejected a contribution — {code, path?, message, received, expected, next})}. Validated statically; the plugin binary is never executed.' },
228
249
  ],
229
250
  outputKind: 'object',
230
- effects: ['Clones, links, or copies the plugin into the scope plugins directory. Registers the plugin in config.json with enabled=true. Marketplace installs refresh the source marketplace before resolving the plugin entry.'],
251
+ effects: [
252
+ 'Clones, links, or copies the plugin into the scope plugins directory. Registers the plugin in config.json with enabled=true. Marketplace installs refresh the source marketplace before resolving the plugin entry.',
253
+ 'If the plugin declares a command manifest, its commands go live on the next crtr invocation. A command leaf runs the plugin\'s own executable as trusted local code with your authority — only when you explicitly invoke that leaf, never on install, help, or discovery.',
254
+ ],
231
255
  },
232
256
  run: async (input) => {
233
257
  const rawRef = input['installRef'];
@@ -235,13 +259,11 @@ export const pluginInstall = defineLeaf({
235
259
  const gitRef = input['ref'];
236
260
  const scope = resolveInstallScope(scopeInput);
237
261
  const classified = classifyInstallRef(rawRef);
238
- if (classified.kind === 'marketplace') {
239
- return installFromMarketplace(classified.value, scope);
240
- }
241
- if (classified.kind === 'local') {
242
- return installFromLocal(classified.value, scope);
243
- }
244
- return installFromGit(classified.value, gitRef, scope);
262
+ const installed = classified.kind === 'marketplace' ? installFromMarketplace(classified.value, scope)
263
+ : classified.kind === 'local' ? installFromLocal(classified.value, scope)
264
+ : installFromGit(classified.value, gitRef, scope);
265
+ const commands = commandReport(installed.name, installed.scope);
266
+ return commands !== undefined ? { ...installed, commands } : installed;
245
267
  },
246
268
  });
247
269
  export const pluginRemove = defineLeaf({
@@ -351,7 +373,7 @@ export const pluginUpdate = defineLeaf({
351
373
  { kind: 'flag', name: 'name', type: 'string', required: false, constraint: 'Plugin name to update. Omit to update all installed plugins.' },
352
374
  ],
353
375
  output: [
354
- { name: 'updated', type: 'object[]', required: true, constraint: 'One entry per plugin processed: {name, updated, sha}.' },
376
+ { name: 'updated', type: 'object[]', required: true, constraint: 'One entry per plugin processed: {name, updated, sha, commands?}. commands is present only for plugins declaring a command manifest: {mounts: string[] (accepted top-level command names), issues: object[] ({code, path?, message, received, expected, next})} — validated statically, never executing the plugin binary.' },
355
377
  ],
356
378
  outputKind: 'object',
357
379
  effects: ['Runs git pull in plugin or marketplace directories when they are git checkouts. Updates plugin version and last_updated state.'],