@llblab/pi-kit 0.38.2 → 0.38.4

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 (42) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +3 -3
  3. package/node_modules/@llblab/pi-codex-usage/AGENTS.md +2 -2
  4. package/node_modules/@llblab/pi-codex-usage/CHANGELOG.md +6 -0
  5. package/node_modules/@llblab/pi-codex-usage/README.md +9 -5
  6. package/node_modules/@llblab/pi-codex-usage/lib/query.ts +229 -50
  7. package/node_modules/@llblab/pi-codex-usage/lib/usage.ts +9 -1
  8. package/node_modules/@llblab/pi-codex-usage/package.json +1 -1
  9. package/node_modules/@llblab/pi-state-flow/AGENTS.md +1 -1
  10. package/node_modules/@llblab/pi-state-flow/BACKLOG.md +11 -673
  11. package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +18 -0
  12. package/node_modules/@llblab/pi-state-flow/README.md +8 -8
  13. package/node_modules/@llblab/pi-state-flow/dist/lib/extension.js +3 -6
  14. package/node_modules/@llblab/pi-state-flow/dist/lib/model-interface.js +2 -3
  15. package/node_modules/@llblab/pi-state-flow/dist/lib/semantic-module.d.ts +1 -1
  16. package/node_modules/@llblab/pi-state-flow/dist/lib/telegram.js +2 -6
  17. package/node_modules/@llblab/pi-state-flow/dist/package.json +2 -1
  18. package/node_modules/@llblab/pi-state-flow/dist/skills/state-flow-guide/SKILL.md +5 -5
  19. package/node_modules/@llblab/pi-state-flow/dist/skills/state-flow-memory/SKILL.md +1 -1
  20. package/node_modules/@llblab/pi-state-flow/docs/README.md +7 -7
  21. package/node_modules/@llblab/pi-state-flow/docs/architecture.md +11 -15
  22. package/node_modules/@llblab/pi-state-flow/docs/compatibility.md +12 -10
  23. package/node_modules/@llblab/pi-state-flow/docs/fork-contract.md +18 -16
  24. package/node_modules/@llblab/pi-state-flow/docs/lazy-state.md +13 -15
  25. package/node_modules/@llblab/pi-state-flow/docs/protocol-composition-decision.md +2 -2
  26. package/node_modules/@llblab/pi-state-flow/docs/public-module-contract.md +16 -6
  27. package/node_modules/@llblab/pi-state-flow/docs/scope-selection-contract.md +25 -23
  28. package/node_modules/@llblab/pi-state-flow/docs/semantic-compatibility-decision.md +14 -12
  29. package/node_modules/@llblab/pi-state-flow/docs/semantic-extensibility-decision.md +2 -2
  30. package/node_modules/@llblab/pi-state-flow/docs/semantic-handoff-contract.md +133 -15
  31. package/node_modules/@llblab/pi-state-flow/docs/semantic-memory-evaluation.md +5 -5
  32. package/node_modules/@llblab/pi-state-flow/docs/semantic-module-contract.md +5 -5
  33. package/node_modules/@llblab/pi-state-flow/docs/single-scope-interface-contract.md +2 -2
  34. package/node_modules/@llblab/pi-state-flow/docs/usage.md +33 -23
  35. package/node_modules/@llblab/pi-state-flow/lib/extension.ts +3 -7
  36. package/node_modules/@llblab/pi-state-flow/lib/model-interface.ts +2 -3
  37. package/node_modules/@llblab/pi-state-flow/lib/semantic-module.ts +1 -1
  38. package/node_modules/@llblab/pi-state-flow/lib/telegram.ts +2 -6
  39. package/node_modules/@llblab/pi-state-flow/package.json +2 -1
  40. package/node_modules/@llblab/pi-state-flow/skills/state-flow-guide/SKILL.md +5 -5
  41. package/node_modules/@llblab/pi-state-flow/skills/state-flow-memory/SKILL.md +1 -1
  42. package/package.json +3 -3
package/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  All notable changes to `@llblab/pi-kit` are documented here.
4
4
 
5
+ ## 0.38.4: Bundled Package Updates (2026-11-08)
6
+
7
+ - `Exact pins`: `@llblab/pi-codex-usage` 0.13.2 → 0.13.4.
8
+ - `Packaging`: Synchronizes the lockfile, bundle aliases and README inventory. Package membership and resource order remain unchanged; packed validation does not certify extension safety or installed-client behavior.
9
+
10
+ ## 0.38.3: Bundled Package Updates (2026-11-01)
11
+
12
+ - `Exact pins`: `@llblab/pi-state-flow` 0.27.1 → 0.27.4.
13
+ - `Packaging`: Synchronizes the lockfile, bundle aliases and README inventory. Package membership and resource order remain unchanged; packed validation does not certify extension safety or installed-client behavior.
14
+
5
15
  ## 0.38.2: Bundled Package Updates (2026-10-30)
6
16
 
7
17
  - `Exact pins`: `@llblab/pi-claude-usage` 0.2.4 → 0.2.5.
