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.
- package/CHANGELOG.md +23 -0
- package/index.ts +62 -109
- 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
|
-
/**
|
|
1156
|
-
|
|
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
|
|
1186
|
+
pi.sendUserMessage(prompt);
|
|
1187
|
+
return true;
|
|
1161
1188
|
}
|
|
1162
1189
|
|
|
1163
1190
|
/**
|
|
1164
|
-
*
|
|
1165
|
-
*
|
|
1166
|
-
*
|
|
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
|
-
|
|
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
|
-
//
|
|
1224
|
-
//
|
|
1225
|
-
//
|
|
1226
|
-
|
|
1227
|
-
const
|
|
1228
|
-
|
|
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
|
-
|
|
1237
|
-
if (alreadyPending) return;
|
|
1238
|
-
const delayMs = nextPendingWakeDelayMs();
|
|
1217
|
+
if (alreadyStopped) return;
|
|
1239
1218
|
ctx.ui.notify(
|
|
1240
|
-
`Provider failover:
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
},
|