@north-light/crouter 0.3.262 → 0.3.269

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 (150) hide show
  1. package/dist/api/client.d.ts +5 -1
  2. package/dist/api/client.js +6 -0
  3. package/dist/api/dto/canvas.d.ts +53 -2
  4. package/dist/api/dto/health.d.ts +2 -0
  5. package/dist/api/dto/nodes.d.ts +6 -2
  6. package/dist/api/routes.d.ts +1 -0
  7. package/dist/api/routes.js +1 -0
  8. package/dist/builtin-memory/05-kinds/advisor/advice-contract.md +2 -2
  9. package/dist/builtin-memory/internal/memory-loading.md +3 -3
  10. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/integration/provider-rotation.test.ts +50 -0
  11. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/memory-slash-commands.ts +46 -6
  12. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.js +10 -5
  13. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +12 -5
  14. package/dist/clients/attach/input/controller.d.ts +4 -0
  15. package/dist/clients/attach/input/controller.js +8 -0
  16. package/dist/clients/attach/session/bindings.d.ts +3 -0
  17. package/dist/clients/attach/session/bindings.js +7 -0
  18. package/dist/clients/attach/session/keys.d.ts +2 -0
  19. package/dist/clients/attach/session/keys.js +3 -3
  20. package/dist/clients/attach/session/whip-achievements.d.ts +4 -0
  21. package/dist/clients/attach/session/whip-achievements.js +145 -0
  22. package/dist/clients/attach/slash/dispatch.js +5 -3
  23. package/dist/clients/attach/viewer.js +668 -667
  24. package/dist/commands/canvas-history/grep.js +3 -22
  25. package/dist/commands/canvas-history/read.js +5 -3
  26. package/dist/commands/canvas-history/search.js +3 -22
  27. package/dist/commands/canvas-history/shared.d.ts +8 -0
  28. package/dist/commands/canvas-history/shared.js +64 -1
  29. package/dist/commands/canvas-history/stats.d.ts +1 -0
  30. package/dist/commands/canvas-history/stats.js +68 -0
  31. package/dist/commands/canvas-history.js +4 -3
  32. package/dist/commands/memory/read.js +15 -38
  33. package/dist/commands/memory/shared.d.ts +1 -1
  34. package/dist/commands/memory/shared.js +1 -1
  35. package/dist/commands/node/create.js +2 -2
  36. package/dist/commands/sys/config.js +19 -2
  37. package/dist/commands/sys/context/admin/docs-panel.d.ts +3 -3
  38. package/dist/commands/sys/context/admin/model.d.ts +7 -2
  39. package/dist/commands/sys/context/admin/model.js +7 -1
  40. package/dist/commands/sys/context/admin/read-view.d.ts +5 -5
  41. package/dist/commands/sys/context/admin/read-view.js +9 -4
  42. package/dist/commands/sys/context/resolve.d.ts +32 -3
  43. package/dist/commands/sys/context/resolve.js +74 -25
  44. package/dist/commands/sys/panels/models-panel.d.ts +28 -5
  45. package/dist/commands/sys/panels/models-panel.js +560 -75
  46. package/dist/core/__tests__/integration/broker-sdk-wiring.test.js +1 -1
  47. package/dist/core/__tests__/integration/revive.test.js +4 -2
  48. package/dist/core/__tests__/model-pin-durability.test.js +25 -1
  49. package/dist/core/__tests__/model-routes-config.test.d.ts +1 -0
  50. package/dist/core/__tests__/model-routes-config.test.js +142 -0
  51. package/dist/core/__tests__/on-read-nested-store.test.js +8 -0
  52. package/dist/core/__tests__/profile-project-memory-delivery.test.js +264 -1
  53. package/dist/core/__tests__/relaunch-root.test.js +17 -0
  54. package/dist/core/__tests__/seam/broker-provider-retry.test.js +21 -6
  55. package/dist/core/__tests__/seam/memory-slash-node-relative-inventory.test.js +19 -2
  56. package/dist/core/broker-client/__tests__/transport-relay.test.js +3 -1
  57. package/dist/core/broker-client/transport-relay.js +5 -1
  58. package/dist/core/canvas/__tests__/history-transcript.test.d.ts +1 -0
  59. package/dist/core/canvas/__tests__/history-transcript.test.js +102 -0
  60. package/dist/core/canvas/browse/app.d.ts +1 -1
  61. package/dist/core/canvas/browse/app.js +13 -10
  62. package/dist/core/canvas/browse/model.js +16 -1
  63. package/dist/core/canvas/browse/render.d.ts +23 -3
  64. package/dist/core/canvas/browse/render.js +429 -64
  65. package/dist/core/canvas/canvas.js +12 -8
  66. package/dist/core/canvas/extensions.d.ts +1 -1
  67. package/dist/core/canvas/extensions.js +7 -1
  68. package/dist/core/canvas/history.d.ts +36 -1
  69. package/dist/core/canvas/history.js +328 -7
  70. package/dist/core/canvas/labels.d.ts +2 -2
  71. package/dist/core/canvas/migrations.js +32 -0
  72. package/dist/core/canvas/node-recap.d.ts +23 -0
  73. package/dist/core/canvas/node-recap.js +67 -0
  74. package/dist/core/canvas/render-source.d.ts +34 -2
  75. package/dist/core/canvas/render-source.js +83 -24
  76. package/dist/core/canvas/types.d.ts +9 -0
  77. package/dist/core/config.d.ts +19 -2
  78. package/dist/core/config.js +174 -51
  79. package/dist/core/keybindings/catalog.d.ts +2 -2
  80. package/dist/core/keybindings/catalog.js +11 -8
  81. package/dist/core/model-routes.js +11 -4
  82. package/dist/core/runtime/broker/auth-reload.js +4 -1
  83. package/dist/core/runtime/broker/fault-retry.js +7 -2
  84. package/dist/core/runtime/broker/frame-client.js +4 -0
  85. package/dist/core/runtime/broker/frame-dispatch.js +7 -0
  86. package/dist/core/runtime/broker/inbox.d.ts +12 -2
  87. package/dist/core/runtime/broker/inbox.js +68 -38
  88. package/dist/core/runtime/broker-protocol.d.ts +6 -0
  89. package/dist/core/runtime/broker.js +14 -0
  90. package/dist/core/runtime/canvas-extensions.d.ts +1 -0
  91. package/dist/core/runtime/canvas-extensions.js +2 -0
  92. package/dist/core/runtime/interactive-deliver.d.ts +8 -2
  93. package/dist/core/runtime/interactive-deliver.js +12 -3
  94. package/dist/core/runtime/launch-target.js +1 -1
  95. package/dist/core/runtime/launch.d.ts +12 -6
  96. package/dist/core/runtime/launch.js +19 -17
  97. package/dist/core/runtime/model-swap.d.ts +5 -4
  98. package/dist/core/runtime/model-swap.js +7 -5
  99. package/dist/core/runtime/promote.js +2 -1
  100. package/dist/core/runtime/reset.js +4 -2
  101. package/dist/core/runtime/revive.js +22 -0
  102. package/dist/core/runtime/spawn.d.ts +3 -3
  103. package/dist/core/runtime/spawn.js +20 -3
  104. package/dist/core/runtime/stamp/channel.d.ts +18 -0
  105. package/dist/core/runtime/stamp/channel.js +74 -0
  106. package/dist/core/runtime/stamp/protocol.d.ts +86 -0
  107. package/dist/core/runtime/stamp/protocol.js +113 -0
  108. package/dist/core/runtime/tmux-driver.d.ts +11 -1
  109. package/dist/core/runtime/tmux-driver.js +27 -11
  110. package/dist/core/self-update.js +2 -5
  111. package/dist/core/substrate/listings.d.ts +1 -1
  112. package/dist/core/substrate/listings.js +6 -2
  113. package/dist/core/substrate/on-read.d.ts +61 -5
  114. package/dist/core/substrate/on-read.js +129 -22
  115. package/dist/core/substrate/render.d.ts +3 -2
  116. package/dist/core/substrate/render.js +133 -38
  117. package/dist/core/tui/draw.d.ts +1 -0
  118. package/dist/core/tui/draw.js +5 -1
  119. package/dist/core/tui/markdown.d.ts +17 -0
  120. package/dist/core/tui/markdown.js +407 -0
  121. package/dist/core/user-settings.d.ts +4 -0
  122. package/dist/core/user-settings.js +1 -0
  123. package/dist/daemon/api/__tests__/node-model-validation.test.js +21 -0
  124. package/dist/daemon/api/__tests__/seam/api-server.test.js +34 -0
  125. package/dist/daemon/api/bridge.d.ts +3 -2
  126. package/dist/daemon/api/bridge.js +34 -9
  127. package/dist/daemon/api/handlers/canvas.js +80 -18
  128. package/dist/daemon/api/handlers/health.js +17 -12
  129. package/dist/daemon/api/handlers/messages.js +12 -1
  130. package/dist/daemon/api/handlers/nodes.js +10 -3
  131. package/dist/daemon/api/map.js +2 -0
  132. package/dist/daemon/messaging/node-message.js +15 -1
  133. package/dist/daemon/reconcilers/broker-supervision.js +26 -9
  134. package/dist/daemon/reconcilers/live-obligation.d.ts +8 -1
  135. package/dist/daemon/reconcilers/live-obligation.js +17 -0
  136. package/dist/pi-extensions/__tests__/pre-command-gate.test.js +2 -2
  137. package/dist/pi-extensions/canvas-inbox-watcher.js +27 -2
  138. package/dist/pi-extensions/canvas-recap.js +4 -0
  139. package/dist/pi-extensions/canvas-stamp.d.ts +14 -0
  140. package/dist/pi-extensions/canvas-stamp.js +77 -0
  141. package/dist/pi-extensions/canvas-stophook.js +3 -0
  142. package/dist/shared/__tests__/env-boundary.test.js +4 -2
  143. package/dist/shared/crtr-version.d.ts +2 -0
  144. package/dist/shared/crtr-version.js +18 -0
  145. package/dist/shared/env.d.ts +10 -0
  146. package/dist/shared/env.js +19 -4
  147. package/dist/types.d.ts +14 -2
  148. package/dist/types.js +4 -0
  149. package/package.json +1 -1
  150. package/runtime.lock.json +2 -2
