@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.
- package/README.md +67 -22
- package/config/skills/workflow-code-review-actions/SKILL.md +44 -0
- package/package.json +9 -4
- package/src/core/accounts/oauth-pool.ts +137 -0
- package/src/core/accounts/registry.ts +12 -25
- package/src/core/accounts/routing.ts +144 -0
- package/src/core/anthropic/client-identity.ts +9 -64
- package/src/core/anthropic/identity.ts +54 -0
- package/src/core/anthropic/oauth.ts +3 -0
- package/src/core/anthropic/quota.ts +232 -78
- package/src/core/anthropic/routing.ts +40 -115
- package/src/core/anthropic/store.ts +11 -10
- package/src/core/catalog/quality.ts +33 -15
- package/src/core/codex/quota.ts +46 -8
- package/src/core/codex/store.ts +29 -16
- package/src/core/config.ts +16 -17
- package/src/core/store.ts +13 -12
- package/src/domains/agents/format.ts +97 -0
- package/src/domains/agents/index.ts +58 -47
- package/src/domains/compact/archive.ts +140 -0
- package/src/domains/compact/chunking.ts +166 -0
- package/src/domains/compact/index.ts +452 -0
- package/src/domains/compact/jev.ts +242 -0
- package/src/domains/compact/policy.ts +255 -0
- package/src/domains/compact/types.ts +79 -0
- package/src/domains/models/provider-picker.ts +1 -1
- package/src/domains/setup/index.ts +23 -23
- package/src/domains/subscriptions/accounts-picker.ts +61 -40
- package/src/domains/subscriptions/accounts.ts +53 -36
- package/src/domains/subscriptions/footer.ts +48 -29
- package/src/domains/subscriptions/index.ts +11 -27
- package/src/domains/subscriptions/provider.ts +79 -103
- package/src/domains/subscriptions/providers/anthropic.ts +42 -22
- package/src/domains/subscriptions/providers/codex.ts +146 -113
- package/src/domains/subscriptions/providers/hosted.ts +18 -0
- package/src/domains/subscriptions/providers/oauth-pool.ts +325 -0
- package/src/domains/subscriptions/routing.ts +19 -11
- package/src/domains/workflows/LICENSE.md +21 -0
- package/src/domains/workflows/index.ts +836 -0
- package/src/domains/workflows/runtime/advisory-challenge.ts +75 -0
- package/src/domains/workflows/runtime/advisory-evidence.ts +90 -0
- package/src/domains/workflows/runtime/advisory-schema.ts +83 -0
- package/src/domains/workflows/runtime/agent-attempt.ts +177 -0
- package/src/domains/workflows/runtime/agent-failure.ts +14 -0
- package/src/domains/workflows/runtime/agent-limits.ts +66 -0
- package/src/domains/workflows/runtime/agent-replay.ts +399 -0
- package/src/domains/workflows/runtime/agent-retry.ts +116 -0
- package/src/domains/workflows/runtime/agent-runner-types.ts +96 -0
- package/src/domains/workflows/runtime/agent-runner.ts +225 -0
- package/src/domains/workflows/runtime/agent-session-identity.ts +256 -0
- package/src/domains/workflows/runtime/agent-session-providers.ts +50 -0
- package/src/domains/workflows/runtime/agent-session.ts +382 -0
- package/src/domains/workflows/runtime/agent-skills.ts +270 -0
- package/src/domains/workflows/runtime/agent-workspace.ts +79 -0
- package/src/domains/workflows/runtime/background-workflow-tool.ts +75 -0
- package/src/domains/workflows/runtime/background-workflows.ts +492 -0
- package/src/domains/workflows/runtime/budget.ts +53 -0
- package/src/domains/workflows/runtime/cancellation.ts +87 -0
- package/src/domains/workflows/runtime/command-completions.ts +36 -0
- package/src/domains/workflows/runtime/concurrency.ts +403 -0
- package/src/domains/workflows/runtime/debug.ts +3 -0
- package/src/domains/workflows/runtime/diff-capture.ts +81 -0
- package/src/domains/workflows/runtime/discovery.ts +137 -0
- package/src/domains/workflows/runtime/dynamax-shortcuts.ts +122 -0
- package/src/domains/workflows/runtime/dynamax.ts +330 -0
- package/src/domains/workflows/runtime/engine.ts +579 -0
- package/src/domains/workflows/runtime/filesystem-error.ts +4 -0
- package/src/domains/workflows/runtime/finalizers.ts +66 -0
- package/src/domains/workflows/runtime/identity-canonicalization.ts +138 -0
- package/src/domains/workflows/runtime/identity-fingerprint.ts +15 -0
- package/src/domains/workflows/runtime/inline-workflow.ts +403 -0
- package/src/domains/workflows/runtime/journal.ts +313 -0
- package/src/domains/workflows/runtime/model-profiles.ts +310 -0
- package/src/domains/workflows/runtime/options.ts +157 -0
- package/src/domains/workflows/runtime/perf.ts +146 -0
- package/src/domains/workflows/runtime/pi-compat.ts +32 -0
- package/src/domains/workflows/runtime/process-runner.ts +260 -0
- package/src/domains/workflows/runtime/progress-types.ts +59 -0
- package/src/domains/workflows/runtime/progress.ts +400 -0
- package/src/domains/workflows/runtime/provider-usage-limit.ts +191 -0
- package/src/domains/workflows/runtime/replay-path-identity.ts +48 -0
- package/src/domains/workflows/runtime/research-contract.ts +89 -0
- package/src/domains/workflows/runtime/research-evidence.ts +289 -0
- package/src/domains/workflows/runtime/resume-context.ts +751 -0
- package/src/domains/workflows/runtime/review/code-review-orchestration.ts +24 -0
- package/src/domains/workflows/runtime/review/github-pr-comments.ts +249 -0
- package/src/domains/workflows/runtime/review/patch-validation.ts +120 -0
- package/src/domains/workflows/runtime/review/review-actions.ts +180 -0
- package/src/domains/workflows/runtime/review/review-budget.ts +45 -0
- package/src/domains/workflows/runtime/review/review-fix-workflow.ts +153 -0
- package/src/domains/workflows/runtime/review/review-format.ts +133 -0
- package/src/domains/workflows/runtime/review/review-handoff.ts +38 -0
- package/src/domains/workflows/runtime/review/review-issues.ts +87 -0
- package/src/domains/workflows/runtime/review/review-report.ts +42 -0
- package/src/domains/workflows/runtime/review/review-results-flow.ts +52 -0
- package/src/domains/workflows/runtime/review/review-results-viewer.ts +312 -0
- package/src/domains/workflows/runtime/review/review-session-coordinator.ts +153 -0
- package/src/domains/workflows/runtime/review/review-snapshot.ts +294 -0
- package/src/domains/workflows/runtime/review-diff-target.ts +130 -0
- package/src/domains/workflows/runtime/session-identity.ts +6 -0
- package/src/domains/workflows/runtime/structured-output.ts +32 -0
- package/src/domains/workflows/runtime/tool-capabilities.ts +44 -0
- package/src/domains/workflows/runtime/tool-source-identity.ts +150 -0
- package/src/domains/workflows/runtime/tree-fingerprint.ts +404 -0
- package/src/domains/workflows/runtime/types.ts +255 -0
- package/src/domains/workflows/runtime/ui/display-text.ts +28 -0
- package/src/domains/workflows/runtime/ui/dynamax-editor-decoration.ts +220 -0
- package/src/domains/workflows/runtime/ui/workflow-format.ts +165 -0
- package/src/domains/workflows/runtime/ui/workflow-inspector.ts +435 -0
- package/src/domains/workflows/runtime/ui/workflow-result-renderer.ts +171 -0
- package/src/domains/workflows/runtime/ui/workflow-viewer-layout.ts +51 -0
- package/src/domains/workflows/runtime/ui/workflow-widget.ts +78 -0
- package/src/domains/workflows/runtime/unknown-error.ts +8 -0
- package/src/domains/workflows/runtime/usage.ts +341 -0
- package/src/domains/workflows/runtime/workflow-advisory-utils.ts +245 -0
- package/src/domains/workflows/runtime/workflow-execution.ts +121 -0
- package/src/domains/workflows/runtime/workflow-module.ts +75 -0
- package/src/domains/workflows/runtime/workflow-run-background.ts +72 -0
- package/src/domains/workflows/runtime/workflow-run-controller.ts +342 -0
- package/src/domains/workflows/runtime/workflow-run-history.ts +164 -0
- package/src/domains/workflows/runtime/workflow-run-record.ts +636 -0
- package/src/domains/workflows/runtime/workflow-run-store.ts +176 -0
- package/src/domains/workflows/runtime/workflow-usage-limit-scheduler.ts +80 -0
- package/src/domains/workflows/runtime/workflows.ts +40 -0
- package/src/domains/workflows/runtime/worktree.ts +615 -0
- package/src/domains/workflows/workflows/code-review.ts +232 -0
- package/src/domains/workflows/workflows/diagnose.ts +154 -0
- package/src/domains/workflows/workflows/perf-review.ts +149 -0
- package/src/domains/workflows/workflows/refactor-scout.ts +143 -0
- package/src/domains/workflows/workflows/research.ts +169 -0
- package/src/services/usage-service.ts +6 -30
- package/src/ui/usage-bars.ts +1 -2
- 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
|
[](https://www.npmjs.com/package/@jameslovespancakes/pi-plus)
|
|
8
|
-
[](https://github.com/jameslovespancakes/pi-plus/actions/workflows/ci.yml)
|
|
9
9
|
[](LICENSE)
|
|
10
10
|
[](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 ·
|
|
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
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
/
|
|
57
|
-
/
|
|
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
|
|
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
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
| `/
|
|
165
|
-
| `/
|
|
166
|
-
| `/
|
|
167
|
-
| `/routing
|
|
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:** `
|
|
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
|
-
|
|
218
|
-
|
|
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 #
|
|
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.
|
|
3
|
+
"version": "1.0.13",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|