@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
@@ -4,6 +4,7 @@ export declare const CANVAS_RECAP_PATH: string;
4
4
  export declare const CANVAS_GOAL_CAPTURE_PATH: string;
5
5
  export declare const CANVAS_PASSIVE_CONTEXT_PATH: string;
6
6
  export declare const CANVAS_CONTEXT_INTRO_PATH: string;
7
+ export declare const CANVAS_TOOL_GUIDE_PATH: string;
7
8
  export declare const CANVAS_DOC_SUBSTRATE_PATH: string;
8
9
  export declare const CANVAS_STRUCTURED_OUTPUT_PATH: string;
9
10
  export declare const CANVAS_BASH_VALVE_PATH: string;
@@ -16,6 +16,7 @@ export const CANVAS_RECAP_PATH = resolveExtension('canvas-recap');
16
16
  export const CANVAS_GOAL_CAPTURE_PATH = resolveExtension('canvas-goal-capture');
17
17
  export const CANVAS_PASSIVE_CONTEXT_PATH = resolveExtension('canvas-passive-context');
18
18
  export const CANVAS_CONTEXT_INTRO_PATH = resolveExtension('canvas-context-intro');
19
+ export const CANVAS_TOOL_GUIDE_PATH = resolveExtension('canvas-tool-guide');
19
20
  export const CANVAS_DOC_SUBSTRATE_PATH = resolveExtension('canvas-doc-substrate');
20
21
  export const CANVAS_STRUCTURED_OUTPUT_PATH = resolveExtension('canvas-structured-output');
21
22
  export const CANVAS_BASH_VALVE_PATH = resolveExtension('canvas-bash-valve');
