pi-multi-account 1.2.0 → 1.3.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 +23 -0
  2. package/index.ts +62 -109
  3. package/package.json +2 -1
package/CHANGELOG.md CHANGED
@@ -5,6 +5,28 @@ 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.3.0] - 2026-06-10
9
+
10
+ ### Fixed
11
+
12
+ - **Manual model/account selection is now respected.** Picking a model (e.g. Opus
13
+ on another account) no longer gets auto-yanked onto a different provider on the
14
+ next rate limit — the failover stays put and tells you, until you switch with
15
+ `/model` or `/multi-account next`. The pin auto-releases after a successful
16
+ response on that provider.
17
+ - **No more self-resurrecting work.** All background resume timers were removed:
18
+ continuation now happens only synchronously inside an active turn, so Esc and
19
+ quitting always stop it. When every account is rate-limited the failover STOPS
20
+ and asks you to retry, instead of churning between exhausted accounts.
21
+ - **No more "Agent is already processing" / "Cannot continue from message role:
22
+ assistant".** Continuations are sent only when the agent is idle and not aborting.
23
+
24
+ ### Added
25
+
26
+ - Test suite (`npm test`) covering the failover edge cases: limit/401 failover,
27
+ all-accounts-exhausted stop, Esc/abort, manual-selection pinning, idle gating,
28
+ Anthropic OAuth shaping idempotency, and session shutdown. Wired into CI.
29
+
8
30
  ## [1.2.0] - 2026-06-10
9
31
 
10
32
  ### Added
@@ -76,6 +98,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
76
98
  - Plaintext-free credential handling (SHA-256 fingerprints only); `0600`
77
99
  config/state files.
78
100
 
101
+ [1.3.0]: https://github.com/Sarrius/pi-multi-account/releases/tag/v1.3.0
79
102
  [1.2.0]: https://github.com/Sarrius/pi-multi-account/releases/tag/v1.2.0
80
103
  [1.1.0]: https://github.com/Sarrius/pi-multi-account/releases/tag/v1.1.0
81
104
  [1.0.0]: https://github.com/Sarrius/pi-multi-account/releases/tag/v1.0.0
