@jameslovespancakes/pi-plus 1.0.12 → 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 (45) hide show
  1. package/README.md +24 -3
  2. package/package.json +4 -2
  3. package/src/core/accounts/oauth-pool.ts +35 -0
  4. package/src/core/accounts/registry.ts +4 -0
  5. package/src/core/accounts/routing.ts +22 -0
  6. package/src/core/anthropic/identity.ts +54 -0
  7. package/src/core/anthropic/oauth.ts +3 -0
  8. package/src/core/anthropic/quota.ts +227 -28
  9. package/src/core/anthropic/store.ts +4 -1
  10. package/src/core/codex/quota.ts +43 -11
  11. package/src/core/codex/store.ts +21 -11
  12. package/src/core/config.ts +12 -0
  13. package/src/domains/agents/format.ts +97 -0
  14. package/src/domains/agents/index.ts +58 -47
  15. package/src/domains/compact/archive.ts +140 -0
  16. package/src/domains/compact/chunking.ts +166 -0
  17. package/src/domains/compact/index.ts +452 -0
  18. package/src/domains/compact/jev.ts +242 -0
  19. package/src/domains/compact/policy.ts +255 -0
  20. package/src/domains/compact/types.ts +79 -0
  21. package/src/domains/subscriptions/accounts-picker.ts +44 -4
  22. package/src/domains/subscriptions/accounts.ts +31 -8
  23. package/src/domains/subscriptions/index.ts +2 -2
  24. package/src/domains/subscriptions/provider.ts +46 -5
  25. package/src/domains/subscriptions/providers/anthropic.ts +37 -5
  26. package/src/domains/subscriptions/providers/codex.ts +120 -254
  27. package/src/domains/subscriptions/providers/hosted.ts +2 -2
  28. package/src/domains/subscriptions/providers/oauth-pool.ts +124 -72
  29. package/src/domains/workflows/index.ts +1 -1
  30. package/src/domains/workflows/runtime/agent-attempt.ts +41 -11
  31. package/src/domains/workflows/runtime/agent-failure.ts +14 -0
  32. package/src/domains/workflows/runtime/agent-limits.ts +24 -1
  33. package/src/domains/workflows/runtime/agent-runner.ts +42 -20
  34. package/src/domains/workflows/runtime/agent-workspace.ts +0 -24
  35. package/src/domains/workflows/runtime/engine.ts +48 -12
  36. package/src/domains/workflows/runtime/progress.ts +2 -2
  37. package/src/domains/workflows/runtime/types.ts +4 -4
  38. package/src/domains/workflows/runtime/ui/display-text.ts +15 -0
  39. package/src/domains/workflows/runtime/ui/workflow-inspector.ts +51 -14
  40. package/src/domains/workflows/runtime/ui/workflow-viewer-layout.ts +6 -6
  41. package/src/domains/workflows/runtime/workflow-run-history.ts +3 -1
  42. package/src/domains/workflows/runtime/workflow-run-record.ts +1 -0
  43. package/src/domains/workflows/runtime/worktree.ts +35 -1
  44. package/src/ui/usage-bars.ts +1 -2
  45. package/src/core/codex/oauth.ts +0 -129
package/README.md CHANGED
@@ -61,7 +61,7 @@ quota-aware routing.
61
61
  Your live quota, always in the footer:
62
62
 
63
63
  ```
64
- Claude Σ2 · 2/2 ready · partial Codex · pro
64
+ Claude Σ2 · 2/2 ready Codex · pro
65
65
  5h █████████████████░░░░░░░░░ 65% 1h 5h ███████████████████████░░░ 88% 57m
66
66
  weekly ███████████████░░░░░░░░░░░ 58% 3d weekly ████████████████████████░░ 93% 6d
67
67
  Fable ████████████░░░░░░░░░░░░░░ ~47% 3d
@@ -154,6 +154,25 @@ across every running pi agent. `/board` opens the messaging view:
154
154
  any Mac or Linux host over SSH, where launchd or systemd brings it back after a
155
155
  reboot.
156
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
+
157
176
  ### Orchestrate repeatable workflows
158
177
 
159
178
  The built-in workflow engine runs named or inline multi-agent workflows with
@@ -194,11 +213,12 @@ with workflow options when a task needs them.
194
213
  | `/remote add \| rename \| remove` | jump to one step |
195
214
  | `/board` | live agent board UI |
196
215
  | `/board setup \| restart \| clear \| status` | manage the board server |
216
+ | `/compact better on \| off \| jev` | select reversible compaction and compact now |
197
217
  | `/workflow` | open the running workflow agent board |
198
218
  | `/workflow <name> [args]` | run a bundled workflow |
199
219
 
200
220
  **Tools available to the agent:** `workflow`, `list_models`, `agent_board`,
201
- `remote_status`, `remote_test`.
221
+ `remote_status`, `remote_test`, `super_context_recall`.
202
222
 
203
223
  ---
204
224
 
@@ -210,7 +230,8 @@ Everything lives in one file, `~/.pi/agent/pi-plus.json`, created on first use:
210
230
  {
211
231
  "env": { "ARTIFICIAL_ANALYSIS_API_KEY": "aa_…", "AGENT_BOARD_URL": "ws://…" },
212
232
  "policy": { "autoApprove": [], "requireApproval": [], "deny": [] },
213
- "remote": { "workers": [] }
233
+ "remote": { "workers": [] },
234
+ "compact": { "better": "off" }
214
235
  }
215
236
  ```
