oira666_pi-limits-wait 0.2.4 → 0.4.0

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 (3) hide show
  1. package/README.md +54 -0
  2. package/package.json +1 -1
  3. package/src/index.ts +368 -11
package/README.md CHANGED
@@ -21,6 +21,60 @@ Restart Pi or run `/reload` after installing.
21
21
  - On `server_is_overloaded`, waits 5 minutes, then retries. If the provider is still overloaded after Pi's normal retries, it waits another 5 minutes and repeats.
22
22
  - Shows a countdown in the Pi status/working line.
23
23
  - Press Enter during the countdown to skip the wait and retry immediately.
24
+ - Optionally falls back to configured models when the current/default model is rate-limited.
25
+
26
+ ## Optional fallback models
27
+
28
+ By default, if you do not configure fallback models, the extension behaves exactly as before: it waits for the current model's limit to reset and retries.
29
+
30
+ To enable automatic model fallback, add `oira666_pi-subagents.try_models` to Pi's normal `settings.json`.
31
+
32
+ Pi settings can be global or project-local:
33
+
34
+ | Location | Scope |
35
+ |----------|-------|
36
+ | `~/.pi/agent/settings.json` | Global, all projects |
37
+ | `.pi/settings.json` | Project-local |
38
+
39
+ Project settings override global settings. The extension reads these through Pi's `SettingsManager`, so normal Pi settings paths and `PI_CODING_AGENT_DIR` are respected.
40
+
41
+ Example:
42
+
43
+ ```json
44
+ {
45
+ "oira666_pi-subagents": {
46
+ "try_models": [
47
+ {
48
+ "provider": "anthropic",
49
+ "modelname": "claude-sonnet-4-5",
50
+ "reasoning effort": "medium"
51
+ },
52
+ {
53
+ "provider": "openai",
54
+ "modelname": "gpt-5.1-codex"
55
+ }
56
+ ]
57
+ }
58
+ }
59
+ ```
60
+
61
+ Each `try_models` entry supports:
62
+
63
+ - `provider` — required. The Pi provider name, for example `anthropic`, `openai`, `google`, etc.
64
+ - `modelname` — required. The model id/name as Pi knows it.
65
+ - `reasoning effort` — optional. One of `off`, `minimal`, `low`, `medium`, `high`, `xhigh`. If omitted, Pi's current/default reasoning level is used.
66
+
67
+ Fallback behavior:
68
+
69
+ 1. Pi starts with the normal default or user-selected model.
70
+ 2. If that model is rate-limited, the extension tries models in this order:
71
+ - the original default/user-selected model;
72
+ - then every model from `oira666_pi-subagents.try_models`, top to bottom.
73
+ 3. The first model that responds without a rate-limit becomes the active Pi model for the rest of the session/task.
74
+ 4. If that model later becomes rate-limited too, the extension starts again from the same ordered list.
75
+ 5. Rate-limit reset times are remembered only in memory, so known-limited models are skipped until their countdown expires. This memory is cleared when Pi restarts.
76
+
77
+ When the settings are loaded, the extension shows the full usable fallback model list. When models become rate-limited, it shows a live countdown for each limited model.
24
78
 
25
79
  ## License
26
80
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oira666_pi-limits-wait",
3
- "version": "0.2.4",
3
+ "version": "0.4.0",
4
4
  "description": "Extension for PI that allows to automatically wait for limits to reset and retry the request",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
