pi-multi-account 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +32 -0
- package/index.ts +173 -10
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,37 @@ All notable changes to this project are documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [1.1.0] - 2026-06-10
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- **Runaway failover loop that could freeze the machine.** When every account was
|
|
13
|
+
rate-limited the rotation ping-ponged between accounts every 1–9s indefinitely,
|
|
14
|
+
growing session history until the system swapped itself to death. The
|
|
15
|
+
auto-continue counter was reset on every agent start, so `maxAutoContinuesPerPrompt`
|
|
16
|
+
never actually bounded the loop. The counter is now reset only by a genuine new
|
|
17
|
+
user prompt, making the cap a real per-task limit.
|
|
18
|
+
- **Escape did not stop the loop.** Auto-continuation ran from background event
|
|
19
|
+
hooks and a timer, so cancelling the agent was immediately undone. User aborts
|
|
20
|
+
(`stopReason: "aborted"` / `ctx.signal`) now stop the chain and cancel all timers.
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- Anti-ping-pong guard: immediate failover only switches to an account usable right
|
|
25
|
+
now and never bounces straight back to the account it just left within 60s.
|
|
26
|
+
- Minimum 15s spacing between auto-continuations (no tight CPU/network loop, and a
|
|
27
|
+
real window for Esc to take effect).
|
|
28
|
+
- In-session auto-resume: when the whole fallback circle is exhausted, the extension
|
|
29
|
+
waits and continues the agent's work as soon as any account recovers — for as long
|
|
30
|
+
as the session stays open.
|
|
31
|
+
|
|
32
|
+
### Changed
|
|
33
|
+
|
|
34
|
+
- **Tight session binding.** Background activity is now scoped to the live session:
|
|
35
|
+
ending or replacing a session (quit, reload, new, resume, fork) cancels all timers
|
|
36
|
+
and drops any pending resume. A new session starts clean and never inherits a
|
|
37
|
+
previous session's paused work; nothing survives once Pi exits.
|
|
38
|
+
|
|
8
39
|
## [1.0.0] - 2026-06-09
|
|
9
40
|
|
|
10
41
|
### Added
|
|
@@ -24,4 +55,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
24
55
|
- Plaintext-free credential handling (SHA-256 fingerprints only); `0600`
|
|
25
56
|
config/state files.
|
|
26
57
|
|
|
58
|
+
[1.1.0]: https://github.com/Sarrius/pi-multi-account/releases/tag/v1.1.0
|
|
27
59
|
[1.0.0]: https://github.com/Sarrius/pi-multi-account/releases/tag/v1.0.0
|
package/index.ts
CHANGED
|
@@ -106,6 +106,11 @@ const STATE_VERSION = 3;
|
|
|
106
106
|
const DEFAULT_COOLDOWN_MS = 6 * 60 * 60 * 1000;
|
|
107
107
|
const DEFAULT_PROBE_COOLDOWN_MS = 5 * 60 * 1000;
|
|
108
108
|
const DEFAULT_INVALID_COOLDOWN_MS = 365 * 24 * 60 * 60 * 1000; // effectively "until re-login"
|
|
109
|
+
// Runaway-loop guards (added). Without these, when every account is rate-limited the
|
|
110
|
+
// failover bounces between accounts every 1-9s forever, growing the session history
|
|
111
|
+
// until the machine swaps itself to death.
|
|
112
|
+
const ANTI_PINGPONG_MS = 60 * 1000; // don't switch straight back to the account we just left
|
|
113
|
+
const MIN_AUTOCONTINUE_INTERVAL_MS = 15 * 1000; // floor between auto-continuations (CPU/network guard)
|
|
109
114
|
|
|
110
115
|
const ANTHROPIC_BASE = "anthropic";
|
|
111
116
|
const CODEX_BASE = "openai-codex";
|
|
@@ -573,6 +578,14 @@ function formatUntil(timestamp: number) {
|
|
|
573
578
|
return rest ? `${hours}h ${rest}m` : `${hours}h`;
|
|
574
579
|
}
|
|
575
580
|
|
|
581
|
+
/** stopReason of the most recent assistant message — "aborted" means the user pressed Esc. */
|
|
582
|
+
function lastAssistantStopReason(messages: any[]): string | undefined {
|
|
583
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
584
|
+
if (messages[i]?.role === "assistant") return messages[i]?.stopReason as string | undefined;
|
|
585
|
+
}
|
|
586
|
+
return undefined;
|
|
587
|
+
}
|
|
588
|
+
|
|
576
589
|
function getAssistantErrorText(messages: any[]) {
|
|
577
590
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
578
591
|
const message = messages[i];
|
|
@@ -606,10 +619,22 @@ export default function piMultiAccount(pi: ExtensionAPI) {
|
|
|
606
619
|
let lastAuthMtime = -1;
|
|
607
620
|
|
|
608
621
|
let currentPromptSwitch: SwitchRecord | undefined;
|
|
622
|
+
// Number of auto-continuations issued for the CURRENT task. Crucially this is NOT
|
|
623
|
+
// reset by the self-triggered re-prompts failover issues (only by a genuine new user
|
|
624
|
+
// prompt — see before_agent_start), so config.maxAutoContinuesPerPrompt actually bounds
|
|
625
|
+
// the failover loop instead of resetting to 0 on every iteration.
|
|
609
626
|
let autoContinuesThisPrompt = 0;
|
|
610
627
|
let lastErrorText = "";
|
|
611
628
|
let latestCtx: any | undefined;
|
|
612
629
|
let pendingWakeTimer: ReturnType<typeof setTimeout> | undefined;
|
|
630
|
+
// --- runaway-loop & user-interrupt guards (added) ---
|
|
631
|
+
let expectingSelfContinuation = false; // true between our sendUserMessage and its agent_start
|
|
632
|
+
let lastSentContinuationPrompt = ""; // secondary check to recognise our own re-prompt
|
|
633
|
+
let userAbortedChain = false; // user pressed Esc → stop auto-continuing until a new prompt
|
|
634
|
+
let lastAutoContinueAt = 0; // for minimum spacing between auto-continuations
|
|
635
|
+
let autoContinueTimer: ReturnType<typeof setTimeout> | undefined; // pending spaced continuation
|
|
636
|
+
let lastLeftProvider: string | undefined; // account we just failed away from (anti-ping-pong)
|
|
637
|
+
let lastLeftAt = 0;
|
|
613
638
|
// The thinking level the user intended for this turn. pi.setModel() re-clamps and
|
|
614
639
|
// persists the thinking level on every model switch, so without this it drifts
|
|
615
640
|
// downward across failovers ("thinking level keeps dropping"). We capture it before
|
|
@@ -836,7 +861,7 @@ export default function piMultiAccount(pi: ExtensionAPI) {
|
|
|
836
861
|
* cooldown first — i.e. the account that will recover soonest — honoring the
|
|
837
862
|
* per-provider probe interval so we don't hammer a still-limited account.
|
|
838
863
|
*/
|
|
839
|
-
function findFallbackModels(ctx: any, currentModel: any) {
|
|
864
|
+
function findFallbackModels(ctx: any, currentModel: any, options: { availableNowOnly?: boolean } = {}) {
|
|
840
865
|
const fallbacks = activeFallbacks();
|
|
841
866
|
if (fallbacks.length === 0) return [];
|
|
842
867
|
|
|
@@ -865,10 +890,21 @@ export default function piMultiAccount(pi: ExtensionAPI) {
|
|
|
865
890
|
|
|
866
891
|
// (1) Anything available right now → soonest-recovered wins (all remaining=0),
|
|
867
892
|
// deterministic rotation-order tiebreak.
|
|
868
|
-
|
|
893
|
+
let availableNow = scored.filter((s) => s.remaining === 0).sort((a, b) => a.rotIndex - b.rotIndex);
|
|
894
|
+
// Anti-ping-pong: don't bounce straight back to the account we just left if any
|
|
895
|
+
// other account is also free right now — that's the loop that freezes the machine.
|
|
896
|
+
if (lastLeftProvider && now - lastLeftAt < ANTI_PINGPONG_MS && availableNow.length > 1) {
|
|
897
|
+
availableNow = availableNow.filter((s) => s.model.provider !== lastLeftProvider);
|
|
898
|
+
}
|
|
869
899
|
if (availableNow.length > 0) return availableNow.map((s) => s.model);
|
|
870
900
|
|
|
901
|
+
// Immediate failover must NEVER switch into a still-exhausted account: that account
|
|
902
|
+
// would re-fail at once and the rotation would ping-pong forever. When nothing is
|
|
903
|
+
// available right now, the caller falls back to the delayed pending-resume path.
|
|
904
|
+
if (options.availableNowOnly) return [];
|
|
905
|
+
|
|
871
906
|
// (2) All exhausted → closest-to-recovery first (shortest remaining cooldown).
|
|
907
|
+
// Only reached by the pending-resume probe, which is rate-limited per provider.
|
|
872
908
|
const probeable = scored.filter((s) => s.probeReady);
|
|
873
909
|
const pool = probeable.length > 0 ? probeable : scored;
|
|
874
910
|
return pool.sort((a, b) => a.remaining - b.remaining || a.rotIndex - b.rotIndex).map((s) => s.model);
|
|
@@ -880,16 +916,22 @@ export default function piMultiAccount(pi: ExtensionAPI) {
|
|
|
880
916
|
if (!currentModel) return false;
|
|
881
917
|
|
|
882
918
|
markExhausted(currentModel.provider, cooldownMs);
|
|
883
|
-
|
|
919
|
+
lastLeftProvider = currentModel.provider;
|
|
920
|
+
lastLeftAt = Date.now();
|
|
921
|
+
// Immediate failover only ever switches to an account that is usable RIGHT NOW. If
|
|
922
|
+
// none is, we don't bounce into an exhausted one — we arm the delayed pending-resume
|
|
923
|
+
// path, which probes accounts as their cooldowns expire.
|
|
924
|
+
const candidates = findFallbackModels(ctx, currentModel, { availableNowOnly: true });
|
|
884
925
|
if (candidates.length === 0) {
|
|
885
926
|
const cooldowns = [...exhaustedUntilByProvider.entries()]
|
|
886
927
|
.filter(([, until]) => until > Date.now())
|
|
887
928
|
.map(([c, until]) => `${c}: ${formatUntil(until)}`)
|
|
888
929
|
.join(", ");
|
|
889
930
|
ctx.ui.notify(
|
|
890
|
-
`Provider failover: no available fallback after ${currentModel.provider}/${currentModel.id}. ${cooldowns ? `Cooldowns: ${cooldowns}` : "All known accounts may be unauthenticated, invalidated, or same account."}`,
|
|
931
|
+
`Provider failover: no immediately available fallback after ${currentModel.provider}/${currentModel.id}. ${cooldowns ? `Cooldowns: ${cooldowns}` : "All known accounts may be unauthenticated, invalidated, or same account."}`,
|
|
891
932
|
"warning",
|
|
892
933
|
);
|
|
934
|
+
setPendingContinuation(ctx, reason); // wait for an account to recover, then resume
|
|
893
935
|
return false;
|
|
894
936
|
}
|
|
895
937
|
|
|
@@ -921,6 +963,37 @@ export default function piMultiAccount(pi: ExtensionAPI) {
|
|
|
921
963
|
return config.continuationPrompt.replaceAll("{from}", record.from).replaceAll("{to}", record.to).replaceAll("{reason}", record.reason);
|
|
922
964
|
}
|
|
923
965
|
|
|
966
|
+
/** Mark that the next agent run is our own failover continuation, then send it. */
|
|
967
|
+
function dispatchSelfContinuation(ctx: any, prompt: string) {
|
|
968
|
+
lastAutoContinueAt = Date.now();
|
|
969
|
+
lastSentContinuationPrompt = prompt;
|
|
970
|
+
expectingSelfContinuation = true;
|
|
971
|
+
pi.sendUserMessage(prompt, ctx.isIdle() ? undefined : { deliverAs: "followUp" });
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
/**
|
|
975
|
+
* Send an auto-continuation, but never faster than MIN_AUTOCONTINUE_INTERVAL_MS.
|
|
976
|
+
* The spacing keeps a fully rate-limited rotation from pegging CPU/network and gives
|
|
977
|
+
* the user a real window in which Esc actually sticks.
|
|
978
|
+
*/
|
|
979
|
+
function scheduleAutoContinue(ctx: any, prompt: string) {
|
|
980
|
+
if (autoContinueTimer) {
|
|
981
|
+
clearTimeout(autoContinueTimer);
|
|
982
|
+
autoContinueTimer = undefined;
|
|
983
|
+
}
|
|
984
|
+
const wait = Math.max(0, MIN_AUTOCONTINUE_INTERVAL_MS - (Date.now() - lastAutoContinueAt));
|
|
985
|
+
if (wait === 0) {
|
|
986
|
+
dispatchSelfContinuation(ctx, prompt);
|
|
987
|
+
return;
|
|
988
|
+
}
|
|
989
|
+
autoContinueTimer = setTimeout(() => {
|
|
990
|
+
autoContinueTimer = undefined;
|
|
991
|
+
if (userAbortedChain || ctx.signal?.aborted) return; // user took over while we waited
|
|
992
|
+
dispatchSelfContinuation(ctx, prompt);
|
|
993
|
+
}, wait);
|
|
994
|
+
ctx.ui.notify(`Provider failover: next auto-continue in ~${Math.ceil(wait / 1000)}s (press Esc to cancel).`, "info");
|
|
995
|
+
}
|
|
996
|
+
|
|
924
997
|
function clearPendingContinuation() {
|
|
925
998
|
if (pendingWakeTimer) {
|
|
926
999
|
clearTimeout(pendingWakeTimer);
|
|
@@ -958,6 +1031,10 @@ export default function piMultiAccount(pi: ExtensionAPI) {
|
|
|
958
1031
|
}
|
|
959
1032
|
|
|
960
1033
|
function setPendingContinuation(ctx: any, reason: string) {
|
|
1034
|
+
// Don't re-arm or re-notify if a pending resume is already queued — switchToFallback
|
|
1035
|
+
// and agent_end can both reach here for the same exhaustion, and the wake timer is
|
|
1036
|
+
// already running.
|
|
1037
|
+
const alreadyPending = !!persistedState.pendingContinuationPrompt;
|
|
961
1038
|
const current = ctx.model ? ref(ctx.model.provider, ctx.model.id) : ("unknown/model" as ModelRef);
|
|
962
1039
|
const record: SwitchRecord = { from: current, to: "next-available/account" as ModelRef, reason, at: Date.now() };
|
|
963
1040
|
persistedState = {
|
|
@@ -968,6 +1045,7 @@ export default function piMultiAccount(pi: ExtensionAPI) {
|
|
|
968
1045
|
};
|
|
969
1046
|
persist();
|
|
970
1047
|
schedulePendingWake(ctx);
|
|
1048
|
+
if (alreadyPending) return;
|
|
971
1049
|
const delayMs = nextPendingWakeDelayMs();
|
|
972
1050
|
ctx.ui.notify(
|
|
973
1051
|
`Provider failover: all accounts appear exhausted. Will automatically probe/resume in ~${Math.ceil((delayMs ?? config.probeCooldownMs) / 1000)}s if this Pi session stays open.`,
|
|
@@ -979,6 +1057,14 @@ export default function piMultiAccount(pi: ExtensionAPI) {
|
|
|
979
1057
|
const ctx = latestCtx;
|
|
980
1058
|
const prompt = persistedState.pendingContinuationPrompt;
|
|
981
1059
|
if (!ctx || !prompt || !config.enabled || !config.autoContinue) return;
|
|
1060
|
+
if (userAbortedChain) {
|
|
1061
|
+
clearPendingContinuation(); // user took over — abandon the background resurrection
|
|
1062
|
+
return;
|
|
1063
|
+
}
|
|
1064
|
+
if (autoContinuesThisPrompt >= config.maxAutoContinuesPerPrompt) {
|
|
1065
|
+
clearPendingContinuation(); // task-level cap reached — stop resurrecting
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
982
1068
|
refreshDiscovery();
|
|
983
1069
|
pruneCooldowns();
|
|
984
1070
|
const candidates = findFallbackModels(ctx, ctx.model);
|
|
@@ -996,8 +1082,12 @@ export default function piMultiAccount(pi: ExtensionAPI) {
|
|
|
996
1082
|
restoreDesiredThinking(); // keep the user's thinking level across the switch
|
|
997
1083
|
setLastProbe(candidate.provider);
|
|
998
1084
|
clearPendingContinuation();
|
|
1085
|
+
// A genuine recovery after a real wait earns a fresh continuation budget so the
|
|
1086
|
+
// agent can keep going whenever an account recovers; rapid flapping (resume that
|
|
1087
|
+
// immediately re-limits) does NOT reset, so the cap still bounds a tight loop.
|
|
1088
|
+
if (Date.now() - lastAutoContinueAt >= config.probeCooldownMs) autoContinuesThisPrompt = 0;
|
|
999
1089
|
ctx.ui.notify(`Provider failover: resuming pending work on ${to}`, "warning");
|
|
1000
|
-
|
|
1090
|
+
dispatchSelfContinuation(ctx, prompt);
|
|
1001
1091
|
return;
|
|
1002
1092
|
}
|
|
1003
1093
|
schedulePendingWake(ctx);
|
|
@@ -1104,23 +1194,65 @@ export default function piMultiAccount(pi: ExtensionAPI) {
|
|
|
1104
1194
|
latestCtx = ctx;
|
|
1105
1195
|
refreshDiscovery(true);
|
|
1106
1196
|
pruneCooldowns();
|
|
1107
|
-
|
|
1197
|
+
// Tight session binding: every session starts as a clean slate. Auto-resume only ever
|
|
1198
|
+
// runs *inside the live session that hit the limit* (its timer is armed by
|
|
1199
|
+
// setPendingContinuation). A new session — or a reopened one after a crash — must NEVER
|
|
1200
|
+
// inherit and silently restart a previous session's paused work, so we drop any leftover
|
|
1201
|
+
// pending state and reset all in-memory guards here.
|
|
1202
|
+
if (persistedState.pendingContinuationPrompt) clearPendingContinuation();
|
|
1203
|
+
autoContinuesThisPrompt = 0;
|
|
1204
|
+
userAbortedChain = false;
|
|
1205
|
+
expectingSelfContinuation = false;
|
|
1206
|
+
lastSentContinuationPrompt = "";
|
|
1108
1207
|
ctx.ui.notify(
|
|
1109
1208
|
`pi-multi-account loaded (${config.enabled ? "enabled" : "disabled"}). ${rotation.length} account(s) in rotation. Config: ${CONFIG_PATH}`,
|
|
1110
1209
|
"info",
|
|
1111
1210
|
);
|
|
1112
1211
|
});
|
|
1113
1212
|
|
|
1213
|
+
// CRITICAL: when the current session ends — for ANY reason (quit, reload, or replacement
|
|
1214
|
+
// by a new/resumed/forked session) — the extension's background activity must end with it.
|
|
1215
|
+
// Kill every timer and drop the pending continuation so nothing survives the session.
|
|
1114
1216
|
pi.on("session_shutdown", async () => {
|
|
1115
1217
|
if (pendingWakeTimer) {
|
|
1116
1218
|
clearTimeout(pendingWakeTimer);
|
|
1117
1219
|
pendingWakeTimer = undefined;
|
|
1118
1220
|
}
|
|
1221
|
+
if (autoContinueTimer) {
|
|
1222
|
+
clearTimeout(autoContinueTimer);
|
|
1223
|
+
autoContinueTimer = undefined;
|
|
1224
|
+
}
|
|
1225
|
+
clearPendingContinuation();
|
|
1226
|
+
userAbortedChain = false;
|
|
1227
|
+
expectingSelfContinuation = false;
|
|
1228
|
+
autoContinuesThisPrompt = 0;
|
|
1229
|
+
lastSentContinuationPrompt = "";
|
|
1230
|
+
});
|
|
1231
|
+
|
|
1232
|
+
// Distinguish a genuine new user prompt from our own failover continuation. Only a
|
|
1233
|
+
// genuine prompt resets the per-task auto-continue counter and cancels any pending
|
|
1234
|
+
// resurrection — this is what stops maxAutoContinuesPerPrompt from resetting every
|
|
1235
|
+
// iteration (the bug that let the failover loop run forever).
|
|
1236
|
+
pi.on("before_agent_start", async (event) => {
|
|
1237
|
+
const prompt = typeof (event as any).prompt === "string" ? (event as any).prompt : "";
|
|
1238
|
+
const isSelfContinuation =
|
|
1239
|
+
expectingSelfContinuation || (!!lastSentContinuationPrompt && prompt.trim() === lastSentContinuationPrompt.trim());
|
|
1240
|
+
if (isSelfContinuation) return;
|
|
1241
|
+
// Genuine user input → fresh task: reset the chain and stop any auto-resume so the
|
|
1242
|
+
// user is fully back in control.
|
|
1243
|
+
autoContinuesThisPrompt = 0;
|
|
1244
|
+
userAbortedChain = false;
|
|
1245
|
+
lastSentContinuationPrompt = "";
|
|
1246
|
+
if (autoContinueTimer) {
|
|
1247
|
+
clearTimeout(autoContinueTimer);
|
|
1248
|
+
autoContinueTimer = undefined;
|
|
1249
|
+
}
|
|
1250
|
+
if (persistedState.pendingContinuationPrompt) clearPendingContinuation();
|
|
1119
1251
|
});
|
|
1120
1252
|
|
|
1121
1253
|
pi.on("agent_start", async () => {
|
|
1122
1254
|
currentPromptSwitch = undefined;
|
|
1123
|
-
|
|
1255
|
+
expectingSelfContinuation = false; // consume the flag once the run has started
|
|
1124
1256
|
lastErrorText = "";
|
|
1125
1257
|
captureDesiredThinking(); // remember the level BEFORE any failover can clamp it
|
|
1126
1258
|
refreshDiscovery(); // cheap: only re-scans when auth.json changed (new /login)
|
|
@@ -1129,6 +1261,7 @@ export default function piMultiAccount(pi: ExtensionAPI) {
|
|
|
1129
1261
|
pi.on("after_provider_response", async (event, ctx) => {
|
|
1130
1262
|
latestCtx = ctx;
|
|
1131
1263
|
if (!config.enabled) return;
|
|
1264
|
+
if (userAbortedChain || ctx.signal?.aborted) return; // user is cancelling — don't fail over
|
|
1132
1265
|
const status = (event as any).status;
|
|
1133
1266
|
if (status === 401) {
|
|
1134
1267
|
// Authorization is dead → drop this account, then move on.
|
|
@@ -1145,6 +1278,7 @@ export default function piMultiAccount(pi: ExtensionAPI) {
|
|
|
1145
1278
|
latestCtx = ctx;
|
|
1146
1279
|
const message = (event as any).message;
|
|
1147
1280
|
if (message?.role !== "assistant" || message.stopReason !== "error") return;
|
|
1281
|
+
if (userAbortedChain || ctx.signal?.aborted) return; // user is cancelling — don't fail over
|
|
1148
1282
|
const errorText = typeof message.errorMessage === "string" ? message.errorMessage : "";
|
|
1149
1283
|
lastErrorText = errorText;
|
|
1150
1284
|
if (currentPromptSwitch) return;
|
|
@@ -1161,15 +1295,44 @@ export default function piMultiAccount(pi: ExtensionAPI) {
|
|
|
1161
1295
|
pi.on("agent_end", async (event, ctx) => {
|
|
1162
1296
|
latestCtx = ctx;
|
|
1163
1297
|
if (!config.enabled || !config.autoContinue) return;
|
|
1298
|
+
|
|
1299
|
+
// Respect the user: if they pressed Esc, the last assistant message is "aborted".
|
|
1300
|
+
// Stop the failover chain dead and cancel every background timer so nothing
|
|
1301
|
+
// resurrects the task. It only restarts when the user sends a new prompt.
|
|
1302
|
+
if (lastAssistantStopReason((event as any).messages ?? []) === "aborted" || ctx.signal?.aborted) {
|
|
1303
|
+
userAbortedChain = true;
|
|
1304
|
+
if (autoContinueTimer) {
|
|
1305
|
+
clearTimeout(autoContinueTimer);
|
|
1306
|
+
autoContinueTimer = undefined;
|
|
1307
|
+
}
|
|
1308
|
+
clearPendingContinuation();
|
|
1309
|
+
currentPromptSwitch = undefined;
|
|
1310
|
+
lastErrorText = "";
|
|
1311
|
+
return;
|
|
1312
|
+
}
|
|
1313
|
+
if (userAbortedChain) return;
|
|
1314
|
+
|
|
1164
1315
|
const errorText = lastErrorText || getAssistantErrorText((event as any).messages ?? []);
|
|
1165
1316
|
if (isAuthError(errorText) && ctx.model) markInvalid(ctx.model.provider, errorText.slice(0, 60));
|
|
1166
1317
|
if (!isLimitError(errorText) && !isAuthError(errorText)) return;
|
|
1167
|
-
|
|
1318
|
+
|
|
1319
|
+
// Task-level cap. Because this counter is no longer reset by our own re-prompts,
|
|
1320
|
+
// it genuinely bounds the failover loop. When it trips we stop completely (and do
|
|
1321
|
+
// NOT arm a resurrection timer) so the machine can't be driven into a swap spiral.
|
|
1322
|
+
if (autoContinuesThisPrompt >= config.maxAutoContinuesPerPrompt) {
|
|
1323
|
+
ctx.ui.notify(
|
|
1324
|
+
`Provider failover: stopped after ${autoContinuesThisPrompt} auto-continues — every account kept hitting limits. Send a new message, or run /multi-account reset to retry.`,
|
|
1325
|
+
"warning",
|
|
1326
|
+
);
|
|
1327
|
+
return;
|
|
1328
|
+
}
|
|
1168
1329
|
|
|
1169
1330
|
if (!currentPromptSwitch) {
|
|
1170
1331
|
const reason = `agent ended with provider limit: ${errorText.slice(0, 120)}`;
|
|
1171
1332
|
const switched = await switchToFallback(ctx, reason, cooldownFromErrorText(errorText) ?? config.cooldownMs);
|
|
1172
|
-
|
|
1333
|
+
// switchToFallback already arms pending-resume when nothing is available now, so
|
|
1334
|
+
// only set it here if it somehow didn't (defensive; alreadyPending makes it a no-op).
|
|
1335
|
+
if (!switched && !currentPromptSwitch && !persistedState.pendingContinuationPrompt) {
|
|
1173
1336
|
setPendingContinuation(ctx, reason);
|
|
1174
1337
|
return;
|
|
1175
1338
|
}
|
|
@@ -1178,7 +1341,7 @@ export default function piMultiAccount(pi: ExtensionAPI) {
|
|
|
1178
1341
|
if (currentPromptSwitch) {
|
|
1179
1342
|
autoContinuesThisPrompt++;
|
|
1180
1343
|
const prompt = continuationPrompt(currentPromptSwitch);
|
|
1181
|
-
|
|
1344
|
+
scheduleAutoContinue(ctx, prompt); // spaced + Esc-cancellable, not a tight loop
|
|
1182
1345
|
}
|
|
1183
1346
|
});
|
|
1184
1347
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-multi-account",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Automatic multi-account failover & rotation for Pi Agent across Anthropic (Claude), OpenAI/ChatGPT Codex, and Qwen/Alibaba. Auto-discovers authenticated accounts, grows the rotation on login, and drops accounts on logout, expiry, or quota/rate-limit errors.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|