@herbertgao/pi-extensions 2026.9.9 → 2026.9.11

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 (170) hide show
  1. package/README.md +1 -0
  2. package/THIRD_PARTY_NOTICES.md +26 -0
  3. package/node_modules/@herbertgao/pi-bark/package.json +2 -2
  4. package/node_modules/@herbertgao/pi-cc-extensions/README.en.md +2 -2
  5. package/node_modules/@herbertgao/pi-cc-extensions/README.md +2 -2
  6. package/node_modules/@herbertgao/pi-cc-extensions/package.json +3 -2
  7. package/node_modules/@herbertgao/pi-subagents/CHANGELOG.md +12 -0
  8. package/node_modules/@herbertgao/pi-subagents/README.md +427 -120
  9. package/node_modules/@herbertgao/pi-subagents/docs/rpc.md +184 -0
  10. package/node_modules/@herbertgao/pi-subagents/docs/workflows.md +466 -0
  11. package/node_modules/@herbertgao/pi-subagents/examples/agent-tool-description.md +6 -6
  12. package/node_modules/@herbertgao/pi-subagents/examples/workflows/compose.js +52 -0
  13. package/node_modules/@herbertgao/pi-subagents/examples/workflows/fan-out-audit.js +56 -0
  14. package/node_modules/@herbertgao/pi-subagents/examples/workflows/gated-fix.js +60 -0
  15. package/node_modules/@herbertgao/pi-subagents/examples/workflows/lib/count-child.js +30 -0
  16. package/node_modules/@herbertgao/pi-subagents/examples/workflows/review-panel.js +68 -0
  17. package/node_modules/@herbertgao/pi-subagents/examples/workflows/structured-findings.js +81 -0
  18. package/node_modules/@herbertgao/pi-subagents/package.json +12 -10
  19. package/node_modules/@herbertgao/pi-subagents/src/agent-file-toggle.ts +52 -12
  20. package/node_modules/@herbertgao/pi-subagents/src/agent-manager.ts +837 -146
  21. package/node_modules/@herbertgao/pi-subagents/src/agent-runner.ts +213 -39
  22. package/node_modules/@herbertgao/pi-subagents/src/cross-extension-rpc.ts +73 -14
  23. package/node_modules/@herbertgao/pi-subagents/src/custom-agents.ts +101 -47
  24. package/node_modules/@herbertgao/pi-subagents/src/index.ts +2249 -914
  25. package/node_modules/@herbertgao/pi-subagents/src/invocation-config.ts +13 -0
  26. package/node_modules/@herbertgao/pi-subagents/src/mention-clone.ts +215 -0
  27. package/node_modules/@herbertgao/pi-subagents/src/mention.ts +147 -0
  28. package/node_modules/@herbertgao/pi-subagents/src/model-resolver.ts +9 -1
  29. package/node_modules/@herbertgao/pi-subagents/src/nested-tools.ts +40 -26
  30. package/node_modules/@herbertgao/pi-subagents/src/output-file.ts +18 -8
  31. package/node_modules/@herbertgao/pi-subagents/src/prompts.ts +46 -9
  32. package/node_modules/@herbertgao/pi-subagents/src/schedule.ts +21 -16
  33. package/node_modules/@herbertgao/pi-subagents/src/settings.ts +137 -7
  34. package/node_modules/@herbertgao/pi-subagents/src/structured-output.ts +136 -0
  35. package/node_modules/@herbertgao/pi-subagents/src/types.ts +126 -8
  36. package/node_modules/@herbertgao/pi-subagents/src/ui/agent-mention.ts +274 -0
  37. package/node_modules/@herbertgao/pi-subagents/src/ui/agent-widget.ts +20 -5
  38. package/node_modules/@herbertgao/pi-subagents/src/ui/conversation-viewer.ts +10 -4
  39. package/node_modules/@herbertgao/pi-subagents/src/ui/fleet-list.ts +167 -22
  40. package/node_modules/@herbertgao/pi-subagents/src/ui/workflow-card.ts +555 -0
  41. package/node_modules/@herbertgao/pi-subagents/src/ui/workflow-dialog.ts +1304 -0
  42. package/node_modules/@herbertgao/pi-subagents/src/ui/workflow-menu.ts +226 -0
  43. package/node_modules/@herbertgao/pi-subagents/src/workflow/collisions.ts +122 -0
  44. package/node_modules/@herbertgao/pi-subagents/src/workflow/entry.ts +47 -0
  45. package/node_modules/@herbertgao/pi-subagents/src/workflow/host.ts +463 -0
  46. package/node_modules/@herbertgao/pi-subagents/src/workflow/journal.ts +164 -0
  47. package/node_modules/@herbertgao/pi-subagents/src/workflow/json-schema.ts +142 -0
  48. package/node_modules/@herbertgao/pi-subagents/src/workflow/meta.ts +401 -0
  49. package/node_modules/@herbertgao/pi-subagents/src/workflow/progress.ts +622 -0
  50. package/node_modules/@herbertgao/pi-subagents/src/workflow/runtime.ts +1399 -0
  51. package/node_modules/@herbertgao/pi-subagents/src/workflow/saved.ts +230 -0
  52. package/node_modules/@herbertgao/pi-subagents/src/workflow/task.ts +333 -0
  53. package/node_modules/@herbertgao/pi-subagents/src/workflow/tool-description.ts +200 -0
  54. package/node_modules/@herbertgao/pi-subagents/src/workflow/worker-source.ts +781 -0
  55. package/node_modules/@herbertgao/pi-subagents/src/worktree.ts +97 -95
  56. package/node_modules/@herbertgao/pi-subagents/src/xml.ts +13 -0
  57. package/node_modules/@herbertgao/resume-from/package.json +1 -1
  58. package/node_modules/@herbertgao/sol-pi/README.md +3 -3
  59. package/node_modules/@herbertgao/sol-pi/THIRD_PARTY_NOTICES.md +4 -4
  60. package/node_modules/@herbertgao/sol-pi/agents-install.md +4 -4
  61. package/node_modules/@herbertgao/sol-pi/docs/compatibility.md +6 -6
  62. package/node_modules/@herbertgao/sol-pi/package.json +2 -2
  63. package/node_modules/@narumitw/pi-btw/dist/index.ts +39 -89
  64. package/node_modules/@narumitw/pi-btw/dist/index.ts.map +3 -3
  65. package/node_modules/@narumitw/pi-btw/package.json +4 -4
  66. package/node_modules/@narumitw/pi-btw/src/btw.ts +28 -87
  67. package/node_modules/@narumitw/pi-btw/src/main-tree-picker.ts +8 -0
  68. package/node_modules/@narumitw/pi-btw/src/side-thread.ts +40 -37
  69. package/node_modules/@narumitw/pi-caffeinate/README.md +21 -66
  70. package/node_modules/@narumitw/pi-caffeinate/dist/index.ts +10 -41
  71. package/node_modules/@narumitw/pi-caffeinate/dist/index.ts.map +2 -2
  72. package/node_modules/@narumitw/pi-caffeinate/package.json +50 -51
  73. package/node_modules/@narumitw/pi-caffeinate/src/caffeinate.ts +637 -663
  74. package/node_modules/@narumitw/pi-caffeinate/src/dbus-inhibit.ts +114 -120
  75. package/node_modules/@narumitw/pi-caffeinate/src/inhibitor-process.ts +29 -29
  76. package/node_modules/@narumitw/pi-caffeinate/src/inhibitors.ts +108 -126
  77. package/node_modules/@narumitw/pi-caffeinate/src/settings.ts +124 -128
  78. package/node_modules/pi-multi-account/CHANGELOG.md +1209 -0
  79. package/node_modules/pi-multi-account/CONTRIBUTING.md +61 -0
  80. package/node_modules/pi-multi-account/LICENSE +21 -0
  81. package/node_modules/pi-multi-account/README.md +197 -0
  82. package/node_modules/pi-multi-account/SECURITY.md +27 -0
  83. package/node_modules/pi-multi-account/auth-file-transaction.ts +56 -0
  84. package/node_modules/pi-multi-account/child-usability.ts +233 -0
  85. package/node_modules/pi-multi-account/compaction-summary.ts +32 -0
  86. package/node_modules/pi-multi-account/completion-route-planner.ts +224 -0
  87. package/node_modules/pi-multi-account/context-guard.ts +420 -0
  88. package/node_modules/pi-multi-account/cursor/LICENSE +21 -0
  89. package/node_modules/pi-multi-account/cursor/NOTICE +2 -0
  90. package/node_modules/pi-multi-account/cursor/auth.ts +165 -0
  91. package/node_modules/pi-multi-account/cursor/bridge-handle.ts +155 -0
  92. package/node_modules/pi-multi-account/cursor/conversation-registry.ts +104 -0
  93. package/node_modules/pi-multi-account/cursor/cursor-models-raw.json +611 -0
  94. package/node_modules/pi-multi-account/cursor/cursor-shared.ts +192 -0
  95. package/node_modules/pi-multi-account/cursor/h2-bridge.mjs +175 -0
  96. package/node_modules/pi-multi-account/cursor/index.ts +572 -0
  97. package/node_modules/pi-multi-account/cursor/message-parsing.ts +323 -0
  98. package/node_modules/pi-multi-account/cursor/prompt-usage.ts +53 -0
  99. package/node_modules/pi-multi-account/cursor/proto/agent_pb.ts +15294 -0
  100. package/node_modules/pi-multi-account/cursor/proxy.ts +2510 -0
  101. package/node_modules/pi-multi-account/cursor/session-lifecycle.ts +40 -0
  102. package/node_modules/pi-multi-account/cursor/sse-keepalive.ts +24 -0
  103. package/node_modules/pi-multi-account/cursor/stream-lifecycle.ts +193 -0
  104. package/node_modules/pi-multi-account/cursor/upstream-watchdog.ts +88 -0
  105. package/node_modules/pi-multi-account/cursor-bridge.ts +240 -0
  106. package/node_modules/pi-multi-account/cursor-model-name.ts +12 -0
  107. package/node_modules/pi-multi-account/index.ts +11825 -0
  108. package/node_modules/pi-multi-account/model-catalog.ts +354 -0
  109. package/node_modules/pi-multi-account/package.json +101 -0
  110. package/node_modules/pi-multi-account/pi-contract.ts +281 -0
  111. package/node_modules/pi-multi-account/provider-payload-stream.ts +44 -0
  112. package/node_modules/pi-multi-account/provider-priority.ts +189 -0
  113. package/node_modules/pi-multi-account/slot-proxy-auth.ts +167 -0
  114. package/node_modules/pi-multi-account/slot-proxy.ts +344 -0
  115. package/node_modules/pi-multi-account/state-file-transaction.ts +67 -0
  116. package/node_modules/pi-multi-account/usage.ts +1099 -0
  117. package/node_modules/pi-typesafe/README.md +6 -2
  118. package/node_modules/pi-typesafe/dist/client.d.ts +11 -0
  119. package/node_modules/pi-typesafe/dist/client.js +45 -10
  120. package/node_modules/pi-typesafe/dist/index.d.ts +2 -2
  121. package/node_modules/pi-typesafe/dist/index.js +1 -1
  122. package/node_modules/pi-typesafe/package.json +2 -2
  123. package/node_modules/pi-web-access/CHANGELOG.md +36 -0
  124. package/node_modules/pi-web-access/README.md +75 -18
  125. package/node_modules/pi-web-access/anysearch.ts +4 -15
  126. package/node_modules/pi-web-access/bocha.ts +3 -22
  127. package/node_modules/pi-web-access/brave.ts +3 -21
  128. package/node_modules/pi-web-access/brightdata.ts +5 -32
  129. package/node_modules/pi-web-access/content-find.ts +168 -53
  130. package/node_modules/pi-web-access/curator-page.ts +4 -1
  131. package/node_modules/pi-web-access/curator-run.ts +2 -1
  132. package/node_modules/pi-web-access/curator-server.ts +1 -0
  133. package/node_modules/pi-web-access/dist/index.js +24620 -0
  134. package/node_modules/pi-web-access/domain-filter-normalization.ts +14 -0
  135. package/node_modules/pi-web-access/duckduckgo.ts +3 -21
  136. package/node_modules/pi-web-access/extract.ts +3 -1
  137. package/node_modules/pi-web-access/firecrawl.ts +5 -29
  138. package/node_modules/pi-web-access/gemini-search.ts +81 -32
  139. package/node_modules/pi-web-access/index.ts +149 -148
  140. package/node_modules/pi-web-access/jina-search.ts +4 -15
  141. package/node_modules/pi-web-access/kagi.ts +4 -13
  142. package/node_modules/pi-web-access/kimi-search.ts +5 -30
  143. package/node_modules/pi-web-access/mistral-search.ts +1 -15
  144. package/node_modules/pi-web-access/ollama.ts +2 -7
  145. package/node_modules/pi-web-access/openai-search.ts +174 -36
  146. package/node_modules/pi-web-access/opencode-session-headers.ts +24 -0
  147. package/node_modules/pi-web-access/package.json +10 -4
  148. package/node_modules/pi-web-access/page-query.ts +10 -2
  149. package/node_modules/pi-web-access/parallel.ts +1 -15
  150. package/node_modules/pi-web-access/pdf-extract.ts +3 -0
  151. package/node_modules/pi-web-access/querit.ts +5 -29
  152. package/node_modules/pi-web-access/search-answer-formatting.ts +11 -0
  153. package/node_modules/pi-web-access/search-result-count-normalization.ts +4 -0
  154. package/node_modules/pi-web-access/search1api.ts +5 -29
  155. package/node_modules/pi-web-access/searchinfinity.ts +5 -29
  156. package/node_modules/pi-web-access/searxng.ts +3 -21
  157. package/node_modules/pi-web-access/serpapi.ts +5 -28
  158. package/node_modules/pi-web-access/serpbase.ts +3 -22
  159. package/node_modules/pi-web-access/serpdive.ts +3 -21
  160. package/node_modules/pi-web-access/serper.ts +5 -28
  161. package/node_modules/pi-web-access/serply.ts +197 -0
  162. package/node_modules/pi-web-access/source-check.ts +11 -47
  163. package/node_modules/pi-web-access/summary-review.ts +7 -3
  164. package/node_modules/pi-web-access/tavily.ts +3 -21
  165. package/node_modules/pi-web-access/tinyfish.ts +5 -29
  166. package/node_modules/pi-web-access/utils.ts +9 -1
  167. package/node_modules/pi-web-access/valyu.ts +5 -28
  168. package/node_modules/pi-web-access/xai-search.ts +1 -15
  169. package/node_modules/pi-web-access/xcrawl.ts +5 -32
  170. package/package.json +17 -11
