@north-light/crouter 0.3.161 → 0.3.163

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 (130) hide show
  1. package/dist/api/client.d.ts +3 -1
  2. package/dist/api/client.js +6 -0
  3. package/dist/api/dto/modelauth.d.ts +32 -0
  4. package/dist/api/dto/modelauth.js +2 -1
  5. package/dist/api/routes.d.ts +1 -0
  6. package/dist/api/routes.js +1 -0
  7. package/dist/builtin-memory/00-runtime-base.md +1 -0
  8. package/dist/builtin-memory/04-orchestration-kernel.md +1 -0
  9. package/dist/builtin-memory/init.md +38 -0
  10. package/dist/builtin-memory/wedged-child-on-runaway-bash.md +2 -2
  11. package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +0 -1
  12. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +88 -396
  13. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +215 -419
  14. package/dist/clients/attach/__tests__/crtr-output.test.js +14 -9
  15. package/dist/clients/attach/__tests__/edit-diff.test.d.ts +1 -0
  16. package/dist/clients/attach/__tests__/edit-diff.test.js +34 -0
  17. package/dist/clients/attach/__tests__/editor-frame-title.test.d.ts +1 -0
  18. package/dist/clients/attach/__tests__/editor-frame-title.test.js +19 -0
  19. package/dist/clients/attach/render/chat-view.js +8 -3
  20. package/dist/clients/attach/render/crtr-output.js +31 -2
  21. package/dist/clients/attach/render/edit-diff.js +26 -2
  22. package/dist/clients/attach/render/tool-calls.d.ts +7 -2
  23. package/dist/clients/attach/render/tool-calls.js +25 -4
  24. package/dist/clients/attach/session/connection.d.ts +1 -1
  25. package/dist/clients/attach/session/editor-frame.d.ts +5 -1
  26. package/dist/clients/attach/session/editor-frame.js +21 -15
  27. package/dist/clients/attach/session/identity.d.ts +1 -1
  28. package/dist/clients/attach/viewer.js +441 -439
  29. package/dist/commands/__tests__/api-canvas-source.test.d.ts +1 -0
  30. package/dist/commands/__tests__/api-canvas-source.test.js +37 -0
  31. package/dist/commands/__tests__/human.test.js +2 -0
  32. package/dist/commands/__tests__/search-contents.test.d.ts +1 -0
  33. package/dist/commands/__tests__/search-contents.test.js +52 -0
  34. package/dist/commands/api-client.js +1 -0
  35. package/dist/commands/human/prompts.js +2 -2
  36. package/dist/commands/human/shared.d.ts +1 -0
  37. package/dist/commands/human/shared.js +7 -4
  38. package/dist/commands/memory/lint.d.ts +6 -0
  39. package/dist/commands/memory/lint.js +177 -35
  40. package/dist/commands/memory/read.js +22 -1
  41. package/dist/commands/memory/write.js +18 -4
  42. package/dist/commands/memory.js +1 -1
  43. package/dist/commands/search/contents.js +2 -2
  44. package/dist/commands/sys/__tests__/sync-deps.test.js +12 -21
  45. package/dist/commands/sys/__tests__/sync-import.test.js +30 -29
  46. package/dist/commands/sys/setup-wizard.d.ts +8 -1
  47. package/dist/commands/sys/setup-wizard.js +209 -58
  48. package/dist/commands/sys/sync-deps.js +14 -18
  49. package/dist/commands/sys/sync-project-guidance.js +16 -19
  50. package/dist/core/__tests__/broker-launch-candidates.test.js +13 -3
  51. package/dist/core/__tests__/broker-sdk-wiring.test.js +53 -25
  52. package/dist/core/__tests__/full/broker-provider-retry.test.js +6 -122
  53. package/dist/core/__tests__/migration.test.js +8 -3
  54. package/dist/core/__tests__/model-pin-durability.test.js +7 -5
  55. package/dist/core/__tests__/model-routes.test.d.ts +1 -0
  56. package/dist/core/__tests__/model-routes.test.js +41 -0
  57. package/dist/core/__tests__/on-read-crouter-home-fence.test.js +7 -10
  58. package/dist/core/__tests__/on-read-dedup-resume.test.js +13 -25
  59. package/dist/core/__tests__/on-read-identity.test.js +8 -15
  60. package/dist/core/__tests__/review-model-floor.test.js +2 -2
  61. package/dist/core/__tests__/revive.test.js +2 -2
  62. package/dist/core/__tests__/tmux-surface.test.js +10 -4
  63. package/dist/core/__tests__/worktree.test.js +3 -3
  64. package/dist/core/auth-file.d.ts +3 -2
  65. package/dist/core/auth-file.js +5 -3
  66. package/dist/core/canvas/extensions.d.ts +1 -1
  67. package/dist/core/canvas/extensions.js +18 -11
  68. package/dist/core/canvas/labels.d.ts +4 -5
  69. package/dist/core/canvas/labels.js +9 -9
  70. package/dist/core/canvas/types.d.ts +6 -1
  71. package/dist/core/command-manifests/schema.js +18 -5
  72. package/dist/core/command.js +17 -7
  73. package/dist/core/config.d.ts +3 -1
  74. package/dist/core/config.js +88 -2
  75. package/dist/core/configured-clis/invoker.js +2 -0
  76. package/dist/core/help.d.ts +3 -1
  77. package/dist/core/help.js +5 -2
  78. package/dist/core/keybindings/__tests__/resolve.test.js +5 -2
  79. package/dist/core/keybindings/catalog.d.ts +3 -2
  80. package/dist/core/keybindings/catalog.js +34 -25
  81. package/dist/core/keybindings/index.d.ts +1 -1
  82. package/dist/core/keybindings/resolve.js +8 -0
  83. package/dist/core/keybindings/types.d.ts +6 -1
  84. package/dist/core/memory/doc-link-grammar.d.ts +20 -0
  85. package/dist/core/memory/doc-link-grammar.js +110 -0
  86. package/dist/core/memory-resolver.d.ts +5 -0
  87. package/dist/core/memory-resolver.js +12 -1
  88. package/dist/core/model-routes.d.ts +37 -0
  89. package/dist/core/model-routes.js +140 -0
  90. package/dist/core/runtime/bearings.d.ts +8 -8
  91. package/dist/core/runtime/bearings.js +20 -16
  92. package/dist/core/runtime/broker.d.ts +0 -4
  93. package/dist/core/runtime/broker.js +119 -385
  94. package/dist/core/runtime/canvas-extensions.d.ts +1 -0
  95. package/dist/core/runtime/canvas-extensions.js +2 -0
  96. package/dist/core/runtime/launch.d.ts +10 -12
  97. package/dist/core/runtime/launch.js +16 -22
  98. package/dist/core/runtime/managed-provider-cooling.d.ts +0 -14
  99. package/dist/core/runtime/managed-provider-cooling.js +0 -19
  100. package/dist/core/runtime/model-swap.js +7 -1
  101. package/dist/core/runtime/promote.js +1 -0
  102. package/dist/core/runtime/tmux.js +136 -100
  103. package/dist/core/scope.js +4 -5
  104. package/dist/core/subscription-state.d.ts +12 -36
  105. package/dist/core/subscription-state.js +19 -203
  106. package/dist/core/substrate/ceiling.d.ts +3 -6
  107. package/dist/core/substrate/ceiling.js +13 -15
  108. package/dist/core/substrate/index.d.ts +2 -3
  109. package/dist/core/substrate/index.js +2 -2
  110. package/dist/core/substrate/on-read.d.ts +4 -13
  111. package/dist/core/substrate/on-read.js +142 -262
  112. package/dist/core/substrate/render.js +4 -4
  113. package/dist/core/substrate/schema.d.ts +3 -2
  114. package/dist/core/substrate/schema.js +5 -5
  115. package/dist/daemon/api/__tests__/full/api-server.test.js +9 -6
  116. package/dist/daemon/api/__tests__/full/b10-attach-modelauth.test.js +123 -1
  117. package/dist/daemon/api/handlers/modelauth.js +51 -2
  118. package/dist/daemon/api/handlers/nodes.js +2 -0
  119. package/dist/pi-extensions/__tests__/canvas-tool-guide.test.d.ts +1 -0
  120. package/dist/pi-extensions/__tests__/canvas-tool-guide.test.js +96 -0
  121. package/dist/pi-extensions/canvas-doc-substrate.js +7 -8
  122. package/dist/pi-extensions/canvas-tool-guide.d.ts +14 -0
  123. package/dist/pi-extensions/canvas-tool-guide.js +74 -0
  124. package/dist/types.d.ts +17 -0
  125. package/dist/web-client/assets/{index-B76ZKfT_.js → index-NIuSCOHM.js} +1 -1
  126. package/dist/web-client/index.html +1 -1
  127. package/dist/web-client/sw.js +1 -1
  128. package/package.json +1 -1
  129. package/runtime.lock.json +2 -2
  130. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/crouter-help.ts +0 -95
