@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
@@ -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",
@@ -0,0 +1,54 @@
1
+ import { createHash } from "node:crypto";
2
+ import { CLAUDE_CODE_VERSION } from "./client-identity.ts";
3
+
4
+ /** Resolves the stable Claude account UUID exposed by the CLI bootstrap API. */
5
+
6
+ const resolved = new Map<string, string>();
7
+ const pending = new Map<string, Promise<string | undefined>>();
8
+
9
+ function tokenKey(accessToken: string): string {
10
+ return createHash("sha256").update(accessToken).digest("hex");
11
+ }
12
+
13
+ /** Synchronous lookup for request routing; never starts network I/O. */
14
+ export function cachedAnthropicAccountIdentity(accessToken: string): string | undefined {
15
+ return resolved.get(tokenKey(accessToken));
16
+ }
17
+
18
+ export async function anthropicAccountIdentity(
19
+ accessToken: string,
20
+ fetchImpl: typeof fetch = fetch,
21
+ ): Promise<string | undefined> {
22
+ if (!accessToken.startsWith("sk-ant-oat")) return undefined;
23
+
24
+ const key = tokenKey(accessToken);
25
+ const cached = resolved.get(key);
26
+ if (cached) return cached;
27
+ const active = pending.get(key);
28
+ if (active) return active;
29
+
30
+ const request = (async () => {
31
+ try {
32
+ const response = await fetchImpl("https://api.anthropic.com/api/claude_cli/bootstrap?entrypoint=cli", {
33
+ headers: {
34
+ Accept: "application/json",
35
+ Authorization: `Bearer ${accessToken}`,
36
+ "anthropic-beta": "oauth-2025-04-20",
37
+ "user-agent": `claude-cli/${CLAUDE_CODE_VERSION} (external, cli)`,
38
+ },
39
+ signal: AbortSignal.timeout(5_000),
40
+ });
41
+ if (!response.ok) return undefined;
42
+ const body = await response.json() as any;
43
+ const identity = body?.oauth_account?.account_uuid;
44
+ if (typeof identity !== "string" || !identity) return undefined;
45
+ resolved.set(key, identity);
46
+ return identity;
47
+ } catch {
48
+ return undefined;
49
+ }
50
+ })().finally(() => pending.delete(key));
51
+
52
+ pending.set(key, request);
53
+ return request;
54
+ }
@@ -149,6 +149,8 @@ export interface RefreshOptions {
149
149
  baseDelayMs?: number;
150
150
  fetchImpl?: typeof fetch;
151
151
  now?: () => number;
152
+ /** Bounds the entire refresh, including retries. */
153
+ signal?: AbortSignal;
152
154
  }
153
155
 
154
156
  /**
@@ -172,6 +174,7 @@ export async function refreshToken(options: RefreshOptions): Promise<TokenSet> {
172
174
  const response = await doFetch(TOKEN_URL, {
173
175
  method: "POST",
174
176
  headers: TOKEN_HEADERS,
177
+ signal: options.signal,
175
178
  body: JSON.stringify({
176
179
  grant_type: "refresh_token",
177
180
  refresh_token: options.refreshToken,
@@ -1,33 +1,28 @@
1
- import { loadAccounts, saveAccount, type Account, type QuotaSnapshot } from "./store.ts";
2
- import { refreshToken } from "./oauth.ts";
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { closeSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { dirname } from "node:path";
4
+ import { refreshAbortSignal } from "../accounts/routing.ts";
5
+ import {
6
+ configPath as defaultConfigPath,
7
+ loadAccounts,
8
+ saveAccount,
9
+ statePath,
10
+ type Account,
11
+ type QuotaSnapshot,
12
+ } from "./store.ts";
13
+ import { refreshToken, type RefreshOptions, type TokenSet } from "./oauth.ts";
3
14
 
4
- /**
5
- * Quota tracking.
6
- *
7
- * There are two sources, and the cheap one is strongly preferred:
8
- *
9
- * 1. Response headers. Every `/v1/messages` reply carries the same numbers
10
- * as the usage endpoint, so the account actually serving traffic keeps
11
- * its snapshot current at no request cost at all.
12
- * 2. The usage endpoint. Only needed for accounts that are NOT serving
13
- * traffic, since routing compares accounts and an idle one would
14
- * otherwise never update.
15
- *
16
- * Polling used to run on a 5 minute timer regardless of activity. That is what
17
- * the backoff below exists for: the endpoint rate limits, it answers 429 with
18
- * `retry-after: 0`, and `pollQuota` reports failure as `undefined`, so the
19
- * throttling was invisible and simply left routing on stale data.
20
- */
15
+ /** Uses free response headers first, polling only stale idle accounts. */
21
16
 
