@jameslovespancakes/pi-plus 1.0.11 → 1.0.13

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 (133) hide show
  1. package/README.md +67 -22
  2. package/config/skills/workflow-code-review-actions/SKILL.md +44 -0
  3. package/package.json +9 -4
  4. package/src/core/accounts/oauth-pool.ts +137 -0
  5. package/src/core/accounts/registry.ts +12 -25
  6. package/src/core/accounts/routing.ts +144 -0
  7. package/src/core/anthropic/client-identity.ts +9 -64
  8. package/src/core/anthropic/identity.ts +54 -0
  9. package/src/core/anthropic/oauth.ts +3 -0
  10. package/src/core/anthropic/quota.ts +232 -78
  11. package/src/core/anthropic/routing.ts +40 -115
  12. package/src/core/anthropic/store.ts +11 -10
  13. package/src/core/catalog/quality.ts +33 -15
  14. package/src/core/codex/quota.ts +46 -8
  15. package/src/core/codex/store.ts +29 -16
  16. package/src/core/config.ts +16 -17
  17. package/src/core/store.ts +13 -12
  18. package/src/domains/agents/format.ts +97 -0
  19. package/src/domains/agents/index.ts +58 -47
  20. package/src/domains/compact/archive.ts +140 -0
  21. package/src/domains/compact/chunking.ts +166 -0
  22. package/src/domains/compact/index.ts +452 -0
  23. package/src/domains/compact/jev.ts +242 -0
  24. package/src/domains/compact/policy.ts +255 -0
  25. package/src/domains/compact/types.ts +79 -0
  26. package/src/domains/models/provider-picker.ts +1 -1
  27. package/src/domains/setup/index.ts +23 -23
  28. package/src/domains/subscriptions/accounts-picker.ts +61 -40
  29. package/src/domains/subscriptions/accounts.ts +53 -36
  30. package/src/domains/subscriptions/footer.ts +48 -29
  31. package/src/domains/subscriptions/index.ts +11 -27
  32. package/src/domains/subscriptions/provider.ts +79 -103
  33. package/src/domains/subscriptions/providers/anthropic.ts +42 -22
  34. package/src/domains/subscriptions/providers/codex.ts +146 -113
  35. package/src/domains/subscriptions/providers/hosted.ts +18 -0
  36. package/src/domains/subscriptions/providers/oauth-pool.ts +325 -0
  37. package/src/domains/subscriptions/routing.ts +19 -11
  38. package/src/domains/workflows/LICENSE.md +21 -0
  39. package/src/domains/workflows/index.ts +836 -0
  40. package/src/domains/workflows/runtime/advisory-challenge.ts +75 -0
  41. package/src/domains/workflows/runtime/advisory-evidence.ts +90 -0
  42. package/src/domains/workflows/runtime/advisory-schema.ts +83 -0
  43. package/src/domains/workflows/runtime/agent-attempt.ts +177 -0
  44. package/src/domains/workflows/runtime/agent-failure.ts +14 -0
  45. package/src/domains/workflows/runtime/agent-limits.ts +66 -0
  46. package/src/domains/workflows/runtime/agent-replay.ts +399 -0
  47. package/src/domains/workflows/runtime/agent-retry.ts +116 -0
  48. package/src/domains/workflows/runtime/agent-runner-types.ts +96 -0
  49. package/src/domains/workflows/runtime/agent-runner.ts +225 -0
  50. package/src/domains/workflows/runtime/agent-session-identity.ts +256 -0
  51. package/src/domains/workflows/runtime/agent-session-providers.ts +50 -0
  52. package/src/domains/workflows/runtime/agent-session.ts +382 -0
  53. package/src/domains/workflows/runtime/agent-skills.ts +270 -0
  54. package/src/domains/workflows/runtime/agent-workspace.ts +79 -0
  55. package/src/domains/workflows/runtime/background-workflow-tool.ts +75 -0
  56. package/src/domains/workflows/runtime/background-workflows.ts +492 -0
  57. package/src/domains/workflows/runtime/budget.ts +53 -0
  58. package/src/domains/workflows/runtime/cancellation.ts +87 -0
  59. package/src/domains/workflows/runtime/command-completions.ts +36 -0
  60. package/src/domains/workflows/runtime/concurrency.ts +403 -0
  61. package/src/domains/workflows/runtime/debug.ts +3 -0
  62. package/src/domains/workflows/runtime/diff-capture.ts +81 -0
  63. package/src/domains/workflows/runtime/discovery.ts +137 -0
  64. package/src/domains/workflows/runtime/dynamax-shortcuts.ts +122 -0
  65. package/src/domains/workflows/runtime/dynamax.ts +330 -0
  66. package/src/domains/workflows/runtime/engine.ts +579 -0
  67. package/src/domains/workflows/runtime/filesystem-error.ts +4 -0
  68. package/src/domains/workflows/runtime/finalizers.ts +66 -0
  69. package/src/domains/workflows/runtime/identity-canonicalization.ts +138 -0
  70. package/src/domains/workflows/runtime/identity-fingerprint.ts +15 -0
  71. package/src/domains/workflows/runtime/inline-workflow.ts +403 -0
  72. package/src/domains/workflows/runtime/journal.ts +313 -0
  73. package/src/domains/workflows/runtime/model-profiles.ts +310 -0
  74. package/src/domains/workflows/runtime/options.ts +157 -0
  75. package/src/domains/workflows/runtime/perf.ts +146 -0
  76. package/src/domains/workflows/runtime/pi-compat.ts +32 -0
  77. package/src/domains/workflows/runtime/process-runner.ts +260 -0
  78. package/src/domains/workflows/runtime/progress-types.ts +59 -0
  79. package/src/domains/workflows/runtime/progress.ts +400 -0
  80. package/src/domains/workflows/runtime/provider-usage-limit.ts +191 -0
  81. package/src/domains/workflows/runtime/replay-path-identity.ts +48 -0
  82. package/src/domains/workflows/runtime/research-contract.ts +89 -0
  83. package/src/domains/workflows/runtime/research-evidence.ts +289 -0
  84. package/src/domains/workflows/runtime/resume-context.ts +751 -0
  85. package/src/domains/workflows/runtime/review/code-review-orchestration.ts +24 -0
  86. package/src/domains/workflows/runtime/review/github-pr-comments.ts +249 -0
  87. package/src/domains/workflows/runtime/review/patch-validation.ts +120 -0
  88. package/src/domains/workflows/runtime/review/review-actions.ts +180 -0
  89. package/src/domains/workflows/runtime/review/review-budget.ts +45 -0
  90. package/src/domains/workflows/runtime/review/review-fix-workflow.ts +153 -0
  91. package/src/domains/workflows/runtime/review/review-format.ts +133 -0
  92. package/src/domains/workflows/runtime/review/review-handoff.ts +38 -0
  93. package/src/domains/workflows/runtime/review/review-issues.ts +87 -0
  94. package/src/domains/workflows/runtime/review/review-report.ts +42 -0
  95. package/src/domains/workflows/runtime/review/review-results-flow.ts +52 -0
  96. package/src/domains/workflows/runtime/review/review-results-viewer.ts +312 -0
  97. package/src/domains/workflows/runtime/review/review-session-coordinator.ts +153 -0
  98. package/src/domains/workflows/runtime/review/review-snapshot.ts +294 -0
  99. package/src/domains/workflows/runtime/review-diff-target.ts +130 -0
  100. package/src/domains/workflows/runtime/session-identity.ts +6 -0
  101. package/src/domains/workflows/runtime/structured-output.ts +32 -0
  102. package/src/domains/workflows/runtime/tool-capabilities.ts +44 -0
  103. package/src/domains/workflows/runtime/tool-source-identity.ts +150 -0
  104. package/src/domains/workflows/runtime/tree-fingerprint.ts +404 -0
  105. package/src/domains/workflows/runtime/types.ts +255 -0
  106. package/src/domains/workflows/runtime/ui/display-text.ts +28 -0
  107. package/src/domains/workflows/runtime/ui/dynamax-editor-decoration.ts +220 -0
  108. package/src/domains/workflows/runtime/ui/workflow-format.ts +165 -0
  109. package/src/domains/workflows/runtime/ui/workflow-inspector.ts +435 -0
  110. package/src/domains/workflows/runtime/ui/workflow-result-renderer.ts +171 -0
  111. package/src/domains/workflows/runtime/ui/workflow-viewer-layout.ts +51 -0
  112. package/src/domains/workflows/runtime/ui/workflow-widget.ts +78 -0
  113. package/src/domains/workflows/runtime/unknown-error.ts +8 -0
  114. package/src/domains/workflows/runtime/usage.ts +341 -0
  115. package/src/domains/workflows/runtime/workflow-advisory-utils.ts +245 -0
  116. package/src/domains/workflows/runtime/workflow-execution.ts +121 -0
  117. package/src/domains/workflows/runtime/workflow-module.ts +75 -0
  118. package/src/domains/workflows/runtime/workflow-run-background.ts +72 -0
  119. package/src/domains/workflows/runtime/workflow-run-controller.ts +342 -0
  120. package/src/domains/workflows/runtime/workflow-run-history.ts +164 -0
  121. package/src/domains/workflows/runtime/workflow-run-record.ts +636 -0
  122. package/src/domains/workflows/runtime/workflow-run-store.ts +176 -0
  123. package/src/domains/workflows/runtime/workflow-usage-limit-scheduler.ts +80 -0
  124. package/src/domains/workflows/runtime/workflows.ts +40 -0
  125. package/src/domains/workflows/runtime/worktree.ts +615 -0
  126. package/src/domains/workflows/workflows/code-review.ts +232 -0
  127. package/src/domains/workflows/workflows/diagnose.ts +154 -0
  128. package/src/domains/workflows/workflows/perf-review.ts +149 -0
  129. package/src/domains/workflows/workflows/refactor-scout.ts +143 -0
  130. package/src/domains/workflows/workflows/research.ts +169 -0
  131. package/src/services/usage-service.ts +6 -30
  132. package/src/ui/usage-bars.ts +1 -2
  133. package/src/core/codex/oauth.ts +0 -129
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  **Everything [pi](https://pi.dev/) is missing, in one install.**
6
6
 
7
7
  [![npm](https://img.shields.io/npm/v/@jameslovespancakes/pi-plus?color=%234D9ABF&label=npm)](https://www.npmjs.com/package/@jameslovespancakes/pi-plus)
8
- [![build](https://github.com/jameslovespancakes/pi-plus/actions/workflows/release.yml/badge.svg?branch=main)](https://github.com/jameslovespancakes/pi-plus/actions/workflows/release.yml)
8
+ [![CI](https://github.com/jameslovespancakes/pi-plus/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/jameslovespancakes/pi-plus/actions/workflows/ci.yml)
9
9
  [![license](https://img.shields.io/badge/license-MIT-F1BE58)](LICENSE)
10
10
  [![pi-package](https://img.shields.io/badge/pi--package-F09082)](https://pi.dev/packages)
11
11
 
@@ -29,7 +29,7 @@ on any row starts that setup.
29
29
  ```
30
30
  ──────────────────────────────────────────────────────────
31
31
  pi-plus
32
- › ● Subscriptions 2 accounts · optimal
32
+ › ● Subscriptions 2 accounts · quota-aware
33
33
  ● Model Information catalogue cached
34
34
  ● Providers 2 allowed · 4 need approval
35
35
  ● Agent Board not configured
@@ -48,19 +48,20 @@ Nothing else is required, every feature configures itself from within pi.
48
48
 
49
49
  ### Pool every subscription
50
50
 
51
- Add all the Claude accounts you own. pi-plus balances sessions across them by
52
- **remaining quota and time-to-reset**, keeps caches sticky, and migrates only on
53
- confirmed exhaustion.
51
+ Add multiple Claude, ChatGPT/Codex, Kimi Code, or xAI/Grok accounts. pi-plus
52
+ keeps credentials separate, refreshes them safely, and supports sequential or
53
+ quota-aware routing.
54
54
 
55
55
  ```
56
- /account anthropic add work
57
- /routing optimal
56
+ /accounts add anthropic work
57
+ /accounts add kimi-coding personal
58
+ /routing quota-aware
58
59
  ```
59
60
 
60
61
  Your live quota, always in the footer:
61
62
 
62
63
  ```
63
- Claude Σ2 · 2/2 ready · partial Codex · pro
64
+ Claude Σ2 · 2/2 ready Codex · pro
64
65
  5h █████████████████░░░░░░░░░ 65% 1h 5h ███████████████████████░░░ 88% 57m
65
66
  weekly ███████████████░░░░░░░░░░░ 58% 3d weekly ████████████████████████░░ 93% 6d
66
67
  Fable ████████████░░░░░░░░░░░░░░ ~47% 3d
@@ -70,9 +71,9 @@ Your live quota, always in the footer:
70
71
  With more than two accounts only the two most recently used are listed, so the
71
72
  footer stays a fixed height however many you pool.
72
73
 
73
- Account and routing commands are provider-agnostic: they dispatch through an
74
- adapter registry, so a second provider is one adapter, not a new command
75
- surface. Anthropic ships today.
74
+ Account and routing commands are provider-agnostic. Sequential routing uses
75
+ account order; quota-aware routing uses reported capacity and fairly probes
76
+ accounts whose provider does not publish quota headers.
76
77
 
77
78
  ### Pick models on evidence
78
79
 
@@ -153,6 +154,42 @@ across every running pi agent. `/board` opens the messaging view:
153
154
  any Mac or Linux host over SSH, where launchd or systemd brings it back after a
154
155
  reboot.
155
156
 
157
+ ### Compact without deleting the source
158
+
159
+ Better Compact replaces Pi's normal compaction with a reversible local archive,
160
+ deterministic protection and extractive compression. Jev mode adds six-signal
161
+ routing through OpenRouter's decisions API; Jev ranks compression but cannot
162
+ override protected facts or authorize source deletion.
163
+
164
+ ```
165
+ /compact better on # switch to local deterministic routing and compact now
166
+ /compact better jev # switch to Jev routing and compact now
167
+ /compact better off # restore Pi compaction and compact now
168
+ ```
169
+
170
+ The selected mode persists for later manual and automatic compactions. Jev mode
171
+ requires `OPENROUTER_API_KEY` (or OpenRouter auth configured in Pi). Original
172
+ chunks stay under `~/.pi/agent/super-context/archives/`; the
173
+ `super_context_recall` tool performs bounded retrieval from the current branch's
174
+ checkpoint.
175
+
176
+ ### Orchestrate repeatable workflows
177
+
178
+ The built-in workflow engine runs named or inline multi-agent workflows with
179
+ optional concurrency limits, replay, background runs, worktree isolation, progress,
180
+ and usage accounting. `code-review`, `diagnose`, `perf-review`,
181
+ `refactor-scout`, and `research` ship in this package—no external workflow
182
+ package is installed.
183
+
184
+ ```
185
+ /workflow code-review HEAD~3
186
+ /workflow research "Compare the current provider implementations"
187
+ ```
188
+
189
+ The `workflow` tool exposes the same engine to the model. Runs have no agent,
190
+ timeout, submission, or token-budget limit by default; set limits explicitly
191
+ with workflow options when a task needs them.
192
+
156
193
  ---
157
194
 
158
195
  ## Commands
@@ -161,10 +198,10 @@ reboot.
161
198
  | --- | --- |
162
199
  | `/pi-plus` | status modal for every feature |
163
200
  | `/pi-plus help` | model explains the pack and what is missing |
164
- | `/account` | account hub: toggle, add, reauth, switch routing |
165
- | `/account <provider> add [label]` | add a subscription |
166
- | `/account <provider> reauth [label]` | reauthorize one |
167
- | `/routing standard \| optimal` | main-first, or balance by quota |
201
+ | `/accounts` | account hub: toggle, add, reauth, switch routing |
202
+ | `/accounts add <provider> [label]` | add a subscription |
203
+ | `/accounts reauth <provider> [label]` | reauthorize one |
204
+ | `/routing sequential \| quota-aware` | account order, or remaining capacity |
168
205
  | `/usage [on\|off\|text]` | quota bars |
169
206
  | `/models [sort]` | ranked catalog |
170
207
  | `/model-info <id>` | every benchmark for one model |
@@ -176,9 +213,12 @@ reboot.
176
213
  | `/remote add \| rename \| remove` | jump to one step |
177
214
  | `/board` | live agent board UI |
178
215
  | `/board setup \| restart \| clear \| status` | manage the board server |
216
+ | `/compact better on \| off \| jev` | select reversible compaction and compact now |
217
+ | `/workflow` | open the running workflow agent board |
218
+ | `/workflow <name> [args]` | run a bundled workflow |
179
219
 
180
- **Tools available to the agent:** `list_models`, `agent_board`, `remote_status`,
181
- `remote_test`.
220
+ **Tools available to the agent:** `workflow`, `list_models`, `agent_board`,
221
+ `remote_status`, `remote_test`, `super_context_recall`.
182
222
 
183
223
  ---
184
224
 
@@ -190,7 +230,8 @@ Everything lives in one file, `~/.pi/agent/pi-plus.json`, created on first use:
190
230
  {
191
231
  "env": { "ARTIFICIAL_ANALYSIS_API_KEY": "aa_…", "AGENT_BOARD_URL": "ws://…" },
192
232
  "policy": { "autoApprove": [], "requireApproval": [], "deny": [] },
193
- "remote": { "workers": [] }
233
+ "remote": { "workers": [] },
234
+ "compact": { "better": "off" }
194
235
  }
195
236
  ```
196
237
 
@@ -213,13 +254,13 @@ src/
213
254
  usage-service, the single quota poller
214
255
  ui/ dumb render primitives: format · usage-bars
215
256
  domains/ one pi extension entry each
216
- setup · subscriptions · models · agents · remote
217
- vendor/ anthropic.ts, the only file importing @cortexkit/*
218
- server/ the agent board server, one file and one dependency (ws)
257
+ setup · subscriptions · models · workflows · agents · remote
258
+ server/ agent board server
259
+ config/ example settings and bundled skills
219
260
  ```
220
261
 
221
262
  ```sh
222
- npm install && npm run verify # typecheck + 106 tests
263
+ npm install && npm run verify # lint + type check + tests
223
264
  ```
224
265
 
225
266
  ---
@@ -232,6 +273,10 @@ pi-plus is a thin layer over other people's work.
232
273
  | --- | --- | --- |
233
274
  | [`pi`](https://pi.dev/) | the host agent and the entire extension API | MIT |
234
275
  | [`xxhash-wasm`](https://github.com/jungomi/xxhash-wasm) | vendored into `src/core/anthropic/vendor/` for the billing checksum | MIT |
276
+ | [`pi-workflow-engine`](https://github.com/timbrinded/pi-workflow-engine) | embedded workflow runtime and built-in workflows | MIT |
277
+
278
+ The workflow engine keeps its upstream license in
279
+ [`src/domains/workflows/LICENSE.md`](src/domains/workflows/LICENSE.md).
235
280
 
236
281
  The Anthropic provider, OAuth, quota and routing were originally adopted from
237
282
  [`@cortexkit/pi-anthropic-auth`](https://github.com/cortexkit/anthropic-auth)
@@ -0,0 +1,44 @@
1
+ ---
2
+ name: workflow-code-review-actions
3
+ description: "Act on selected workflow code-review findings: make minimal fixes or post GitHub PR inline comments using gh, GitHub MCP/tools, or project-specific tools."
4
+ ---
5
+
6
+ Use this skill when the parent agent receives selected findings from the workflow code review.
7
+
8
+ ## Inputs
9
+
10
+ The prompt will include compact JSON with:
11
+
12
+ - `context`: workflow name, target, diff command, changed files, and optional summary.
13
+ - `issues`: selected findings with `id`, `summary`, `category`, `severity`, `confidence`, `location`, `impact`, `evidence`, and `recommendation`.
14
+
15
+ ## Fix mode
16
+
17
+ When mode is `fix selected code-review findings`:
18
+
19
+ 1. Inspect the selected issue JSON before editing.
20
+ 2. Make minimal edits that address only the selected findings.
21
+ 3. Preserve unrelated user changes and avoid broad refactors.
22
+ 4. Run focused validation if available for the touched files or behavior.
23
+ 5. Summarize changed files and validation results.
24
+ 6. Do not post GitHub PR comments in fix mode.
25
+
26
+ ## Comment mode
27
+
28
+ When mode is `post inline GitHub PR comments`:
29
+
30
+ 1. Do not edit files or make code changes.
31
+ 2. Prefer installed GitHub MCP/tools if visible in the active tool list.
32
+ 3. If no GitHub MCP/tools are available, use `gh`:
33
+ - Resolve the PR with `gh pr view --json number,headRefOid,url,headRepositoryOwner,headRepository`.
34
+ - Resolve the repository with `gh repo view --json nameWithOwner` when owner/name is missing.
35
+ - Post each inline comment with `gh api repos/{owner}/{repo}/pulls/{number}/comments` and include `commit_id`, `path`, `line`, and `side=RIGHT`.
36
+ 4. Keep each comment concise: summary, severity/confidence/category, impact, evidence, and recommendation.
37
+ 5. Report posted, skipped, and failed counts.
38
+
39
+ ## Safety rules
40
+
41
+ - Do not post duplicate comments.
42
+ - Do not comment line-less findings inline.
43
+ - Ask the user if the upstream PR cannot be identified.
44
+ - Keep actions scoped to the selected issue IDs only.
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@jameslovespancakes/pi-plus",
3
- "version": "1.0.11",
3
+ "version": "1.0.13",
4
4
  "type": "module",
5
- "description": "Consolidated pi extensions: Claude subscription accounts and quota HUD, benchmark-driven model catalog and billing policy, live agent board, and capacity-gated remote test workers.",
5
+ "description": "pi and more",
6
6
  "license": "MIT",
7
7
  "author": "james",
8
8
  "keywords": [
@@ -12,7 +12,9 @@
12
12
  "anthropic",
13
13
  "claude",
14
14
  "quota",
15
- "models"
15
+ "models",
16
+ "workflows",
17
+ "multi-agent"
16
18
  ],
17
19
  "repository": {
18
20
  "type": "git",
@@ -35,6 +37,8 @@
35
37
  "./src/domains/setup/index.ts",
36
38
  "./src/domains/subscriptions/index.ts",
37
39
  "./src/domains/models/index.ts",
40
+ "./src/domains/compact/index.ts",
41
+ "./src/domains/workflows/index.ts",
38
42
  "./src/domains/agents/index.ts",
39
43
  "./src/domains/remote/index.ts"
40
44
  ],
@@ -61,6 +65,7 @@
61
65
  "version:next": "node scripts/bump-version.mjs --dry-run"
62
66
  },
63
67
  "devDependencies": {
64
- "oxlint": "^1.83.0"
68
+ "oxlint": "^1.83.0",
69
+ "typescript": "5.9.3"
65
70
  }
66
71
  }
@@ -0,0 +1,137 @@
1
+ import type { OAuthCredential } from "@earendil-works/pi-ai";
2
+ import { agentPath, readJson, writeJson } from "../store.ts";
3
+ import { normalizeRoutingMode, type AccountQuotaState, type AccountRoutingMode } from "./routing.ts";
4
+
5
+ export type OAuthPoolMode = AccountRoutingMode;
6
+
7
+ export interface PooledOAuthAccount extends OAuthCredential {
8
+ id: string;
9
+ label: string;
10
+ enabled?: boolean;
11
+ identity?: string;
12
+ addedAt: number;
13
+ lastUsed?: number;
14
+ quota?: AccountQuotaState;
15
+ }
16
+
17
+ export interface ProviderOAuthPool {
18
+ accounts: PooledOAuthAccount[];
19
+ mode: OAuthPoolMode;
20
+ }
21
+
22
+ /**
23
+ * Storage behind a pooled provider.
24
+ *
25
+ * Most providers share one `pi-plus-oauth-accounts.json`, but a provider that
26
+ * already owns a file with extra fields (Codex carries `accountId`/`plan`, and
27
+ * its quota snapshot feeds the usage bars) supplies its own implementation so
28
+ * it can join the shared serving path without a risky file migration.
29
+ */
30
+ export interface PooledOAuthStore {
31
+ load(): ProviderOAuthPool;
32
+ saveAccount(account: PooledOAuthAccount): void;
33
+ saveMode(mode: OAuthPoolMode): void;
34
+ /** Routing state for the credential pi itself is logged in as. */
35
+ primaryQuota(): AccountQuotaState | undefined;
36
+ }
37
+
38
+ interface OAuthPoolFile {
39
+ version: 1;
40
+ providers: Record<string, ProviderOAuthPool>;
41
+ }
42
+
43
+ const EMPTY_FILE: OAuthPoolFile = { version: 1, providers: {} };
44
+ const DEFAULT_PATH = "pi-plus-oauth-accounts.json";
45
+ const fileCache = new Map<string, OAuthPoolFile>();
46
+
47
+ export function oauthPoolPath(): string {
48
+ return process.env.PI_PLUS_OAUTH_ACCOUNTS_FILE ?? agentPath(DEFAULT_PATH);
49
+ }
50
+
51
+ function loadFile(path = oauthPoolPath()): OAuthPoolFile {
52
+ const cached = fileCache.get(path);
53
+ if (cached) return cached;
54
+
55
+ const raw = readJson<Partial<OAuthPoolFile>>(path, EMPTY_FILE);
56
+ const file: OAuthPoolFile = {
57
+ version: 1,
58
+ providers: raw.providers && typeof raw.providers === "object" ? raw.providers : {},
59
+ };
60
+ fileCache.set(path, file);
61
+ return file;
62
+ }
63
+
64
+ function saveFile(file: OAuthPoolFile, path = oauthPoolPath()): void {
65
+ fileCache.set(path, file);
66
+ if (!writeJson(path, file, true, 0o600)) throw new Error(`Could not write OAuth accounts to ${path}`);
67
+ }
68
+
69
+ export function loadOAuthPool(providerId: string, path = oauthPoolPath()): ProviderOAuthPool {
70
+ const pool = loadFile(path).providers[providerId];
71
+ return {
72
+ accounts: Array.isArray(pool?.accounts) ? pool.accounts : [],
73
+ mode: normalizeRoutingMode(pool?.mode as string | undefined),
74
+ };
75
+ }
76
+
77
+ export function saveOAuthPool(providerId: string, pool: ProviderOAuthPool, path = oauthPoolPath()): void {
78
+ const file = loadFile(path);
79
+ file.providers[providerId] = pool;
80
+ saveFile(file, path);
81
+ }
82
+
83
+ export function saveOAuthAccount(providerId: string, account: PooledOAuthAccount, path = oauthPoolPath()): void {
84
+ const pool = loadOAuthPool(providerId, path);
85
+ const index = pool.accounts.findIndex((candidate) => candidate.id === account.id);
86
+ if (index === -1) pool.accounts.push(account);
87
+ else pool.accounts[index] = { ...pool.accounts[index], ...account };
88
+ saveOAuthPool(providerId, pool, path);
89
+ }
90
+
91
+ export function removeOAuthAccount(providerId: string, accountId: string, path = oauthPoolPath()): boolean {
92
+ const pool = loadOAuthPool(providerId, path);
93
+ const accounts = pool.accounts.filter((account) => account.id !== accountId);
94
+ if (accounts.length === pool.accounts.length) return false;
95
+ saveOAuthPool(providerId, { ...pool, accounts }, path);
96
+ return true;
97
+ }
98
+
99
+ export function setOAuthPoolMode(providerId: string, mode: OAuthPoolMode, path = oauthPoolPath()): void {
100
+ saveOAuthPool(providerId, { ...loadOAuthPool(providerId, path), mode }, path);
101
+ }
102
+
103
+ export function oauthIdentity(access: string): string | undefined {
104
+ try {
105
+ const payload = JSON.parse(Buffer.from(access.split(".")[1] ?? "", "base64url").toString()) as Record<string, unknown>;
106
+ for (const key of ["sub", "email", "account_id", "user_id", "uid"]) {
107
+ const value = payload[key];
108
+ if (typeof value === "string" && value) return `${key}:${value}`;
109
+ }
110
+ } catch {
111
+ // Opaque access tokens have no local identity.
112
+ }
113
+ return undefined;
114
+ }
115
+
116
+ /** In-memory quota for each provider's primary (host) credential. */
117
+ const primaryQuotas = new Map<string, AccountQuotaState>();
118
+
119
+ /** Default store: the shared pool file, with primary quota held in memory. */
120
+ export function sharedOAuthPoolStore(providerId: string): PooledOAuthStore {
121
+ return {
122
+ load: () => loadOAuthPool(providerId),
123
+ saveAccount: (account) => saveOAuthAccount(providerId, account),
124
+ saveMode: (mode) => setOAuthPoolMode(providerId, mode),
125
+ primaryQuota: () => primaryQuotas.get(providerId),
126
+ };
127
+ }
128
+
129
+ /** Records routing state for a provider's primary credential. */
130
+ export function setPrimaryQuota(providerId: string, quota: AccountQuotaState): void {
131
+ primaryQuotas.set(providerId, quota);
132
+ }
133
+
134
+ export function resetOAuthPoolCache(): void {
135
+ fileCache.clear();
136
+ primaryQuotas.clear();
137
+ }
@@ -1,13 +1,4 @@
1
- /**
2
- * Provider-agnostic subscription accounts.
3
- *
4
- * Today only Anthropic implements this, through
5
- * `domains/subscriptions/providers/anthropic.ts`. The point of the indirection
6
- * is that `/account` and `/routing` contain no provider-specific logic, so a
7
- * second provider is a new adapter rather than a new command surface.
8
- *
9
- * Structural types only; nothing here imports pi.
10
- */
1
+ /** Provider-agnostic subscription account adapters. */
11
2
 
12
3
  export interface ManagedAccount {
13
4
  id: string;
@@ -18,17 +9,16 @@ export interface ManagedAccount {
18
9
  expiresAt?: number;
19
10
  /** True for the account pi itself is authenticated as. */
20
11
  primary?: boolean;
12
+ /** Provider-stable identity used only to collapse duplicate logins. */
13
+ identity?: string;
21
14
  }
22
15
 
23
- /**
24
- * `standard` uses the main account first and falls back only when it is
25
- * exhausted. `optimal` balances across accounts by remaining quota and time to
26
- * reset, keeping session caches sticky.
27
- */
28
- export type RoutingMode = "standard" | "optimal";
16
+ /** Fixed account order or provider quota-aware selection. */
17
+ export type RoutingMode = "sequential" | "quota-aware";
29
18
 
30
19
  export interface AccountUi {
31
20
  input(title: string, placeholder?: string): Promise<string | undefined>;
21
+ select(title: string, options: string[]): Promise<string | undefined>;
32
22
  confirm(title: string, message: string): Promise<boolean>;
33
23
  notify(message: string, type?: "info" | "warning" | "error"): void;
34
24
  }
@@ -36,7 +26,8 @@ export interface AccountUi {
36
26
  export interface AccountContext {
37
27
  ui: AccountUi;
38
28
  hasUI: boolean;
39
- /** Opens a URL in the user's browser, for OAuth flows. */
29
+ signal?: AbortSignal;
30
+ /** Opens an OAuth URL. */
40
31
  openBrowser(url: string): Promise<void>;
41
32
  }
42
33
 
@@ -52,19 +43,15 @@ export interface AccountProvider {
52
43
  /** Human name, e.g. "Claude". */
53
44
  label: string;
54
45
  list(): Promise<ManagedAccount[]>;
46
+ /** Resolves a provider-stable identity from an OAuth access token. */
47
+ identify?(accessToken: string): string | undefined | Promise<string | undefined>;
55
48
  /** Returns the label of the account that was added. */
56
49
  add(ctx: AccountContext, label: string): Promise<string | undefined>;
57
50
  /** Returns the label of the account that was reauthorized. */
58
51
  reauth(ctx: AccountContext, accountId: string): Promise<string | undefined>;
59
- /**
60
- * Enables or disables one account without removing its credentials.
61
- * Optional: a provider that cannot suspend accounts simply omits it.
62
- */
52
+ /** Enables or disables an account without removing credentials. */
63
53
  setEnabled?(accountId: string, enabled: boolean): Promise<void>;
64
- /**
65
- * Changes an account's display label. Credentials are untouched, so this is
66
- * purely cosmetic and never needs a re-authorization.
67
- */
54
+ /** Changes only the display label. */
68
55
  rename?(accountId: string, label: string): Promise<void>;
69
56
  routing?: RoutingSupport;
70
57
  }
@@ -0,0 +1,144 @@
1
+ export interface AccountQuotaState {
2
+ remainingPercent?: number;
3
+ resetAt?: number;
4
+ checkedAt: number;
5
+ blockedUntil?: number;
6
+ }
7
+
8
+ export interface AccountRoutingCandidate<T> {
9
+ readonly id: string;
10
+ readonly order: number;
11
+ readonly lastUsed: number;
12
+ readonly quota?: AccountQuotaState;
13
+ readonly value: T;
14
+ }
15
+
16
+ export type AccountRoutingMode = "sequential" | "quota-aware";
17
+
18
+ /**
19
+ * Upper bound on a token refresh, matching pi-ai's own
20
+ * `DEFAULT_OAUTH_REFRESH_TIMEOUT_MS` in `auth/resolve.js`.
21
+ *
22
+ * Routed auth runs outside pi-ai's refresh path, so nothing else bounds it.
23
+ * An unbounded refresh against a stalled token endpoint hangs the request
24
+ * forever, and because in-flight refreshes are de-duplicated per account,
25
+ * every later request joins the same hung promise.
26
+ */
27
+ export const OAUTH_REFRESH_TIMEOUT_MS = 15_000;
28
+
29
+ /** Bounds a refresh, honouring a caller signal when one is available. */
30
+ export function refreshAbortSignal(signal?: AbortSignal): AbortSignal {
31
+ const timeout = AbortSignal.timeout(OAUTH_REFRESH_TIMEOUT_MS);
32
+ return signal ? AbortSignal.any([signal, timeout]) : timeout;
33
+ }
34
+
35
+ /** Maps stored legacy names onto the two routing modes. */
36
+ export function normalizeRoutingMode(value: string | undefined): AccountRoutingMode {
37
+ return value === "quota-aware" || value === "optimal" || value === "sticky-balanced"
38
+ ? "quota-aware"
39
+ : "sequential";
40
+ }
41
+
42
+ /** Sequential keeps account order; quota-aware prefers unmeasured then highest remaining capacity. */
43
+ export function selectRoutingCandidate<T>(
44
+ candidates: readonly AccountRoutingCandidate<T>[],
45
+ mode: AccountRoutingMode,
46
+ now = Date.now(),
47
+ ): AccountRoutingCandidate<T> | undefined {
48
+ if (candidates.length === 0) return undefined;
49
+ const viable = candidates.filter((candidate) => isCandidateViable(candidate.quota, now));
50
+ if (viable.length === 0) return undefined;
51
+
52
+ if (mode === "sequential") {
53
+ return [...viable].sort((left, right) => left.order - right.order)[0];
54
+ }
55
+
56
+ const unmeasured = viable.filter((candidate) => candidate.quota?.remainingPercent === undefined);
57
+ if (unmeasured.length > 0) return leastRecentlyUsed(unmeasured);
58
+
59
+ return [...viable].sort((left, right) =>
60
+ (right.quota?.remainingPercent ?? 0) - (left.quota?.remainingPercent ?? 0)
61
+ || left.lastUsed - right.lastUsed
62
+ || left.order - right.order
63
+ || left.id.localeCompare(right.id))[0];
64
+ }
65
+
66
+ export function quotaStateFromHeaders(
67
+ status: number,
68
+ headers: Record<string, string>,
69
+ previous?: AccountQuotaState,
70
+ now = Date.now(),
71
+ ): AccountQuotaState | undefined {
72
+ const normalized = new Map(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]));
73
+ const remaining = firstNumber(normalized, [
74
+ "x-ratelimit-remaining-requests",
75
+ "ratelimit-remaining-requests",
76
+ "x-ratelimit-remaining",
77
+ "ratelimit-remaining",
78
+ ]);
79
+ const limit = firstNumber(normalized, [
80
+ "x-ratelimit-limit-requests",
81
+ "ratelimit-limit-requests",
82
+ "x-ratelimit-limit",
83
+ "ratelimit-limit",
84
+ ]);
85
+ const resetAt = parseResetAt(normalized, now);
86
+
87
+ if (status === 429) {
88
+ return {
89
+ remainingPercent: 0,
90
+ resetAt,
91
+ checkedAt: now,
92
+ blockedUntil: resetAt ?? now + 60_000,
93
+ };
94
+ }
95
+ if (remaining === undefined) return previous;
96
+
97
+ const remainingPercent = limit && limit > 0
98
+ ? Math.max(0, Math.min(100, remaining / limit * 100))
99
+ : Math.max(0, Math.min(100, remaining));
100
+ return { remainingPercent, resetAt, checkedAt: now };
101
+ }
102
+
103
+ /**
104
+ * Exhausted-with-no-reset is already handled by `isCandidateViable` below, so
105
+ * callers never need a sentinel `blockedUntil`. Persisting `Infinity` would
106
+ * serialize to `null` and silently unblock on the next load.
107
+ */
108
+ function isCandidateViable(quota: AccountQuotaState | undefined, now: number): boolean {
109
+ if (!quota) return true;
110
+ if (quota.blockedUntil !== undefined && quota.blockedUntil > now) return false;
111
+ if (quota.remainingPercent !== 0) return true;
112
+ return quota.resetAt !== undefined && quota.resetAt <= now;
113
+ }
114
+
115
+ function leastRecentlyUsed<T>(candidates: readonly AccountRoutingCandidate<T>[]): AccountRoutingCandidate<T> {
116
+ return [...candidates].sort((left, right) =>
117
+ left.lastUsed - right.lastUsed
118
+ || left.order - right.order
119
+ || left.id.localeCompare(right.id))[0];
120
+ }
121
+
122
+ function firstNumber(headers: ReadonlyMap<string, string>, names: readonly string[]): number | undefined {
123
+ for (const name of names) {
124
+ const value = Number(headers.get(name));
125
+ if (Number.isFinite(value)) return value;
126
+ }
127
+ return undefined;
128
+ }
129
+
130
+ function parseResetAt(headers: ReadonlyMap<string, string>, now: number): number | undefined {
131
+ const retryAfter = Number(headers.get("retry-after"));
132
+ if (Number.isFinite(retryAfter) && retryAfter >= 0) return now + retryAfter * 1_000;
133
+
134
+ const raw = firstNumber(headers, [
135
+ "x-ratelimit-reset-requests",
136
+ "ratelimit-reset-requests",
137
+ "x-ratelimit-reset",
138
+ "ratelimit-reset",
139
+ ]);
140
+ if (raw === undefined) return undefined;
141
+ if (raw > 1_000_000_000_000) return raw;
142
+ if (raw > 1_000_000_000) return raw * 1_000;
143
+ return now + raw * 1_000;
144
+ }