@@ -114,10 +114,11 @@ export function ensureScopeInitialized(scope, root) {
114
114
  // a missing `modelLadders` from whatever build is CURRENTLY running, and an explicit
115
115
  // user override (`sys config set modelLadders...`) still persists and still wins.
116
116
  // Keybindings are sparse user overrides, so an untouched scope omits that member too.
117
- const { modelLadders: _omittedModelLadders, keybindings: _omittedKeybindings, tmux_passthrough: _omittedTmuxPassthrough, brokerThresholds, ...rest } = defaultScopeConfig();
118
- // Broker thresholds are host-wide daemon policy, so only user scope
119
- // persists them. Project config must never acquire a machine setting.
120
- atomicWriteJson(cfgPath, scope === 'user' ? { ...rest, brokerThresholds } : rest);
117
+ const { modelLadders: _omittedModelLadders, keybindings: _omittedKeybindings, tmux_passthrough: _omittedTmuxPassthrough, brokerThresholds, lifecycle, ...rest } = defaultScopeConfig();
118
+ // Broker thresholds and lifecycle timing are host-wide daemon policy, so
119
+ // only user scope persists them. Project config must never acquire a
120
+ // machine setting.
121
+ atomicWriteJson(cfgPath, scope === 'user' ? { ...rest, brokerThresholds, lifecycle } : rest);
121
122
  });
122
123
  }