22
17
  const QUOTA_URL = "https://api.anthropic.com/api/oauth/usage";
23
18
  const TIMEOUT_MS = 10_000;
24
- /**
25
- * Selection tolerates a snapshot this old before it is worth re-polling.
26
- *
27
- * This doubles as the poll rate limiter. Polling is triggered by sending a
28
- * message, so this is the floor between polls however fast you type: send a
29
- * message after the window and it polls, send ten inside it and it polls once.
30
- */
19
+ /** Refresh early enough to absorb transient OAuth rate limits before expiry. */
20
+ export const ACCESS_REFRESH_WINDOW_MS = 4 * 60 * 60_000;
21
+ /** Background refresh cadence; each process adds a small startup jitter. */
22
+ export const ACCESS_REFRESH_INTERVAL_MS = 5 * 60_000;
23
+ const REFRESH_LOCK_TTL_MS = 60_000;
24
+ const REFRESH_JOIN_WAIT_MS = 16_000;
25
+ /** Snapshot lifetime and minimum interval between polls. */
31
26
  export const QUOTA_FRESH_MS = 10 * 60_000;
32
27
  /** After a 429, wait at least this long before touching the endpoint again. */
33
28
  export const QUOTA_BACKOFF_MS = 15 * 60_000;
@@ -95,15 +90,8 @@ export function isFresh(quota: QuotaSnapshot | undefined, now = Date.now()): boo
95
90
  }
96
91
 
97
92
  /**
98
- * Reads a quota snapshot out of `/v1/messages` response headers.
99
- *
100
- * Anthropic reports utilisation here as a FRACTION (`0.16`), while the usage
101
- * endpoint reports a PERCENT (`16`). Verified against the same account at the
102
- * same moment, including matching reset timestamps. Scaling by 100 is what
103
- * makes the two sources comparable, so do not drop it.
104
- *
105
- * Returns undefined when the headers are absent, which is normal: they do not
106
- * appear on 4xx replies, and non-Anthropic transports may not expose them.
93
+ * Parses response quota headers. Header utilization is a fraction, while the
94
+ * usage endpoint returns a percentage, so header values are scaled by 100.
107
95
  */
