pi-multi-account 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/index.ts +137 -115
  3. package/package.json +2 -1
package/CHANGELOG.md CHANGED
@@ -5,6 +5,46 @@ 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.4.0] - 2026-06-10
9
+
10
+ ### Fixed
11
+
12
+ - **A single 401 no longer drops an account that still has valid tokens.** A 401 on
13
+ an OAuth account usually just means the access token needs a refresh (Pi refreshes
14
+ on the next call). Previously the first 401 permanently invalidated the account
15
+ (≈1-year cooldown until re-login) and yanked you onto another — often broken —
16
+ account. Now a refreshable account is given a brief cooldown and retried; it is
17
+ only marked dead after 3 consecutive 401s with no success in between. A
18
+ non-refreshable (API-key) 401 is still treated as immediately fatal.
19
+ - Any successful response clears that account's 401 streak.
20
+
21
+ ### Added
22
+
23
+ - Tests for transient-401 tolerance, the consecutive-401 kill threshold, and
24
+ success-resets-streak (suite now 17 tests).
25
+
26
+ ## [1.3.0] - 2026-06-10
27
+
28
+ ### Fixed
29
+
30
+ - **Manual model/account selection is now respected.** Picking a model (e.g. Opus
31
+ on another account) no longer gets auto-yanked onto a different provider on the
32
+ next rate limit — the failover stays put and tells you, until you switch with
33
+ `/model` or `/multi-account next`. The pin auto-releases after a successful
34
+ response on that provider.
35
+ - **No more self-resurrecting work.** All background resume timers were removed:
36
+ continuation now happens only synchronously inside an active turn, so Esc and
37
+ quitting always stop it. When every account is rate-limited the failover STOPS
38
+ and asks you to retry, instead of churning between exhausted accounts.
39
+ - **No more "Agent is already processing" / "Cannot continue from message role:
40
+ assistant".** Continuations are sent only when the agent is idle and not aborting.
41
+
42
+ ### Added
43
+
44
+ - Test suite (`npm test`) covering the failover edge cases: limit/401 failover,
45
+ all-accounts-exhausted stop, Esc/abort, manual-selection pinning, idle gating,
46
+ Anthropic OAuth shaping idempotency, and session shutdown. Wired into CI.
47
+
8
48
  ## [1.2.0] - 2026-06-10
9
49
 
10
50
  ### Added
@@ -76,6 +116,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
76
116
  - Plaintext-free credential handling (SHA-256 fingerprints only); `0600`
77
117
  config/state files.
78
118
 
119
+ [1.3.0]: https://github.com/Sarrius/pi-multi-account/releases/tag/v1.3.0
79
120
  [1.2.0]: https://github.com/Sarrius/pi-multi-account/releases/tag/v1.2.0
80
121
  [1.1.0]: https://github.com/Sarrius/pi-multi-account/releases/tag/v1.1.0
81
122
  [1.0.0]: https://github.com/Sarrius/pi-multi-account/releases/tag/v1.0.0
package/index.ts CHANGED
@@ -113,6 +113,16 @@ const DEFAULT_INVALID_COOLDOWN_MS = 365 * 24 * 60 * 60 * 1000; // effectively "u
113
113
  // until the machine swaps itself to death.
114
114
  const ANTI_PINGPONG_MS = 60 * 1000; // don't switch straight back to the account we just left
115
115
  const MIN_AUTOCONTINUE_INTERVAL_MS = 15 * 1000; // floor between auto-continuations (CPU/network guard)
116
+ // A 401 on an OAuth account usually just means "access token expired, refresh it" — Pi refreshes
117
+ // on the next call. So a single 401 must NOT permanently kill a refreshable account (that was the
118
+ // "it dropped me off an account that still had tokens" bug). Only kill it after this many 401s in a
119
+ // row with no successful response in between. Non-refreshable (API-key) 401 is fatal immediately.
120
+ // Bumped on every release. Printed at startup and in `/multi-account status` so you can verify
121
+ // which version Pi actually loaded (a running Pi keeps the version it started with — /login and
122
+ // /reload do NOT reload extension code; only a full restart does).
123
+ const VERSION = "1.4.0";
124
+ const MAX_CONSECUTIVE_AUTH_FAILURES = 3;
125
+ const TRANSIENT_AUTH_COOLDOWN_MS = 60 * 1000; // brief skip after a 401 so the next call can refresh
116
126
 