@@ -0,0 +1,61 @@
1
+ # Contributing to pi-multi-account
2
+
3
+ Thanks for your interest in improving pi-multi-account! Issues and pull
4
+ requests are welcome. All PRs are reviewed by the maintainer before merge.
5
+
6
+ ## Ground rules
7
+
8
+ - **Never include secrets.** The extension handles credentials for provider
9
+ authentication and usage requests and can keep a `0600` private OAuth recovery
10
+ sidecar while publishing loopback proxies. Credential values never enter
11
+ rotation state or logs; only SHA-256 fingerprints are persisted there. Never
12
+ paste tokens, API keys, or `auth.json` contents into issues, PRs, logs, or tests.
13
+ - Keep each PR focused on a single concern.
14
+ - Discuss large or breaking changes in an issue first.
15
+
16
+ ## Development setup
17
+
18
+ ```bash
19
+ git clone https://github.com/Sarrius/pi-multi-account.git
20
+ cd pi-multi-account
21
+ npm install
22
+ ```
23
+
24
+ Requirements: Node.js >= 22.
25
+
26
+ ## Before you open a PR
27
+
28
+ ```bash
29
+ npm run check # TypeScript type-check (tsc --noEmit)
30
+ npm run pack:check # npm pack --dry-run — verify the published file set
31
+ ```
32
+
33
+ CI runs the same checks on every pull request; they must pass before review.
34
+
35
+ ## Coding guidelines
36
+
37
+ - The whole extension lives in `index.ts`. Match the surrounding style.
38
+ - Prefer small, well-commented guards over clever one-liners — failover logic is
39
+ safety-critical (a bad loop can peg a machine).
40
+ - Update `CHANGELOG.md` under a new version / `Unreleased` heading describing the
41
+ user-visible effect of your change.
42
+
43
+ ## Releasing (maintainer only)
44
+
45
+ Publishing runs on GitHub Actions through npm **trusted publishing** (OIDC). There is no
46
+ npm token anywhere, and `npm publish` is never run from a laptop — the workflow refuses to
47
+ publish unless it is checked out on the tag matching `package.json`.
48
+
49
+ 1. Bump `version` in **both** `package.json` and `const VERSION` in `index.ts` — they must
50
+ match — and add the `CHANGELOG.md` entry.
51
+ 2. Land the change on `main` via PR (CI must be green).
52
+ 3. Tag and push the tag:
53
+ `git tag -a vX.Y.Z -m "vX.Y.Z" && git push origin vX.Y.Z`
54
+ 4. Dry run first — this exercises the whole pipeline without uploading:
55
+ `gh workflow run publish.yml --ref vX.Y.Z -f dist_tag=latest -f dry_run=true`
56
+ 5. When that is green, publish for real:
57
+ `gh workflow run publish.yml --ref vX.Y.Z -f dist_tag=latest -f dry_run=false`
58
+ 6. Create the GitHub release for the tag.
59
+
60
+ `--ref` must be the **tag**, not a branch: the workflow asserts `GITHUB_REF_TYPE=tag` and
61
+ `GITHUB_REF_NAME=v<version>`, so dispatching from `main` fails that guard by design.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 pi-multi-account contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,197 @@
1
+ # pi-multi-account
2
+
3
+ Automatic multi-account failover & rotation for [Pi Agent](https://pi.dev/), across **Anthropic (Claude)**, **OpenAI / ChatGPT Codex**, **Kimi For Coding**, **Cursor**, **Qwen / Alibaba**, and **Ollama**.
4
+
5
+ When the account you are using hits a quota or rate limit, `pi-multi-account` transparently switches to the next authenticated account/model and (optionally) resumes the interrupted task — so a long agent run does not die just because one account ran out of budget.
6
+
7
+ ## What it does
8
+
9
+ - **Auto-discovers** every authenticated account from `~/.pi/agent/auth.json` (Anthropic Claude Pro/Max, OpenAI/ChatGPT Codex, Kimi For Coding, Cursor, Qwen/Alibaba, and Ollama) and builds the failover rotation dynamically — no manual config editing.
10
+ - **Grows the rotation on login.** Run `/login`, choose **Use a subscription**, then select a numbered slot such as `anthropic-account-3` or `openai-codex-account-5`. The next discovery sweep adds it to the rotation automatically.
11
+ - **Auto-discovers new Codex models per account.** At session start (and on `reload` / `rediscover`) it reads OpenAI's authenticated model catalog, mirrors each account's actually available models onto its Pi alias, and follows OpenAI's server priority. A new flagship can therefore win immediately without an extension release or a hard-coded model id.
12
+ - **Handles auth failures without poisoning healthy OAuth accounts.** A generic final 401 briefly cools down a refreshable account and moves the current task forward. Explicit provider verdicts such as `authentication token has been invalidated` force an early refresh; if the refresh token is dead too, the slot is removed and Pi prints the interactive `/login` recovery steps.
13
+ - **Fails over on quota / rate-limit** (429 / 402 / 403 and friends): the exhausted account goes on cooldown (parsed from the provider's own reset metadata when available) and Pi first tries another account with the same model. If it must leave the family, it preserves the model's quality band — Sol/Opus/other frontier flagships stay frontier; Terra/Sonnet stay balanced; Luna/Haiku stay fast — and keeps the session's thinking level. A fresh provider verdict of `blocked` or 100% is skipped automatically instead of wasting the turn; manual `next` remains an explicit one-attempt override for stale telemetry.
14
+ - **Optional auto-continue**: resumes the interrupted turn after a switch from the last safe point. Temporary 5xx/overload errors and Cursor stalls retry the exact selected provider/account/model, without marking its quota exhausted. After four failed attempts (or an earlier recovery breaker), automatic retries stop with an explicit explanation; temporary errors never authorize a switch. Same-route retries are labelled as retries, not fake switches. A failed or cancelled automatic compaction still continues the task instead of leaving the session parked in Working.
15
+ - **Never hides a fresh user message in a private cooldown queue.** If no account is ready, the message remains in Pi's visible transcript and Pi owns its normal delivery/retry path.
16
+ - **Session-bound overnight resume**: if every account is cooling down, the live Pi session waits for the earliest recovery and continues automatically. A new user message, `/multi-account stop`, session exit, or Esc during a running turn cancels the chain.
17
+ - **Deduplicates provably identical accounts** so duplicate Codex workspace memberships and identical credentials do not consume multiple rotation slots or get separate cooldowns. Different users in one Team/Business workspace remain distinct. New provable duplicate logins are rejected before the redundant slot is saved.
18
+ - **Keeps YOUR reasoning level across automatic switches.** Whatever the session runs at — your Pi default, `/thinking`, or a per-agent `--thinking low` — is preserved and restored after every account/model switch, so it never drifts downward when a weaker fallback model clamps it. Manual `/model` selections adopt Pi's per-model thinking default in `auto` mode; an explicit CLI `--thinking` or forced `reasoningLevel` remains authoritative. The extension does not otherwise override your level, and extreme levels such as `xhigh` / Max / Ultra are never forced.
19
+ - **Shows live limits for the active account** in Pi's footer: remaining 5-hour/session and weekly allowance plus reset countdowns for Codex, Anthropic, and Ollama Cloud accounts, SuperGrok / xAI subscription credit usage for `/login xai` OAuth (not Cursor Grok, not `XAI_API_KEY`), and GLM Coding Plan CN 5-hour/weekly credit quota for `zai-coding-cn`. GLM CN uses the raw Coding Plan API key only at `https://open.bigmodel.cn/api/monitor/usage/quota/limit`; global `zai` keys are not sent there. Missing or malformed quota is unknown, never assumed unused.
20
+
21
+ ## Install
22
+
23
+ ```bash
24
+ pi install npm:pi-multi-account
25
+ ```
26
+
27
+ Restart Pi or run `/reload` after installation.
28
+
29
+ Requires Node 22+ and `@earendil-works/pi-ai` 0.78 or newer — it is installed automatically as a
30
+ dependency. Both the pre-0.80 OAuth API and the 0.80+ provider-factory API are supported, so the
31
+ extension keeps working across pi-ai upgrades. If a pi-ai it cannot adapt is ever encountered, the
32
+ extension still loads and API-key accounts keep rotating; only subscription login is unavailable,
33
+ and it says so at session start.
34
+
35
+ > **Anthropic (Claude Pro/Max) works out of the box.** OAuth login and request
36
+ > shaping for the base `anthropic` provider and every `anthropic-account-*` alias
37
+ > are built in — no separate `pi-anthropic-auth` install is required. If you
38
+ > already have `pi-anthropic-auth`, the two coexist safely (the shaping is
39
+ > idempotent). OpenAI Codex / ChatGPT and Qwen accounts work as well.
40
+
41
+ ### Recommended setting
42
+
43
+ Set Pi provider-level retries to zero so the SDK does not keep retrying an exhausted account before failover kicks in. Pi's separate agent-level retry loop may remain enabled: the extension cancels its pending wake if that retry succeeds. Repeated temporary failures get bounded recovery on the same route, then an explicit stop—not provider failover. In `~/.pi/agent/settings.json`:
44
+
45
+ ```json
46
+ { "retry": { "provider": { "maxRetries": 0 } } }
47
+ ```
48
+
49
+ ## Usage
50
+
51
+ Add accounts by opening the login picker:
52
+
53
+ ```text
54
+ /login
55
+ Use a subscription
56
+ ChatGPT Plus/Pro (Codex openai-codex-account-2)
57
+ /multi-account rediscover
58
+ ```
59
+
60
+ Pi 0.79.3 does not accept a provider argument after `/login`; select the account
61
+ slot from the interactive provider picker instead.
62
+
63
+ Check what's in the rotation at any time:
64
+
65
+ ```text
66
+ /multi-account status
67
+ ```
68
+
69
+ Force-refresh and display detailed limits for the active account:
70
+
71
+ ```text
72
+ /multi-account limits refresh
73
+ ```
74
+
75
+ Example status output:
76
+
77
+ ```text
78
+ pi-multi-account: enabled · auto-discover ON
79
+ Current: anthropic/claude-opus-4-8
80
+ Current limits: Claude | 5h 0% left/2h14m | 7d 92% left/1d18h
81
+ Rotation (3): anthropic → openai-codex → openai-codex-account-2
82
+ Registered login slots: anthropic-account-2, openai-codex-account-2
83
+ Cooldowns: none
84
+ Invalidated (need re-login): none
85
+ Pending auto-resume: none
86
+ ```
87
+
88
+ ### Commands
89
+
90
+ All three names are aliases for the same command: `/multi-account`, `/provider-failover`, `/failover`.
91
+
92
+ | Subcommand | Description |
93
+ |---|---|
94
+ | `status` (default) | Show enabled state, current model, rotation, login slots, cooldowns, invalidations, pending resume. |
95
+ | `limits [refresh]` | Show active-account session/weekly limits; `refresh` bypasses the cache. Aliases: `usage`, `quota`. |
96
+ | `accounts [refresh]` | List every configured slot with provider-reported alias/email, plan, limits, and live routing status. Uses cached metadata unless `refresh` is explicit. |
97
+ | `rediscover` | Force a re-scan of `auth.json`, rebuild the rotation, and refresh Codex model catalogs now. |
98
+ | `add [anthropic\|codex\|kimi\|cursor\|ollama\|qwen]` | Print the next free account slot to select from the interactive `/login` picker. Subscription families (Anthropic, Codex, Kimi, Cursor) are logged in through `/login`; API-key families are filled in `auth.json`. |
99
+ | `remove [anthropic\|codex\|kimi\|cursor\|ollama\|qwen\|<provider-id>]` | Remove an account from `auth.json` and rotation. Family name drops the highest numbered alias slot; a full provider id removes that exact slot. Aliases: `rm`, `delete`. |
100
+ | `next` | Manually switch to the next compatible-quality fallback, deliberately overriding recorded cooldowns for one attempt. Use explicit `switch` to select a different tier. |
101
+ | `only-active [on\|off]` | Legacy picker preference; the complete Pi model registry remains available to all clients. Pi currently has no separate picker-only filter. Alias: `focus`. |
102
+ | `stop` | Abort and cancel automatic failover/resume for the current task. |
103
+ | `reset` | Clear all cooldowns, invalidations and any pending auto-resume. |
104
+ | `reload` | Reload config from disk and re-discover accounts. |
105
+ | `enable` / `disable` | Turn failover on/off for the current Pi process. |
106
+
107
+ ## How rotation membership works
108
+
109
+ - **Joins the rotation** when an account has a present, non-expired credential in `auth.json` (after `/login`).
110
+ - **Leaves the rotation** when the credential is logged out / removed, its access token is expired with no refresh token, an API key is rejected, or a refreshable OAuth credential produces three distinct final auth failures without a success in between.
111
+ - **Quota / rate-limit** does not invalidate an account — it puts it on a temporary cooldown and the account returns once the cooldown expires.
112
+ - **Duplicate identities** share one rotation position and one cooldown, and status/startup identifies the redundant slot. Codex/ChatGPT is matched by the JWT's stable `chatgpt_account_user_id` workspace-membership claim; tokens without it use the documented `chatgpt_user_id` plus workspace id, with stored `accountId` as the final legacy fallback. Identical API keys or literal identical tokens are also matched. Separate Anthropic OAuth logins cannot be proven identical because Anthropic's stored credential exposes no stable account identifier.
113
+
114
+ Rotation refresh is triggered by changes to `auth.json` (detected on session/turn start) or on demand with `/multi-account rediscover`.
115
+
116
+ After re-authenticating an invalidated slot, restart any older Pi processes that
117
+ were already running. Pi keeps a still-unexpired access token in each process's
118
+ memory, so an old process can continue using the invalidated token even after a
119
+ new `/login` updates `auth.json`.
120
+
121
+ ## Configuration
122
+
123
+ A default config is created at `~/.pi/agent/provider-failover.json` on first run. Useful keys:
124
+
125
+ | Key | Default | Description |
126
+ |---|---|---|
127
+ | `enabled` | `true` | Master switch. |
128
+ | `autoContinue` | `true` | Queue a continuation prompt after a switch. |
129
+ | `autoDiscover` | `true` | Auto-discover accounts from `auth.json`. |
130
+ | `autoDiscoverModels` | `true` | Fetch OpenAI's authenticated model catalog for every Codex account and register new models on that account's alias automatically. |
131
+ | `includeQwen` | `true` | Include Qwen / Alibaba accounts. |
132
+ | `includeOllama` | `true` | Include Ollama (local) accounts. |
133
+ | `neverFailoverProviders` | `[]` | Provider ids to never fail away from, e.g. `["my-provider"]`. For **unmanaged** providers that run their own retry logic (typically a companion extension owning retries for that provider) — switching accounts underneath it would fight those retries. Managed accounts still cool and rotate normally. |
134
+ | `includeCursor` | `true` | Include Cursor subscription accounts. The Cursor provider is a separate, optional repo — until it is cloned this setting does nothing at all: no cursor login slot is offered and no warning is printed. Run `/multi-account add cursor` to get the install instructions. |
135
+ | `childProxy` | `true` | Serve OAuth rotation slots to processes that do not load this extension (a memory extension consolidating its notes, an external CLI, any `pi -p --no-extensions` call) through a loopback route this process owns. A bare child launched without `--model` inherits Pi's **saved global default**, which since Pi 0.84.3 intentionally may differ from this session's live rotation; an explicitly pinned child uses its requested slot. Without a usable publication either form can fail authentication and fall back to whichever provider Pi finds first. The published route carries a non-secret placeholder; the real credential never leaves the parent. |
136
+ | `providerOrder` | `["anthropic","openai-codex","qwen","ollama"]` | Preferred family order in the rotation. |
137
+ | `cooldownMs` | 6 h | Default cooldown when no reset metadata is provided. |
138
+ | `showUsage` | `true` | Show active Codex/Claude/xAI limits in Pi's footer. |
139
+ | `usageRefreshMs` | 5 min | Per-account usage cache TTL; every authenticated rotation account is refreshed independently, and Anthropic is clamped to at least 10 min to avoid endpoint throttling. |
140
+ | `usageStatusRefreshMs` | 1 min | Re-render the footer and sweep idle sessions for stale usage/model catalogs; network refreshes remain limited by their five-minute (Anthropic: ten-minute) TTLs. |
141
+ | `maxAutoContinuesPerPrompt` | `8` | Cap on auto-resume hops per task. |
142
+ | `continuationPrompt` | (built-in) | Template; supports `{from}`, `{to}`, `{reason}`. |
143
+ | `maxRecheckIntervalMs` | `600000` (10 min) | Ceiling on how long a quota snapshot may bench an account before background refresh/re-evaluation. A fresh explicit provider verdict (`serviceable: false`) is authoritative during that interval and is skipped by automatic routing; manual `next` can still spend one deliberate probe. |
144
+ | `preserveInterruptedContext` | `true` | Rewrite the turn that triggered the failover into a verbatim `[handoff:interrupted-turn]` record so the account taking over still sees the reasoning, output and tool calls of the turn pi-ai would otherwise drop as unreplayable — including which calls never returned. Deterministic (never moves the prompt-cache breakpoint) and hard-capped. Set to `false` for the previous drop-everything behaviour. |
145
+ | `continueAfterCompaction` | `true` | After an **automatic** compaction, carry the task on instead of ending the run and waiting for the user to type "continue". This covers both Pi's threshold/overflow path (a follow-up is queued while compaction is still in flight so Pi's own `hasQueuedMessages()` route drains it) and the extension's context-guard summary (a real turn starts only from Pi's completion callback, because that guard compacts from an already-idle boundary). One continuation shares the `maxAutoContinuesPerPrompt` budget with failover and is skipped for a manual `/compact`, when Pi is already retrying, when another message/turn is present, and after the user pressed Esc. The message tells the model that finishing is valid. Set `false` only when another installed extension owns post-compaction continuation. |
146
+ | `providerPriority` | managed families, per-token providers last | Where work goes once **every** account of the current provider is spent. An ordered list of provider groups — a managed family, or the base id of anything else you are logged in to (`openrouter`, `zai`, `minimax`…), which `providerOrder` could never name. Three bounds: same-provider failover always runs first and is untouched (588 of 602 automatic failovers in a real black box stayed inside the family — that step already worked); an account on a real cooldown is never chosen over a free one; and a group nobody ranked sorts after every group somebody did. Inside those bounds it settles the hop that used to be arbitrary — the other 14 of those 602 scattered across five destinations with no policy behind them. It sits below the per-account liveness signals on purpose: evidence about one account beats a preference about its category. Set with `/multi-account priority ...`. |
147
+ | `contextGuard` | `true` | Keep every request inside the model's context window **while the agent is working**. Pi ≥0.84.4 also compacts between tool execution and the next assistant request. This optional guard adds a local window cap (especially for advertised 1M windows). Existing elisions are reapplied before measuring the outgoing request; another batch is added only when it crosses the soft threshold again, rather than invalidating the cached prefix on each tool turn. Above 75 % of the usable window the oldest large tool results are left out of the outgoing request (the transcript is never touched); above 70 % a real summary is requested at the next settled boundary. The size is measured locally rather than taken from the provider, because Cursor and openai-codex both report the size of their own server-side copy of the conversation. Pass an object to override `softPercent`, `targetPercent`, `compactPercent`, `keepVerbatimTokens`, `minElideTokens`, `maxWindowTokens` (default `400000`: a ceiling on advertised windows, since a model claiming 1 000 000 puts Pi's own threshold out of reach entirely). `false` disables it. |
148
+ | `routeCompactionToHealthyAccount` | `true` | When the active account is rate-limited/invalid and Pi needs to compact, generate the summary on a healthy fallback account. If every live attempt fails, cancel — never hand the job to Pi's default on the spent account (that is the infinite "Compacting context…" spinner). |
149
+ | `compactionWatchdogMs` | 8 min | Upper bound for one routed compaction attempt. A timed-out attempt is aborted and the next live account is tried. |
150
+ | `resumeIdleTimeoutMs` | 90 s | Max time to wait for the previous turn to go idle before a resume gives up and retries later (never an unbounded loop). |
151
+ | `stuckWatchdogMs` | 180 s | A resumed turn silent for this long (with no tool running) is treated as wedged. |
152
+ | `autoRecoverStuck` | `true` | When a resume wedges, auto-cancel it and retry the same provider/model within the shared recovery budget, instead of only notifying. Set `false` for notify-only. |
153
+ | `debugLog` | `true` | Write a structured "black box" decision log to `provider-failover-debug.log` (no credentials — only provider/model ids and truncated reasons). View with `/multi-account log`. |
154
+ | `preferLatestModel` | `true` | Rank the strongest/current model ahead of older siblings within the current quality band during automatic failover. |
155
+ | `reasoningLevel` | `"auto"` | `"auto"` follows the level the session actually runs at (your Pi default, `/thinking`, per-agent `--thinking`) and only restores it after switches. Set an explicit level (`"off"`…`"xhigh"`) to **force** it on every turn regardless of the session — `"xhigh"` only if you really want the extreme level. |
156
+ | `preferredModels` | `{}` | Optional manual strongest-first override per family; when present it wins over live catalog priority. |
157
+
158
+ State (cooldowns, invalidations, recent switches, credential-free Codex model catalogs, and a diagnostic pending marker) is persisted to `~/.pi/agent/provider-failover-state.json`. The actual pending task is session-local: one Pi window never consumes or resumes another window's work. Pending work is discarded when its owning session closes.
159
+
160
+ ### Session model ownership
161
+
162
+ Requires Agent Pi **0.85.1 or newer**. Pi's session branch and explicit SDK/CLI launch model are authoritative. Shared account telemetry and legacy `lastUserModel` / `lastUserThinkingLevel` state cannot replace another pane's live selection, and shutdown does not publish that selection as a global default. Cold-catalog repair uses this session's model history once at startup. Configure Pi's saved default explicitly for new sessions.
163
+
164
+ ### Host-owned background completions
165
+
166
+ When the host exposes `registerCompletionRouter`, Multi Account registers one credentialless completion router with it. This is capability-detected, not inferred from the Pi version: stock Pi 0.85.1 does not expose this optional API, so the integration remains inactive there. Extensions can request isolated background completions through Pi without importing or calling Multi Account. The router sees operation IDs, model identities, attempt outcomes, and provider response facts only; prompts and credentials remain inside the host.
167
+
168
+ Background attempts share the same cooldown and invalidation state as foreground routing. Quota, authentication, model, and transport failures can select a healthy fallback; parser and consumer failures do not affect route health. The route lease is operation-local: it never calls `pi.setModel`, queues a continuation, or changes the user's selected foreground model.
169
+
170
+ ### pi-subagents and delegation-broker compatibility
171
+
172
+ `pi-subagents` marks native child processes with `PI_SUBAGENT_CHILD=1` and owns their explicit model plus `fallbackModels` chain. Concurrent in-process SDK sessions also stay passive while a root session activation is live. The root lease is released at session shutdown, so `/reload`, `/new` and replacement root sessions do not permanently lose routing ownership. This is process-local ownership, not detection based on model names or a permanent first-factory flag. In those children this extension stays loaded only for provider/account registration, OAuth request shaping, and catalog support. It deliberately does **not** restore the interactive process's remembered model, persist the child's model as a user preference, switch models, queue work, or auto-continue after errors. The original provider error is returned unchanged so the parent runner can advance its verified fallback chain without a second router competing for model identity.
173
+
174
+ [`pi-delegation-broker`](https://github.com/Sarrius/pi-delegation-broker) is an optional companion for splitting work among isolated child agents, with task budgets, reports and verification. Each extension works independently and depends on Agent Pi, not on the other extension. Together, Multi Account manages your interactive account rotation while the broker delegates through Pi's registered providers and models. Neither installs or initializes the other.
175
+
176
+ ## Staying unstuck (resilience)
177
+
178
+ A failover is only useful if the agent actually keeps working afterward. These guarantees keep a switch from silently freezing the session:
179
+
180
+ - **Compaction survives account limits — and never leaves the spinner running.** When your context fills up and the active account is rate-limited, the summary is generated on a *healthy* account. If that attempt times out it is aborted (not leaked) and the next live account is tried. If none can finish, compaction is cancelled so "Compacting context…" stops; Pi's default is never given a spent account.
181
+ - **Resumes only happen when there is something to resume.** The extension continues a turn only when it actually ended in an error it can pick up from — it never tries to "continue" a finished reply (the cause of the cryptic `Cannot continue from message role: assistant` error).
182
+ - **A forward-progress watchdog that acts.** If a resumed turn goes completely silent (no streaming, no tool activity, no provider response) and no tool is running, the extension auto-cancels the wedged turn and retries the same provider/model within the recovery budget — you do not have to press Esc or re-type the prompt. A long, silent build/test command is never mistaken for a wedge.
183
+ - **A circuit breaker as the floor.** If automatic recovery keeps failing, the extension drops to *advisory mode*: confirmed quota limits can still select a fresh account, but temporary errors preserve the selected route and stop the auto-continue that was failing, so a bad state can never spiral into repeated hangs. It re-enables itself on the next success, a new prompt, or `/multi-account reset`.
184
+ - **A black box for diagnosis.** Every decision (switch, error and how it was classified, watchdog action, breaker trip, compaction routing) is appended to `~/.pi/agent/provider-failover-debug.log`. If anything misbehaves, run `/multi-account log` — the exact sequence is there, so a bug can be reproduced and fixed instead of guessed at. The file is bounded in size and redacts token-shaped material; review private project details before sharing it.
185
+
186
+ ## Privacy & security
187
+
188
+ `pi-multi-account` reads credentials through Pi and its account store. Account removal, OAuth refresh and parent-owned proxy publication can update the credential files under Pi-compatible locks. Proxy publication keeps a private recovery copy before replacing a credential with a loopback placeholder, and restores the real credential before deleting that copy. Credentials are never stored in rotation state. Account/token values are reduced to a short irreversible SHA-256 fingerprint for re-login detection and deduplication. Credentials are sent only to their own provider endpoints: usage/account probes (`chatgpt.com/backend-api/wham/usage`, `api.anthropic.com/api/oauth/usage`, `cli-chat-proxy.grok.com/v1/billing`, `open.bigmodel.cn/api/monitor/usage/quota/limit` (CN Coding Plan keys only), Ollama Cloud's `/api/me` and `/api/usage`, or Ollama's loopback-only `http://127.0.0.1:11434/api/me` fallback), OpenAI's authenticated `chatgpt.com/backend-api/codex/models` catalog, and provider OAuth token endpoints when Pi's authentication implementation refreshes a login (for xAI, `auth.x.ai/oauth2/token`). Cached state contains percentages, reset times, plan/credit metadata, model metadata, provider-reported account email/alias, and the fingerprint, never the token. Config, state, and the debug log are written with `0600` permissions. The debug log records only provider/model ids, decisions, and truncated reasons — token-shaped material is redacted defensively — Review logs for private project details before sharing an issue. Disable it with `"debugLog": false` or `/multi-account log off`.
189
+
190
+ ## License
191
+
192
+ [MIT](./LICENSE)
193
+
194
+
195
+ ### Help make these tools more reliable
196
+
197
+ Use them on real work? Share a reproducible [issue](https://github.com/Sarrius/pi-multi-account/issues), a small regression test, a documentation improvement, or a focused PR. Include Pi/extension versions and expected versus actual behavior; remove credentials and private task data. Shared fixes cover more providers and workflows than one maintainer can test alone.
@@ -0,0 +1,27 @@
1
+ # Security Policy
2
+
3
+ ## Reporting a vulnerability
4
+
5
+ Please report security issues privately via GitHub's
6
+ [security advisories](https://github.com/Sarrius/pi-multi-account/security/advisories/new)
7
+ rather than opening a public issue.
8
+
9
+ ## Handling of credentials
10
+
11
+ pi-multi-account reads authenticated accounts from `~/.pi/agent/auth.json`. Raw
12
+ credentials are never copied into rotation state or logs. A `0600` private
13
+ recovery sidecar can temporarily retain OAuth credentials while the public
14
+ account store contains loopback proxy placeholders. Credentials are transmitted
15
+ over HTTPS to provider endpoints needed for authentication, account usage, and
16
+ model-catalog operations. The only non-HTTPS exception is Ollama's tightly
17
+ scoped `http://127.0.0.1:11434/api/me` loopback fallback. The endpoints currently
18
+ called by the extension are documented under
19
+ [Privacy & security](./README.md#privacy--security); OAuth refresh delegates to
20
+ the provider authentication implementation shipped by Pi.
21
+
22
+ - Only SHA-256 fingerprints (first 12 hex chars) of tokens/keys are kept in
23
+ extension state, to detect re-login and dedupe accounts.
24
+ - Its config and state files are written with `0600` permissions.
25
+
26
+ When reporting an issue, never include tokens, API keys, or the contents of
27
+ `auth.json`.
@@ -0,0 +1,56 @@
1
+ /** Pi-compatible cross-process auth lock and crash-safe OAuth shadow publication. */
2
+ import { createRequire } from "node:module";
3
+ import { randomUUID } from "node:crypto";
4
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
5
+ import { dirname } from "node:path";
6
+ import type { AuthBlob } from "./slot-proxy-auth.ts";
7
+
8
+ const require = createRequire(import.meta.url);
9
+ type Auth = Record<string, AuthBlob>;
10
+ type Plan = { auth: Auth; sidecar: Auth; changed: boolean };
11
+
12
+ function read(path: string): Auth {
13
+ if (!existsSync(path)) return {};
14
+ const value = JSON.parse(readFileSync(path, "utf8"));
15
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("auth storage is not an object");
16
+ return value;
17
+ }
18
+
19
+ function atomicWrite(path: string, data: Auth): void {
20
+ const temp = `${path}.${process.pid}.${randomUUID()}.tmp`;
21
+ try {
22
+ writeFileSync(temp, `${JSON.stringify(data, null, "\t")}\n`, { mode: 0o600, flag: "wx" });
23
+ renameSync(temp, path);
24
+ } finally { rmSync(temp, { force: true }); }
25
+ }
26
+
27
+ export function mutateProxyAuth(
28
+ authPath: string,
29
+ sidecarPath: string,
30
+ transform: (auth: Auth, sidecar: Auth) => Plan,
31
+ mode: "shadow" | "restore",
32
+ write: (path: string, data: Auth) => void = atomicWrite,
33
+ ): boolean {
34
+ mkdirSync(dirname(authPath), { recursive: true, mode: 0o700 });
35
+ if (!existsSync(authPath)) {
36
+ try { writeFileSync(authPath, "{}\n", { mode: 0o600, flag: "wx" }); }
37
+ catch (error: any) { if (error?.code !== "EEXIST") throw error; }
38
+ }
39
+ // Same package/options as Pi FileAuthStorageBackend. Never read a snapshot before locking.
40
+ const lockfile = require("proper-lockfile") as { lockSync(path: string, options: object): () => void };
41
+ const release = lockfile.lockSync(authPath, { realpath: false });
42
+ try {
43
+ const plan = transform(read(authPath), read(sidecarPath));
44
+ if (!plan.changed) return false;
45
+ if (mode === "shadow") {
46
+ // Persist the only recoverable OAuth copy BEFORE replacing auth with a placeholder.
47
+ write(sidecarPath, plan.sidecar);
48
+ write(authPath, plan.auth);
49
+ } else {
50
+ // Restore the real credential BEFORE removing its recovery copy.
51
+ write(authPath, plan.auth);
52
+ write(sidecarPath, plan.sidecar);
53
+ }
54
+ return true;
55
+ } finally { release(); }
56
+ }
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Can an extension-free Pi child actually use this rotation slot?
3
+ *
4
+ * ## Why this exists
5
+ *
6
+ * Pi keeps an optional saved global default in `settings.json`. On Pi <=0.84.2 every model
7
+ * switch rewrites it; since Pi 0.84.3 ordinary model selection is session-scoped and only an
8
+ * explicit persistent choice rewrites it. Anything that later spawns a bare
9
+ * `pi -p --no-extensions` child without `--model` inherits that saved default — which may
10
+ * intentionally differ from whichever rotation slot is active in this session. Brokered and
11
+ * subagent children pass an explicit provider/model and do not use this fallback.
12
+ *
13
+ * A child launched that way does not load this extension, so a slot named `openai-codex-account-4`
14
+ * exists for it only if we published it into Pi's own `models.json`. Publishing the *name*,
15
+ * however, is not the same as publishing a usable route, and the difference is invisible until
16
+ * something fails far away from here.
17
+ *
18
+ * ## What was measured (2026-08-24), not assumed
19
+ *
20
+ * In an isolated agent directory containing one `models.json` provider entry mirroring the
21
+ * built-in Codex definition plus the matching **OAuth** credential under the same key:
22
+ *
23
+ * pi -p --no-extensions --no-session --model openai-codex-account-4/gpt-5.6-sol …
24
+ * → exit 1: "No API key found for openai-codex-account-4."
25
+ *
26
+ * The same child, pointed at the **built-in** `openai-codex` provider with its OAuth credential,
27
+ * got past authentication (it reached the network instead of refusing).
28
+ *
29
+ * The reason is in Pi: `checkProviderAuth` honours an OAuth credential only when the *provider
30
+ * definition* declares an OAuth flow. A `models.json` entry declares none, so an OAuth token
31
+ * sitting in `auth.json` under exactly that key is never consulted. An API key is different —
32
+ * it resolves through the credential store and works.
33
+ *
34
+ * ## What was measured again (2026-08-30), and what it corrected
35
+ *
36
+ * The row below claiming a built-in provider is usable whatever its credential turned out to be
37
+ * true of authentication and false of the request. On the account the parent session was itself
38
+ * using successfully at that moment:
39
+ *
40
+ * pi -p --no-extensions --no-session --model anthropic/claude-opus-5 …
41
+ * → 400 invalid_request_error: "Third-party apps now draw from your extra usage, not your
42
+ * plan limits."
43
+ *
44
+ * Pi resolved the built-in provider and its OAuth credential correctly; Anthropic then refused,
45
+ * because a subscription token is only honoured for a request carrying the client identity the
46
+ * parent adds and a bare child does not. So for that family being built-in is not sufficient,
47
+ * and the same parent-owned loopback route the alias slots use is what makes it usable. Codex
48
+ * is unaffected — the 2026-08-24 measurement of a bare child on built-in `openai-codex` reached
49
+ * the network and was served.
50
+ *
51
+ * | slot shape | child outcome |
52
+ * |---|---|
53
+ * | built-in provider, credential the vendor serves any client (API key, Codex OAuth) | usable — Pi owns the auth flow |
54
+ * | **built-in Anthropic on a subscription OAuth token** | **authenticates, then refused as a third-party app** |
55
+ * | alias slot published with a real or placeholder `apiKey` | usable — Pi sees a credential |
56
+ * | **alias slot with an OAuth credential and no `apiKey`** | **resolves by name, then fails at auth** |
57
+ *
58
+ * The third row is what we do today for Kimi slots, under a comment promising that
59
+ * "extension-free children resolve it". True of the name; false of the credential. The Cursor
60
+ * slots avoid it by publishing a non-secret placeholder that points at a parent-owned local
61
+ * proxy, so the child authenticates to `127.0.0.1` while the real token stays in the parent.
62
+ *
63
+ * This module is deliberately pure: it decides and explains, and it touches no file, no socket
64
+ * and no credential. Wiring and the proxy itself are separate, reviewable pieces — this one can
65
+ * be tested exhaustively without any of them.
66
+ */
67
+
68
+ /** How a slot's credential is stored, as far as `auth.json` is concerned. */
69
+ export type SlotCredentialKind = "oauth" | "api_key" | "none";
70
+
71
+ export interface SlotChildFacts {
72
+ /** Rotation slot id, e.g. `openai-codex-account-4` or a base provider name. */
73
+ slotId: string;
74
+ /** Credential kind held for this exact slot id. */
75
+ credential: SlotCredentialKind;
76
+ /** True when Pi itself defines this provider (it then owns the OAuth flow). */
77
+ builtin: boolean;
78
+ /** `apiKey` published for this slot in `models.json`, when we published one. */
79
+ publishedApiKey?: string;
80
+ /** `baseUrl` published for this slot in `models.json`, when we published one. */
81
+ publishedBaseUrl?: string;
82
+ }
83
+
84
+ export type ChildUsability =
85
+ /** A bare child can authenticate and run on this slot. */
86
+ | { usable: true; slotId: string; via: "builtin" | "api-key" | "parent-proxy"; note: string }
87
+ /** A bare child cannot use it; `remedy` says what would change that. */
88
+ | { usable: false; slotId: string; reason: string; remedy: string };
89
+
90
+ /**
91
+ * Families whose subscription refuses a request that does not carry the parent's client
92
+ * identity, even when Pi resolved the credential. Being built-in does not help here: the
93
+ * failure is the vendor's answer, not Pi's auth lookup.
94
+ */
95
+ function vendorRefusesBareChild(
96
+ slotId: string,
97
+ credential: SlotCredentialKind,
98
+ builtin: boolean,
99
+ ): boolean {
100
+ // Numbered alias slots never get this far: Pi does not consult their OAuth blob at all.
101
+ // The 400 is specific to a built-in Anthropic provider, which does resolve the credential.
102
+ return builtin && credential === "oauth" && /^anthropic(?:-account-\d+)?$/.test(slotId);
103
+ }
104
+
105
+ /** Loopback-only, so a published route can never send a child off this machine. */
106
+ function isLoopback(baseUrl?: string): boolean {
107
+ if (!baseUrl) return false;
108
+ try {
109
+ // `URL` reports an IPv6 host bracketed (`[::1]`), so compare against the unwrapped form.
110
+ const hostname = new URL(baseUrl).hostname.replace(/^\[|\]$/g, "");
111
+ return hostname === "127.0.0.1" || hostname === "localhost" || hostname === "::1";
112
+ } catch {
113
+ return false;
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Decide, for one slot, whether an extension-free child can run on it.
119
+ *
120
+ * Order matters: Pi's resolver keys off the stored credential *type* before it ever looks at a
121
+ * models.json apiKey. An OAuth blob under the same key as a published placeholder is therefore
122
+ * not a usable child route — measured against Pi's own `resolveProviderAuth`: stored OAuth plus a
123
+ * provider that has no OAuth method returns undefined (\"No API key found\"), and stored OAuth
124
+ * plus a built-in OAuth method ignores the placeholder entirely.
125
+ */
126
+ export function classifyChildUsability(facts: SlotChildFacts): ChildUsability {
127
+ const { slotId, credential, builtin, publishedApiKey, publishedBaseUrl } = facts;
128
+
129
+ const vendorRefuses = vendorRefusesBareChild(slotId, credential, builtin);
130
+
131
+ // A built-in provider is resolved by Pi without us — but resolution is not service. When the
132
+ // vendor refuses a bare client, a loopback baseUrl is what actually redirects the request;
133
+ // the published apiKey is not what Pi sends, because the stored credential is still OAuth.
134
+ if (builtin && !vendorRefuses) {
135
+ return {
136
+ usable: true,
137
+ slotId,
138
+ via: "builtin",
139
+ note: "Pi defines this provider itself and owns its auth flow, so a bare child resolves it without this extension.",
140
+ };
141
+ }
142
+
143
+ if (vendorRefuses && isLoopback(publishedBaseUrl)) {
144
+ return {
145
+ usable: true,
146
+ slotId,
147
+ via: "parent-proxy",
148
+ note: "The child still presents the subscription token; the published loopback is what makes the parent shape that request instead of the vendor refusing it as a third-party app.",
149
+ };
150
+ }
151
+
152
+ if (vendorRefuses) {
153
+ return {
154
+ usable: false,
155
+ slotId,
156
+ reason:
157
+ "The credential is a subscription OAuth token. Pi resolves it, but the vendor then refuses the call as a third-party app (\"Third-party apps now draw from your extra usage, not your plan limits\"), because a bare child does not send the client identity the parent adds.",
158
+ remedy:
159
+ "Publish this account against a parent-owned loopback proxy so the child's request is shaped by the parent that holds the subscription.",
160
+ };
161
+ }
162
+
163
+ if (credential === "api_key") {
164
+ if (publishedApiKey && isLoopback(publishedBaseUrl)) {
165
+ return {
166
+ usable: true,
167
+ slotId,
168
+ via: "parent-proxy",
169
+ note: "The child authenticates to a parent-owned loopback route with a non-secret placeholder; the real credential never leaves the parent.",
170
+ };
171
+ }
172
+ return {
173
+ usable: true,
174
+ slotId,
175
+ via: "api-key",
176
+ note: "The credential is an API key, which Pi resolves through its own credential store for a published provider.",
177
+ };
178
+ }
179
+
180
+ if (credential === "oauth") {
181
+ return {
182
+ usable: false,
183
+ slotId,
184
+ reason:
185
+ "The credential is OAuth, and Pi honours OAuth only for a provider definition that declares the flow. A models.json entry declares none, so the token under this key is never consulted — even a published placeholder is ignored, and the slot then fails with \"No API key found\".",
186
+ remedy:
187
+ "While the parent proxy is listening, present this slot to a child as a non-secret api_key placeholder and keep the OAuth blob where only the parent reads it.",
188
+ };
189
+ }
190
+
191
+ return {
192
+ usable: false,
193
+ slotId,
194
+ reason: "No credential is held for this slot.",
195
+ remedy: "Log in to this account, or drop the slot from the published registry.",
196
+ };
197
+ }
198
+
199
+ /**
200
+ * The slot a bare unpinned child would actually be sent to, given Pi's saved global default.
201
+ * Returns `undefined` when that saved route is usable.
202
+ *
203
+ * This is the check that turns a silent, far-away failure into something sayable here: when the
204
+ * active rotation slot is not child-usable, every child spawned without an explicit `--model`
205
+ * falls through Pi's own "first available provider" list instead — which is a different account,
206
+ * often a different vendor, and never the one the rotation chose.
207
+ */
208
+ export function defaultRouteWarning(
209
+ savedDefaultSlotId: string | undefined,
210
+ classify: (slotId: string) => ChildUsability | undefined,
211
+ ): string | undefined {
212
+ if (!savedDefaultSlotId) return undefined;
213
+ const verdict = classify(savedDefaultSlotId);
214
+ if (!verdict || verdict.usable) return undefined;
215
+ return (
216
+ `Pi's saved global default is ${savedDefaultSlotId}, but a bare extension-free child launched ` +
217
+ `without --model cannot use it: ${verdict.reason} Such an unpinned child falls back to Pi's ` +
218
+ `own first-available provider. The live multi-account session and explicitly pinned ` +
219
+ `broker/subagent children are unaffected. ${verdict.remedy}`
220
+ );
221
+ }
222
+
223
+ /** One line per slot, for `/multi-account status`. Stable order: unusable first, then by id. */
224
+ export function describeChildUsability(verdicts: readonly ChildUsability[]): string[] {
225
+ const ordered = [...verdicts].sort(
226
+ (a, b) => Number(a.usable) - Number(b.usable) || a.slotId.localeCompare(b.slotId),
227
+ );
228
+ return ordered.map((verdict) =>
229
+ verdict.usable
230
+ ? ` ${verdict.slotId} — usable by a bare child (${verdict.via})`
231
+ : ` ${verdict.slotId} — NOT usable by a bare child: ${verdict.reason}`,
232
+ );
233
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Guard against Pi throwing away an accumulated compaction summary.
3
+ *
4
+ * Pi's `compact()` has a branch for "the cut fell inside a turn": it summarizes the history
5
+ * range, summarizes the turn prefix, and glues them together. When the history range is empty —
6
+ * which is exactly what happens when a second compaction fires minutes after the first — that
7
+ * branch substitutes the literal string "No prior history." and never passes `previousSummary`
8
+ * to the model at all. The new compaction entry then replaces the accumulated summary with a
9
+ * note about one truncated turn, and everything the session had learned is gone.
10
+ *
11
+ * Seen in a real session: an 11 130-char summary became 2 032 chars beginning "No prior history."
12
+ * and describing work from a different day.
13
+ *
14
+ * We intercept compaction anyway (to route it to a healthy account), so the repair goes here:
15
+ * put the previous summary back where the placeholder is, keeping the turn-prefix context Pi
16
+ * did manage to produce.
17
+ */
18
+
19
+ /** The exact placeholder Pi writes when it has no history left to summarize. */
20
+ const NO_HISTORY_PLACEHOLDER = "No prior history.";
21
+
22
+ export function restorePreviousSummary(summary: string, previousSummary?: string): string {
23
+ if (!summary.startsWith(NO_HISTORY_PLACEHOLDER)) return summary;
24
+ const previous = previousSummary?.trim();
25
+ if (!previous) return summary;
26
+ return `${previous}${summary.slice(NO_HISTORY_PLACEHOLDER.length)}`;
27
+ }
28
+
29
+ /** True when Pi produced a summary that dropped an existing accumulated summary. */
30
+ export function droppedPreviousSummary(summary: string, previousSummary?: string): boolean {
31
+ return summary.startsWith(NO_HISTORY_PLACEHOLDER) && !!previousSummary?.trim();
32
+ }