108
96
  export function parseQuotaHeaders(
109
97
  headers: Record<string, unknown> | undefined,
@@ -145,12 +133,7 @@ export function parseQuotaHeaders(
145
133
  return { five_hour, seven_day, checkedAt: now, source: "headers" };
146
134
  }
147
135
 
148
- /**
149
- * Merges a header-derived snapshot into an account.
150
- *
151
- * Scoped per-model limits only come from the usage endpoint, so they are
152
- * carried over from the previous snapshot rather than dropped.
153
- */
136
+ /** Merges header quota while preserving polled model limits. */
154
137
  export function applyQuotaHeaders(
155
138
  accountId: string,
156
139
  headers: Record<string, unknown> | undefined,
@@ -163,10 +146,7 @@ export function applyQuotaHeaders(
163
146
  const account = storage?.accounts.find((a) => a.id === accountId);
164
147
  if (!account) return false;
165
148
 
166
- // Headers arrive on every response, but utilisation moves in whole percent
167
- // steps over windows of hours. Rewriting two credential files per request to
168
- // store an unchanged number is pure write amplification, and on Windows each
169
- // rewrite is another chance for the rename to collide with a file lock.
149
+ // Skip unchanged values to avoid credential writes on every response.
170
150
  const previous = account.quota;
171
151
  const unchanged =
172
152
  previous?.five_hour?.usedPercent === fresh.five_hour?.usedPercent
@@ -177,12 +157,7 @@ export function applyQuotaHeaders(
177
157
  return true;
178
158
  }
179
159
 
180
- /**
181
- * Polls one account. Returns undefined rather than throwing on failure.
182
- *
183
- * Pass `accountId` so a 429 registers backoff; without it the caller can
184
- * hammer a throttled endpoint and silently keep stale quota.
185
- */
160
+ /** Polls one account and records rate-limit backoff. */
186
161
  export async function pollQuota(
187
162
  accessToken: string,
188
163
  accountId?: string,
@@ -212,34 +187,208 @@ export async function pollQuota(
212
187
  }
213
188
  }
214
189
 
215
- /** A token good for at least a minute, refreshing and persisting if needed. */
216
- export async function ensureAccessToken(account: Account): Promise<string | undefined> {
217
- if (account.access && typeof account.expires === "number" && Date.now() + 60_000 < account.expires) {
218
- return account.access;
190
+ export function accessTokenNeedsRefresh(account: Account, now = Date.now()): boolean {
191
+ return !account.access
192
+ || typeof account.expires !== "number"
193
+ || account.expires - now <= ACCESS_REFRESH_WINDOW_MS;
194
+ }
195
+
196
+ type RefreshTokenFn = (options: RefreshOptions) => Promise<TokenSet>;
197
+
198
+ export interface EnsureAccessTokenOptions {
199
+ config?: string;
200
+ now?: () => number;
201
+ refresh?: RefreshTokenFn;
202
+ }
203
+
204
+ export interface RefreshAllQuotaOptions extends EnsureAccessTokenOptions {
205
+ poll?: typeof pollQuota;
206
+ }
207
+
208
+ interface RefreshLock {
209
+ release(): void;
210
+ }
211
+
212
+ const refreshes = new Map<string, Promise<string | undefined>>();
213
+
214
+ function refreshLockPath(accountId: string, config: string): string {
215
+ const id = createHash("sha256").update(accountId).digest("hex").slice(0, 16);
216
+ return `${statePath(config)}.refresh-${id}.lock`;
217
+ }
218
+
219
+ /** Cross-process exclusion for Anthropic's rotating refresh tokens. */
220
+ function acquireRefreshLock(accountId: string, config: string, now: number): RefreshLock | undefined {
221
+ const path = refreshLockPath(accountId, config);
222
+ const owner = randomUUID();
223
+ mkdirSync(dirname(path), { recursive: true });
224
+
225
+ for (let attempt = 0; attempt < 2; attempt++) {
226
+ try {
227
+ const fd = openSync(path, "wx", 0o600);
228
+ try {
229
+ writeFileSync(fd, JSON.stringify({ owner, expiresAt: now + REFRESH_LOCK_TTL_MS }), "utf8");
230
+ } finally {
231
+ closeSync(fd);
232
+ }
233
+ return {
234
+ release() {
235
+ try {
236
+ const current = JSON.parse(readFileSync(path, "utf8"));
237
+ if (current?.owner === owner) rmSync(path, { force: true });
238
+ } catch {
239
+ // A stale or externally removed lock needs no cleanup.
240
+ }
241
+ },
242
+ };
243
+ } catch (error: any) {
244
+ if (error?.code !== "EEXIST") throw error;
245
+ try {
246
+ const current = JSON.parse(readFileSync(path, "utf8"));
247
+ if (Number(current?.expiresAt) > now) return undefined;
248
+ rmSync(path, { force: true });
249
+ } catch {
250
+ // A malformed lock is stale; remove it and retry once.
251
+ try { rmSync(path, { force: true }); } catch { /* best effort */ }
252
+ }
253
+ }
219
254
  }
220
- if (!account.refresh) return account.access;
221
-
222
- const refreshed = await refreshToken({ refreshToken: account.refresh, maxRetries: 0 });
223
- // Persist immediately: Anthropic may rotate the refresh token, and losing the
224
- // new one would invalidate the account.
225
- saveAccount({
226
- ...account,
227
- access: refreshed.access,
228
- refresh: refreshed.refresh,
229
- expires: refreshed.expires,
230
- lastRefreshedAt: Date.now(),
231
- });
232
- return refreshed.access;
255
+ return undefined;
233
256
  }
234
257
 
235
- /**
236
- * Refreshes stale quota for every usable account, in parallel.
237
- * Best-effort: a failure leaves the previous snapshot in place.
238
- */
239
- export async function refreshAllQuota(force = false): Promise<number> {
240
- const storage = loadAccounts();
258
+ function storedAccount(accountId: string, config: string): Account | undefined {
259
+ return loadAccounts(config)?.accounts.find((candidate) => candidate.id === accountId);
260
+ }
261
+
262
+ function usableAccess(account: Account | undefined, now: number): string | undefined {
263
+ return account?.access && typeof account.expires === "number" && account.expires > now
264
+ ? account.access
265
+ : undefined;
266
+ }
267
+
268
+ async function joinConcurrentRefresh(
269
+ account: Account,
270
+ config: string,
271
+ now: () => number,
272
+ ): Promise<string | undefined> {
273
+ const deadline = now() + REFRESH_JOIN_WAIT_MS;
274
+ while (now() < deadline) {
275
+ await new Promise((resolve) => setTimeout(resolve, 100));
276
+ const latest = storedAccount(account.id, config);
277
+ const changed = latest && (
278
+ latest.access !== account.access
279
+ || latest.refresh !== account.refresh
280
+ || (latest.expires ?? 0) > (account.expires ?? 0) + 60_000
281
+ );
282
+ if (changed && !accessTokenNeedsRefresh(latest, now())) return latest.access;
283
+ }
284
+ return usableAccess(storedAccount(account.id, config) ?? account, now());
285
+ }
286
+
287
+ function refreshErrorMessage(error: unknown): string {
288
+ const message = error instanceof Error ? error.message : String(error);
289
+ return message.replace(/sk-ant-[A-Za-z0-9_-]+/g, "[redacted]").slice(0, 500);
290
+ }
291
+
292
+ async function refreshAccountNow(
293
+ account: Account,
294
+ options: EnsureAccessTokenOptions,
295
+ ): Promise<string | undefined> {
296
+ const config = options.config ?? defaultConfigPath();
297
+ const now = options.now ?? Date.now;
298
+ const refresh = options.refresh ?? refreshToken;
299
+ let latest = storedAccount(account.id, config) ?? account;
300
+ if (!accessTokenNeedsRefresh(latest, now())) return latest.access;
301
+ if (!latest.refresh) return usableAccess(latest, now());
302
+
303
+ const lock = acquireRefreshLock(account.id, config, now());
304
+ if (!lock) return joinConcurrentRefresh(latest, config, now);
305
+
306
+ try {
307
+ // The lock winner re-reads so it never spends a refresh token rotated by a
308
+ // different process immediately before the lock was acquired.
309
+ latest = storedAccount(account.id, config) ?? latest;
310
+ if (!accessTokenNeedsRefresh(latest, now())) return latest.access;
311
+ if (!latest.refresh) return usableAccess(latest, now());
312
+
313
+ const sourceRefresh = latest.refresh;
314
+ try {
315
+ const refreshed = await refresh({
316
+ refreshToken: sourceRefresh,
317
+ signal: refreshAbortSignal(),
318
+ });
319
+ const current = storedAccount(account.id, config) ?? latest;
320
+ // A changed refresh token is a stronger, newer write. Never overwrite it
321
+ // with the result of an older token family.
322
+ if (current.refresh !== sourceRefresh) return usableAccess(current, now());
323
+ saveAccount({
324
+ ...current,
325
+ access: refreshed.access,
326
+ refresh: refreshed.refresh,
327
+ expires: refreshed.expires,
328
+ lastRefreshedAt: now(),
329
+ lastRefreshError: undefined,
330
+ }, config);
331
+ return refreshed.access;
332
+ } catch (error) {
333
+ const current = storedAccount(account.id, config) ?? latest;
334
+ if (current.refresh === sourceRefresh) {
335
+ saveAccount({ ...current, lastRefreshError: refreshErrorMessage(error) }, config);
336
+ }
337
+ throw error;
338
+ }
339
+ } finally {
340
+ lock.release();
341
+ }
342
+ }
343
+
344
+ /** Returns a usable token, proactively rotating it well before expiry. */
345
+ export async function ensureAccessToken(
346
+ account: Account,
347
+ options: EnsureAccessTokenOptions = {},
348
+ ): Promise<string | undefined> {
349
+ const config = options.config ?? defaultConfigPath();
350
+ const now = options.now ?? Date.now;
351
+ if (!accessTokenNeedsRefresh(account, now())) return account.access;
352
+ if (!account.refresh) return usableAccess(account, now());
353
+
354
+ const key = `${config}\0${account.id}`;
355
+ const active = refreshes.get(key);
356
+ if (active) return active;
357
+ const pending = refreshAccountNow(account, options).finally(() => refreshes.delete(key));
358
+ refreshes.set(key, pending);
359
+ return pending;
360
+ }
361
+
362
+ /** Rotates every due sidecar token independently of quota freshness. */
363
+ export async function refreshDueAccessTokens(
364
+ config = defaultConfigPath(),
365
+ options: EnsureAccessTokenOptions = {},
366
+ ): Promise<number> {
367
+ const now = options.now ?? Date.now;
368
+ const accounts = (loadAccounts(config)?.accounts ?? []).filter(
369
+ (account) => account.enabled !== false && account.type === "oauth" && accessTokenNeedsRefresh(account, now()),
370
+ );
371
+ const results = await Promise.all(accounts.map(async (account) => {
372
+ try {
373
+ return Boolean(await ensureAccessToken(account, { ...options, config }));
374
+ } catch {
375
+ return false;
376
+ }
377
+ }));
378
+ return results.filter(Boolean).length;
379
+ }
380
+
381
+ /** Refreshes due access tokens, then polls only stale quota snapshots. */
382
+ export async function refreshAllQuota(
383
+ force = false,
384
+ config = defaultConfigPath(),
385
+ options: RefreshAllQuotaOptions = {},
386
+ ): Promise<number> {
387
+ await refreshDueAccessTokens(config, options);
388
+ const storage = loadAccounts(config);
241
389
  if (!storage) return 0;
242
- const now = Date.now();
390
+ const now = options.now?.() ?? Date.now();
391
+ const poll = options.poll ?? pollQuota;
243
392
 
244
393
  const stale = storage.accounts.filter(
245
394
  (a) => a.enabled !== false && a.type === "oauth"
@@ -248,11 +397,16 @@ export async function refreshAllQuota(force = false): Promise<number> {
248
397
 
249
398
  const results = await Promise.all(stale.map(async (account) => {
250
399
  try {
251
- const token = await ensureAccessToken(account);
400
+ const token = await ensureAccessToken(account, { ...options, config });
252
401
  if (!token) return false;
253
- const quota = await pollQuota(token, account.id);
402
+ const quota = await poll(token, account.id);
254
403
  if (!quota) return false;
255
- saveAccount({ ...account, quota });
404
+ // Do not restore the pre-refresh account snapshot here. That used to
405
+ // overwrite a freshly rotated access/refresh pair with the now-invalid
406
+ // old pair immediately after a successful quota poll.
407
+ const current = storedAccount(account.id, config);
408
+ if (!current || current.access !== token) return false;
409
+ saveAccount({ ...current, quota }, config);
256
410
  return true;
257
411
  } catch {
258
412
  return false;