@herbertgao/pi-extensions 2026.9.12 → 2026.9.13

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 (42) hide show
  1. package/README.md +4 -4
  2. package/THIRD_PARTY_NOTICES.md +1 -1
  3. package/node_modules/@herbertgao/pi-subagents/CHANGELOG.md +8 -0
  4. package/node_modules/@herbertgao/pi-subagents/package.json +1 -1
  5. package/node_modules/@herbertgao/pi-subagents/src/agent-manager.ts +5 -4
  6. package/node_modules/@herbertgao/pi-subagents/src/mention-clone.ts +39 -16
  7. package/node_modules/@narumitw/pi-btw/README.md +21 -4
  8. package/node_modules/@narumitw/pi-btw/dist/index.ts +1668 -958
  9. package/node_modules/@narumitw/pi-btw/dist/index.ts.map +4 -4
  10. package/node_modules/@narumitw/pi-btw/docs/workflows.md +10 -2
  11. package/node_modules/@narumitw/pi-btw/package.json +1 -1
  12. package/node_modules/@narumitw/pi-btw/src/btw.ts +17 -79
  13. package/node_modules/@narumitw/pi-btw/src/conversation-context.ts +74 -0
  14. package/node_modules/@narumitw/pi-btw/src/fullscreen-ui.ts +196 -7
  15. package/node_modules/@narumitw/pi-btw/src/main-thread-updates.ts +40 -0
  16. package/node_modules/@narumitw/pi-btw/src/menu.ts +34 -2
  17. package/node_modules/@narumitw/pi-btw/src/settings.ts +49 -0
  18. package/node_modules/@narumitw/pi-btw/src/transcript-pager.ts +9 -1
  19. package/node_modules/@narumitw/pi-btw/src/workspace-layout.ts +559 -0
  20. package/node_modules/pi-multi-account/CHANGELOG.md +23 -0
  21. package/node_modules/pi-multi-account/README.md +38 -11
  22. package/node_modules/pi-multi-account/index.ts +371 -96
  23. package/node_modules/pi-multi-account/package.json +5 -4
  24. package/node_modules/pi-multi-account/provider-payload-stream.ts +36 -28
  25. package/node_modules/pi-multi-account/usage.ts +31 -2
  26. package/node_modules/pi-typesafe/README.md +3 -1
  27. package/node_modules/pi-typesafe/dist/auth.d.ts +10 -3
  28. package/node_modules/pi-typesafe/dist/auth.js +13 -7
  29. package/node_modules/pi-typesafe/dist/backends.d.ts +31 -0
  30. package/node_modules/pi-typesafe/dist/backends.js +33 -0
  31. package/node_modules/pi-typesafe/dist/client.d.ts +3 -9
  32. package/node_modules/pi-typesafe/dist/client.js +63 -39
  33. package/node_modules/pi-typesafe/dist/credentials.d.ts +11 -5
  34. package/node_modules/pi-typesafe/dist/credentials.js +15 -8
  35. package/node_modules/pi-typesafe/dist/extension.js +4 -1
  36. package/node_modules/pi-typesafe/dist/index.d.ts +1 -1
  37. package/node_modules/pi-typesafe/dist/index.js +1 -1
  38. package/node_modules/pi-typesafe/dist/login.d.ts +13 -7
  39. package/node_modules/pi-typesafe/dist/login.js +17 -8
  40. package/node_modules/pi-typesafe/dist/schema.js +19 -13
  41. package/node_modules/pi-typesafe/package.json +1 -1
  42. package/package.json +5 -5