@@ -32,6 +33,7 @@ export const CANVAS_EXTENSIONS = [
32
33
  CANVAS_GOAL_CAPTURE_PATH,
33
34
  CANVAS_PASSIVE_CONTEXT_PATH,
34
35
  CANVAS_CONTEXT_INTRO_PATH,
36
+ CANVAS_TOOL_GUIDE_PATH,
35
37
  CANVAS_DOC_SUBSTRATE_PATH,
36
38
  CANVAS_STRUCTURED_OUTPUT_PATH,
37
39
  CANVAS_BASH_VALVE_PATH,
@@ -1,19 +1,8 @@
1
- export { CANVAS_EXTENSIONS, CANVAS_STOPHOOK_PATH, CANVAS_INBOX_WATCHER_PATH, CANVAS_RECAP_PATH, CANVAS_GOAL_CAPTURE_PATH, CANVAS_PASSIVE_CONTEXT_PATH, CANVAS_CONTEXT_INTRO_PATH, CANVAS_DOC_SUBSTRATE_PATH, CANVAS_STRUCTURED_OUTPUT_PATH, CANVAS_BASH_VALVE_PATH, } from './canvas-extensions.js';
1
+ export { CANVAS_EXTENSIONS, CANVAS_STOPHOOK_PATH, CANVAS_INBOX_WATCHER_PATH, CANVAS_RECAP_PATH, CANVAS_GOAL_CAPTURE_PATH, CANVAS_PASSIVE_CONTEXT_PATH, CANVAS_CONTEXT_INTRO_PATH, CANVAS_TOOL_GUIDE_PATH, CANVAS_DOC_SUBSTRATE_PATH, CANVAS_STRUCTURED_OUTPUT_PATH, CANVAS_BASH_VALVE_PATH, } from './canvas-extensions.js';
2
2
  import type { NodeMeta, LaunchSpec, Mode, Lifecycle } from '../canvas/index.js';
3
3
  import type { ModelProvider, ModelStrength, ScopeConfig } from '../../types.js';
4
4
  export { OPENAI_PROVIDER_ENV, configuredLadderProviders, inferPortableModelSelection } from './model-selection.js';
5
5
  export type { PortableModelSelection } from './model-selection.js';
6
- export interface EquivalentProviderModel {
7
- fromProvider: ModelProvider;
8
- toProvider: ModelProvider;
9
- strength: ModelStrength;
10
- model: string;
11
- }
12
- /** Map a concrete ladder model back to its provider×strength cell, then return
13
- * the other configured provider's model at the same strength. `failedProviders`
14
- * is per-turn state: once a provider has produced a retryable outage error, do
15
- * not bounce back to it or keep reattempting the same failed provider. */
16
- export declare function equivalentOtherProviderModel(currentModel: string, failedProviders?: ReadonlySet<ModelProvider>): EquivalentProviderModel | null;
17
6
  /** True when an explicit model token is QUALIFIED — a `<provider>/<model-id>`
18
7
  * or `<provider>/<tier>` raw token — the structural marker of a concrete exact
19
8
  * pick. Deliberately provider-agnostic: a real registry provider namespace
@@ -31,6 +20,10 @@ export declare function isProviderPinnedModelToken(model: string): boolean;
31
20
  * ladder the running build ships, so they never freeze a stale concrete
32
21
  * model id to disk. Mirrors `normalizeModel`'s branch structure exactly. */
33
22
  export declare function isPortableModelToken(model: string): boolean;
23
+ export declare function logicalModelIntent(model: string, ladders?: ScopeConfig['modelLadders']): {
24
+ family: string;
25
+ strength: ModelStrength;
26
+ } | undefined;
34
27
  export declare function normalizeModel(model: string, ladders?: ScopeConfig['modelLadders']): string;
35
28
  /** The interleaved model-ladder cycle Alt+M / Alt+Shift+M walks: for each strength tier
36
29
  * from strongest to weakest, each offered provider before descending — anthropic/ultra,
@@ -95,6 +88,11 @@ export declare function buildLaunchSpec(kind: string, mode: Mode, opts: {
95
88
  * promote/config, where `opts.model`'s shape IS the raw token and
96
89
  * legitimately re-decides the pin. */
97
90
  modelProviderPinned?: boolean;
91
+ /** Preserved intent from an existing launch recipe when rebuilding it. */
92
+ modelIntent?: {
93
+ family: string;
94
+ strength: ModelStrength;
95
+ };
98
96
  /** Exact-consult launch: pass the captured `provider/id:thinking` spec
99
97
  * verbatim as `launch.model`, skipping `floorReviewModel`/`normalizeModel`
100
98
  * entirely (avoids any tier-collision reinterpretation). `opts.model` MUST
@@ -14,7 +14,7 @@ import { readMergedLaunchConfig } from '../config.js';
14
14
  import { LAUNCH_SYSTEM_PROMPT } from './launch-prompt.js';
15
15
  import { nodeEnv } from './nodes.js';
16
16
  import { editorLabel } from '../canvas/index.js';
17
- export { CANVAS_EXTENSIONS, CANVAS_STOPHOOK_PATH, CANVAS_INBOX_WATCHER_PATH, CANVAS_RECAP_PATH, CANVAS_GOAL_CAPTURE_PATH, CANVAS_PASSIVE_CONTEXT_PATH, CANVAS_CONTEXT_INTRO_PATH, CANVAS_DOC_SUBSTRATE_PATH, CANVAS_STRUCTURED_OUTPUT_PATH, CANVAS_BASH_VALVE_PATH, } from './canvas-extensions.js';
17
+ export { CANVAS_EXTENSIONS, CANVAS_STOPHOOK_PATH, CANVAS_INBOX_WATCHER_PATH, CANVAS_RECAP_PATH, CANVAS_GOAL_CAPTURE_PATH, CANVAS_PASSIVE_CONTEXT_PATH, CANVAS_CONTEXT_INTRO_PATH, CANVAS_TOOL_GUIDE_PATH, CANVAS_DOC_SUBSTRATE_PATH, CANVAS_STRUCTURED_OUTPUT_PATH, CANVAS_BASH_VALVE_PATH, } from './canvas-extensions.js';
18
18
  import { nodeDir } from '../canvas/paths.js';
19
19
  import { CANVAS_EXTENSIONS, mergeCanvasExtensions, assertMandatoryCanvasExtensions } from './canvas-extensions.js';
20
20
  import { modelLadders, defaultProvider, STRENGTH_ALIASES, MODEL_PROVIDER_KEYS, MODEL_STRENGTHS, THINKING_SUFFIXES, stripThinkingSuffix, } from './model-selection.js';
@@ -43,26 +43,7 @@ export { OPENAI_PROVIDER_ENV, configuredLadderProviders, inferPortableModelSelec
43
43
  function ladderFor(provider, ladders = modelLadders()) {
44
44
  return ladders[provider];
45
45
  }
46
- // Automatic turn-time fallback ordering + strength ranking stay local to the
47
- // launch recipe (not needed by the pure selection primitives).
48
- const FALLBACK_PROVIDER_KEYS = ['anthropic', 'openai'];
49
46
  const MODEL_STRENGTH_RANK = { light: 0, medium: 1, strong: 2, ultra: 3 };
50
- /** Map a concrete ladder model back to its provider×strength cell, then return
51
- * the other configured provider's model at the same strength. `failedProviders`
52
- * is per-turn state: once a provider has produced a retryable outage error, do
53
- * not bounce back to it or keep reattempting the same failed provider. */
54
- export function equivalentOtherProviderModel(currentModel, failedProviders = new Set()) {
55
- const ladders = modelLadders();
56
- const cell = findLadderCell(normalizeModel(currentModel), ladders, FALLBACK_PROVIDER_KEYS);
57
- if (cell === null)
58
- return null;
59
- if (failedProviders.has(cell.provider))
60
- return null;
61
- const toProvider = cell.provider === 'anthropic' ? 'openai' : 'anthropic';
62
- if (failedProviders.has(toProvider))
63
- return null;
64
- return { fromProvider: cell.provider, toProvider, strength: cell.strength, model: ladders[toProvider][cell.strength] };
65
- }
66
47
  /** Back-map a concrete model spec to the provider×strength ladder cell it came
67
48
  * from. Two cells may legitimately name the SAME model id at different thinking
68
49
  * levels (the openai ladder's `ultra` and `strong` are both gpt-5.6-sol, at
@@ -147,7 +128,8 @@ function floorReviewModel(kind, requestedModel, kindModel, ladders = modelLadder
147
128
  * (`ultra`) or bare family alias (`opus`/`sonnet`/`haiku`) has no `/` and
148
129
  * stays unpinned. */
149
130
  export function isProviderPinnedModelToken(model) {
150
- return model.includes('/');
131
+ // Logical family/strength refs express a preference, not provider exclusivity.
132
+ return model.includes('/') && !isPortableModelToken(model);
151
133
  }
152
134
  /** True when `model` is a PORTABLE ladder token — a bare strength/alias
153
135
  * (`ultra`, `normal`, `opus`, …) or a `anthropic|openai / strength` pair —
@@ -165,6 +147,16 @@ export function isPortableModelToken(model) {
165
147
  }
166
148
  return STRENGTH_ALIASES[model] !== undefined || model === 'opus' || model === 'sonnet' || model === 'haiku';
167
149
  }
150
+ export function logicalModelIntent(model, ladders = modelLadders()) {
151
+ const strength = modelTokenStrength(model);
152
+ if (strength === null)
153
+ return undefined;
154
+ if (model.includes('/'))
155
+ return { family: model.slice(0, model.indexOf('/')), strength };
156
+ if (model === 'opus' || model === 'sonnet' || model === 'haiku')
157
+ return { family: 'anthropic', strength };
158
+ return { family: defaultProvider(ladders), strength };
159
+ }
168
160
  export function normalizeModel(model, ladders = modelLadders()) {
169
161
  // 1. Qualified provider/rest.
170
162
  if (model.includes('/')) {
@@ -265,10 +257,12 @@ export function buildLaunchSpec(kind, mode, opts) {
265
257
  const ladders = merged.modelLadders;
266
258
  const kindModel = mode === 'orchestrator' ? kindConfig?.orchestratorModel ?? kindConfig?.model : kindConfig?.model;
267
259
  const chosenModel = opts.modelExact === true ? opts.model : floorReviewModel(kind, opts.model, kindModel, ladders);
260
+ const intent = opts.modelExact === true ? undefined : (opts.modelIntent ?? (chosenModel !== undefined ? logicalModelIntent(chosenModel, ladders) : undefined));
268
261
  const launch = {
269
262
  model: opts.modelExact === true
270
263
  ? chosenModel
271
264
  : (chosenModel !== undefined ? normalizeModel(chosenModel, ladders) : undefined),
265
+ modelIntent: intent,
272
266
  modelProviderPinned: opts.modelProviderPinned !== undefined
273
267
  ? opts.modelProviderPinned
274
268
  : opts.model !== undefined && isProviderPinnedModelToken(opts.model),
@@ -276,7 +270,7 @@ export function buildLaunchSpec(kind, mode, opts) {
276
270
  tools: kindConfig?.tools ?? [],
277
271
  extensions: mergeCanvasExtensions([...CANVAS_EXTENSIONS, ...(kindConfig?.extensions ?? [])]),
278
272
  systemPrompt: LAUNCH_SYSTEM_PROMPT,
279
- env: { ...(opts.extraEnv ?? {}) },
273
+ env: { ...(opts.extraEnv ?? {}), ...(intent ? { CRTR_MODEL_INTENT: `${intent.family}/${intent.strength}` } : {}) },
280
274
  };
281
275
  return { launch, lifecycle: opts.lifecycle };
282
276
  }
@@ -4,20 +4,6 @@ import type { AssistantMessageDiagnostic } from "@earendil-works/pi-ai";
4
4
  export declare const MANAGED_PROVIDER_COOLING_DIAGNOSTIC = "crtr:managed-provider-cooling";
5
5
  /** Stable error code so a caller can recognize the typed error without message parsing. */
6
6
  export declare const MANAGED_PROVIDER_COOLING_CODE = "managed_provider_cooling";
7
- /**
8
- * Broker-owned, live policy exposed to the jiti-loaded provider-rotation
9
- * extension through its UI context. A callback (rather than a boot-time value)
10
- * is required because an attached viewer can pin or unpin the running broker
11
- * with `set_model`; the very next provider attempt must observe that decision.
12
- */
13
- export declare const MANAGED_PROVIDER_ROTATION_POLICY_UI: unique symbol;
14
- export type ManagedProviderRotationPolicy = {
15
- allowCrossProviderFallback: boolean;
16
- reason?: "provider-pinned" | "model-exact";
17
- };
18
- export type ManagedProviderRotationPolicyControl = () => ManagedProviderRotationPolicy;
19
- /** Non-broker/test hosts retain the historical permissive fallback policy. */
20
- export declare function readManagedProviderRotationPolicy(ui: unknown): ManagedProviderRotationPolicy;
21
7
  /** Every managed provider pool tried this turn is cooling; `rateLimitedUntil` is
22
8
  * the earliest future deadline (epoch ms) across those pools. */
23
9
  export declare class ManagedProviderCoolingError extends Error {
@@ -16,25 +16,6 @@
16
16
  export const MANAGED_PROVIDER_COOLING_DIAGNOSTIC = "crtr:managed-provider-cooling";
17
17
  /** Stable error code so a caller can recognize the typed error without message parsing. */
18
18
  export const MANAGED_PROVIDER_COOLING_CODE = "managed_provider_cooling";
19
- /**
20
- * Broker-owned, live policy exposed to the jiti-loaded provider-rotation
21
- * extension through its UI context. A callback (rather than a boot-time value)
22
- * is required because an attached viewer can pin or unpin the running broker
23
- * with `set_model`; the very next provider attempt must observe that decision.
24
- */
25
- export const MANAGED_PROVIDER_ROTATION_POLICY_UI = Symbol.for("@crouton-kit/crtr:managed-provider-rotation-policy");
26
- /** Non-broker/test hosts retain the historical permissive fallback policy. */
27
- export function readManagedProviderRotationPolicy(ui) {
28
- const control = ui?.[MANAGED_PROVIDER_ROTATION_POLICY_UI];
29
- if (typeof control !== "function")
30
- return { allowCrossProviderFallback: true };
31
- try {
32
- return control();
33
- }
34
- catch {
35
- return { allowCrossProviderFallback: true };
36
- }
37
- }
38
19
  /** Every managed provider pool tried this turn is cooling; `rateLimitedUntil` is
39
20
  * the earliest future deadline (epoch ms) across those pools. */
40
21
  export class ManagedProviderCoolingError extends Error {
@@ -65,6 +65,12 @@ export function persistDormantModel(nodeId, spec, pinned) {
65
65
  const meta = getNode(nodeId);
66
66
  if (meta === null)
67
67
  return;
68
- const launch = meta.launch !== undefined ? { ...meta.launch, model: spec, modelProviderPinned: pinned } : undefined;
68
+ const launch = meta.launch === undefined
69
+ ? undefined
70
+ : (() => {
71
+ const { modelIntent: _modelIntent, env, ...rest } = meta.launch;
72
+ const { CRTR_MODEL_INTENT: _intentEnv, ...remainingEnv } = env;
73
+ return { ...rest, model: spec, modelProviderPinned: pinned, env: remainingEnv };
74
+ })();
69
75
  updateNode(nodeId, { model_override: spec, ...(launch !== undefined ? { launch } : {}) });
70
76
  }
@@ -59,6 +59,7 @@ function reshapeNode(nodeId, opts = {}, beforePersist) {
59
59
  // just re-supplies the current value for resolution, not a new user
60
60
  // choice — so carry the node's existing pin verbatim instead.
61
61
  modelProviderPinned: opts.model === undefined ? node.launch?.modelProviderPinned : undefined,
62
+ modelIntent: opts.model === undefined ? node.launch?.modelIntent : undefined,
62
63
  // Resolve kind/ladder config against the NODE's own cwd + profile, not
63
64
  // this process's ambient scope.
64
65
  cwd: node.cwd,
@@ -11,7 +11,7 @@ import { spawnSync } from 'node:child_process';
11
11
  import { readFileSync, rmSync } from 'node:fs';
12
12
  import { homedir } from 'node:os';
13
13
  import { join } from 'node:path';
14
- import { formatGesture, resolveUserKeybindings, } from '../keybindings/index.js';
14
+ import { BINDING_CATALOG, formatGesture, resolveUserKeybindings, } from '../keybindings/index.js';
15
15
  import { bundledPiCommandForShell } from './pi-cli.js';
16
16
  import { surfaceTmuxStyleArgs } from './surface-bg.js';
17
17
  import { inboxToggleTmuxCommand } from '@crouton-kit/humanloop';
@@ -470,7 +470,7 @@ export function sendKeysEnter(pane, text) {
470
470
  // ---------------------------------------------------------------------------
471
471
  const TMUX_BINDING_MANIFEST = '@crtr_keybindings_manifest';
472
472
  const ROOT_TABLE = 'root';
473
- const MENU_ACTIONS = {
473
+ const LEAF_MENU_ACTIONS = {
474
474
  'crtr.tmux.menu.promote': { description: 'promote to orchestrator', action: { kind: 'keys', keys: '/promote' } },
475
475
  'crtr.tmux.menu.resume': { description: 'resume node', action: { kind: 'keys', keys: '/resume-node' } },
476
476
  'crtr.tmux.menu.fork': { description: 'fork into idle root', action: { kind: 'direct', run: 'node fork --pane {pane}' } },
@@ -484,7 +484,6 @@ const MENU_ACTIONS = {
484
484
  'crtr.tmux.menu.file-review': { description: 'review a file', action: { kind: 'raw-keys', keys: 'M-S-r' } },
485
485
  'crtr.tmux.menu.metadata': { description: 'metadata', action: { kind: 'keys', keys: '/node-metadata' } },
486
486
  'crtr.tmux.menu.fold-tools': { description: 'fold finished tool calls', action: { kind: 'keys', keys: '/fold-tools' } },
487
- 'crtr.tmux.menu.view-settings': undefined,
488
487
  'crtr.tmux.menu.context': { description: 'context + reports', action: { kind: 'keys', keys: '/context' } },
489
488
  'crtr.tmux.menu.providers': { description: 'settings', action: { kind: 'popup', run: 'sys setup' } },
490
489
  'crtr.tmux.menu.graph': { description: 'graph view', action: { kind: 'keys', keys: '/graph' } },
@@ -492,16 +491,8 @@ const MENU_ACTIONS = {
492
491
  'crtr.tmux.menu.copy-id': { description: 'copy node id', action: { kind: 'command', run: 'surface node id --pane {pane}' } },
493
492
  'crtr.tmux.menu.reload-viewer': { description: 'reload viewer', action: { kind: 'direct', run: 'surface reload --pane {pane}' } },
494
493
  'crtr.tmux.menu.pin': { description: 'pin conversation', action: { kind: 'direct', run: 'surface node pin --pane {pane}' } },
495
- 'crtr.tmux.menu.open': undefined,
496
- 'crtr.tmux.menu.show': undefined,
497
- 'crtr.tmux.menu.issues': undefined,
498
- 'crtr.tmux.menu.issue.idea': undefined,
499
- 'crtr.tmux.menu.issue.bug': undefined,
500
- 'crtr.tmux.node.next': undefined,
501
- 'crtr.tmux.node.previous': undefined,
502
- 'crtr.tmux.inbox.toggle': undefined,
503
- 'crtr.tmux.view.next': undefined,
504
- 'crtr.tmux.view.previous': undefined,
494
+ 'crtr.tmux.menu.issue.idea': { description: 'idea', action: { kind: 'popup', run: 'canvas issue new --type idea' } },
495
+ 'crtr.tmux.menu.issue.bug': { description: 'bug', action: { kind: 'popup', run: 'canvas issue new --type bug' } },
505
496
  };
506
497
  function escapeTmuxFormats(value) {
507
498
  return value.replace(/#/g, '##');
@@ -512,34 +503,69 @@ function tmuxCommand(args) {
512
503
  function chordCommand(run, interactive = false) {
513
504
  const command = `crtr canvas chord --pane '#{pane_id}' --run ${shellQuote(escapeTmuxFormats(run))}${interactive ? ' --interactive' : ''}`;
514
505
  return interactive
515
- ? `display-popup -E -w 60% -h 50% -d "#{pane_current_path}" ${shellQuote(command)}`
516
- : `run-shell ${shellQuote(`${command} >/dev/null 2>&1`)}`;
506
+ ? {
507
+ argv: ['display-popup', '-E', '-w', '60%', '-h', '50%', '-d', '#{pane_current_path}', command],
508
+ menu: `display-popup -E -w 60% -h 50% -d "#{pane_current_path}" ${shellQuote(command)}`,
509
+ }
510
+ : {
511
+ argv: ['run-shell', `${command} >/dev/null 2>&1`],
512
+ menu: `run-shell ${shellQuote(`${command} >/dev/null 2>&1`)}`,
513
+ };
517
514
  }
518
- /** Dispatch a menu action straight to a `crtr` invocation, skipping the
515
+ /** Dispatch an action straight to a `crtr` invocation, skipping the
519
516
  * `canvas chord` hop entirely. Only sound for an action whose sole
520
517
  * interpolation need is `{pane}` — tmux expands it natively as `#{pane_id}` at
521
- * selection time (a real tmux format, substituted AFTER escaping the static
518
+ * keypress time (a real tmux format, substituted AFTER escaping the static
522
519
  * run text so the `#` here is never doubled), so chord's node-resolution
523
- * round trip buys nothing. `-b` keeps the menu selection non-blocking. */
520
+ * round trip buys nothing. `-b` keeps dispatch non-blocking. */
524
521
  function directCommand(run) {
525
522
  const argv = escapeTmuxFormats(run).replace('{pane}', '#{pane_id}');
526
- return `run-shell -b ${shellQuote(`crtr ${argv} >/dev/null 2>&1`)}`;
523
+ const command = `crtr ${argv} >/dev/null 2>&1`;
524
+ return {
525
+ argv: ['run-shell', '-b', command],
526
+ menu: `run-shell -b ${shellQuote(command)}`,
527
+ };
527
528
  }
528
- function renderMenuAction(action) {
529
+ /** One command constructor serves both display-menu selections and direct root
530
+ * shortcuts. Each result carries the argv bind-key needs plus the stable menu
531
+ * serialization existing Alt+C installs already use. */
532
+ function menuActionCommand(action) {
529
533
  switch (action.kind) {
530
- case 'disabled': return '';
531
- case 'separator': return '';
534
+ case 'disabled': return null;
535
+ case 'separator': return null;
532
536
  // Choosing any menu item closes it; refresh-client is the deliberately
533
537
  // inert command that lets the prefix shortcut choose this close row.
534
- case 'dismiss': return 'refresh-client';
535
- case 'keys': return `send-keys -t '#{pane_id}' ${shellQuote(escapeTmuxFormats(action.keys))} Enter`;
536
- case 'raw-keys': return `send-keys -t '#{pane_id}' ${shellQuote(escapeTmuxFormats(action.keys))}`;
538
+ case 'dismiss': return { argv: ['refresh-client'], menu: 'refresh-client' };
539
+ case 'keys': {
540
+ const keys = escapeTmuxFormats(action.keys);
541
+ return {
542
+ // A root binding already runs in the pressed key's pane context. Its
543
+ // target must stay implicit: bind-key does not expand `#{pane_id}` in
544
+ // send-keys' target argument. A menu command is serialized later and
545
+ // does need the pane captured when the menu opened.
546
+ argv: ['send-keys', keys, 'Enter'],
547
+ menu: `send-keys -t '#{pane_id}' ${shellQuote(keys)} Enter`,
548
+ };
549
+ }
550
+ case 'raw-keys': {
551
+ const keys = escapeTmuxFormats(action.keys);
552
+ return {
553
+ argv: ['send-keys', keys],
554
+ menu: `send-keys -t '#{pane_id}' ${shellQuote(keys)}`,
555
+ };
556
+ }
537
557
  case 'command': return chordCommand(action.run);
538
558
  case 'direct': return directCommand(action.run);
539
559
  case 'popup': return chordCommand(action.run, true);
540
- case 'submenu': return tmuxCommand(buildMenuArgs(action.title, action.items));
560
+ case 'submenu': {
561
+ const argv = buildMenuArgs(action.title, action.items);
562
+ return { argv, menu: tmuxCommand(argv) };
563
+ }
541
564
  }
542
565
  }
566
+ function renderMenuAction(action) {
567
+ return menuActionCommand(action)?.menu ?? '';
568
+ }
543
569
  /** Visible width of a menu item name: style markup (`#[...]`) takes no cells. */
544
570
  function menuItemWidth(description) {
545
571
  return description.replace(/#\[[^\]]*\]/g, '').length;
@@ -573,8 +599,12 @@ function tmuxKey(stroke) {
573
599
  let key = parts.pop();
574
600
  const modifiers = new Set(parts);
575
601
  if (modifiers.has('shift') && key.length === 1 && /[a-z]/.test(key)) {
602
+ // tmux names a shifted printable with its uppercase character even when an
603
+ // extended-key terminal also preserves Shift as a modifier: Alt+Shift+F is
604
+ // `M-S-F`. Shift by itself needs only the uppercase character (`F`).
576
605
  key = key.toUpperCase();
577
- modifiers.delete('shift');
606
+ if (!modifiers.has('ctrl') && !modifiers.has('alt'))
607
+ modifiers.delete('shift');
578
608
  }
579
609
  key = TMUX_NAMED_KEYS[key] ?? key;
580
610
  const prefix = [
@@ -589,34 +619,56 @@ function menuSelector(stroke) {
589
619
  const key = parts.at(-1);
590
620
  return parts.includes('shift') && /[a-z]/.test(key) ? key.toUpperCase() : key === 'space' ? ' ' : key;
591
621
  }
592
- function menuItems(bindings) {
593
- const boundItems = (ids) => {
594
- const items = [];
595
- for (const id of ids) {
596
- const entry = MENU_ACTIONS[id];
597
- if (entry === undefined)
598
- continue;
599
- for (const gesture of bindings.gestures(id)) {
600
- items.push({ description: entry.description, selector: menuSelector(gesture), action: entry.action });
601
- }
602
- }
603
- return items;
604
- };
605
- const issueItems = [];
606
- for (const [id, description, type] of [
607
- ['crtr.tmux.menu.issue.idea', 'idea', 'idea'],
608
- ['crtr.tmux.menu.issue.bug', 'bug', 'bug'],
609
- ]) {
622
+ function boundMenuItems(ids, entries, bindings) {
623
+ const items = [];
624
+ for (const id of ids) {
625
+ const entry = entries.get(id);
626
+ if (entry === undefined)
627
+ continue;
610
628
  for (const gesture of bindings.gestures(id)) {
611
- issueItems.push({ description, selector: menuSelector(gesture), action: { kind: 'popup', run: `canvas issue new --type ${type}` } });
629
+ items.push({ description: entry.description, selector: menuSelector(gesture), action: entry.action });
612
630
  }
613
631
  }
614
- const lifecycleItems = boundItems([
615
- 'crtr.tmux.menu.promote',
616
- 'crtr.tmux.menu.demote',
617
- 'crtr.tmux.menu.detach',
618
- 'crtr.tmux.menu.close-subtree',
619
- ]);
632
+ return items;
633
+ }
634
+ /** Resolve the complete action descriptor set once. Submenu openers point at
635
+ * the same child MenuItems the prefix menu renders, so their direct global
636
+ * shortcuts cannot drift from the menu path. */
637
+ function resolvedMenuActions(bindings) {
638
+ const entries = new Map();
639
+ for (const [id, entry] of Object.entries(LEAF_MENU_ACTIONS)) {
640
+ if (entry !== undefined)
641
+ entries.set(id, entry);
642
+ }
643
+ const showItems = boundMenuItems([
644
+ 'crtr.tmux.menu.metadata',
645
+ 'crtr.tmux.menu.context',
646
+ ], entries, bindings);
647
+ entries.set('crtr.tmux.menu.show', {
648
+ description: 'show ›',
649
+ action: { kind: 'submenu', title: ' show node ', items: showItems },
650
+ });
651
+ // How this viewer PAINTS the transcript, as opposed to what it shows. Each item
652
+ // is a toggle the viewer applies to its own render state (a `/`-command sent to
653
+ // the pane); a tmux menu is built once and cannot show live state, so the item
654
+ // reads as the action and the viewer answers with a notice.
655
+ const viewSettingsItems = boundMenuItems(['crtr.tmux.menu.fold-tools'], entries, bindings);
656
+ entries.set('crtr.tmux.menu.view-settings', {
657
+ description: 'view settings ›',
658
+ action: { kind: 'submenu', title: ' view settings ', items: viewSettingsItems },
659
+ });
660
+ const issueItems = boundMenuItems([
661
+ 'crtr.tmux.menu.issue.idea',
662
+ 'crtr.tmux.menu.issue.bug',
663
+ ], entries, bindings);
664
+ entries.set('crtr.tmux.menu.issues', {
665
+ description: 'create ›',
666
+ action: { kind: 'submenu', title: ' create ', items: issueItems },
667
+ });
668
+ return entries;
669
+ }
670
+ function menuItems(bindings, entries) {
671
+ const boundItems = (ids) => boundMenuItems(ids, entries, bindings);
620
672
  const items = [];
621
673
  const addGroup = (heading, groupItems) => {
622
674
  if (groupItems.length === 0)
@@ -625,41 +677,15 @@ function menuItems(bindings) {
625
677
  items.push({ description: '', selector: '', action: { kind: 'separator' } });
626
678
  items.push({ description: `-#[bold]${heading}`, selector: '', action: { kind: 'disabled' } }, ...groupItems);
627
679
  };
628
- const showItems = boundItems([
629
- 'crtr.tmux.menu.metadata',
630
- 'crtr.tmux.menu.context',
631
- ]);
632
- // How this viewer PAINTS the transcript, as opposed to what it shows. Each item
633
- // is a toggle the viewer applies to its own render state (a `/`-command sent to
634
- // the pane); a tmux menu is built once and cannot show live state, so the item
635
- // reads as the action and the viewer answers with a notice.
636
- const viewSettingsItems = boundItems(['crtr.tmux.menu.fold-tools']);
637
- const exploreItems = boundItems([
680
+ // Explore changes what is in view or opens a canvas-level browser. Current
681
+ // node acts on the agent in this pane without changing its lifecycle.
682
+ addGroup('Explore', boundItems([
638
683
  'crtr.tmux.menu.resume',
639
684
  'crtr.tmux.menu.graph',
640
685
  'crtr.tmux.menu.focus-manager',
641
- ]);
642
- if (showItems.length > 0) {
643
- for (const gesture of bindings.gestures('crtr.tmux.menu.show')) {
644
- exploreItems.push({
645
- description: 'show ›',
646
- selector: menuSelector(gesture),
647
- action: { kind: 'submenu', title: ' show node ', items: showItems },
648
- });
649
- }
650
- }
651
- if (viewSettingsItems.length > 0) {
652
- for (const gesture of bindings.gestures('crtr.tmux.menu.view-settings')) {
653
- exploreItems.push({
654
- description: 'view settings ›',
655
- selector: menuSelector(gesture),
656
- action: { kind: 'submenu', title: ' view settings ', items: viewSettingsItems },
657
- });
658
- }
659
- }
660
- // Explore changes what is in view or opens a canvas-level browser. Current
661
- // node acts on the agent in this pane without changing its lifecycle.
662
- addGroup('Explore', exploreItems);
686
+ 'crtr.tmux.menu.show',
687
+ 'crtr.tmux.menu.view-settings',
688
+ ]));
663
689
  addGroup('Current node', boundItems([
664
690
  'crtr.tmux.menu.background-bash',
665
691
  'crtr.tmux.menu.file-review',
@@ -667,21 +693,17 @@ function menuItems(bindings) {
667
693
  'crtr.tmux.menu.copy-id',
668
694
  'crtr.tmux.menu.reload-viewer',
669
695
  ]));
670
- addGroup('Lifecycle', [
671
- ...boundItems(['crtr.tmux.menu.fork']),
672
- ...lifecycleItems,
673
- ]);
674
- const systemItems = boundItems(['crtr.tmux.menu.providers']);
675
- if (issueItems.length > 0) {
676
- for (const gesture of bindings.gestures('crtr.tmux.menu.issues')) {
677
- systemItems.push({
678
- description: 'create ›',
679
- selector: menuSelector(gesture),
680
- action: { kind: 'submenu', title: ' create ', items: issueItems },
681
- });
682
- }
683
- }
684
- addGroup('System', systemItems);
696
+ addGroup('Lifecycle', boundItems([
697
+ 'crtr.tmux.menu.fork',
698
+ 'crtr.tmux.menu.promote',
699
+ 'crtr.tmux.menu.demote',
700
+ 'crtr.tmux.menu.detach',
701
+ 'crtr.tmux.menu.close-subtree',
702
+ ]));
703
+ addGroup('System', boundItems([
704
+ 'crtr.tmux.menu.providers',
705
+ 'crtr.tmux.menu.issues',
706
+ ]));
685
707
  const prefixGestures = bindings.gestures('crtr.tmux.menu.open');
686
708
  if (prefixGestures.length > 0 && items.length > 0) {
687
709
  items.push({ description: '', selector: '', action: { kind: 'separator' } });
@@ -835,10 +857,24 @@ export function installTmuxBindings(bindings = resolveUserKeybindings()) {
835
857
  installed.push({ table, key });
836
858
  return true;
837
859
  };
838
- const menuCommand = buildMenuArgs(' crtr ', menuItems(bindings));
860
+ const menuActions = resolvedMenuActions(bindings);
861
+ const menuCommand = buildMenuArgs(' crtr ', menuItems(bindings, menuActions));
839
862
  for (const gesture of bindings.gestures('crtr.tmux.menu.open')) {
840
863
  install(ROOT_TABLE, tmuxKey(gesture), menuCommand, 'crtr.tmux.menu.open', gesture);
841
864
  }
865
+ for (const definition of BINDING_CATALOG) {
866
+ if (definition.pair?.field !== 'menu')
867
+ continue;
868
+ const menuId = definition.id;
869
+ const globalId = definition.pair.pairedId;
870
+ const entry = menuActions.get(menuId);
871
+ const command = entry === undefined ? null : menuActionCommand(entry.action);
872
+ if (command === null)
873
+ throw new Error(`no direct tmux action for paired menu binding ${menuId}`);
874
+ for (const gesture of bindings.gestures(globalId)) {
875
+ install(ROOT_TABLE, tmuxKey(gesture), command.argv, globalId, gesture);
876
+ }
877
+ }
842
878
  for (const id of ['crtr.tmux.node.next', 'crtr.tmux.node.previous', 'crtr.tmux.inbox.toggle']) {
843
879
  for (const gesture of bindings.gestures(id))
844
880
  install(ROOT_TABLE, tmuxKey(gesture), actionCommand(id), id, gesture);
@@ -87,11 +87,10 @@ export function findProjectScopeRoot(startDir = process.cwd()) {
87
87
  * nested under a directory tree that itself contains a `.crouter/` (e.g. a
88
88
  * mock-VM home under the real host home) would sweep the host's `~/.crouter`
89
89
  * (and any other above-HOME `.crouter`) in as a bogus project ancestor,
90
- * leaking one machine's user-global memory into a nested-HOME node. Mirrors
91
- * the on-read positional-walk fence (src/core/substrate/on-read.ts). A read
92
- * whose start dir is outside HOME (e.g. a production VM with HOME=/root and no
93
- * ancestor `.crouter`) never reaches home and walks to the filesystem root, as
94
- * before. */
90
+ * leaking one machine's user-global memory into a nested-HOME node. The
91
+ * explicit file-context store discovery in substrate/on-read.ts applies the
92
+ * same fence. A read whose start dir is outside HOME (e.g. a production VM
93
+ * with HOME=/root and no ancestor `.crouter`) walks to the filesystem root. */
95
94
  function collectAncestorScopeRoots(startDir, userRoot) {
96
95
  const roots = [];
97
96
  const home = realpathOrSelf(homedir());