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.
Files changed (3) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/index.ts +173 -10
  3. 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
- const availableNow = scored.filter((s) => s.remaining === 0).sort((a, b) => a.rotIndex - b.rotIndex);
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
- const candidates = findFallbackModels(ctx, currentModel);
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
- pi.sendUserMessage(prompt, ctx.isIdle() ? undefined : { deliverAs: "followUp" });
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
- schedulePendingWake(ctx);
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
- autoContinuesThisPrompt = 0;
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
- if (autoContinuesThisPrompt >= config.maxAutoContinuesPerPrompt) return;
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
- if (!switched && !currentPromptSwitch) {
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
- pi.sendUserMessage(prompt, ctx.isIdle() ? undefined : { deliverAs: "followUp" });
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.0.0",
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",