@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
@@ -3,7 +3,8 @@ import assert from "node:assert/strict";
3
3
  import { spawn } from "node:child_process";
4
4
  import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
5
5
  import { LockedJsonFile } from "../../../../core/locked-file.ts";
6
- import type { FallbackTarget, SubscriptionCredential } from "../../lib/subscription-state.ts";
6
+ import { expandModelCandidates } from "../../../../core/model-routes.ts";
7
+ import type { SubscriptionCredential } from "../../lib/subscription-state.ts";
7
8
  import { dirname, join } from "node:path";
8
9
  import { tmpdir } from "node:os";
9
10
 
@@ -12,15 +13,18 @@ import { tmpdir } from "node:os";
12
13
  const lockedFileUrl = new URL("../../../../core/locked-file.ts", import.meta.url).href;
13
14
 
14
15
  let home: string;
16
+ let nodeCwd: string;
15
17
  let helpers: typeof import("../../lib/subscription-state.ts");
16
18
  let providerManagement: typeof import("../../../../core/provider-management.ts");
17
19
  let rotationModule: typeof import("../provider-rotation.ts");
18
20
  let providerRotationExtension: typeof import("../provider-rotation.ts").default;
19
21
  const previousHome = process.env.HOME;
20
22
  const previousAgentDir = process.env.PI_CODING_AGENT_DIR;
23
+ const previousModelIntent = process.env.CRTR_MODEL_INTENT;
24
+ const previousNodeCwd = process.env.CRTR_NODE_CWD;
21
25
 
