@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.
- package/README.md +4 -4
- package/THIRD_PARTY_NOTICES.md +1 -1
- package/node_modules/@herbertgao/pi-subagents/CHANGELOG.md +8 -0
- package/node_modules/@herbertgao/pi-subagents/package.json +1 -1
- package/node_modules/@herbertgao/pi-subagents/src/agent-manager.ts +5 -4
- package/node_modules/@herbertgao/pi-subagents/src/mention-clone.ts +39 -16
- package/node_modules/@narumitw/pi-btw/README.md +21 -4
- package/node_modules/@narumitw/pi-btw/dist/index.ts +1668 -958
- package/node_modules/@narumitw/pi-btw/dist/index.ts.map +4 -4
- package/node_modules/@narumitw/pi-btw/docs/workflows.md +10 -2
- package/node_modules/@narumitw/pi-btw/package.json +1 -1
- package/node_modules/@narumitw/pi-btw/src/btw.ts +17 -79
- package/node_modules/@narumitw/pi-btw/src/conversation-context.ts +74 -0
- package/node_modules/@narumitw/pi-btw/src/fullscreen-ui.ts +196 -7
- package/node_modules/@narumitw/pi-btw/src/main-thread-updates.ts +40 -0
- package/node_modules/@narumitw/pi-btw/src/menu.ts +34 -2
- package/node_modules/@narumitw/pi-btw/src/settings.ts +49 -0
- package/node_modules/@narumitw/pi-btw/src/transcript-pager.ts +9 -1
- package/node_modules/@narumitw/pi-btw/src/workspace-layout.ts +559 -0
- package/node_modules/pi-multi-account/CHANGELOG.md +23 -0
- package/node_modules/pi-multi-account/README.md +38 -11
- package/node_modules/pi-multi-account/index.ts +371 -96
- package/node_modules/pi-multi-account/package.json +5 -4
- package/node_modules/pi-multi-account/provider-payload-stream.ts +36 -28
- package/node_modules/pi-multi-account/usage.ts +31 -2
- package/node_modules/pi-typesafe/README.md +3 -1
- package/node_modules/pi-typesafe/dist/auth.d.ts +10 -3
- package/node_modules/pi-typesafe/dist/auth.js +13 -7
- package/node_modules/pi-typesafe/dist/backends.d.ts +31 -0
- package/node_modules/pi-typesafe/dist/backends.js +33 -0
- package/node_modules/pi-typesafe/dist/client.d.ts +3 -9
- package/node_modules/pi-typesafe/dist/client.js +63 -39
- package/node_modules/pi-typesafe/dist/credentials.d.ts +11 -5
- package/node_modules/pi-typesafe/dist/credentials.js +15 -8
- package/node_modules/pi-typesafe/dist/extension.js +4 -1
- package/node_modules/pi-typesafe/dist/index.d.ts +1 -1
- package/node_modules/pi-typesafe/dist/index.js +1 -1
- package/node_modules/pi-typesafe/dist/login.d.ts +13 -7
- package/node_modules/pi-typesafe/dist/login.js +17 -8
- package/node_modules/pi-typesafe/dist/schema.js +19 -13
- package/node_modules/pi-typesafe/package.json +1 -1
- 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
|
|
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.
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
| {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
:
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
6410
|
-
//
|
|
6411
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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?: {
|
|
7882
|
-
|
|
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
|
-
?
|
|
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
|
-
|
|
7932
|
-
|
|
7933
|
-
|
|
7934
|
-
|
|
7935
|
-
|
|
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?: {
|
|
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
|
-
//
|
|
8066
|
-
|
|
8067
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
8121
|
-
|
|
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
|
-
!
|
|
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
|
-
!
|
|
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
|
|
8434
|
-
|
|
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(
|
|
8559
|
+
if (isSameModelResumeReason(pendingSnapshot?.reason ?? "")) {
|
|
8452
8560
|
const now = Date.now();
|
|
8453
|
-
if (providerRecoveryAt(sourceModel.provider, now) <= now && (
|
|
8454
|
-
const resumeReason =
|
|
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 =
|
|
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:
|
|
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 =
|
|
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(
|
|
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)
|
|
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(
|
|
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(
|
|
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;
|
|
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
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
10463
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
11547
|
-
//
|
|
11548
|
-
|
|
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 (
|
|
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
|
-
{
|
|
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
|
-
|
|
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);
|