@jameslovespancakes/pi-plus 1.0.11 → 1.0.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/README.md +67 -22
  2. package/config/skills/workflow-code-review-actions/SKILL.md +44 -0
  3. package/package.json +9 -4
  4. package/src/core/accounts/oauth-pool.ts +137 -0
  5. package/src/core/accounts/registry.ts +12 -25
  6. package/src/core/accounts/routing.ts +144 -0
  7. package/src/core/anthropic/client-identity.ts +9 -64
  8. package/src/core/anthropic/identity.ts +54 -0
  9. package/src/core/anthropic/oauth.ts +3 -0
  10. package/src/core/anthropic/quota.ts +232 -78
  11. package/src/core/anthropic/routing.ts +40 -115
  12. package/src/core/anthropic/store.ts +11 -10
  13. package/src/core/catalog/quality.ts +33 -15
  14. package/src/core/codex/quota.ts +46 -8
  15. package/src/core/codex/store.ts +29 -16
  16. package/src/core/config.ts +16 -17
  17. package/src/core/store.ts +13 -12
  18. package/src/domains/agents/format.ts +97 -0
  19. package/src/domains/agents/index.ts +58 -47
  20. package/src/domains/compact/archive.ts +140 -0
  21. package/src/domains/compact/chunking.ts +166 -0
  22. package/src/domains/compact/index.ts +452 -0
  23. package/src/domains/compact/jev.ts +242 -0
  24. package/src/domains/compact/policy.ts +255 -0
  25. package/src/domains/compact/types.ts +79 -0
  26. package/src/domains/models/provider-picker.ts +1 -1
  27. package/src/domains/setup/index.ts +23 -23
  28. package/src/domains/subscriptions/accounts-picker.ts +61 -40
  29. package/src/domains/subscriptions/accounts.ts +53 -36
  30. package/src/domains/subscriptions/footer.ts +48 -29
  31. package/src/domains/subscriptions/index.ts +11 -27
  32. package/src/domains/subscriptions/provider.ts +79 -103
  33. package/src/domains/subscriptions/providers/anthropic.ts +42 -22
  34. package/src/domains/subscriptions/providers/codex.ts +146 -113
  35. package/src/domains/subscriptions/providers/hosted.ts +18 -0
  36. package/src/domains/subscriptions/providers/oauth-pool.ts +325 -0
  37. package/src/domains/subscriptions/routing.ts +19 -11
  38. package/src/domains/workflows/LICENSE.md +21 -0
  39. package/src/domains/workflows/index.ts +836 -0
  40. package/src/domains/workflows/runtime/advisory-challenge.ts +75 -0
  41. package/src/domains/workflows/runtime/advisory-evidence.ts +90 -0
  42. package/src/domains/workflows/runtime/advisory-schema.ts +83 -0
  43. package/src/domains/workflows/runtime/agent-attempt.ts +177 -0
  44. package/src/domains/workflows/runtime/agent-failure.ts +14 -0
  45. package/src/domains/workflows/runtime/agent-limits.ts +66 -0
  46. package/src/domains/workflows/runtime/agent-replay.ts +399 -0
  47. package/src/domains/workflows/runtime/agent-retry.ts +116 -0
  48. package/src/domains/workflows/runtime/agent-runner-types.ts +96 -0
  49. package/src/domains/workflows/runtime/agent-runner.ts +225 -0
  50. package/src/domains/workflows/runtime/agent-session-identity.ts +256 -0
  51. package/src/domains/workflows/runtime/agent-session-providers.ts +50 -0
  52. package/src/domains/workflows/runtime/agent-session.ts +382 -0
  53. package/src/domains/workflows/runtime/agent-skills.ts +270 -0
  54. package/src/domains/workflows/runtime/agent-workspace.ts +79 -0
  55. package/src/domains/workflows/runtime/background-workflow-tool.ts +75 -0
  56. package/src/domains/workflows/runtime/background-workflows.ts +492 -0
  57. package/src/domains/workflows/runtime/budget.ts +53 -0
  58. package/src/domains/workflows/runtime/cancellation.ts +87 -0
  59. package/src/domains/workflows/runtime/command-completions.ts +36 -0
  60. package/src/domains/workflows/runtime/concurrency.ts +403 -0
  61. package/src/domains/workflows/runtime/debug.ts +3 -0
  62. package/src/domains/workflows/runtime/diff-capture.ts +81 -0
  63. package/src/domains/workflows/runtime/discovery.ts +137 -0
  64. package/src/domains/workflows/runtime/dynamax-shortcuts.ts +122 -0
  65. package/src/domains/workflows/runtime/dynamax.ts +330 -0
  66. package/src/domains/workflows/runtime/engine.ts +579 -0
  67. package/src/domains/workflows/runtime/filesystem-error.ts +4 -0
  68. package/src/domains/workflows/runtime/finalizers.ts +66 -0
  69. package/src/domains/workflows/runtime/identity-canonicalization.ts +138 -0
  70. package/src/domains/workflows/runtime/identity-fingerprint.ts +15 -0
  71. package/src/domains/workflows/runtime/inline-workflow.ts +403 -0
  72. package/src/domains/workflows/runtime/journal.ts +313 -0
  73. package/src/domains/workflows/runtime/model-profiles.ts +310 -0
  74. package/src/domains/workflows/runtime/options.ts +157 -0
  75. package/src/domains/workflows/runtime/perf.ts +146 -0
  76. package/src/domains/workflows/runtime/pi-compat.ts +32 -0
  77. package/src/domains/workflows/runtime/process-runner.ts +260 -0
  78. package/src/domains/workflows/runtime/progress-types.ts +59 -0
  79. package/src/domains/workflows/runtime/progress.ts +400 -0
  80. package/src/domains/workflows/runtime/provider-usage-limit.ts +191 -0
  81. package/src/domains/workflows/runtime/replay-path-identity.ts +48 -0
  82. package/src/domains/workflows/runtime/research-contract.ts +89 -0
  83. package/src/domains/workflows/runtime/research-evidence.ts +289 -0
  84. package/src/domains/workflows/runtime/resume-context.ts +751 -0
  85. package/src/domains/workflows/runtime/review/code-review-orchestration.ts +24 -0
  86. package/src/domains/workflows/runtime/review/github-pr-comments.ts +249 -0
  87. package/src/domains/workflows/runtime/review/patch-validation.ts +120 -0
  88. package/src/domains/workflows/runtime/review/review-actions.ts +180 -0
  89. package/src/domains/workflows/runtime/review/review-budget.ts +45 -0
  90. package/src/domains/workflows/runtime/review/review-fix-workflow.ts +153 -0
  91. package/src/domains/workflows/runtime/review/review-format.ts +133 -0
  92. package/src/domains/workflows/runtime/review/review-handoff.ts +38 -0
  93. package/src/domains/workflows/runtime/review/review-issues.ts +87 -0
  94. package/src/domains/workflows/runtime/review/review-report.ts +42 -0
  95. package/src/domains/workflows/runtime/review/review-results-flow.ts +52 -0
  96. package/src/domains/workflows/runtime/review/review-results-viewer.ts +312 -0
  97. package/src/domains/workflows/runtime/review/review-session-coordinator.ts +153 -0
  98. package/src/domains/workflows/runtime/review/review-snapshot.ts +294 -0
  99. package/src/domains/workflows/runtime/review-diff-target.ts +130 -0
  100. package/src/domains/workflows/runtime/session-identity.ts +6 -0
  101. package/src/domains/workflows/runtime/structured-output.ts +32 -0
  102. package/src/domains/workflows/runtime/tool-capabilities.ts +44 -0
  103. package/src/domains/workflows/runtime/tool-source-identity.ts +150 -0
  104. package/src/domains/workflows/runtime/tree-fingerprint.ts +404 -0
  105. package/src/domains/workflows/runtime/types.ts +255 -0
  106. package/src/domains/workflows/runtime/ui/display-text.ts +28 -0
  107. package/src/domains/workflows/runtime/ui/dynamax-editor-decoration.ts +220 -0
  108. package/src/domains/workflows/runtime/ui/workflow-format.ts +165 -0
  109. package/src/domains/workflows/runtime/ui/workflow-inspector.ts +435 -0
  110. package/src/domains/workflows/runtime/ui/workflow-result-renderer.ts +171 -0
  111. package/src/domains/workflows/runtime/ui/workflow-viewer-layout.ts +51 -0
  112. package/src/domains/workflows/runtime/ui/workflow-widget.ts +78 -0
  113. package/src/domains/workflows/runtime/unknown-error.ts +8 -0
  114. package/src/domains/workflows/runtime/usage.ts +341 -0
  115. package/src/domains/workflows/runtime/workflow-advisory-utils.ts +245 -0
  116. package/src/domains/workflows/runtime/workflow-execution.ts +121 -0
  117. package/src/domains/workflows/runtime/workflow-module.ts +75 -0
  118. package/src/domains/workflows/runtime/workflow-run-background.ts +72 -0
  119. package/src/domains/workflows/runtime/workflow-run-controller.ts +342 -0
  120. package/src/domains/workflows/runtime/workflow-run-history.ts +164 -0
  121. package/src/domains/workflows/runtime/workflow-run-record.ts +636 -0
  122. package/src/domains/workflows/runtime/workflow-run-store.ts +176 -0
  123. package/src/domains/workflows/runtime/workflow-usage-limit-scheduler.ts +80 -0
  124. package/src/domains/workflows/runtime/workflows.ts +40 -0
  125. package/src/domains/workflows/runtime/worktree.ts +615 -0
  126. package/src/domains/workflows/workflows/code-review.ts +232 -0
  127. package/src/domains/workflows/workflows/diagnose.ts +154 -0
  128. package/src/domains/workflows/workflows/perf-review.ts +149 -0
  129. package/src/domains/workflows/workflows/refactor-scout.ts +143 -0
  130. package/src/domains/workflows/workflows/research.ts +169 -0
  131. package/src/services/usage-service.ts +6 -30
  132. package/src/ui/usage-bars.ts +1 -2
  133. package/src/core/codex/oauth.ts +0 -129