@@ -68,7 +68,7 @@ test('review still permits equal-or-stronger explicit models', () => {
68
68
  assert.equal(spec('review', 'strong'), expected('strong'));
69
69
  assert.equal(spec('review', 'ultra'), expected('ultra'));
70
70
  });
71
- test('explicit provider-qualified overrides preserve their provider pin through launch construction', () => {
71
+ test('concrete overrides stay pinned while logical provider/strength overrides remain unpinned', () => {
72
72
  const qualified = buildLaunchSpec('developer', 'base', {
73
73
  lifecycle: 'terminal', hasManager: true, cwd: home, profileId: null, model: 'anthropic/ultra',
74
74
  }).launch;
@@ -78,7 +78,7 @@ test('explicit provider-qualified overrides preserve their provider pin through
78
78
  const bare = buildLaunchSpec('developer', 'base', {
79
79
  lifecycle: 'terminal', hasManager: true, cwd: home, profileId: null, model: 'ultra',
80
80
  }).launch;
81
- assert.equal(qualified.modelProviderPinned, true);
81
+ assert.equal(qualified.modelProviderPinned, false);
82
82
  assert.equal(exact.modelProviderPinned, true);
83
83
  assert.equal(bare.modelProviderPinned, false);
84
84
  });
@@ -193,9 +193,9 @@ test('buildPiArgv restores mandatory canvas extensions when persisted launch met
193
193
  }
194
194
  assert.deepEqual(extensions.slice(0, required.size), mandatory, 'mandatory extensions are loaded first and once');
195
195
  });
196
- test('buildPiArgv replaces the historical stophook-first prefix without loading old-generation hooks twice', () => {
196
+ test('buildPiArgv replaces the previous mandatory prefix with the current canvas hooks', () => {
197
197
  const historicalNames = [
198
- 'canvas-stophook', 'canvas-inbox-watcher', 'canvas-recap',
198
+ 'canvas-inbox-watcher', 'canvas-stophook', 'canvas-recap',
199
199
  'canvas-goal-capture', 'canvas-passive-context', 'canvas-context-intro',
200
200
  'canvas-doc-substrate', 'canvas-structured-output', 'canvas-bash-valve',
201
201
  ];
@@ -224,6 +224,7 @@ test('tmux installer remaps owned pairs and preserves occupied user bindings', (
224
224
  const remapped = installTmuxBindings(resolveKeybindings({
225
225
  'crtr.tmux.menu.open': ['alt+shift+z'],
226
226
  'crtr.tmux.menu.graph': ['shift+z'],
227
+ 'crtr.tmux.menu-global.fold-tools': ['alt+shift+f'],
227
228
  'crtr.tmux.node.next': [],
228
229
  'crtr.tmux.view.next': ['alt+z > n'],
229
230
  'crtr.tmux.view.previous': ['alt+z > p'],
@@ -231,10 +232,15 @@ test('tmux installer remaps owned pairs and preserves occupied user bindings', (
231
232
  assert.equal(remapped.ok, true, JSON.stringify(remapped.diagnostics));
232
233
  assert.equal(tmuxBinding(socket, 'root', 'M-c'), null);
233
234
  assert.equal(tmuxBinding(socket, 'root', 'M-]'), null);
234
- const menu = tmuxBinding(socket, 'root', 'M-Z') ?? '';
235
+ assert.equal(tmuxBinding(socket, 'root', 'M-F'), null);
236
+ assert.equal(tmuxBinding(socket, 'root', 'M-S-f'), null);
237
+ const foldTools = tmuxBinding(socket, 'root', 'M-S-F') ?? '';
238
+ assert.match(foldTools, /fold-tools/);
239
+ assert.doesNotMatch(foldTools, /#\{pane_id\}/);
240
+ const menu = tmuxBinding(socket, 'root', 'M-S-Z') ?? '';
235
241
  assert.match(menu, /graph view/);
236
242
  assert.match(menu, /Z/);
237
- assert.match(menu, /"Alt\+Shift\+Z to close" M-Z refresh-client/);
243
+ assert.match(menu, /"Alt\+Shift\+Z to close" M-S-Z refresh-client/);
238
244
  const prefix = tmuxBinding(socket, 'root', 'M-z') ?? '';
239
245
  assert.match(prefix, /switch-client/);
240
246
  const again = installTmuxBindings(resolveKeybindings({
@@ -244,11 +250,11 @@ test('tmux installer remaps owned pairs and preserves occupied user bindings', (
244
250
  'crtr.tmux.view.previous': ['alt+z > p'],
245
251
  }));
246
252
  assert.equal(again.ok, true, JSON.stringify(again.diagnostics));
247
- isolatedTmux(socket, ['bind-key', '-T', 'root', 'M-Z', 'display-message', 'user-owned']);
253
+ isolatedTmux(socket, ['bind-key', '-T', 'root', 'M-S-Z', 'display-message', 'user-owned']);
248
254
  const occupied = installTmuxBindings(resolveKeybindings({ 'crtr.tmux.menu.open': ['alt+shift+z'] }));
249
255
  assert.equal(occupied.ok, true);
250
256
  assert.ok(occupied.diagnostics.some((diagnostic) => diagnostic.code === 'occupied' && diagnostic.bindingId === 'crtr.tmux.menu.open'));
251
- assert.match(tmuxBinding(socket, 'root', 'M-Z') ?? '', /user-owned/);
257
+ assert.match(tmuxBinding(socket, 'root', 'M-S-Z') ?? '', /user-owned/);
252
258
  }
253
259
  finally {
254
260
  if (priorTmux === undefined)
@@ -386,15 +386,15 @@ test('close still blocks on real .crouter/memory/ work — a tracked-file edit a
386
386
  run(['config', 'user.email', 'test@example.com'], memoryRepo);
387
387
  run(['config', 'user.name', 'Test'], memoryRepo);
388
388
  mkdirSync(join(memoryRepo, '.crouter', 'memory'), { recursive: true });
389
- writeFileSync(join(memoryRepo, '.crouter', 'memory', 'AGENTS.md'), 'tracked memory doc\n');
389
+ writeFileSync(join(memoryRepo, '.crouter', 'memory', 'tracked.md'), 'tracked memory doc\n');
390
390
  run(['add', '.'], memoryRepo);
391
391
  run(['commit', '-m', 'init with tracked memory'], memoryRepo);
392
392
  const editNodeId = 'wt-real-crouter-memory-edit';
393
393
  const editWt = createManagedWorktree(memoryRepo, editNodeId);
394
394
  createNode(node(editNodeId, { cwd: editWt.path, managed_worktree: editWt }));
395
- writeFileSync(join(editWt.path, '.crouter', 'memory', 'AGENTS.md'), 'edited tracked memory doc\n');
395
+ writeFileSync(join(editWt.path, '.crouter', 'memory', 'tracked.md'), 'edited tracked memory doc\n');
396
396
  const editStatus = gitSync(['status', '--porcelain'], editWt.path).stdout;
397
- assert.ok(editStatus.includes('M .crouter/memory/AGENTS.md'), 'a tracked .crouter/memory/ edit must still show as modified — exclude can never hide a tracked-file change');
397
+ assert.ok(editStatus.includes('M .crouter/memory/tracked.md'), 'a tracked .crouter/memory/ edit must still show as modified — exclude can never hide a tracked-file change');
398
398
  assert.throws(() => closeManagedWorktree(editNodeId), (error) => error instanceof Error && 'code' in error && error.code === 'dirty_worktree');
399
399
  const newDocNodeId = 'wt-real-crouter-memory-new-doc';
400
400
  const newDocWt = createManagedWorktree(memoryRepo, newDocNodeId);
@@ -20,8 +20,9 @@ export declare function readAuthCredentials(authPath?: string): Promise<AuthFile
20
20
  export declare function readAuthCredential(providerId: string, authPath?: string): Promise<Credential | undefined>;
21
21
  /** Persist one provider's credential under the auth.json lock, leaving every other entry intact. */
22
22
  export declare function setAuthCredential(providerId: string, credential: Credential, authPath?: string): Promise<void>;
23
- /** Remove one provider's credential (logout) under the auth.json lock. */
24
- export declare function deleteAuthCredential(providerId: string, authPath?: string): Promise<void>;
23
+ /** Remove one provider's credential (logout) under the auth.json lock. Returns
24
+ * whether a record was removed from the exact snapshot this write replaced. */
25
+ export declare function deleteAuthCredential(providerId: string, authPath?: string): Promise<boolean>;
25
26
  /**
26
27
  * The `CredentialStore` crouter hands to `ModelRuntime`. Backed directly by auth.json rather
27
28
  * than by an in-memory cache, so `modify` is a real cross-process read-modify-write: pi's
@@ -126,12 +126,14 @@ export async function setAuthCredential(providerId, credential, authPath = defau
126
126
  return { result: undefined, next: serialize(data) };
127
127
  });
128
128
  }
129
- /** Remove one provider's credential (logout) under the auth.json lock. */
129
+ /** Remove one provider's credential (logout) under the auth.json lock. Returns
130
+ * whether a record was removed from the exact snapshot this write replaced. */
130
131
  export async function deleteAuthCredential(providerId, authPath = defaultAuthPath()) {
131
- await new LockedJsonFile(authPath).withLockAsync(async (current) => {
132
+ return new LockedJsonFile(authPath).withLockAsync(async (current) => {
132
133
  const data = parse(current);
134
+ const removed = Object.hasOwn(data, providerId);
133
135
  delete data[providerId];
134
- return { result: undefined, next: serialize(data) };
136
+ return { result: removed, next: serialize(data) };
135
137
  });
136
138
  }
137
139
  /**
@@ -1,4 +1,4 @@
1
- export declare const CANVAS_EXTENSION_NAMES: readonly ["canvas-inbox-watcher", "canvas-stophook", "canvas-recap", "canvas-goal-capture", "canvas-passive-context", "canvas-context-intro", "canvas-doc-substrate", "canvas-structured-output", "canvas-bash-valve"];
1
+ export declare const CANVAS_EXTENSION_NAMES: readonly ["canvas-inbox-watcher", "canvas-stophook", "canvas-recap", "canvas-goal-capture", "canvas-passive-context", "canvas-context-intro", "canvas-tool-guide", "canvas-doc-substrate", "canvas-structured-output", "canvas-bash-valve"];
2
2
  /** Remove a complete persisted canvas prefix while retaining every genuine
3
3
  * caller/kind extra, including a custom extension whose basename happens to
4
4
  * resemble a canvas hook. */
@@ -1,18 +1,28 @@
1
1
  // Canvas-extension identity is structural, not basename-based: old runtime roots
2
- // are recognized only when they carry the complete shipped canvas prefix.
2
+ // are recognized only when they carry a complete shipped canvas prefix.
3
3
  // canvas-inbox-watcher must load before canvas-stophook: at agent_settled it
4
4
  // commits the cursor the stophook then reads to decide idle release.
5
5
  export const CANVAS_EXTENSION_NAMES = [
6
6
  'canvas-inbox-watcher', 'canvas-stophook', 'canvas-recap',
7
7
  'canvas-goal-capture', 'canvas-passive-context', 'canvas-context-intro',
8
- 'canvas-doc-substrate', 'canvas-structured-output', 'canvas-bash-valve',
8
+ 'canvas-tool-guide', 'canvas-doc-substrate', 'canvas-structured-output',
9
+ 'canvas-bash-valve',
9
10
  ];
10
- const LEGACY_CANVAS_EXTENSION_NAMES = [
11
- 'canvas-stophook', 'canvas-inbox-watcher', 'canvas-recap',
12
- 'canvas-goal-capture', 'canvas-passive-context', 'canvas-context-intro',
13
- 'canvas-doc-substrate', 'canvas-structured-output', 'canvas-bash-valve',
11
+ const HISTORICAL_CANVAS_EXTENSION_PREFIXES = [
12
+ [
13
+ 'canvas-inbox-watcher', 'canvas-stophook', 'canvas-recap',
14
+ 'canvas-goal-capture', 'canvas-passive-context', 'canvas-context-intro',
15
+ 'canvas-doc-substrate', 'canvas-structured-output', 'canvas-bash-valve',
16
+ ],
17
+ [
18
+ 'canvas-stophook', 'canvas-inbox-watcher', 'canvas-recap',
19
+ 'canvas-goal-capture', 'canvas-passive-context', 'canvas-context-intro',
20
+ 'canvas-doc-substrate', 'canvas-structured-output', 'canvas-bash-valve',
21
+ ],
14
22
  ];
15
23
  function matchesExtensionPrefix(extensions, names) {
24
+ if (extensions.length < names.length)
25
+ return false;
16
26
  const paths = extensions.slice(0, names.length);
17
27
  const first = paths[0].replaceAll('\\', '/');
18
28
  const marker = '/pi-extensions/';
@@ -24,11 +34,8 @@ function matchesExtensionPrefix(extensions, names) {
24
34
  return names.every((name, index) => paths[index].replaceAll('\\', '/') === `${root}${name}${suffix}`);
25
35
  }
26
36
  function extensionPrefixLength(extensions) {
27
- if (extensions.length < CANVAS_EXTENSION_NAMES.length)
28
- return 0;
29
- return matchesExtensionPrefix(extensions, CANVAS_EXTENSION_NAMES) || matchesExtensionPrefix(extensions, LEGACY_CANVAS_EXTENSION_NAMES)
30
- ? CANVAS_EXTENSION_NAMES.length
31
- : 0;
37
+ const prefixes = [CANVAS_EXTENSION_NAMES, ...HISTORICAL_CANVAS_EXTENSION_PREFIXES];
38
+ return prefixes.find((names) => matchesExtensionPrefix(extensions, names))?.length ?? 0;
32
39
  }
33
40
  /** Remove a complete persisted canvas prefix while retaining every genuine
34
41
  * caller/kind extra, including a custom extension whose basename happens to
@@ -23,9 +23,8 @@ export declare function fullName(node: NamedNode): string;
23
23
  * badge. */
24
24
  export declare function personaLabel(meta: Pick<NodeMeta, 'kind' | 'mode'>): string;
25
25
  /** The pi session display name — the editor label in the top-left. It carries
26
- * ONE name: the pi-generated description of the node's first task once naming
27
- * has run, else the spawn handle. Unlike `fullName` it never prints both, so
28
- * the chip doesn't read as two names side by side. Persona lives in the
29
- * editor's bottom-right badge. Recomputed from meta on every revive and pushed
30
- * live when naming generates the first task description. */
26
+ * ONE name: a person's explicit handle when present, otherwise the generated
27
+ * description of the node's first task. Unlike `fullName` it never prints
28
+ * both, so the chip doesn't read as two names side by side. Persona lives in
29
+ * the editor's bottom-right badge. Recomputed from meta on every revive. */
31
30
  export declare function editorLabel(meta: NodeMeta): string;
@@ -2,9 +2,9 @@
2
2
  //
3
3
  // One source of truth for "what do we call this node on screen". `fullName`
4
4
  // combines the node's explicit handle (meta.name) with the pi-generated
5
- // description of its first task (meta.description) — the same string the editor
6
- // label, the canvas dashboard tree, the nav spine, and the tmux window tab all
7
- // render, so a node reads identically everywhere it appears.
5
+ // description of its first task (meta.description) for canvas and tmux surfaces.
6
+ // `editorLabel` keeps the editor chip compact by choosing the explicit handle
7
+ // when a person supplied one, otherwise the generated description.
8
8
  /** Nerd Font glyph for a node's mode in its persona badge. A base worker reads
9
9
  * as a single figure (nf-fa-user, U+F007); an orchestrator reads as a fan-out
10
10
  * hierarchy (nf-fa-sitemap, U+F0E8). */
@@ -30,14 +30,14 @@ export function personaLabel(meta) {
30
30
  return `${modeIcon(meta.mode)} ${meta.kind}`;
31
31
  }
32
32
  /** The pi session display name — the editor label in the top-left. It carries
33
- * ONE name: the pi-generated description of the node's first task once naming
34
- * has run, else the spawn handle. Unlike `fullName` it never prints both, so
35
- * the chip doesn't read as two names side by side. Persona lives in the
36
- * editor's bottom-right badge. Recomputed from meta on every revive and pushed
37
- * live when naming generates the first task description. */
33
+ * ONE name: a person's explicit handle when present, otherwise the generated
34
+ * description of the node's first task. Unlike `fullName` it never prints
35
+ * both, so the chip doesn't read as two names side by side. Persona lives in
36
+ * the editor's bottom-right badge. Recomputed from meta on every revive. */
38
37
  export function editorLabel(meta) {
39
38
  const icon = (meta.icon ?? '').trim();
39
+ const handle = meta.name && meta.name !== meta.kind ? meta.name.trim() : '';
40
40
  const desc = (meta.description ?? '').trim();
41
- const name = desc !== '' ? desc : fullName(meta);
41
+ const name = handle !== '' ? handle : desc !== '' ? desc : meta.name;
42
42
  return icon !== '' ? `${icon} ${name}` : name;
43
43
  }
@@ -25,7 +25,12 @@ export type EdgeType = 'subscribes_to' | 'spawned_by';
25
25
  export interface LaunchSpec {
26
26
  /** Model id/pattern passed to pi `--model`. */
27
27
  model?: string;
28
- /** True only for a caller's explicit `provider/tier` or `provider/id` override. */
28
+ /** Original logical family/strength preference, retained after normalization. */
29
+ modelIntent?: {
30
+ family: string;
31
+ strength: import('../../types.js').ModelStrength;
32
+ };
33
+ /** True only for a caller's explicit concrete `provider/id` override. */
29
34
  modelProviderPinned?: boolean;
30
35
  /** True for an exact-consult launch: `model` is the verbatim captured
31
36
  * `provider/id:thinking` spec, bypassing the review floor/normalize and
@@ -27,7 +27,7 @@ const CONFIGURED_LEAF_KEYS = new Set([...LEAF_KEYS_COMMON, 'rest']);
27
27
  const ROOT_ENTRY_KEYS = new Set(['concept', 'description', 'whenToUse']);
28
28
  const FILE_ENCODINGS = new Set(['text', 'base64']);
29
29
  const PARAM_KEYS_BY_KIND = {
30
- positional: new Set(['kind', 'name', 'type', 'required', 'constraint', 'encoding', 'defaultFromEnv']),
30
+ positional: new Set(['kind', 'name', 'type', 'required', 'constraint', 'repeatable', 'encoding', 'defaultFromEnv']),
31
31
  flag: new Set([
32
32
  'kind',
33
33
  'name',
@@ -137,18 +137,31 @@ function validateParams(raw, path, issue) {
137
137
  issue('command_node_invalid', `positional type must be string|path`, String(p['type']), 'string | path | omitted', 'Fix the positional type.', `${pp}.type`);
138
138
  return null;
139
139
  }
140
+ let repeatable = false;
141
+ if (p['repeatable'] !== undefined) {
142
+ if (typeof p['repeatable'] !== 'boolean') {
143
+ issue('command_node_invalid', `repeatable must be a boolean`, typeName(p['repeatable']), 'true | false | omitted', 'Fix repeatable.', `${pp}.repeatable`);
144
+ return null;
145
+ }
146
+ repeatable = p['repeatable'];
147
+ }
140
148
  const encoding = validateFileEncoding(p, pp, issue);
141
149
  if (encoding === null)
142
150
  return null;
143
151
  const envDefault = validateDefaultFromEnv(p, pp, issue);
144
152
  if (envDefault === null)
145
153
  return null;
154
+ if (repeatable && envDefault !== undefined) {
155
+ issue('command_node_invalid', `repeatable positional must not declare defaultFromEnv`, name, 'no defaultFromEnv on a repeatable positional (values accumulate into an array)', 'Remove defaultFromEnv, or drop repeatable.', `${pp}.defaultFromEnv`);
156
+ return null;
157
+ }
146
158
  out.push({
147
159
  kind: 'positional',
148
160
  name,
149
161
  ...(p['type'] !== undefined ? { type: p['type'] } : {}),
150
162
  required: p['required'],
151
163
  constraint: p['constraint'],
164
+ ...(repeatable ? { repeatable: true } : {}),
152
165
  ...(encoding !== undefined ? { encoding } : {}),
153
166
  ...(envDefault !== undefined ? { defaultFromEnv: envDefault } : {}),
154
167
  });
@@ -251,7 +264,7 @@ function validateFileEncoding(p, pp, issue) {
251
264
  * so it is meaningful only on a string|path param, and it cannot coexist with a
252
265
  * static `default` (two competing sources for one slot is an authoring error,
253
266
  * not a precedence puzzle for the reader). The caller checks `repeatable`
254
- * separately, where that flag is in scope: a repeatable flag accumulates an
267
+ * separately, where that param is in scope: a repeatable param accumulates an
255
268
  * ARRAY, and one env string cannot honestly fill it. Returns the env var name,
256
269
  * undefined when absent, or null when invalid. */
257
270
  function validateDefaultFromEnv(p, pp, issue) {
@@ -716,11 +729,11 @@ function validateRestMapping(raw, basePath, params, issue) {
716
729
  return null;
717
730
  }
718
731
  }
719
- // A repeatable flag's parsed value is an array; only JSON body placement
732
+ // A repeatable param's parsed value is an array; only JSON body placement
720
733
  // can carry it faithfully (query/header/path would String() it into "a,b").
721
734
  const mappedParam = paramsByName.get(paramName);
722
- if (mappedParam.kind === 'flag' && mappedParam.repeatable === true && inValue !== 'body') {
723
- issue('command_rest_invalid', `repeatable flag must use body placement`, `${paramName}: in "${inValue}"`, 'in: "body" (array values ship as a JSON array)', `Map "${paramName}" to body placement.`, `${path}.params.${paramName}.in`);
735
+ if ((mappedParam.kind === 'flag' || mappedParam.kind === 'positional') && mappedParam.repeatable === true && inValue !== 'body') {
736
+ issue('command_rest_invalid', `repeatable param must use body placement`, `${paramName}: in "${inValue}"`, 'in: "body" (array values ship as a JSON array)', `Map "${paramName}" to body placement.`, `${path}.params.${paramName}.in`);
724
737
  return null;
725
738
  }
726
739
  // Collect body params for coalesced source validation
@@ -283,7 +283,8 @@ export async function parseArgv(params, tokens, options) {
283
283
  result[flagNameToKey(f.name)] = f.default;
284
284
  }
285
285
  }
286
- let positionalValue;
286
+ const positionalValues = [];
287
+ const repeatablePositional = positionalParam?.repeatable === true;
287
288
  let positionalWasSupplied = false; // Track if positional came from argv (not stdin)
288
289
  let forcedPositional = false; // true after bare --
289
290
  let i = 0;
@@ -385,7 +386,13 @@ export async function parseArgv(params, tokens, options) {
385
386
  continue;
386
387
  }
387
388
  // Positional (or token after --)
388
- if (positionalValue !== undefined) {
389
+ if (repeatablePositional && positionalParam !== undefined) {
390
+ positionalValues.push(token);
391
+ positionalWasSupplied = true;
392
+ i++;
393
+ continue;
394
+ }
395
+ if (positionalValues.length > 0) {
389
396
  // A leaf that also declares a `stdin` param treats its stdin value as
390
397
  // write-only/sensitive by convention (`profile env set`'s value, etc.).
391
398
  // An unexpected extra positional here is the single most likely misuse
@@ -404,13 +411,15 @@ export async function parseArgv(params, tokens, options) {
404
411
  if (positionalParam === undefined && stdinParam === undefined) {
405
412
  throw parseArgvError('bad_invocation', `this leaf takes no positional arguments: ${token}`, token, undefined, 'Use --flag for parameters. Run -h for the schema.');
406
413
  }
407
- positionalValue = token;
414
+ positionalValues.push(token);
408
415
  positionalWasSupplied = true;
409
416
  i++;
410
417
  }
411
418
  // Assign positional (already tracked in provided set above)
412
- if (positionalValue !== undefined && positionalParam !== undefined) {
413
- result[flagNameToKey(positionalParam.name)] = positionalValue;
419
+ if (positionalValues.length > 0 && positionalParam !== undefined) {
420
+ result[flagNameToKey(positionalParam.name)] = repeatablePositional
421
+ ? positionalValues
422
+ : positionalValues[0];
414
423
  }
415
424
  // Resolve stdin if declared. A positional token (when there's no dedicated
416
425
  // positional param to claim it) satisfies the stdin param directly, so
@@ -421,7 +430,8 @@ export async function parseArgv(params, tokens, options) {
421
430
  // and name both sources.
422
431
  let stdinWasSupplied = false;
423
432
  if (stdinParam !== undefined) {
424
- if (positionalValue !== undefined && positionalParam === undefined) {
433
+ if (positionalValues.length > 0 && positionalParam === undefined) {
434
+ const positionalValue = positionalValues[0];
425
435
  if (positionalValue === '-') {
426
436
  const raw = await readStdinRaw();
427
437
  // Empty required stdin is NOT thrown here: it is left unset so the
@@ -492,7 +502,7 @@ export async function parseArgv(params, tokens, options) {
492
502
  const envValue = process.env[p.defaultFromEnv];
493
503
  if (envValue === undefined || envValue === '')
494
504
  continue;
495
- result[key] = envValue;
505
+ result[key] = p.kind === 'positional' && p.repeatable === true ? [envValue] : envValue;
496
506
  provided.add(key);
497
507
  }
498
508
  // Validate required params — COMPLETE, never first-failure. Drip-feeding one
@@ -1,4 +1,4 @@
1
- import type { Scope, ScopeConfig, ScopeState, KindConfig, ModelLaddersConfig, ModelProvider, ModelStrength } from '../types.js';
1
+ import type { Scope, ScopeConfig, ScopeState, KindConfig, ModelLaddersConfig, ModelProvider, ModelStrength, ModelRouteConfig, ModelRoutingConfig } from '../types.js';
2
2
  export declare function configPath(scope: Scope): string | null;
3
3
  export declare function statePath(scope: Scope): string | null;
4
4
  export declare function readConfig(scope: Scope): ScopeConfig;
@@ -33,6 +33,8 @@ export declare function readRawScopeConfig(scope: Scope): Partial<ScopeConfig> |
33
33
  export interface MergedLaunchConfig {
34
34
  kinds: Record<string, KindConfig>;
35
35
  modelLadders: ModelLaddersConfig;
36
+ modelRoutes?: Record<string, ModelRouteConfig>;
37
+ modelRouting?: ModelRoutingConfig;
36
38
  /** The merged `spawnEnv.allow` set (project stack > profile > user >
37
39
  * builtin, additive) — read by `buildOperationalEnvBase`
38
40
  * (`core/runtime/spawn-env.ts`) as source C of the broker spawn-env
@@ -2,6 +2,7 @@ import { mkdirSync, rmSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
3
  import { isDeepStrictEqual } from 'node:util';
4
4
  import { CONFIG_FILE, STATE_FILE, CONDENSED_HISTORY_MODES, defaultScopeConfig, defaultScopeState, defaultModelLaddersConfig, defaultKindsConfig, defaultRemoteCanvasConfig } from '../types.js';
5
+ import { emitEvent } from './events/emit.js';
5
6
  import { atomicWriteJson, readJsonIfExists, writeJson, ensureDir } from './fs-utils.js';
6
7
  import { scopeRoot, requireScopeRoot, findProjectScopeRoots } from './scope.js';
7
8
  import { profileRoot } from './profiles/manifest.js';
@@ -172,6 +173,83 @@ function mergeModelLadders(raw, base = defaultModelLaddersConfig()) {
172
173
  }
173
174
  return out;
174
175
  }
176
+ function validStrength(value) {
177
+ return value === 'ultra' || value === 'strong' || value === 'medium' || value === 'light';
178
+ }
179
+ /** Validate and overlay route entries. Invalid declarations are loud but local:
180
+ * a typo must not prevent unrelated launch configuration from resolving. */
181
+ function mergeModelRoutes(raw, base = {}) {
182
+ const out = { ...base };
183
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
184
+ return out;
185
+ for (const [routeId, value] of Object.entries(raw)) {
186
+ const patch = value;
187
+ const invalid = (rule) => { emitEvent({ level: 'warn', event: 'config.model_routes.invalid', fields: { route_id: routeId, rule } }); };
188
+ if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
189
+ invalid('route must be an object');
190
+ continue;
191
+ }
192
+ const route = { ...out[routeId], ...patch, models: { ...out[routeId]?.models, ...patch.models } };
193
+ if (typeof route.family !== 'string' || route.family.trim() === '') {
194
+ invalid('family must be a non-empty string');
195
+ continue;
196
+ }
197
+ if (typeof route.credentialSource !== 'string' || route.credentialSource.trim() === '') {
198
+ invalid('credentialSource must be a non-empty string');
199
+ continue;
200
+ }
201
+ if (typeof route.providerId !== 'string' || route.providerId.trim() === '') {
202
+ invalid('providerId must be a non-empty string');
203
+ continue;
204
+ }
205
+ if (!route.models || typeof route.models !== 'object' || Array.isArray(route.models)) {
206
+ invalid('models must be an object');
207
+ continue;
208
+ }
209
+ const models = {};
210
+ let rule;
211
+ for (const [strength, spec] of Object.entries(route.models)) {
212
+ if (!validStrength(strength)) {
213
+ rule = `models.${strength} is not a model strength`;
214
+ break;
215
+ }
216
+ if (typeof spec !== 'string' || spec.trim() === '') {
217
+ rule = `models.${strength} must be a non-empty provider/id ref`;
218
+ break;
219
+ }
220
+ const slash = spec.indexOf('/');
221
+ if (slash <= 0 || slash === spec.length - 1) {
222
+ rule = `models.${strength} must be provider/id`;
223
+ break;
224
+ }
225
+ if (spec.slice(0, slash) !== route.providerId) {
226
+ rule = `models.${strength} provider prefix must equal providerId`;
227
+ break;
228
+ }
229
+ models[strength] = spec;
230
+ }
231
+ if (rule !== undefined) {
232
+ invalid(rule);
233
+ continue;
234
+ }
235
+ out[routeId] = { family: route.family, credentialSource: route.credentialSource, providerId: route.providerId, models };
236
+ }
237
+ return out;
238
+ }
239
+ function mergeModelRouting(raw, base) {
240
+ if (raw === undefined)
241
+ return base;
242
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
243
+ return base;
244
+ const value = raw;
245
+ const strings = (items) => Array.isArray(items) && items.every((item) => typeof item === 'string' && item.trim() !== '') ? [...items] : undefined;
246
+ const strengths = Array.isArray(value.strengthFallback) && value.strengthFallback.every((item) => validStrength(item)) ? [...value.strengthFallback] : undefined;
247
+ return {
248
+ ...(strings(value.credentialSourceOrder) !== undefined ? { credentialSourceOrder: strings(value.credentialSourceOrder) } : {}),
249
+ ...(strings(value.familyOrder) !== undefined ? { familyOrder: strings(value.familyOrder) } : {}),
250
+ ...(strengths !== undefined ? { strengthFallback: strengths } : {}),
251
+ };
252
+ }
175
253
  /** True when `pattern` (a `spawnEnv.allow` entry) matches EVERY possible env
176
254
  * var name — a bare `*`, or any run of nothing but `*` characters (`**`,
177
255
  * `***`, …). This is the one glob shape that reopens the inherit-all hole
@@ -362,10 +440,12 @@ function mergeConfig(partial) {
362
440
  const fold_finished_tools = typeof partial.fold_finished_tools === 'boolean' ? partial.fold_finished_tools : defaults.fold_finished_tools;
363
441
  const keybindings = normalizeKeybindings(partial.keybindings);
364
442
  const modelLadders = mergeModelLadders(partial.modelLadders);
443
+ const modelRoutes = mergeModelRoutes(partial.modelRoutes);
444
+ const modelRouting = mergeModelRouting(partial.modelRouting, undefined);
365
445
  const kinds = mergeKinds(partial.kinds);
366
446
  const remoteCanvas = mergeRemoteCanvas(partial.remoteCanvas);
367
447
  const spawnEnv = mergeSpawnEnv(partial.spawnEnv, defaults.spawnEnv?.allow);
368
- return { schema_version, marketplaces, plugins, configuredClis, auto_update, max_panes_per_window, brokerThresholds, completion_bell, live_cycles, condensed_history, fold_finished_tools, keybindings, modelLadders, kinds, remoteCanvas, spawnEnv };
448
+ return { schema_version, marketplaces, plugins, configuredClis, auto_update, max_panes_per_window, brokerThresholds, completion_bell, live_cycles, condensed_history, fold_finished_tools, keybindings, modelLadders, ...(Object.keys(modelRoutes).length > 0 ? { modelRoutes } : {}), ...(modelRouting !== undefined ? { modelRouting } : {}), kinds, remoteCanvas, spawnEnv };
369
449
  }
370
450
  /** Raw (un-defaulted) partial config for one scope, or null if the scope has
371
451
  * no root or no config.json. Used by `readMergedLaunchConfig` to layer
@@ -450,18 +530,24 @@ export function readMergedLaunchConfig(targetCwd = process.cwd(), targetProfileI
450
530
  const projectRawsFarthestFirst = readRawProjectScopeConfigs(targetCwd, targetProfileId);
451
531
  let kinds = mergeKinds(userRaw?.kinds, defaults.kinds);
452
532
  let modelLadders = mergeModelLadders(userRaw?.modelLadders, defaults.modelLadders);
533
+ let modelRoutes = mergeModelRoutes(userRaw?.modelRoutes);
534
+ let modelRouting = mergeModelRouting(userRaw?.modelRouting, undefined);
453
535
  let spawnEnv = mergeSpawnEnv(userRaw?.spawnEnv, defaults.spawnEnv?.allow);
454
536
  if (profileRaw !== null) {
455
537
  kinds = mergeKinds(profileRaw.kinds, kinds);
456
538
  modelLadders = mergeModelLadders(profileRaw.modelLadders, modelLadders);
539
+ modelRoutes = mergeModelRoutes(profileRaw.modelRoutes, modelRoutes);
540
+ modelRouting = mergeModelRouting(profileRaw.modelRouting, modelRouting);
457
541
  spawnEnv = mergeSpawnEnv(profileRaw.spawnEnv, spawnEnv.allow);
458
542
  }
459
543
  for (const raw of projectRawsFarthestFirst) {
460
544
  kinds = mergeKinds(raw.kinds, kinds);
461
545
  modelLadders = mergeModelLadders(raw.modelLadders, modelLadders);
546
+ modelRoutes = mergeModelRoutes(raw.modelRoutes, modelRoutes);
547
+ modelRouting = mergeModelRouting(raw.modelRouting, modelRouting);
462
548
  spawnEnv = mergeSpawnEnv(raw.spawnEnv, spawnEnv.allow);
463
549
  }
464
- return { kinds, modelLadders, spawnEnv };
550
+ return { kinds, modelLadders, ...(Object.keys(modelRoutes).length > 0 ? { modelRoutes } : {}), ...(modelRouting !== undefined ? { modelRouting } : {}), spawnEnv };
465
551
  }
466
552
  /** The effective `KindConfig` for one full kind string (top-level, e.g.
467
553
  * `developer`, or sub-kind, e.g. `plan/reviewers/security`), across
@@ -190,6 +190,8 @@ function materializeBodyValue(spec, param, value) {
190
190
  if (param.kind === 'context-file')
191
191
  return value;
192
192
  if ((param.kind === 'positional' || param.kind === 'flag') && param.type === 'path') {
193
+ if (Array.isArray(value))
194
+ return value.map((item) => materializeBodyValue(spec, param, item));
193
195
  const filePath = String(value);
194
196
  const resolved = resolvePath(process.cwd(), filePath);
195
197
  let stat;
@@ -6,7 +6,7 @@ export interface Field {
6
6
  * token caps. Lives here, never in a separate Preconditions section. */
7
7
  constraint: string;
8
8
  }
9
- /** Positional argument — at most one per leaf. */
9
+ /** Positional argument — at most one parameter per leaf; optionally repeatable. */
10
10
  export interface PositionalParam {
11
11
  kind: 'positional';
12
12
  name: string;
@@ -14,6 +14,8 @@ export interface PositionalParam {
14
14
  type?: 'string' | 'path';
15
15
  required: boolean;
16
16
  constraint: string;
17
+ /** When true, every positional token is collected into an array in argv order. */
18
+ repeatable?: boolean;
17
19
  /** Local-file materialization contract, valid only on a `path` param. The
18
20
  * CLI-side value is a path to a local file; the invoker reads that file and
19
21
  * sends its CONTENT as the mapped value — 'text' as UTF-8, 'base64' as the
package/dist/core/help.js CHANGED
@@ -175,7 +175,7 @@ export function renderBranch(h) {
175
175
  /** Build the display label for a param entry (left column). */
176
176
  function paramLabel(p) {
177
177
  if (p.kind === 'positional')
178
- return p.name.toUpperCase();
178
+ return `${p.name.toUpperCase()}${p.repeatable === true ? '...' : ''}`;
179
179
  if (p.kind === 'stdin')
180
180
  return 'stdin';
181
181
  if (p.kind === 'context-file')
@@ -207,7 +207,10 @@ function envDefaultNote(envVar) {
207
207
  function paramDesc(p) {
208
208
  const req = p.required ? 'required' : 'optional';
209
209
  if (p.kind === 'positional') {
210
- return `positional, ${req}.${fileEncodingNote(p.encoding)}${envDefaultNote(p.defaultFromEnv)} ${p.constraint}`;
210
+ const repeatable = p.repeatable === true
211
+ ? ' Repeatable — pass multiple values to accumulate in argv order.'
212
+ : '';
213
+ return `positional, ${req}.${fileEncodingNote(p.encoding)}${envDefaultNote(p.defaultFromEnv)} ${p.constraint}${repeatable}`;
211
214
  }
212
215
  if (p.kind === 'stdin')
213
216
  return `${req}. ${p.constraint}`;
@@ -13,9 +13,12 @@ void rejectedBindingId;
13
13
  function ids(prefix, actions) {
14
14
  return actions.map((action) => `${prefix}.${action}`);
15
15
  }
16
+ function pairedMenuIds(actions) {
17
+ return actions.flatMap((action) => [`crtr.tmux.menu.${action}`, `crtr.tmux.menu-global.${action}`]);
18
+ }
16
19
  const REQUIRED_IDS = [
17
- ...ids('crtr.tmux.menu', ['open', 'promote', 'resume', 'fork', 'demote', 'detach', 'close-subtree', 'background-bash', 'file-review', 'pin', 'show', 'view-settings', 'fold-tools', 'metadata', 'context', 'providers', 'graph', 'focus-manager', 'copy-id', 'reload-viewer', 'issues']),
18
- ...ids('crtr.tmux.menu.issue', ['idea', 'bug']),
20
+ 'crtr.tmux.menu.open',
21
+ ...pairedMenuIds(['promote', 'resume', 'fork', 'demote', 'detach', 'close-subtree', 'background-bash', 'file-review', 'pin', 'show', 'view-settings', 'fold-tools', 'metadata', 'context', 'providers', 'graph', 'focus-manager', 'copy-id', 'reload-viewer', 'issues', 'issue.idea', 'issue.bug']),
19
22
  ...ids('crtr.tmux.node', ['next', 'previous']),
20
23
  'crtr.tmux.inbox.toggle',
21
24
  ...ids('crtr.tmux.view', ['next', 'previous']),