216
237
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jameslovespancakes/pi-plus",
3
- "version": "1.0.12",
3
+ "version": "1.0.13",
4
4
  "type": "module",
5
5
  "description": "pi and more",
6
6
  "license": "MIT",
@@ -37,6 +37,7 @@
37
37
  "./src/domains/setup/index.ts",
38
38
  "./src/domains/subscriptions/index.ts",
39
39
  "./src/domains/models/index.ts",
40
+ "./src/domains/compact/index.ts",
40
41
  "./src/domains/workflows/index.ts",
41
42
  "./src/domains/agents/index.ts",
42
43
  "./src/domains/remote/index.ts"
@@ -64,6 +65,7 @@
64
65
  "version:next": "node scripts/bump-version.mjs --dry-run"
65
66
  },
66
67
  "devDependencies": {
67
- "oxlint": "^1.83.0"
68
+ "oxlint": "^1.83.0",
69
+ "typescript": "5.9.3"
68
70
  }
69
71
  }
@@ -19,6 +19,22 @@ export interface ProviderOAuthPool {
19
19
  mode: OAuthPoolMode;
20
20
  }
21
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
+
22
38
  interface OAuthPoolFile {
23
39
  version: 1;
24
40
  providers: Record<string, ProviderOAuthPool>;
@@ -97,6 +113,25 @@ export function oauthIdentity(access: string): string | undefined {
97
113
  return undefined;
98
114
  }
99
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
+
100
134
  export function resetOAuthPoolCache(): void {
101
135
  fileCache.clear();
136
+ primaryQuotas.clear();
102
137
  }
@@ -9,6 +9,8 @@ export interface ManagedAccount {
9
9
  expiresAt?: number;
10
10
  /** True for the account pi itself is authenticated as. */
11
11
  primary?: boolean;
12
+ /** Provider-stable identity used only to collapse duplicate logins. */
13
+ identity?: string;
12
14
  }
13
15
 
14
16
  /** Fixed account order or provider quota-aware selection. */
@@ -41,6 +43,8 @@ export interface AccountProvider {
41
43
  /** Human name, e.g. "Claude". */
42
44
  label: string;
43
45
  list(): Promise<ManagedAccount[]>;
46
+ /** Resolves a provider-stable identity from an OAuth access token. */
47
+ identify?(accessToken: string): string | undefined | Promise<string | undefined>;
44
48
  /** Returns the label of the account that was added. */
45
49
  add(ctx: AccountContext, label: string): Promise<string | undefined>;
46
50
  /** Returns the label of the account that was reauthorized. */
@@ -15,6 +15,23 @@ export interface AccountRoutingCandidate<T> {
15
15
 
16
16
  export type AccountRoutingMode = "sequential" | "quota-aware";
17
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
+
18
35
  /** Maps stored legacy names onto the two routing modes. */
19
36
  export function normalizeRoutingMode(value: string | undefined): AccountRoutingMode {
20
37
  return value === "quota-aware" || value === "optimal" || value === "sticky-balanced"
@@ -83,6 +100,11 @@ export function quotaStateFromHeaders(
83
100
  return { remainingPercent, resetAt, checkedAt: now };
84
101
  }
85
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
+ */
86
108
  function isCandidateViable(quota: AccountQuotaState | undefined, now: number): boolean {
87
109
  if (!quota) return true;
88
110
  if (quota.blockedUntil !== undefined && quota.blockedUntil > now) return false;
@@ -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,10 +1,27 @@
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
15
  /** Uses free response headers first, polling only stale idle accounts. */
5
16
 
6
17
  const QUOTA_URL = "https://api.anthropic.com/api/oauth/usage";
7
18
  const TIMEOUT_MS = 10_000;
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;
8
25
  /** Snapshot lifetime and minimum interval between polls. */
9
26
  export const QUOTA_FRESH_MS = 10 * 60_000;
10
27
  /** After a 429, wait at least this long before touching the endpoint again. */
@@ -170,31 +187,208 @@ export async function pollQuota(
170
187
  }
171
188
  }
172
189
 
173
- /** A token good for at least a minute, refreshing and persisting if needed. */
174
- export async function ensureAccessToken(account: Account): Promise<string | undefined> {
175
- if (account.access && typeof account.expires === "number" && Date.now() + 60_000 < account.expires) {
176
- 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
+ }
177
254
  }
178
- if (!account.refresh) return account.access;
179
-
180
- const refreshed = await refreshToken({ refreshToken: account.refresh, maxRetries: 0 });
181
- // Persist immediately: Anthropic may rotate the refresh token, and losing the
182
- // new one would invalidate the account.
183
- saveAccount({
184
- ...account,
185
- access: refreshed.access,
186
- refresh: refreshed.refresh,
187
- expires: refreshed.expires,
188
- lastRefreshedAt: Date.now(),
189
- });
190
- return refreshed.access;
191
- }
192
-
193
- /** Refreshes stale account quota in parallel. */
194
- export async function refreshAllQuota(force = false): Promise<number> {
195
- const storage = loadAccounts();
255
+ return undefined;
256
+ }
257
+
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);
196
389
  if (!storage) return 0;
197
- const now = Date.now();
390
+ const now = options.now?.() ?? Date.now();
391
+ const poll = options.poll ?? pollQuota;
198
392
 
199
393
  const stale = storage.accounts.filter(
200
394
  (a) => a.enabled !== false && a.type === "oauth"
@@ -203,11 +397,16 @@ export async function refreshAllQuota(force = false): Promise<number> {
203
397
 
204
398
  const results = await Promise.all(stale.map(async (account) => {
205
399
  try {
206
- const token = await ensureAccessToken(account);
400
+ const token = await ensureAccessToken(account, { ...options, config });
207
401
  if (!token) return false;
208
- const quota = await pollQuota(token, account.id);
402
+ const quota = await poll(token, account.id);
209
403
  if (!quota) return false;
210
- 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);
211
410
  return true;
212
411
  } catch {
213
412
  return false;
@@ -48,6 +48,9 @@ export interface Account {
48
48
  expires?: number;
49
49
  lastUsed?: number;
50
50
  lastRefreshedAt?: number;
51
+ lastRefreshError?: string;
52
+ /** Stable provider account UUID; survives OAuth token rotation. */
53
+ identity?: string;
51
54
  authLineageId?: string;
52
55
  quota?: QuotaSnapshot;
53
56
  apiKey?: string;
@@ -68,7 +71,7 @@ export interface Storage {
68
71
  const CONFIG_FIELDS = ["id", "label", "type", "enabled", "addedAt", "baseURL", "authHeader"] as const;
69
72
  /** Fields that belong in the state file. */
70
73
  const STATE_FIELDS = [
71
- "authLineageId", "access", "refresh", "expires", "lastUsed",
74
+ "authLineageId", "identity", "access", "refresh", "expires", "lastUsed",
72
75
  "lastRefreshedAt", "lastRefreshError", "lastQuotaRefreshError", "quota", "profile", "prime", "apiKey",
73
76
  ] as const;
74
77
 
@@ -73,22 +73,54 @@ export function parseCodexQuotaHeaders(
73
73
  } as QuotaSnapshot;
74
74
  }
75
75
 
76
- /** Merges a header-derived snapshot into the stored account. */
76
+ /**
77
+ * True when two snapshots carry the same reading. `checkedAt` moves on every
78
+ * response, so comparing it would defeat the whole check.
79
+ */
80
+ function sameQuota(left: QuotaSnapshot | undefined, right: QuotaSnapshot | undefined): boolean {
81
+ if (!left || !right) return false;
82
+ const compare = (snapshot: QuotaSnapshot) => {
83
+ const window = (value: QuotaSnapshot["five_hour"]) =>
84
+ value ? { used: value.usedPercent, resets: value.resetsAt, minutes: (value as any).windowMinutes } : undefined;
85
+ return JSON.stringify({
86
+ five_hour: window(snapshot.five_hour),
87
+ seven_day: window(snapshot.seven_day),
88
+ plan: (snapshot as any).plan,
89
+ });
90
+ };
91
+ return compare(left) === compare(right);
92
+ }
93
+
94
+ /**
95
+ * Merges a header-derived snapshot into the stored account.
96
+ *
97
+ * Runs from `onResponse` on every single reply, so it is deliberately quiet
98
+ * and cheap: unchanged readings never touch the disk, and a failed write is
99
+ * swallowed. Throwing here would reject the awaited `onResponse` inside the
100
+ * provider and kill an in-flight stream — quota telemetry must never do that.
101
+ */
77
102
  export function applyCodexQuotaHeaders(
78
103
  accountId: string,
79
104
  headers: Record<string, unknown> | undefined,
80
105
  now = Date.now(),
81
106
  ): boolean {
82
- const quota = parseCodexQuotaHeaders(headers, now);
83
- if (!quota) return false;
84
- const storage = loadCodexAccounts();
85
- if (accountId === MAIN_ACCOUNT_ID) {
86
- storage.main = { ...storage.main, quota };
87
- saveCodexAccounts(storage);
107
+ try {
108
+ const quota = parseCodexQuotaHeaders(headers, now);
109
+ if (!quota) return false;
110
+ const storage = loadCodexAccounts();
111
+ if (accountId === MAIN_ACCOUNT_ID) {
112
+ if (sameQuota(storage.main?.quota, quota)) return false;
113
+ storage.main = { ...storage.main, quota };
114
+ saveCodexAccounts(storage);
115
+ return true;
116
+ }
117
+ const account = storage.accounts.find((candidate) => candidate.id === accountId);
118
+ if (!account) return false;
119
+ if (sameQuota(account.quota, quota)) return false;
120
+ saveCodexAccount({ ...account, quota });
88
121
  return true;
122
+ } catch {
123
+ // Best effort: a quota write must never break the request that produced it.
124
+ return false;
89
125
  }
90
- const account = storage.accounts.find((candidate) => candidate.id === accountId);
91
- if (!account) return false;
92
- saveCodexAccount({ ...account, quota });
93
- return true;
94
126
  }
@@ -1,6 +1,6 @@
1
1
  import { homedir } from "node:os";
2
- import { dirname, join } from "node:path";
3
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { readJson, writeJson } from "../store.ts";
4
4
  import type { QuotaSnapshot } from "../anthropic/store.ts";
5
5
 
6
6
  /**
@@ -44,19 +44,29 @@ export function codexAccountsPath(): string {
44
44
  }
45
45
 
46
46
  export function loadCodexAccounts(path = codexAccountsPath()): CodexStorage {
47
- try {
48
- const raw = JSON.parse(readFileSync(path, "utf8"));
49
- const accounts = Array.isArray(raw?.accounts) ? raw.accounts : [];
50
- return { accounts, main: raw?.main, routing: raw?.routing };
51
- } catch {
52
- return { accounts: [] };
53
- }
47
+ const raw = readJson<Partial<CodexStorage>>(path, {});
48
+ return {
49
+ accounts: Array.isArray(raw?.accounts) ? raw.accounts : [],
50
+ main: raw?.main,
51
+ routing: raw?.routing,
52
+ };
54
53
  }
55
54
 
55
+ /**
56
+ * Atomic write (temp file + rename), matching every other store in the repo.
57
+ *
58
+ * A truncate-in-place `writeFileSync` here let a concurrent reader observe a
59
+ * half-written file; `loadCodexAccounts` swallows the parse error and returns
60
+ * an empty account list, so the next save persisted that emptiness and the
61
+ * user's accounts disappeared. On Windows the same overlap surfaces as
62
+ * EPERM/EBUSY. Callers that mutate credentials want to hear about a failed
63
+ * write, so this still throws — only the per-response quota path swallows it.
64
+ */
56
65
  export function saveCodexAccounts(storage: CodexStorage, path = codexAccountsPath()): void {
57
- mkdirSync(dirname(path), { recursive: true });
58
66
  // 0600: these are live OAuth credentials.
59
- writeFileSync(path, JSON.stringify(storage, null, 2) + "\n", { mode: 0o600 });
67
+ if (!writeJson(path, storage, true, 0o600)) {
68
+ throw new Error(`Could not write Codex accounts to ${path}`);
69
+ }
60
70
  }
61
71
 
62
72
  /** Inserts or replaces one account, leaving the others untouched. */