117
127
  const ANTHROPIC_BASE = "anthropic";
118
128
  const CODEX_BASE = "openai-codex";
@@ -800,6 +810,9 @@ export default function piMultiAccount(pi: ExtensionAPI) {
800
810
 
801
811
  const exhaustedUntilByProvider = new Map<string, number>(Object.entries(persistedState.exhaustedUntilByProvider ?? {}));
802
812
  const invalidatedByProvider = new Map<string, InvalidationRecord>(Object.entries(persistedState.invalidatedByProvider ?? {}));
813
+ // Consecutive 401s per provider (in-memory, reset on any success). Used so a transient 401 on a
814
+ // refreshable OAuth account doesn't permanently kill it.
815
+ const consecutiveAuthFailures = new Map<string, number>();
803
816
 
804
817
  // Discovered, authed, deduped provider ids in rotation order.
805
818
  let rotation: string[] = [];
@@ -824,6 +837,11 @@ export default function piMultiAccount(pi: ExtensionAPI) {
824
837
  let autoContinueTimer: ReturnType<typeof setTimeout> | undefined; // pending spaced continuation
825
838
  let lastLeftProvider: string | undefined; // account we just failed away from (anti-ping-pong)
826
839
  let lastLeftAt = 0;
840
+ // When the USER manually picks a model/account, we respect it: auto-failover will not yank
841
+ // them off it (that's the "I selected opus and it flipped to chatgpt" bug). selfModelSwitch
842
+ // marks our OWN setModel calls so the model_select event isn't mistaken for a manual pick.
843
+ let userSelectedProvider: string | undefined;
844
+ let selfModelSwitch = false;
827
845
  // The thinking level the user intended for this turn. pi.setModel() re-clamps and
828
846
  // persists the thinking level on every model switch, so without this it drifts
829
847
  // downward across failovers ("thinking level keeps dropping"). We capture it before
@@ -897,6 +915,43 @@ export default function piMultiAccount(pi: ExtensionAPI) {
897
915
  return invalidatedByProvider.has(provider);
898
916
  }
899
917
 
918
+ /** True if this account can self-heal a 401 by refreshing its OAuth token. */
919
+ function isRefreshable(provider: string): boolean {
920
+ const entry = readAuthFile()[provider];
921
+ return !!entry && typeof entry.refresh === "string" && entry.refresh.length > 0;
922
+ }
923
+
924
+ /** A successful response → this account's auth is fine; clear its 401 streak. */
925
+ function noteAuthSuccess(provider: string) {
926
+ if (consecutiveAuthFailures.delete(provider)) {
927
+ /* had a streak, now cleared */
928
+ }
929
+ }
930
+
931
+ /**
932
+ * Handle a 401/auth failure WITHOUT nuking an account that just needs a token refresh.
933
+ * Refreshable (OAuth) account: first failures → short cooldown only, so the next attempt can
934
+ * refresh; only after MAX_CONSECUTIVE_AUTH_FAILURES in a row do we mark it dead-until-relogin.
935
+ * Non-refreshable (API key): a 401 is genuinely fatal, mark invalid at once.
936
+ * Returns true if the account was permanently invalidated.
937
+ */
938
+ function markAuthFailure(provider: string, reason: string): boolean {
939
+ if (!isRefreshable(provider)) {
940
+ markInvalid(provider, reason);
941
+ return true;
942
+ }
943
+ const n = (consecutiveAuthFailures.get(provider) ?? 0) + 1;
944
+ consecutiveAuthFailures.set(provider, n);
945
+ if (n >= MAX_CONSECUTIVE_AUTH_FAILURES) {
946
+ consecutiveAuthFailures.delete(provider);
947
+ markInvalid(provider, `${reason} (after ${n} consecutive 401s)`);
948
+ return true;
949
+ }
950
+ // Transient: brief cooldown so selection skips it for a moment; Pi refreshes on next use.
951
+ markExhausted(provider, TRANSIENT_AUTH_COOLDOWN_MS);
952
+ return false;
953
+ }
954
+
900
955
  // ----- cooldowns --------------------------------------------------------
901
956
 
902
957
  function pruneCooldowns() {
@@ -1099,11 +1154,22 @@ export default function piMultiAccount(pi: ExtensionAPI) {
1099
1154
  return pool.sort((a, b) => a.remaining - b.remaining || a.rotIndex - b.rotIndex).map((s) => s.model);
1100
1155
  }
1101
1156
 
1102
- async function switchToFallback(ctx: any, reason: string, cooldownMs = config.cooldownMs) {
1157
+ async function switchToFallback(ctx: any, reason: string, cooldownMs = config.cooldownMs, manual = false) {
1103
1158
  if (!config.enabled) return false;
1104
1159
  const currentModel = ctx.model;
1105
1160
  if (!currentModel) return false;
1106
1161
 
1162
+ // Respect a manual model choice: if the user just picked this provider, do NOT auto-yank
1163
+ // them onto another one — show the error and let them decide. Manual /multi-account next
1164
+ // bypasses this (manual=true).
1165
+ if (!manual && userSelectedProvider && currentModel.provider === userSelectedProvider) {
1166
+ ctx.ui.notify(
1167
+ `Provider failover: you selected ${currentModel.provider}/${currentModel.id} manually — staying on it (${reason.slice(0, 90)}). Use /model or /multi-account next to switch.`,
1168
+ "warning",
1169
+ );
1170
+ return false;
1171
+ }
1172
+
1107
1173
  markExhausted(currentModel.provider, cooldownMs);
1108
1174
  lastLeftProvider = currentModel.provider;
1109
1175
  lastLeftAt = Date.now();
@@ -1127,7 +1193,9 @@ export default function piMultiAccount(pi: ExtensionAPI) {
1127
1193
  const from = ref(currentModel.provider, currentModel.id);
1128
1194
  for (const fallback of candidates) {
1129
1195
  const to = ref(fallback.provider, fallback.id);
1196
+ selfModelSwitch = true; // our own switch — not a manual user pick
1130
1197
  const ok = await pi.setModel(fallback);
1198
+ selfModelSwitch = false;
1131
1199
  if (!ok) {
1132
1200
  // setModel failed → the account has no usable auth right now.
1133
1201
  ctx.ui.notify(`Provider failover: ${to} has no usable auth, dropping from rotation`, "warning");
@@ -1152,35 +1220,31 @@ export default function piMultiAccount(pi: ExtensionAPI) {
1152
1220
  return config.continuationPrompt.replaceAll("{from}", record.from).replaceAll("{to}", record.to).replaceAll("{reason}", record.reason);
1153
1221
  }
1154
1222
 
1155
- /** Mark that the next agent run is our own failover continuation, then send it. */
1156
- function dispatchSelfContinuation(ctx: any, prompt: string) {
1223
+ /**
1224
+ * Send our failover continuation — but ONLY when it is genuinely safe:
1225
+ * - the user has not aborted (Esc), and the current op isn't aborting, and
1226
+ * - the agent is idle (sending mid-turn throws "Agent is already processing" /
1227
+ * "Cannot continue from message role: assistant").
1228
+ * Returns whether it actually sent. No background timer is ever used, so a turn is
1229
+ * always active for Esc to cancel — Esc/quit therefore always stop the chain.
1230
+ */
1231
+ function dispatchSelfContinuation(ctx: any, prompt: string): boolean {
1232
+ if (userAbortedChain || ctx.signal?.aborted || !ctx.isIdle()) return false;
1157
1233
  lastAutoContinueAt = Date.now();
1158
1234
  lastSentContinuationPrompt = prompt;
1159
1235
  expectingSelfContinuation = true;
1160
- pi.sendUserMessage(prompt, ctx.isIdle() ? undefined : { deliverAs: "followUp" });
1236
+ pi.sendUserMessage(prompt);
1237
+ return true;
1161
1238
  }
1162
1239
 
1163
1240
  /**
1164
- * Send an auto-continuation, but never faster than MIN_AUTOCONTINUE_INTERVAL_MS.
1165
- * The spacing keeps a fully rate-limited rotation from pegging CPU/network and gives
1166
- * the user a real window in which Esc actually sticks.
1241
+ * Continue after a successful failover switch SYNCHRONOUSLY only.
1242
+ * Deliberately NOT a setTimeout: a deferred timer fires sendUserMessage when there is no
1243
+ * active turn for Esc to cancel, which is exactly how the chain escaped the user's control
1244
+ * and resurrected work on its own. Returns whether it sent.
1167
1245
  */
1168
- function scheduleAutoContinue(ctx: any, prompt: string) {
1169
- if (autoContinueTimer) {
1170
- clearTimeout(autoContinueTimer);
1171
- autoContinueTimer = undefined;
1172
- }
1173
- const wait = Math.max(0, MIN_AUTOCONTINUE_INTERVAL_MS - (Date.now() - lastAutoContinueAt));
1174
- if (wait === 0) {
1175
- dispatchSelfContinuation(ctx, prompt);
1176
- return;
1177
- }
1178
- autoContinueTimer = setTimeout(() => {
1179
- autoContinueTimer = undefined;
1180
- if (userAbortedChain || ctx.signal?.aborted) return; // user took over while we waited
1181
- dispatchSelfContinuation(ctx, prompt);
1182
- }, wait);
1183
- ctx.ui.notify(`Provider failover: next auto-continue in ~${Math.ceil(wait / 1000)}s (press Esc to cancel).`, "info");
1246
+ function scheduleAutoContinue(ctx: any, prompt: string): boolean {
1247
+ return dispatchSelfContinuation(ctx, prompt);
1184
1248
  }
1185
1249
 
1186
1250
  function clearPendingContinuation() {
@@ -1192,96 +1256,21 @@ export default function piMultiAccount(pi: ExtensionAPI) {
1192
1256
  persist();
1193
1257
  }
1194
1258
 
1195
- function nextPendingWakeDelayMs() {
1196
- if (!persistedState.pendingContinuationPrompt) return undefined;
1197
- const now = Date.now();
1198
- const lastProbe = lastProbeMap();
1199
- let bestWakeAt = Number.POSITIVE_INFINITY;
1200
- for (const provider of configuredProviders()) {
1201
- if (isInvalidated(provider)) continue;
1202
- const exhaustedUntil = exhaustedUntilByProvider.get(provider) ?? 0;
1203
- if (exhaustedUntil <= now) return 1000;
1204
- const probeDueAt = (lastProbe[provider] ?? 0) + config.probeCooldownMs;
1205
- bestWakeAt = Math.min(bestWakeAt, exhaustedUntil, probeDueAt);
1206
- }
1207
- if (!Number.isFinite(bestWakeAt)) return config.probeCooldownMs;
1208
- return Math.max(1000, Math.min(bestWakeAt - now, 2_147_483_647));
1209
- }
1210
-
1211
- function schedulePendingWake(ctx?: any) {
1212
- if (ctx) latestCtx = ctx;
1213
- if (pendingWakeTimer) clearTimeout(pendingWakeTimer);
1214
- const delayMs = nextPendingWakeDelayMs();
1215
- if (delayMs === undefined) return;
1216
- pendingWakeTimer = setTimeout(() => {
1217
- pendingWakeTimer = undefined;
1218
- void attemptPendingResume();
1219
- }, delayMs);
1220
- }
1221
-
1222
1259
  function setPendingContinuation(ctx: any, reason: string) {
1223
- // Don't re-arm or re-notify if a pending resume is already queued — switchToFallback
1224
- // and agent_end can both reach here for the same exhaustion, and the wake timer is
1225
- // already running.
1226
- const alreadyPending = !!persistedState.pendingContinuationPrompt;
1227
- const current = ctx.model ? ref(ctx.model.provider, ctx.model.id) : ("unknown/model" as ModelRef);
1228
- const record: SwitchRecord = { from: current, to: "next-available/account" as ModelRef, reason, at: Date.now() };
1229
- persistedState = {
1230
- ...persistedState,
1231
- pendingContinuationPrompt: persistedState.pendingContinuationPrompt || continuationPrompt(record),
1232
- pendingSince: persistedState.pendingSince || Date.now(),
1233
- pendingReason: reason,
1234
- };
1260
+ // Every available account is rate-limited or unavailable right now. We deliberately do
1261
+ // NOT arm a background timer to auto-resume later: such a timer fires sendUserMessage with
1262
+ // no active turn for Esc to cancel and resurrects work on its own. Instead we STOP cleanly
1263
+ // and tell the user — they retry by sending a message when an account has recovered.
1264
+ const alreadyStopped = persistedState.pendingReason === reason;
1265
+ persistedState = { ...persistedState, pendingReason: reason, pendingContinuationPrompt: undefined, pendingSince: undefined };
1235
1266
  persist();
1236
- schedulePendingWake(ctx);
1237
- if (alreadyPending) return;
1238
- const delayMs = nextPendingWakeDelayMs();
1267
+ if (alreadyStopped) return;
1239
1268
  ctx.ui.notify(
1240
- `Provider failover: all accounts appear exhausted. Will automatically probe/resume in ~${Math.ceil((delayMs ?? config.probeCooldownMs) / 1000)}s if this Pi session stays open.`,
1269
+ `Provider failover: every account is rate-limited or unavailable right now stopped here. Send a message to retry once one recovers (check /multi-account status).`,
1241
1270
  "warning",
1242
1271
  );
1243
1272
  }
1244
1273
 
1245
- async function attemptPendingResume() {
1246
- const ctx = latestCtx;
1247
- const prompt = persistedState.pendingContinuationPrompt;
1248
- if (!ctx || !prompt || !config.enabled || !config.autoContinue) return;
1249
- if (userAbortedChain) {
1250
- clearPendingContinuation(); // user took over — abandon the background resurrection
1251
- return;
1252
- }
1253
- if (autoContinuesThisPrompt >= config.maxAutoContinuesPerPrompt) {
1254
- clearPendingContinuation(); // task-level cap reached — stop resurrecting
1255
- return;
1256
- }
1257
- refreshDiscovery();
1258
- pruneCooldowns();
1259
- const candidates = findFallbackModels(ctx, ctx.model);
1260
- if (candidates.length === 0) {
1261
- schedulePendingWake(ctx);
1262
- return;
1263
- }
1264
- for (const candidate of candidates) {
1265
- const to = ref(candidate.provider, candidate.id);
1266
- const ok = await pi.setModel(candidate);
1267
- if (!ok) {
1268
- markInvalid(candidate.provider, "setModel failed on resume");
1269
- continue;
1270
- }
1271
- restoreDesiredThinking(); // keep the user's thinking level across the switch
1272
- setLastProbe(candidate.provider);
1273
- clearPendingContinuation();
1274
- // A genuine recovery after a real wait earns a fresh continuation budget so the
1275
- // agent can keep going whenever an account recovers; rapid flapping (resume that
1276
- // immediately re-limits) does NOT reset, so the cap still bounds a tight loop.
1277
- if (Date.now() - lastAutoContinueAt >= config.probeCooldownMs) autoContinuesThisPrompt = 0;
1278
- ctx.ui.notify(`Provider failover: resuming pending work on ${to}`, "warning");
1279
- dispatchSelfContinuation(ctx, prompt);
1280
- return;
1281
- }
1282
- schedulePendingWake(ctx);
1283
- }
1284
-
1285
1274
  // ----- error classification --------------------------------------------
1286
1275
 
1287
1276
  function isAuthError(text: string) {
@@ -1328,6 +1317,9 @@ export default function piMultiAccount(pi: ExtensionAPI) {
1328
1317
  exhaustedUntilByProvider.clear();
1329
1318
  currentPromptSwitch = undefined;
1330
1319
  autoContinuesThisPrompt = 0;
1320
+ userAbortedChain = false;
1321
+ userSelectedProvider = undefined;
1322
+ consecutiveAuthFailures.clear();
1331
1323
  if (pendingWakeTimer) {
1332
1324
  clearTimeout(pendingWakeTimer);
1333
1325
  pendingWakeTimer = undefined;
@@ -1340,7 +1332,8 @@ export default function piMultiAccount(pi: ExtensionAPI) {
1340
1332
  return;
1341
1333
  }
1342
1334
  if (command === "next") {
1343
- await switchToFallback(ctx, "manual /multi-account next", 5 * 60 * 1000);
1335
+ userSelectedProvider = undefined; // explicit request to move drop any manual pin
1336
+ await switchToFallback(ctx, "manual /multi-account next", 5 * 60 * 1000, true);
1344
1337
  return;
1345
1338
  }
1346
1339
  if (command === "enable" || command === "disable") {
@@ -1357,7 +1350,7 @@ export default function piMultiAccount(pi: ExtensionAPI) {
1357
1350
  const invalids = [...invalidatedByProvider.entries()].map(([p, r]) => `${p} (${r.reason.slice(0, 40)})`);
1358
1351
  ctx.ui.notify(
1359
1352
  [
1360
- `pi-multi-account: ${config.enabled ? "enabled" : "disabled"}${config.autoDiscover ? " · auto-discover ON" : " · auto-discover OFF"}`,
1353
+ `pi-multi-account v${VERSION}: ${config.enabled ? "enabled" : "disabled"}${config.autoDiscover ? " · auto-discover ON" : " · auto-discover OFF"}`,
1361
1354
  `Current: ${current}`,
1362
1355
  `Rotation (${rotation.length}): ${rotation.join(" → ") || "none — log in to an account"}`,
1363
1356
  `Registered login slots: ${[...registeredSlots].join(", ") || "(base accounts only)"}`,
@@ -1402,7 +1395,7 @@ export default function piMultiAccount(pi: ExtensionAPI) {
1402
1395
  expectingSelfContinuation = false;
1403
1396
  lastSentContinuationPrompt = "";
1404
1397
  ctx.ui.notify(
1405
- `pi-multi-account loaded (${config.enabled ? "enabled" : "disabled"}). ${rotation.length} account(s) in rotation. Config: ${CONFIG_PATH}`,
1398
+ `pi-multi-account v${VERSION} loaded (${config.enabled ? "enabled" : "disabled"}). ${rotation.length} account(s) in rotation. Config: ${CONFIG_PATH}`,
1406
1399
  "info",
1407
1400
  );
1408
1401
  });
@@ -1455,14 +1448,36 @@ export default function piMultiAccount(pi: ExtensionAPI) {
1455
1448
  refreshDiscovery(); // cheap: only re-scans when auth.json changed (new /login)
1456
1449
  });
1457
1450
 
1451
+ // Detect a MANUAL model/account selection by the user (vs our own failover setModel) and
1452
+ // pin it, so auto-failover won't immediately yank them off it.
1453
+ pi.on("model_select", (event) => {
1454
+ if (selfModelSwitch) return; // our own failover switch — not a manual pick
1455
+ const model = (event as any).model;
1456
+ if (model?.provider) userSelectedProvider = model.provider;
1457
+ });
1458
+
1458
1459
  pi.on("after_provider_response", async (event, ctx) => {
1459
1460
  latestCtx = ctx;
1460
1461
  if (!config.enabled) return;
1461
1462
  if (userAbortedChain || ctx.signal?.aborted) return; // user is cancelling — don't fail over
1462
1463
  const status = (event as any).status;
1464
+ // A successful response proves this account's auth is fine → clear its 401 streak, and
1465
+ // release a manual pin so normal auto-failover resumes for the user's chosen model.
1466
+ if (status < 400 && ctx.model) {
1467
+ noteAuthSuccess(ctx.model.provider);
1468
+ if (ctx.model.provider === userSelectedProvider) userSelectedProvider = undefined;
1469
+ }
1463
1470
  if (status === 401) {
1464
- // Authorization is dead drop this account, then move on.
1465
- if (ctx.model) markInvalid(ctx.model.provider, `HTTP 401`);
1471
+ // A 401 on an OAuth account usually just needs a token refresh — do NOT kill it on the
1472
+ // first one. markAuthFailure only invalidates after repeated 401s; otherwise it's a
1473
+ // brief cooldown so Pi can refresh and we retry the SAME account, not abandon it.
1474
+ if (ctx.model) {
1475
+ const killed = markAuthFailure(ctx.model.provider, "HTTP 401");
1476
+ if (!killed) {
1477
+ ctx.ui.notify(`Provider failover: ${ctx.model.provider} got a 401 — will refresh and retry (not dropping it).`, "info");
1478
+ return; // give the same account a chance to refresh on the next call
1479
+ }
1480
+ }
1466
1481
  await switchToFallback(ctx, "HTTP 401 (auth invalid)");
1467
1482
  return;
1468
1483
  }
@@ -1480,7 +1495,13 @@ export default function piMultiAccount(pi: ExtensionAPI) {
1480
1495
  lastErrorText = errorText;
1481
1496
  if (currentPromptSwitch) return;
1482
1497
  if (isAuthError(errorText)) {
1483
- if (ctx.model) markInvalid(ctx.model.provider, errorText.slice(0, 60));
1498
+ if (ctx.model) {
1499
+ const killed = markAuthFailure(ctx.model.provider, errorText.slice(0, 60));
1500
+ if (!killed) {
1501
+ ctx.ui.notify(`Provider failover: ${ctx.model.provider} hit a transient auth error — will refresh and retry (not dropping it).`, "info");
1502
+ return; // refreshable account: let it refresh and retry rather than abandoning it
1503
+ }
1504
+ }
1484
1505
  await switchToFallback(ctx, `auth invalid: ${errorText.slice(0, 100)}`);
1485
1506
  return;
1486
1507
  }
@@ -1510,7 +1531,7 @@ export default function piMultiAccount(pi: ExtensionAPI) {
1510
1531
  if (userAbortedChain) return;
1511
1532
 
1512
1533
  const errorText = lastErrorText || getAssistantErrorText((event as any).messages ?? []);
1513
- if (isAuthError(errorText) && ctx.model) markInvalid(ctx.model.provider, errorText.slice(0, 60));
1534
+ if (isAuthError(errorText) && ctx.model) markAuthFailure(ctx.model.provider, errorText.slice(0, 60));
1514
1535
  if (!isLimitError(errorText) && !isAuthError(errorText)) return;
1515
1536
 
1516
1537
  // Task-level cap. Because this counter is no longer reset by our own re-prompts,
@@ -1536,9 +1557,10 @@ export default function piMultiAccount(pi: ExtensionAPI) {
1536
1557
  }
1537
1558
 
1538
1559
  if (currentPromptSwitch) {
1539
- autoContinuesThisPrompt++;
1540
1560
  const prompt = continuationPrompt(currentPromptSwitch);
1541
- scheduleAutoContinue(ctx, prompt); // spaced + Esc-cancellable, not a tight loop
1561
+ // Continue synchronously and only if it actually sent (agent idle, not aborted).
1562
+ // Count the attempt only when we really sent, so the cap reflects real tries.
1563
+ if (scheduleAutoContinue(ctx, prompt)) autoContinuesThisPrompt++;
1542
1564
  }
1543
1565
  });
1544
1566
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-multi-account",
3
- "version": "1.2.0",
3
+ "version": "1.4.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",
@@ -42,6 +42,7 @@
42
42
  ],
43
43
  "scripts": {
44
44
  "check": "tsc --noEmit",
45
+ "test": "node --test test/*.test.ts",
45
46
  "pack:check": "npm pack --dry-run",
46
47
  "prepublishOnly": "npm run check"
47
48
  },