pi-multi-account 1.0.0 → 1.2.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 CHANGED
@@ -5,6 +5,58 @@ 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.2.0] - 2026-06-10
9
+
10
+ ### Added
11
+
12
+ - **Anthropic (Claude Pro/Max) OAuth now works out of the box.** OAuth login is
13
+ enabled on the base `anthropic` provider and on every `anthropic-account-*`
14
+ alias, and outgoing Anthropic OAuth requests are shaped (billing header +
15
+ system-prompt normalization) directly by this package. A separate
16
+ `pi-anthropic-auth` install is no longer required.
17
+
18
+ ### Changed
19
+
20
+ - Request shaping is idempotent and only touches OAuth-marked Anthropic requests,
21
+ so it coexists safely with `pi-anthropic-auth` if both are installed, and leaves
22
+ API-key Anthropic and OpenAI Codex / Qwen requests untouched.
23
+
24
+ ### Credits
25
+
26
+ - Anthropic OAuth request-shaping logic vendored from
27
+ [`gotgenes/pi-anthropic-auth`](https://github.com/gotgenes/pi-anthropic-auth) (MIT).
28
+
29
+ ## [1.1.0] - 2026-06-10
30
+
31
+ ### Fixed
32
+
33
+ - **Runaway failover loop that could freeze the machine.** When every account was
34
+ rate-limited the rotation ping-ponged between accounts every 1–9s indefinitely,
35
+ growing session history until the system swapped itself to death. The
36
+ auto-continue counter was reset on every agent start, so `maxAutoContinuesPerPrompt`
37
+ never actually bounded the loop. The counter is now reset only by a genuine new
38
+ user prompt, making the cap a real per-task limit.
39
+ - **Escape did not stop the loop.** Auto-continuation ran from background event
40
+ hooks and a timer, so cancelling the agent was immediately undone. User aborts
41
+ (`stopReason: "aborted"` / `ctx.signal`) now stop the chain and cancel all timers.
42
+
43
+ ### Added
44
+
45
+ - Anti-ping-pong guard: immediate failover only switches to an account usable right
46
+ now and never bounces straight back to the account it just left within 60s.
47
+ - Minimum 15s spacing between auto-continuations (no tight CPU/network loop, and a
48
+ real window for Esc to take effect).
49
+ - In-session auto-resume: when the whole fallback circle is exhausted, the extension
50
+ waits and continues the agent's work as soon as any account recovers — for as long
51
+ as the session stays open.
52
+
53
+ ### Changed
54
+
55
+ - **Tight session binding.** Background activity is now scoped to the live session:
56
+ ending or replacing a session (quit, reload, new, resume, fork) cancels all timers
57
+ and drops any pending resume. A new session starts clean and never inherits a
58
+ previous session's paused work; nothing survives once Pi exits.
59
+
8
60
  ## [1.0.0] - 2026-06-09
9
61
 
10
62
  ### Added
@@ -24,4 +76,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
24
76
  - Plaintext-free credential handling (SHA-256 fingerprints only); `0600`
25
77
  config/state files.
26
78
 
79
+ [1.2.0]: https://github.com/Sarrius/pi-multi-account/releases/tag/v1.2.0
80
+ [1.1.0]: https://github.com/Sarrius/pi-multi-account/releases/tag/v1.1.0
27
81
  [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
@@ -106,6 +108,11 @@ const STATE_VERSION = 3;
106
108
  const DEFAULT_COOLDOWN_MS = 6 * 60 * 60 * 1000;
107
109
  const DEFAULT_PROBE_COOLDOWN_MS = 5 * 60 * 1000;
108
110
  const DEFAULT_INVALID_COOLDOWN_MS = 365 * 24 * 60 * 60 * 1000; // effectively "until re-login"
111
+ // Runaway-loop guards (added). Without these, when every account is rate-limited the
112
+ // failover bounces between accounts every 1-9s forever, growing the session history
113
+ // until the machine swaps itself to death.
114
+ const ANTI_PINGPONG_MS = 60 * 1000; // don't switch straight back to the account we just left
115
+ const MIN_AUTOCONTINUE_INTERVAL_MS = 15 * 1000; // floor between auto-continuations (CPU/network guard)
109
116
 
110
117
  const ANTHROPIC_BASE = "anthropic";
111
118
  const CODEX_BASE = "openai-codex";
@@ -450,7 +457,7 @@ function codexModelDef(id: string) {
450
457
  }
451
458
 
452
459
  function registerAnthropicSlot(pi: ExtensionAPI, id: string) {
453
- 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()
454
461
  const models = DEFAULT_ANTHROPIC_MODELS.map((m) => anthropicModelDef(m, id));
455
462
  pi.registerProvider(id, {
456
463
  name: `Claude Pro/Max (${id})`,
@@ -573,6 +580,14 @@ function formatUntil(timestamp: number) {
573
580
  return rest ? `${hours}h ${rest}m` : `${hours}h`;
574
581
  }
575
582
 
583
+ /** stopReason of the most recent assistant message — "aborted" means the user pressed Esc. */
584
+ function lastAssistantStopReason(messages: any[]): string | undefined {
585
+ for (let i = messages.length - 1; i >= 0; i--) {
586
+ if (messages[i]?.role === "assistant") return messages[i]?.stopReason as string | undefined;
587
+ }
588
+ return undefined;
589
+ }
590
+
576
591
  function getAssistantErrorText(messages: any[]) {
577
592
  for (let i = messages.length - 1; i >= 0; i--) {
578
593
  const message = messages[i];
@@ -588,6 +603,193 @@ function getAssistantErrorText(messages: any[]) {
588
603
  return "";
589
604
  }
590
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
+
591
793
  // ===========================================================================
592
794
  // Extension entry point
593
795
  // ===========================================================================
@@ -606,10 +808,22 @@ export default function piMultiAccount(pi: ExtensionAPI) {
606
808
  let lastAuthMtime = -1;
607
809
 
608
810
  let currentPromptSwitch: SwitchRecord | undefined;
811
+ // Number of auto-continuations issued for the CURRENT task. Crucially this is NOT
812
+ // reset by the self-triggered re-prompts failover issues (only by a genuine new user
813
+ // prompt — see before_agent_start), so config.maxAutoContinuesPerPrompt actually bounds
814
+ // the failover loop instead of resetting to 0 on every iteration.
609
815
  let autoContinuesThisPrompt = 0;
610
816
  let lastErrorText = "";
611
817
  let latestCtx: any | undefined;
612
818
  let pendingWakeTimer: ReturnType<typeof setTimeout> | undefined;
819
+ // --- runaway-loop & user-interrupt guards (added) ---
820
+ let expectingSelfContinuation = false; // true between our sendUserMessage and its agent_start
821
+ let lastSentContinuationPrompt = ""; // secondary check to recognise our own re-prompt
822
+ let userAbortedChain = false; // user pressed Esc → stop auto-continuing until a new prompt
823
+ let lastAutoContinueAt = 0; // for minimum spacing between auto-continuations
824
+ let autoContinueTimer: ReturnType<typeof setTimeout> | undefined; // pending spaced continuation
825
+ let lastLeftProvider: string | undefined; // account we just failed away from (anti-ping-pong)
826
+ let lastLeftAt = 0;
613
827
  // The thinking level the user intended for this turn. pi.setModel() re-clamps and
614
828
  // persists the thinking level on every model switch, so without this it drifts
615
829
  // downward across failovers ("thinking level keeps dropping"). We capture it before
@@ -836,7 +1050,7 @@ export default function piMultiAccount(pi: ExtensionAPI) {
836
1050
  * cooldown first — i.e. the account that will recover soonest — honoring the
837
1051
  * per-provider probe interval so we don't hammer a still-limited account.
838
1052
  */
839
- function findFallbackModels(ctx: any, currentModel: any) {
1053
+ function findFallbackModels(ctx: any, currentModel: any, options: { availableNowOnly?: boolean } = {}) {
840
1054
  const fallbacks = activeFallbacks();
841
1055
  if (fallbacks.length === 0) return [];
842
1056
 
@@ -865,10 +1079,21 @@ export default function piMultiAccount(pi: ExtensionAPI) {
865
1079
 
866
1080
  // (1) Anything available right now → soonest-recovered wins (all remaining=0),
867
1081
  // deterministic rotation-order tiebreak.
868
- const availableNow = scored.filter((s) => s.remaining === 0).sort((a, b) => a.rotIndex - b.rotIndex);
1082
+ let availableNow = scored.filter((s) => s.remaining === 0).sort((a, b) => a.rotIndex - b.rotIndex);
1083
+ // Anti-ping-pong: don't bounce straight back to the account we just left if any
1084
+ // other account is also free right now — that's the loop that freezes the machine.
1085
+ if (lastLeftProvider && now - lastLeftAt < ANTI_PINGPONG_MS && availableNow.length > 1) {
1086
+ availableNow = availableNow.filter((s) => s.model.provider !== lastLeftProvider);
1087
+ }
869
1088
  if (availableNow.length > 0) return availableNow.map((s) => s.model);
870
1089
 
1090
+ // Immediate failover must NEVER switch into a still-exhausted account: that account
1091
+ // would re-fail at once and the rotation would ping-pong forever. When nothing is
1092
+ // available right now, the caller falls back to the delayed pending-resume path.
1093
+ if (options.availableNowOnly) return [];
1094
+
871
1095
  // (2) All exhausted → closest-to-recovery first (shortest remaining cooldown).
1096
+ // Only reached by the pending-resume probe, which is rate-limited per provider.
872
1097
  const probeable = scored.filter((s) => s.probeReady);
873
1098
  const pool = probeable.length > 0 ? probeable : scored;
874
1099
  return pool.sort((a, b) => a.remaining - b.remaining || a.rotIndex - b.rotIndex).map((s) => s.model);
@@ -880,16 +1105,22 @@ export default function piMultiAccount(pi: ExtensionAPI) {
880
1105
  if (!currentModel) return false;
881
1106
 
882
1107
  markExhausted(currentModel.provider, cooldownMs);
883
- const candidates = findFallbackModels(ctx, currentModel);
1108
+ lastLeftProvider = currentModel.provider;
1109
+ lastLeftAt = Date.now();
1110
+ // Immediate failover only ever switches to an account that is usable RIGHT NOW. If
1111
+ // none is, we don't bounce into an exhausted one — we arm the delayed pending-resume
1112
+ // path, which probes accounts as their cooldowns expire.
1113
+ const candidates = findFallbackModels(ctx, currentModel, { availableNowOnly: true });
884
1114
  if (candidates.length === 0) {
885
1115
  const cooldowns = [...exhaustedUntilByProvider.entries()]
886
1116
  .filter(([, until]) => until > Date.now())
887
1117
  .map(([c, until]) => `${c}: ${formatUntil(until)}`)
888
1118
  .join(", ");
889
1119
  ctx.ui.notify(
890
- `Provider failover: no available fallback after ${currentModel.provider}/${currentModel.id}. ${cooldowns ? `Cooldowns: ${cooldowns}` : "All known accounts may be unauthenticated, invalidated, or same account."}`,
1120
+ `Provider failover: no immediately available fallback after ${currentModel.provider}/${currentModel.id}. ${cooldowns ? `Cooldowns: ${cooldowns}` : "All known accounts may be unauthenticated, invalidated, or same account."}`,
891
1121
  "warning",
892
1122
  );
1123
+ setPendingContinuation(ctx, reason); // wait for an account to recover, then resume
893
1124
  return false;
894
1125
  }
895
1126
 
@@ -921,6 +1152,37 @@ export default function piMultiAccount(pi: ExtensionAPI) {
921
1152
  return config.continuationPrompt.replaceAll("{from}", record.from).replaceAll("{to}", record.to).replaceAll("{reason}", record.reason);
922
1153
  }
923
1154
 
1155
+ /** Mark that the next agent run is our own failover continuation, then send it. */
1156
+ function dispatchSelfContinuation(ctx: any, prompt: string) {
1157
+ lastAutoContinueAt = Date.now();
1158
+ lastSentContinuationPrompt = prompt;
1159
+ expectingSelfContinuation = true;
1160
+ pi.sendUserMessage(prompt, ctx.isIdle() ? undefined : { deliverAs: "followUp" });
1161
+ }
1162
+
1163
+ /**
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.
1167
+ */
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");
1184
+ }
1185
+
924
1186
  function clearPendingContinuation() {
925
1187
  if (pendingWakeTimer) {
926
1188
  clearTimeout(pendingWakeTimer);
@@ -958,6 +1220,10 @@ export default function piMultiAccount(pi: ExtensionAPI) {
958
1220
  }
959
1221
 
960
1222
  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;
961
1227
  const current = ctx.model ? ref(ctx.model.provider, ctx.model.id) : ("unknown/model" as ModelRef);
962
1228
  const record: SwitchRecord = { from: current, to: "next-available/account" as ModelRef, reason, at: Date.now() };
963
1229
  persistedState = {
@@ -968,6 +1234,7 @@ export default function piMultiAccount(pi: ExtensionAPI) {
968
1234
  };
969
1235
  persist();
970
1236
  schedulePendingWake(ctx);
1237
+ if (alreadyPending) return;
971
1238
  const delayMs = nextPendingWakeDelayMs();
972
1239
  ctx.ui.notify(
973
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.`,
@@ -979,6 +1246,14 @@ export default function piMultiAccount(pi: ExtensionAPI) {
979
1246
  const ctx = latestCtx;
980
1247
  const prompt = persistedState.pendingContinuationPrompt;
981
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
+ }
982
1257
  refreshDiscovery();
983
1258
  pruneCooldowns();
984
1259
  const candidates = findFallbackModels(ctx, ctx.model);
@@ -996,8 +1271,12 @@ export default function piMultiAccount(pi: ExtensionAPI) {
996
1271
  restoreDesiredThinking(); // keep the user's thinking level across the switch
997
1272
  setLastProbe(candidate.provider);
998
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;
999
1278
  ctx.ui.notify(`Provider failover: resuming pending work on ${to}`, "warning");
1000
- pi.sendUserMessage(prompt, ctx.isIdle() ? undefined : { deliverAs: "followUp" });
1279
+ dispatchSelfContinuation(ctx, prompt);
1001
1280
  return;
1002
1281
  }
1003
1282
  schedulePendingWake(ctx);
@@ -1096,6 +1375,14 @@ export default function piMultiAccount(pi: ExtensionAPI) {
1096
1375
  pi.registerCommand(name, { description: "Manage automatic multi-account failover & rotation", handler: handleCommand });
1097
1376
  }
1098
1377
 
1378
+ // ----- Anthropic OAuth out of the box -----------------------------------
1379
+ // Enable Claude Pro/Max OAuth login on the base `anthropic` provider and shape
1380
+ // every Anthropic OAuth request so subscription tokens are accepted — without
1381
+ // requiring a separate pi-anthropic-auth install. Idempotent, so it coexists
1382
+ // safely if pi-anthropic-auth is also present.
1383
+ pi.registerProvider("anthropic", { oauth: anthropicOAuthOverride } as any);
1384
+ pi.on("before_provider_request", (event: any) => shapeAnthropicOAuthPayload(event.payload));
1385
+
1099
1386
  // ----- lifecycle hooks --------------------------------------------------
1100
1387
 
1101
1388
  refreshDiscovery(true);
@@ -1104,23 +1391,65 @@ export default function piMultiAccount(pi: ExtensionAPI) {
1104
1391
  latestCtx = ctx;
1105
1392
  refreshDiscovery(true);
1106
1393
  pruneCooldowns();
1107
- schedulePendingWake(ctx);
1394
+ // Tight session binding: every session starts as a clean slate. Auto-resume only ever
1395
+ // runs *inside the live session that hit the limit* (its timer is armed by
1396
+ // setPendingContinuation). A new session — or a reopened one after a crash — must NEVER
1397
+ // inherit and silently restart a previous session's paused work, so we drop any leftover
1398
+ // pending state and reset all in-memory guards here.
1399
+ if (persistedState.pendingContinuationPrompt) clearPendingContinuation();
1400
+ autoContinuesThisPrompt = 0;
1401
+ userAbortedChain = false;
1402
+ expectingSelfContinuation = false;
1403
+ lastSentContinuationPrompt = "";
1108
1404
  ctx.ui.notify(
1109
1405
  `pi-multi-account loaded (${config.enabled ? "enabled" : "disabled"}). ${rotation.length} account(s) in rotation. Config: ${CONFIG_PATH}`,
1110
1406
  "info",
1111
1407
  );
1112
1408
  });
1113
1409
 
1410
+ // CRITICAL: when the current session ends — for ANY reason (quit, reload, or replacement
1411
+ // by a new/resumed/forked session) — the extension's background activity must end with it.
1412
+ // Kill every timer and drop the pending continuation so nothing survives the session.
1114
1413
  pi.on("session_shutdown", async () => {
1115
1414
  if (pendingWakeTimer) {
1116
1415
  clearTimeout(pendingWakeTimer);
1117
1416
  pendingWakeTimer = undefined;
1118
1417
  }
1418
+ if (autoContinueTimer) {
1419
+ clearTimeout(autoContinueTimer);
1420
+ autoContinueTimer = undefined;
1421
+ }
1422
+ clearPendingContinuation();
1423
+ userAbortedChain = false;
1424
+ expectingSelfContinuation = false;
1425
+ autoContinuesThisPrompt = 0;
1426
+ lastSentContinuationPrompt = "";
1427
+ });
1428
+
1429
+ // Distinguish a genuine new user prompt from our own failover continuation. Only a
1430
+ // genuine prompt resets the per-task auto-continue counter and cancels any pending
1431
+ // resurrection — this is what stops maxAutoContinuesPerPrompt from resetting every
1432
+ // iteration (the bug that let the failover loop run forever).
1433
+ pi.on("before_agent_start", async (event) => {
1434
+ const prompt = typeof (event as any).prompt === "string" ? (event as any).prompt : "";
1435
+ const isSelfContinuation =
1436
+ expectingSelfContinuation || (!!lastSentContinuationPrompt && prompt.trim() === lastSentContinuationPrompt.trim());
1437
+ if (isSelfContinuation) return;
1438
+ // Genuine user input → fresh task: reset the chain and stop any auto-resume so the
1439
+ // user is fully back in control.
1440
+ autoContinuesThisPrompt = 0;
1441
+ userAbortedChain = false;
1442
+ lastSentContinuationPrompt = "";
1443
+ if (autoContinueTimer) {
1444
+ clearTimeout(autoContinueTimer);
1445
+ autoContinueTimer = undefined;
1446
+ }
1447
+ if (persistedState.pendingContinuationPrompt) clearPendingContinuation();
1119
1448
  });
1120
1449
 
1121
1450
  pi.on("agent_start", async () => {
1122
1451
  currentPromptSwitch = undefined;
1123
- autoContinuesThisPrompt = 0;
1452
+ expectingSelfContinuation = false; // consume the flag once the run has started
1124
1453
  lastErrorText = "";
1125
1454
  captureDesiredThinking(); // remember the level BEFORE any failover can clamp it
1126
1455
  refreshDiscovery(); // cheap: only re-scans when auth.json changed (new /login)
@@ -1129,6 +1458,7 @@ export default function piMultiAccount(pi: ExtensionAPI) {
1129
1458
  pi.on("after_provider_response", async (event, ctx) => {
1130
1459
  latestCtx = ctx;
1131
1460
  if (!config.enabled) return;
1461
+ if (userAbortedChain || ctx.signal?.aborted) return; // user is cancelling — don't fail over
1132
1462
  const status = (event as any).status;
1133
1463
  if (status === 401) {
1134
1464
  // Authorization is dead → drop this account, then move on.
@@ -1145,6 +1475,7 @@ export default function piMultiAccount(pi: ExtensionAPI) {
1145
1475
  latestCtx = ctx;
1146
1476
  const message = (event as any).message;
1147
1477
  if (message?.role !== "assistant" || message.stopReason !== "error") return;
1478
+ if (userAbortedChain || ctx.signal?.aborted) return; // user is cancelling — don't fail over
1148
1479
  const errorText = typeof message.errorMessage === "string" ? message.errorMessage : "";
1149
1480
  lastErrorText = errorText;
1150
1481
  if (currentPromptSwitch) return;
@@ -1161,15 +1492,44 @@ export default function piMultiAccount(pi: ExtensionAPI) {
1161
1492
  pi.on("agent_end", async (event, ctx) => {
1162
1493
  latestCtx = ctx;
1163
1494
  if (!config.enabled || !config.autoContinue) return;
1495
+
1496
+ // Respect the user: if they pressed Esc, the last assistant message is "aborted".
1497
+ // Stop the failover chain dead and cancel every background timer so nothing
1498
+ // resurrects the task. It only restarts when the user sends a new prompt.
1499
+ if (lastAssistantStopReason((event as any).messages ?? []) === "aborted" || ctx.signal?.aborted) {
1500
+ userAbortedChain = true;
1501
+ if (autoContinueTimer) {
1502
+ clearTimeout(autoContinueTimer);
1503
+ autoContinueTimer = undefined;
1504
+ }
1505
+ clearPendingContinuation();
1506
+ currentPromptSwitch = undefined;
1507
+ lastErrorText = "";
1508
+ return;
1509
+ }
1510
+ if (userAbortedChain) return;
1511
+
1164
1512
  const errorText = lastErrorText || getAssistantErrorText((event as any).messages ?? []);
1165
1513
  if (isAuthError(errorText) && ctx.model) markInvalid(ctx.model.provider, errorText.slice(0, 60));
1166
1514
  if (!isLimitError(errorText) && !isAuthError(errorText)) return;
1167
- if (autoContinuesThisPrompt >= config.maxAutoContinuesPerPrompt) return;
1515
+
1516
+ // Task-level cap. Because this counter is no longer reset by our own re-prompts,
1517
+ // it genuinely bounds the failover loop. When it trips we stop completely (and do
1518
+ // NOT arm a resurrection timer) so the machine can't be driven into a swap spiral.
1519
+ if (autoContinuesThisPrompt >= config.maxAutoContinuesPerPrompt) {
1520
+ ctx.ui.notify(
1521
+ `Provider failover: stopped after ${autoContinuesThisPrompt} auto-continues — every account kept hitting limits. Send a new message, or run /multi-account reset to retry.`,
1522
+ "warning",
1523
+ );
1524
+ return;
1525
+ }
1168
1526
 
1169
1527
  if (!currentPromptSwitch) {
1170
1528
  const reason = `agent ended with provider limit: ${errorText.slice(0, 120)}`;
1171
1529
  const switched = await switchToFallback(ctx, reason, cooldownFromErrorText(errorText) ?? config.cooldownMs);
1172
- if (!switched && !currentPromptSwitch) {
1530
+ // switchToFallback already arms pending-resume when nothing is available now, so
1531
+ // only set it here if it somehow didn't (defensive; alreadyPending makes it a no-op).
1532
+ if (!switched && !currentPromptSwitch && !persistedState.pendingContinuationPrompt) {
1173
1533
  setPendingContinuation(ctx, reason);
1174
1534
  return;
1175
1535
  }
@@ -1178,7 +1538,7 @@ export default function piMultiAccount(pi: ExtensionAPI) {
1178
1538
  if (currentPromptSwitch) {
1179
1539
  autoContinuesThisPrompt++;
1180
1540
  const prompt = continuationPrompt(currentPromptSwitch);
1181
- pi.sendUserMessage(prompt, ctx.isIdle() ? undefined : { deliverAs: "followUp" });
1541
+ scheduleAutoContinue(ctx, prompt); // spaced + Esc-cancellable, not a tight loop
1182
1542
  }
1183
1543
  });
1184
1544
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-multi-account",
3
- "version": "1.0.0",
3
+ "version": "1.2.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",