22
26
  function writeLadderConfig() {
23
- const configPath = join(home, ".crouter", "config.json");
27
+ const configPath = join(nodeCwd, ".crouter", "config.json");
24
28
  mkdirSync(dirname(configPath), { recursive: true });
25
29
  writeFileSync(
26
30
  configPath,
@@ -65,7 +69,7 @@ async function resetRotationState() {
65
69
  rmSync(join(home, ".pi", "agent", "auth.json"), { force: true });
66
70
  await helpers.writeSubscriptionPool("anthropic", []);
67
71
  await helpers.writeSubscriptionPool("openai-codex", []);
68
- helpers.clearPreferredModel();
72
+ rmSync(join(home, ".pi", "agent", "provider-rotation.json"), { force: true });
69
73
  }
70
74
 
71
75
  function observeLines(stream: NodeJS.ReadableStream, onLine: (line: string) => void): void {
@@ -101,8 +105,10 @@ function within<T>(promise: Promise<T>, ms: number, message: string): Promise<T>
101
105
 
102
106
  before(async () => {
103
107
  home = mkdtempSync(join(tmpdir(), "pi-provider-rotation-"));
108
+ nodeCwd = mkdtempSync(join(tmpdir(), "pi-provider-rotation-cwd-"));
104
109
  process.env.HOME = home;
105
110
  process.env.PI_CODING_AGENT_DIR = join(home, ".pi", "agent");
111
+ process.env.CRTR_NODE_CWD = nodeCwd;
106
112
  helpers = await import("../../lib/subscription-state.ts");
107
113
  providerManagement = await import("../../../../core/provider-management.ts");
108
114
  rotationModule = await import("../provider-rotation.ts");
@@ -114,7 +120,12 @@ after(() => {
114
120
  else process.env.HOME = previousHome;
115
121
  if (previousAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR;
116
122
  else process.env.PI_CODING_AGENT_DIR = previousAgentDir;
123
+ if (previousModelIntent === undefined) delete process.env.CRTR_MODEL_INTENT;
124
+ else process.env.CRTR_MODEL_INTENT = previousModelIntent;
125
+ if (previousNodeCwd === undefined) delete process.env.CRTR_NODE_CWD;
126
+ else process.env.CRTR_NODE_CWD = previousNodeCwd;
117
127
  rmSync(home, { recursive: true, force: true });
128
+ rmSync(nodeCwd, { recursive: true, force: true });
118
129
  });
119
130
 
120
131
  test("normalizes provider subscription pool files", async () => {
@@ -491,17 +502,21 @@ test("loginWithManualInputCleanup's readCode rejects (rather than resolving to \
491
502
  );
492
503
  });
493
504
 
494
- test("resolves Anthropic to strength-matched OpenAI Codex fallback", async () => {
505
+ test("the synthesized ladder registry preserves family preference before configured strength fallback", async () => {
495
506
  await resetRotationState();
496
507
  writeLadderConfig();
508
+ helpers.writeRotationConfig({ defaultFallbackStrength: "medium" });
497
509
 
498
- assert.deepEqual(helpers.resolveFallbackTarget("anthropic", "anthropic/claude-opus-4-8:high"), {
499
- providerId: "openai-codex",
500
- modelId: "gpt-5.6-sol",
501
- label: "openai-codex/gpt-5.6-sol:high",
510
+ const candidates = expandModelCandidates({
511
+ kind: "logical",
512
+ family: "anthropic",
502
513
  strength: "strong",
503
- thinkingLevel: "high",
504
- });
514
+ spec: "anthropic/claude-opus-4-8:high",
515
+ }, home, null);
516
+
517
+ assert.deepEqual(candidates.map((candidate) => `${candidate.family}/${candidate.strength}`), [
518
+ "anthropic/strong", "openai/strong", "anthropic/medium", "openai/medium",
519
+ ]);
505
520
  });
506
521
 
507
522
  test("shared launch/general candidate policy preserves a viable request, skips a cooling request, and reports total exhaustion", async () => {
@@ -525,45 +540,6 @@ test("shared launch/general candidate policy preserves a viable request, skips a
525
540
  );
526
541
  });
527
542
 
528
- test("resolves OpenAI Codex to strength-matched Anthropic fallback", async () => {
529
- writeLadderConfig();
530
-
531
- assert.deepEqual(helpers.resolveFallbackTarget("openai-codex", "openai-codex/gpt-5.6-terra:medium"), {
532
- providerId: "anthropic",
533
- modelId: "claude-sonnet-4-6",
534
- label: "anthropic/claude-sonnet-4-6:high",
535
- strength: "medium",
536
- thinkingLevel: "high",
537
- });
538
- });
539
-
540
- // Ultra and strong share the bare model id gpt-5.6-sol; the live thinking level selects
541
- // the matching ladder rung.
542
- test("disambiguates colliding ladder rungs by the live thinking level", async () => {
543
- await resetRotationState();
544
- writeLadderConfig();
545
- const config = helpers.readRotationConfig();
546
-
547
- // max -> ultra rung -> Anthropic ultra (claude-fable-5)
548
- assert.deepEqual(helpers.resolveFallbackTarget("openai-codex", "gpt-5.6-sol", config, "max"), {
549
- providerId: "anthropic",
550
- modelId: "claude-fable-5",
551
- label: "anthropic/claude-fable-5:high",
552
- strength: "ultra",
553
- thinkingLevel: "high",
554
- });
555
-
556
- // high -> strong rung -> Anthropic strong (claude-opus-4-8)
557
- const highTarget = helpers.resolveFallbackTarget("openai-codex", "gpt-5.6-sol", config, "high");
558
- assert.ok(highTarget);
559
- assert.equal(highTarget.modelId, "claude-opus-4-8");
560
-
561
- // No thinking level falls back to the default-strength tie-break (strong -> opus).
562
- const defaultTarget = helpers.resolveFallbackTarget("openai-codex", "gpt-5.6-sol", config);
563
- assert.ok(defaultTarget);
564
- assert.equal(defaultTarget.modelId, "claude-opus-4-8");
565
- });
566
-
567
543
  test("parses Retry-After seconds and HTTP dates", async () => {
568
544
  const now = Date.UTC(2026, 5, 16, 18, 30, 0);
569
545
  assert.equal(helpers.parseRetryAfterHeader("1.5", now), 1500);
@@ -575,63 +551,6 @@ test("parses Retry-After seconds and HTTP dates", async () => {
575
551
  assert.equal(helpers.parseRetryAfterHeader("not-a-delay", now), undefined);
576
552
  });
577
553
 
578
- test("restores preferred model when its provider pool frees up", async () => {
579
- await resetRotationState();
580
- writeLadderConfig();
581
- await helpers.writeSubscriptionPool("openai-codex", [credential("codex")]);
582
- helpers.writeRotationConfig({ preferredModel: { providerId: "openai-codex", modelId: "gpt-5.6-sol" } });
583
-
584
- const setModelCalls: any[] = [];
585
- const ctx = {
586
- model: { provider: "anthropic", id: "claude-opus-4-8", api: "anthropic-messages" },
587
- modelRegistry: {
588
- find(provider: string, id: string) {
589
- return { provider, id, api: provider === "openai-codex" ? "openai-codex-responses" : "anthropic-messages" };
590
- },
591
- },
592
- } as any;
593
- const pi = {
594
- async setModel(model: any) {
595
- setModelCalls.push(model);
596
- ctx.model = model;
597
- return true;
598
- },
599
- } as any;
600
-
601
- const restored = await helpers.restorePreferredModelIfPossible(pi, ctx);
602
-
603
- assert.equal(restored, true);
604
- assert.deepEqual(setModelCalls[0], {
605
- provider: "openai-codex",
606
- id: "gpt-5.6-sol",
607
- api: "openai-codex-responses",
608
- });
609
- assert.equal(helpers.readRotationConfig().preferredModel, undefined);
610
- });
611
-
612
- test("pinned and exact launches retain their captured model during startup preferred-model restoration", async () => {
613
- for (const reason of ["provider-pinned", "model-exact"] as const) {
614
- await resetRotationState();
615
- await helpers.writeSubscriptionPool("openai-codex", [credential("codex")]);
616
- helpers.writeRotationConfig({ preferredModel: { providerId: "openai-codex", modelId: "gpt-5.6-sol" } });
617
-
618
- const { ctx, events, pi, setModelCalls } = makeRotationCtx();
619
- ctx.model = { provider: "anthropic", id: "claude-opus-4-8", api: "anthropic-messages" };
620
- ctx.ui[Symbol.for("@crouton-kit/crtr:managed-provider-rotation-policy")] = () => ({
621
- allowCrossProviderFallback: false,
622
- reason,
623
- });
624
-
625
- await providerRotationExtension(pi);
626
- await events.session_start?.({}, ctx);
627
- await events.before_agent_start?.({}, ctx);
628
-
629
- assert.deepEqual(setModelCalls, [], `${reason} launches must not restore the shared preferred model`);
630
- assert.deepEqual(ctx.model, { provider: "anthropic", id: "claude-opus-4-8", api: "anthropic-messages" });
631
- assert.deepEqual(helpers.readRotationConfig().preferredModel, { providerId: "openai-codex", modelId: "gpt-5.6-sol" });
632
- }
633
- });
634
-
635
554
  test("keeps newest Retry-After cooldown across overlapping attempts", async () => {
636
555
  await resetRotationState();
637
556
  await helpers.writeSubscriptionPool("anthropic", [credential("personal")]);
@@ -1029,101 +948,13 @@ test("treats numeric subscription refs as 1-based indexes", async () => {
1029
948
  );
1030
949
  });
1031
950
 
1032
- test("switches exhausted Anthropic sessions to OpenAI Codex fallback", async () => {
1033
- await resetRotationState();
1034
- writeLadderConfig();
1035
-
1036
- const ctx = {
1037
- model: { provider: "anthropic", id: "claude-sonnet-4-6", api: "anthropic-messages" },
1038
- modelRegistry: {
1039
- find(provider: string, id: string) {
1040
- return provider === "openai-codex" && id === "gpt-5.6-terra" ? { provider, id, api: "openai-codex-responses" } : undefined;
1041
- },
1042
- },
1043
- } as any;
1044
- let switchedTo: any;
1045
- let thinkingLevel: string | undefined;
1046
- const piApi = {
1047
- async setModel(model: any) {
1048
- switchedTo = model;
1049
- ctx.model = model;
1050
- return true;
1051
- },
1052
- setThinkingLevel(level: string) {
1053
- thinkingLevel = level;
1054
- },
1055
- } as any;
1056
-
1057
- const target = await helpers.switchToFallbackIfPossible(piApi, ctx);
1058
-
1059
- assert.deepEqual(target, {
1060
- providerId: "openai-codex",
1061
- modelId: "gpt-5.6-terra",
1062
- label: "openai-codex/gpt-5.6-terra:medium",
1063
- strength: "medium",
1064
- thinkingLevel: "medium",
1065
- });
1066
- assert.deepEqual(switchedTo, {
1067
- provider: "openai-codex",
1068
- id: "gpt-5.6-terra",
1069
- api: "openai-codex-responses",
1070
- });
1071
- assert.equal(thinkingLevel, "medium");
1072
- assert.deepEqual(helpers.readRotationConfig().preferredModel, {
1073
- providerId: "anthropic",
1074
- modelId: "claude-sonnet-4-6",
1075
- });
1076
- });
1077
-
1078
- test("switches exhausted OpenAI Codex sessions to Anthropic fallback", async () => {
1079
- await resetRotationState();
1080
- writeLadderConfig();
1081
-
951
+ function makeRotationCtx(modelIntent?: string) {
952
+ const concreteRequest = arguments.length > 0 && modelIntent === undefined;
953
+ if (modelIntent === undefined) delete process.env.CRTR_MODEL_INTENT;
954
+ else process.env.CRTR_MODEL_INTENT = modelIntent;
1082
955
  const ctx = {
1083
956
  model: { provider: "openai-codex", id: "gpt-5.6-sol", api: "openai-codex-responses" },
1084
- modelRegistry: {
1085
- find(provider: string, id: string) {
1086
- return provider === "anthropic" && id === "claude-opus-4-8" ? { provider, id, api: "anthropic-messages" } : undefined;
1087
- },
1088
- },
1089
- } as any;
1090
- let switchedTo: any;
1091
- let thinkingLevel: string | undefined;
1092
- const piApi = {
1093
- async setModel(model: any) {
1094
- switchedTo = model;
1095
- ctx.model = model;
1096
- return true;
1097
- },
1098
- setThinkingLevel(level: string) {
1099
- thinkingLevel = level;
1100
- },
1101
- } as any;
1102
-
1103
- const target = await helpers.switchToFallbackIfPossible(piApi, ctx);
1104
-
1105
- assert.deepEqual(target, {
1106
- providerId: "anthropic",
1107
- modelId: "claude-opus-4-8",
1108
- label: "anthropic/claude-opus-4-8:high",
1109
- strength: "strong",
1110
957
  thinkingLevel: "high",
1111
- });
1112
- assert.deepEqual(switchedTo, {
1113
- provider: "anthropic",
1114
- id: "claude-opus-4-8",
1115
- api: "anthropic-messages",
1116
- });
1117
- assert.equal(thinkingLevel, "high");
1118
- assert.deepEqual(helpers.readRotationConfig().preferredModel, {
1119
- providerId: "openai-codex",
1120
- modelId: "gpt-5.6-sol",
1121
- });
1122
- });
1123
-
1124
- function makeRotationCtx() {
1125
- const ctx = {
1126
- model: { provider: "openai-codex", id: "gpt-5.6-sol", api: "openai-codex-responses" },
1127
958
  ui: { setStatus() {} },
1128
959
  modelRegistry: {
1129
960
  find(provider: string, id: string) {
@@ -1134,12 +965,22 @@ function makeRotationCtx() {
1134
965
  },
1135
966
  } as any;
1136
967
  const setModelCalls: any[] = [];
968
+ const thinkingLevelCalls: string[] = [];
1137
969
  const providers: Record<string, { streamSimple: (...args: any[]) => AsyncIterable<any> }> = {};
1138
970
  const events: Record<string, (...args: any[]) => unknown> = {};
1139
971
  const commands: Record<string, { handler: (args: string, ctx: any) => unknown }> = {};
1140
972
  const pi = {
1141
973
  registerProvider(name: string, provider: { streamSimple: (...args: any[]) => AsyncIterable<any> }) {
1142
- providers[name] = provider;
974
+ providers[name] = {
975
+ ...provider,
976
+ streamSimple(...args: any[]) {
977
+ if (!concreteRequest) {
978
+ const model = args[0] as { provider: string };
979
+ process.env.CRTR_MODEL_INTENT = model.provider === "anthropic" ? "anthropic/strong" : "openai/strong";
980
+ }
981
+ return provider.streamSimple(...args);
982
+ },
983
+ };
1143
984
  },
1144
985
  registerCommand(name: string, command: { handler: (args: string, ctx: any) => unknown }) {
1145
986
  commands[name] = command;
@@ -1152,11 +993,42 @@ function makeRotationCtx() {
1152
993
  ctx.model = model;
1153
994
  return true;
1154
995
  },
1155
- setThinkingLevel() {},
996
+ setThinkingLevel(level: string) {
997
+ thinkingLevelCalls.push(level);
998
+ ctx.thinkingLevel = level;
999
+ },
1156
1000
  } as any;
1157
- return { ctx, setModelCalls, providers, events, commands, pi };
1001
+ return { ctx, setModelCalls, thinkingLevelCalls, providers, events, commands, pi };
1158
1002
  }
1159
1003
 
1004
+ // A live route carries the session's exact thinking level through every provider
1005
+ // request, including the requests that continue a turn after tool results.
1006
+ test("preserves a manually selected thinking level across provider requests", async () => {
1007
+ await resetRotationState();
1008
+ writeLadderConfig();
1009
+ await helpers.writeSubscriptionPool("openai-codex", [credential("codex")]);
1010
+
1011
+ const { ctx, thinkingLevelCalls, providers, events, pi } = makeRotationCtx("openai/strong");
1012
+ ctx.thinkingLevel = "max";
1013
+ rotationModule.__setStreamForProviderForTest(() => (async function* () {
1014
+ yield { type: "text_delta", text: "continued after tool result" };
1015
+ })() as any);
1016
+
1017
+ try {
1018
+ await providerRotationExtension(pi);
1019
+ await events.session_start?.({}, ctx);
1020
+ const stream = providers["openai-codex"].streamSimple(ctx.model, ctx);
1021
+ for await (const _event of stream) {
1022
+ // Drain the managed-provider request to completion.
1023
+ }
1024
+
1025
+ assert.deepEqual(thinkingLevelCalls, ["max"]);
1026
+ assert.equal(ctx.thinkingLevel, "max");
1027
+ } finally {
1028
+ rotationModule.__setStreamForProviderForTest(undefined);
1029
+ }
1030
+ });
1031
+
1160
1032
  // Codex exhaustion switches the model and serves the same turn on the Anthropic fallback.
1161
1033
  test("falls back transparently and serves the turn on Anthropic when Codex is exhausted", async () => {
1162
1034
  await resetRotationState();
@@ -1195,12 +1067,9 @@ test("falls back transparently and serves the turn on Anthropic when Codex is ex
1195
1067
  }
1196
1068
  });
1197
1069
 
1198
- // Live incident: a viewer explicitly selected qualified Sol, which set the
1199
- // broker's provider pin, but the extension ignored that broker state. The first
1200
- // model call could request a tool; on the post-tool continuation a shared Codex
1201
- // cooldown then rotated the supposedly-pinned session through every Anthropic
1202
- // credential. The live broker policy must be consulted on every provider call.
1203
- test("an explicit live Sol pin forbids Anthropic fallback on a post-tool provider continuation", async () => {
1070
+ // A concrete request only expands to routes carrying its exact model id. A
1071
+ // cooling Codex Sol route must not substitute Anthropic's different model.
1072
+ test("a cooling concrete model never substitutes a different-provider model", async () => {
1204
1073
  await resetRotationState();
1205
1074
  writeLadderConfig();
1206
1075
  await helpers.writeSubscriptionPool("openai-codex", [credential("codex")]);
@@ -1208,11 +1077,7 @@ test("an explicit live Sol pin forbids Anthropic fallback on a post-tool provide
1208
1077
  const expectedDeadline = helpers.readSubscriptionPool("openai-codex")[0].rateLimitedUntil;
1209
1078
  await helpers.writeSubscriptionPool("anthropic", [credential("personal")]);
1210
1079
 
1211
- const { ctx, setModelCalls, providers, events, pi } = makeRotationCtx();
1212
- ctx.ui[Symbol.for("@crouton-kit/crtr:managed-provider-rotation-policy")] = () => ({
1213
- allowCrossProviderFallback: false,
1214
- reason: "provider-pinned",
1215
- });
1080
+ const { ctx, setModelCalls, providers, events, pi } = makeRotationCtx(undefined);
1216
1081
  let streamCalls = 0;
1217
1082
  rotationModule.__setStreamForProviderForTest((_model) => {
1218
1083
  streamCalls += 1;
@@ -1231,10 +1096,10 @@ test("an explicit live Sol pin forbids Anthropic fallback on a post-tool provide
1231
1096
  const emitted = [] as any[];
1232
1097
  for await (const event of stream) emitted.push(event);
1233
1098
 
1234
- assert.deepEqual(setModelCalls, [], "the pinned Sol session never switches to Anthropic");
1099
+ assert.deepEqual(setModelCalls, [], "the concrete Sol request never switches to Anthropic");
1235
1100
  assert.equal(streamCalls, 0);
1236
1101
  assert.equal(emitted.length, 1);
1237
- assert.match(emitted[0].error.errorMessage, /explicitly pinned/i);
1102
+ assert.match(emitted[0].error.errorMessage, /All provider routes are rate limited/i);
1238
1103
  assert.equal(emitted[0].error.diagnostics?.[0]?.details?.rateLimitedUntil, expectedDeadline);
1239
1104
  } finally {
1240
1105
  rotationModule.__setStreamForProviderForTest(undefined);
@@ -1346,8 +1211,6 @@ test("surfaces the transient error and rotates to the fallback once the in-place
1346
1211
  // The turn was served on the fallback, never failed with a terminal error.
1347
1212
  assert.ok(emitted.some((e) => e.type === "text_delta"));
1348
1213
  assert.ok(!emitted.some((e) => e.type === "error"));
1349
- // The user was told about the transient failure AND that it rotated.
1350
- assert.ok(notifications.some((n) => /transient error after 3 attempts/.test(n.message) && /rotating/i.test(n.message)));
1351
1214
  // The struggling anthropic credential was cooled down so rotation moved on.
1352
1215
  assert.ok(helpers.readSubscriptionPool("anthropic")[0].rateLimitedUntil > Date.now());
1353
1216
  } finally {
@@ -1758,14 +1621,14 @@ test("emits an actionable 'not authenticated' error without switching models whe
1758
1621
  assert.deepEqual(setModelCalls, []);
1759
1622
  assert.equal(emitted.length, 1);
1760
1623
  assert.equal(emitted[0].type, "error");
1761
- assert.match(emitted[0].error.errorMessage, /OpenAI Codex not authenticated.*crtr sys setup/);
1624
+ assert.match(emitted[0].error.errorMessage, /All provider routes exhausted \(tried openai\)/);
1762
1625
  });
1763
1626
 
1764
1627
  // The builtin ladder is available even with no ~/.crouter/config.json, so a config-less
1765
1628
  // user can still resolve cross-provider fallbacks.
1766
1629
  test("degrades gracefully via the builtin ladder when a config-less user has no cross-provider credentials", async () => {
1767
1630
  await resetRotationState();
1768
- rmSync(join(home, ".crouter", "config.json"), { force: true });
1631
+ rmSync(join(nodeCwd, ".crouter", "config.json"), { force: true });
1769
1632
 
1770
1633
  const ctx = {
1771
1634
  model: { provider: "anthropic", id: "claude-opus-4-8", api: "anthropic-messages" },
@@ -1797,6 +1660,7 @@ test("degrades gracefully via the builtin ladder when a config-less user has no
1797
1660
  setThinkingLevel() {},
1798
1661
  } as any;
1799
1662
 
1663
+ process.env.CRTR_MODEL_INTENT = "anthropic/strong";
1800
1664
  await providerRotationExtension(pi);
1801
1665
  await events.session_start?.({}, ctx);
1802
1666
  // Anthropic's pool is empty, so the credential loop never runs. The builtin ladder resolves
@@ -1809,26 +1673,10 @@ test("degrades gracefully via the builtin ladder when a config-less user has no
1809
1673
  assert.deepEqual(setModelCalls, []);
1810
1674
  assert.equal(emitted.length, 1);
1811
1675
  assert.equal(emitted[0].type, "error");
1812
- assert.match(emitted[0].error.errorMessage, /Claude not authenticated.*crtr sys setup/);
1676
+ assert.match(emitted[0].error.errorMessage, /All provider routes exhausted \(tried live:anthropic\/claude-opus-4-8\)/);
1813
1677
  assert.ok(!emitted[0].error.errorMessage.includes("configure a openai ladder"));
1814
1678
  });
1815
1679
 
1816
- // readModelLadders() reads the builtin anthropic/openai ladder when config.json is absent.
1817
- test("resolves the builtin anthropic ladder (medium = sonnet-5) with no ~/.crouter/config.json present", async () => {
1818
- await resetRotationState();
1819
- rmSync(join(home, ".crouter", "config.json"), { force: true });
1820
-
1821
- const target = helpers.resolveFallbackTarget("anthropic", "anthropic/claude-sonnet-5:xhigh");
1822
-
1823
- assert.deepEqual(target, {
1824
- providerId: "openai-codex",
1825
- modelId: "gpt-5.6-terra",
1826
- label: "openai-codex/gpt-5.6-terra:medium",
1827
- strength: "medium",
1828
- thinkingLevel: "medium",
1829
- });
1830
- });
1831
-
1832
1680
  // A cooling provider must settle the turn with a typed retry deadline. Holding
1833
1681
  // the stream open here rendered the node as generic `Working...` for five silent
1834
1682
  // minutes and prevented the broker's existing scheduled retry path from owning
@@ -1952,9 +1800,7 @@ test("emits a typed rate-limit cooling error for a long provider deadline", asyn
1952
1800
  assert.equal(emitted[0].type, "error");
1953
1801
  // Rate-limit-classifiable so classify('pi→provider', msg) → rate-limit/auto (not fatal other).
1954
1802
  assert.match(emitted[0].error.errorMessage, /rate limit/i);
1955
- // Still carries the actionable fallback hint.
1956
- assert.match(emitted[0].error.errorMessage, /authenticate a fallback provider/);
1957
- // And the typed cooling diagnostic carrying the exact pool deadline for the stophook.
1803
+ // The typed cooling diagnostic carries the exact pool deadline for the stophook.
1958
1804
  assert.equal(emitted[0].error.diagnostics?.[0]?.type, "crtr:managed-provider-cooling");
1959
1805
  assert.equal(emitted[0].error.diagnostics?.[0]?.details?.rateLimitedUntil, expectedDeadline);
1960
1806
  } finally {
@@ -1988,10 +1834,6 @@ test("an excluded invalid_grant credential does not mask a cooling credential",
1988
1834
  assert.equal(emitted[0].error.diagnostics?.[0]?.details?.rateLimitedUntil, expectedDeadline);
1989
1835
  });
1990
1836
 
1991
- // Fix 1 (switchToFallbackIfPossible): pi's `setModel` returns false when the target lacks
1992
- // configured auth and leaves the model unchanged. A concurrent transition can still land on
1993
- // the target while that call is in flight; accept only that observed live result and reject
1994
- // every other false return.
1995
1837
  test("a same-turn 429 settles immediately instead of starting cooldown continuations", async () => {
1996
1838
  await resetRotationState();
1997
1839
  writeLadderConfig();
@@ -2059,54 +1901,6 @@ test("does not requeue a zero Retry-After credential without a later success", a
2059
1901
  }
2060
1902
  });
2061
1903
 
2062
- test("switchToFallbackIfPossible accepts a false setModel only when the live model already equals the target", async () => {
2063
- await resetRotationState();
2064
- writeLadderConfig();
2065
-
2066
- const target = helpers.resolveFallbackTarget("anthropic", "anthropic/claude-opus-4-8:high");
2067
- assert.ok(target && target.providerId === "openai-codex" && target.modelId === "gpt-5.6-sol", "anthropic opus falls back to codex sol");
2068
-
2069
- // Benign: setModel returns false without changing the model, while an independent
2070
- // concurrent transition lands on the exact target before the required live re-read.
2071
- {
2072
- let modelReads = 0;
2073
- const ctx = {
2074
- get model() {
2075
- modelReads++;
2076
- return modelReads === 1
2077
- ? { provider: "anthropic", id: "claude-opus-4-8", api: "anthropic-messages" }
2078
- : { provider: "openai-codex", id: "gpt-5.6-sol", api: "openai-codex-responses" };
2079
- },
2080
- modelRegistry: { find: (provider: string, id: string) => ({ provider, id, api: "x" }) },
2081
- } as any;
2082
- const pi = {
2083
- async setModel(_m: any) { return false; },
2084
- setThinkingLevel() {},
2085
- getThinkingLevel() { return undefined; },
2086
- } as any;
2087
- const result = await helpers.switchToFallbackIfPossible(pi, ctx);
2088
- assert.ok(result, "a false setModel with the target already current must be accepted");
2089
- assert.equal(result!.providerId, "openai-codex");
2090
- assert.equal(result!.modelId, "gpt-5.6-sol");
2091
- }
2092
-
2093
- // Real failure: setModel returns false and the live model stays DIFFERENT from the target
2094
- // → the switch is a genuine failure and must be rejected (undefined).
2095
- {
2096
- const ctx = {
2097
- model: { provider: "anthropic", id: "claude-opus-4-8", api: "anthropic-messages" },
2098
- modelRegistry: { find: (provider: string, id: string) => ({ provider, id, api: "x" }) },
2099
- } as any;
2100
- const pi = {
2101
- async setModel(_m: any) { return false; }, // leaves ctx.model on anthropic
2102
- setThinkingLevel() {},
2103
- getThinkingLevel() { return undefined; },
2104
- } as any;
2105
- const result = await helpers.switchToFallbackIfPossible(pi, ctx);
2106
- assert.equal(result, undefined, "a false setModel that did not land on the target is a real failure");
2107
- }
2108
- });
2109
-
2110
1904
  // Fresh-availability re-entry (Fix 3a): a credential that becomes ready AFTER the initial
2111
1905
  // `available` snapshot (a concurrent turn's cooldown expiring, or a causally valid success)
2112
1906
  // must be consumed by re-entering the SAME provider, not lost to a stale snapshot that would
@@ -2221,103 +2015,6 @@ test("both pools cooling: emits a rate-limit-classifiable terminal carrying the
2221
2015
  }
2222
2016
  });
2223
2017
 
2224
- // Live-incident regression (Hearth: a home's config.json had `gpt-5.6-*` ladder rungs baked
2225
- // in while the installed guest's model registry only had `gpt-5.5`): when the ONLY viable
2226
- // fallback target is authenticated (its pool has a credential) but its resolved model isn't
2227
- // in this build's registry, the terminal error must name that specific reason -- not the old
2228
- // generic "authenticate a fallback provider" text, which is actively wrong when the real
2229
- // blocker is a stale/out-of-sync ladder, not a missing login.
2230
- test("surfaces a specific 'not in model registry' reason instead of misleadingly telling the user to authenticate", async () => {
2231
- await resetRotationState();
2232
- writeLadderConfig();
2233
- await helpers.writeSubscriptionPool("anthropic", [credential("primary")]);
2234
- await helpers.markSubscriptionRateLimited("anthropic", "primary", 30 * 24 * 60 * 60 * 1000);
2235
- // OpenAI Codex IS authenticated -- the fallback rejection must NOT be "no credentials".
2236
- await helpers.writeSubscriptionPool("openai-codex", [credential("codex")]);
2237
-
2238
- const { ctx, setModelCalls, providers, events, pi } = makeRotationCtx();
2239
- ctx.model = { provider: "anthropic", id: "claude-opus-4-8", api: "anthropic-messages" };
2240
- // Registry recognizes anthropic's own model but NOT the configured openai-codex fallback
2241
- // rung (`gpt-5.6-sol`, from writeLadderConfig) -- simulating an installed guest whose
2242
- // catalog hasn't caught up with the configured ladder.
2243
- ctx.modelRegistry = {
2244
- find(provider: string, id: string) {
2245
- return provider === "anthropic" && id === "claude-opus-4-8" ? { provider, id, api: "anthropic-messages" } : undefined;
2246
- },
2247
- };
2248
- const sleepCalls: number[] = [];
2249
- rotationModule.__setSleepForTest(async (ms) => {
2250
- sleepCalls.push(ms);
2251
- });
2252
-
2253
- try {
2254
- await providerRotationExtension(pi);
2255
- await events.session_start?.({}, ctx);
2256
- const stream = providers["anthropic"].streamSimple(
2257
- { provider: "anthropic", id: "claude-opus-4-8", api: "anthropic-messages" },
2258
- ctx,
2259
- );
2260
- const emitted = [] as any[];
2261
- for await (const event of stream) emitted.push(event);
2262
-
2263
- assert.deepEqual(setModelCalls, []);
2264
- assert.deepEqual(sleepCalls, []);
2265
- assert.equal(emitted.length, 1);
2266
- assert.equal(emitted[0].type, "error");
2267
- assert.match(emitted[0].error.errorMessage, /openai-codex\/gpt-5\.6-sol:high is not in this build's model registry/);
2268
- assert.match(emitted[0].error.errorMessage, /modelLadders config is out of sync with the installed model catalog/);
2269
- assert.doesNotMatch(emitted[0].error.errorMessage, /authenticate a fallback provider/);
2270
- // The repair command must be EXECUTABLE as printed: `--value`, the CONFIG ladder
2271
- // provider key (`openai`, not the runtime `openai-codex`), and the target's strength.
2272
- assert.match(
2273
- emitted[0].error.errorMessage,
2274
- /crtr sys config set modelLadders\.openai\.strong --value <installed-model-id>/,
2275
- );
2276
- } finally {
2277
- rotationModule.__setSleepForTest(undefined);
2278
- }
2279
- });
2280
-
2281
- // Every fallback-rejection discriminant maps to actionable advice. Only `no_credentials`
2282
- // advises authentication because the other rejection states require a different remedy.
2283
- test("fallback rejection hints: only no_credentials advises authentication; every other discriminant gets accurate advice", async () => {
2284
- const codex = {
2285
- providerId: "openai-codex",
2286
- modelId: "gpt-5.6-sol",
2287
- label: "openai-codex/gpt-5.6-sol:high",
2288
- strength: "strong",
2289
- } as FallbackTarget;
2290
-
2291
- const noCreds = rotationModule.__fallbackRejectionHintForTest("no_credentials", codex);
2292
- assert.match(noCreds, /authenticate a fallback provider/);
2293
- assert.match(noCreds, /OpenAI Codex/);
2294
- assert.match(noCreds, /\/provider-sub openai-codex add/);
2295
-
2296
- const unregistered = rotationModule.__fallbackRejectionHintForTest("model_unregistered", codex);
2297
- assert.doesNotMatch(unregistered, /authenticate a fallback provider/);
2298
- assert.match(unregistered, /not in this build's model registry/);
2299
- // Executable repair command: --value, CONFIG ladder key (openai), target strength.
2300
- assert.match(unregistered, /crtr sys config set modelLadders\.openai\.strong --value <installed-model-id>/);
2301
-
2302
- const alreadyTried = rotationModule.__fallbackRejectionHintForTest("already_attempted", codex);
2303
- assert.doesNotMatch(alreadyTried, /authenticate a fallback provider/);
2304
- assert.match(alreadyTried, /already tried this turn/);
2305
- assert.match(alreadyTried, /try again later/);
2306
-
2307
- const cooling = rotationModule.__fallbackRejectionHintForTest("cooling", codex);
2308
- assert.doesNotMatch(cooling, /authenticate a fallback provider/);
2309
- assert.match(cooling, /also cooling down/);
2310
-
2311
- const pinned = rotationModule.__fallbackRejectionHintForTest("provider_pinned");
2312
- assert.doesNotMatch(pinned, /authenticate a fallback provider/);
2313
- assert.match(pinned, /explicitly pinned/);
2314
-
2315
- const noLadder = rotationModule.__fallbackRejectionHintForTest("no_ladder_entry");
2316
- assert.doesNotMatch(noLadder, /authenticate a fallback provider/);
2317
- assert.match(noLadder, /no cross-provider fallback is configured/);
2318
- assert.match(noLadder, /modelLadders/);
2319
- });
2320
-
2321
2018
  // A 429 observed during this provider call must author the deadline from the
2322
2019
  // freshly-mutated pool, without retrying or sleeping inside the active turn.
2323
2020
  test("schedules a sole credential cooldown observed in this turn from fresh pool state", async () => {
@@ -2417,18 +2114,14 @@ test("treats a headerless Codex usage-limit message as turn-local and falls back
2417
2114
  }
2418
2115
  });
2419
2116
 
2420
- test("a pinned provider's headerless usage-limit stays turn-local and carries its retry deadline", async () => {
2117
+ test("a concrete model's headerless usage-limit stays turn-local and carries its retry deadline", async () => {
2421
2118
  await resetRotationState();
2422
2119
  writeLadderConfig();
2423
2120
  await helpers.writeSubscriptionPool("openai-codex", [credential("codex")]);
2424
2121
  await helpers.writeSubscriptionPool("anthropic", [credential("personal")]);
2425
2122
 
2426
- const { ctx, setModelCalls, providers, events, pi } = makeRotationCtx();
2123
+ const { ctx, setModelCalls, providers, events, pi } = makeRotationCtx(undefined);
2427
2124
  ctx.model = { provider: "openai-codex", id: "gpt-5.6-sol", api: "openai-codex-responses" };
2428
- ctx.ui[Symbol.for("@crouton-kit/crtr:managed-provider-rotation-policy")] = () => ({
2429
- allowCrossProviderFallback: false,
2430
- reason: "provider-pinned",
2431
- });
2432
2125
  rotationModule.__setStreamForProviderForTest(() => {
2433
2126
  return (async function* () {
2434
2127
  yield {
@@ -2564,9 +2257,8 @@ test("a provider with no account: rotation never switches onto it", async () =>
2564
2257
  assert.deepEqual(setModelCalls, [], "never switches the session model onto an unconfigured provider");
2565
2258
  assert.equal(emitted.length, 1);
2566
2259
  assert.equal(emitted[0].type, "error");
2567
- // The blocker is a missing login, and the error says exactly that instead of wedging a node.
2568
- assert.match(emitted[0].error.errorMessage, /authenticate a fallback provider/);
2569
- assert.match(emitted[0].error.errorMessage, /\/provider-sub openai-codex add/);
2260
+ // An unauthenticated fallback never receives a model switch, while the live route's cooldown remains actionable.
2261
+ assert.match(emitted[0].error.errorMessage, /All provider routes are rate limited/);
2570
2262
  });
2571
2263
 
2572
2264