@@ -1,12 +1,14 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import type { AccountContext, AccountProvider, ManagedAccount, RoutingMode } from "../../../core/accounts/registry.ts";
3
+ import { anthropicAccountIdentity } from "../../../core/anthropic/identity.ts";
3
4
  import { authorize, exchange } from "../../../core/anthropic/oauth.ts";
5
+ import { ensureAccessToken } from "../../../core/anthropic/quota.ts";
4
6
  import {
5
7
  getRoutingMode,
6
8
  loadAccounts,
7
9
  saveAccount,
8
10
  setRoutingMode,
9
- type RoutingMode as StoreRoutingMode,
11
+ type Account,
10
12
  } from "../../../core/anthropic/store.ts";
11
13
 
12
14
  /**
@@ -22,16 +24,6 @@ import {
22
24
  * the extracted store directly.
23
25
  */
24
26
 
25
- /** The registry's vocabulary maps onto the store's routing modes. */
26
- const MODE_TO_STORE: Record<RoutingMode, StoreRoutingMode> = {
27
- standard: "main-first",
28
- optimal: "sticky-balanced",
29
- };
30
-
31
- function fromStoreMode(mode: string): RoutingMode {
32
- return mode === "sticky-balanced" ? "optimal" : "standard";
33
- }
34
-
35
27
  async function authenticate(ctx: AccountContext): Promise<{ access: string; refresh: string; expires: number } | undefined> {
36
28
  const auth = await authorize("max");
37
29
 
@@ -51,22 +43,45 @@ async function authenticate(ctx: AccountContext): Promise<{ access: string; refr
51
43
  return { access: result.access, refresh: result.refresh, expires: result.expires };
52
44
  }
53
45
 
46
+ /** Resolves and persists identity once; token rotation does not change it. */
47
+ async function stableIdentity(account: Account): Promise<string | undefined> {
48
+ if (account.identity) return account.identity;
49
+ let access = account.access;
50
+ if (!access || (typeof account.expires === "number" && account.expires <= Date.now())) {
51
+ try { access = await ensureAccessToken(account); } catch { return undefined; }
52
+ }
53
+ if (!access) return undefined;
54
+ const identity = await anthropicAccountIdentity(access);
55
+ if (!identity) return undefined;
56
+
57
+ const current = loadAccounts()?.accounts.find((candidate) => candidate.id === account.id);
58
+ if (current && !current.identity) saveAccount({ ...current, identity });
59
+ return identity;
60
+ }
61
+
54
62
  export const anthropicAccounts: AccountProvider = {
55
63
  id: "anthropic",
56
64
  label: "Claude",
57
65
 
58
66
  async list(): Promise<ManagedAccount[]> {
59
- const storage = loadAccounts();
60
- return (storage?.accounts ?? [])
61
- .filter((account) => account.type === "oauth")
62
- .map((account) => ({
67
+ const accounts = (loadAccounts()?.accounts ?? []).filter((account) => account.type === "oauth");
68
+ const result: ManagedAccount[] = [];
69
+ // Bootstrap is deliberately serial: this is a one-time migration for old
70
+ // rows, and concurrent profile requests can be throttled independently.
71
+ for (const account of accounts) {
72
+ result.push({
63
73
  id: account.id,
64
74
  label: account.label ?? account.id.slice(0, 8),
65
75
  enabled: account.enabled !== false,
66
76
  expiresAt: typeof account.expires === "number" ? account.expires : undefined,
67
- }));
77
+ identity: await stableIdentity(account),
78
+ });
79
+ }
80
+ return result;
68
81
  },
69
82
 
83
+ identify: anthropicAccountIdentity,
84
+
70
85
  async add(ctx, label): Promise<string | undefined> {
71
86
  const proceed = await ctx.ui.confirm(
72
87
  "Add Claude account",
@@ -78,6 +93,7 @@ export const anthropicAccounts: AccountProvider = {
78
93
  if (!result) return undefined;
79
94
 
80
95
  const now = Date.now();
96
+ const identity = await anthropicAccountIdentity(result.access);
81
97
  saveAccount({
82
98
  id: randomUUID(),
83
99
  label,
@@ -88,6 +104,7 @@ export const anthropicAccounts: AccountProvider = {
88
104
  expires: result.expires,
89
105
  addedAt: now,
90
106
  lastRefreshedAt: now,
107
+ identity,
91
108
  authLineageId: randomUUID(),
92
109
  });
93
110
  return label;
@@ -105,12 +122,16 @@ export const anthropicAccounts: AccountProvider = {
105
122
  const result = await authenticate(ctx);
106
123
  if (!result) return undefined;
107
124
 
125
+ const identity = await anthropicAccountIdentity(result.access);
108
126
  saveAccount({
109
127
  ...account,
110
128
  access: result.access,
111
129
  refresh: result.refresh,
112
130
  expires: result.expires,
113
131
  lastRefreshedAt: Date.now(),
132
+ lastRefreshError: undefined,
133
+ identity,
134
+ quota: undefined,
114
135
  authLineageId: randomUUID(),
115
136
  });
116
137
  return account.label ?? account.id;
@@ -133,17 +154,16 @@ export const anthropicAccounts: AccountProvider = {
133
154
 
134
155
  routing: {
135
156
  async get(): Promise<RoutingMode> {
136
- return fromStoreMode(getRoutingMode(loadAccounts()));
157
+ return getRoutingMode(loadAccounts());
137
158
  },
138
159
  async set(mode: RoutingMode): Promise<RoutingMode> {
139
- setRoutingMode(MODE_TO_STORE[mode]);
160
+ setRoutingMode(mode);
140
161
  return mode;
141
162
  },
142
163
  describe(mode: RoutingMode): string {
143
- return mode === "optimal"
144
- ? "Balances sessions across subscriptions using remaining quota and time until reset. "
145
- + "Keeps session caches sticky; migrates on confirmed exhaustion."
146
- : "Main subscription first, then eligible fallbacks.";
164
+ return mode === "quota-aware"
165
+ ? "Uses the Claude subscription with the most remaining quota."
166
+ : "Uses Claude subscriptions in order, moving on when one is exhausted.";
147
167
  },
148
168
  },
149
169
  };
@@ -1,148 +1,181 @@
1
- import { randomUUID } from "node:crypto";
2
- import type { AccountContext, AccountProvider, ManagedAccount, RoutingMode } from "../../../core/accounts/registry.ts";
3
- import { authorizeCodex, exchangeCodex } from "../../../core/codex/oauth.ts";
1
+ import type { OAuthCredential } from "@earendil-works/pi-ai";
2
+ import { openaiCodexProvider } from "@earendil-works/pi-ai/providers/openai-codex";
3
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
+ import type { RoutingMode } from "../../../core/accounts/registry.ts";
5
+ import { normalizeRoutingMode, type AccountQuotaState } from "../../../core/accounts/routing.ts";
6
+ import type { PooledOAuthAccount, PooledOAuthStore } from "../../../core/accounts/oauth-pool.ts";
7
+ import { applyCodexQuotaHeaders } from "../../../core/codex/quota.ts";
4
8
  import {
9
+ MAIN_ACCOUNT_ID,
5
10
  claimsOf,
6
11
  loadCodexAccounts,
7
12
  saveCodexAccount,
8
13
  saveCodexAccounts,
9
14
  type CodexAccount,
10
15
  } from "../../../core/codex/store.ts";
16
+ import {
17
+ chooseCredential,
18
+ createPooledOAuthAdapter,
19
+ registerPooledOAuthProvider,
20
+ type PooledOAuthProviderSpec,
21
+ } from "./oauth-pool.ts";
11
22
 
12
23
  /**
13
24
  * Codex adapter.
14
25
  *
15
- * Structurally the same as the Anthropic one, with two differences that come
16
- * from the provider rather than from us:
17
- * - quota arrives only through response headers, so there is no poll;
18
- * - every request must carry the account's `chatgpt-account-id`, so the
19
- * account id is stored alongside the tokens rather than derived later.
26
+ * Codex shares the pooled serving path with every other subscription provider
27
+ * (one `routedAuth`, one bounded refresh, one quota observer). Only the two
28
+ * genuinely provider-specific pieces are supplied here:
29
+ *
30
+ * - storage stays in `codex-accounts.json`, because it carries `accountId`
31
+ * (which the usage bars read) and a rich quota snapshot that the generic
32
+ * pool schema has no room for;
33
+ * - quota arrives through `x-codex-*` response headers rather than the
34
+ * generic `x-ratelimit-*` set, so there is no polling path at all.
35
+ *
36
+ * The ChatGPT account id does NOT need to be threaded through auth: the
37
+ * provider derives it from the access token's JWT claims and sets the
38
+ * `chatgpt-account-id` header itself, so swapping the token is self-consistent.
20
39
  */
21
40
 
22
- async function authenticate(ctx: AccountContext) {
23
- const auth = await authorizeCodex();
41
+ function describePlan(account: CodexAccount): string {
42
+ const name = account.label ?? account.id.slice(0, 8);
43
+ return account.plan ? `${name} (${account.plan})` : name;
44
+ }
24
45
 
25
- try {
26
- await ctx.openBrowser(auth.url);
27
- } catch {
28
- ctx.ui.notify(`Open this authorization URL:\n${auth.url}`, "warning");
46
+ /** Routing view of a stored Codex snapshot. */
47
+ function quotaState(quota: CodexAccount["quota"], blockedUntil?: number): AccountQuotaState | undefined {
48
+ const blocked = blockedUntil && blockedUntil > Date.now() ? blockedUntil : undefined;
49
+ if (!quota) {
50
+ return blocked ? { remainingPercent: 0, checkedAt: Date.now(), blockedUntil: blocked } : undefined;
51
+ }
52
+ const windows = [quota.five_hour, quota.seven_day, ...(quota.scoped ?? [])]
53
+ .filter((window) => window && Number.isFinite(window.remainingPercent));
54
+ if (windows.length === 0) {
55
+ return blocked ? { remainingPercent: 0, checkedAt: quota.checkedAt ?? Date.now(), blockedUntil: blocked } : undefined;
29
56
  }
30
57
 
31
- const callback = await ctx.ui.input("Codex OAuth", "Paste the callback URL or authorization code");
32
- if (!callback) return undefined;
33
-
34
- return exchangeCodex(callback, auth.verifier, auth.redirectUri, auth.state);
58
+ const remainingPercent = Math.min(...windows.map((window) => window!.remainingPercent!));
59
+ const resetTimes = windows
60
+ .filter((window) => (window!.remainingPercent ?? 0) <= 0 && window!.resetsAt)
61
+ .map((window) => Date.parse(window!.resetsAt!))
62
+ .filter(Number.isFinite);
63
+ const resetAt = resetTimes.length > 0 ? Math.min(...resetTimes) : undefined;
64
+ return {
65
+ remainingPercent: blocked ? 0 : remainingPercent,
66
+ resetAt,
67
+ checkedAt: quota.checkedAt ?? Date.now(),
68
+ // An exhausted window with no reset time is already non-viable, so there is
69
+ // no sentinel here: `Infinity` would serialize to null and silently unblock.
70
+ blockedUntil: blocked ?? (remainingPercent <= 0 ? resetAt : undefined),
71
+ };
35
72
  }
36
73
 
37
- function describePlan(account: CodexAccount): string {
38
- return account.plan ? `${account.label ?? account.id.slice(0, 8)} (${account.plan})` : (account.label ?? account.id.slice(0, 8));
74
+ /** True when the account has a complete credential and can serve a request. */
75
+ function isRoutable(account: CodexAccount): boolean {
76
+ return typeof account.access === "string"
77
+ && typeof account.refresh === "string"
78
+ && typeof account.expires === "number";
39
79
  }
40
80
 
41
- export const codexAccounts: AccountProvider = {
42
- id: "openai-codex",
43
- label: "Codex",
44
-
45
- async list(): Promise<ManagedAccount[]> {
46
- return loadCodexAccounts().accounts.map((account) => ({
47
- id: account.id,
48
- label: describePlan(account),
49
- enabled: account.enabled !== false,
50
- expiresAt: typeof account.expires === "number" ? account.expires : undefined,
51
- }));
52
- },
53
-
54
- async add(ctx, label): Promise<string | undefined> {
55
- const proceed = await ctx.ui.confirm(
56
- "Add Codex account",
57
- "Sign in with a DIFFERENT ChatGPT account in the browser. Continue?",
58
- );
59
- if (!proceed) return undefined;
60
-
61
- const tokens = await authenticate(ctx);
62
- if (!tokens) return undefined;
63
-
64
- const claims = claimsOf(tokens.access);
65
-
66
- // Adding the same ChatGPT account twice gives routing two entries backed by
67
- // one quota pool, which looks like capacity that does not exist.
68
- const existing = loadCodexAccounts().accounts.find(
69
- (a) => claims.accountId && a.accountId === claims.accountId,
70
- );
71
- if (existing) {
72
- throw new Error(
73
- `That ChatGPT account is already added as “${existing.label ?? existing.id.slice(0, 8)}”. `
74
- + "Use the browser's account switcher to sign in as a different account.",
75
- );
76
- }
77
-
78
- const now = Date.now();
79
- saveCodexAccount({
80
- id: randomUUID(),
81
- label,
82
- enabled: true,
83
- access: tokens.access,
84
- refresh: tokens.refresh,
85
- expires: tokens.expires,
86
- accountId: claims.accountId,
87
- plan: claims.plan,
88
- addedAt: now,
89
- lastRefreshedAt: now,
90
- });
91
- return label;
81
+ /**
82
+ * Adapts `codex-accounts.json` to the pooled store contract.
83
+ *
84
+ * `quota` and `identity` are derived views, so they are never written back:
85
+ * the pool would otherwise overwrite the rich snapshot the usage bars read
86
+ * with the flattened routing state.
87
+ */
88
+ const CODEX_STORE: PooledOAuthStore = {
89
+ load() {
90
+ const storage = loadCodexAccounts();
91
+ return {
92
+ mode: normalizeRoutingMode(storage.routing?.mode),
93
+ accounts: storage.accounts.map((account) => ({
94
+ ...account,
95
+ type: "oauth" as const,
96
+ // Blanking access on an incomplete credential keeps it out of routing
97
+ // while still listing it under `/accounts`.
98
+ access: isRoutable(account) ? account.access! : "",
99
+ refresh: account.refresh ?? "",
100
+ expires: account.expires ?? 0,
101
+ label: account.label ?? "",
102
+ identity: account.accountId,
103
+ addedAt: account.addedAt ?? 0,
104
+ quota: quotaState(account.quota, account.blockedUntil),
105
+ })),
106
+ };
92
107
  },
93
108
 
94
- async reauth(ctx, accountId): Promise<string | undefined> {
95
- const account = loadCodexAccounts().accounts.find(
96
- (a) => a.id === accountId || a.label === accountId,
97
- );
98
- if (!account) throw new Error(`Codex account “${accountId}” not found.`);
99
-
100
- const tokens = await authenticate(ctx);
101
- if (!tokens) return undefined;
102
-
103
- const claims = claimsOf(tokens.access);
109
+ saveAccount(account: PooledOAuthAccount) {
110
+ const { quota: _quota, identity: _identity, type: _type, ...rest } = account as PooledOAuthAccount & CodexAccount;
111
+ const claims = claimsOf(rest.access);
104
112
  saveCodexAccount({
105
- ...account,
106
- access: tokens.access,
107
- refresh: tokens.refresh,
108
- expires: tokens.expires,
109
- accountId: claims.accountId ?? account.accountId,
110
- plan: claims.plan ?? account.plan,
111
- lastRefreshedAt: Date.now(),
113
+ ...rest,
114
+ accountId: rest.accountId ?? claims.accountId,
115
+ plan: rest.plan ?? claims.plan,
112
116
  });
113
- return account.label ?? account.id;
114
117
  },
115
118
 
116
- async setEnabled(accountId: string, enabled: boolean): Promise<void> {
119
+ saveMode(mode) {
117
120
  const storage = loadCodexAccounts();
118
- const account = storage.accounts.find((a) => a.id === accountId);
119
- if (!account) throw new Error(`Account “${accountId}” not found.`);
120
- account.enabled = enabled;
121
+ storage.routing = { ...(storage.routing ?? {}), mode };
121
122
  saveCodexAccounts(storage);
122
123
  },
123
124
 
124
- async rename(accountId: string, label: string): Promise<void> {
125
+ primaryQuota() {
125
126
  const storage = loadCodexAccounts();
126
- const account = storage.accounts.find((a) => a.id === accountId);
127
- if (!account) throw new Error(`Account “${accountId}” not found.`);
128
- account.label = label;
129
- saveCodexAccounts(storage);
127
+ return quotaState(storage.main?.quota, storage.main?.blockedUntil);
130
128
  },
129
+ };
130
+
131
+ /** Persists a rate-limit block so routing skips the account until it clears. */
132
+ function markCodexRateLimited(accountId: string, headers: Record<string, string>): void {
133
+ const raw = Object.entries(headers).find(([key]) => key.toLowerCase() === "retry-after")?.[1];
134
+ const seconds = Number(raw);
135
+ const blockedUntil = Date.now() + (Number.isFinite(seconds) && seconds >= 0 ? seconds * 1_000 : 60_000);
136
+ const storage = loadCodexAccounts();
137
+ if (accountId === MAIN_ACCOUNT_ID) {
138
+ storage.main = { ...storage.main, blockedUntil };
139
+ saveCodexAccounts(storage);
140
+ return;
141
+ }
142
+ const account = storage.accounts.find((candidate) => candidate.id === accountId);
143
+ if (account) saveCodexAccount({ ...account, blockedUntil });
144
+ }
131
145
 
132
- routing: {
133
- async get(): Promise<RoutingMode> {
134
- return loadCodexAccounts().routing?.mode === "optimal" ? "optimal" : "standard";
135
- },
136
- async set(mode: RoutingMode): Promise<RoutingMode> {
137
- const storage = loadCodexAccounts();
138
- storage.routing = { ...(storage.routing ?? {}), mode };
139
- saveCodexAccounts(storage);
140
- return mode;
141
- },
142
- describe(mode: RoutingMode): string {
143
- return mode === "optimal"
144
- ? "Balances across Codex subscriptions using remaining quota and time until reset."
145
- : "Main Codex subscription first, then eligible fallbacks.";
146
- },
146
+ export const CODEX_SPEC: PooledOAuthProviderSpec<"openai-codex-responses"> = {
147
+ id: "openai-codex",
148
+ label: "Codex",
149
+ createProvider: openaiCodexProvider,
150
+ store: CODEX_STORE,
151
+ addPrompt: "Sign in with a DIFFERENT ChatGPT account in the browser. Continue?",
152
+ describeAccount: (account) => describePlan(account as PooledOAuthAccount & CodexAccount),
153
+ // Two entries backed by one ChatGPT account would look like capacity that
154
+ // does not exist, so duplicates are matched on the token's account claim.
155
+ identityOf: (access) => claimsOf(access).accountId,
156
+ recordQuota(accountId, status, headers) {
157
+ // Never throws: swallows its own write failures.
158
+ applyCodexQuotaHeaders(accountId, headers);
159
+ if (status !== 429) return;
160
+ try {
161
+ markCodexRateLimited(accountId, headers);
162
+ } catch {
163
+ // Telemetry only — a failed block write must not fail the response.
164
+ }
147
165
  },
148
166
  };
167
+
168
+ export const codexAccounts = createPooledOAuthAdapter(CODEX_SPEC);
169
+
170
+ export function codexRoutingMode(value: string | undefined): RoutingMode {
171
+ return normalizeRoutingMode(value);
172
+ }
173
+
174
+ /** Retained for callers and tests that select a Codex account directly. */
175
+ export function chooseCodexCredential(primary: OAuthCredential) {
176
+ return chooseCredential(CODEX_SPEC, primary);
177
+ }
178
+
179
+ export function registerCodexProvider(pi: ExtensionAPI): void {
180
+ registerPooledOAuthProvider(pi, CODEX_SPEC);
181
+ }
@@ -0,0 +1,18 @@
1
+ import { kimiCodingProvider } from "@earendil-works/pi-ai/providers/kimi-coding";
2
+ import { xaiProvider } from "@earendil-works/pi-ai/providers/xai";
3
+ import { createPooledOAuthAdapter, type PooledOAuthProviderSpec } from "./oauth-pool.ts";
4
+
5
+ export const KIMI_SPEC: PooledOAuthProviderSpec<"anthropic-messages"> = {
6
+ id: "kimi-coding",
7
+ label: "Kimi",
8
+ createProvider: kimiCodingProvider,
9
+ };
10
+
11
+ export const XAI_SPEC: PooledOAuthProviderSpec<"openai-responses"> = {
12
+ id: "xai",
13
+ label: "Grok",
14
+ createProvider: xaiProvider,
15
+ };
16
+
17
+ export const kimiAccounts = createPooledOAuthAdapter(KIMI_SPEC);
18
+ export const xaiAccounts = createPooledOAuthAdapter(XAI_SPEC);