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 +54 -0
- package/README.md +5 -1
- package/index.ts +374 -14
- package/package.json +1 -1
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
|
|
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
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|