@crouton-kit/crouter 0.3.70 → 0.3.79

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. package/README.md +1 -1
  2. package/dist/build-root.d.ts +12 -4
  3. package/dist/build-root.js +25 -6
  4. package/dist/builtin-memory/00-runtime-base.md +3 -10
  5. package/dist/builtin-memory/04-base-worker.md +18 -0
  6. package/dist/builtin-memory/04-orchestration-kernel.md +1 -1
  7. package/dist/builtin-memory/crouter-development/plugins.md +82 -5
  8. package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +13 -34
  9. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +1115 -1
  10. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +234 -71
  11. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/statusline.ts +2 -9
  12. package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +2 -516
  13. package/dist/builtin-pi-packages/pi-crtr-extensions/package-lock.json +2 -2
  14. package/dist/builtin-pi-packages/pi-mode-switch/README.md +11 -39
  15. package/dist/builtin-pi-packages/pi-mode-switch/extensions/index.ts +20 -15
  16. package/dist/builtin-pi-packages/pi-mode-switch/package.json +1 -1
  17. package/dist/builtin-views/canvas/tui.mjs +8 -9
  18. package/dist/builtin-views/chat/tui.mjs +12 -12
  19. package/dist/builtin-views/git-pr/tui.mjs +7 -8
  20. package/dist/builtin-views/inbox/tui.mjs +27 -41
  21. package/dist/builtin-views/linkedin/tui.mjs +23 -37
  22. package/dist/builtin-views/prompt-review/tui.mjs +11 -11
  23. package/dist/builtin-views/settings/tui.mjs +11 -11
  24. package/dist/builtin-views/workspace-sidebar/tui.mjs +8 -9
  25. package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +11 -6
  26. package/dist/clients/attach/__tests__/attach-keybindings.test.d.ts +1 -0
  27. package/dist/clients/attach/__tests__/attach-keybindings.test.js +113 -0
  28. package/dist/clients/attach/__tests__/crtr-output-render.test.js +1 -1
  29. package/dist/clients/attach/__tests__/mermaid-render.test.d.ts +1 -0
  30. package/dist/clients/attach/__tests__/mermaid-render.test.js +28 -0
  31. package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.d.ts +1 -0
  32. package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.js +110 -0
  33. package/dist/clients/attach/attach-cmd.d.ts +9 -1
  34. package/dist/clients/attach/attach-cmd.js +841 -792
  35. package/dist/clients/attach/auth-pickers.d.ts +0 -12
  36. package/dist/clients/attach/auth-pickers.js +64 -15
  37. package/dist/clients/attach/canvas-panels.js +2 -3
  38. package/dist/clients/attach/chat-view.d.ts +7 -8
  39. package/dist/clients/attach/chat-view.js +119 -79
  40. package/dist/clients/attach/edit-diff-render.d.ts +6 -0
  41. package/dist/clients/attach/edit-diff-render.js +263 -0
  42. package/dist/clients/attach/graph-overlay.d.ts +12 -2
  43. package/dist/clients/attach/graph-overlay.js +83 -33
  44. package/dist/clients/attach/input-controller.d.ts +28 -2
  45. package/dist/clients/attach/input-controller.js +38 -5
  46. package/dist/clients/attach/mermaid-render.js +5 -2
  47. package/dist/clients/attach/pickers.d.ts +8 -7
  48. package/dist/clients/attach/pickers.js +11 -17
  49. package/dist/clients/attach/slash-commands.d.ts +9 -0
  50. package/dist/clients/attach/slash-commands.js +127 -7
  51. package/dist/clients/attach/titled-editor.d.ts +12 -1
  52. package/dist/clients/attach/titled-editor.js +103 -8
  53. package/dist/commands/canvas-browse.js +2 -2
  54. package/dist/commands/human/queue.js +3 -4
  55. package/dist/commands/memory/lint.js +39 -5
  56. package/dist/commands/memory/write.js +1 -0
  57. package/dist/commands/node.js +9 -2
  58. package/dist/commands/pkg/plugin-inspect.js +22 -1
  59. package/dist/commands/pkg/plugin-manage.js +31 -9
  60. package/dist/commands/surface-tmux-spread.js +1 -3
  61. package/dist/commands/sys/__tests__/config-keybindings.test.d.ts +1 -0
  62. package/dist/commands/sys/__tests__/config-keybindings.test.js +55 -0
  63. package/dist/commands/sys/__tests__/config-model-ladders.test.d.ts +1 -0
  64. package/dist/commands/sys/__tests__/config-model-ladders.test.js +121 -0
  65. package/dist/commands/sys/__tests__/setup-core.test.js +158 -1
  66. package/dist/commands/sys/config.js +18 -21
  67. package/dist/commands/sys/doctor.js +42 -4
  68. package/dist/commands/sys/setup-core.d.ts +49 -1
  69. package/dist/commands/sys/setup-core.js +161 -4
  70. package/dist/commands/sys/setup.d.ts +88 -0
  71. package/dist/commands/sys/setup.js +943 -169
  72. package/dist/commands/view-pick.d.ts +4 -0
  73. package/dist/commands/view-pick.js +17 -7
  74. package/dist/core/__tests__/base-worker-prompt.test.d.ts +1 -0
  75. package/dist/core/__tests__/base-worker-prompt.test.js +24 -0
  76. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +232 -1
  77. package/dist/core/__tests__/canvas-inbox-watcher.test.js +34 -9
  78. package/dist/core/__tests__/command-plugins-surfaces.test.d.ts +1 -0
  79. package/dist/core/__tests__/command-plugins-surfaces.test.js +298 -0
  80. package/dist/core/__tests__/command-plugins.test.d.ts +1 -0
  81. package/dist/core/__tests__/command-plugins.test.js +444 -0
  82. package/dist/core/__tests__/fault-classifier.test.js +15 -0
  83. package/dist/core/__tests__/fixtures/fake-engine.d.ts +6 -0
  84. package/dist/core/__tests__/fixtures/fake-engine.js +9 -1
  85. package/dist/core/__tests__/full/broker-dialogs.test.js +7 -2
  86. package/dist/core/__tests__/host-teardown-process-group.test.js +15 -4
  87. package/dist/core/__tests__/preview-registry-sync.test.js +30 -1
  88. package/dist/core/__tests__/scope-crouter-home-fence.test.d.ts +1 -0
  89. package/dist/core/__tests__/scope-crouter-home-fence.test.js +55 -0
  90. package/dist/core/__tests__/stream-watchdog.test.d.ts +1 -0
  91. package/dist/core/__tests__/stream-watchdog.test.js +70 -0
  92. package/dist/core/__tests__/tmux-surface.test.js +72 -0
  93. package/dist/core/canvas/browse/__tests__/model.test.js +23 -5
  94. package/dist/core/canvas/browse/__tests__/render.test.js +1 -0
  95. package/dist/core/canvas/browse/app.d.ts +6 -0
  96. package/dist/core/canvas/browse/app.js +230 -41
  97. package/dist/core/canvas/browse/model.d.ts +10 -6
  98. package/dist/core/canvas/browse/model.js +20 -12
  99. package/dist/core/canvas/browse/pins.d.ts +4 -0
  100. package/dist/core/canvas/browse/pins.js +29 -0
  101. package/dist/core/canvas/browse/render.d.ts +13 -1
  102. package/dist/core/canvas/browse/render.js +72 -15
  103. package/dist/core/canvas/nav-model.js +5 -11
  104. package/dist/core/canvas/paths.d.ts +5 -0
  105. package/dist/core/canvas/paths.js +7 -0
  106. package/dist/core/command-plugins/adapter.d.ts +15 -0
  107. package/dist/core/command-plugins/adapter.js +145 -0
  108. package/dist/core/command-plugins/compose.d.ts +5 -0
  109. package/dist/core/command-plugins/compose.js +56 -0
  110. package/dist/core/command-plugins/discovery.d.ts +104 -0
  111. package/dist/core/command-plugins/discovery.js +565 -0
  112. package/dist/core/config.d.ts +48 -0
  113. package/dist/core/config.js +228 -110
  114. package/dist/core/fault-classifier.js +1 -1
  115. package/dist/core/fs-utils.d.ts +1 -0
  116. package/dist/core/fs-utils.js +15 -1
  117. package/dist/core/keybindings/__tests__/bespoke-consumers.test.d.ts +1 -0
  118. package/dist/core/keybindings/__tests__/bespoke-consumers.test.js +40 -0
  119. package/dist/core/keybindings/__tests__/resolve.test.d.ts +1 -0
  120. package/dist/core/keybindings/__tests__/resolve.test.js +224 -0
  121. package/dist/core/keybindings/catalog.d.ts +14 -0
  122. package/dist/core/keybindings/catalog.js +257 -0
  123. package/dist/core/keybindings/index.d.ts +5 -0
  124. package/dist/core/keybindings/index.js +4 -0
  125. package/dist/core/keybindings/match.d.ts +29 -0
  126. package/dist/core/keybindings/match.js +99 -0
  127. package/dist/core/keybindings/persistence.d.ts +14 -0
  128. package/dist/core/keybindings/persistence.js +31 -0
  129. package/dist/core/keybindings/resolve.d.ts +14 -0
  130. package/dist/core/keybindings/resolve.js +305 -0
  131. package/dist/core/keybindings/types.d.ts +37 -0
  132. package/dist/core/keybindings/types.js +1 -0
  133. package/dist/core/memory-resolver.d.ts +1 -1
  134. package/dist/core/memory-resolver.js +10 -3
  135. package/dist/core/predicate.d.ts +5 -3
  136. package/dist/core/predicate.js +5 -3
  137. package/dist/core/profiles/select.d.ts +6 -0
  138. package/dist/core/profiles/select.js +86 -52
  139. package/dist/core/provider-management.d.ts +12 -0
  140. package/dist/core/provider-management.js +24 -0
  141. package/dist/core/runtime/broker-protocol.d.ts +33 -5
  142. package/dist/core/runtime/broker.js +164 -14
  143. package/dist/core/runtime/launch.d.ts +39 -6
  144. package/dist/core/runtime/launch.js +78 -20
  145. package/dist/core/runtime/naming.js +3 -3
  146. package/dist/core/runtime/pi-cli.d.ts +6 -0
  147. package/dist/core/runtime/pi-cli.js +16 -2
  148. package/dist/core/runtime/pi-vendored.d.ts +8 -0
  149. package/dist/core/runtime/pi-vendored.js +14 -0
  150. package/dist/core/runtime/placement.d.ts +2 -2
  151. package/dist/core/runtime/placement.js +4 -1
  152. package/dist/core/runtime/promote.js +4 -0
  153. package/dist/core/runtime/recap.d.ts +3 -3
  154. package/dist/core/runtime/recap.js +75 -47
  155. package/dist/core/runtime/recycle.js +6 -1
  156. package/dist/core/runtime/reset.js +5 -0
  157. package/dist/core/runtime/session-list-cache.d.ts +33 -0
  158. package/dist/core/runtime/session-list-cache.js +338 -0
  159. package/dist/core/runtime/spawn.js +52 -45
  160. package/dist/core/runtime/stream-watchdog.d.ts +26 -0
  161. package/dist/core/runtime/stream-watchdog.js +75 -0
  162. package/dist/core/runtime/tmux-chrome.d.ts +1 -1
  163. package/dist/core/runtime/tmux-chrome.js +2 -2
  164. package/dist/core/runtime/tmux.d.ts +18 -15
  165. package/dist/core/runtime/tmux.js +217 -110
  166. package/dist/core/scope.js +27 -4
  167. package/dist/core/subscription-state.d.ts +90 -0
  168. package/dist/core/subscription-state.js +762 -0
  169. package/dist/core/tui/__tests__/host-keybindings.test.d.ts +1 -0
  170. package/dist/core/tui/__tests__/host-keybindings.test.js +112 -0
  171. package/dist/core/tui/host.d.ts +30 -1
  172. package/dist/core/tui/host.js +148 -34
  173. package/dist/core/view/contract.d.ts +17 -3
  174. package/dist/daemon/crtrd.js +253 -12
  175. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +41 -0
  176. package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.d.ts +1 -0
  177. package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.js +121 -0
  178. package/dist/pi-extensions/canvas-inbox-watcher.d.ts +1 -1
  179. package/dist/pi-extensions/canvas-inbox-watcher.js +203 -25
  180. package/dist/pi-extensions/canvas-recap.d.ts +4 -0
  181. package/dist/pi-extensions/canvas-recap.js +73 -46
  182. package/dist/pi-extensions/canvas-stophook.d.ts +11 -0
  183. package/dist/pi-extensions/canvas-stophook.js +33 -4
  184. package/dist/types.d.ts +9 -26
  185. package/dist/types.js +1 -28
  186. package/dist/web-client/assets/{index-DiFuLcp6.js → index--md2ylfi.js} +1 -1
  187. package/dist/web-client/index.html +1 -1
  188. package/dist/web-client/sw.js +1 -1
  189. package/package.json +8 -4
  190. package/dist/pi-extensions/canvas-nav.d.ts +0 -43
  191. package/dist/pi-extensions/canvas-nav.js +0 -640
  192. package/dist/pi-extensions/widget-order-bus.d.ts +0 -6
  193. package/dist/pi-extensions/widget-order-bus.js +0 -34
