@jameslovespancakes/pi-plus 1.0.10 → 1.0.12

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 (120) hide show
  1. package/README.md +44 -20
  2. package/config/skills/workflow-code-review-actions/SKILL.md +44 -0
  3. package/package.json +6 -3
  4. package/src/core/accounts/oauth-pool.ts +102 -0
  5. package/src/core/accounts/registry.ts +8 -25
  6. package/src/core/accounts/routing.ts +122 -0
  7. package/src/core/anthropic/client-identity.ts +9 -64
  8. package/src/core/anthropic/quota.ts +8 -53
  9. package/src/core/anthropic/routing.ts +40 -115
  10. package/src/core/anthropic/store.ts +7 -9
  11. package/src/core/catalog/quality.ts +33 -15
  12. package/src/core/codex/quota.ts +8 -2
  13. package/src/core/codex/store.ts +12 -9
  14. package/src/core/config.ts +4 -17
  15. package/src/core/store.ts +13 -12
  16. package/src/domains/models/provider-picker.ts +1 -1
  17. package/src/domains/setup/index.ts +23 -23
  18. package/src/domains/subscriptions/accounts-picker.ts +18 -37
  19. package/src/domains/subscriptions/accounts.ts +23 -29
  20. package/src/domains/subscriptions/footer.ts +48 -29
  21. package/src/domains/subscriptions/index.ts +11 -27
  22. package/src/domains/subscriptions/provider.ts +38 -103
  23. package/src/domains/subscriptions/providers/anthropic.ts +5 -17
  24. package/src/domains/subscriptions/providers/codex.ts +171 -4
  25. package/src/domains/subscriptions/providers/hosted.ts +18 -0
  26. package/src/domains/subscriptions/providers/oauth-pool.ts +273 -0
  27. package/src/domains/subscriptions/routing.ts +19 -11
  28. package/src/domains/workflows/LICENSE.md +21 -0
  29. package/src/domains/workflows/index.ts +836 -0
  30. package/src/domains/workflows/runtime/advisory-challenge.ts +75 -0
  31. package/src/domains/workflows/runtime/advisory-evidence.ts +90 -0
  32. package/src/domains/workflows/runtime/advisory-schema.ts +83 -0
  33. package/src/domains/workflows/runtime/agent-attempt.ts +147 -0
  34. package/src/domains/workflows/runtime/agent-limits.ts +43 -0
  35. package/src/domains/workflows/runtime/agent-replay.ts +399 -0
  36. package/src/domains/workflows/runtime/agent-retry.ts +116 -0
  37. package/src/domains/workflows/runtime/agent-runner-types.ts +96 -0
  38. package/src/domains/workflows/runtime/agent-runner.ts +203 -0
  39. package/src/domains/workflows/runtime/agent-session-identity.ts +256 -0
  40. package/src/domains/workflows/runtime/agent-session-providers.ts +50 -0
  41. package/src/domains/workflows/runtime/agent-session.ts +382 -0
  42. package/src/domains/workflows/runtime/agent-skills.ts +270 -0
  43. package/src/domains/workflows/runtime/agent-workspace.ts +103 -0
  44. package/src/domains/workflows/runtime/background-workflow-tool.ts +75 -0
  45. package/src/domains/workflows/runtime/background-workflows.ts +492 -0
  46. package/src/domains/workflows/runtime/budget.ts +53 -0
  47. package/src/domains/workflows/runtime/cancellation.ts +87 -0
  48. package/src/domains/workflows/runtime/command-completions.ts +36 -0
  49. package/src/domains/workflows/runtime/concurrency.ts +403 -0
  50. package/src/domains/workflows/runtime/debug.ts +3 -0
  51. package/src/domains/workflows/runtime/diff-capture.ts +81 -0
  52. package/src/domains/workflows/runtime/discovery.ts +137 -0
  53. package/src/domains/workflows/runtime/dynamax-shortcuts.ts +122 -0
  54. package/src/domains/workflows/runtime/dynamax.ts +330 -0
  55. package/src/domains/workflows/runtime/engine.ts +543 -0
  56. package/src/domains/workflows/runtime/filesystem-error.ts +4 -0
  57. package/src/domains/workflows/runtime/finalizers.ts +66 -0
  58. package/src/domains/workflows/runtime/identity-canonicalization.ts +138 -0
  59. package/src/domains/workflows/runtime/identity-fingerprint.ts +15 -0
  60. package/src/domains/workflows/runtime/inline-workflow.ts +403 -0
  61. package/src/domains/workflows/runtime/journal.ts +313 -0
  62. package/src/domains/workflows/runtime/model-profiles.ts +310 -0
  63. package/src/domains/workflows/runtime/options.ts +157 -0
  64. package/src/domains/workflows/runtime/perf.ts +146 -0
  65. package/src/domains/workflows/runtime/pi-compat.ts +32 -0
  66. package/src/domains/workflows/runtime/process-runner.ts +260 -0
  67. package/src/domains/workflows/runtime/progress-types.ts +59 -0
  68. package/src/domains/workflows/runtime/progress.ts +400 -0
  69. package/src/domains/workflows/runtime/provider-usage-limit.ts +191 -0
  70. package/src/domains/workflows/runtime/replay-path-identity.ts +48 -0
  71. package/src/domains/workflows/runtime/research-contract.ts +89 -0
  72. package/src/domains/workflows/runtime/research-evidence.ts +289 -0
  73. package/src/domains/workflows/runtime/resume-context.ts +751 -0
  74. package/src/domains/workflows/runtime/review/code-review-orchestration.ts +24 -0
  75. package/src/domains/workflows/runtime/review/github-pr-comments.ts +249 -0
  76. package/src/domains/workflows/runtime/review/patch-validation.ts +120 -0
  77. package/src/domains/workflows/runtime/review/review-actions.ts +180 -0
  78. package/src/domains/workflows/runtime/review/review-budget.ts +45 -0
  79. package/src/domains/workflows/runtime/review/review-fix-workflow.ts +153 -0
  80. package/src/domains/workflows/runtime/review/review-format.ts +133 -0
  81. package/src/domains/workflows/runtime/review/review-handoff.ts +38 -0
  82. package/src/domains/workflows/runtime/review/review-issues.ts +87 -0
  83. package/src/domains/workflows/runtime/review/review-report.ts +42 -0
  84. package/src/domains/workflows/runtime/review/review-results-flow.ts +52 -0
  85. package/src/domains/workflows/runtime/review/review-results-viewer.ts +312 -0
  86. package/src/domains/workflows/runtime/review/review-session-coordinator.ts +153 -0
  87. package/src/domains/workflows/runtime/review/review-snapshot.ts +294 -0
  88. package/src/domains/workflows/runtime/review-diff-target.ts +130 -0
  89. package/src/domains/workflows/runtime/session-identity.ts +6 -0
  90. package/src/domains/workflows/runtime/structured-output.ts +32 -0
  91. package/src/domains/workflows/runtime/tool-capabilities.ts +44 -0
  92. package/src/domains/workflows/runtime/tool-source-identity.ts +150 -0
  93. package/src/domains/workflows/runtime/tree-fingerprint.ts +404 -0
  94. package/src/domains/workflows/runtime/types.ts +255 -0
  95. package/src/domains/workflows/runtime/ui/display-text.ts +13 -0
  96. package/src/domains/workflows/runtime/ui/dynamax-editor-decoration.ts +220 -0
  97. package/src/domains/workflows/runtime/ui/workflow-format.ts +165 -0
  98. package/src/domains/workflows/runtime/ui/workflow-inspector.ts +398 -0
  99. package/src/domains/workflows/runtime/ui/workflow-result-renderer.ts +171 -0
  100. package/src/domains/workflows/runtime/ui/workflow-viewer-layout.ts +51 -0
  101. package/src/domains/workflows/runtime/ui/workflow-widget.ts +78 -0
  102. package/src/domains/workflows/runtime/unknown-error.ts +8 -0
  103. package/src/domains/workflows/runtime/usage.ts +341 -0
  104. package/src/domains/workflows/runtime/workflow-advisory-utils.ts +245 -0
  105. package/src/domains/workflows/runtime/workflow-execution.ts +121 -0
  106. package/src/domains/workflows/runtime/workflow-module.ts +75 -0
  107. package/src/domains/workflows/runtime/workflow-run-background.ts +72 -0
  108. package/src/domains/workflows/runtime/workflow-run-controller.ts +342 -0
  109. package/src/domains/workflows/runtime/workflow-run-history.ts +162 -0
  110. package/src/domains/workflows/runtime/workflow-run-record.ts +635 -0
  111. package/src/domains/workflows/runtime/workflow-run-store.ts +176 -0
  112. package/src/domains/workflows/runtime/workflow-usage-limit-scheduler.ts +80 -0
  113. package/src/domains/workflows/runtime/workflows.ts +40 -0
  114. package/src/domains/workflows/runtime/worktree.ts +581 -0
  115. package/src/domains/workflows/workflows/code-review.ts +232 -0
  116. package/src/domains/workflows/workflows/diagnose.ts +154 -0
  117. package/src/domains/workflows/workflows/perf-review.ts +149 -0
  118. package/src/domains/workflows/workflows/refactor-scout.ts +143 -0
  119. package/src/domains/workflows/workflows/research.ts +169 -0
  120. package/src/services/usage-service.ts +6 -30
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,13 +48,14 @@ 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:
@@ -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,23 @@ 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
+ ### Orchestrate repeatable workflows
158
+
159
+ The built-in workflow engine runs named or inline multi-agent workflows with
160
+ optional concurrency limits, replay, background runs, worktree isolation, progress,
161
+ and usage accounting. `code-review`, `diagnose`, `perf-review`,
162
+ `refactor-scout`, and `research` ship in this package—no external workflow
163
+ package is installed.
164
+
165
+ ```
166
+ /workflow code-review HEAD~3
167
+ /workflow research "Compare the current provider implementations"
168
+ ```
169
+
170
+ The `workflow` tool exposes the same engine to the model. Runs have no agent,
171
+ timeout, submission, or token-budget limit by default; set limits explicitly
172
+ with workflow options when a task needs them.
173
+
156
174
  ---