123
124
  function normalizeKeybindings(raw) {
@@ -167,66 +168,140 @@ function mergeModelLadders(raw, base = defaultModelLaddersConfig()) {
167
168
  function validStrength(value) {
168
169
  return value === 'ultra' || value === 'strong' || value === 'medium' || value === 'light';
169
170
  }
170
- /** Validate and overlay route entries. Invalid declarations are loud but local:
171
- * a typo must not prevent unrelated launch configuration from resolving. */
172
- function mergeModelRoutes(raw, base = {}) {
173
- const out = { ...base };
171
+ const MODEL_STRENGTHS = ['ultra', 'strong', 'medium', 'light'];
172
+ function routeRecord() {
173
+ return Object.create(null);
174
+ }
175
+ function setRouteRecord(record, routeId, value) {
176
+ Object.defineProperty(record, routeId, { value, writable: true, enumerable: true, configurable: true });
177
+ }
178
+ function copyRouteRecord(record) {
179
+ const copy = routeRecord();
180
+ for (const [routeId, value] of Object.entries(record ?? {}))
181
+ setRouteRecord(copy, routeId, value);
182
+ return copy;
183
+ }
184
+ function routeInvalid(routeId, rule) {
185
+ emitEvent({ level: 'warn', event: 'config.model_routes.invalid', fields: { route_id: routeId, rule } });
186
+ }
187
+ /** Overlay raw route patches first so a higher scope may replace one cell of a
188
+ * complete lower route. Completeness belongs to the resulting effective route,
189
+ * never an individual raw scope patch. */
190
+ function mergeModelRoutePatches(raw, base = {}) {
191
+ const out = copyRouteRecord(base);
174
192
  if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
175
193
  return out;
176
194
  for (const [routeId, value] of Object.entries(raw)) {
177
- const patch = value;
178
- const invalid = (rule) => { emitEvent({ level: 'warn', event: 'config.model_routes.invalid', fields: { route_id: routeId, rule } }); };
179
- if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
180
- invalid('route must be an object');
181
- continue;
182
- }
183
- const route = { ...out[routeId], ...patch, models: { ...out[routeId]?.models, ...patch.models } };
184
- if (typeof route.family !== 'string' || route.family.trim() === '') {
185
- invalid('family must be a non-empty string');
186
- continue;
187
- }
188
- if (typeof route.credentialSource !== 'string' || route.credentialSource.trim() === '') {
189
- invalid('credentialSource must be a non-empty string');
190
- continue;
191
- }
192
- if (typeof route.providerId !== 'string' || route.providerId.trim() === '') {
193
- invalid('providerId must be a non-empty string');
195
+ if (routeId.trim() === '') {
196
+ routeInvalid(routeId, 'route id must be a non-empty string');
194
197
  continue;
195
198
  }
196
- if (!route.models || typeof route.models !== 'object' || Array.isArray(route.models)) {
197
- invalid('models must be an object');
199
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
200
+ routeInvalid(routeId, 'route must be an object');
198
201
  continue;
199
202
  }
200
- const models = {};
203
+ const patch = value;
204
+ const current = out[routeId];
205
+ const route = { ...current };
201
206
  let rule;
202
- for (const [strength, spec] of Object.entries(route.models)) {
203
- if (!validStrength(strength)) {
204
- rule = `models.${strength} is not a model strength`;
205
- break;
206
- }
207
- if (typeof spec !== 'string' || spec.trim() === '') {
208
- rule = `models.${strength} must be a non-empty provider/id ref`;
207
+ for (const field of ['family', 'credentialSource', 'providerId']) {
208
+ const candidate = patch[field];
209
+ if (candidate === undefined)
210
+ continue;
211
+ if (typeof candidate !== 'string' || candidate.trim() === '') {
212
+ rule = `${field} must be a non-empty string`;
209
213
  break;
210
214
  }
211
- const slash = spec.indexOf('/');
212
- if (slash <= 0 || slash === spec.length - 1) {
213
- rule = `models.${strength} must be provider/id`;
214
- break;
215
+ route[field] = candidate;
216
+ }
217
+ if (rule === undefined && patch.models !== undefined) {
218
+ if (patch.models === null || typeof patch.models !== 'object' || Array.isArray(patch.models))
219
+ rule = 'models must be an object';
220
+ else {
221
+ const models = { ...current?.models };
222
+ for (const [strength, spec] of Object.entries(patch.models)) {
223
+ if (!validStrength(strength)) {
224
+ rule = `models.${strength} is not a model strength`;
225
+ break;
226
+ }
227
+ if (typeof spec !== 'string' || spec.trim() === '') {
228
+ rule = `models.${strength} must be a non-empty provider/id ref`;
229
+ break;
230
+ }
231
+ const slash = spec.indexOf('/');
232
+ if (slash <= 0 || slash === spec.length - 1) {
233
+ rule = `models.${strength} must be provider/id`;
234
+ break;
235
+ }
236
+ models[strength] = spec;
237
+ }
238
+ route.models = models;
215
239
  }
216
- if (spec.slice(0, slash) !== route.providerId) {
217
- rule = `models.${strength} provider prefix must equal providerId`;
218
- break;
240
+ }
241
+ if (rule === undefined && route.models !== undefined && route.providerId !== undefined) {
242
+ for (const [strength, spec] of Object.entries(route.models)) {
243
+ if (spec.slice(0, spec.indexOf('/')) !== route.providerId) {
244
+ rule = `models.${strength} provider prefix must equal providerId`;
245
+ break;
246
+ }
219
247
  }
220
- models[strength] = spec;
221
248
  }
222
249
  if (rule !== undefined) {
223
- invalid(rule);
250
+ routeInvalid(routeId, rule);
224
251
  continue;
225
252
  }
226
- out[routeId] = { family: route.family, credentialSource: route.credentialSource, providerId: route.providerId, models };
253
+ setRouteRecord(out, routeId, route);
227
254
  }
228
255
  return out;
229
256
  }
257
+ /** Resolve complete routes after every scope has overlaid its sparse raw patch.
258
+ * Sparse standalone routes are read for one compatibility release only: they
259
+ * retain their historical candidate behavior, emit a repair warning, and never
260
+ * gain invented cells. New writes cannot create them. */
261
+ function resolveModelRoutes(routes) {
262
+ const complete = routeRecord();
263
+ const legacy = routeRecord();
264
+ for (const [routeId, route] of Object.entries(routes)) {
265
+ if (route.family === undefined || route.credentialSource === undefined || route.providerId === undefined || route.models === undefined) {
266
+ routeInvalid(routeId, 'route must include family, credentialSource, providerId, and models');
267
+ continue;
268
+ }
269
+ const missing = MODEL_STRENGTHS.filter((strength) => route.models[strength] === undefined);
270
+ if (missing.length > 0) {
271
+ routeInvalid(routeId, `incomplete route; missing strengths: ${missing.join(', ')}`);
272
+ setRouteRecord(legacy, routeId, { family: route.family, credentialSource: route.credentialSource, providerId: route.providerId, models: route.models });
273
+ continue;
274
+ }
275
+ setRouteRecord(complete, routeId, {
276
+ family: route.family,
277
+ credentialSource: route.credentialSource,
278
+ providerId: route.providerId,
279
+ models: route.models,
280
+ });
281
+ }
282
+ return { complete, legacy };
283
+ }
284
+ function assertCompleteModelRoute(routeId, route) {
285
+ if (routeId.trim() === '')
286
+ throw new Error('model route id must be a non-empty string');
287
+ for (const field of ['family', 'credentialSource', 'providerId']) {
288
+ if (typeof route[field] !== 'string' || route[field].trim() === '')
289
+ throw new Error(`model route "${routeId}" ${field} must be a non-empty string`);
290
+ }
291
+ const missing = MODEL_STRENGTHS.filter((strength) => route.models?.[strength] === undefined);
292
+ if (missing.length > 0)
293
+ throw new Error(`model route "${routeId}" is incomplete; missing strengths: ${missing.join(', ')}`);
294
+ for (const strength of MODEL_STRENGTHS) {
295
+ const spec = route.models[strength];
296
+ if (typeof spec !== 'string' || spec.trim() === '')
297
+ throw new Error(`model route "${routeId}" models.${strength} must be a non-empty provider/id ref`);
298
+ const slash = spec.indexOf('/');
299
+ if (slash <= 0 || slash === spec.length - 1)
300
+ throw new Error(`model route "${routeId}" models.${strength} must be provider/id`);
301
+ if (spec.slice(0, slash) !== route.providerId)
302
+ throw new Error(`model route "${routeId}" models.${strength} provider prefix must equal providerId`);
303
+ }
304
+ }
230
305
  function mergeModelRouting(raw, base) {
231
306
  if (raw === undefined)
232
307
  return base;
@@ -627,6 +702,12 @@ function mergeBrokerThresholds(raw, defaults) {
627
702
  ? { warning, automaticReviveCap }
628
703
  : { ...defaults };
629
704
  }
705
+ function mergeLifecycle(raw, defaults) {
706
+ const partial = raw !== null && typeof raw === 'object'
707
+ ? raw
708
+ : {};
709
+ return { unattendedParkMs: normalizePositiveInteger(partial.unattendedParkMs, defaults.unattendedParkMs) };
710
+ }
630
711
  function normalizeWhipMessages(raw) {
631
712
  if (!Array.isArray(raw))
632
713
  return [...DEFAULT_WHIP_MESSAGES];
@@ -654,6 +735,7 @@ function mergeConfig(partial) {
654
735
  ? partial.auto_open_child_viewers
655
736
  : defaults.auto_open_child_viewers;
656
737
  const brokerThresholds = mergeBrokerThresholds(partial.brokerThresholds, defaults.brokerThresholds);
738
+ const lifecycle = mergeLifecycle(partial.lifecycle, defaults.lifecycle);
657
739
  const completion_bell = typeof partial.completion_bell === 'boolean' ? partial.completion_bell : defaults.completion_bell;
658
740
  const working_gerunds = partial.working_gerunds === undefined
659
741
  ? [...defaults.working_gerunds]
@@ -679,14 +761,15 @@ function mergeConfig(partial) {
679
761
  const keybindings = normalizeKeybindings(partial.keybindings);
680
762
  const tmux_passthrough = normalizeTmuxPassthrough(partial.tmux_passthrough);
681
763
  const modelLadders = mergeModelLadders(partial.modelLadders);
682
- const modelRoutes = mergeModelRoutes(partial.modelRoutes);
764
+ const routes = resolveModelRoutes(mergeModelRoutePatches(partial.modelRoutes));
765
+ const modelRoutes = routes.complete;
683
766
  const modelRouting = mergeModelRouting(partial.modelRouting, undefined);
684
767
  const kinds = mergeKinds(partial.kinds);
685
768
  const remoteCanvas = mergeRemoteCanvas(partial.remoteCanvas);
686
769
  const spawnEnv = mergeSpawnEnv(partial.spawnEnv, defaults.spawnEnv?.allow);
687
770
  const bin = normalizeBin(partial.bin);
688
771
  const humanActions = normalizeHumanActions(partial.humanActions);
689
- return { bin, humanActions, schema_version, marketplaces, plugins, auto_update, max_panes_per_window, auto_open_child_viewers, brokerThresholds, completion_bell, working_gerunds, whip_mode, page_components, page_surface, whip_messages, whip_message_mode, mouse_mode_default, live_cycles, condensed_history, bash_tool_purpose, fold_finished_tools, summarize_tool_calls, detailed_tool_recaps, keybindings, tmux_passthrough, modelLadders, ...(Object.keys(modelRoutes).length > 0 ? { modelRoutes } : {}), ...(modelRouting !== undefined ? { modelRouting } : {}), kinds, remoteCanvas, spawnEnv };
772
+ return { bin, humanActions, schema_version, marketplaces, plugins, auto_update, max_panes_per_window, auto_open_child_viewers, brokerThresholds, lifecycle, completion_bell, working_gerunds, whip_mode, page_components, page_surface, whip_messages, whip_message_mode, mouse_mode_default, live_cycles, condensed_history, bash_tool_purpose, fold_finished_tools, summarize_tool_calls, detailed_tool_recaps, keybindings, tmux_passthrough, modelLadders, ...(Object.keys(modelRoutes).length > 0 ? { modelRoutes } : {}), ...(Object.keys(routes.legacy).length > 0 ? { legacyModelRoutes: routes.legacy } : {}), ...(modelRouting !== undefined ? { modelRouting } : {}), kinds, remoteCanvas, spawnEnv };
690
773
  }
691
774
  /** Raw (un-defaulted) partial config for one scope, or null if the scope has
692
775
  * no root or no config.json. Used by `readMergedLaunchConfig` to layer
@@ -775,13 +858,13 @@ export function readMergedLaunchConfig(targetCwd = process.cwd(), targetProfileI
775
858
  // (ladders/routes/spawnEnv) have no plugin channel.
776
859
  let kinds = mergeKinds(userRaw?.kinds, layerPluginKinds('user', scopeRoot('user'), defaults.kinds));
777
860
  let modelLadders = mergeModelLadders(userRaw?.modelLadders, defaults.modelLadders);
778
- let modelRoutes = mergeModelRoutes(userRaw?.modelRoutes);
861
+ let modelRoutePatches = mergeModelRoutePatches(userRaw?.modelRoutes);
779
862
  let modelRouting = mergeModelRouting(userRaw?.modelRouting, undefined);
780
863
  let spawnEnv = mergeSpawnEnv(userRaw?.spawnEnv, defaults.spawnEnv?.allow);
781
864
  if (profileRaw !== null) {
782
865
  kinds = mergeKinds(profileRaw.kinds, kinds);
783
866
  modelLadders = mergeModelLadders(profileRaw.modelLadders, modelLadders);
784
- modelRoutes = mergeModelRoutes(profileRaw.modelRoutes, modelRoutes);
867
+ modelRoutePatches = mergeModelRoutePatches(profileRaw.modelRoutes, modelRoutePatches);
785
868
  modelRouting = mergeModelRouting(profileRaw.modelRouting, modelRouting);
786
869
  spawnEnv = mergeSpawnEnv(profileRaw.spawnEnv, spawnEnv.allow);
787
870
  }
@@ -790,11 +873,19 @@ export function readMergedLaunchConfig(targetCwd = process.cwd(), targetProfileI
790
873
  if (raw === null)
791
874
  continue;
792
875
  modelLadders = mergeModelLadders(raw.modelLadders, modelLadders);
793
- modelRoutes = mergeModelRoutes(raw.modelRoutes, modelRoutes);
876
+ modelRoutePatches = mergeModelRoutePatches(raw.modelRoutes, modelRoutePatches);
794
877
  modelRouting = mergeModelRouting(raw.modelRouting, modelRouting);
795
878
  spawnEnv = mergeSpawnEnv(raw.spawnEnv, spawnEnv.allow);
796
879
  }
797
- return { kinds, modelLadders, ...(Object.keys(modelRoutes).length > 0 ? { modelRoutes } : {}), ...(modelRouting !== undefined ? { modelRouting } : {}), spawnEnv };
880
+ const routes = resolveModelRoutes(modelRoutePatches);
881
+ return {
882
+ kinds,
883
+ modelLadders,
884
+ ...(Object.keys(routes.complete).length > 0 ? { modelRoutes: routes.complete } : {}),
885
+ ...(Object.keys(routes.legacy).length > 0 ? { legacyModelRoutes: routes.legacy } : {}),
886
+ ...(modelRouting !== undefined ? { modelRouting } : {}),
887
+ spawnEnv,
888
+ };
798
889
  }
799
890
  /** The effective `KindConfig` for one full kind string (top-level, e.g.
800
891
  * `developer`, or sub-kind, e.g. `audit/security`), across
@@ -930,6 +1021,38 @@ export function persistModelLadders(root, patch) {
930
1021
  atomicWriteJson(lockedPath, { ...raw, modelLadders: next });
931
1022
  });
932
1023
  }
1024
+ /** Persist one complete route at one exact config root. The route is the only
1025
+ * raw route entry touched; unrelated routes and local ladder defaults remain
1026
+ * absent. Pass `undefined` to remove this scope's entry, which resumes lower
1027
+ * scope inheritance rather than introducing a tombstone. */
1028
+ export function persistModelRoute(root, routeId, route) {
1029
+ if (route !== undefined)
1030
+ assertCompleteModelRoute(routeId, route);
1031
+ else if (routeId.trim() === '')
1032
+ throw new Error('model route id must be a non-empty string');
1033
+ const path = configPathFor(root);
1034
+ ensureDir(root);
1035
+ withConfigPathLock(path, (lockedPath) => {
1036
+ const raw = readJsonIfExists(lockedPath) ?? {};
1037
+ const routes = copyRouteRecord((raw.modelRoutes ?? {}));
1038
+ if (route === undefined)
1039
+ delete routes[routeId];
1040
+ else {
1041
+ const current = routes[routeId];
1042
+ setRouteRecord(routes, routeId, {
1043
+ ...(current !== null && typeof current === 'object' && !Array.isArray(current) ? current : {}),
1044
+ ...route,
1045
+ models: { ...route.models },
1046
+ });
1047
+ }
1048
+ const next = { ...raw };
1049
+ if (Object.keys(routes).length === 0)
1050
+ delete next.modelRoutes;
1051
+ else
1052
+ next.modelRoutes = routes;
1053
+ atomicWriteJson(lockedPath, next);
1054
+ });
1055
+ }
933
1056
  /** Mutate one exact config root. Setup uses this for profile and explicit
934
1057
  * project targets that cannot be represented by the user/project Scope enum. */
935
1058
  export function updateConfigAtRoot(root, mutate) {
@@ -3,13 +3,13 @@ import type { AttachBindingId, AttachMenuBindingId } from './attach-control.js';
3
3
  type TmuxMenuAction = 'open' | 'attach' | 'show' | 'view-settings' | 'fold-tools' | 'metadata' | 'promote' | 'demote-mode' | 'resume' | 'fork' | 'demote' | 'make-resident' | 'detach' | 'close-subtree' | 'background-bash' | 'file-review' | 'pin' | 'reload-viewer' | 'recap' | 'context' | 'context-admin' | 'providers' | 'plugins' | 'graph' | 'focus-manager' | 'copy-id' | 'issues';
4
4
  type TmuxMenuEntryAction = Exclude<TmuxMenuAction, 'open' | 'attach'> | `issue.${'idea' | 'bug'}`;
5
5
  type GraphAction = 'down' | 'up' | 'first' | 'last' | 'collapse-or-manager' | 'expand-or-child' | 'focus' | 'revive' | 'focus-manager' | 'close-subtree' | 'dismiss';
6
- type BrowseAction = 'quit' | 'down' | 'up' | 'first' | 'last' | 'expand-or-child' | 'collapse-or-parent' | 'toggle-fold' | 'tab-next' | 'tab-previous' | `tab.${1 | 2 | 3 | 4 | 5}` | 'close-subtree' | 'confirm-close' | 'sort' | 'scope' | 'profile' | 'residents' | 'preview' | 'search' | 'revive-now' | 'focus' | 'focus-background' | 'yank-id';
6
+ type BrowseAction = 'quit' | 'down' | 'up' | 'first' | 'last' | 'expand-or-child' | 'collapse-or-parent' | 'toggle-fold' | 'tab-next' | 'tab-previous' | `tab.${1 | 2 | 3 | 4 | 5}` | 'close-subtree' | 'confirm-close' | 'sort' | 'scope' | 'profile' | 'residents' | 'preview' | 'pin' | 'search' | 'revive-now' | 'focus' | 'focus-background' | 'yank-id';
7
7
  type ProfileMenuAction = 'up' | 'down' | 'select' | 'accept-default' | 'abort' | `slot.${1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9}` | 'create' | 'search-existing' | 'use-root' | 'set-default' | 'add-directory' | 'unpin' | 'manage';
8
8
  type SetupAction = 'cancel' | 'tab-next' | 'tab-previous' | 'open' | 'list-down' | 'list-up' | 'toggle' | 'details' | 'advance' | 'cell-previous' | 'cell-next';
9
9
  type SetupKeybindingAction = 'edit' | 'commit-edit' | 'cancel-edit' | 'unbind' | 'reset-row' | 'reset-all' | 'save' | 'discard' | 'page-up' | 'page-down' | 'filter' | 'filter-delete' | 'filter-clear';
10
10
  type SetupModelsAction = 'open' | 'commit' | 'cancel' | 'scope-next';
11
11
  export type BindingId = `crtr.tmux.menu.${TmuxMenuAction}` | `crtr.tmux.menu.issue.${'idea' | 'bug'}` | AttachMenuBindingId | `crtr.tmux.menu-global.${TmuxMenuEntryAction}` | `crtr.tmux.node.${'next' | 'previous'}` | 'crtr.tmux.inbox.toggle' | `crtr.attach.scroll.${'up' | 'down' | 'page-up' | 'page-down' | 'live'}` | 'crtr.attach.mouse.toggle' | 'crtr.attach.copy.message' | 'crtr.attach.help.toggle' | `crtr.attach.search.${'next' | 'previous' | 'edit' | 'close'}` | AttachBindingId | `crtr.graph.${GraphAction}` | `crtr.graph.confirm.${'accept' | 'cancel'}` | `crtr.inspector.${'force-quit' | 'down' | 'up' | 'section-next' | 'section-prev' | 'page-down' | 'page-up' | 'toggle-detail' | 'cancel-item' | 'open-log' | 'confirm' | 'dismiss' | 'retarget' | 'refresh' | 'quit'}` | `crtr.browse.${BrowseAction}` | `crtr.browse.search.${'cancel' | 'commit' | 'delete' | 'quit'}` | `crtr.profile.menu.${ProfileMenuAction}` | `crtr.profile.search.${'up' | 'down' | 'pick' | 'back' | 'delete' | 'abort'}` | `crtr.setup.${SetupAction}` | `crtr.setup.confirm.${'accept' | 'back'}` | `crtr.setup.keybindings.${SetupKeybindingAction}` | `crtr.setup.models.${SetupModelsAction}`;
12
- export declare const BINDING_CATALOG: readonly [BindingDefinition<"crtr.tmux.menu.open">, BindingDefinition<"crtr.tmux.menu.attach">, ...(BindingDefinition<BindingId> | BindingDefinition<"crtr.tmux.node.next"> | BindingDefinition<"crtr.tmux.node.previous"> | BindingDefinition<"crtr.tmux.inbox.toggle"> | BindingDefinition<"crtr.attach.scroll.up"> | BindingDefinition<"crtr.attach.scroll.down"> | BindingDefinition<"crtr.attach.scroll.page-up"> | BindingDefinition<"crtr.attach.scroll.page-down"> | BindingDefinition<"crtr.attach.scroll.live"> | BindingDefinition<"crtr.attach.mouse.toggle"> | BindingDefinition<"crtr.attach.copy.message"> | BindingDefinition<"crtr.attach.search.next"> | BindingDefinition<"crtr.attach.search.previous"> | BindingDefinition<"crtr.attach.search.edit"> | BindingDefinition<"crtr.attach.search.close"> | BindingDefinition<"crtr.graph.down"> | BindingDefinition<"crtr.graph.up"> | BindingDefinition<"crtr.graph.first"> | BindingDefinition<"crtr.graph.last"> | BindingDefinition<"crtr.graph.collapse-or-manager"> | BindingDefinition<"crtr.graph.expand-or-child"> | BindingDefinition<"crtr.graph.focus"> | BindingDefinition<"crtr.graph.revive"> | BindingDefinition<"crtr.graph.focus-manager"> | BindingDefinition<"crtr.graph.close-subtree"> | BindingDefinition<"crtr.graph.dismiss"> | BindingDefinition<"crtr.graph.confirm.accept"> | BindingDefinition<"crtr.graph.confirm.cancel"> | BindingDefinition<"crtr.inspector.force-quit"> | BindingDefinition<"crtr.inspector.down"> | BindingDefinition<"crtr.inspector.up"> | BindingDefinition<"crtr.inspector.section-next"> | BindingDefinition<"crtr.inspector.section-prev"> | BindingDefinition<"crtr.inspector.page-down"> | BindingDefinition<"crtr.inspector.page-up"> | BindingDefinition<"crtr.inspector.toggle-detail"> | BindingDefinition<"crtr.inspector.cancel-item"> | BindingDefinition<"crtr.inspector.open-log"> | BindingDefinition<"crtr.inspector.confirm"> | BindingDefinition<"crtr.inspector.dismiss"> | BindingDefinition<"crtr.inspector.retarget"> | BindingDefinition<"crtr.inspector.refresh"> | BindingDefinition<"crtr.inspector.quit"> | BindingDefinition<"crtr.browse.quit"> | BindingDefinition<"crtr.browse.down"> | BindingDefinition<"crtr.browse.up"> | BindingDefinition<"crtr.browse.first"> | BindingDefinition<"crtr.browse.last"> | BindingDefinition<"crtr.browse.expand-or-child"> | BindingDefinition<"crtr.browse.collapse-or-parent"> | BindingDefinition<"crtr.browse.toggle-fold"> | BindingDefinition<"crtr.browse.tab-next"> | BindingDefinition<"crtr.browse.tab-previous"> | BindingDefinition<"crtr.browse.tab.2" | "crtr.browse.tab.1" | "crtr.browse.tab.3" | "crtr.browse.tab.4" | "crtr.browse.tab.5"> | BindingDefinition<"crtr.browse.close-subtree"> | BindingDefinition<"crtr.browse.confirm-close"> | BindingDefinition<"crtr.browse.sort"> | BindingDefinition<"crtr.browse.scope"> | BindingDefinition<"crtr.browse.profile"> | BindingDefinition<"crtr.browse.residents"> | BindingDefinition<"crtr.browse.preview"> | BindingDefinition<"crtr.browse.search"> | BindingDefinition<"crtr.browse.revive-now"> | BindingDefinition<"crtr.browse.focus"> | BindingDefinition<"crtr.browse.focus-background"> | BindingDefinition<"crtr.browse.yank-id"> | BindingDefinition<"crtr.browse.search.cancel"> | BindingDefinition<"crtr.browse.search.commit"> | BindingDefinition<"crtr.browse.search.delete"> | BindingDefinition<"crtr.browse.search.quit"> | BindingDefinition<"crtr.profile.menu.up"> | BindingDefinition<"crtr.profile.menu.down"> | BindingDefinition<"crtr.profile.menu.select"> | BindingDefinition<"crtr.profile.menu.accept-default"> | BindingDefinition<"crtr.profile.menu.abort"> | BindingDefinition<"crtr.profile.menu.slot.2" | "crtr.profile.menu.slot.1" | "crtr.profile.menu.slot.3" | "crtr.profile.menu.slot.4" | "crtr.profile.menu.slot.5" | "crtr.profile.menu.slot.6" | "crtr.profile.menu.slot.7" | "crtr.profile.menu.slot.8" | "crtr.profile.menu.slot.9">)[], BindingDefinition<"crtr.profile.menu.create">, BindingDefinition<"crtr.profile.menu.search-existing">, BindingDefinition<"crtr.profile.menu.use-root">, BindingDefinition<"crtr.profile.menu.set-default">, BindingDefinition<"crtr.profile.menu.add-directory">, BindingDefinition<"crtr.profile.menu.unpin">, BindingDefinition<"crtr.profile.menu.manage">, BindingDefinition<"crtr.profile.search.up">, BindingDefinition<"crtr.profile.search.down">, BindingDefinition<"crtr.profile.search.pick">, BindingDefinition<"crtr.profile.search.back">, BindingDefinition<"crtr.profile.search.delete">, BindingDefinition<"crtr.profile.search.abort">, BindingDefinition<"crtr.setup.cancel">, BindingDefinition<"crtr.setup.tab-next">, BindingDefinition<"crtr.setup.tab-previous">, BindingDefinition<"crtr.setup.open">, BindingDefinition<"crtr.setup.list-down">, BindingDefinition<"crtr.setup.list-up">, BindingDefinition<"crtr.setup.toggle">, BindingDefinition<"crtr.setup.details">, BindingDefinition<"crtr.setup.advance">, BindingDefinition<"crtr.setup.confirm.accept">, BindingDefinition<"crtr.setup.confirm.back">, BindingDefinition<"crtr.setup.keybindings.edit">, BindingDefinition<"crtr.setup.keybindings.commit-edit">, BindingDefinition<"crtr.setup.keybindings.cancel-edit">, BindingDefinition<"crtr.setup.keybindings.unbind">, BindingDefinition<"crtr.setup.keybindings.reset-row">, BindingDefinition<"crtr.setup.keybindings.reset-all">, BindingDefinition<"crtr.setup.keybindings.save">, BindingDefinition<"crtr.setup.keybindings.discard">, BindingDefinition<"crtr.setup.keybindings.page-up">, BindingDefinition<"crtr.setup.keybindings.page-down">, BindingDefinition<"crtr.setup.keybindings.filter">, BindingDefinition<"crtr.setup.keybindings.filter-delete">, BindingDefinition<"crtr.setup.keybindings.filter-clear">, BindingDefinition<"crtr.setup.cell-previous">, BindingDefinition<"crtr.setup.cell-next">, BindingDefinition<"crtr.setup.models.open">, BindingDefinition<"crtr.setup.models.scope-next">, BindingDefinition<"crtr.setup.models.commit">, BindingDefinition<"crtr.setup.models.cancel">];
12
+ export declare const BINDING_CATALOG: readonly [BindingDefinition<"crtr.tmux.menu.open">, BindingDefinition<"crtr.tmux.menu.attach">, ...(BindingDefinition<BindingId> | BindingDefinition<"crtr.tmux.node.next"> | BindingDefinition<"crtr.tmux.node.previous"> | BindingDefinition<"crtr.tmux.inbox.toggle"> | BindingDefinition<"crtr.attach.scroll.up"> | BindingDefinition<"crtr.attach.scroll.down"> | BindingDefinition<"crtr.attach.scroll.page-up"> | BindingDefinition<"crtr.attach.scroll.page-down"> | BindingDefinition<"crtr.attach.scroll.live"> | BindingDefinition<"crtr.attach.mouse.toggle"> | BindingDefinition<"crtr.attach.copy.message"> | BindingDefinition<"crtr.attach.search.next"> | BindingDefinition<"crtr.attach.search.previous"> | BindingDefinition<"crtr.attach.search.edit"> | BindingDefinition<"crtr.attach.search.close"> | BindingDefinition<"crtr.graph.down"> | BindingDefinition<"crtr.graph.up"> | BindingDefinition<"crtr.graph.first"> | BindingDefinition<"crtr.graph.last"> | BindingDefinition<"crtr.graph.collapse-or-manager"> | BindingDefinition<"crtr.graph.expand-or-child"> | BindingDefinition<"crtr.graph.focus"> | BindingDefinition<"crtr.graph.revive"> | BindingDefinition<"crtr.graph.focus-manager"> | BindingDefinition<"crtr.graph.close-subtree"> | BindingDefinition<"crtr.graph.dismiss"> | BindingDefinition<"crtr.graph.confirm.accept"> | BindingDefinition<"crtr.graph.confirm.cancel"> | BindingDefinition<"crtr.inspector.force-quit"> | BindingDefinition<"crtr.inspector.down"> | BindingDefinition<"crtr.inspector.up"> | BindingDefinition<"crtr.inspector.section-next"> | BindingDefinition<"crtr.inspector.section-prev"> | BindingDefinition<"crtr.inspector.page-down"> | BindingDefinition<"crtr.inspector.page-up"> | BindingDefinition<"crtr.inspector.toggle-detail"> | BindingDefinition<"crtr.inspector.cancel-item"> | BindingDefinition<"crtr.inspector.open-log"> | BindingDefinition<"crtr.inspector.confirm"> | BindingDefinition<"crtr.inspector.dismiss"> | BindingDefinition<"crtr.inspector.retarget"> | BindingDefinition<"crtr.inspector.refresh"> | BindingDefinition<"crtr.inspector.quit"> | BindingDefinition<"crtr.browse.quit"> | BindingDefinition<"crtr.browse.down"> | BindingDefinition<"crtr.browse.up"> | BindingDefinition<"crtr.browse.first"> | BindingDefinition<"crtr.browse.last"> | BindingDefinition<"crtr.browse.expand-or-child"> | BindingDefinition<"crtr.browse.collapse-or-parent"> | BindingDefinition<"crtr.browse.toggle-fold"> | BindingDefinition<"crtr.browse.tab-next"> | BindingDefinition<"crtr.browse.tab-previous"> | BindingDefinition<"crtr.browse.tab.2" | "crtr.browse.tab.1" | "crtr.browse.tab.3" | "crtr.browse.tab.4" | "crtr.browse.tab.5"> | BindingDefinition<"crtr.browse.close-subtree"> | BindingDefinition<"crtr.browse.confirm-close"> | BindingDefinition<"crtr.browse.sort"> | BindingDefinition<"crtr.browse.scope"> | BindingDefinition<"crtr.browse.profile"> | BindingDefinition<"crtr.browse.residents"> | BindingDefinition<"crtr.browse.preview"> | BindingDefinition<"crtr.browse.pin"> | BindingDefinition<"crtr.browse.search"> | BindingDefinition<"crtr.browse.revive-now"> | BindingDefinition<"crtr.browse.focus"> | BindingDefinition<"crtr.browse.focus-background"> | BindingDefinition<"crtr.browse.yank-id"> | BindingDefinition<"crtr.browse.search.cancel"> | BindingDefinition<"crtr.browse.search.commit"> | BindingDefinition<"crtr.browse.search.delete"> | BindingDefinition<"crtr.browse.search.quit"> | BindingDefinition<"crtr.profile.menu.up"> | BindingDefinition<"crtr.profile.menu.down"> | BindingDefinition<"crtr.profile.menu.select"> | BindingDefinition<"crtr.profile.menu.accept-default"> | BindingDefinition<"crtr.profile.menu.abort"> | BindingDefinition<"crtr.profile.menu.slot.2" | "crtr.profile.menu.slot.1" | "crtr.profile.menu.slot.3" | "crtr.profile.menu.slot.4" | "crtr.profile.menu.slot.5" | "crtr.profile.menu.slot.6" | "crtr.profile.menu.slot.7" | "crtr.profile.menu.slot.8" | "crtr.profile.menu.slot.9">)[], BindingDefinition<"crtr.profile.menu.create">, BindingDefinition<"crtr.profile.menu.search-existing">, BindingDefinition<"crtr.profile.menu.use-root">, BindingDefinition<"crtr.profile.menu.set-default">, BindingDefinition<"crtr.profile.menu.add-directory">, BindingDefinition<"crtr.profile.menu.unpin">, BindingDefinition<"crtr.profile.menu.manage">, BindingDefinition<"crtr.profile.search.up">, BindingDefinition<"crtr.profile.search.down">, BindingDefinition<"crtr.profile.search.pick">, BindingDefinition<"crtr.profile.search.back">, BindingDefinition<"crtr.profile.search.delete">, BindingDefinition<"crtr.profile.search.abort">, BindingDefinition<"crtr.setup.cancel">, BindingDefinition<"crtr.setup.tab-next">, BindingDefinition<"crtr.setup.tab-previous">, BindingDefinition<"crtr.setup.open">, BindingDefinition<"crtr.setup.list-down">, BindingDefinition<"crtr.setup.list-up">, BindingDefinition<"crtr.setup.toggle">, BindingDefinition<"crtr.setup.details">, BindingDefinition<"crtr.setup.advance">, BindingDefinition<"crtr.setup.confirm.accept">, BindingDefinition<"crtr.setup.confirm.back">, BindingDefinition<"crtr.setup.keybindings.edit">, BindingDefinition<"crtr.setup.keybindings.commit-edit">, BindingDefinition<"crtr.setup.keybindings.cancel-edit">, BindingDefinition<"crtr.setup.keybindings.unbind">, BindingDefinition<"crtr.setup.keybindings.reset-row">, BindingDefinition<"crtr.setup.keybindings.reset-all">, BindingDefinition<"crtr.setup.keybindings.save">, BindingDefinition<"crtr.setup.keybindings.discard">, BindingDefinition<"crtr.setup.keybindings.page-up">, BindingDefinition<"crtr.setup.keybindings.page-down">, BindingDefinition<"crtr.setup.keybindings.filter">, BindingDefinition<"crtr.setup.keybindings.filter-delete">, BindingDefinition<"crtr.setup.keybindings.filter-clear">, BindingDefinition<"crtr.setup.cell-previous">, BindingDefinition<"crtr.setup.cell-next">, BindingDefinition<"crtr.setup.models.open">, BindingDefinition<"crtr.setup.models.scope-next">, BindingDefinition<"crtr.setup.models.commit">, BindingDefinition<"crtr.setup.models.cancel">];
13
13
  export declare const BINDING_IDS: ReadonlySet<string>;
14
14
  /** True for an action whose direct gesture is handled inside an attach viewer.
15
15
  * Tmux menu selectors and root shortcuts are chrome, not pane input. */
@@ -72,7 +72,7 @@ export const BINDING_CATALOG = Object.freeze([
72
72
  b('crtr.tmux.node.previous', 'Global tmux', 'Previous node', 'Focus the previous canvas node.', ['alt+['], 'host-global', ['tmux.root']),
73
73
  b('crtr.tmux.inbox.toggle', 'Global tmux', 'Toggle inbox', 'Open or close the human inbox in the focused viewer.', ['alt+i'], 'host-global', ['tmux.root']),
74
74
  ...attachEntry('crtr.tmux.menu.attach.detach', 'crtr.attach.detach', 'Detach', 'Detach from the node.', ['d'], ['ctrl+d']),
75
- ...attachEntry('crtr.tmux.menu.attach.clear-or-detach', 'crtr.attach.clear-or-detach', 'Clear input (detach if empty)', 'Clear input or detach when input is empty.', ['c'], ['ctrl+c']),
75
+ ...attachEntry('crtr.tmux.menu.attach.clear-or-detach', 'crtr.attach.clear-or-detach', 'Clear input (cancel agent on repeat)', 'Clear the input; press again within a second to cancel the running agent turn. Detach lives on its own binding.', ['c'], ['ctrl+c']),
76
76
  ...attachEntry('crtr.tmux.menu.attach.graph.toggle', 'crtr.attach.graph.toggle', 'Toggle graph', 'Open or close the graph overlay.', ['g'], ['alt+g']),
77
77
  ...attachEntry('crtr.tmux.menu.attach.help.toggle', 'crtr.attach.help.toggle', 'Help', 'Open the attach help overlay.', ['?'], ['alt+shift+k']),
78
78
  ...attachEntry('crtr.tmux.menu.attach.inbox.toggle', 'crtr.attach.inbox.toggle', 'Toggle inbox', 'Open or close the human inbox surface in this viewer.', ['i'], []),
@@ -147,7 +147,8 @@ function browseBindings() {
147
147
  b('crtr.browse.scope', 'Browse and pickers', 'Change scope', 'Toggle the browse cwd scope.', ['c'], 'terminal', [nav]),
148
148
  b('crtr.browse.profile', 'Browse and pickers', 'Toggle profile', 'Toggle the browse profile scope.', ['shift+p'], 'terminal', [nav]),
149
149
  b('crtr.browse.residents', 'Browse and pickers', 'Toggle residents', 'Toggle resident display.', ['r'], 'terminal', [nav]),
150
- b('crtr.browse.preview', 'Browse and pickers', 'Toggle preview', 'Toggle the preview panel.', ['p'], 'terminal', [nav]),
150
+ b('crtr.browse.preview', 'Browse and pickers', 'Toggle preview', 'Toggle the preview panel.', ['v'], 'terminal', [nav]),
151
+ b('crtr.browse.pin', 'Browse and pickers', 'Pin node', 'Star or unstar the selected node.', ['p'], 'terminal', [nav]),
151
152
  b('crtr.browse.search', 'Browse and pickers', 'Search', 'Open browse search.', ['/'], 'terminal', [nav]),
152
153
  b('crtr.browse.revive-now', 'Browse and pickers', 'Revive now', 'Revive the selected node now.', ['shift+k'], 'terminal', [nav]),
153
154
  b('crtr.browse.focus', 'Browse and pickers', 'Focus node', 'Focus the selected node.', ['enter', 'o'], 'terminal', [nav]),
@@ -190,13 +191,13 @@ function setupBindings() {
190
191
  b('crtr.setup.tab-next', 'Setup', 'Next tab', 'Select the next setup tab.', ['tab', 'right'], 'terminal', SETUP_TAB_CONTEXTS),
191
192
  b('crtr.setup.tab-previous', 'Setup', 'Previous tab', 'Select the previous setup tab.', ['shift+tab', 'left'], 'terminal', SETUP_TAB_CONTEXTS),
192
193
  b('crtr.setup.open', 'Setup', 'Open', 'Open the selected setup item.', ['enter'], 'terminal', ['setup.login']),
193
- b('crtr.setup.list-down', 'Setup', 'Move down', 'Move to the next setup item.', ['j', 'down'], 'terminal', ['setup.list', 'setup.models', 'setup.models.dimension-picker']),
194
- b('crtr.setup.list-up', 'Setup', 'Move up', 'Move to the previous setup item.', ['k', 'up'], 'terminal', ['setup.list', 'setup.models', 'setup.models.dimension-picker']),
194
+ b('crtr.setup.list-down', 'Setup', 'Move down', 'Move to the next setup item.', ['j', 'down'], 'terminal', ['setup.list', 'setup.models', 'setup.models.dimension-picker', 'setup.models.route-form']),
195
+ b('crtr.setup.list-up', 'Setup', 'Move up', 'Move to the previous setup item.', ['k', 'up'], 'terminal', ['setup.list', 'setup.models', 'setup.models.dimension-picker', 'setup.models.route-form']),
195
196
  b('crtr.setup.toggle', 'Setup', 'Toggle', 'Toggle the selected setup item.', ['space'], 'terminal', ['setup.list', 'setup.keybindings']),
196
197
  b('crtr.setup.details', 'Setup', 'Show details', 'Show details for the selected item.', ['?'], 'terminal', ['setup.list']),
197
198
  b('crtr.setup.advance', 'Setup', 'Advance', 'Advance the setup flow.', ['enter'], 'terminal', ['setup.list']),
198
- b('crtr.setup.confirm.accept', 'Setup', 'Confirm', 'Accept the setup confirmation.', ['y', 'enter'], 'terminal', ['setup.confirm']),
199
- b('crtr.setup.confirm.back', 'Setup', 'Go back', 'Cancel the setup confirmation.', ['n', 'escape', 'q'], 'terminal', ['setup.confirm'], 'dismiss-modal'),
199
+ b('crtr.setup.confirm.accept', 'Setup', 'Confirm', 'Accept the setup confirmation.', ['y', 'enter'], 'terminal', ['setup.confirm', 'setup.models.confirm']),
200
+ b('crtr.setup.confirm.back', 'Setup', 'Go back', 'Cancel the setup confirmation.', ['n', 'escape', 'q'], 'terminal', ['setup.confirm', 'setup.models.confirm'], 'dismiss-modal'),
200
201
  b('crtr.setup.keybindings.edit', 'Setup', 'Edit binding', 'Edit the selected binding.', ['enter'], 'terminal', ['setup.keybindings']),
201
202
  b('crtr.setup.keybindings.commit-edit', 'Setup', 'Commit edit', 'Commit the active binding edit.', ['enter'], 'terminal', ['setup.keybindings.edit']),
202
203
  b('crtr.setup.keybindings.cancel-edit', 'Setup', 'Cancel edit', 'Cancel the active binding edit.', ['escape'], 'terminal', ['setup.keybindings.edit'], 'dismiss-modal'),
@@ -212,10 +213,10 @@ function setupBindings() {
212
213
  b('crtr.setup.keybindings.filter-clear', 'Setup', 'Clear filter', 'Clear the keybinding filter.', ['escape'], 'terminal', ['setup.keybindings.filter'], 'dismiss-modal'),
213
214
  b('crtr.setup.cell-previous', 'Setup', 'Previous field', 'Select the previous field or setup table cell.', ['h'], 'terminal', ['setup.models', 'setup.keybindings']),
214
215
  b('crtr.setup.cell-next', 'Setup', 'Next field', 'Select the next field or setup table cell.', ['l'], 'terminal', ['setup.models', 'setup.keybindings']),
215
- b('crtr.setup.models.open', 'Setup', 'Edit model', 'Edit the selected model cell.', ['enter'], 'terminal', ['setup.models']),
216
+ b('crtr.setup.models.open', 'Setup', 'Edit model', 'Edit the selected model cell or route field.', ['enter'], 'terminal', ['setup.models', 'setup.models.route-form']),
216
217
  b('crtr.setup.models.scope-next', 'Setup', 'Next model config scope', 'Edit the next applicable user, profile, or project config.', ['s'], 'terminal', ['setup.models']),
217
218
  b('crtr.setup.models.commit', 'Setup', 'Commit pick', 'Commit the provider, strength, model, or thinking-level pick.', ['enter'], 'terminal', ['setup.models.picker', 'setup.models.dimension-picker']),
218
- b('crtr.setup.models.cancel', 'Setup', 'Cancel pick', 'Cancel the provider, strength, model, or thinking-level picker.', ['escape'], 'terminal', ['setup.models.picker', 'setup.models.dimension-picker'], 'dismiss-modal'),
219
+ b('crtr.setup.models.cancel', 'Setup', 'Cancel pick', 'Cancel the model picker or route form.', ['escape'], 'terminal', ['setup.models.picker', 'setup.models.dimension-picker', 'setup.models.route-form'], 'dismiss-modal'),
219
220
  ];
220
221
  }
221
222
  export const BINDING_IDS = new Set(BINDING_CATALOG.map((definition) => definition.id));
@@ -246,4 +247,6 @@ export const SAFETY_REQUIREMENTS = Object.freeze([
246
247
  { context: 'setup.models', capability: 'leave-screen' },
247
248
  { context: 'setup.models.picker', capability: 'dismiss-modal' },
248
249
  { context: 'setup.models.dimension-picker', capability: 'dismiss-modal' },
250
+ { context: 'setup.models.route-form', capability: 'dismiss-modal' },
251
+ { context: 'setup.models.confirm', capability: 'dismiss-modal' },
249
252
  ]);
@@ -19,6 +19,8 @@ function parseSpec(spec) {
19
19
  function synthesizeRoutes(ladders) {
20
20
  const route = (family) => {
21
21
  const providerId = ladders[family].strong.slice(0, ladders[family].strong.indexOf('/'));
22
+ // `credentialSource` orders routes. Auth and subscription-pool lookup are
23
+ // provider-wide, keyed by providerId, so this does not select credentials.
22
24
  return { family, credentialSource: 'default', providerId, models: ladders[family] };
23
25
  };
24
26
  return { anthropic: route('anthropic'), openai: route('openai') };
@@ -32,7 +34,10 @@ function strengths(requested, configured) {
32
34
  /** Expand config-derived route order only. Availability never affects rank. */
33
35
  export function expandModelCandidates(request, cwd, profileId) {
34
36
  const cfg = readMergedLaunchConfig(cwd, profileId ?? null);
35
- const routes = cfg.modelRoutes && Object.keys(cfg.modelRoutes).length > 0 ? cfg.modelRoutes : synthesizeRoutes(cfg.modelLadders);
37
+ // Complete routes are the effective contract. Sparse entries written by an
38
+ // older crouter release remain readable for one cutover release only.
39
+ const declaredRoutes = { ...(cfg.modelRoutes ?? {}), ...(cfg.legacyModelRoutes ?? {}) };
40
+ const routes = Object.keys(declaredRoutes).length > 0 ? declaredRoutes : synthesizeRoutes(cfg.modelLadders);
36
41
  const routeEntries = Object.entries(routes);
37
42
  const fallback = cfg.modelRouting?.strengthFallback ?? [readRotationConfig().defaultFallbackStrength ?? 'strong'];
38
43
  const declarationFamilies = routeEntries.map(([, route]) => route.family).filter((value, index, all) => all.indexOf(value) === index);
@@ -79,7 +84,9 @@ export function expandModelCandidates(request, cwd, profileId) {
79
84
  return;
80
85
  if (concrete && parsed.modelId !== concrete.modelId)
81
86
  return;
82
- const key = `${parsed.providerId}/${parsed.modelId}:${parsed.thinkingLevel ?? ''}`;
87
+ // Route identity is meaningful: two panel-managed routes can intentionally
88
+ // share a provider/model while carrying distinct credential-source order.
89
+ const key = JSON.stringify([routeId, parsed.providerId, parsed.modelId, parsed.thinkingLevel]);
83
90
  if (seen.has(key))
84
91
  return;
85
92
  seen.add(key);
@@ -91,7 +98,7 @@ export function expandModelCandidates(request, cwd, profileId) {
91
98
  if (concrete) {
92
99
  const [routeId, route] = concreteRoute ?? [
93
100
  'concrete',
94
- { family: requestedFamily, credentialSource: 'default', providerId: concrete.providerId, models: { [requestedStrength]: request.spec } },
101
+ { family: requestedFamily, credentialSource: 'default' },
95
102
  ];
96
103
  append(routeId, route, requestedStrength, request.spec);
97
104
  }
@@ -182,7 +189,7 @@ export function modelRequestFromConfig(model, modelIntent, exact) {
182
189
  if (exact)
183
190
  return { kind: 'exact', spec: model };
184
191
  if (modelIntent) {
185
- const slash = modelIntent.indexOf('/');
192
+ const slash = modelIntent.lastIndexOf('/');
186
193
  const strength = slash > 0 ? modelIntent.slice(slash + 1) : '';
187
194
  if (STRENGTHS.includes(strength))
188
195
  return { kind: 'logical', family: modelIntent.slice(0, slash), strength: strength, spec: model };
@@ -3,6 +3,7 @@ import { envModelIntent } from '../../../shared/env.js';
3
3
  import { emitEvent } from '../../events/emit.js';
4
4
  import { clearFault, readFault } from '../fault.js';
5
5
  import { isUnknownModel } from '../model-selection.js';
6
+ import { announceMessageOrigin } from '../stamp/channel.js';
6
7
  export function createAuthReloadGate(deps) {
7
8
  const { nodeId, cfg, replies, rebind, session, services, broadcastModelChanged, formatModelSpec, resolveLaunchModel, driveKickoff, turnAdmission, } = deps;
8
9
  let reloadInFlight = null;
@@ -118,7 +119,9 @@ export function createAuthReloadGate(deps) {
118
119
  return Promise.resolve();
119
120
  }
120
121
  clearFault(nodeId, { link: 'pi→provider' });
121
- return faultSession.prompt(formatCard('recovery', {}, AUTH_FAULT_RECOVERY_BODY), { preflightResult: release });
122
+ const card = formatCard('recovery', {}, AUTH_FAULT_RECOVERY_BODY);
123
+ announceMessageOrigin(card, { type: 'runtime', kind: 'recovery' });
124
+ return faultSession.prompt(card, { preflightResult: release });
122
125
  });
123
126
  }
124
127
  catch (err) {
@@ -12,6 +12,7 @@ import { markFatalOnExhaust, nextFaultRetry, policyFor } from '../fault-recovery
12
12
  import { parseModelSpec } from '../model-selection.js';
13
13
  import { endedByAbort } from '../session-cycles.js';
14
14
  import { resolveProviderCandidates } from '../../subscription-state.js';
15
+ import { announceMessageOrigin } from '../stamp/channel.js';
15
16
  const MAX_TIMEOUT_MS = 2_147_483_647;
16
17
  /** Owns provider fallback and daemon-driven fault retry state for one broker session. */
17
18
  export class FaultRetry {
@@ -266,7 +267,9 @@ export class FaultRetry {
266
267
  return;
267
268
  if (unconfigured)
268
269
  clearFault(this.deps.nodeId, { link: 'pi→provider' });
269
- await this.deps.turnAdmission.admit((release) => operationIdContext.fresh(() => candidateSession.prompt(formatCard('recovery', {}, formatModelFallbackRecovery(currentSpec, resolved, unconfigured ? 'credential' : 'not-found')), { preflightResult: release })));
270
+ const fallbackCard = formatCard('recovery', {}, formatModelFallbackRecovery(currentSpec, resolved, unconfigured ? 'credential' : 'not-found'));
271
+ announceMessageOrigin(fallbackCard, { type: 'runtime', kind: 'recovery' });
272
+ await this.deps.turnAdmission.admit((release) => operationIdContext.fresh(() => candidateSession.prompt(fallbackCard, { preflightResult: release })));
270
273
  })().catch((err) => {
271
274
  emitEvent({ level: 'debug', event: 'broker.runtime.diagnostic', fields: { message: `${unconfigured ? 'unconfigured-provider' : 'not_found'} fallback failed: ${err instanceof Error ? err.message : String(err)}` } });
272
275
  }).finally(() => {
@@ -405,7 +408,9 @@ export class FaultRetry {
405
408
  return Promise.resolve();
406
409
  }
407
410
  recordFault(this.deps.nodeId, plan.fault);
408
- return operationIdContext.fresh(() => candidateSession.prompt(formatCard('recovery', {}, fault.kind === 'connection' ? CONNECTION_FAULT_RECOVERY_BODY : PROVIDER_FAULT_RECOVERY_BODY), { preflightResult: release }));
411
+ const retryCard = formatCard('recovery', {}, fault.kind === 'connection' ? CONNECTION_FAULT_RECOVERY_BODY : PROVIDER_FAULT_RECOVERY_BODY);
412
+ announceMessageOrigin(retryCard, { type: 'runtime', kind: 'recovery' });
413
+ return operationIdContext.fresh(() => candidateSession.prompt(retryCard, { preflightResult: release }));
409
414
  });
410
415
  }
411
416
  }
@@ -1,6 +1,7 @@
1
1
  import { SITUATIONAL_CONTEXT_CUSTOM_TYPE } from '../../../shared/generated-context.js';
2
2
  import { emitEvent } from '../../events/emit.js';
3
3
  import { normalizeSnapshotTail } from '../broker-protocol.js';
4
+ import { announceMessageOrigin } from '../stamp/channel.js';
4
5
  export function createClientHandlers(deps) {
5
6
  const { registry, replies, session, driveEngine, turnAcceptance, pendingDialogs, sendWelcome, replayExtraPendingDialogsTo, disposeAndExit, } = deps;
6
7
  const { notWritable, ackTo } = replies;
@@ -82,6 +83,9 @@ export function createClientHandlers(deps) {
82
83
  };
83
84
  if (via === 'prompt')
84
85
  turnAcceptance.waitFor(ackAccepted);
86
+ // The daemon knows who authored this body; the stamp writer lives here.
87
+ if (frame.origin !== undefined)
88
+ announceMessageOrigin(frame.text, frame.origin);
85
89
  const outcome = driveEngine(client, {
86
90
  type: 'prompt',
87
91
  text: frame.text,
@@ -1,5 +1,6 @@
1
1
  import { createFrameReplies } from './frame-replies.js';
2
2
  import { createEngineDriver } from './engine-drive.js';
3
+ import { announceMessageOrigin } from '../stamp/channel.js';
3
4
  import { createClientHandlers } from './frame-client.js';
4
5
  import { createMemoryRefGuidance } from './frame-memory-refs.js';
5
6
  import { createAuthReloadGate } from './auth-reload.js';
@@ -51,6 +52,12 @@ export function createFrameDispatchContext(deps) {
51
52
  const handleDriveFrame = (client, frame) => {
52
53
  if (notWritable(client, 'drive the engine'))
53
54
  return;
55
+ // These frames are the viewer's input vocabulary — a person typed them.
56
+ // (crtrd's `deliver` never lands here: frame-client synthesizes its own
57
+ // prompt frame and calls driveEngine directly, announcing its own origin.)
58
+ if (frame.type === 'prompt' || frame.type === 'steer' || frame.type === 'follow_up') {
59
+ announceMessageOrigin(frame.text, { type: 'human', channel: { type: 'viewer' } });
60
+ }
54
61
  driveEngine(client, frame);
55
62
  };
56
63
  const clientHandlers = createClientHandlers({
@@ -27,7 +27,9 @@ export declare function readBrokerCanceledEntryIds(nodeId: string): Set<BrokerIn
27
27
  export declare function reportRefNodeId(ref: string | undefined): string | undefined;
28
28
  /** One-shot runtime cards an entry carries (rendered envelopes written by the
29
29
  * message API). They ride the delivery their own entry belongs to, so a note
30
- * can never land ahead of an unrelated person's message. */
30
+ * can never land ahead of an unrelated person's message — except in the one
31
+ * deliberate fold below, where deferred entries join a person's delivery as
32
+ * the context their turn reads. */
31
33
  export declare function entryCards(entry: BrokerInboxEntry): readonly string[];
32
34
  /** One deliverable user message: either a person's verbatim words or the whole
33
35
  * `<runtime kind="inbox">` envelope. Never both — a reader parses an envelope
@@ -40,5 +42,13 @@ export interface InboxDelivery {
40
42
  entries: readonly BrokerInboxEntry[];
41
43
  }
42
44
  /** Split unread entries into ordered deliveries: each human entry verbatim, and
43
- * every node/system entry in one card landing where the first such sender fell. */
45
+ * each CONTIGUOUS run of node/system entries as one card in the physical slot
46
+ * that run occupies. A deferred-only card is folded into the person's delivery
47
+ * waiting immediately behind it rather than being deliverable on its own.
48
+ *
49
+ * The runs are what keep the cursor honest. Exactly one delivery goes out per
50
+ * cycle and the rest requeue, while a sent delivery commits the cursor across
51
+ * its own entries — so a delivery that spanned a person's message would commit
52
+ * past words this cycle never sent. A run can never span one, and node mail
53
+ * that arrived after a person's message now waits behind it. */
44
54
  export declare function coalesceBrokerInbox(entries: readonly BrokerInboxEntry[], reportNodes: ReadonlyMap<string, BrokerReportNodeDTO>): readonly InboxDelivery[];