@@ -1,516 +1,2 @@
1
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
- import { homedir } from "node:os";
3
- import { dirname, join } from "node:path";
4
- import type { ThinkingLevel } from "@earendil-works/pi-ai";
5
- import { FileAuthStorageBackend } from "@earendil-works/pi-coding-agent";
6
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
7
-
8
- export const ANTHROPIC_PROVIDER_ID = "anthropic";
9
- export const OPENAI_CODEX_PROVIDER_ID = "openai-codex";
10
-
11
- export type ManagedProviderId = typeof ANTHROPIC_PROVIDER_ID | typeof OPENAI_CODEX_PROVIDER_ID;
12
- export type LadderProviderId = "anthropic" | "openai";
13
- export type ModelStrength = "ultra" | "strong" | "medium" | "light";
14
-
15
- // The default label's slot (`label === providerId`) is the OAuth-login-linked credential --
16
- // its live value (`refresh`/`access`/`expires`) is owned exclusively by pi's auth.json (see
17
- // provider-rotation.ts's `refreshDefaultSlotCredential`/`reauthenticateCredential`), so those
18
- // fields are absent on that entry. Any OTHER explicitly-added account (`/provider-sub add`,
19
- // label !== providerId) keeps the full value here, unchanged.
20
- export type SubscriptionCredential = {
21
- label: string;
22
- refresh?: string;
23
- access?: string;
24
- expires?: number;
25
- rateLimitedUntil: number;
26
- lastAttemptAt: number;
27
- lastRateLimitedAt: number;
28
- };
29
-
30
- export type ModelRef = {
31
- providerId: ManagedProviderId;
32
- modelId: string;
33
- };
34
-
35
- export type RotationConfig = {
36
- defaultFallbackStrength?: ModelStrength;
37
- revertWhenAvailable?: boolean;
38
- preferredModel?: ModelRef;
39
- };
40
-
41
- type RotationConfigUpdate = {
42
- defaultFallbackStrength?: ModelStrength;
43
- revertWhenAvailable?: boolean;
44
- preferredModel?: ModelRef | null;
45
- };
46
-
47
- export type FallbackTarget = {
48
- providerId: ManagedProviderId;
49
- modelId: string;
50
- label: string;
51
- strength: ModelStrength;
52
- thinkingLevel?: ThinkingLevel;
53
- };
54
-
55
- type ModelLadderConfig = Partial<Record<ModelStrength, string>>;
56
- type ModelLaddersConfig = Partial<Record<LadderProviderId, ModelLadderConfig>>;
57
- const AGENT_DIR = join(homedir(), ".pi", "agent");
58
- const ROTATION_CONFIG_FILE = join(AGENT_DIR, "provider-rotation.json");
59
- const CROUTER_CONFIG_FILE = join(homedir(), ".crouter", "config.json");
60
- const DEFAULT_FALLBACK_STRENGTH: ModelStrength = "strong";
61
-
62
- const PROVIDERS: Record<
63
- ManagedProviderId,
64
- {
65
- label: string;
66
- ladderProviderId: LadderProviderId;
67
- poolFile: string;
68
- }
69
- > = {
70
- [ANTHROPIC_PROVIDER_ID]: {
71
- label: "Claude",
72
- ladderProviderId: "anthropic",
73
- poolFile: join(AGENT_DIR, "anthropic-subscriptions.json"),
74
- },
75
- [OPENAI_CODEX_PROVIDER_ID]: {
76
- label: "OpenAI Codex",
77
- ladderProviderId: "openai",
78
- poolFile: join(AGENT_DIR, "openai-codex-subscriptions.json"),
79
- },
80
- };
81
-
82
- function readJsonFile<T>(path: string): T | undefined {
83
- if (!existsSync(path)) return undefined;
84
- try {
85
- const raw = readFileSync(path, "utf8").trim();
86
- if (!raw) return undefined;
87
- return JSON.parse(raw) as T;
88
- } catch {
89
- return undefined;
90
- }
91
- }
92
-
93
- function writeJsonFile(path: string, value: unknown): void {
94
- mkdirSync(dirname(path), { recursive: true });
95
- const tmp = `${path}.${process.pid}.tmp`;
96
- writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, "utf8");
97
- renameSync(tmp, path);
98
- }
99
-
100
- function normalizeStrength(value: unknown): ModelStrength | undefined {
101
- return value === "ultra" || value === "strong" || value === "medium" || value === "light" ? value : undefined;
102
- }
103
-
104
- function normalizeTiming(value: unknown): number {
105
- const numeric = Number(value);
106
- return Number.isFinite(numeric) ? numeric : 0;
107
- }
108
-
109
- function normalizeCredential(value: Partial<SubscriptionCredential> & { label?: string }): SubscriptionCredential | undefined {
110
- const label = typeof value.label === "string" ? value.label.trim() : "";
111
- if (!label) return undefined;
112
- const refresh = typeof value.refresh === "string" ? value.refresh.trim() : "";
113
- const access = typeof value.access === "string" ? value.access.trim() : "";
114
- const expires = Number(value.expires);
115
- const rateLimitedUntil = Number(value.rateLimitedUntil);
116
- return {
117
- label,
118
- ...(refresh && access ? { refresh, access, expires: Number.isFinite(expires) ? expires : 0 } : {}),
119
- rateLimitedUntil: Number.isFinite(rateLimitedUntil) ? rateLimitedUntil : 0,
120
- lastAttemptAt: normalizeTiming(value.lastAttemptAt),
121
- lastRateLimitedAt: normalizeTiming(value.lastRateLimitedAt),
122
- };
123
- }
124
-
125
- function normalizeCredentials(values: unknown): SubscriptionCredential[] {
126
- if (!Array.isArray(values)) return [];
127
- return values.map((entry) => normalizeCredential(entry as Partial<SubscriptionCredential> & { label?: string })).filter((entry): entry is SubscriptionCredential => Boolean(entry));
128
- }
129
-
130
- function getCurrentModelRef(model?: { provider: string; id: string }): ModelRef | undefined {
131
- return isManagedProvider(model?.provider) ? { providerId: model.provider, modelId: model.id } : undefined;
132
- }
133
-
134
- type AuthFileData = Record<string, { type?: string }>;
135
-
136
- function readAuthCredential(providerId: ManagedProviderId): { type?: string } | undefined {
137
- return new FileAuthStorageBackend().withLock((current) => {
138
- if (!current) return { result: undefined };
139
- const data = JSON.parse(current) as AuthFileData;
140
- return { result: data[providerId] };
141
- });
142
- }
143
-
144
- // Seeds a METADATA-ONLY pool entry for the default label (`label === providerId`)
145
- // whenever an OAuth-login-linked credential exists for it in auth.json, regardless of
146
- // whether the pool already holds OTHER explicit accounts. The auth.json seed read goes
147
- // through FileAuthStorageBackend's real lock so a held auth.json lock or parse failure is
148
- // surfaced instead of silently pretending the default credential is absent. Gated
149
- // specifically on an OAUTH auth.json record (`type === "oauth"`) rather than the presence
150
- // of any credential, because an api_key credential has no refresh token -- a
151
- // metadata-only "default" slot seeded for one would be a slot `refreshDefaultSlotCredential`
152
- // can never actually refresh. No token is ever copied into the pool: the value lives
153
- // exclusively in auth.json.
154
- function ensureDefaultSubscriptionMetadata(providerId: ManagedProviderId, pool: SubscriptionCredential[]): SubscriptionCredential[] {
155
- if (pool.some((entry) => entry.label === providerId)) return pool;
156
- const authCred = readAuthCredential(providerId);
157
- if (authCred?.type !== "oauth") return pool;
158
- const seeded = normalizeCredential({ label: providerId, rateLimitedUntil: 0, lastAttemptAt: 0, lastRateLimitedAt: 0 });
159
- return seeded ? [...pool, seeded] : pool;
160
- }
161
-
162
- // The default label's value lives exclusively in auth.json, so its pool entry must never
163
- // carry `refresh`/`access`/`expires`; this strips those fields on every read and write,
164
- // self-healing any pool file that has them physically present on disk. `changed` tells
165
- // the caller whether anything was actually stripped, so `readSubscriptionPool` can
166
- // persist the metadata-only result immediately rather than leaving a stale copy on disk
167
- // until some unrelated write.
168
- function scrubDefaultValue(providerId: ManagedProviderId, pool: SubscriptionCredential[]): { pool: SubscriptionCredential[]; changed: boolean } {
169
- let changed = false;
170
- const next = pool.map((entry) => {
171
- if (entry.label !== providerId || (entry.refresh === undefined && entry.access === undefined && entry.expires === undefined)) return entry;
172
- changed = true;
173
- const { refresh: _refresh, access: _access, expires: _expires, ...meta } = entry;
174
- return meta;
175
- });
176
- return { pool: next, changed };
177
- }
178
-
179
- export function isManagedProvider(providerId: string | undefined): providerId is ManagedProviderId {
180
- return providerId === ANTHROPIC_PROVIDER_ID || providerId === OPENAI_CODEX_PROVIDER_ID;
181
- }
182
-
183
- export function getProviderLabel(providerId: ManagedProviderId): string {
184
- return PROVIDERS[providerId].label;
185
- }
186
-
187
- export function readSubscriptionPool(providerId: ManagedProviderId): SubscriptionCredential[] {
188
- const poolFile = PROVIDERS[providerId].poolFile;
189
- const raw = normalizeCredentials(readJsonFile<unknown>(poolFile));
190
- const { pool: scrubbed, changed: wasScrubbed } = scrubDefaultValue(providerId, raw);
191
- const withDefaultSlot = ensureDefaultSubscriptionMetadata(providerId, scrubbed);
192
- if (wasScrubbed || withDefaultSlot !== scrubbed) return writeSubscriptionPool(providerId, withDefaultSlot);
193
- return withDefaultSlot;
194
- }
195
-
196
- export function writeSubscriptionPool(providerId: ManagedProviderId, next: SubscriptionCredential[]): SubscriptionCredential[] {
197
- const normalized = scrubDefaultValue(providerId, normalizeCredentials(next)).pool;
198
- writeJsonFile(PROVIDERS[providerId].poolFile, normalized);
199
- return normalized;
200
- }
201
-
202
- function resolveCredentialIndex(pool: SubscriptionCredential[], ref: string): number {
203
- const needle = ref.trim().toLowerCase();
204
- const numeric = Number.parseInt(needle, 10);
205
- if (Number.isFinite(numeric) && String(numeric) === needle && numeric >= 1 && numeric <= pool.length) {
206
- return numeric - 1;
207
- }
208
- return pool.findIndex((entry) => entry.label.toLowerCase() === needle);
209
- }
210
-
211
- export function upsertSubscription(providerId: ManagedProviderId, next: SubscriptionCredential): SubscriptionCredential[] {
212
- const pool = readSubscriptionPool(providerId);
213
- const index = pool.findIndex((entry) => entry.label.toLowerCase() === next.label.toLowerCase());
214
- if (index === -1) pool.push(next);
215
- else pool[index] = next;
216
- return writeSubscriptionPool(providerId, pool);
217
- }
218
-
219
- export function removeSubscription(providerId: ManagedProviderId, ref: string): SubscriptionCredential[] {
220
- const pool = readSubscriptionPool(providerId);
221
- const index = resolveCredentialIndex(pool, ref);
222
- if (index === -1) throw new Error(`Unknown ${getProviderLabel(providerId)} subscription: ${ref}`);
223
- pool.splice(index, 1);
224
- return writeSubscriptionPool(providerId, pool);
225
- }
226
-
227
- export function promoteSubscription(providerId: ManagedProviderId, ref: string): SubscriptionCredential[] {
228
- const pool = readSubscriptionPool(providerId);
229
- const index = resolveCredentialIndex(pool, ref);
230
- if (index === -1) throw new Error(`Unknown ${getProviderLabel(providerId)} subscription: ${ref}`);
231
- if (index > 0) pool.unshift(pool.splice(index, 1)[0]);
232
- return writeSubscriptionPool(providerId, pool);
233
- }
234
-
235
- export function markSubscriptionAttempt(providerId: ManagedProviderId, ref: string, attemptAt = Date.now()): SubscriptionCredential[] {
236
- const pool = readSubscriptionPool(providerId);
237
- const index = resolveCredentialIndex(pool, ref);
238
- if (index === -1) throw new Error(`Unknown ${getProviderLabel(providerId)} subscription: ${ref}`);
239
- pool[index] = {
240
- ...pool[index],
241
- lastAttemptAt: Math.max(pool[index].lastAttemptAt, attemptAt),
242
- };
243
- return writeSubscriptionPool(providerId, pool);
244
- }
245
-
246
- export function markSubscriptionRateLimited(
247
- providerId: ManagedProviderId,
248
- ref: string,
249
- retryAfterMs: number,
250
- attemptAt = Date.now(),
251
- rateLimitedAt = Date.now(),
252
- ): SubscriptionCredential[] {
253
- const pool = readSubscriptionPool(providerId);
254
- const index = resolveCredentialIndex(pool, ref);
255
- if (index === -1) throw new Error(`Unknown ${getProviderLabel(providerId)} subscription: ${ref}`);
256
- if (rateLimitedAt < pool[index].lastRateLimitedAt) return pool;
257
- pool[index] = {
258
- ...pool[index],
259
- lastAttemptAt: Math.max(pool[index].lastAttemptAt, attemptAt),
260
- lastRateLimitedAt: Math.max(pool[index].lastRateLimitedAt, rateLimitedAt),
261
- rateLimitedUntil: rateLimitedAt + Math.max(0, retryAfterMs),
262
- };
263
- return writeSubscriptionPool(providerId, pool);
264
- }
265
-
266
- export function markSubscriptionSuccess(providerId: ManagedProviderId, ref: string, attemptAt = Date.now()): SubscriptionCredential[] {
267
- const pool = readSubscriptionPool(providerId);
268
- const index = resolveCredentialIndex(pool, ref);
269
- if (index === -1) throw new Error(`Unknown ${getProviderLabel(providerId)} subscription: ${ref}`);
270
- if (attemptAt < pool[index].lastRateLimitedAt) return pool;
271
- pool[index] = {
272
- ...pool[index],
273
- lastAttemptAt: Math.max(pool[index].lastAttemptAt, attemptAt),
274
- rateLimitedUntil: 0,
275
- };
276
- return writeSubscriptionPool(providerId, pool);
277
- }
278
-
279
- export function findAvailableSubscription(providerId: ManagedProviderId, now = Date.now()): SubscriptionCredential | undefined {
280
- return readSubscriptionPool(providerId).find((entry) => !entry.rateLimitedUntil || entry.rateLimitedUntil <= now);
281
- }
282
-
283
- export function parseRetryAfterHeader(value: string | undefined, now = Date.now()): number | undefined {
284
- if (!value) return undefined;
285
- const trimmed = value.trim();
286
- if (!trimmed) return undefined;
287
- const seconds = Number(trimmed);
288
- if (Number.isFinite(seconds)) return Math.max(0, Math.round(seconds * 1000));
289
- const parsed = Date.parse(trimmed);
290
- return Number.isFinite(parsed) ? Math.max(0, parsed - now) : undefined;
291
- }
292
-
293
- function thinkingLevelOfRef(ref: string): ThinkingLevel | undefined {
294
- const match = ref.trim().match(/:(thinking|off|minimal|low|medium|high|xhigh|max)$/i);
295
- if (!match) return undefined;
296
- const level = match[1].toLowerCase();
297
- return level === "off" || level === "thinking" ? undefined : (level as ThinkingLevel);
298
- }
299
-
300
- function normalizeModelRef(ref: string): string {
301
- return ref
302
- .trim()
303
- .replace(/^[^/]+\//, "")
304
- .replace(/:(?:thinking|off|minimal|low|medium|high|xhigh|max)$/i, "")
305
- .toLowerCase();
306
- }
307
-
308
- function splitModelRef(ref: string): FallbackTarget {
309
- const trimmed = ref.trim();
310
- const slash = trimmed.indexOf("/");
311
- const providerId = slash === -1 ? OPENAI_CODEX_PROVIDER_ID : trimmed.slice(0, slash).trim();
312
- if (!isManagedProvider(providerId)) {
313
- throw new Error(`Unsupported fallback provider: ${providerId}`);
314
- }
315
- const rawModelId = slash === -1 ? trimmed : trimmed.slice(slash + 1).trim();
316
- const thinkingMatch = rawModelId.match(/^(.*?)(?::(thinking|off|minimal|low|medium|high|xhigh|max))$/i);
317
- return {
318
- providerId,
319
- modelId: thinkingMatch ? thinkingMatch[1] : rawModelId,
320
- label: `${providerId}/${thinkingMatch ? thinkingMatch[1] : rawModelId}${thinkingMatch?.[2] ? `:${thinkingMatch[2].toLowerCase()}` : ""}`,
321
- strength: DEFAULT_FALLBACK_STRENGTH,
322
- thinkingLevel: thinkingMatch?.[2] && thinkingMatch[2].toLowerCase() !== "off" && thinkingMatch[2].toLowerCase() !== "thinking"
323
- ? (thinkingMatch[2].toLowerCase() as ThinkingLevel)
324
- : undefined,
325
- };
326
- }
327
-
328
- // Vendored copy of crouter's `defaultModelLaddersConfig()` (src/types.ts).
329
- // This package is synced from a standalone repo (pi-personal-extensions) and
330
- // has no runtime import path into crouter's compiled internals, so the
331
- // builtin rungs are duplicated here rather than imported; keep the two in
332
- // sync by hand if either changes.
333
- const BUILTIN_MODEL_LADDERS: Record<LadderProviderId, ModelLadderConfig> = {
334
- anthropic: {
335
- ultra: "anthropic/claude-fable-5:high",
336
- strong: "anthropic/claude-fable-5:medium",
337
- medium: "anthropic/claude-opus-4-8:high",
338
- light: "anthropic/claude-sonnet-5:high",
339
- },
340
- openai: {
341
- ultra: "openai-codex/gpt-5.6-sol:max",
342
- strong: "openai-codex/gpt-5.6-sol:high",
343
- medium: "openai-codex/gpt-5.6-terra:medium",
344
- light: "openai-codex/gpt-5.6-luna:low",
345
- },
346
- };
347
-
348
- // Layers ~/.crouter/config.json's `modelLadders` (if any) OVER the builtin
349
- // defaults above, per provider/strength — mirroring crouter's own
350
- // `mergeModelLadders` (src/core/config.ts), which is what a node's launcher
351
- // actually resolves models from. Without this fallback, a config-less user
352
- // (the common case: fresh install, no ~/.crouter/config.json) saw an EMPTY
353
- // ladder here while crtr itself launched them on the builtin defaults —
354
- // rotation could then never resolve a strength tier or a cross-provider
355
- // fallback for a model it was already running. Always returns a fully
356
- // populated ladder for both providers; a config.json override only replaces
357
- // the specific strengths it sets.
358
- function readModelLadders(): ModelLaddersConfig {
359
- const config = readJsonFile<{ modelLadders?: Partial<Record<LadderProviderId, Partial<ModelLadderConfig>>> }>(CROUTER_CONFIG_FILE);
360
- const overrides = config?.modelLadders;
361
- const result: ModelLaddersConfig = {};
362
- for (const provider of ["anthropic", "openai"] as const) {
363
- const normalized: ModelLadderConfig = { ...BUILTIN_MODEL_LADDERS[provider] };
364
- const override = overrides?.[provider];
365
- if (override) {
366
- for (const strength of ["ultra", "strong", "medium", "light"] as const) {
367
- const value = typeof override[strength] === "string" ? override[strength]?.trim() : "";
368
- if (value) normalized[strength] = value;
369
- }
370
- }
371
- result[provider] = normalized;
372
- }
373
- return result;
374
- }
375
-
376
- function findStrengthForModelId(
377
- providerId: ManagedProviderId,
378
- modelId: string | undefined,
379
- defaultStrength: ModelStrength,
380
- ladders = readModelLadders(),
381
- currentThinkingLevel?: string,
382
- ): ModelStrength | undefined {
383
- const source = ladders?.[PROVIDERS[providerId].ladderProviderId];
384
- const bareId = modelId ? normalizeModelRef(modelId) : undefined;
385
- if (!source || !bareId) return undefined;
386
- const matches: ModelStrength[] = [];
387
- for (const strength of ["ultra", "strong", "medium", "light"] as const) {
388
- const ref = source[strength];
389
- if (ref && normalizeModelRef(ref) === bareId) matches.push(strength);
390
- }
391
- if (matches.length === 0) return undefined;
392
- // Rungs that share a bare model id (e.g. gpt-5.6-sol:max vs gpt-5.6-sol:high) are
393
- // disambiguated by the live reasoning level so an ultra run doesn't collapse to strong.
394
- if (matches.length > 1 && currentThinkingLevel) {
395
- const exact = matches.find((strength) => thinkingLevelOfRef(source[strength]!) === currentThinkingLevel);
396
- if (exact) return exact;
397
- }
398
- return matches.includes(defaultStrength) ? defaultStrength : matches[0];
399
- }
400
-
401
- export function readRotationConfig(): RotationConfig {
402
- const raw = readJsonFile<RotationConfig>(ROTATION_CONFIG_FILE) ?? {};
403
- const preferredModel =
404
- raw.preferredModel && isManagedProvider(raw.preferredModel.providerId) && typeof raw.preferredModel.modelId === "string"
405
- ? { providerId: raw.preferredModel.providerId, modelId: raw.preferredModel.modelId.trim() }
406
- : undefined;
407
- return {
408
- defaultFallbackStrength: normalizeStrength(raw.defaultFallbackStrength) ?? DEFAULT_FALLBACK_STRENGTH,
409
- revertWhenAvailable: raw.revertWhenAvailable ?? true,
410
- preferredModel,
411
- };
412
- }
413
-
414
- export function writeRotationConfig(next: RotationConfigUpdate): RotationConfig {
415
- const current = readRotationConfig();
416
- const merged: RotationConfig = {
417
- defaultFallbackStrength: normalizeStrength(next.defaultFallbackStrength) ?? current.defaultFallbackStrength ?? DEFAULT_FALLBACK_STRENGTH,
418
- revertWhenAvailable: next.revertWhenAvailable ?? current.revertWhenAvailable ?? true,
419
- preferredModel: Object.prototype.hasOwnProperty.call(next, "preferredModel")
420
- ? next.preferredModel || undefined
421
- : current.preferredModel,
422
- };
423
- writeJsonFile(ROTATION_CONFIG_FILE, merged);
424
- return merged;
425
- }
426
-
427
- export function clearPreferredModel(): RotationConfig {
428
- return writeRotationConfig({ preferredModel: null });
429
- }
430
-
431
- export function rememberPreferredModel(model: ModelRef): RotationConfig {
432
- return writeRotationConfig({ preferredModel: model });
433
- }
434
-
435
- // `readModelLadders()` always fills both providers from the builtin defaults, so this
436
- // only returns undefined (never throws) in the defensive case where a rung is still
437
- // genuinely missing; callers must treat that as a graceful "no fallback available" case
438
- // rather than a config error to surface mid-turn.
439
- export function resolveFallbackTarget(
440
- currentProviderId: ManagedProviderId,
441
- currentModelId?: string,
442
- config = readRotationConfig(),
443
- currentThinkingLevel?: string,
444
- ): FallbackTarget | undefined {
445
- const ladders = readModelLadders();
446
- const defaultStrength = config.defaultFallbackStrength ?? DEFAULT_FALLBACK_STRENGTH;
447
- const sourceStrength = findStrengthForModelId(currentProviderId, currentModelId, defaultStrength, ladders, currentThinkingLevel);
448
- const strength = sourceStrength ?? defaultStrength;
449
- const targetProvider = currentProviderId === ANTHROPIC_PROVIDER_ID ? OPENAI_CODEX_PROVIDER_ID : ANTHROPIC_PROVIDER_ID;
450
- const targetLadder = ladders?.[PROVIDERS[targetProvider].ladderProviderId];
451
- const candidates = strength === defaultStrength ? [strength] : [strength, defaultStrength];
452
- const fallbackRef = candidates.map((candidate) => targetLadder?.[candidate]).find((ref): ref is string => typeof ref === "string" && ref.trim().length > 0);
453
- if (!fallbackRef) {
454
- return undefined;
455
- }
456
- const parsed = splitModelRef(fallbackRef);
457
- const resolvedStrength = candidates.find((candidate) => targetLadder?.[candidate] === fallbackRef) ?? strength;
458
- return { ...parsed, strength: resolvedStrength };
459
- }
460
-
461
- export function shouldRestorePreferredModel(config = readRotationConfig(), now = Date.now()): boolean {
462
- if (!config.revertWhenAvailable || !config.preferredModel) return false;
463
- return Boolean(findAvailableSubscription(config.preferredModel.providerId, now));
464
- }
465
-
466
- export async function restorePreferredModelIfPossible(
467
- piApi: ExtensionAPI,
468
- ctx: Pick<ExtensionContext, "model" | "modelRegistry">,
469
- ): Promise<boolean> {
470
- const config = readRotationConfig();
471
- if (!shouldRestorePreferredModel(config, Date.now())) return false;
472
- const preferred = config.preferredModel;
473
- if (!preferred) return false;
474
- const current = getCurrentModelRef(ctx.model);
475
- if (current?.providerId === preferred.providerId && current.modelId === preferred.modelId) {
476
- clearPreferredModel();
477
- return true;
478
- }
479
- const model = ctx.modelRegistry.find(preferred.providerId, preferred.modelId);
480
- if (!model) return false;
481
- const switched = await piApi.setModel(model);
482
- if (switched) clearPreferredModel();
483
- return switched;
484
- }
485
-
486
- export async function switchToFallbackIfPossible(
487
- piApi: ExtensionAPI,
488
- ctx: Pick<ExtensionContext, "model" | "modelRegistry">,
489
- preferredModel = getCurrentModelRef(ctx.model),
490
- ): Promise<FallbackTarget | undefined> {
491
- if (!preferredModel) return undefined;
492
- rememberPreferredModel(preferredModel);
493
- const currentThinkingLevel = piApi.getThinkingLevel?.();
494
- const target = resolveFallbackTarget(preferredModel.providerId, preferredModel.modelId, readRotationConfig(), currentThinkingLevel);
495
- if (!target) return undefined;
496
- const current = getCurrentModelRef(ctx.model);
497
- const model = ctx.modelRegistry.find(target.providerId, target.modelId);
498
- if (!model) return undefined;
499
- if (current?.providerId !== target.providerId || current.modelId !== target.modelId) {
500
- const switched = await piApi.setModel(model);
501
- if (!switched) return undefined;
502
- }
503
- if (target.thinkingLevel) piApi.setThinkingLevel(target.thinkingLevel);
504
- return target;
505
- }
506
-
507
- export function formatStatusLine(providerId: ManagedProviderId, now = Date.now()): string {
508
- const pool = readSubscriptionPool(providerId);
509
- const ready = pool.filter((entry) => !entry.rateLimitedUntil || entry.rateLimitedUntil <= now);
510
- const label = getProviderLabel(providerId);
511
- if (ready.length === 0) {
512
- const fallback = resolveFallbackTarget(providerId, undefined, readRotationConfig());
513
- return fallback ? `${label} pool exhausted -> ${fallback.label}` : `${label} pool exhausted (no fallback configured)`;
514
- }
515
- return `${label} ${ready[0]?.label ?? providerId} (${ready.length}/${pool.length} ready)`;
516
- }
1
+ // Provider pool state belongs to crouter core so every human surface and the rotation runtime share one invariant-preserving implementation.
2
+ export * from "../../../core/subscription-state.js";
@@ -1,11 +1,11 @@
1
1
  {
2
- "name": "pi-personal-extensions",
2
+ "name": "pi-crtr-extensions",
3
3
  "version": "0.1.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
- "name": "pi-personal-extensions",
8
+ "name": "pi-crtr-extensions",
9
9
  "version": "0.1.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
@@ -1,8 +1,6 @@
1
1
  # pi-mode-switch
2
2
 
3
- Press **Alt+M** to cycle between operating modes — the way Claude's Shift+Tab
4
- cycles plan / accept-edits. Whenever the mode changes, the new mode's operating
5
- instructions are injected (invisibly) before your next message.
3
+ Cycle between operating modes with crouter's configurable **Cycle mode** binding (default **Alt+M**) — the way Claude's Shift+Tab cycles plan / accept-edits. Whenever the mode changes, the new mode's operating instructions are injected invisibly before your next message.
6
4
 
7
5
  ## Modes
8
6
 
@@ -14,51 +12,25 @@ instructions are injected (invisibly) before your next message.
14
12
 
15
13
  ## Usage
16
14
 
17
- - `Alt+M` — cycle `normal → spec → plan → normal`
18
- - `/mode` — same as Alt+M
15
+ - the configured `crtr.mode.cycle` binding (default `Alt+M`) — cycle `normal → spec → plan → normal`
16
+ - `/mode` — cycle without using the shortcut
19
17
  - `/mode <normal\|spec\|plan>` — jump to a specific mode
20
- - the agent can switch its own mode by running the bundled `bin/mode` CLI through
21
- its bash tool — e.g. `bash <plugin>/bin/mode normal` after a spec/plan is
22
- approved. The command prints that mode's guidance, so the text only enters
23
- context at the moment of the switch (no always-on tool)
18
+ - the agent can switch its own mode by running the bundled `bin/mode` CLI through its bash tool — e.g. `bash <plugin>/bin/mode normal` after a spec/plan is approved. The command prints that mode's guidance, so the text only enters context at the moment of the switch (no always-on tool)
24
19
 
25
- The current mode renders as a badge on the **bottom-right of the input box**,
26
- diagonally opposite the session-name badge (top-left). Mode persists across
27
- `--resume`.
20
+ The current mode renders as a badge on the **bottom-right of the input box**, diagonally opposite the session-name badge (top-left). Mode persists across `--resume`.
28
21
 
29
22
  ## How it works
30
23
 
31
- The extension tracks the active mode and the mode whose guidance was last
32
- injected. On `before_agent_start` it compares the two: if they differ, it
33
- prepends that mode's guidance to the turn (as a hidden `mode-switch-context`
34
- message) and records the injection. Stay in the same mode and nothing extra is
35
- added. Returning to `normal` also strips stale guidance from context.
24
+ The extension tracks the active mode and the mode whose guidance was last injected. On `before_agent_start` it compares the two: if they differ, it prepends that mode's guidance to the turn as a hidden `mode-switch-context` message and records the injection. Stay in the same mode and nothing extra is added. Returning to `normal` also strips stale guidance from context.
36
25
 
37
- The agent can also switch its own mode without a permanent tool. The bundled
38
- `bin/mode` CLI, run through the normal bash tool, writes the requested mode to
39
- `<artifact-root>/mode-switch/request.json` and prints that mode's operating
40
- instructions to stdout. The extension mirrors each mode's guidance to
41
- `mode-switch/guidance/<mode>.txt` at session start (single source of truth stays
42
- in the extension), watches the request file to keep the badge and session state in
43
- sync, and consumes each request on read so a later Alt+M isn't clobbered. The
44
- guidance reaches the agent through the command's own output, so it only enters
45
- context at the moment of the switch — no tool sits in the context window, and
46
- there's no coupling to any external runtime, just files.
26
+ The agent can also switch its own mode without a permanent tool. The bundled `bin/mode` CLI, run through the normal bash tool, writes the requested mode to `<artifact-root>/mode-switch/request.json` and prints that mode's operating instructions to stdout. The extension mirrors each mode's guidance to `mode-switch/guidance/<mode>.txt` at session start, watches the request file to keep the badge and session state in sync, and applies each tokenized request once. The guidance reaches the agent through the command's own output, so it only enters context at the moment of the switch — no tool sits in the context window, and there's no coupling to an external runtime beyond the shared files.
47
27
 
48
- This is guidance-only — it does not hard-restrict tools. The instructions tell
49
- the agent not to edit in spec/plan mode; combine with a tool gate if you want
50
- enforcement.
28
+ This is guidance-only — it does not hard-restrict tools. The instructions tell the agent not to edit in spec/plan mode; combine with a tool gate if you want enforcement.
51
29
 
52
- The input badge is drawn by wrapping whatever editor is already installed (e.g. a
53
- session-name badge extension) in a transparent Proxy that overrides only
54
- `render()`. It composes instead of replacing, so other editor decorations keep
55
- working.
30
+ The input badge is drawn by wrapping whatever editor is already installed (for example, a session-name badge extension) in a transparent Proxy that overrides only `render()`. It composes instead of replacing, so other editor decorations keep working.
56
31
 
57
32
  ## Install
58
33
 
59
- ```bash
60
- pi install /Users/silasrhyneer/Code/cli/pi-mode-switch # user scope
61
- pi install -l /Users/silasrhyneer/Code/cli/pi-mode-switch # project scope
62
- ```
34
+ This package ships inside crouter. `crtr sys setup` registers its built package path with pi; there is no separate repository or package to install.
63
35
 
64
- Run `/reload` in pi after install, then press Alt+M.
36
+ Run `/reload` in pi after setup. The extension resolves `crtr.mode.cycle` from the sparse `keybindings` object in `~/.crouter/config.json`; missing overrides use Alt+M, multiple gestures all register, and an empty list disables the shortcut. `/mode` remains available when the shortcut is disabled. Pi's stock actions and `~/.pi/agent/keybindings.json` remain untouched.