157
175
 
158
176
  ## Commands
@@ -161,10 +179,10 @@ reboot.
161
179
  | --- | --- |
162
180
  | `/pi-plus` | status modal for every feature |
163
181
  | `/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 |
182
+ | `/accounts` | account hub: toggle, add, reauth, switch routing |
183
+ | `/accounts add <provider> [label]` | add a subscription |
184
+ | `/accounts reauth <provider> [label]` | reauthorize one |
185
+ | `/routing sequential \| quota-aware` | account order, or remaining capacity |
168
186
  | `/usage [on\|off\|text]` | quota bars |
169
187
  | `/models [sort]` | ranked catalog |
170
188
  | `/model-info <id>` | every benchmark for one model |
@@ -176,9 +194,11 @@ reboot.
176
194
  | `/remote add \| rename \| remove` | jump to one step |
177
195
  | `/board` | live agent board UI |
178
196
  | `/board setup \| restart \| clear \| status` | manage the board server |
197
+ | `/workflow` | open the running workflow agent board |
198
+ | `/workflow <name> [args]` | run a bundled workflow |
179
199
 
180
- **Tools available to the agent:** `list_models`, `agent_board`, `remote_status`,
181
- `remote_test`.
200
+ **Tools available to the agent:** `workflow`, `list_models`, `agent_board`,
201
+ `remote_status`, `remote_test`.
182
202
 
183
203
  ---
184
204
 
@@ -213,13 +233,13 @@ src/
213
233
  usage-service, the single quota poller
214
234
  ui/ dumb render primitives: format · usage-bars
215
235
  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)
236
+ setup · subscriptions · models · workflows · agents · remote
237
+ server/ agent board server
238
+ config/ example settings and bundled skills
219
239
  ```