- import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
1
+ import { SettingsManager, type ExtensionAPI, type ExtensionContext } from "@mariozechner/pi-coding-agent";
2
+ import type { ThinkingLevel } from "@mariozechner/pi-agent-core";
2
3
  import {
3
4
  createAssistantMessageEventStream,
4
5
  getApiProviders,
@@ -13,11 +14,25 @@ import {
13
14
 
14
15
  // ─── Constants ────────────────────────────────────────────────────────────────
15
16
 
17
+ const CLAUDE_CODE_IDENTITY =
18
+ "You are Claude Code, Anthropic's official CLI for Claude.";
19
+
20
+ const PI_REMOVAL_ANCHORS = [
21
+ "pi-coding-agent",
22
+ "@mariozechner/pi-coding-agent",
23
+ "badlogic/pi-mono",
24
+ ] as const;
25
+
26
+ const PI_IDENTITY_SENTENCE_PATTERN =
27
+ /(?:^|\n)\s*You are pi\b[^.!?\n]*(?:[.!?](?=\s|$)|(?=\n|$))/gi;
28
+
16
29
  const DEFAULT_RATE_LIMIT_WAIT_MS = 30 * 60 * 1_000; // 30 minutes
17
30
  const DEFAULT_OVERLOADED_WAIT_MS = 5 * 60 * 1_000; // 5 minutes
31
+ const SETTINGS_KEY = "oira666_pi-subagents";
18
32
 
19
33
  /** Key used with ctx.ui.setStatus() for the countdown line. */
20
34
  const STATUS_KEY = "limits-wait";
35
+ const MODELS_STATUS_KEY = "limits-wait-models";
21
36
 
22
37
  type RetryReason = "rate-limit" | "overloaded";
23
38
 
@@ -26,13 +41,290 @@ type RetryableError = {
26
41
  waitMs: number;
27
42
  };
28
43
 
44
+ type ConfiguredModel = {
45
+ provider: string;
46
+ modelname: string;
47
+ reasoningEffort?: ThinkingLevel;
48
+ };
49
+
50
+ type FallbackModel = {
51
+ model: Model<Api>;
52
+ reasoningEffort?: ThinkingLevel;
53
+ };
54
+
55
+ type RateLimitMemory = {
56
+ reason: RetryReason;
57
+ limitedAt: number;
58
+ deadline: number;
59
+ };
60
+
29
61
  // ─── Shared UI context ────────────────────────────────────────────────────────
30
62
 
31
63
  let sharedCtx: ExtensionContext | undefined;
64
+ let extensionApi: ExtensionAPI | undefined;
32
65
  let restoreFetch: (() => void) | undefined;
33
66
  let ambientStatusCleanup: (() => void) | undefined;
67
+ let modelStatusCleanup: (() => void) | undefined;
34
68
  let activeProviderRequests = 0;
35
69
  const wrappedApis = new Set<Api>();
70
+ const builtinStreamSimpleByApi = new Map<Api, StreamSimpleFn>();
71
+ let fallbackModels: FallbackModel[] = [];
72
+ let primaryModel: Model<Api> | undefined;
73
+ let primaryThinkingLevel: ThinkingLevel | undefined;
74
+ let suppressNextModelSelect = false;
75
+ let settingsSignature: string | undefined;
76
+ const rateLimitMemory = new Map<string, RateLimitMemory>();
77
+
78
+ // ─── Anthropic subscription prompt sanitisation ──────────────────────────────
79
+
80
+ export function sanitiseSystemPrompt(raw: string): string {
81
+ const paragraphs = raw.split(/\n\n+/);
82
+ const filtered = paragraphs.filter((p) =>
83
+ !PI_REMOVAL_ANCHORS.some((anchor) => p.includes(anchor)),
84
+ );
85
+
86
+ return filtered
87
+ .join("\n\n")
88
+ .replace(PI_IDENTITY_SENTENCE_PATTERN, "")
89
+ .replace(/\n{3,}/g, "\n\n")
90
+ .trim();
91
+ }
92
+
93
+ /**
94
+ * Returns true if Anthropic OAuth is configured for this session, regardless
95
+ * of which model is currently active. This handles cases where a synthetic or
96
+ * temporary model (e.g. a subagent/resume provider) is active at
97
+ * before_agent_start time, even though the session will ultimately use an
98
+ * Anthropic subscription model.
99
+ */
100
+ function isAnthropicOAuthSession(ctx: ExtensionContext): boolean {
101
+ return ctx.modelRegistry.isUsingOAuth(
102
+ { provider: "anthropic" } as Parameters<typeof ctx.modelRegistry.isUsingOAuth>[0],
103
+ );
104
+ }
105
+
106
+ // ─── Optional fallback model settings ────────────────────────────────────────
107
+
108
+ function modelKey(model: Pick<Model<Api>, "provider" | "id">): string {
109
+ return `${model.provider}/${model.id}`;
110
+ }
111
+
112
+ function formatModel(model: Pick<Model<Api>, "provider" | "id">): string {
113
+ return `${model.provider}/${model.id}`;
114
+ }
115
+
116
+ function isThinkingLevel(value: unknown): value is ThinkingLevel {
117
+ return ["off", "minimal", "low", "medium", "high", "xhigh"].includes(String(value));
118
+ }
119
+
120
+ function parseConfiguredModels(raw: unknown): ConfiguredModel[] {
121
+ const source = raw && typeof raw === "object" && Array.isArray((raw as { try_models?: unknown }).try_models)
122
+ ? (raw as { try_models: unknown[] }).try_models
123
+ : [];
124
+
125
+ const models: ConfiguredModel[] = [];
126
+ for (const item of source) {
127
+ if (!item || typeof item !== "object") continue;
128
+ const record = item as Record<string, unknown>;
129
+ const provider = typeof record.provider === "string" ? record.provider.trim() : "";
130
+ const modelname =
131
+ typeof record.modelname === "string" ? record.modelname.trim()
132
+ : typeof record.modelName === "string" ? record.modelName.trim()
133
+ : typeof record.model === "string" ? record.model.trim()
134
+ : "";
135
+ const reasoning = record["reasoning effort"] ?? record.reasoningEffort ?? record.reasoning_effort;
136
+ if (!provider || !modelname) continue;
137
+ models.push({
138
+ provider,
139
+ modelname,
140
+ reasoningEffort: isThinkingLevel(reasoning) ? reasoning : undefined,
141
+ });
142
+ }
143
+ return models;
144
+ }
145
+
146
+ function loadFallbackSettings(ctx: ExtensionContext): void {
147
+ fallbackModels = [];
148
+
149
+ try {
150
+ const settingsManager = SettingsManager.create(ctx.cwd);
151
+ const globalSettings = settingsManager.getGlobalSettings() as Record<string, unknown>;
152
+ const projectSettings = settingsManager.getProjectSettings() as Record<string, unknown>;
153
+ const globalConfig = globalSettings[SETTINGS_KEY];
154
+ const projectConfig = projectSettings[SETTINGS_KEY];
155
+ const config = {
156
+ ...(globalConfig && typeof globalConfig === "object" && !Array.isArray(globalConfig) ? globalConfig : {}),
157
+ ...(projectConfig && typeof projectConfig === "object" && !Array.isArray(projectConfig) ? projectConfig : {}),
158
+ };
159
+ const content = JSON.stringify(config);
160
+ const shouldNotify = settingsSignature !== content;
161
+ settingsSignature = content;
162
+
163
+ const configured = parseConfiguredModels(config);
164
+ if (configured.length === 0) return;
165
+
166
+ const warnings: string[] = [];
167
+ const seen = new Set<string>();
168
+
169
+ for (const entry of configured) {
170
+ const model = ctx.modelRegistry.find(entry.provider, entry.modelname);
171
+ if (!model) {
172
+ warnings.push(`missing ${entry.provider}/${entry.modelname}`);
173
+ continue;
174
+ }
175
+ if (!ctx.modelRegistry.hasConfiguredAuth(model)) {
176
+ warnings.push(`no auth ${entry.provider}/${entry.modelname}`);
177
+ continue;
178
+ }
179
+ const key = modelKey(model);
180
+ if (seen.has(key)) continue;
181
+ seen.add(key);
182
+ fallbackModels.push({ model, reasoningEffort: entry.reasoningEffort });
183
+ }
184
+
185
+ const lines = fallbackModels.map((entry, index) =>
186
+ `${index + 1}. ${formatModel(entry.model)}${entry.reasoningEffort ? ` (${entry.reasoningEffort})` : ""}`,
187
+ );
188
+ const message = lines.length > 0
189
+ ? `Loaded ${SETTINGS_KEY}.try_models:\n${lines.join("\n")}`
190
+ : `Loaded ${SETTINGS_KEY}.try_models, but no usable fallback models were found.`;
191
+ if (shouldNotify) {
192
+ ctx.ui.notify(warnings.length > 0 ? `${message}\nSkipped: ${warnings.join(", ")}` : message, warnings.length > 0 ? "warning" : "info");
193
+ }
194
+ } catch (err) {
195
+ ctx.ui.notify(`Could not load ${SETTINGS_KEY}.try_models: ${err instanceof Error ? err.message : String(err)}`, "warning");
196
+ }
197
+ }
198
+
199
+ function fallbackEnabled(): boolean {
200
+ return fallbackModels.length > 0;
201
+ }
202
+
203
+ function getPrimaryModel(current: Model<Api>): Model<Api> {
204
+ return primaryModel ?? current;
205
+ }
206
+
207
+ function candidateOrder(current: Model<Api>): FallbackModel[] {
208
+ const primary = getPrimaryModel(current);
209
+ const order: FallbackModel[] = [{ model: primary, reasoningEffort: primaryThinkingLevel }];
210
+ const seen = new Set([modelKey(primary)]);
211
+ for (const entry of fallbackModels) {
212
+ const key = modelKey(entry.model);
213
+ if (seen.has(key)) continue;
214
+ seen.add(key);
215
+ order.push(entry);
216
+ }
217
+ return order;
218
+ }
219
+
220
+ function activeLimit(model: Model<Api>): RateLimitMemory | undefined {
221
+ const entry = rateLimitMemory.get(modelKey(model));
222
+ if (!entry) return undefined;
223
+ if (Date.now() >= entry.deadline) {
224
+ rateLimitMemory.delete(modelKey(model));
225
+ return undefined;
226
+ }
227
+ return entry;
228
+ }
229
+
230
+ function formatDuration(ms: number): string {
231
+ const totalSecs = Math.max(0, Math.ceil(ms / 1_000));
232
+ const hours = Math.floor(totalSecs / 3_600);
233
+ const mins = Math.floor((totalSecs % 3_600) / 60);
234
+ const secs = (totalSecs % 60).toString().padStart(2, "0");
235
+ return hours > 0 ? `${hours}h ${mins}m ${secs}s` : `${mins}m ${secs}s`;
236
+ }
237
+
238
+ function updateRateLimitedModelsStatus(): void {
239
+ const ctx = sharedCtx;
240
+ if (!ctx) return;
241
+ const now = Date.now();
242
+ const lines: string[] = [];
243
+ for (const [key, entry] of rateLimitMemory) {
244
+ if (now >= entry.deadline) {
245
+ rateLimitMemory.delete(key);
246
+ continue;
247
+ }
248
+ lines.push(`${key}: ${formatDuration(entry.deadline - now)}`);
249
+ }
250
+ if (lines.length === 0) {
251
+ modelStatusCleanup?.();
252
+ return;
253
+ }
254
+ ctx.ui.setStatus(MODELS_STATUS_KEY, `🚦 Limited: ${lines.join(" | ")}`);
255
+ }
256
+
257
+ function ensureRateLimitedModelsStatus(): void {
258
+ if (modelStatusCleanup) return;
259
+ updateRateLimitedModelsStatus();
260
+ const ticker = setInterval(updateRateLimitedModelsStatus, 1_000);
261
+ modelStatusCleanup = () => {
262
+ clearInterval(ticker);
263
+ modelStatusCleanup = undefined;
264
+ sharedCtx?.ui.setStatus(MODELS_STATUS_KEY, undefined);
265
+ };
266
+ }
267
+
268
+ function rememberRateLimit(model: Model<Api>, retryable: RetryableError): void {
269
+ const deadline = Date.now() + retryable.waitMs;
270
+ rateLimitMemory.set(modelKey(model), { reason: retryable.reason, limitedAt: Date.now(), deadline });
271
+ sharedCtx?.ui.notify(`${formatModel(model)} ${reasonLabel(retryable.reason).toLowerCase()} for ${formatDuration(retryable.waitMs)}.`, "warning");
272
+ ensureRateLimitedModelsStatus();
273
+ }
274
+
275
+ function nextAvailableCandidate(current: Model<Api>): FallbackModel | undefined {
276
+ return candidateOrder(current).find((entry) => !activeLimit(entry.model));
277
+ }
278
+
279
+ function earliestCandidateDeadline(current: Model<Api>): number | undefined {
280
+ const deadlines = candidateOrder(current)
281
+ .map((entry) => activeLimit(entry.model)?.deadline)
282
+ .filter((deadline): deadline is number => typeof deadline === "number");
283
+ return deadlines.length > 0 ? Math.min(...deadlines) : undefined;
284
+ }
285
+
286
+ function initialAttempt(model: Model<Api>): FallbackModel {
287
+ const configured = fallbackModels.find((entry) => modelKey(entry.model) === modelKey(model));
288
+ const current = configured ?? { model, reasoningEffort: primaryThinkingLevel };
289
+ if (!fallbackEnabled() || !activeLimit(model)) return current;
290
+ return nextAvailableCandidate(model) ?? current;
291
+ }
292
+
293
+ async function optionsForModel(
294
+ originalModel: Model<Api>,
295
+ target: FallbackModel,
296
+ options?: SimpleStreamOptions,
297
+ ): Promise<SimpleStreamOptions | undefined> {
298
+ const level = target.reasoningEffort ?? primaryThinkingLevel;
299
+ const reasoning = level && level !== "off" ? level : undefined;
300
+ if (modelKey(originalModel) === modelKey(target.model)) {
301
+ return reasoning ? { ...options, reasoning } : options;
302
+ }
303
+ const auth = await sharedCtx?.modelRegistry.getApiKeyAndHeaders(target.model);
304
+ if (!auth?.ok) throw new Error(auth ? auth.error : "Model registry is unavailable.");
305
+ return {
306
+ ...options,
307
+ ...(reasoning ? { reasoning } : {}),
308
+ apiKey: auth.apiKey,
309
+ headers: auth.headers || options?.headers ? { ...auth.headers, ...options?.headers } : undefined,
310
+ } as SimpleStreamOptions;
311
+ }
312
+
313
+ async function switchPiModel(entry: FallbackModel): Promise<void> {
314
+ const pi = extensionApi;
315
+ if (!pi) return;
316
+ const current = sharedCtx?.model;
317
+ if (current && modelKey(current) === modelKey(entry.model)) return;
318
+ suppressNextModelSelect = true;
319
+ const ok = await pi.setModel(entry.model);
320
+ if (!ok) {
321
+ suppressNextModelSelect = false;
322
+ return;
323
+ }
324
+ const level = entry.reasoningEffort ?? primaryThinkingLevel;
325
+ if (level) pi.setThinkingLevel(level);
326
+ sharedCtx?.ui.notify(`Switched to ${formatModel(entry.model)}${level ? ` (${level})` : ""}.`, "info");
327
+ }
36
328
 
37
329
  // ─── Retryable error detection ────────────────────────────────────────────────
38
330
 
@@ -323,6 +615,7 @@ export function streamWithLimitsRetry(
323
615
 
324
616
  void (async () => {
325
617
  let committed = false;
618
+ let attempt: FallbackModel = initialAttempt(model);
326
619
 
327
620
  const flush = (buffer: AssistantMessageEvent[]) => {
328
621
  for (const event of buffer) output.push(event);
@@ -337,7 +630,15 @@ export function streamWithLimitsRetry(
337
630
 
338
631
  try {
339
632
  try {
340
- const inner = delegate(model, context, options);
633
+ const attemptOptions = fallbackEnabled()
634
+ ? await optionsForModel(model, attempt, options)
635
+ : options;
636
+ const attemptDelegate = attempt.model.api === model.api
637
+ ? delegate
638
+ : builtinStreamSimpleByApi.get(attempt.model.api)
639
+ ?? getApiProviders().find((provider) => provider.api === attempt.model.api)?.streamSimple;
640
+ if (!attemptDelegate) throw new Error(`No stream handler registered for API ${attempt.model.api}.`);
641
+ const inner = attemptDelegate(attempt.model, context, attemptOptions);
341
642
  for await (const event of inner) {
342
643
  if (!committed) {
343
644
  if (event.type === "error") {
@@ -349,7 +650,7 @@ export function streamWithLimitsRetry(
349
650
  if (buffer.length > 0) {
350
651
  flush(buffer);
351
652
  } else {
352
- output.push({ type: "start", partial: freshMessage(model) });
653
+ output.push({ type: "start", partial: freshMessage(attempt.model) });
353
654
  committed = true;
354
655
  }
355
656
  output.push(event);
@@ -363,10 +664,11 @@ export function streamWithLimitsRetry(
363
664
  }
364
665
 
365
666
  // First non-start, non-error event means this attempt is real.
667
+ if (fallbackEnabled()) await switchPiModel(attempt);
366
668
  if (buffer.length > 0) {
367
669
  flush(buffer);
368
670
  } else {
369
- output.push({ type: "start", partial: freshMessage(model) });
671
+ output.push({ type: "start", partial: freshMessage(attempt.model) });
370
672
  committed = true;
371
673
  }
372
674
  output.push(event);
@@ -390,10 +692,10 @@ export function streamWithLimitsRetry(
390
692
  if (!retryable) {
391
693
  if (!committed) {
392
694
  // Synthetic start+error so the stream protocol stays valid.
393
- output.push({ type: "start", partial: freshMessage(model) });
695
+ output.push({ type: "start", partial: freshMessage(attempt.model) });
394
696
  committed = true;
395
697
  }
396
- const error = freshMessage(model);
698
+ const error = freshMessage(attempt.model);
397
699
  error.stopReason = options?.signal?.aborted ? "aborted" : "error";
398
700
  error.errorMessage = errMsg;
399
701
  output.push({
@@ -418,11 +720,11 @@ export function streamWithLimitsRetry(
418
720
  if (buffer.length > 0) {
419
721
  flush(buffer);
420
722
  } else {
421
- output.push({ type: "start", partial: freshMessage(model) });
723
+ output.push({ type: "start", partial: freshMessage(attempt.model) });
422
724
  committed = true;
423
725
  }
424
726
  }
425
- const error = freshMessage(model);
727
+ const error = freshMessage(attempt.model);
426
728
  error.stopReason = "error";
427
729
  error.errorMessage = "Provider stream ended without a terminal event.";
428
730
  output.push({ type: "error", reason: "error", error });
@@ -430,13 +732,40 @@ export function streamWithLimitsRetry(
430
732
  return;
431
733
  }
432
734
 
735
+ if (fallbackEnabled() && retryable.reason === "rate-limit") {
736
+ rememberRateLimit(attempt.model, retryable);
737
+ const next = nextAvailableCandidate(attempt.model);
738
+ if (next) {
739
+ attempt = next;
740
+ continue;
741
+ }
742
+
743
+ const deadline = earliestCandidateDeadline(attempt.model);
744
+ const waitMs = deadline ? Math.max(0, deadline - Date.now()) : retryable.waitMs;
745
+ const waitResult = await waitForRetry(retryable.reason, waitMs, options?.signal);
746
+ if (waitResult === "aborted") {
747
+ if (!committed) {
748
+ output.push({ type: "start", partial: freshMessage(attempt.model) });
749
+ committed = true;
750
+ }
751
+ const error = freshMessage(attempt.model);
752
+ error.stopReason = "aborted";
753
+ error.errorMessage = `Request aborted during ${reasonLabel(retryable.reason).toLowerCase()} wait.`;
754
+ output.push({ type: "error", reason: "aborted", error });
755
+ output.end();
756
+ return;
757
+ }
758
+ attempt = nextAvailableCandidate(attempt.model) ?? { model: getPrimaryModel(attempt.model), reasoningEffort: primaryThinkingLevel };
759
+ continue;
760
+ }
761
+
433
762
  const waitResult = await waitForRetry(retryable.reason, retryable.waitMs, options?.signal);
434
763
  if (waitResult === "aborted") {
435
764
  if (!committed) {
436
- output.push({ type: "start", partial: freshMessage(model) });
765
+ output.push({ type: "start", partial: freshMessage(attempt.model) });
437
766
  committed = true;
438
767
  }
439
- const error = freshMessage(model);
768
+ const error = freshMessage(attempt.model);
440
769
  error.stopReason = "aborted";
441
770
  error.errorMessage = `Request aborted during ${reasonLabel(retryable.reason).toLowerCase()} wait.`;
442
771
  output.push({ type: "error", reason: "aborted", error });
@@ -459,6 +788,7 @@ function registerWrappedApi(pi: ExtensionAPI, api: Api): void {
459
788
  const builtinStreamSimple = getApiProviders().find((provider) => provider.api === api)?.streamSimple;
460
789
  if (!builtinStreamSimple) return;
461
790
 
791
+ builtinStreamSimpleByApi.set(api, builtinStreamSimple);
462
792
  wrappedApis.add(api);
463
793
  pi.registerProvider(`limits-wait-${api}`, {
464
794
  api,
@@ -470,8 +800,22 @@ function registerWrappedApi(pi: ExtensionAPI, api: Api): void {
470
800
  // ─── Extension entry point ────────────────────────────────────────────────────
471
801
 
472
802
  export default function (pi: ExtensionAPI) {
473
- pi.on("before_agent_start", (_event, ctx) => {
803
+ extensionApi = pi;
804
+
805
+ pi.on("model_select", (event) => {
806
+ if (suppressNextModelSelect) {
807
+ suppressNextModelSelect = false;
808
+ return;
809
+ }
810
+ primaryModel = event.model;
811
+ primaryThinkingLevel = pi.getThinkingLevel();
812
+ });
813
+
814
+ pi.on("before_agent_start", (event, ctx) => {
474
815
  sharedCtx = ctx;
816
+ primaryModel ??= ctx.model;
817
+ primaryThinkingLevel ??= pi.getThinkingLevel();
818
+ loadFallbackSettings(ctx);
475
819
 
476
820
  // Some extensions may register providers after this extension loads. Wrap
477
821
  // any APIs that exist by the time an agent starts too.
@@ -479,5 +823,18 @@ export default function (pi: ExtensionAPI) {
479
823
  registerWrappedApi(pi, model.api);
480
824
  }
481
825
  if (ctx.model) registerWrappedApi(pi, ctx.model.api);
826
+
827
+ // Anthropic subscription/OAuth requests identify as Claude Code, not Pi.
828
+ // Check the session-wide Anthropic OAuth configuration rather than only the
829
+ // current model: resumed/subagent sessions can temporarily expose a
830
+ // synthetic active model during before_agent_start.
831
+ if (!isAnthropicOAuthSession(ctx)) return;
832
+
833
+ const sanitised = sanitiseSystemPrompt(event.systemPrompt);
834
+ return {
835
+ systemPrompt: sanitised
836
+ ? `${CLAUDE_CODE_IDENTITY}\n\n${sanitised}`
837
+ : CLAUDE_CODE_IDENTITY,
838
+ };
482
839
  });
483
840
  }