pi-multi-account 1.1.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 (4) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +5 -1
  3. package/index.ts +263 -113
  4. package/package.json +2 -1
package/CHANGELOG.md CHANGED
@@ -5,6 +5,49 @@ 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
+
30
+ ## [1.2.0] - 2026-06-10
31
+
32
+ ### Added
33
+
34
+ - **Anthropic (Claude Pro/Max) OAuth now works out of the box.** OAuth login is
35
+ enabled on the base `anthropic` provider and on every `anthropic-account-*`
36
+ alias, and outgoing Anthropic OAuth requests are shaped (billing header +
37
+ system-prompt normalization) directly by this package. A separate
38
+ `pi-anthropic-auth` install is no longer required.
39
+
40
+ ### Changed
41
+
42
+ - Request shaping is idempotent and only touches OAuth-marked Anthropic requests,
43
+ so it coexists safely with `pi-anthropic-auth` if both are installed, and leaves
44
+ API-key Anthropic and OpenAI Codex / Qwen requests untouched.
45
+
46
+ ### Credits
47
+
48
+ - Anthropic OAuth request-shaping logic vendored from
49
+ [`gotgenes/pi-anthropic-auth`](https://github.com/gotgenes/pi-anthropic-auth) (MIT).
50
+
8
51
  ## [1.1.0] - 2026-06-10
9
52
 
10
53
  ### Fixed
@@ -55,5 +98,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
55
98
  - Plaintext-free credential handling (SHA-256 fingerprints only); `0600`
56
99
  config/state files.
57
100
 
101
+ [1.3.0]: https://github.com/Sarrius/pi-multi-account/releases/tag/v1.3.0
102
+ [1.2.0]: https://github.com/Sarrius/pi-multi-account/releases/tag/v1.2.0
58
103
  [1.1.0]: https://github.com/Sarrius/pi-multi-account/releases/tag/v1.1.0
59
104
  [1.0.0]: https://github.com/Sarrius/pi-multi-account/releases/tag/v1.0.0
package/README.md CHANGED
@@ -21,7 +21,11 @@ pi install npm:pi-multi-account
21
21
 
22
22
  Restart Pi or run `/reload` after installation.
23
23
 
24
- > **Anthropic OAuth aliases** (`anthropic-account-2`, …) require [`@gotgenes/pi-anthropic-auth`](https://www.npmjs.com/package/@gotgenes/pi-anthropic-auth) for request shaping. The base `anthropic` provider and all OpenAI Codex / Qwen accounts work without it.
24
+ > **Anthropic (Claude Pro/Max) works out of the box.** OAuth login and request
25
+ > shaping for the base `anthropic` provider and every `anthropic-account-*` alias
26
+ > are built in — no separate `pi-anthropic-auth` install is required. If you
27
+ > already have `pi-anthropic-auth`, the two coexist safely (the shaping is
28
+ > idempotent). OpenAI Codex / ChatGPT and Qwen accounts work as well.
25
29
 
26
30
  ### Recommended setting
27
31
 
package/index.ts CHANGED
@@ -15,9 +15,11 @@
15
15
  * transparently switches to the next available account/model, optionally
16
16
  * queuing a safe continuation prompt.
17
17
  *
18
- * Anthropic OAuth aliases require `@gotgenes/pi-anthropic-auth` for request
19
- * shaping (its before_provider_request hook is provider-agnostic and covers
20
- * every Anthropic OAuth alias this package registers).
18
+ * Anthropic OAuth (Claude Pro/Max) works out of the box: this package enables
19
+ * OAuth login on the base `anthropic` provider and on every `anthropic-account-*`
20
+ * alias, and shapes the outgoing requests itself (billing header + system-prompt
21
+ * normalization, vendored from gotgenes/pi-anthropic-auth, MIT). No separate
22
+ * pi-anthropic-auth install is needed; if you have one, both coexist (idempotent).
21
23
  *
22
24
  * Config: ~/.pi/agent/provider-failover.json
23
25
  * State: ~/.pi/agent/provider-failover-state.json
@@ -455,7 +457,7 @@ function codexModelDef(id: string) {
455
457
  }
456
458
 
457
459
  function registerAnthropicSlot(pi: ExtensionAPI, id: string) {
458
- if (id === ANTHROPIC_BASE) return; // base provider is native / shaped by pi-anthropic-auth
460
+ if (id === ANTHROPIC_BASE) return; // base provider: oauth + shaping registered in piMultiAccount()
459
461
  const models = DEFAULT_ANTHROPIC_MODELS.map((m) => anthropicModelDef(m, id));
460
462
  pi.registerProvider(id, {
461
463
  name: `Claude Pro/Max (${id})`,
@@ -601,6 +603,193 @@ function getAssistantErrorText(messages: any[]) {
601
603
  return "";
602
604
  }
603
605
 
606
+ // ===========================================================================
607
+ // Anthropic OAuth request shaping (vendored)
608
+ //
609
+ // Makes Claude Pro/Max (OAuth) accounts work out of the box — no separate
610
+ // pi-anthropic-auth install required. Ported from gotgenes/pi-anthropic-auth
611
+ // (MIT). The logic is idempotent: if pi-anthropic-auth is ALSO installed, both
612
+ // before_provider_request hooks run, but the second sees the request already
613
+ // shaped (billing header present, Pi preamble already replaced) and no-ops.
614
+ //
615
+ // CLAUDE_CODE_VERSION must track the current Claude Code release; if it drifts
616
+ // too far Anthropic may reject or miscount OAuth requests. Check `claude
617
+ // --version` or https://github.com/anthropics/claude-code.
618
+ // ===========================================================================
619
+
620
+ const PI_DEFAULT_PROMPT_PREFIX = "You are an expert coding assistant operating inside pi, a coding agent harness.";
621
+ const PI_DEFAULT_PROMPT_TERMINATOR =
622
+ "- Always read pi .md files completely and follow links to related docs (e.g., tui.md for TUI API details)";
623
+ const MINIMAL_ANTHROPIC_OAUTH_PROMPT_PREFIX = "You are an expert coding assistant.";
624
+ const MINIMAL_ANTHROPIC_OAUTH_PROMPT = [
625
+ MINIMAL_ANTHROPIC_OAUTH_PROMPT_PREFIX,
626
+ "Be concise and helpful.",
627
+ "Use the available tools to answer the user's request.",
628
+ "Show file paths clearly when working with files.",
629
+ ].join("\n");
630
+ const CLAUDE_CODE_IDENTITY_PREFIX = "You are Claude Code, Anthropic's official CLI";
631
+ const CLAUDE_CODE_VERSION = "2.1.150";
632
+ const BILLING_HEADER_SALT = "59cf53e54c78";
633
+ const BILLING_HEADER_POSITIONS = [4, 7, 20] as const;
634
+ const CLAUDE_CODE_ENTRYPOINT = "sdk-cli";
635
+ const PARAGRAPH_REMOVAL_ANCHORS: readonly string[] = [
636
+ "operating inside pi, a coding agent harness",
637
+ "In addition to the tools above",
638
+ "Pi documentation (read only when the user asks about pi itself",
639
+ ];
640
+ const TEXT_REPLACEMENTS: readonly { match: string; replacement: string }[] = [
641
+ {
642
+ match: "Here is some useful information about the environment you are running in:",
643
+ replacement: "Environment context you are running in:",
644
+ },
645
+ ];
646
+
647
+ type ShapeTextBlock = { type: "text"; text: string; [key: string]: unknown };
648
+ type ShapeMessageBlock = { type?: string; text?: string; [key: string]: unknown };
649
+ type ShapeMessageParam = { role?: string; content?: string | ShapeMessageBlock[]; [key: string]: unknown };
650
+ type ShapeAnthropicPayload = { model?: unknown; messages?: unknown; system?: unknown; stream?: unknown; [key: string]: unknown };
651
+
652
+ function isRecord(value: unknown): value is Record<string, unknown> {
653
+ return value !== null && typeof value === "object" && !Array.isArray(value);
654
+ }
655
+
656
+ function isAnthropicMessagesPayload(payload: unknown): payload is ShapeAnthropicPayload {
657
+ return isRecord(payload) && typeof payload.model === "string" && Array.isArray(payload.messages) && typeof payload.stream === "boolean";
658
+ }
659
+
660
+ function hasOAuthAnthropicSystemMarker(block: unknown): boolean {
661
+ if (!isRecord(block) || block.type !== "text" || typeof block.text !== "string") return false;
662
+ return (
663
+ block.text.includes(CLAUDE_CODE_IDENTITY_PREFIX) ||
664
+ block.text.includes("x-anthropic-billing-header:") ||
665
+ block.text.startsWith(MINIMAL_ANTHROPIC_OAUTH_PROMPT_PREFIX)
666
+ );
667
+ }
668
+
669
+ // Only requests that Pi already marked as OAuth (Claude Code identity block, or
670
+ // already-shaped) are touched — API-key Anthropic requests pass through untouched.
671
+ function isOAuthAnthropicPayload(payload: ShapeAnthropicPayload): boolean {
672
+ if (!Array.isArray(payload.system)) return false;
673
+ return payload.system.some(hasOAuthAnthropicSystemMarker);
674
+ }
675
+
676
+ function getFirstUserText(messages: ShapeMessageParam[]): string {
677
+ const firstUserMessage = messages.find((message) => message.role === "user");
678
+ if (!firstUserMessage) return "";
679
+ if (typeof firstUserMessage.content === "string") return firstUserMessage.content;
680
+ if (!Array.isArray(firstUserMessage.content)) return "";
681
+ const firstTextBlock = firstUserMessage.content.find((block) => block.type === "text" && typeof block.text === "string");
682
+ return typeof firstTextBlock?.text === "string" ? firstTextBlock.text : "";
683
+ }
684
+
685
+ function buildBillingHeaderValue(messages: ShapeMessageParam[]): string | undefined {
686
+ const messageText = getFirstUserText(messages);
687
+ if (!messageText) return undefined;
688
+ const cch = createHash("sha256").update(messageText).digest("hex").slice(0, 5);
689
+ const sampledCharacters = BILLING_HEADER_POSITIONS.map((index) => messageText[index] || "0").join("");
690
+ const suffix = createHash("sha256")
691
+ .update(`${BILLING_HEADER_SALT}${sampledCharacters}${CLAUDE_CODE_VERSION}`)
692
+ .digest("hex")
693
+ .slice(0, 3);
694
+ return ["x-anthropic-billing-header:", `cc_version=${CLAUDE_CODE_VERSION}.${suffix};`, `cc_entrypoint=${CLAUDE_CODE_ENTRYPOINT};`, `cch=${cch};`].join(" ");
695
+ }
696
+
697
+ function normalizeSystemBlock(block: unknown): ShapeTextBlock {
698
+ if (typeof block === "string") return { type: "text", text: block };
699
+ if (isRecord(block) && typeof block.text === "string") return { ...block, type: "text", text: block.text };
700
+ return { type: "text", text: "" };
701
+ }
702
+
703
+ function prependBillingHeader(system: unknown, messages: ShapeMessageParam[]): unknown {
704
+ const billingHeader = buildBillingHeaderValue(messages);
705
+ if (!billingHeader) return system;
706
+ const systemBlocks = Array.isArray(system) ? system.map(normalizeSystemBlock) : system == null ? [] : [normalizeSystemBlock(system)];
707
+ // Idempotent: don't add a second billing header (e.g. pi-anthropic-auth also ran).
708
+ if (systemBlocks.some((block) => block.text.includes("x-anthropic-billing-header:"))) return systemBlocks;
709
+ const billingBlock: ShapeTextBlock = { type: "text", text: billingHeader };
710
+ return [billingBlock, ...systemBlocks];
711
+ }
712
+
713
+ // The Anthropic API rejects assistant turns where non-tool_use blocks follow a
714
+ // tool_use block; Pi's serializer can produce that, so split into two turns.
715
+ function splitAssistantToolUseTrailingContent(messages: ShapeMessageParam[]): ShapeMessageParam[] {
716
+ return messages.flatMap((message) => {
717
+ if (message.role !== "assistant" || !Array.isArray(message.content)) return [message];
718
+ const firstToolUseIndex = message.content.findIndex((block) => block.type === "tool_use");
719
+ if (firstToolUseIndex === -1) return [message];
720
+ const trailingBlocks = message.content.slice(firstToolUseIndex);
721
+ if (!trailingBlocks.some((block) => block.type !== "tool_use")) return [message];
722
+ const nonToolUseBlocks = message.content.filter((block) => block.type !== "tool_use");
723
+ const toolUseBlocks = message.content.filter((block) => block.type === "tool_use");
724
+ return [
725
+ { ...message, content: nonToolUseBlocks },
726
+ { ...message, content: toolUseBlocks },
727
+ ];
728
+ });
729
+ }
730
+
731
+ function sanitizeSystemText(text: string): string {
732
+ const paragraphs = text.split(/\n\n+/);
733
+ const filtered = paragraphs.filter((paragraph) => !PARAGRAPH_REMOVAL_ANCHORS.some((anchor) => paragraph.includes(anchor)));
734
+ let result = filtered.join("\n\n");
735
+ for (const rule of TEXT_REPLACEMENTS) result = result.replaceAll(rule.match, rule.replacement);
736
+ return result.trim();
737
+ }
738
+
739
+ function findProjectContextStart(systemPrompt: string): number {
740
+ return systemPrompt.indexOf("\n\n# Project Context\n\n");
741
+ }
742
+
743
+ function shapeAnthropicOAuthSystemPrompt(systemPrompt: string): string {
744
+ const prefixIdx = systemPrompt.indexOf(PI_DEFAULT_PROMPT_PREFIX);
745
+ if (prefixIdx === -1) return systemPrompt;
746
+ const terminatorIdx = systemPrompt.indexOf(PI_DEFAULT_PROMPT_TERMINATOR, prefixIdx);
747
+ if (terminatorIdx !== -1) {
748
+ const terminatorEnd = terminatorIdx + PI_DEFAULT_PROMPT_TERMINATOR.length;
749
+ const preamble = systemPrompt.slice(prefixIdx, terminatorEnd);
750
+ const sanitized = sanitizeSystemText(preamble);
751
+ const shapedPreamble = sanitized ? `${MINIMAL_ANTHROPIC_OAUTH_PROMPT}\n\n${sanitized}` : MINIMAL_ANTHROPIC_OAUTH_PROMPT;
752
+ return systemPrompt.slice(0, prefixIdx) + shapedPreamble + systemPrompt.slice(terminatorEnd);
753
+ }
754
+ // Pi reworded its preamble terminator → fall back to slicing from project context.
755
+ const projectContextStart = findProjectContextStart(systemPrompt);
756
+ if (projectContextStart === -1) return MINIMAL_ANTHROPIC_OAUTH_PROMPT;
757
+ return `${MINIMAL_ANTHROPIC_OAUTH_PROMPT}${systemPrompt.slice(projectContextStart)}`;
758
+ }
759
+
760
+ function shapeSystemBlocks(blocks: ShapeTextBlock[]): ShapeTextBlock[] {
761
+ return blocks.map((block) => {
762
+ if (block.type !== "text" || !block.text.includes(PI_DEFAULT_PROMPT_PREFIX)) return block;
763
+ return { ...block, text: shapeAnthropicOAuthSystemPrompt(block.text) };
764
+ });
765
+ }
766
+
767
+ /** before_provider_request shaper: makes Claude Pro/Max OAuth requests acceptable. */
768
+ function shapeAnthropicOAuthPayload(payload: unknown): unknown {
769
+ if (!isAnthropicMessagesPayload(payload)) return payload;
770
+ const messages = payload.messages as ShapeMessageParam[];
771
+ if (!isOAuthAnthropicPayload(payload)) return payload; // API-key / non-OAuth → untouched
772
+ const normalizedMessages = splitAssistantToolUseTrailingContent(messages);
773
+ const shapedSystem = Array.isArray(payload.system) ? shapeSystemBlocks(payload.system as ShapeTextBlock[]) : payload.system;
774
+ const finalSystem = prependBillingHeader(shapedSystem, normalizedMessages);
775
+ return { ...payload, messages: normalizedMessages, system: finalSystem };
776
+ }
777
+
778
+ /** OAuth override enabling Claude Pro/Max login on a provider (base or alias). */
779
+ const anthropicOAuthOverride = {
780
+ name: "Anthropic (Claude Pro/Max)",
781
+ login: (callbacks: any) => loginAnthropic(callbacks),
782
+ async refreshToken(credentials: any) {
783
+ const refreshed = await refreshAnthropicToken(credentials.refresh);
784
+ return {
785
+ ...credentials,
786
+ ...refreshed,
787
+ refresh: typeof refreshed.refresh === "string" && refreshed.refresh.trim().length > 0 ? refreshed.refresh : credentials.refresh,
788
+ };
789
+ },
790
+ getApiKey: (credentials: any) => credentials.access,
791
+ };
792
+
604
793
  // ===========================================================================
605
794
  // Extension entry point
606
795
  // ===========================================================================
@@ -635,6 +824,11 @@ export default function piMultiAccount(pi: ExtensionAPI) {
635
824
  let autoContinueTimer: ReturnType<typeof setTimeout> | undefined; // pending spaced continuation
636
825
  let lastLeftProvider: string | undefined; // account we just failed away from (anti-ping-pong)
637
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;
638
832
  // The thinking level the user intended for this turn. pi.setModel() re-clamps and
639
833
  // persists the thinking level on every model switch, so without this it drifts
640
834
  // downward across failovers ("thinking level keeps dropping"). We capture it before
@@ -910,11 +1104,22 @@ export default function piMultiAccount(pi: ExtensionAPI) {
910
1104
  return pool.sort((a, b) => a.remaining - b.remaining || a.rotIndex - b.rotIndex).map((s) => s.model);
911
1105
  }
912
1106
 
913
- async function switchToFallback(ctx: any, reason: string, cooldownMs = config.cooldownMs) {
1107
+ async function switchToFallback(ctx: any, reason: string, cooldownMs = config.cooldownMs, manual = false) {
914
1108
  if (!config.enabled) return false;
915
1109
  const currentModel = ctx.model;
916
1110
  if (!currentModel) return false;
917
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
+
918
1123
  markExhausted(currentModel.provider, cooldownMs);
919
1124
  lastLeftProvider = currentModel.provider;
920
1125
  lastLeftAt = Date.now();
@@ -938,7 +1143,9 @@ export default function piMultiAccount(pi: ExtensionAPI) {
938
1143
  const from = ref(currentModel.provider, currentModel.id);
939
1144
  for (const fallback of candidates) {
940
1145
  const to = ref(fallback.provider, fallback.id);
1146
+ selfModelSwitch = true; // our own switch — not a manual user pick
941
1147
  const ok = await pi.setModel(fallback);
1148
+ selfModelSwitch = false;
942
1149
  if (!ok) {
943
1150
  // setModel failed → the account has no usable auth right now.
944
1151
  ctx.ui.notify(`Provider failover: ${to} has no usable auth, dropping from rotation`, "warning");
@@ -963,35 +1170,31 @@ export default function piMultiAccount(pi: ExtensionAPI) {
963
1170
  return config.continuationPrompt.replaceAll("{from}", record.from).replaceAll("{to}", record.to).replaceAll("{reason}", record.reason);
964
1171
  }
965
1172
 
966
- /** Mark that the next agent run is our own failover continuation, then send it. */
967
- 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;
968
1183
  lastAutoContinueAt = Date.now();
969
1184
  lastSentContinuationPrompt = prompt;
970
1185
  expectingSelfContinuation = true;
971
- pi.sendUserMessage(prompt, ctx.isIdle() ? undefined : { deliverAs: "followUp" });
1186
+ pi.sendUserMessage(prompt);
1187
+ return true;
972
1188
  }
973
1189
 
974
1190
  /**
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.
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.
978
1195
  */
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");
1196
+ function scheduleAutoContinue(ctx: any, prompt: string): boolean {
1197
+ return dispatchSelfContinuation(ctx, prompt);
995
1198
  }
996
1199
 
997
1200
  function clearPendingContinuation() {
@@ -1003,96 +1206,21 @@ export default function piMultiAccount(pi: ExtensionAPI) {
1003
1206
  persist();
1004
1207
  }
1005
1208
 
1006
- function nextPendingWakeDelayMs() {
1007
- if (!persistedState.pendingContinuationPrompt) return undefined;
1008
- const now = Date.now();
1009
- const lastProbe = lastProbeMap();
1010
- let bestWakeAt = Number.POSITIVE_INFINITY;
1011
- for (const provider of configuredProviders()) {
1012
- if (isInvalidated(provider)) continue;
1013
- const exhaustedUntil = exhaustedUntilByProvider.get(provider) ?? 0;
1014
- if (exhaustedUntil <= now) return 1000;
1015
- const probeDueAt = (lastProbe[provider] ?? 0) + config.probeCooldownMs;
1016
- bestWakeAt = Math.min(bestWakeAt, exhaustedUntil, probeDueAt);
1017
- }
1018
- if (!Number.isFinite(bestWakeAt)) return config.probeCooldownMs;
1019
- return Math.max(1000, Math.min(bestWakeAt - now, 2_147_483_647));
1020
- }
1021
-
1022
- function schedulePendingWake(ctx?: any) {
1023
- if (ctx) latestCtx = ctx;
1024
- if (pendingWakeTimer) clearTimeout(pendingWakeTimer);
1025
- const delayMs = nextPendingWakeDelayMs();
1026
- if (delayMs === undefined) return;
1027
- pendingWakeTimer = setTimeout(() => {
1028
- pendingWakeTimer = undefined;
1029
- void attemptPendingResume();
1030
- }, delayMs);
1031
- }
1032
-
1033
1209
  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;
1038
- const current = ctx.model ? ref(ctx.model.provider, ctx.model.id) : ("unknown/model" as ModelRef);
1039
- const record: SwitchRecord = { from: current, to: "next-available/account" as ModelRef, reason, at: Date.now() };
1040
- persistedState = {
1041
- ...persistedState,
1042
- pendingContinuationPrompt: persistedState.pendingContinuationPrompt || continuationPrompt(record),
1043
- pendingSince: persistedState.pendingSince || Date.now(),
1044
- pendingReason: reason,
1045
- };
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 };
1046
1216
  persist();
1047
- schedulePendingWake(ctx);
1048
- if (alreadyPending) return;
1049
- const delayMs = nextPendingWakeDelayMs();
1217
+ if (alreadyStopped) return;
1050
1218
  ctx.ui.notify(
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.`,
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).`,
1052
1220
  "warning",
1053
1221
  );
1054
1222
  }
1055
1223
 
1056
- async function attemptPendingResume() {
1057
- const ctx = latestCtx;
1058
- const prompt = persistedState.pendingContinuationPrompt;
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
- }
1068
- refreshDiscovery();
1069
- pruneCooldowns();
1070
- const candidates = findFallbackModels(ctx, ctx.model);
1071
- if (candidates.length === 0) {
1072
- schedulePendingWake(ctx);
1073
- return;
1074
- }
1075
- for (const candidate of candidates) {
1076
- const to = ref(candidate.provider, candidate.id);
1077
- const ok = await pi.setModel(candidate);
1078
- if (!ok) {
1079
- markInvalid(candidate.provider, "setModel failed on resume");
1080
- continue;
1081
- }
1082
- restoreDesiredThinking(); // keep the user's thinking level across the switch
1083
- setLastProbe(candidate.provider);
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;
1089
- ctx.ui.notify(`Provider failover: resuming pending work on ${to}`, "warning");
1090
- dispatchSelfContinuation(ctx, prompt);
1091
- return;
1092
- }
1093
- schedulePendingWake(ctx);
1094
- }
1095
-
1096
1224
  // ----- error classification --------------------------------------------
1097
1225
 
1098
1226
  function isAuthError(text: string) {
@@ -1139,6 +1267,8 @@ export default function piMultiAccount(pi: ExtensionAPI) {
1139
1267
  exhaustedUntilByProvider.clear();
1140
1268
  currentPromptSwitch = undefined;
1141
1269
  autoContinuesThisPrompt = 0;
1270
+ userAbortedChain = false;
1271
+ userSelectedProvider = undefined;
1142
1272
  if (pendingWakeTimer) {
1143
1273
  clearTimeout(pendingWakeTimer);
1144
1274
  pendingWakeTimer = undefined;
@@ -1151,7 +1281,8 @@ export default function piMultiAccount(pi: ExtensionAPI) {
1151
1281
  return;
1152
1282
  }
1153
1283
  if (command === "next") {
1154
- 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);
1155
1286
  return;
1156
1287
  }
1157
1288
  if (command === "enable" || command === "disable") {
@@ -1186,6 +1317,14 @@ export default function piMultiAccount(pi: ExtensionAPI) {
1186
1317
  pi.registerCommand(name, { description: "Manage automatic multi-account failover & rotation", handler: handleCommand });
1187
1318
  }
1188
1319
 
1320
+ // ----- Anthropic OAuth out of the box -----------------------------------
1321
+ // Enable Claude Pro/Max OAuth login on the base `anthropic` provider and shape
1322
+ // every Anthropic OAuth request so subscription tokens are accepted — without
1323
+ // requiring a separate pi-anthropic-auth install. Idempotent, so it coexists
1324
+ // safely if pi-anthropic-auth is also present.
1325
+ pi.registerProvider("anthropic", { oauth: anthropicOAuthOverride } as any);
1326
+ pi.on("before_provider_request", (event: any) => shapeAnthropicOAuthPayload(event.payload));
1327
+
1189
1328
  // ----- lifecycle hooks --------------------------------------------------
1190
1329
 
1191
1330
  refreshDiscovery(true);
@@ -1258,11 +1397,21 @@ export default function piMultiAccount(pi: ExtensionAPI) {
1258
1397
  refreshDiscovery(); // cheap: only re-scans when auth.json changed (new /login)
1259
1398
  });
1260
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
+
1261
1408
  pi.on("after_provider_response", async (event, ctx) => {
1262
1409
  latestCtx = ctx;
1263
1410
  if (!config.enabled) return;
1264
1411
  if (userAbortedChain || ctx.signal?.aborted) return; // user is cancelling — don't fail over
1265
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;
1266
1415
  if (status === 401) {
1267
1416
  // Authorization is dead → drop this account, then move on.
1268
1417
  if (ctx.model) markInvalid(ctx.model.provider, `HTTP 401`);
@@ -1339,9 +1488,10 @@ export default function piMultiAccount(pi: ExtensionAPI) {
1339
1488
  }
1340
1489
 
1341
1490
  if (currentPromptSwitch) {
1342
- autoContinuesThisPrompt++;
1343
1491
  const prompt = continuationPrompt(currentPromptSwitch);
1344
- 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++;
1345
1495
  }
1346
1496
  });
1347
1497
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-multi-account",
3
- "version": "1.1.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
  },