@@ -474,6 +474,7 @@ import {
474
474
  formatUsageCompact,
475
475
  formatUsageDetails,
476
476
  parseCodexUsageHeaders,
477
+ mergeUsageSnapshot,
477
478
  providerUsageLabel,
478
479
  remainingPercent,
479
480
  usageColor,
@@ -530,6 +531,12 @@ type AnthropicOAuthAliasConfig = {
530
531
  type ProviderFailoverConfig = {
531
532
  enabled?: boolean;
532
533
  autoContinue?: boolean;
534
+ /**
535
+ * Keep quota-blocked work armed when every compatible account is cooling, then resume in the
536
+ * same live session as soon as any account is genuinely usable. Independent of the immediate
537
+ * post-switch `autoContinue` setting. Default: true.
538
+ */
539
+ resumeAfterAllAccountsRecover?: boolean;
533
540
  autoDiscover?: boolean;
534
541
  autoDiscoverModels?: boolean;
535
542
  maxAccountsPerProvider?: number;
@@ -556,7 +563,8 @@ type ProviderFailoverConfig = {
556
563
  */
557
564
  onlyActive?: boolean;
558
565
  /**
559
- * Provider ids this extension must never fail away from, even on an actionable error.
566
+ * Provider ids whose foreground route this extension must not automatically change,
567
+ * including preflight with stale cooldowns and actionable response errors.
560
568
  *
561
569
  * For providers we do not manage (no cooldown/refresh lifecycle of ours) that run their own
562
570
  * retry logic — typically a companion extension that owns retries for that provider. Failing
@@ -655,6 +663,7 @@ type RuntimeConfig = Required<
655
663
  ProviderFailoverConfig,
656
664
  | "enabled"
657
665
  | "autoContinue"
666
+ | "resumeAfterAllAccountsRecover"
658
667
  | "autoDiscover"
659
668
  | "autoDiscoverModels"
660
669
  | "maxAccountsPerProvider"
@@ -1186,7 +1195,7 @@ const ANTI_PINGPONG_MS = 60 * 1000; // don't switch straight back to the account
1186
1195
  // Bumped on every release. Printed at startup and in `/multi-account status` so you can verify
1187
1196
  // which version Pi actually loaded (a running Pi keeps the version it started with — /login and
1188
1197
  // /reload do NOT reload extension code; only a full restart does).
1189
- const VERSION = "1.22.0";
1198
+ const VERSION = "1.23.0";
1190
1199
  function sourceFingerprint(): string {
1191
1200
  try {
1192
1201
  const root = dirname(fileURLToPath(import.meta.url));
@@ -1766,6 +1775,7 @@ const DEFAULT_CURSOR_MODELS = ["cursor-grok-4.6", "grok-4.6", "composer-2.5"];
1766
1775
  const DEFAULT_CONFIG: ProviderFailoverConfig = {
1767
1776
  enabled: true,
1768
1777
  autoContinue: true,
1778
+ resumeAfterAllAccountsRecover: true,
1769
1779
  autoDiscover: true,
1770
1780
  autoDiscoverModels: true,
1771
1781
  maxAccountsPerProvider: 10,
@@ -1915,6 +1925,8 @@ function normalizeConfig(raw: ProviderFailoverConfig): RuntimeConfig {
1915
1925
  return {
1916
1926
  enabled: raw.enabled ?? true,
1917
1927
  autoContinue: raw.autoContinue ?? true,
1928
+ resumeAfterAllAccountsRecover:
1929
+ raw.resumeAfterAllAccountsRecover ?? true,
1918
1930
  autoDiscover: raw.autoDiscover ?? true,
1919
1931
  autoDiscoverModels: raw.autoDiscoverModels ?? true,
1920
1932
  maxAccountsPerProvider: Math.max(
@@ -3032,7 +3044,7 @@ function registerCodexSlot(
3032
3044
  pi: ExtensionAPI,
3033
3045
  id: string,
3034
3046
  models: Array<Record<string, unknown>> = DEFAULT_CODEX_MODELS.map(codexModelDef),
3035
- baseUrl = "https://chatgpt.com/backend-api",
3047
+ baseUrl: string,
3036
3048
  ) {
3037
3049
  if (id === CODEX_BASE) return; // base provider is native until live catalog sync enriches it
3038
3050
  pi.registerProvider(id, {
@@ -3049,7 +3061,7 @@ function registerCodexCatalog(
3049
3061
  pi: ExtensionAPI,
3050
3062
  id: string,
3051
3063
  models: Array<Record<string, unknown>>,
3052
- baseUrl = "https://chatgpt.com/backend-api",
3064
+ baseUrl: string,
3053
3065
  ) {
3054
3066
  const name =
3055
3067
  id === CODEX_BASE
@@ -3536,7 +3548,7 @@ const MINIMAL_ANTHROPIC_OAUTH_PROMPT = [
3536
3548
  ].join("\n");
3537
3549
  const CLAUDE_CODE_IDENTITY_PREFIX =
3538
3550
  "You are Claude Code, Anthropic's official CLI";
3539
- const CLAUDE_CODE_VERSION = "2.1.274";
3551
+ const CLAUDE_CODE_VERSION = "2.1.280";
3540
3552
  const BILLING_HEADER_SALT = "59cf53e54c78";
3541
3553
  const BILLING_HEADER_POSITIONS = [4, 7, 20] as const;
3542
3554
  const CLAUDE_CODE_ENTRYPOINT = "sdk-cli";
@@ -4264,8 +4276,15 @@ export default function piMultiAccount(pi: ExtensionAPI) {
4264
4276
  let usageStatusTimer: ReturnType<typeof setInterval> | undefined;
4265
4277
  // Pending work is session-local. The shared state file may contain another Pi window's marker;
4266
4278
  // using that marker as this window's runtime state makes two unrelated tasks resume each other.
4279
+ type PendingResumeMode = "auto-continue" | "quota-recovery";
4267
4280
  let pendingResume:
4268
- | { from: ModelRef; reason: string; since: number; retryAt?: number }
4281
+ | {
4282
+ from: ModelRef;
4283
+ reason: string;
4284
+ since: number;
4285
+ retryAt?: number;
4286
+ mode: PendingResumeMode;
4287
+ }
4269
4288
  | undefined;
4270
4289
 
4271
4290
  // ----- the governor ----------------------------------------------------
@@ -5026,7 +5045,7 @@ export default function piMultiAccount(pi: ExtensionAPI) {
5026
5045
  pi,
5027
5046
  provider,
5028
5047
  ranked,
5029
- numberedSlotBaseUrl(provider, "anthropic"),
5048
+ numberedAnthropicBaseUrl(provider),
5030
5049
  );
5031
5050
  }
5032
5051
  }
@@ -5053,7 +5072,7 @@ export default function piMultiAccount(pi: ExtensionAPI) {
5053
5072
  pi,
5054
5073
  provider,
5055
5074
  merged as Array<Record<string, unknown>>,
5056
- numberedSlotBaseUrl(provider, "codex"),
5075
+ numberedCodexBaseUrl(provider),
5057
5076
  );
5058
5077
  }
5059
5078
  }
@@ -5149,7 +5168,7 @@ export default function piMultiAccount(pi: ExtensionAPI) {
5149
5168
  models as Array<Record<string, unknown>>,
5150
5169
  provider === CODEX_BASE
5151
5170
  ? "https://chatgpt.com/backend-api"
5152
- : numberedSlotBaseUrl(provider, "codex"),
5171
+ : numberedCodexBaseUrl(provider),
5153
5172
  );
5154
5173
  }
5155
5174
  // Also keep unauthenticated spare login slots current so a newly logged-in account can select
@@ -5161,7 +5180,7 @@ export default function piMultiAccount(pi: ExtensionAPI) {
5161
5180
  pi,
5162
5181
  provider,
5163
5182
  allKnown as Array<Record<string, unknown>>,
5164
- numberedSlotBaseUrl(provider, "codex"),
5183
+ numberedCodexBaseUrl(provider),
5165
5184
  );
5166
5185
  }
5167
5186
  if (changed) persist();
@@ -5374,6 +5393,7 @@ export default function piMultiAccount(pi: ExtensionAPI) {
5374
5393
 
5375
5394
  function storeUsage(ctx: any, snapshot: UsageSnapshot): boolean {
5376
5395
  if (!usageSnapshotIsCurrent(snapshot)) return false;
5396
+ snapshot = mergeUsageSnapshot(usageByProvider.get(snapshot.provider), snapshot);
5377
5397
  usageByProvider.set(snapshot.provider, snapshot);
5378
5398
  usageErrors.delete(snapshot.provider);
5379
5399
  // AUTHORITATIVE PROACTIVE BENCH. If the account's own usage endpoint reports a hard block (a
@@ -6110,14 +6130,32 @@ export default function piMultiAccount(pi: ExtensionAPI) {
6110
6130
  * enabled) and acts only on a FRESH cached snapshot, so resume is never stalled on a slow probe
6111
6131
  * and a stale pre-limit reading can never clear a cooldown prematurely.
6112
6132
  */
6113
- function reconcileCooldownsFromUsage(ctx: any) {
6133
+ function reconcileCooldownsFromUsage(
6134
+ ctx: any,
6135
+ options: { allowWhenHidden?: boolean } = {},
6136
+ ) {
6114
6137
  const now = Date.now();
6115
6138
  for (const [provider, until] of [...exhaustedUntilByProvider.entries()]) {
6116
6139
  if (until <= now || isInvalidated(provider) || !usageFamily(provider))
6117
6140
  continue;
6118
- runBackground("cooldown reconcile usage", ctx, () =>
6119
- refreshUsage(ctx, provider, true),
6120
- );
6141
+ runBackground("cooldown reconcile usage", ctx, async () => {
6142
+ const snapshot = await refreshUsage(
6143
+ ctx,
6144
+ provider,
6145
+ true,
6146
+ options.allowWhenHidden ?? false,
6147
+ );
6148
+ // A provider's fresh "usable now" verdict should shorten an already-armed
6149
+ // multi-hour timer immediately. Schedule through the single owned timer rather
6150
+ // than calling the resume body concurrently for every recovered account.
6151
+ if (
6152
+ snapshot &&
6153
+ pendingResume?.mode === "quota-recovery" &&
6154
+ providerRecoveryAt(provider) <= Date.now()
6155
+ ) {
6156
+ schedulePendingWake(ctx);
6157
+ }
6158
+ });
6121
6159
  const cached = usageByProvider.get(provider);
6122
6160
  if (cached && now - cached.fetchedAt < usageCacheTtl(provider))
6123
6161
  applyUsageToCooldown(provider, cached, now);
@@ -6389,7 +6427,7 @@ export default function piMultiAccount(pi: ExtensionAPI) {
6389
6427
  pi,
6390
6428
  id,
6391
6429
  DEFAULT_ANTHROPIC_MODELS,
6392
- numberedSlotBaseUrl(id, "anthropic"),
6430
+ numberedAnthropicBaseUrl(id),
6393
6431
  );
6394
6432
  } else if (family === "openai-codex") {
6395
6433
  const cached = codexModelCatalogByProvider.get(id)?.models;
@@ -6399,21 +6437,16 @@ export default function piMultiAccount(pi: ExtensionAPI) {
6399
6437
  config.autoDiscoverModels && cached?.length
6400
6438
  ? (cached as Array<Record<string, unknown>>)
6401
6439
  : undefined,
6402
- numberedSlotBaseUrl(id, "codex"),
6440
+ numberedCodexBaseUrl(id),
6403
6441
  );
6404
6442
  } else if (family === "kimi-coding") {
6405
6443
  const kimiModels = [
6406
6444
  ...new Set([...DEFAULT_KIMI_MODELS, ...hostModelIdsFor(ctx, KIMI_BASE)]),
6407
6445
  ];
6408
6446
  registerKimiSlot(pi, id, kimiModels);
6409
- // Provision into Pi's native registry so an extension-free child can RESOLVE the
6410
- // slot by name. That is all this buys: measured 2026-08-24, such a child then
6411
- // fails with "No API key found", because Pi honours an OAuth credential only
6412
- // for a provider definition that declares the flow and a models.json entry
6413
- // declares none. Making the slot genuinely usable needs the Cursor pattern — a
6414
- // parent-owned loopback route with a non-secret placeholder — which Kimi does
6415
- // not have yet. See child-usability.ts.
6416
- if (ownsSharedChildPublication()) {
6447
+ // An in-memory OAuth spare belongs in /login, not in models.json. Kimi has
6448
+ // no child OAuth proxy, so only a real API-key slot is usable by a bare child.
6449
+ if (ownsSharedChildPublication() && auth[id]?.type === "api_key" && isEntryUsable(auth[id])) {
6417
6450
  provisionNativeSlot(id, {
6418
6451
  api: "anthropic-messages",
6419
6452
  baseUrl: KIMI_BASE_URL,
@@ -6438,8 +6471,10 @@ export default function piMultiAccount(pi: ExtensionAPI) {
6438
6471
  */
6439
6472
  function publishOwnedNativeAliases(ctx?: any): void {
6440
6473
  if (!ownsSharedChildPublication()) return;
6474
+ const auth = readAuthFile();
6441
6475
  for (const id of registeredSlots) {
6442
6476
  if (classifyProvider(id, config.qwenProvider) !== "kimi-coding") continue;
6477
+ if (auth[id]?.type !== "api_key" || !isEntryUsable(auth[id])) continue;
6443
6478
  const kimiModels = [
6444
6479
  ...new Set([...DEFAULT_KIMI_MODELS, ...hostModelIdsFor(ctx, KIMI_BASE)]),
6445
6480
  ];
@@ -6914,6 +6949,12 @@ export default function piMultiAccount(pi: ExtensionAPI) {
6914
6949
  );
6915
6950
  }
6916
6951
 
6952
+ function isFailoverExempt(provider: string | undefined): boolean {
6953
+ return !!provider &&
6954
+ !classifyProvider(provider, config.qwenProvider) &&
6955
+ config.neverFailoverProviders.includes(provider);
6956
+ }
6957
+
6917
6958
  async function activateFallback(
6918
6959
  ctx: any,
6919
6960
  sourceModel: any,
@@ -6921,6 +6962,9 @@ export default function piMultiAccount(pi: ExtensionAPI) {
6921
6962
  reason: string,
6922
6963
  options: { armContinuation?: boolean; manual?: boolean } = {},
6923
6964
  ) {
6965
+ // All automatic foreground switches share this boundary. Explicit user switches
6966
+ // remain allowed; an opted-out route belongs to Pi/the provider, not this router.
6967
+ if (!options.manual && (isFailoverExempt(sourceModel?.provider) || isFailoverExempt(ctx.model?.provider))) return false;
6924
6968
  const activationEpoch = chainEpoch;
6925
6969
  const stale = () => sessionClosed || activationEpoch !== chainEpoch || (!options.manual && (userAbortedChain || ctx.signal?.aborted));
6926
6970
  // A switch the user asked for is not an automatic step: it is never refused, and it clears
@@ -7030,8 +7074,11 @@ export default function piMultiAccount(pi: ExtensionAPI) {
7030
7074
  scope?: "provider" | "model";
7031
7075
  excludeProviders?: Iterable<string>;
7032
7076
  allowFailedRouteResume?: boolean;
7077
+ /** This failure is real quota evidence; if no route is ready, use the separate wait option. */
7078
+ waitForQuotaRecovery?: boolean;
7033
7079
  } = {},
7034
7080
  ) {
7081
+ if (!options.manual && (isFailoverExempt(failedModel?.provider) || isFailoverExempt(ctx.model?.provider))) return false;
7035
7082
  const switchEpoch = chainEpoch;
7036
7083
  if (!automaticFailoverEnabled() || !failedModel?.provider || !failedModel?.id)
7037
7084
  return false;
@@ -7096,12 +7143,19 @@ export default function piMultiAccount(pi: ExtensionAPI) {
7096
7143
  `Provider failover: no immediately available fallback after ${failedModel.provider}/${failedModel.id}. ${availability || "All known accounts may be unauthenticated, invalidated, or duplicate slots."}`,
7097
7144
  "warning",
7098
7145
  );
7146
+ const shouldWait = options.waitForQuotaRecovery
7147
+ ? config.resumeAfterAllAccountsRecover
7148
+ : config.autoContinue;
7099
7149
  if (
7100
7150
  !options.manual &&
7101
- config.autoContinue &&
7151
+ shouldWait &&
7102
7152
  options.allowFailedRouteResume !== false
7103
7153
  )
7104
- setPendingContinuation(ctx, failedModel, reason);
7154
+ setPendingContinuation(ctx, failedModel, reason, {
7155
+ mode: options.waitForQuotaRecovery
7156
+ ? "quota-recovery"
7157
+ : "auto-continue",
7158
+ });
7105
7159
  return false;
7106
7160
  }
7107
7161
 
@@ -7287,7 +7341,7 @@ export default function piMultiAccount(pi: ExtensionAPI) {
7287
7341
  // The launch candidate is authoritative in a pi-subagents child. Let the
7288
7342
  // request produce its real error so the parent runner can advance its own
7289
7343
  // fallbackModels chain instead of starting a competing router here.
7290
- if (subagentChild) return true;
7344
+ if (subagentChild || isFailoverExempt(ctx.model?.provider)) return true;
7291
7345
  refreshDiscovery(false, ctx);
7292
7346
  pruneCooldowns();
7293
7347
  const intended = intendedStartupModel();
@@ -7301,7 +7355,9 @@ export default function piMultiAccount(pi: ExtensionAPI) {
7301
7355
  if (!hostOwnsSessionModel && intended && !onIntended) {
7302
7356
  await restoreRememberedModel(ctx);
7303
7357
  }
7304
- if (isCurrentModelReady(ctx)) return true;
7358
+ // Restoration can change the provider on legacy hosts. Opt-out means pass the
7359
+ // request to Pi even when our cached cooldown/auth forecast says unavailable.
7360
+ if (isFailoverExempt(ctx.model?.provider) || isCurrentModelReady(ctx)) return true;
7305
7361
  // The user chose this account by hand and has not spent the attempt yet. Let the request
7306
7362
  // through: our reason for believing it unusable is a forecast, and this is the only way
7307
7363
  // anyone finds out the forecast was stale. Merely being asked about it never spends it —
@@ -7331,6 +7387,15 @@ export default function piMultiAccount(pi: ExtensionAPI) {
7331
7387
  return !!pendingResume;
7332
7388
  }
7333
7389
 
7390
+ function pendingResumeEnabled(
7391
+ pending = pendingResume,
7392
+ ): boolean {
7393
+ if (!pending) return false;
7394
+ return pending.mode === "quota-recovery"
7395
+ ? config.resumeAfterAllAccountsRecover
7396
+ : config.autoContinue;
7397
+ }
7398
+
7334
7399
  // Reject a promise that does not settle within `ms`. Used to bound every network-bound
7335
7400
  // hand-off (compaction summary) so a wedged provider call can never hang the session.
7336
7401
  // `onTimeout` MUST abort the underlying work — otherwise the timed-out compact() keeps
@@ -7876,10 +7941,14 @@ export default function piMultiAccount(pi: ExtensionAPI) {
7876
7941
  // continueAgent() cannot pick up. Inject the continuation prompt as a fresh USER turn so the
7877
7942
  // session keeps moving on the account we just switched to, WITHOUT the user re-typing anything.
7878
7943
  // Bounded by maxAutoContinuesPerPrompt. Returns true when it started a continuation turn.
7879
- function injectContinuationPrompt(
7944
+ async function injectContinuationPrompt(
7880
7945
  ctx: any,
7881
- resumeFrom?: { from?: ModelRef; reason?: string },
7882
- ): boolean {
7946
+ resumeFrom?: {
7947
+ from?: ModelRef;
7948
+ reason?: string;
7949
+ allowWhenAutoContinueDisabled?: boolean;
7950
+ },
7951
+ ): Promise<boolean> {
7883
7952
  // `currentPromptSwitch` is set ONLY when we actually rotated accounts. The pending-resume
7884
7953
  // path (transient overload, or a cooldown that expired on the same account) deliberately
7885
7954
  // returns to the SAME account, so it never has a switch record. Requiring one here meant
@@ -7889,7 +7958,7 @@ export default function piMultiAccount(pi: ExtensionAPI) {
7889
7958
  const source = currentPromptSwitch ?? resumeFrom;
7890
7959
  const blocked = !source
7891
7960
  ? "no switch or resume context"
7892
- : !config.autoContinue
7961
+ : !config.autoContinue && !resumeFrom?.allowWhenAutoContinueDisabled
7893
7962
  ? "autoContinue disabled"
7894
7963
  : userAbortedChain
7895
7964
  ? "user aborted the chain"
@@ -7913,7 +7982,9 @@ export default function piMultiAccount(pi: ExtensionAPI) {
7913
7982
  : "the active account");
7914
7983
  const sameModelRetry = source?.from === to;
7915
7984
  const prompt = sameModelRetry
7916
- ? `Provider retry activated: retrying ${to} after a temporary failure; no account or model switch occurred. Continue the interrupted task from where it stopped. The interrupted turn is preserved verbatim in this session as a [handoff:interrupted-turn] record — read it before acting and do not restart the task from the beginning.`
7985
+ ? resumeFrom?.allowWhenAutoContinueDisabled
7986
+ ? `Provider quota recovery activated: ${to} is usable again after every compatible account was limited. Continue the interrupted task from where it stopped. The interrupted turn is preserved verbatim in this session as a [handoff:interrupted-turn] record — read it before acting and do not restart the task from the beginning.`
7987
+ : `Provider retry activated: retrying ${to} after a temporary failure; no account or model switch occurred. Continue the interrupted task from where it stopped. The interrupted turn is preserved verbatim in this session as a [handoff:interrupted-turn] record — read it before acting and do not restart the task from the beginning.`
7917
7988
  : config.continuationPrompt
7918
7989
  .replaceAll("{from}", String(source?.from ?? "the previous account"))
7919
7990
  .replaceAll("{to}", String(to))
@@ -7928,21 +7999,11 @@ export default function piMultiAccount(pi: ExtensionAPI) {
7928
7999
  // current turn settles instead of being rejected; the host ignores it when not streaming.
7929
8000
  // `sendUserMessage` is async on the host: a rejected promise would otherwise escape this
7930
8001
  // synchronous try/catch as an unhandled rejection AND still report success here.
7931
- const dispatched = pi.sendUserMessage(prompt, {
7932
- deliverAs: "followUp",
7933
- }) as unknown;
7934
- if (
7935
- dispatched &&
7936
- typeof (dispatched as Promise<void>).catch === "function"
7937
- ) {
7938
- (dispatched as Promise<void>).catch((error) => {
7939
- expectingInjectedContinuation = false;
7940
- logEvent("continuation_injection_failed", {
7941
- error: String(error).slice(0, 200),
7942
- });
7943
- reportExtensionError("continuation injection", error, ctx);
7944
- });
7945
- }
8002
+ await Promise.resolve(
8003
+ pi.sendUserMessage(prompt, {
8004
+ deliverAs: "followUp",
8005
+ }),
8006
+ );
7946
8007
  logEvent("continuation_injected", {
7947
8008
  session: sessionInstanceId,
7948
8009
  from: source?.from,
@@ -7964,7 +8025,11 @@ export default function piMultiAccount(pi: ExtensionAPI) {
7964
8025
  ctx: any,
7965
8026
  // Set by the pending-resume path, which returns to the SAME account and therefore has no
7966
8027
  // `currentPromptSwitch`. Without it the prompt-injection fallback refuses to fire.
7967
- resumeFrom?: { from?: ModelRef; reason?: string },
8028
+ resumeFrom?: {
8029
+ from?: ModelRef;
8030
+ reason?: string;
8031
+ allowWhenAutoContinueDisabled?: boolean;
8032
+ },
7968
8033
  ): Promise<boolean> {
7969
8034
  const resumeEpoch = chainEpoch;
7970
8035
  if (sessionClosed || userAbortedChain || ctx.signal?.aborted) return false;
@@ -7984,7 +8049,7 @@ export default function piMultiAccount(pi: ExtensionAPI) {
7984
8049
  // @earendil-works/pi-coding-agent). Do NOT dead-end the failover with a red error that
7985
8050
  // leaves the user reloading by hand: fall back to injecting the continuation prompt so the
7986
8051
  // work resumes by itself on the account we just switched to.
7987
- if (injectContinuationPrompt(ctx, resumeFrom)) return true;
8052
+ if (await injectContinuationPrompt(ctx, resumeFrom)) return true;
7988
8053
  // Reaching here means the injection fallback ALSO declined — the reason is in the
7989
8054
  // debug log as continuation_injection_blocked/_failed. Do not blame the Pi build:
7990
8055
  // the missing pi.continueAgent is only why we took the fallback path, never why the
@@ -8061,10 +8126,10 @@ export default function piMultiAccount(pi: ExtensionAPI) {
8061
8126
  // continuation prompt as a user message instead. That always starts a turn, so the
8062
8127
  // session keeps moving by itself (this is how auto-recovery after a watchdog abort
8063
8128
  // continues without the user re-typing anything). Bounded by maxAutoContinuesPerPrompt.
8064
- if (injectContinuationPrompt(ctx, resumeFrom)) return true;
8065
- // Nothing to continue (spurious) or injection unavailable — drop stale state quietly.
8066
- currentPromptSwitch = undefined;
8067
- clearPendingContinuation();
8129
+ if (await injectContinuationPrompt(ctx, resumeFrom)) return true;
8130
+ // Keep the selected route/context available to the caller. A host can reject a
8131
+ // follow-up during a narrow busy race; dropping the switch here turns that transient
8132
+ // rejection into a permanently stopped task.
8068
8133
  return false;
8069
8134
  }
8070
8135
  noteRecoveryFailure(ctx);
@@ -8078,15 +8143,24 @@ export default function piMultiAccount(pi: ExtensionAPI) {
8078
8143
  }
8079
8144
  }
8080
8145
 
8081
- async function maybeDispatchContinuation(ctx: any): Promise<boolean> {
8146
+ async function maybeDispatchContinuation(
8147
+ ctx: any,
8148
+ options: {
8149
+ allowWhenAutoContinueDisabled?: boolean;
8150
+ pendingMode?: PendingResumeMode;
8151
+ } = {},
8152
+ ): Promise<boolean> {
8153
+ const allowRecoveryResume =
8154
+ options.allowWhenAutoContinueDisabled === true;
8082
8155
  if (
8083
- !config.autoContinue ||
8156
+ (!config.autoContinue && !allowRecoveryResume) ||
8084
8157
  userAbortedChain ||
8085
8158
  ctx.signal?.aborted ||
8086
8159
  !currentPromptSwitch ||
8087
8160
  autoContinuesThisPrompt >= config.maxAutoContinuesPerPrompt
8088
8161
  )
8089
8162
  return false;
8163
+ const dispatchSwitch = currentPromptSwitch;
8090
8164
  // Circuit breaker open → advisory mode. The account switch already happened (useful);
8091
8165
  // we just don't attempt the auto-resume that has been failing. The user's next message
8092
8166
  // runs on the fresh account. This is the floor: never worse than switching by hand.
@@ -8107,18 +8181,46 @@ export default function piMultiAccount(pi: ExtensionAPI) {
8107
8181
  if (!isCurrentModelReady(ctx)) {
8108
8182
  const failed =
8109
8183
  ctx.model?.provider && ctx.model?.id ? ctx.model : undefined;
8110
- if (failed && config.autoContinue) {
8184
+ if (failed && (config.autoContinue || allowRecoveryResume)) {
8111
8185
  setPendingContinuation(
8112
8186
  ctx,
8113
8187
  failed,
8114
- currentPromptSwitch?.reason ?? "account is cooling down",
8188
+ dispatchSwitch.reason || "account is cooling down",
8189
+ { mode: options.pendingMode ?? "auto-continue" },
8115
8190
  );
8116
8191
  }
8117
8192
  return false;
8118
8193
  }
8119
- const resumed = await resumeWithExistingContext(ctx);
8120
- if (resumed) continuationDispatchedForAgentTurn = true;
8121
- return resumed;
8194
+ const resumed = await resumeWithExistingContext(ctx, {
8195
+ from: dispatchSwitch.from,
8196
+ reason: dispatchSwitch.reason,
8197
+ allowWhenAutoContinueDisabled: allowRecoveryResume,
8198
+ });
8199
+ if (resumed) {
8200
+ continuationDispatchedForAgentTurn = true;
8201
+ return true;
8202
+ }
8203
+ // `sendUserMessage(..., followUp)` can reject asynchronously if the host is still
8204
+ // crossing a turn boundary. Preserve the selected fallback and retry there instead of
8205
+ // reporting a switch whose task never actually continued.
8206
+ if (
8207
+ !hasPendingResume() &&
8208
+ currentPromptSwitch === dispatchSwitch &&
8209
+ !sessionClosed &&
8210
+ !userAbortedChain &&
8211
+ !ctx.signal?.aborted
8212
+ ) {
8213
+ const failed =
8214
+ ctx.model?.provider && ctx.model?.id ? ctx.model : undefined;
8215
+ if (failed)
8216
+ setPendingContinuation(
8217
+ ctx,
8218
+ failed,
8219
+ `${SELECTED_FALLBACK_PENDING_PREFIX} ${dispatchSwitch.reason}`,
8220
+ { mode: options.pendingMode ?? "auto-continue" },
8221
+ );
8222
+ }
8223
+ return false;
8122
8224
  }
8123
8225
 
8124
8226
  // ----- the governor, continued -----------------------------------------
@@ -8366,7 +8468,7 @@ export default function piMultiAccount(pi: ExtensionAPI) {
8366
8468
  !hasPendingResume() ||
8367
8469
  userAbortedChain ||
8368
8470
  !automaticFailoverEnabled() ||
8369
- !config.autoContinue
8471
+ !pendingResumeEnabled()
8370
8472
  )
8371
8473
  return;
8372
8474
  const delay = nextPendingWakeDelayMs();
@@ -8395,7 +8497,7 @@ export default function piMultiAccount(pi: ExtensionAPI) {
8395
8497
  isBreakerOpen() ||
8396
8498
  userAbortedChain ||
8397
8499
  !automaticFailoverEnabled() ||
8398
- !config.autoContinue
8500
+ !pendingResumeEnabled()
8399
8501
  )
8400
8502
  return;
8401
8503
  if (!ctx.isIdle()) {
@@ -8428,10 +8530,15 @@ export default function piMultiAccount(pi: ExtensionAPI) {
8428
8530
  }
8429
8531
 
8430
8532
  refreshDiscovery();
8431
- reconcileCooldownsFromUsage(ctx);
8533
+ reconcileCooldownsFromUsage(ctx, {
8534
+ allowWhenHidden: pendingResume?.mode === "quota-recovery",
8535
+ });
8432
8536
  pruneCooldowns();
8433
- const parsedFrom = pendingResume?.from
8434
- ? parseTarget(pendingResume.from)
8537
+ const pendingSnapshot = pendingResume;
8538
+ const pendingMode = pendingSnapshot?.mode ?? "auto-continue";
8539
+ const allowRecoveryResume = pendingMode === "quota-recovery";
8540
+ const parsedFrom = pendingSnapshot?.from
8541
+ ? parseTarget(pendingSnapshot.from)
8435
8542
  : undefined;
8436
8543
  const sourceModel = parsedFrom
8437
8544
  ? {
@@ -8439,7 +8546,8 @@ export default function piMultiAccount(pi: ExtensionAPI) {
8439
8546
  id: parsedFrom.modelId ?? ctx.model?.id,
8440
8547
  }
8441
8548
  : ctx.model;
8442
- if (!sourceModel?.provider || !sourceModel?.id) {
8549
+ if (!sourceModel?.provider || !sourceModel?.id ||
8550
+ isFailoverExempt(sourceModel.provider) || isFailoverExempt(ctx.model?.provider)) {
8443
8551
  clearPendingContinuation();
8444
8552
  return;
8445
8553
  }
@@ -8448,10 +8556,10 @@ export default function piMultiAccount(pi: ExtensionAPI) {
8448
8556
  // NOT account/model failures: retry the SAME account/model after any brief cooldown —
8449
8557
  // never rotate to a sibling model (which would silently downgrade e.g. gpt-5.5 → gpt-5.4
8450
8558
  // on the same account, whose quota is shared, so the downgrade escapes nothing).
8451
- if (isSameModelResumeReason(pendingResume?.reason ?? "")) {
8559
+ if (isSameModelResumeReason(pendingSnapshot?.reason ?? "")) {
8452
8560
  const now = Date.now();
8453
- if (providerRecoveryAt(sourceModel.provider, now) <= now && (pendingResume?.retryAt ?? now) <= now) {
8454
- const resumeReason = pendingResume?.reason;
8561
+ if (providerRecoveryAt(sourceModel.provider, now) <= now && (pendingSnapshot?.retryAt ?? now) <= now) {
8562
+ const resumeReason = pendingSnapshot?.reason;
8455
8563
  clearPendingContinuation();
8456
8564
  const same = ref(sourceModel.provider, sourceModel.id);
8457
8565
  // Deliberately NOT routed through the governor: this is not a rotation, it is
@@ -8482,17 +8590,32 @@ export default function piMultiAccount(pi: ExtensionAPI) {
8482
8590
  // Pass the context explicitly or the injection fallback declines and the user has
8483
8591
  // to re-send the prompt by hand.
8484
8592
  if (epoch !== chainEpoch || sessionClosed) return;
8485
- await resumeWithExistingContext(ctx, {
8593
+ const resumed = await resumeWithExistingContext(ctx, {
8486
8594
  from: same,
8487
8595
  reason: resumeReason,
8596
+ allowWhenAutoContinueDisabled: allowRecoveryResume,
8488
8597
  });
8598
+ if (
8599
+ !resumed &&
8600
+ !hasPendingResume() &&
8601
+ epoch === chainEpoch &&
8602
+ !sessionClosed &&
8603
+ !userAbortedChain
8604
+ ) {
8605
+ setPendingContinuation(
8606
+ ctx,
8607
+ sourceModel,
8608
+ `${SELECTED_FALLBACK_PENDING_PREFIX} ${resumeReason ?? "retry continuation"}`,
8609
+ { mode: pendingMode },
8610
+ );
8611
+ }
8489
8612
  return;
8490
8613
  }
8491
8614
  schedulePendingWake(ctx);
8492
8615
  return;
8493
8616
  }
8494
8617
 
8495
- const pendingReason = pendingResume?.reason ?? "";
8618
+ const pendingReason = pendingSnapshot?.reason ?? "";
8496
8619
  const now = Date.now();
8497
8620
  const sourceRef = ref(sourceModel.provider, sourceModel.id);
8498
8621
  const sourceRecovered =
@@ -8531,10 +8654,25 @@ export default function piMultiAccount(pi: ExtensionAPI) {
8531
8654
  // The original account came back and there is no alternative: this is also a
8532
8655
  // same-account resume with no switch record.
8533
8656
  if (epoch !== chainEpoch || sessionClosed) return;
8534
- await resumeWithExistingContext(ctx, {
8657
+ const resumed = await resumeWithExistingContext(ctx, {
8535
8658
  from: sourceRef,
8536
- reason: pendingResume?.reason,
8659
+ reason: pendingReason,
8660
+ allowWhenAutoContinueDisabled: allowRecoveryResume,
8537
8661
  });
8662
+ if (
8663
+ !resumed &&
8664
+ !hasPendingResume() &&
8665
+ epoch === chainEpoch &&
8666
+ !sessionClosed &&
8667
+ !userAbortedChain
8668
+ ) {
8669
+ setPendingContinuation(
8670
+ ctx,
8671
+ sourceModel,
8672
+ `${SELECTED_FALLBACK_PENDING_PREFIX} ${pendingReason || "quota recovery"}`,
8673
+ { mode: pendingMode },
8674
+ );
8675
+ }
8538
8676
  return;
8539
8677
  }
8540
8678
  // Nothing to move to. The wait itself achieved nothing, so slow it down before the
@@ -8544,7 +8682,7 @@ export default function piMultiAccount(pi: ExtensionAPI) {
8544
8682
  return;
8545
8683
  }
8546
8684
 
8547
- const reason = pendingResume?.reason ?? "account cooldown expired";
8685
+ const reason = pendingReason || "account cooldown expired";
8548
8686
  const switched = await activateFallback(
8549
8687
  ctx,
8550
8688
  sourceModel,
@@ -8562,30 +8700,46 @@ export default function piMultiAccount(pi: ExtensionAPI) {
8562
8700
  // precisely the move that used to be free and therefore unbounded.
8563
8701
  pendingResumeHops++;
8564
8702
  clearPendingContinuation();
8565
- const dispatched = await maybeDispatchContinuation(ctx);
8703
+ const dispatched = await maybeDispatchContinuation(ctx, {
8704
+ allowWhenAutoContinueDisabled: allowRecoveryResume,
8705
+ pendingMode,
8706
+ });
8566
8707
  if (epoch !== chainEpoch) return;
8567
8708
  if (dispatched) resetPendingWakeBackoff();
8568
8709
  else growPendingWakeBackoff();
8569
8710
  }
8570
8711
 
8571
- function setPendingContinuation(ctx: any, failedModel: any, reason: string) {
8712
+ function setPendingContinuation(
8713
+ ctx: any,
8714
+ failedModel: any,
8715
+ reason: string,
8716
+ options: {
8717
+ mode?: PendingResumeMode;
8718
+ retryDelayMs?: number;
8719
+ } = {},
8720
+ ) {
8572
8721
  // A stop that leaves an armed resume behind in the state file is not a stop: the next
8573
8722
  // session reads it, `status` reports work pending, and the user is told something is
8574
8723
  // waiting to continue when nothing is.
8575
- if (governorStopped() || isBreakerOpen()) return;
8724
+ if (governorStopped() || isBreakerOpen() || userAbortedChain) return;
8576
8725
  const from = ref(failedModel.provider, failedModel.id);
8577
8726
  const alreadyPending = hasPendingResume();
8727
+ const mode = options.mode ?? "auto-continue";
8578
8728
  pendingResume = {
8579
8729
  from,
8580
8730
  reason,
8731
+ mode,
8581
8732
  since: pendingResume?.since ?? Date.now(),
8582
8733
  // Backoff belongs to this attempt, never shared account/quota health.
8583
- retryAt: isTransientPendingReason(reason) ? Date.now() + config.transientCooldownMs : undefined,
8734
+ retryAt: isTransientPendingReason(reason)
8735
+ ? Date.now() + (options.retryDelayMs ?? config.transientCooldownMs)
8736
+ : undefined,
8584
8737
  };
8585
8738
  logEvent("pending_resume_set", {
8586
8739
  session: sessionInstanceId,
8587
8740
  from,
8588
8741
  reason,
8742
+ mode,
8589
8743
  });
8590
8744
  persistedState = {
8591
8745
  ...persistedState,
@@ -8624,7 +8778,12 @@ export default function piMultiAccount(pi: ExtensionAPI) {
8624
8778
 
8625
8779
  // A failed Run is not a failed subscription. Retry the exact route; neither
8626
8780
  // repeated 5xx nor a watchdog proves that another provider is authorized.
8627
- function retryTemporaryFailure(ctx: any, failedModel: { provider: string; id: string }, errorText: string) {
8781
+ function retryTemporaryFailure(
8782
+ ctx: any,
8783
+ failedModel: { provider: string; id: string },
8784
+ errorText: string,
8785
+ retryDelayMs = config.transientCooldownMs,
8786
+ ) {
8628
8787
  const cursorStall = isCursorProviderId(failedModel.provider) && isCursorUpstreamStall(errorText);
8629
8788
  const failures = cursorStall ? noteCursorStallFailure(failedModel) : noteTransientFailure(failedModel);
8630
8789
  if (continuationDispatchedForAgentTurn || activeResumeWatch) noteRecoveryFailure(ctx);
@@ -8648,7 +8807,12 @@ export default function piMultiAccount(pi: ExtensionAPI) {
8648
8807
  ctx.ui.notify(`Provider retry: temporary error on ${failedModel.provider}/${failedModel.id}; automatic retry is disabled. Provider and model were NOT changed.`, "warning");
8649
8808
  return;
8650
8809
  }
8651
- setPendingContinuation(ctx, failedModel, `${TRANSIENT_PENDING_PREFIX} ${errorText.slice(0, 120)}`);
8810
+ setPendingContinuation(
8811
+ ctx,
8812
+ failedModel,
8813
+ `${TRANSIENT_PENDING_PREFIX} ${errorText.slice(0, 120)}`,
8814
+ { retryDelayMs },
8815
+ );
8652
8816
  }
8653
8817
 
8654
8818
  // ----- cold-start input hold -------------------------------------------
@@ -8930,6 +9094,63 @@ export default function piMultiAccount(pi: ExtensionAPI) {
8930
9094
  return;
8931
9095
  }
8932
9096
 
9097
+ if (command === "pick") {
9098
+ if (!ctx.hasUI || !ctx.model || !ctx.isIdle() || subagentChild || sessionClosed) {
9099
+ ctx.ui.notify("pi-multi-account: model picker needs an idle interactive session with an active model", "warning");
9100
+ return;
9101
+ }
9102
+ const provider = ctx.model.provider;
9103
+ const epoch = chainEpoch;
9104
+ const models = ctx.modelRegistry.getAvailable().filter((model: any) => model.provider === provider);
9105
+ const choices = [...new Set<string>(models.map((model: any) => model.id))].sort();
9106
+ if (!choices.length) {
9107
+ ctx.ui.notify(`pi-multi-account: no available models for ${provider}`, "warning");
9108
+ return;
9109
+ }
9110
+ const choice = await ctx.ui.select(`Models — ${provider}`, choices);
9111
+ if (!choice || sessionClosed || epoch !== chainEpoch || !ctx.isIdle() || ctx.model?.provider !== provider) return;
9112
+ const model = models.find((candidate: any) => candidate.id === choice);
9113
+ if (!model) return;
9114
+ // Use the native manual-selection path: it applies this model's thinking default
9115
+ // and emits model_select. Do not restore the previous model's effort as failover does.
9116
+ if (await setModelEnsuringVisible(model, ctx)) {
9117
+ ctx.ui.notify(`pi-multi-account: selected ${provider}/${choice}; use /multi-account save-default to keep this model and thinking for new sessions`, "info");
9118
+ }
9119
+ return;
9120
+ }
9121
+
9122
+ if (command === "save-default") {
9123
+ if (!ctx.model || !ctx.isIdle() || subagentChild || sessionClosed) {
9124
+ ctx.ui.notify("pi-multi-account: save defaults from an idle parent session with an active model", "warning");
9125
+ return;
9126
+ }
9127
+ const { provider, id } = ctx.model;
9128
+ const epoch = chainEpoch;
9129
+ const level = readThinkingLevel();
9130
+ if (!level) {
9131
+ ctx.ui.notify("pi-multi-account: host cannot report the active thinking level; defaults were not changed", "warning");
9132
+ return;
9133
+ }
9134
+ // Public, lock-backed settings API merges only changed fields. Never persist on
9135
+ // automatic failover or shutdown, where another session may own the defaults.
9136
+ const { SettingsManager } = await import("@earendil-works/pi-coding-agent");
9137
+ if (sessionClosed || epoch !== chainEpoch || !ctx.isIdle() ||
9138
+ ctx.model?.provider !== provider || ctx.model?.id !== id || readThinkingLevel() !== level) return;
9139
+ const settings = SettingsManager.create(ctx.cwd, AGENT_DIR, {
9140
+ projectTrusted: ctx.isProjectTrusted?.() ?? false,
9141
+ });
9142
+ settings.setDefaultModelAndProvider(provider, id);
9143
+ settings.setModelThinkingLevel(provider, id, level);
9144
+ await settings.flush();
9145
+ if (settings.drainErrors().length) {
9146
+ ctx.ui.notify("pi-multi-account: could not save all startup settings; check settings.json permissions and syntax", "error");
9147
+ return;
9148
+ }
9149
+ const overridden = settings.getDefaultProvider() !== provider || settings.getDefaultModel() !== id || settings.getModelThinkingLevel(provider, id) !== level;
9150
+ ctx.ui.notify(`pi-multi-account: saved global startup default ${provider}/${id} • ${level}.${overridden ? " Project settings override this choice in this workspace." : " Applies to new sessions; explicit CLI options and resumed sessions keep their own settings."}`, overridden ? "warning" : "info");
9151
+ return;
9152
+ }
9153
+
8933
9154
  if (command === "models" || command === "model") {
8934
9155
  // Show, per account in the rotation, the model order this extension would use
8935
9156
  // (★ = the one that would be selected). Lets you SEE whether the latest model is
@@ -8982,6 +9203,10 @@ export default function piMultiAccount(pi: ExtensionAPI) {
8982
9203
  }
8983
9204
  refreshDiscovery(true, ctx);
8984
9205
  startUsageStatusTimer(ctx);
9206
+ if (hasPendingResume()) {
9207
+ if (pendingResumeEnabled()) schedulePendingWake(ctx);
9208
+ else clearPendingContinuation();
9209
+ }
8985
9210
  runBackground("reload account metadata refresh", ctx, async () => {
8986
9211
  await refreshRotationUsage(ctx);
8987
9212
  await syncCodexModelCatalog(ctx, true);
@@ -9507,7 +9732,7 @@ export default function piMultiAccount(pi: ExtensionAPI) {
9507
9732
  else clearOnlyActiveFilter();
9508
9733
  ctx.ui.notify(
9509
9734
  next2
9510
- ? "pi-multi-account: only-active ON preference saved; Pi has no separate picker filter, so all registered models remain available"
9735
+ ? "pi-multi-account: only-active ON preference saved; use /multi-account pick for current-account models. The built-in /model and shared registry remain complete"
9511
9736
  : "pi-multi-account: only-active OFF — every provider's models restored",
9512
9737
  "info",
9513
9738
  );
@@ -9695,7 +9920,8 @@ export default function piMultiAccount(pi: ExtensionAPI) {
9695
9920
  `Cooldowns: ${cooldowns.length ? cooldowns.join(", ") : "none"}`,
9696
9921
  `Next recovery: ${nextRecoveryStatus(ctx)}`,
9697
9922
  `Invalidated (need re-login): ${invalids.length ? invalids.join(", ") : "none"}`,
9698
- `Pending auto-resume: ${hasPendingResume() ? `yes (reason: ${pendingResume?.reason ?? "unknown"})` : "none"}`,
9923
+ `Continuation: immediate ${config.autoContinue ? "ON" : "OFF"} · after all-account quota recovery ${config.resumeAfterAllAccountsRecover ? "ON" : "OFF"}`,
9924
+ `Pending auto-resume: ${hasPendingResume() ? `yes (${pendingResume?.mode ?? "unknown"}; reason: ${pendingResume?.reason ?? "unknown"})` : "none"}`,
9699
9925
  `Queued user messages: ${queuedUserInputs.length}`,
9700
9926
  `Resume watchdog: ${activeResumeWatch ? `watching${toolInFlight ? " · tool running" : ""}` : "idle"} · auto-recover ${config.autoRecoverStuck ? "ON" : "OFF"}`,
9701
9927
  `Compaction routing: ${config.routeCompactionToHealthyAccount ? "to healthy account" : "off"}${compactionRoutedNote ? ` (last: ${compactionRoutedNote})` : ""}${lastContextOverflowAt ? ` · last overflow ${formatUntil(lastContextOverflowAt)}` : ""}`,
@@ -9740,7 +9966,7 @@ export default function piMultiAccount(pi: ExtensionAPI) {
9740
9966
  // list below, `switch` was effectively undiscoverable and `next` pressed repeatedly
9741
9967
  // was the only way anyone found to reach a chosen account.
9742
9968
  `Switch accounts: /multi-account best — jump straight to an account that can work now · /multi-account switch <provider> — e.g. /multi-account switch ${rotation.find((p) => p !== ctx.model?.provider) ?? rotation[0] ?? "<provider>"} · /multi-account next steps through the rotation in order`,
9743
- `Other commands: status | accounts [refresh] | best | priority [...] | limits [refresh] | models | log [N|on|off] | only-active [on|off] | rediscover | add [anthropic|codex|kimi|cursor|ollama|qwen] | remove [anthropic|codex|kimi|cursor|ollama|qwen|<provider-id>] | revive <provider|all> | clear | stop | reset | reload | enable | disable`,
9969
+ `Other commands: status | accounts [refresh] | best | priority [...] | limits [refresh] | models | pick | save-default | log [N|on|off] | only-active [on|off] | rediscover | add [anthropic|codex|kimi|cursor|ollama|qwen] | remove [anthropic|codex|kimi|cursor|ollama|qwen|<provider-id>] | revive <provider|all> | clear | stop | reset | reload | enable | disable`,
9744
9970
  ].join("\n"),
9745
9971
  "info",
9746
9972
  );
@@ -10457,11 +10683,37 @@ export default function piMultiAccount(pi: ExtensionAPI) {
10457
10683
  return proxyFamilyFor(slotId);
10458
10684
  }
10459
10685
 
10460
- function numberedSlotBaseUrl(id: string, family: ProxyFamily): string {
10686
+ //
10687
+ // Behavior is unchanged from the single helper this replaced: a numbered Anthropic alias
10688
+ // falls back to the public API when this process has no loopback route yet.
10689
+ //
10690
+ function numberedAnthropicBaseUrl(id: string): string {
10691
+ if (typeof slotProxyPort === "number") return publishedRouteFor(slotProxyPort, id);
10692
+ return "https://api.anthropic.com";
10693
+ }
10694
+
10695
+ /**
10696
+ * Route for a numbered Codex alias.
10697
+ *
10698
+ * With `config.childProxy` enabled this extension publishes a child-facing placeholder into
10699
+ * auth.json. That placeholder is meaningless to ChatGPT: it is only ever valid against the
10700
+ * loopback route this extension serves, which swaps in the real OAuth credential. Sending
10701
+ * it to the public upstream is what produces "Could not parse your authentication token".
10702
+ *
10703
+ * So a numbered alias NEVER gets the public upstream while the proxy is enabled — not even
10704
+ * before the listener exists. The published route is deterministic (the canonical port is
10705
+ * the ownership token for it), so pointing there keeps the alias registered and resolvable
10706
+ * from the moment the extension loads, and whoever ends up owning that port serves the
10707
+ * slot. If the port is never served the request fails to connect, which is loud and
10708
+ * harmless — unlike a credential that reaches a provider that cannot read it.
10709
+ *
10710
+ * With the proxy disabled no placeholder is ever published: the real credential is what Pi
10711
+ * presents, so the public upstream is the correct destination.
10712
+ */
10713
+ function numberedCodexBaseUrl(id: string): string {
10461
10714
  if (typeof slotProxyPort === "number") return publishedRouteFor(slotProxyPort, id);
10462
- return family === "anthropic"
10463
- ? "https://api.anthropic.com"
10464
- : "https://chatgpt.com/backend-api";
10715
+ if (!config.childProxy) return "https://chatgpt.com/backend-api";
10716
+ return publishedRouteFor(SLOT_PROXY_PORT, id);
10465
10717
  }
10466
10718
 
10467
10719
  function restoreChildFacingAuth(): void {
@@ -11140,7 +11392,7 @@ export default function piMultiAccount(pi: ExtensionAPI) {
11140
11392
  // the summary on a healthy account instead. If that cannot finish, we CANCEL — never return
11141
11393
  // undefined onto a spent account, because Pi's default has no timeout of its own.
11142
11394
  safeOn("session_before_compact", async (event: any, ctx: any) => {
11143
- if (!automaticFailoverEnabled()) return undefined;
11395
+ if (!automaticFailoverEnabled() || isFailoverExempt(ctx.model?.provider)) return undefined;
11144
11396
  if (event?.reason === "overflow") lastContextOverflowAt = Date.now();
11145
11397
  const result = await runHealthyCompaction(event, ctx);
11146
11398
  if (result !== undefined) return result;
@@ -11293,7 +11545,9 @@ export default function piMultiAccount(pi: ExtensionAPI) {
11293
11545
  model: ctx.model?.id,
11294
11546
  });
11295
11547
  ctx.ui.notify(
11296
- `pi-multi-account: no account is ready right now. Your message stays in Pi's transcript; if this request is refused, it will resume automatically when a compatible account recovers (next check in ~${formatDelay(delay)}).`,
11548
+ config.resumeAfterAllAccountsRecover
11549
+ ? `pi-multi-account: no account is ready right now. Your message stays in Pi's transcript; if this request is refused, it will resume automatically when a compatible account recovers (next check in ~${formatDelay(delay)}).`
11550
+ : `pi-multi-account: no account is ready right now. Your message stays in Pi's transcript, but resumeAfterAllAccountsRecover is off, so a refused request will not be resumed automatically.`,
11297
11551
  "warning",
11298
11552
  );
11299
11553
  return { action: "continue" as const };
@@ -11543,9 +11797,13 @@ export default function piMultiAccount(pi: ExtensionAPI) {
11543
11797
  return;
11544
11798
  }
11545
11799
  if ((status === 429 || status === 402 || status === 403) && ctx.model) {
11546
- // Only set cooldown hints for providers this extension manages.
11547
- // Without this guard, a 429 on any provider pollutes cooldown state.
11548
- if (!classifyProvider(ctx.model.provider, config.qwenProvider)) return;
11800
+ // Managed providers use these hints for every limit response. For unmanaged
11801
+ // providers retain only 429 hints: they let a bodyless rate limit use the
11802
+ // server's Retry-After instead of the six-hour quota cooldown.
11803
+ if (
11804
+ status !== 429 &&
11805
+ !classifyProvider(ctx.model.provider, config.qwenProvider)
11806
+ ) return;
11549
11807
  const cooldownMs = cooldownFromHeaders((event as any).headers ?? {});
11550
11808
  if (cooldownMs !== undefined) {
11551
11809
  responseCooldownHints.set(
@@ -11631,7 +11889,7 @@ export default function piMultiAccount(pi: ExtensionAPI) {
11631
11889
  // Opt-out for unmanaged providers that run their own retry logic (usually a companion
11632
11890
  // extension owning retries for that provider). Switching accounts underneath it would
11633
11891
  // fight those retries, so leave the turn alone entirely.
11634
- if (config.neverFailoverProviders.includes(provider)) {
11892
+ if (isFailoverExempt(provider)) {
11635
11893
  logEvent("failover_suppressed", {
11636
11894
  provider,
11637
11895
  model: modelId,
@@ -11643,6 +11901,16 @@ export default function piMultiAccount(pi: ExtensionAPI) {
11643
11901
  retryTemporaryFailure(ctx, failedModel, errorText);
11644
11902
  return;
11645
11903
  }
11904
+ // A bodyless 429 proves throttling, not account-level credit exhaustion.
11905
+ // Retry the same route after Retry-After (or the normal transient minute)
11906
+ // instead of poisoning the whole provider for six hours.
11907
+ if (/^429 status code \(no body\)$/i.test(errorText.trim())) {
11908
+ const retryDelayMs =
11909
+ responseCooldownHints.get(provider) ?? config.transientCooldownMs;
11910
+ responseCooldownHints.delete(provider);
11911
+ retryTemporaryFailure(ctx, failedModel, errorText, retryDelayMs);
11912
+ return;
11913
+ }
11646
11914
  // A quota or authorization refusal is about the ACCOUNT, not the model, and it does not
11647
11915
  // clear in a minute: an unmanaged account out of credits refuses everything until it is
11648
11916
  // topped up. Benching only the model for `transientCooldownMs` let the very next switch
@@ -11657,7 +11925,10 @@ export default function piMultiAccount(pi: ExtensionAPI) {
11657
11925
  failedModel,
11658
11926
  `external provider out of quota: ${errorText.slice(0, 100)}`,
11659
11927
  accountLevel ? config.cooldownMs : config.transientCooldownMs,
11660
- { scope: accountLevel ? "provider" : "model" },
11928
+ {
11929
+ scope: accountLevel ? "provider" : "model",
11930
+ waitForQuotaRecovery: failureKind === "limit",
11931
+ },
11661
11932
  );
11662
11933
  }
11663
11934
  return;
@@ -11742,6 +12013,7 @@ export default function piMultiAccount(pi: ExtensionAPI) {
11742
12013
  failedModel,
11743
12014
  `assistant error: ${errorText.slice(0, 120)}`,
11744
12015
  cooldownMs,
12016
+ { waitForQuotaRecovery: true },
11745
12017
  );
11746
12018
  return;
11747
12019
  }
@@ -11811,7 +12083,10 @@ export default function piMultiAccount(pi: ExtensionAPI) {
11811
12083
  }
11812
12084
  if (continuationDispatchedForAgentTurn) {
11813
12085
  continuationDispatchedForAgentTurn = false;
11814
- return;
12086
+ // The continuation itself may have hit quota and message_end may already have
12087
+ // selected another account. Suppress only a duplicate end for the same attempt;
12088
+ // never suppress the new switch that now needs its own continuation.
12089
+ if (!currentPromptSwitch) return;
11815
12090
  }
11816
12091
  if (!config.enabled || !config.autoContinue || userAbortedChain) return;
11817
12092
  await maybeDispatchContinuation(ctx);