package/index.ts CHANGED
@@ -824,6 +824,11 @@ export default function piMultiAccount(pi: ExtensionAPI) {
824
824
  let autoContinueTimer: ReturnType<typeof setTimeout> | undefined; // pending spaced continuation
825
825
  let lastLeftProvider: string | undefined; // account we just failed away from (anti-ping-pong)
826
826
  let lastLeftAt = 0;
827
+ // When the USER manually picks a model/account, we respect it: auto-failover will not yank
828
+ // them off it (that's the "I selected opus and it flipped to chatgpt" bug). selfModelSwitch
829
+ // marks our OWN setModel calls so the model_select event isn't mistaken for a manual pick.
830
+ let userSelectedProvider: string | undefined;
831
+ let selfModelSwitch = false;
827
832
  // The thinking level the user intended for this turn. pi.setModel() re-clamps and
828
833
  // persists the thinking level on every model switch, so without this it drifts
829
834
  // downward across failovers ("thinking level keeps dropping"). We capture it before
@@ -1099,11 +1104,22 @@ export default function piMultiAccount(pi: ExtensionAPI) {
1099
1104
  return pool.sort((a, b) => a.remaining - b.remaining || a.rotIndex - b.rotIndex).map((s) => s.model);
1100
1105
  }
1101
1106
 
1102
- async function switchToFallback(ctx: any, reason: string, cooldownMs = config.cooldownMs) {
1107
+ async function switchToFallback(ctx: any, reason: string, cooldownMs = config.cooldownMs, manual = false) {
1103
1108
  if (!config.enabled) return false;
1104
1109
  const currentModel = ctx.model;
1105
1110
  if (!currentModel) return false;
1106
1111
 
1112
+ // Respect a manual model choice: if the user just picked this provider, do NOT auto-yank
1113
+ // them onto another one — show the error and let them decide. Manual /multi-account next
1114
+ // bypasses this (manual=true).
1115
+ if (!manual && userSelectedProvider && currentModel.provider === userSelectedProvider) {
1116
+ ctx.ui.notify(
1117
+ `Provider failover: you selected ${currentModel.provider}/${currentModel.id} manually — staying on it (${reason.slice(0, 90)}). Use /model or /multi-account next to switch.`,
1118
+ "warning",
1119
+ );
1120
+ return false;
1121
+ }
1122
+
1107
1123
  markExhausted(currentModel.provider, cooldownMs);
1108
1124
  lastLeftProvider = currentModel.provider;
1109
1125
  lastLeftAt = Date.now();
@@ -1127,7 +1143,9 @@ export default function piMultiAccount(pi: ExtensionAPI) {
1127
1143
  const from = ref(currentModel.provider, currentModel.id);
1128
1144
  for (const fallback of candidates) {
1129
1145
  const to = ref(fallback.provider, fallback.id);
1146
+ selfModelSwitch = true; // our own switch — not a manual user pick
1130
1147
  const ok = await pi.setModel(fallback);
1148
+ selfModelSwitch = false;
1131
1149
  if (!ok) {
1132
1150
  // setModel failed → the account has no usable auth right now.
1133
1151
  ctx.ui.notify(`Provider failover: ${to} has no usable auth, dropping from rotation`, "warning");
@@ -1152,35 +1170,31 @@ export default function piMultiAccount(pi: ExtensionAPI) {
1152
1170
  return config.continuationPrompt.replaceAll("{from}", record.from).replaceAll("{to}", record.to).replaceAll("{reason}", record.reason);
1153
1171
  }
1154
1172
 
1155
- /** Mark that the next agent run is our own failover continuation, then send it. */
1156
- function dispatchSelfContinuation(ctx: any, prompt: string) {
1173
+ /**
1174
+ * Send our failover continuation — but ONLY when it is genuinely safe:
1175
+ * - the user has not aborted (Esc), and the current op isn't aborting, and
1176
+ * - the agent is idle (sending mid-turn throws "Agent is already processing" /
1177
+ * "Cannot continue from message role: assistant").
1178
+ * Returns whether it actually sent. No background timer is ever used, so a turn is
1179
+ * always active for Esc to cancel — Esc/quit therefore always stop the chain.
1180
+ */
1181
+ function dispatchSelfContinuation(ctx: any, prompt: string): boolean {
1182
+ if (userAbortedChain || ctx.signal?.aborted || !ctx.isIdle()) return false;
1157
1183
  lastAutoContinueAt = Date.now();
1158
1184
  lastSentContinuationPrompt = prompt;
1159
1185
  expectingSelfContinuation = true;
1160
- pi.sendUserMessage(prompt, ctx.isIdle() ? undefined : { deliverAs: "followUp" });
1186
+ pi.sendUserMessage(prompt);
1187
+ return true;
1161
1188
  }
1162
1189
 
1163
1190
  /**
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.
1191
+ * Continue after a successful failover switch SYNCHRONOUSLY only.
1192
+ * Deliberately NOT a setTimeout: a deferred timer fires sendUserMessage when there is no
1193
+ * active turn for Esc to cancel, which is exactly how the chain escaped the user's control
1194
+ * and resurrected work on its own. Returns whether it sent.
1167
1195
  */
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");
1196
+ function scheduleAutoContinue(ctx: any, prompt: string): boolean {
1197
+ return dispatchSelfContinuation(ctx, prompt);
1184
1198
  }
1185
1199
 
1186
1200
  function clearPendingContinuation() {
@@ -1192,96 +1206,21 @@ export default function piMultiAccount(pi: ExtensionAPI) {
1192
1206
  persist();
1193
1207
  }
1194
1208
 
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
1209
  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
- };
1210
+ // Every available account is rate-limited or unavailable right now. We deliberately do
1211
+ // NOT arm a background timer to auto-resume later: such a timer fires sendUserMessage with
1212
+ // no active turn for Esc to cancel and resurrects work on its own. Instead we STOP cleanly
1213
+ // and tell the user — they retry by sending a message when an account has recovered.
1214
+ const alreadyStopped = persistedState.pendingReason === reason;
1215
+ persistedState = { ...persistedState, pendingReason: reason, pendingContinuationPrompt: undefined, pendingSince: undefined };
1235
1216
  persist();
1236
- schedulePendingWake(ctx);
1237
- if (alreadyPending) return;
1238
- const delayMs = nextPendingWakeDelayMs();
1217
+ if (alreadyStopped) return;
1239
1218
  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.`,
1219
+ `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
1220
  "warning",
1242
1221
  );
1243
1222
  }
1244
1223
 
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
1224
  // ----- error classification --------------------------------------------
1286
1225
 
1287
1226
  function isAuthError(text: string) {
@@ -1328,6 +1267,8 @@ export default function piMultiAccount(pi: ExtensionAPI) {
1328
1267
  exhaustedUntilByProvider.clear();
1329
1268
  currentPromptSwitch = undefined;
1330
1269
  autoContinuesThisPrompt = 0;
1270
+ userAbortedChain = false;
1271
+ userSelectedProvider = undefined;
1331
1272
  if (pendingWakeTimer) {
1332
1273
  clearTimeout(pendingWakeTimer);
1333
1274
  pendingWakeTimer = undefined;
@@ -1340,7 +1281,8 @@ export default function piMultiAccount(pi: ExtensionAPI) {
1340
1281
  return;
1341
1282
  }
1342
1283
  if (command === "next") {
1343
- await switchToFallback(ctx, "manual /multi-account next", 5 * 60 * 1000);
1284
+ userSelectedProvider = undefined; // explicit request to move drop any manual pin
1285
+ await switchToFallback(ctx, "manual /multi-account next", 5 * 60 * 1000, true);
1344
1286
  return;
1345
1287
  }
1346
1288
  if (command === "enable" || command === "disable") {
@@ -1455,11 +1397,21 @@ export default function piMultiAccount(pi: ExtensionAPI) {
1455
1397
  refreshDiscovery(); // cheap: only re-scans when auth.json changed (new /login)
1456
1398
  });
1457
1399
 
1400
+ // Detect a MANUAL model/account selection by the user (vs our own failover setModel) and
1401
+ // pin it, so auto-failover won't immediately yank them off it.
1402
+ pi.on("model_select", (event) => {
1403
+ if (selfModelSwitch) return; // our own failover switch — not a manual pick
1404
+ const model = (event as any).model;
1405
+ if (model?.provider) userSelectedProvider = model.provider;
1406
+ });
1407
+
1458
1408
  pi.on("after_provider_response", async (event, ctx) => {
1459
1409
  latestCtx = ctx;
1460
1410
  if (!config.enabled) return;
1461
1411
  if (userAbortedChain || ctx.signal?.aborted) return; // user is cancelling — don't fail over
1462
1412
  const status = (event as any).status;
1413
+ // The user's manually-picked model just worked → resume normal auto-failover for it.
1414
+ if (status < 400 && ctx.model && ctx.model.provider === userSelectedProvider) userSelectedProvider = undefined;
1463
1415
  if (status === 401) {
1464
1416
  // Authorization is dead → drop this account, then move on.
1465
1417
  if (ctx.model) markInvalid(ctx.model.provider, `HTTP 401`);
@@ -1536,9 +1488,10 @@ export default function piMultiAccount(pi: ExtensionAPI) {
1536
1488
  }
1537
1489
 
1538
1490
  if (currentPromptSwitch) {
1539
- autoContinuesThisPrompt++;
1540
1491
  const prompt = continuationPrompt(currentPromptSwitch);
1541
- scheduleAutoContinue(ctx, prompt); // spaced + Esc-cancellable, not a tight loop
1492
+ // Continue synchronously and only if it actually sent (agent idle, not aborted).
1493
+ // Count the attempt only when we really sent, so the cap reflects real tries.
1494
+ if (scheduleAutoContinue(ctx, prompt)) autoContinuesThisPrompt++;
1542
1495
  }
1543
1496
  });
1544
1497
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-multi-account",
3
- "version": "1.2.0",
3
+ "version": "1.3.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
  },