220
240
 
221
241
  ```sh
222
- npm install && npm run verify # typecheck + 106 tests
242
+ npm install && npm run verify # lint + type check + tests
223
243
  ```
224
244
 
225
245
  ---
@@ -232,6 +252,10 @@ pi-plus is a thin layer over other people's work.
232
252
  | --- | --- | --- |
233
253
  | [`pi`](https://pi.dev/) | the host agent and the entire extension API | MIT |
234
254
  | [`xxhash-wasm`](https://github.com/jungomi/xxhash-wasm) | vendored into `src/core/anthropic/vendor/` for the billing checksum | MIT |
255
+ | [`pi-workflow-engine`](https://github.com/timbrinded/pi-workflow-engine) | embedded workflow runtime and built-in workflows | MIT |
256
+
257
+ The workflow engine keeps its upstream license in
258
+ [`src/domains/workflows/LICENSE.md`](src/domains/workflows/LICENSE.md).
235
259
 
236
260
  The Anthropic provider, OAuth, quota and routing were originally adopted from
237
261
  [`@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.10",
3
+ "version": "1.0.12",
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,7 @@
35
37
  "./src/domains/setup/index.ts",
36
38
  "./src/domains/subscriptions/index.ts",
37
39
  "./src/domains/models/index.ts",
40
+ "./src/domains/workflows/index.ts",
38
41
  "./src/domains/agents/index.ts",
39
42
  "./src/domains/remote/index.ts"
40
43
  ],
@@ -0,0 +1,102 @@
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
+ interface OAuthPoolFile {
23
+ version: 1;
24
+ providers: Record<string, ProviderOAuthPool>;
25
+ }
26
+
27
+ const EMPTY_FILE: OAuthPoolFile = { version: 1, providers: {} };
28
+ const DEFAULT_PATH = "pi-plus-oauth-accounts.json";
29
+ const fileCache = new Map<string, OAuthPoolFile>();
30
+
31
+ export function oauthPoolPath(): string {
32
+ return process.env.PI_PLUS_OAUTH_ACCOUNTS_FILE ?? agentPath(DEFAULT_PATH);
33
+ }
34
+
35
+ function loadFile(path = oauthPoolPath()): OAuthPoolFile {
36
+ const cached = fileCache.get(path);
37
+ if (cached) return cached;
38
+
39
+ const raw = readJson<Partial<OAuthPoolFile>>(path, EMPTY_FILE);
40
+ const file: OAuthPoolFile = {
41
+ version: 1,
42
+ providers: raw.providers && typeof raw.providers === "object" ? raw.providers : {},
43
+ };
44
+ fileCache.set(path, file);
45
+ return file;
46
+ }
47
+
48
+ function saveFile(file: OAuthPoolFile, path = oauthPoolPath()): void {
49
+ fileCache.set(path, file);
50
+ if (!writeJson(path, file, true, 0o600)) throw new Error(`Could not write OAuth accounts to ${path}`);
51
+ }
52
+
53
+ export function loadOAuthPool(providerId: string, path = oauthPoolPath()): ProviderOAuthPool {
54
+ const pool = loadFile(path).providers[providerId];
55
+ return {
56
+ accounts: Array.isArray(pool?.accounts) ? pool.accounts : [],
57
+ mode: normalizeRoutingMode(pool?.mode as string | undefined),
58
+ };
59
+ }
60
+
61
+ export function saveOAuthPool(providerId: string, pool: ProviderOAuthPool, path = oauthPoolPath()): void {
62
+ const file = loadFile(path);
63
+ file.providers[providerId] = pool;
64
+ saveFile(file, path);
65
+ }
66
+
67
+ export function saveOAuthAccount(providerId: string, account: PooledOAuthAccount, path = oauthPoolPath()): void {
68
+ const pool = loadOAuthPool(providerId, path);
69
+ const index = pool.accounts.findIndex((candidate) => candidate.id === account.id);
70
+ if (index === -1) pool.accounts.push(account);
71
+ else pool.accounts[index] = { ...pool.accounts[index], ...account };
72
+ saveOAuthPool(providerId, pool, path);
73
+ }
74
+
75
+ export function removeOAuthAccount(providerId: string, accountId: string, path = oauthPoolPath()): boolean {
76
+ const pool = loadOAuthPool(providerId, path);
77
+ const accounts = pool.accounts.filter((account) => account.id !== accountId);
78
+ if (accounts.length === pool.accounts.length) return false;
79
+ saveOAuthPool(providerId, { ...pool, accounts }, path);
80
+ return true;
81
+ }
82
+
83
+ export function setOAuthPoolMode(providerId: string, mode: OAuthPoolMode, path = oauthPoolPath()): void {
84
+ saveOAuthPool(providerId, { ...loadOAuthPool(providerId, path), mode }, path);
85
+ }
86
+
87
+ export function oauthIdentity(access: string): string | undefined {
88
+ try {
89
+ const payload = JSON.parse(Buffer.from(access.split(".")[1] ?? "", "base64url").toString()) as Record<string, unknown>;
90
+ for (const key of ["sub", "email", "account_id", "user_id", "uid"]) {
91
+ const value = payload[key];
92
+ if (typeof value === "string" && value) return `${key}:${value}`;
93
+ }
94
+ } catch {
95
+ // Opaque access tokens have no local identity.
96
+ }
97
+ return undefined;
98
+ }
99
+
100
+ export function resetOAuthPoolCache(): void {
101
+ fileCache.clear();
102
+ }
@@ -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;
@@ -20,15 +11,12 @@ export interface ManagedAccount {
20
11
  primary?: boolean;
21
12
  }
22
13
 
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";
14
+ /** Fixed account order or provider quota-aware selection. */
15
+ export type RoutingMode = "sequential" | "quota-aware";
29
16
 
30
17
  export interface AccountUi {
31
18
  input(title: string, placeholder?: string): Promise<string | undefined>;
19
+ select(title: string, options: string[]): Promise<string | undefined>;
32
20
  confirm(title: string, message: string): Promise<boolean>;
33
21
  notify(message: string, type?: "info" | "warning" | "error"): void;
34
22
  }
@@ -36,7 +24,8 @@ export interface AccountUi {
36
24
  export interface AccountContext {
37
25
  ui: AccountUi;
38
26
  hasUI: boolean;
39
- /** Opens a URL in the user's browser, for OAuth flows. */
27
+ signal?: AbortSignal;
28
+ /** Opens an OAuth URL. */
40
29
  openBrowser(url: string): Promise<void>;
41
30
  }
42
31
 
@@ -56,15 +45,9 @@ export interface AccountProvider {
56
45
  add(ctx: AccountContext, label: string): Promise<string | undefined>;
57
46
  /** Returns the label of the account that was reauthorized. */
58
47
  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
- */
48
+ /** Enables or disables an account without removing credentials. */
63
49
  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
- */
50
+ /** Changes only the display label. */
68
51
  rename?(accountId: string, label: string): Promise<void>;
69
52
  routing?: RoutingSupport;
70
53
  }
@@ -0,0 +1,122 @@
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
+ /** Maps stored legacy names onto the two routing modes. */
19
+ export function normalizeRoutingMode(value: string | undefined): AccountRoutingMode {
20
+ return value === "quota-aware" || value === "optimal" || value === "sticky-balanced"
21
+ ? "quota-aware"
22
+ : "sequential";
23
+ }
24
+
25
+ /** Sequential keeps account order; quota-aware prefers unmeasured then highest remaining capacity. */
26
+ export function selectRoutingCandidate<T>(
27
+ candidates: readonly AccountRoutingCandidate<T>[],
28
+ mode: AccountRoutingMode,
29
+ now = Date.now(),
30
+ ): AccountRoutingCandidate<T> | undefined {
31
+ if (candidates.length === 0) return undefined;
32
+ const viable = candidates.filter((candidate) => isCandidateViable(candidate.quota, now));
33
+ if (viable.length === 0) return undefined;
34
+
35
+ if (mode === "sequential") {
36
+ return [...viable].sort((left, right) => left.order - right.order)[0];
37
+ }
38
+
39
+ const unmeasured = viable.filter((candidate) => candidate.quota?.remainingPercent === undefined);
40
+ if (unmeasured.length > 0) return leastRecentlyUsed(unmeasured);
41
+
42
+ return [...viable].sort((left, right) =>
43
+ (right.quota?.remainingPercent ?? 0) - (left.quota?.remainingPercent ?? 0)
44
+ || left.lastUsed - right.lastUsed
45
+ || left.order - right.order
46
+ || left.id.localeCompare(right.id))[0];
47
+ }
48
+
49
+ export function quotaStateFromHeaders(
50
+ status: number,
51
+ headers: Record<string, string>,
52
+ previous?: AccountQuotaState,
53
+ now = Date.now(),
54
+ ): AccountQuotaState | undefined {
55
+ const normalized = new Map(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]));
56
+ const remaining = firstNumber(normalized, [
57
+ "x-ratelimit-remaining-requests",
58
+ "ratelimit-remaining-requests",
59
+ "x-ratelimit-remaining",
60
+ "ratelimit-remaining",
61
+ ]);
62
+ const limit = firstNumber(normalized, [
63
+ "x-ratelimit-limit-requests",
64
+ "ratelimit-limit-requests",
65
+ "x-ratelimit-limit",
66
+ "ratelimit-limit",
67
+ ]);
68
+ const resetAt = parseResetAt(normalized, now);
69
+
70
+ if (status === 429) {
71
+ return {
72
+ remainingPercent: 0,
73
+ resetAt,
74
+ checkedAt: now,
75
+ blockedUntil: resetAt ?? now + 60_000,
76
+ };
77
+ }
78
+ if (remaining === undefined) return previous;
79
+
80
+ const remainingPercent = limit && limit > 0
81
+ ? Math.max(0, Math.min(100, remaining / limit * 100))
82
+ : Math.max(0, Math.min(100, remaining));
83
+ return { remainingPercent, resetAt, checkedAt: now };
84
+ }
85
+
86
+ function isCandidateViable(quota: AccountQuotaState | undefined, now: number): boolean {
87
+ if (!quota) return true;
88
+ if (quota.blockedUntil !== undefined && quota.blockedUntil > now) return false;
89
+ if (quota.remainingPercent !== 0) return true;
90
+ return quota.resetAt !== undefined && quota.resetAt <= now;
91
+ }
92
+
93
+ function leastRecentlyUsed<T>(candidates: readonly AccountRoutingCandidate<T>[]): AccountRoutingCandidate<T> {
94
+ return [...candidates].sort((left, right) =>
95
+ left.lastUsed - right.lastUsed
96
+ || left.order - right.order
97
+ || left.id.localeCompare(right.id))[0];
98
+ }
99
+
100
+ function firstNumber(headers: ReadonlyMap<string, string>, names: readonly string[]): number | undefined {
101
+ for (const name of names) {
102
+ const value = Number(headers.get(name));
103
+ if (Number.isFinite(value)) return value;
104
+ }
105
+ return undefined;
106
+ }
107
+
108
+ function parseResetAt(headers: ReadonlyMap<string, string>, now: number): number | undefined {
109
+ const retryAfter = Number(headers.get("retry-after"));
110
+ if (Number.isFinite(retryAfter) && retryAfter >= 0) return now + retryAfter * 1_000;
111
+
112
+ const raw = firstNumber(headers, [
113
+ "x-ratelimit-reset-requests",
114
+ "ratelimit-reset-requests",
115
+ "x-ratelimit-reset",
116
+ "ratelimit-reset",
117
+ ]);
118
+ if (raw === undefined) return undefined;
119
+ if (raw > 1_000_000_000_000) return raw;
120
+ if (raw > 1_000_000_000) return raw * 1_000;
121
+ return now + raw * 1_000;
122
+ }
@@ -2,43 +2,15 @@ import { createHash, randomUUID } from "node:crypto";
2
2
  import { xxhash64 } from "./xxhash64.ts";
3
3
 
4
4
  /**
5
- * Claude Code client identity headers.
6
- *
7
- * ---------------------------------------------------------------------------
8
- * READ THIS BEFORE CHANGING ANYTHING HERE
9
- *
10
- * These headers make a request indistinguishable from Anthropic's official
11
- * Claude Code CLI. Without them Anthropic classifies the caller as a
12
- * third-party app and answers:
13
- *
14
- * "Third-party apps now draw from your extra usage, not your plan limits."
15
- *
16
- * So the purpose of this file is to bill against plan limits rather than extra
17
- * usage. That is plausibly contrary to Anthropic's intent as stated in that
18
- * message, and the exposure falls on the account owner.
19
- *
20
- * It is isolated in one file, and referenced from exactly one place, so it can
21
- * be deleted or replaced without touching the rest of the provider. Removing it
22
- * does not break anything: requests keep working and bill to extra usage.
23
- *
24
- * Ported from @cortexkit/anthropic-auth-core during extraction.
25
- * ---------------------------------------------------------------------------
5
+ * Emulates Claude Code so OAuth requests use plan limits instead of extra usage.
6
+ * This may conflict with Anthropic's stated intent; remove this identity from
7
+ * the provider to opt out. Adapted from @cortexkit/anthropic-auth-core.
26
8
  */
27
9
 
28
10
  /** Pinned to the Claude Code release being imitated. */
29
11
  export const CLAUDE_CODE_VERSION = "2.1.258";
30
12
 
31
- /**
32
- * Anti-tamper checksum, replicated from the official client.
33
- *
34
- * The real CLI signs the serialised request body with xxHash64 under a fixed
35
- * seed and writes the low 20 bits into a `cch=` placeholder. Anthropic added
36
- * this so a client cannot simply assert it is Claude Code in a header: it has
37
- * to prove it by producing a value derived from the request itself.
38
- *
39
- * The salt and sample positions are constants in the official bundle, not
40
- * derivable from anything, and they will change when the CLI is updated.
41
- */
13
+ /** Claude Code checksum constants. They can change between CLI releases. */
42
14
  const CCH_SEED = 0x4d659218e32a3268n;
43
15
  const CCH_SALT = "59cf53e54c78";
44
16
  const CCH_POSITIONS = [4, 7, 20];
@@ -147,15 +119,7 @@ function hasFullAgentShape(body: any): boolean {
147
119
  && !!body?.thinking && typeof body.thinking === "object";
148
120
  }
149
121
 
150
- /**
151
- * Paragraphs containing this anchor cannot sit in the top-level `system` array.
152
- *
153
- * Two lines of pi's documentation paragraph are each independently sufficient to
154
- * make Anthropic answer 400 "Third-party apps now draw from your extra usage".
155
- * The same text is accepted inside `messages`, so it is moved there. Confirmed
156
- * by bisection: the identical payload returns 200 with the paragraph removed and
157
- * 400 with it present, and entry count and payload size are not the factors.
158
- */
122
+ /** Documentation paragraphs must move from `system` into `messages`. */
159
123
  const DOCS_ANCHOR = "Pi documentation";
160
124
 
161
125
  /** Lone surrogates are invalid UTF-8 and are rejected outright. */
@@ -163,14 +127,7 @@ function sanitizePrompt(text: string): string {
163
127
  return text.replace(/[\uD800-\uDFFF]/gu, "\uFFFD");
164
128
  }
165
129
 
166
- /**
167
- * Splits pi's system prompt into what may stay in `system` and what must move
168
- * into the first user message.
169
- *
170
- * An unrecognised prompt shape (no docs paragraph found) is moved whole, the
171
- * same conservative fallback the vendor uses: better to shift the entire prompt
172
- * into messages than to guess and trigger the 400.
173
- */
130
+ /** Splits the system prompt, moving unknown shapes conservatively. */
174
131
  export function splitSystemPrompt(prompt: string): { systemText?: string; messageText: string } {
175
132
  const paragraphs = sanitizePrompt(prompt).split(/\n\n+/);
176
133
  const docs = paragraphs.filter((p) => p.includes(DOCS_ANCHOR));
@@ -183,15 +140,7 @@ export function splitSystemPrompt(prompt: string): { systemText?: string; messag
183
140
  };
184
141
  }
185
142
 
186
- /**
187
- * Inserts text as its own cache-controlled block ahead of the first user
188
- * message.
189
- *
190
- * A separate block rather than merged text, so the cache prefix ends before the
191
- * user's own words and a new conversation with a different first message still
192
- * reads this from cache. `cache_control` is explicit because the message-level
193
- * breakpoint elsewhere only covers the last user message.
194
- */
143
+ /** Prepends a cache-controlled block to the first user message. */
195
144
  export function prependPromptBlock(messages: any[], text: string): void {
196
145
  const firstUser = (messages ?? []).find((m) => m?.role === "user");
197
146
  if (!firstUser || !text) return;
@@ -213,16 +162,12 @@ export function selectBetas(body: unknown, extra: string[] = []): string {
213
162
  return [...new Set(selected)].join(",");
214
163
  }
215
164
 
216
- /**
217
- * Headers presenting this client as Claude Code.
218
- * Merged over whatever pi already set.
219
- */
165
+ /** Headers presenting this client as Claude Code. */
220
166
  export function clientIdentityHeaders(body?: unknown, existingBetas?: string): Record<string, string> {
221
167
  const incoming = (existingBetas ?? "").split(",").map((b) => b.trim()).filter(Boolean);
222
168
  return {
223
169
  "user-agent": USER_AGENT,
224
- // Required: pi's Anthropic client reads this header to decide which betas
225
- // to send, then puts them in the body's `betas` field.
170
+ // Pi copies this header into the request body's betas field.
226
171
  "anthropic-beta": selectBetas(body, incoming),
227
172
  "anthropic-version": "2023-06-01",
228
173
  "anthropic-dangerous-direct-browser-access": "true",