package/README.md CHANGED
@@ -15,9 +15,9 @@ Package links lead to the owning repositories for usage, documentation, issues,
15
15
  | [`@llblab/pi-actors`](https://github.com/llblab/pi-actors) | `0.55.0` | Inspectable local Runs, reusable Recipes, persistent tools, and delegation Skills |
16
16
  | [`@llblab/pi-claude-usage`](https://github.com/llblab/pi-claude-usage) | `0.2.5` | Claude subscription quota status and shared per-model Fast toggle for Opus, mirrored in Telegram |
17
17
  | [`@llblab/pi-clean-room`](https://github.com/llblab/pi-clean-room) | `0.3.1` | Isolated nested Pi TUI with named npm extensions and compatible model selection |
18
- | [`@llblab/pi-codex-usage`](https://github.com/llblab/pi-codex-usage) | `0.13.2` | OpenAI/legacy Codex subscription quota and Business credit status, with persistent priority Fast toggle mirrored in Telegram |
18
+ | [`@llblab/pi-codex-usage`](https://github.com/llblab/pi-codex-usage) | `0.13.4` | OpenAI/legacy Codex subscription quota and Business credit status, with persistent priority Fast toggle mirrored in Telegram |
19
19
  | [`@llblab/pi-grow-loop`](https://github.com/llblab/pi-grow-loop) | `0.9.0` | Visible continuation scheduling and bounded worker Skills through compiled, manifest-owned resources |
20
- | [`@llblab/pi-state-flow`](https://github.com/llblab/pi-state-flow) | `0.27.1` | Scoped context/memory compiler with step-independent current memory, stable Passive/Active modes and visible blocked-state status |
20
+ | [`@llblab/pi-state-flow`](https://github.com/llblab/pi-state-flow) | `0.27.4` | Scoped context/memory compiler with step-independent current memory, stable Passive/Active modes and visible blocked-state status |
21
21
  | [`@llblab/pi-telegram`](https://github.com/llblab/pi-telegram) | `0.58.3` | Telegram companion with per-prompt Steer, queued controls, Reroute/Restore, recoverable connections, previews, follower Threads, files, and voice |
22
22
  | [`@llblab/skills`](https://github.com/llblab/skills) | `1.15.0` | Portable workflows for engineering, review, design, context maintenance, and other focused tasks |
23
23
 
@@ -25,7 +25,7 @@ Versions are exact by design. An upstream release does not change an installed k
25
25
 
26
26
  ## Install
27
27
 
28
- Requires **Pi 1.0.0+** and **Node.js 22.19.0+**. State Flow requires its canonical checkpoint/tail storage format and does not convert unsupported stores in place. Preserve existing stores and consult the [owning package's storage guidance](https://github.com/llblab/pi-state-flow/blob/v0.27.1/docs/usage.md#moving-a-store-and-supported-formats) before changing installations.
28
+ Requires **Pi 1.0.0+** and **Node.js 22.19.0+**. State Flow requires its canonical checkpoint/tail storage format and does not convert unsupported stores in place. Preserve existing stores and consult the [owning package's storage guidance](https://github.com/llblab/pi-state-flow/blob/v0.27.4/docs/usage.md#moving-a-store-and-supported-formats) before changing installations.
29
29
 
30
30
  From NPM:
31
31
 
@@ -2,11 +2,11 @@
2
2
 
3
3
  - `Registry dependency`: Use published `@llblab/pi-command-fast@^0.1.0` with aligned registry lock metadata. Local folder links are only for explicitly requested development tests; rebuild the library's `dist/` after source edits and restore registry dependency/locks before consumer publication.
4
4
  - `Pi baseline`: Every declared `@earendil-works/*` peer requires ≥1.0.0. Keep Pi peer lock identities aligned and validate against that host generation.
5
- - `Domain DAG`: `index.ts` only re-exports the extension and public contracts; `lib/extension.ts` composes the Pi extension. `lib/usage-store.ts` owns persistence/leadership, `lib/query.ts` owns quota I/O, `lib/usage.ts` owns normalized reports, `lib/status.ts` and `lib/status-format.ts` own local UI orchestration and formatting, `lib/telegram.ts` owns optional registration, and `lib/fast.ts` owns Codex Fast semantics/request adaptation. `@llblab/pi-command-fast` owns command arbitration and generic JSONC override editing. Keep dependency direction acyclic; do not import `index.ts` from `lib/` or mix Fast persistence into quota coordination. Place domain tests in `tests/<domain>.test.ts` (or a domain-prefixed focused integration test); use independent names such as `invariants.test.ts` only for cross-domain invariants.
5
+ - `Domain DAG`: `index.ts` only re-exports the extension and public contracts; `lib/extension.ts` composes the Pi extension. `lib/usage-store.ts` owns persistence/leadership, `lib/query.ts` owns auth candidate discovery and quota I/O, `lib/usage.ts` owns normalized reports, `lib/status.ts` and `lib/status-format.ts` own local UI orchestration and formatting, `lib/telegram.ts` owns optional registration, and `lib/fast.ts` owns Codex Fast semantics/request adaptation. `@llblab/pi-command-fast` owns command arbitration and generic JSONC override editing. Keep dependency direction acyclic; do not import `index.ts` from `lib/` or mix Fast persistence into quota coordination. Place domain tests in `tests/<domain>.test.ts` (or a domain-prefixed focused integration test); use independent names such as `invariants.test.ts` only for cross-domain invariants.
6
6
  - `Statusline-first scope`: Own usage state + usage mode: keep quota reporting zero-configuration and the optional Fast toggle focused on the existing terminal status.
7
7
  - Trigger: Considering new commands, menus, persisted settings, or notification output.
8
8
  - Action: Prefer deleting the surface unless it is required for the optimistic TUI status widget, the optional `pi-telegram` `/start` status-line mirror, or the existing argument-free `/fast` command. Register both `openai-codex` and `openai` provider handlers through `registerFastProvider` on session_start and release both on session_shutdown; never register `/fast` directly or filter Fast by model ID/auth. Fast persists solely as `serviceTier: "priority"` in the current provider/model override (never share preferences across providers with the same model ID); OFF deletes only that property. Preserve JSONC/unrelated settings, existing wire tiers, and usage refresh/Telegram. Apply lowercase ` fast` through the final terminal boundary; the optional Telegram row reads the active model's Fast preference at render time and appends plain-text ` fast` (or shows `fast` alone without quota). Toggle redraws without quota fetch or success notification. The library owns session WeakMap/reload arbitration, not Fast state. A Fast config read failure must not block usage status.
9
- - `OpenAI provider support`: Show the same Codex subscription quota for active `openai-codex` and `openai` models. Quota transport accepts `openai` subscription OAuth (guarded by `modelRegistry.isUsingOAuth`) and legacy `openai-codex` auth, with the Codex CLI fallback; never send `openai` API-key credentials to the ChatGPT quota endpoint or label Codex quotas as API billing limits.
9
+ - `OpenAI provider support`: Show the same Codex subscription quota for active `openai-codex` and `openai` models. Recognize both Pi OAuth methods: legacy `openai-codex` credentials and compatible `openai` Sign in with ChatGPT tokens (guarded by `modelRegistry.isUsingOAuth` and a `chatgpt_account_id` claim/header). The newer `openai` token is scoped for `api.openai.com/v1` and rejected by `/wham/usage`, so record it as unavailable instead of sending it. Try legacy Pi auth, compatible new Pi auth, then the local Codex CLI `auth.json` directly before the preserved app-server fallback; never send `openai` API-key credentials to the ChatGPT quota endpoint or label Codex quotas as API billing limits.
10
10
  - `Optimistic refresh`: Preserve the last good statusline bar during refresh and transient failures.
11
11
  - Trigger: Updating quota polling or error handling.
12
12
  - Action: Do not collapse the bar while a request is in flight; only show `n/a` or `error` after repeated failures or no usable quota.
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.13.4: Direct Pi/Codex Auth Hotfix
4
+
5
+ - Fixed the primary quota request falling back to Codex CLI app-server when Pi uses the newer `openai` Sign in with ChatGPT login. The extension recognizes both Pi OpenAI OAuth methods, records the newer API-scoped token as unavailable for `/wham/usage`, and uses legacy-compatible Pi or local Codex CLI credentials for the direct request before the preserved app-server fallback.
6
+ - Compatible credentials now supply `ChatGPT-Account-Id` from Pi headers or the token claim; API keys, non-ChatGPT CLI credentials, and expired tokens are skipped, and JWTs in endpoint error bodies are redacted. The app-server fallback and Windows executable resolution are unchanged.
7
+ - No configuration changes or cache migration are required; restart Pi to load the updated extension.
8
+
3
9
  ## 0.13.2: Empty Quota Bar Hotfix
4
10
 
5
11
  - Aligned quota bars with rounded remaining percentages: when either window rounds to `0%`, its final segment disappears and the whole dual bar uses the error background, while the other half retains its quota. The 5-hour reset countdown follows the same zero boundary; near-full quota still rounds to the nearest 5% step.
@@ -22,7 +22,7 @@ This repository is a minimal fork of [`narumiruna/pi-extensions/extensions/pi-co
22
22
  - Models under either `openai-codex` or `openai` show the same primary Codex subscription quota and `codex` status label (not OpenAI API billing limits)
23
23
  - When `pi-telegram` is available, the same compact value appears as `codex: <value>` in the `/start` menu status text for either supported provider
24
24
  - Additional returned quota buckets are ignored
25
- - Pi subscription auth from `openai` or legacy `openai-codex` is used first
25
+ - Both Pi OpenAI OAuth methods are detected; legacy-compatible Pi/Codex CLI credentials are used for the direct quota request
26
26
  - Codex CLI app-server remains available as a fallback
27
27
  - Missing auth, subscription, plan, or quota windows are shown as `n/a`, not as an error
28
28
  - Successful updates briefly redraw the bar only when a 5% segment changes
@@ -137,7 +137,7 @@ codex error
137
137
 
138
138
  ## Shared Refresh
139
139
 
140
- [`index.ts`](./index.ts) is a re-export-only Pi entrypoint; [`lib/extension.ts`](./lib/extension.ts) composes the live extension. [`lib/usage-store.ts`](./lib/usage-store.ts) owns cross-instance quota coordination, [`lib/query.ts`](./lib/query.ts) owns provider requests, [`lib/usage.ts`](./lib/usage.ts) owns quota normalization, [`lib/status.ts`](./lib/status.ts) and [`lib/status-format.ts`](./lib/status-format.ts) own lifecycle and terminal display, [`lib/telegram.ts`](./lib/telegram.ts) adapts the optional Telegram row, and [`lib/fast.ts`](./lib/fast.ts) owns Codex Fast semantics and the native payload bridge; the shared library owns JSONC and command arbitration. Domain tests live under [`tests/`](./tests/) with matching names; integration tests name the domain whose boundary they exercise.
140
+ [`index.ts`](./index.ts) is a re-export-only Pi entrypoint; [`lib/extension.ts`](./lib/extension.ts) composes the live extension. [`lib/usage-store.ts`](./lib/usage-store.ts) owns cross-instance quota coordination, [`lib/query.ts`](./lib/query.ts) owns auth candidate discovery and provider requests, [`lib/usage.ts`](./lib/usage.ts) owns quota normalization, [`lib/status.ts`](./lib/status.ts) and [`lib/status-format.ts`](./lib/status-format.ts) own lifecycle and terminal display, [`lib/telegram.ts`](./lib/telegram.ts) adapts the optional Telegram row, and [`lib/fast.ts`](./lib/fast.ts) owns Codex Fast semantics and the native payload bridge; the shared library owns JSONC and command arbitration. Domain tests live under [`tests/`](./tests/) with matching names; integration tests name the domain whose boundary they exercise.
141
141
 
142
142
  All Codex models and instances coordinate through `~/.pi/agent/tmp/pi-codex-usage/usage.json` (quota percentages and timestamps only, no tokens). The Claude extension uses its own independent file at `~/.pi/agent/tmp/pi-claude-usage/usage.json`.
143
143
 
@@ -169,12 +169,16 @@ The value is the same compact quota bar plus weekly reset countdown used by the
169
169
 
170
170
  The extension tries usage sources in this order:
171
171
 
172
- 1. Pi's subscription auth from `openai` or legacy `openai-codex` (active model first)
173
- 2. `codex app-server --listen stdio://`
172
+ 1. Pi's legacy `openai-codex` subscription OAuth, resolved through `modelRegistry.getApiKeyAndHeaders()`
173
+ 2. Pi's newer `openai` Sign in with ChatGPT OAuth, when its token carries a Codex account claim
174
+ 3. The local Codex CLI `auth.json` ChatGPT credential, read directly without spawning a process
175
+ 4. `codex app-server --listen stdio://`
176
+
177
+ The `/wham/usage` endpoint accepts legacy Codex-shaped OAuth tokens and rejects the newer `openai` Sign in with ChatGPT token: it is scoped for `api.openai.com/v1`, has no `chatgpt_account_id` claim, and returns 401 even when an account header is supplied. The extension detects that token, records it as unavailable, and does not send it to the quota endpoint. Both Pi login methods are recognized; use `/login openai-codex` for a directly usable Pi credential, or keep the Codex CLI logged in for the direct local auth source. Compatible tokens get `ChatGPT-Account-Id` from Pi headers or the token's `https://api.openai.com/auth` claim. The Codex CLI source reads `$CODEX_HOME/auth.json` (default `~/.codex/auth.json`), accepts ChatGPT-mode credentials (`auth_mode: "chatgpt"` or legacy files without a mode), skips expired tokens, and never persists tokens.
174
178
 
175
179
  On Windows, the fallback resolves the native `codex.exe` from PATH or from the npm platform package behind a `codex.cmd` shim, then launches it directly without a shell. If Codex is missing, install it with `npm install -g @openai/codex@latest` and restart Pi so it inherits the updated PATH.
176
180
 
177
- Selecting an `openai` model enables the status and Fast toggle, and its ChatGPT subscription OAuth credentials can fetch the quota directly; a legacy `openai-codex` provider is not required. The extension uses Pi's resolved auth headers/token, checks `modelRegistry.isUsingOAuth` before using `openai` credentials, and keeps the Codex CLI fallback. OpenAI API keys are not ChatGPT subscription auth and do not expose these quotas; `openai` API-key credentials are skipped, not sent to the ChatGPT quota endpoint. Without subscription auth/quota, the quota status is `n/a` when both sources report an unavailable state. Fast works independently of subscription auth.
181
+ Selecting an `openai` model enables the status and Fast toggle. The extension uses Pi's resolved auth headers/token, checks `modelRegistry.isUsingOAuth` before using `openai` credentials, and keeps the Codex CLI fallback. OpenAI API keys are not ChatGPT subscription auth and do not expose these quotas; `openai` API-key credentials are skipped, not sent to the ChatGPT quota endpoint. Without subscription auth/quota, the quota status is `n/a` when all sources report an unavailable state. Fast works independently of subscription auth.
178
182
 
179
183
  ## License
180
184
 
@@ -1,16 +1,31 @@
1
- /** Domain: quota transport. Owns: Pi auth, Codex app-server fallback and errors. Excludes: shared leadership and UI. */
1
+ /** Domain: quota transport. Owns: Pi/Codex auth candidates, guarded HTTP/RPC quota requests and errors. Excludes: shared leadership and UI. */
2
2
  import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
3
- import { existsSync } from "node:fs";
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { homedir } from "node:os";
4
5
  import { join } from "node:path";
5
6
  import { createInterface } from "node:readline";
6
7
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
7
- import { assertObject, errorMessage, isOpenAICodexModel, normalizeAppServerResponse, normalizeBackendPayload, parseJsonObject, selectUsageSnapshot, type AppServerRateLimitResponse, type CodexUsageModel, type CodexUsageReport, type RateLimitStatusPayload, type UsageQueryError } from "./usage.ts";
8
+ import { assertObject, errorMessage, isOpenAICodexModel, normalizeAppServerResponse, normalizeBackendPayload, parseJsonObject, selectUsageSnapshot, type AppServerRateLimitResponse, type CodexUsageModel, type CodexUsageReport, type RateLimitStatusPayload, type UsageQueryError, type UsageSource } from "./usage.ts";
8
9
  const CODEX_USAGE_LIMIT_ID = "codex";
9
10
  const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
11
+ const CODEX_USAGE_ACCOUNT_HEADER = "ChatGPT-Account-Id";
12
+ const OPENAI_AUTH_CLAIM = "https://api.openai.com/auth";
13
+ const LEGACY_PROVIDER = "openai-codex";
14
+ const NEW_PROVIDER = "openai";
15
+ const USER_AGENT = "pi-codex-usage";
16
+ const TOKEN_EXPIRY_MARGIN_MS = 60_000;
10
17
  const MAX_ERROR_BODY_CHARS = 600;
11
18
  type PiModel = NonNullable<ExtensionContext["model"]>;
12
19
  type QueryUsageOptions = { timeoutMs: number };
13
20
  export type QueryUsageResult = { ok: true; report: CodexUsageReport } | { ok: false; errors: UsageQueryError[] };
21
+ type QuotaAuthCandidate = {
22
+ source: UsageSource;
23
+ headers: Record<string, string>;
24
+ };
25
+ type QuotaAuthResolution = {
26
+ candidates: QuotaAuthCandidate[];
27
+ errors: UsageQueryError[];
28
+ };
14
29
  type RpcResponse = {
15
30
  id?: unknown;
16
31
  result?: unknown;
@@ -29,15 +44,15 @@ export async function queryUsage(
29
44
  mayQuery: () => boolean,
30
45
  ): Promise<QueryUsageResult | undefined> {
31
46
  const errors: UsageQueryError[] = [];
32
- const sources = ["pi-auth", "codex-app-server"] as const;
47
+ if (!mayQuery()) return undefined;
48
+ const auth = await resolveQuotaAuthCandidates(ctx);
49
+ errors.push(...auth.errors);
50
+ if (!mayQuery()) return undefined;
33
51
 
34
- for (const source of sources) {
52
+ for (const candidate of auth.candidates) {
35
53
  if (!mayQuery()) return undefined;
36
54
  try {
37
- const report =
38
- source === "pi-auth"
39
- ? await queryViaPiAuth(ctx, options.timeoutMs, mayQuery)
40
- : await queryViaCodexAppServer(options.timeoutMs, mayQuery);
55
+ const report = await queryViaQuotaAuth(candidate, options.timeoutMs, mayQuery);
41
56
  if (!report) return undefined;
42
57
  if (
43
58
  selectUsageSnapshot(report, CODEX_USAGE_LIMIT_ID) ||
@@ -46,33 +61,44 @@ export async function queryUsage(
46
61
  return { ok: true, report };
47
62
  }
48
63
  errors.push({
49
- source,
50
- message: `${source} returned no displayable codex rate-limit windows`,
64
+ source: candidate.source,
65
+ message: `${candidate.source} returned no displayable rate-limit windows`,
51
66
  });
52
67
  } catch (cause) {
53
- errors.push({ source, message: errorMessage(cause), cause });
68
+ errors.push({ source: candidate.source, message: errorMessage(cause), cause });
54
69
  }
55
70
  }
56
71
 
72
+ if (!mayQuery()) return undefined;
73
+ try {
74
+ const report = await queryViaCodexAppServer(options.timeoutMs, mayQuery);
75
+ if (!report) return undefined;
76
+ if (
77
+ selectUsageSnapshot(report, CODEX_USAGE_LIMIT_ID) ||
78
+ report.credits
79
+ ) {
80
+ return { ok: true, report };
81
+ }
82
+ errors.push({
83
+ source: "codex-app-server",
84
+ message: "codex-app-server returned no displayable rate-limit windows",
85
+ });
86
+ } catch (cause) {
87
+ errors.push({ source: "codex-app-server", message: errorMessage(cause), cause });
88
+ }
89
+
57
90
  return { ok: false, errors };
58
91
  }
59
92
 
60
- async function queryViaPiAuth(
61
- ctx: ExtensionContext,
93
+ async function queryViaQuotaAuth(
94
+ candidate: QuotaAuthCandidate,
62
95
  timeoutMs: number,
63
96
  mayQuery: () => boolean,
64
97
  ): Promise<CodexUsageReport | undefined> {
65
- const auth = await resolvePiCodexAuth(ctx);
66
- if (!auth) {
67
- throw new Error(
68
- "No Pi OpenAI Codex subscription auth was available. Use an openai or openai-codex subscription model or run /login for OpenAI ChatGPT.",
69
- );
70
- }
71
-
72
98
  if (!mayQuery()) return undefined;
73
99
  const response = await fetchWithTimeout(
74
100
  CODEX_USAGE_URL,
75
- { headers: auth.headers },
101
+ { headers: candidate.headers },
76
102
  timeoutMs,
77
103
  );
78
104
  const text = await response.text();
@@ -86,43 +112,93 @@ async function queryViaPiAuth(
86
112
  return normalizeBackendPayload(
87
113
  payload as RateLimitStatusPayload,
88
114
  Date.now(),
89
- "pi-auth",
115
+ candidate.source,
90
116
  );
91
117
  }
92
118
 
93
- async function resolvePiCodexAuth(
94
- ctx: ExtensionContext,
95
- ): Promise<{ headers: Record<string, string> } | undefined> {
96
- const models = codexAuthCandidateModels(ctx);
97
- const errors: string[] = [];
119
+ // --- Auth Candidates ---
98
120
 
99
- for (const model of models) {
100
- if (model.provider === "openai" && !ctx.modelRegistry.isUsingOAuth(model)) continue;
101
- const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
121
+ export async function resolveQuotaAuthCandidates(
122
+ ctx: ExtensionContext,
123
+ env: NodeJS.ProcessEnv = process.env,
124
+ now = Date.now(),
125
+ ): Promise<QuotaAuthResolution> {
126
+ const candidates: QuotaAuthCandidate[] = [];
127
+ const errors: UsageQueryError[] = [];
128
+ const seenTokens = new Set<string>();
129
+
130
+ for (const model of orderedCodexAuthModels(ctx)) {
131
+ if (model.provider === NEW_PROVIDER && !ctx.modelRegistry.isUsingOAuth(model)) continue;
132
+ const source: UsageSource = model.provider === NEW_PROVIDER
133
+ ? "pi-auth-openai"
134
+ : "pi-auth-openai-codex";
135
+ let auth;
136
+ try {
137
+ auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
138
+ } catch (cause) {
139
+ errors.push({ source, message: errorMessage(cause) });
140
+ continue;
141
+ }
102
142
  if (!auth.ok) {
103
- errors.push(auth.error);
143
+ errors.push({ source, message: auth.error });
104
144
  continue;
105
145
  }
106
146
 
107
- const headers: Record<string, string> = {};
108
- for (const [name, value] of Object.entries(auth.headers ?? {})) {
109
- if (value !== null) headers[name] = value;
110
- }
111
- if (!hasHeader(headers, "Authorization") && auth.apiKey) {
147
+ const headers = stringHeaders(auth.headers);
148
+ if (!hasHeader(headers, "Authorization") && auth.apiKey)
112
149
  headers.Authorization = `Bearer ${auth.apiKey}`;
150
+ const token = bearerToken(headers);
151
+ if (!token || token.startsWith("sk-")) continue;
152
+ const accountId = headerValue(headers, CODEX_USAGE_ACCOUNT_HEADER) ?? chatgptAccountId(token);
153
+ if (model.provider === NEW_PROVIDER && !accountId) {
154
+ errors.push({
155
+ source,
156
+ message: "New Pi OpenAI OAuth credential is not accepted by the Codex usage endpoint",
157
+ });
158
+ continue;
113
159
  }
114
- if (!hasHeader(headers, "User-Agent")) {
115
- headers["User-Agent"] = "pi-codex-usage";
160
+ if (isExpiredToken(token, now)) {
161
+ errors.push({ source, message: "Pi OpenAI subscription token has expired" });
162
+ continue;
116
163
  }
117
- if (hasHeader(headers, "Authorization")) {
118
- return { headers };
164
+ if (accountId && !hasHeader(headers, CODEX_USAGE_ACCOUNT_HEADER))
165
+ headers[CODEX_USAGE_ACCOUNT_HEADER] = accountId;
166
+ if (!hasHeader(headers, "User-Agent")) headers["User-Agent"] = USER_AGENT;
167
+ if (!hasHeader(headers, "Accept")) headers.Accept = "application/json";
168
+ if (seenTokens.has(token)) continue;
169
+ seenTokens.add(token);
170
+ candidates.push({ source, headers });
171
+ }
172
+
173
+ const codexCli = readCodexCliAuth(env, now);
174
+ if (codexCli) {
175
+ const token = bearerToken(codexCli.headers);
176
+ if (token && !seenTokens.has(token)) {
177
+ seenTokens.add(token);
178
+ candidates.push(codexCli);
119
179
  }
180
+ } else {
181
+ errors.push({
182
+ source: "codex-cli-auth",
183
+ message: "No usable Codex CLI ChatGPT auth was available",
184
+ });
120
185
  }
121
186
 
122
- if (errors.length > 0) {
123
- throw new Error(errors.join("; "));
187
+ if (candidates.length === 0 && errors.length === 0) {
188
+ errors.push({
189
+ source: "pi-auth",
190
+ message: "No Pi OpenAI Codex subscription auth was available. Use an openai or openai-codex subscription model or run /login for OpenAI ChatGPT.",
191
+ });
124
192
  }
125
- return undefined;
193
+ return { candidates, errors };
194
+ }
195
+
196
+ function orderedCodexAuthModels(ctx: ExtensionContext): PiModel[] {
197
+ const models = codexAuthCandidateModels(ctx);
198
+ return [
199
+ ...models.filter((model) => model.provider === LEGACY_PROVIDER),
200
+ ...models.filter((model) => model.provider === NEW_PROVIDER),
201
+ ];
126
202
  }
127
203
 
128
204
  function codexAuthCandidateModels(ctx: ExtensionContext): PiModel[] {
@@ -142,6 +218,114 @@ function codexAuthCandidateModels(ctx: ExtensionContext): PiModel[] {
142
218
  return candidates;
143
219
  }
144
220
 
221
+ function readCodexCliAuth(
222
+ env: NodeJS.ProcessEnv,
223
+ now: number,
224
+ ): QuotaAuthCandidate | undefined {
225
+ try {
226
+ const payload: unknown = JSON.parse(readFileSync(codexAuthPath(env), "utf8"));
227
+ if (!isRecord(payload)) return undefined;
228
+ if (typeof payload.auth_mode === "string" && payload.auth_mode !== "chatgpt")
229
+ return undefined;
230
+ const tokens = isRecord(payload.tokens) ? payload.tokens : undefined;
231
+ const accessToken = typeof tokens?.access_token === "string"
232
+ ? tokens.access_token
233
+ : undefined;
234
+ if (!accessToken || accessToken.startsWith("sk-")) return undefined;
235
+ if (isExpiredToken(accessToken, now)) return undefined;
236
+ const accountId = stringValue(tokens?.account_id)
237
+ ?? chatgptAccountId(accessToken)
238
+ ?? (typeof tokens?.id_token === "string"
239
+ ? chatgptAccountId(tokens.id_token)
240
+ : undefined);
241
+ if (!accountId) return undefined;
242
+ return {
243
+ source: "codex-cli-auth",
244
+ headers: {
245
+ Authorization: `Bearer ${accessToken}`,
246
+ [CODEX_USAGE_ACCOUNT_HEADER]: accountId,
247
+ "User-Agent": USER_AGENT,
248
+ Accept: "application/json",
249
+ },
250
+ };
251
+ } catch {
252
+ return undefined;
253
+ }
254
+ }
255
+
256
+ function codexAuthPath(env: NodeJS.ProcessEnv): string {
257
+ const configured = typeof env.CODEX_HOME === "string" ? env.CODEX_HOME.trim() : "";
258
+ if (!configured) return join(homedir(), ".codex", "auth.json");
259
+ const root = configured === "~"
260
+ ? homedir()
261
+ : configured.startsWith("~/") || configured.startsWith("~\\")
262
+ ? join(homedir(), configured.slice(2))
263
+ : configured;
264
+ return join(root, "auth.json");
265
+ }
266
+
267
+ function stringHeaders(
268
+ headers: Record<string, string | null> | undefined,
269
+ ): Record<string, string> {
270
+ const result: Record<string, string> = {};
271
+ for (const [name, value] of Object.entries(headers ?? {})) {
272
+ if (value !== null) result[name] = value;
273
+ }
274
+ return result;
275
+ }
276
+
277
+ function hasHeader(headers: Record<string, string>, name: string): boolean {
278
+ return headerValue(headers, name) !== undefined;
279
+ }
280
+
281
+ function headerValue(
282
+ headers: Record<string, string>,
283
+ name: string,
284
+ ): string | undefined {
285
+ const expected = name.toLowerCase();
286
+ for (const [key, value] of Object.entries(headers)) {
287
+ if (key.toLowerCase() === expected && value.trim()) return value;
288
+ }
289
+ return undefined;
290
+ }
291
+
292
+ function bearerToken(headers: Record<string, string>): string | undefined {
293
+ const value = headerValue(headers, "Authorization");
294
+ return /^Bearer\s+(.+)$/iu.exec(value ?? "")?.[1]?.trim();
295
+ }
296
+
297
+ function chatgptAccountId(token: string): string | undefined {
298
+ const payload = decodeJwtPayload(token);
299
+ const authClaim = payload?.[OPENAI_AUTH_CLAIM];
300
+ return stringValue(isRecord(authClaim) ? authClaim.chatgpt_account_id : undefined);
301
+ }
302
+
303
+ function isExpiredToken(token: string, now: number): boolean {
304
+ const payload = decodeJwtPayload(token);
305
+ const exp = payload?.exp;
306
+ if (typeof exp !== "number" || !Number.isFinite(exp)) return false;
307
+ return exp * 1000 <= now + TOKEN_EXPIRY_MARGIN_MS;
308
+ }
309
+
310
+ function decodeJwtPayload(token: string): Record<string, unknown> | undefined {
311
+ const encoded = token.split(".")[1];
312
+ if (!encoded) return undefined;
313
+ try {
314
+ const parsed: unknown = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8"));
315
+ return isRecord(parsed) ? parsed : undefined;
316
+ } catch {
317
+ return undefined;
318
+ }
319
+ }
320
+
321
+ function stringValue(value: unknown): string | undefined {
322
+ return typeof value === "string" && value.trim() ? value : undefined;
323
+ }
324
+
325
+ function isRecord(value: unknown): value is Record<string, unknown> {
326
+ return typeof value === "object" && value !== null && !Array.isArray(value);
327
+ }
328
+
145
329
  async function fetchWithTimeout(
146
330
  url: string,
147
331
  init: RequestInit,
@@ -390,17 +574,12 @@ class CodexAppServerClient {
390
574
  }
391
575
 
392
576
 
393
- function hasHeader(headers: Record<string, string>, name: string): boolean {
394
- return Object.keys(headers).some(
395
- (key) => key.toLowerCase() === name.toLowerCase(),
396
- );
397
- }
398
-
399
577
  function redactErrorBody(body: string): string {
400
578
  return truncateEnd(
401
579
  body
402
580
  .replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer <redacted>")
403
581
  .replace(/"access_token"\s*:\s*"[^"]+"/gi, '"access_token":"<redacted>"')
582
+ .replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, "<redacted>")
404
583
  .trim(),
405
584
  MAX_ERROR_BODY_CHARS,
406
585
  );
@@ -8,7 +8,12 @@ export const OPENAI_USAGE_PROVIDERS = ["openai-codex", "openai"] as const;
8
8
  export function isOpenAICodexModel(model: Pick<PiModel, "provider"> | undefined): boolean {
9
9
  return OPENAI_USAGE_PROVIDERS.some((provider) => model?.provider === provider);
10
10
  }
11
- export type UsageSource = "pi-auth" | "codex-app-server";
11
+ export type UsageSource =
12
+ | "pi-auth"
13
+ | "pi-auth-openai"
14
+ | "pi-auth-openai-codex"
15
+ | "codex-cli-auth"
16
+ | "codex-app-server";
12
17
  export type UsageQueryError = { source: UsageSource; message: string; cause?: unknown };
13
18
  export type CodexUsageReport = {
14
19
  snapshots: NormalizedRateLimitSnapshot[];
@@ -283,6 +288,9 @@ function isUnavailableError(error: UsageQueryError): boolean {
283
288
  const message = error.message.toLowerCase();
284
289
  return (
285
290
  message.includes("no pi openai codex subscription auth") ||
291
+ message.includes("not accepted by the codex usage endpoint") ||
292
+ message.includes("no usable codex cli chatgpt auth") ||
293
+ message.includes("subscription token has expired") ||
286
294
  message.includes("no displayable rate-limit windows") ||
287
295
  message.includes("returned no displayable rate-limit windows") ||
288
296
  message.includes("returned 401") ||
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-codex-usage",
3
- "version": "0.13.2",
3
+ "version": "0.13.4",
4
4
  "private": false,
5
5
  "description": "Pi extension for Codex subscription usage and an optional Fast toggle",
6
6
  "keywords": [
@@ -13,7 +13,7 @@ The [relocation ledger](docs/agent-contract-relocation.md) maps every pre-compac
13
13
  - Artifacts name exact registered source paths; observe only regular non-symlink files, never discover directories or read unrelated bodies. Preserve hidden per-scope provenance, exact-owner compilation, stable fingerprint checks and separate Skill hashes; unavailable sources are not proof of deletion. See [artifact routing](docs/architecture.md#artifact-routing).
14
14
  - Preserve session config/runtime, per-scope metadata, lineage and provenance outside model-patchable state; decode legacy mode evidence read-only, never mix representations or normalize storage eagerly. See [storage and identity](docs/architecture.md#storage-and-identity) and [mode compatibility](docs/compatibility.md#mode-configuration-compatibility).
15
15
  - Keep one opaque composed causal lineage, anchored checkpoint/tail materialization, configured hot-history folding and independent owner revisions; never invent earlier history or rebuild discarded offsets. Historical reads are observational, and `effective[n]`/owner materializations select the same causal boundary. See [temporal model](docs/architecture.md#temporal-model) and [model tools](docs/architecture.md#model-tools).
16
- - Keep the [internal semantic-module contract](docs/semantic-module-contract.md) package-owned and separate from storage/native authority. Module candidates are not accepted publications; immutable means main-agent read-only, not producer-frozen. Preserve the single intent-first engine and current dialect; no runtime discovery/public ABI or automatic inference follows from a module identity.
16
+ - Keep the [internal semantic-module contract](docs/semantic-module-contract.md) package-owned and separate from storage/native authority. Module candidates are not accepted publications; immutable means main-agent read-only, not producer-frozen. Preserve the single intent-first engine and current dialect; no runtime discovery, public registration authority or automatic inference follows from an internal module identity.
17
17
  - Keep the [public module registration boundary](docs/public-module-contract.md) native-host-scoped and revocable. A registration/catalog grants no source, private or publication authority. Follow its explicit compatibility decision before admitting external stored roots; unavailable-module data must never be dropped through filtering or normalization. Pi remains the loader.
18
18
 
19
19
  ## Publication and lifecycle