@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.
- package/README.md +24 -3
- package/package.json +4 -2
- package/src/core/accounts/oauth-pool.ts +35 -0
- package/src/core/accounts/registry.ts +4 -0
- package/src/core/accounts/routing.ts +22 -0
- package/src/core/anthropic/identity.ts +54 -0
- package/src/core/anthropic/oauth.ts +3 -0
- package/src/core/anthropic/quota.ts +227 -28
- package/src/core/anthropic/store.ts +4 -1
- package/src/core/codex/quota.ts +43 -11
- package/src/core/codex/store.ts +21 -11
- package/src/core/config.ts +12 -0
- package/src/domains/agents/format.ts +97 -0
- package/src/domains/agents/index.ts +58 -47
- package/src/domains/compact/archive.ts +140 -0
- package/src/domains/compact/chunking.ts +166 -0
- package/src/domains/compact/index.ts +452 -0
- package/src/domains/compact/jev.ts +242 -0
- package/src/domains/compact/policy.ts +255 -0
- package/src/domains/compact/types.ts +79 -0
- package/src/domains/subscriptions/accounts-picker.ts +44 -4
- package/src/domains/subscriptions/accounts.ts +31 -8
- package/src/domains/subscriptions/index.ts +2 -2
- package/src/domains/subscriptions/provider.ts +46 -5
- package/src/domains/subscriptions/providers/anthropic.ts +37 -5
- package/src/domains/subscriptions/providers/codex.ts +120 -254
- package/src/domains/subscriptions/providers/hosted.ts +2 -2
- package/src/domains/subscriptions/providers/oauth-pool.ts +124 -72
- package/src/domains/workflows/index.ts +1 -1
- package/src/domains/workflows/runtime/agent-attempt.ts +41 -11
- package/src/domains/workflows/runtime/agent-failure.ts +14 -0
- package/src/domains/workflows/runtime/agent-limits.ts +24 -1
- package/src/domains/workflows/runtime/agent-runner.ts +42 -20
- package/src/domains/workflows/runtime/agent-workspace.ts +0 -24
- package/src/domains/workflows/runtime/engine.ts +48 -12
- package/src/domains/workflows/runtime/progress.ts +2 -2
- package/src/domains/workflows/runtime/types.ts +4 -4
- package/src/domains/workflows/runtime/ui/display-text.ts +15 -0
- package/src/domains/workflows/runtime/ui/workflow-inspector.ts +51 -14
- package/src/domains/workflows/runtime/ui/workflow-viewer-layout.ts +6 -6
- package/src/domains/workflows/runtime/workflow-run-history.ts +3 -1
- package/src/domains/workflows/runtime/workflow-run-record.ts +1 -0
- package/src/domains/workflows/runtime/worktree.ts +35 -1
- package/src/ui/usage-bars.ts +1 -2
- 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
|
|
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.
|
|
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 {
|
|
2
|
-
import {
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
|
402
|
+
const quota = await poll(token, account.id);
|
|
209
403
|
if (!quota) return false;
|
|
210
|
-
|
|
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
|
|
package/src/core/codex/quota.ts
CHANGED
|
@@ -73,22 +73,54 @@ export function parseCodexQuotaHeaders(
|
|
|
73
73
|
} as QuotaSnapshot;
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
-
/**
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
}
|
package/src/core/codex/store.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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. */
|