@hienlh/ppm 0.20.1 → 0.20.3

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 (99) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/CLAUDE.md +1 -0
  3. package/assets/skills/ppm/SKILL.md +1 -1
  4. package/assets/skills/ppm/references/http-api.md +5 -1
  5. package/dist/web/assets/accounts-settings-section-DzDmIsPe.js +1 -0
  6. package/dist/web/assets/{ai-resource-editor-BLctP1Pr.js → ai-resource-editor-D7C9wmwA.js} +1 -1
  7. package/dist/web/assets/{ai-settings-section-0hSDp7xx.js → ai-settings-section-BW5a07-Z.js} +1 -1
  8. package/dist/web/assets/{ai-settings-section-CTEU2uGa.js → ai-settings-section-DZuwllLy.js} +1 -1
  9. package/dist/web/assets/{appearance-settings-section-BkcoSV0E.js → appearance-settings-section-RfH2N1rj.js} +1 -1
  10. package/dist/web/assets/chat-tab-Bz0pk0_E.js +15 -0
  11. package/dist/web/assets/{code-editor-C4kKhamq.js → code-editor-C4o1SyuO.js} +2 -2
  12. package/dist/web/assets/{conflict-editor-BEULKwHk.js → conflict-editor-Dk65mBIe.js} +1 -1
  13. package/dist/web/assets/{csv-preview-k3eM8nFf.js → csv-preview-DENi3CFz.js} +1 -1
  14. package/dist/web/assets/{database-viewer-Cslir1dr.js → database-viewer-BiBC7ulB.js} +1 -1
  15. package/dist/web/assets/{diff-viewer-DzAIvBbn.js → diff-viewer-CSvZJHak.js} +1 -1
  16. package/dist/web/assets/{explorer-body-D6H-4cz9.js → explorer-body-QXS0h4AF.js} +1 -1
  17. package/dist/web/assets/{explorer-window-content-C5W_JzCz.js → explorer-window-content-CQgqoRsj.js} +1 -1
  18. package/dist/web/assets/{extension-manager-section-DwicI3CA.js → extension-manager-section-CyC7wX35.js} +1 -1
  19. package/dist/web/assets/{extension-webview-Z3HhC8Pd.js → extension-webview-Da8SIlRa.js} +1 -1
  20. package/dist/web/assets/{files-settings-section-ByV7bRbB.js → files-settings-section-D8cebM51.js} +1 -1
  21. package/dist/web/assets/{general-settings-section-nLksKHur.js → general-settings-section-1HVTvlIw.js} +1 -1
  22. package/dist/web/assets/{git-log-panel-iUXltiTB.js → git-log-panel-C7a4rp8f.js} +1 -1
  23. package/dist/web/assets/{glide-data-grid-C1QB4DHt.js → glide-data-grid-BT9p_4AN.js} +1 -1
  24. package/dist/web/assets/{group-chat-tab-ezXKVVpg.js → group-chat-tab-BUHGnUyW.js} +1 -1
  25. package/dist/web/assets/index-CuYtJ5xF.css +2 -0
  26. package/dist/web/assets/{index-CJWbFurD.js → index-kGmcC9cb.js} +5 -5
  27. package/dist/web/assets/{jira-watcher-section-DwARwskB.js → jira-watcher-section-BxzwW4uQ.js} +1 -1
  28. package/dist/web/assets/{keybindings-store-S7YLGd8B.js → keybindings-store-DjJum4OS.js} +1 -1
  29. package/dist/web/assets/{keyboard-shortcuts-section-BoUDvdDy.js → keyboard-shortcuts-section-DDKG3BU1.js} +1 -1
  30. package/dist/web/assets/{markdown-renderer-DKdfiSIX.js → markdown-renderer-dHeak2mE.js} +1 -1
  31. package/dist/web/assets/{markdown-renderer-DYFmMC_8.js → markdown-renderer-iBw1fCI_.js} +1 -1
  32. package/dist/web/assets/{mobile-explorer-sheet-CMdqLCV-.js → mobile-explorer-sheet-CzL8SLtz.js} +1 -1
  33. package/dist/web/assets/{notification-store-DpSYFj36.js → notification-store-DVi6Ds71.js} +1 -1
  34. package/dist/web/assets/{notifications-settings-section-CIf3-3xd.js → notifications-settings-section-shQ-ugKh.js} +1 -1
  35. package/dist/web/assets/{pdf-preview-C-7IdkIK.js → pdf-preview-DB26KSzm.js} +1 -1
  36. package/dist/web/assets/{postgres-viewer-BYXFYlLg.js → postgres-viewer-CJe2meyF.js} +1 -1
  37. package/dist/web/assets/{ppmbot-settings-section-B9dXvQ6p.js → ppmbot-settings-section-Ba4c9AI9.js} +1 -1
  38. package/dist/web/assets/{process-table-B6NeTWt8.js → process-table-PXwLsMuG.js} +1 -1
  39. package/dist/web/assets/{proxy-settings-section-CUqq7pdU.js → proxy-settings-section-BHBhK6HC.js} +1 -1
  40. package/dist/web/assets/{query-audit-section-C1TYrPIp.js → query-audit-section-BD5ZVqDz.js} +1 -1
  41. package/dist/web/assets/{remote-desktop-mobile-sheet-DuKwKe5F.js → remote-desktop-mobile-sheet-Di4QG8VX.js} +2 -2
  42. package/dist/web/assets/{remote-desktop-mobile-view-DxMTtsa6.js → remote-desktop-mobile-view-DzoZvAqc.js} +1 -1
  43. package/dist/web/assets/{remote-desktop-readiness-gate-BwjfLWqg.js → remote-desktop-readiness-gate-CHO-KJ9Z.js} +1 -1
  44. package/dist/web/assets/{remote-desktop-window-content-BIl4N5Hn.js → remote-desktop-window-content-X324gYbW.js} +1 -1
  45. package/dist/web/assets/{schedules-settings-section-C0qOb7GI.js → schedules-settings-section-a4-h6THc.js} +1 -1
  46. package/dist/web/assets/{settings-body-qbrJXX_A.js → settings-body-Fq7BVDw2.js} +2 -2
  47. package/dist/web/assets/{settings-tab-BKRgYVxf.js → settings-tab-CVLQy-ie.js} +1 -1
  48. package/dist/web/assets/{settings-window-content-DWsxJDu-.js → settings-window-content-Bi7M0iaZ.js} +1 -1
  49. package/dist/web/assets/{sql-query-editor-CsZOpCW3.js → sql-query-editor-D1r265fl.js} +1 -1
  50. package/dist/web/assets/{sqlite-viewer-DUrnCaP7.js → sqlite-viewer-DHkIotRl.js} +1 -1
  51. package/dist/web/assets/{system-monitor-body-C5cMpg7I.js → system-monitor-body-y6VUkYTL.js} +2 -2
  52. package/dist/web/assets/{system-monitor-tab-C66ywbVZ.js → system-monitor-tab-DRAOejhk.js} +1 -1
  53. package/dist/web/assets/{system-monitor-window-content-CENaXYJ-.js → system-monitor-window-content-B5i57xHD.js} +1 -1
  54. package/dist/web/assets/{tab-host-window-content-HViABGoS.js → tab-host-window-content-SkyPFFlc.js} +1 -1
  55. package/dist/web/assets/{team-member-sheet-CK6eyBRL.js → team-member-sheet-BB63u-Lf.js} +2 -2
  56. package/dist/web/assets/{team-member-window-content-D6wxg37P.js → team-member-window-content-D6tVTXuV.js} +1 -1
  57. package/dist/web/assets/{terminal-tab-BmyTawmN.js → terminal-tab-BYaHAkI5.js} +1 -1
  58. package/dist/web/assets/{tool-cards-y-XJcJHo.js → tool-cards-DRJu20sL.js} +2 -2
  59. package/dist/web/assets/use-accounts-data-Ba1_uvEE.js +1 -0
  60. package/dist/web/assets/{use-monaco-theme-BkM5rMgL.js → use-monaco-theme-Uakgj3hJ.js} +1 -1
  61. package/dist/web/assets/{use-remote-desktop-display-choice-BT2r1td1.js → use-remote-desktop-display-choice-Cd3Xalca.js} +1 -1
  62. package/dist/web/assets/{use-websocket-BvVTwMjI.js → use-websocket-BC_EGf5q.js} +1 -1
  63. package/dist/web/assets/{video-preview-BEaofjN7.js → video-preview-BUb43zWb.js} +1 -1
  64. package/dist/web/index.html +2 -2
  65. package/dist/web/sw.js +1 -1
  66. package/docs/deployment-guide.md +10 -0
  67. package/docs/lessons-learned.md +14 -0
  68. package/package.json +1 -1
  69. package/src/providers/codex-app-server/codex-event-mapper.ts +11 -0
  70. package/src/providers/codex-app-server/codex-history.ts +118 -45
  71. package/src/providers/codex-app-server/codex-provider.ts +277 -15
  72. package/src/providers/codex-app-server/codex-rollout-header.ts +99 -0
  73. package/src/providers/codex-app-server/codex-rollout-items.ts +6 -0
  74. package/src/providers/codex-app-server/codex-rollout-transfer.ts +58 -0
  75. package/src/providers/codex-app-server/codex-subagent-thread.ts +103 -0
  76. package/src/providers/codex-app-server/codex-usage-limit.ts +69 -0
  77. package/src/providers/codex-app-server/codex-usage-parser.ts +31 -14
  78. package/src/server/routes/chat.ts +23 -2
  79. package/src/server/routes/codex-accounts.ts +32 -3
  80. package/src/server/ws/chat.ts +15 -11
  81. package/src/services/codex-account-cooldown.ts +56 -0
  82. package/src/services/codex-account-login.ts +109 -34
  83. package/src/services/codex-account.service.ts +56 -10
  84. package/src/services/codex-login-callback.ts +57 -0
  85. package/src/web/components/chat/chat-tab.tsx +8 -2
  86. package/src/web/components/chat/codex-usage-panel.tsx +129 -165
  87. package/src/web/components/chat/usage-badge.tsx +38 -129
  88. package/src/web/components/chat/usage-panel-shell.tsx +153 -0
  89. package/src/web/components/settings/accounts/account-card.tsx +20 -3
  90. package/src/web/components/settings/accounts/codex-accounts-section.tsx +9 -2
  91. package/src/web/components/settings/accounts/codex-add-account-dialog.tsx +47 -27
  92. package/src/web/components/settings/accounts/codex-usage-rows.tsx +17 -6
  93. package/src/web/components/settings/accounts/use-codex-accounts.ts +90 -44
  94. package/src/web/components/ui/tooltip.tsx +14 -1
  95. package/src/web/hooks/use-usage.ts +13 -1
  96. package/dist/web/assets/accounts-settings-section-_sG1AqtH.js +0 -1
  97. package/dist/web/assets/chat-tab-CJi4uYb9.js +0 -15
  98. package/dist/web/assets/index-CoaVwAF6.css +0 -2
  99. package/dist/web/assets/use-accounts-data-BbOb2QNP.js +0 -1
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Codex account login orchestration. Logs codex into a per-account CODEX_HOME
3
3
  * via a short-lived app-server, verifies with account/read, then persists the
4
- * account. Two headless paths:
4
+ * account. Supports API keys, device codes and browser OAuth callbacks.
5
5
  * - apiKey: instant (account/login/start stores the key; validated on first use).
6
6
  * - chatgptDeviceCode: returns a user-code + URL; the app-server's
7
7
  * account/login/completed notification finalizes the account in the
@@ -15,7 +15,8 @@
15
15
  */
16
16
  import { randomUUID } from "node:crypto";
17
17
  import { mkdirSync, rmSync } from "node:fs";
18
- import { CodexJsonRpcClient } from "../providers/codex-app-server/codex-jsonrpc-client.ts";
18
+ import { buildCodexCallback, checkCodexLoginPort, parseBrowserLogin, type BrowserLoginCallback } from "./codex-login-callback.ts";
19
+ import { CodexJsonRpcClient, CONTROL_REQUEST_TIMEOUT_MS } from "../providers/codex-app-server/codex-jsonrpc-client.ts";
19
20
  import { codexAccountHome, createCodexAccount, type CodexAccount } from "./codex-account.service.ts";
20
21
 
21
22
  const CLIENT_INFO = { name: "ppm", title: "PPM", version: "0.0.0" };
@@ -29,13 +30,13 @@ export interface LoginClient {
29
30
  start(opts?: { cwd?: string; codexHome?: string }): void;
30
31
  onNotification(fn: (n: { method: string; params?: unknown }) => void): void;
31
32
  onClose(fn: (code: number | null) => void): void;
32
- request<T = unknown>(method: string, params?: unknown): Promise<T>;
33
+ request<T = unknown>(method: string, params?: unknown, timeoutMs?: number): Promise<T>;
33
34
  notify(method: string, params?: unknown): void;
34
35
  close(): void;
35
36
  }
36
37
 
37
38
  async function handshake(c: LoginClient): Promise<void> {
38
- await c.request("initialize", { clientInfo: CLIENT_INFO, capabilities: CAPABILITIES });
39
+ await c.request("initialize", { clientInfo: CLIENT_INFO, capabilities: CAPABILITIES }, CONTROL_REQUEST_TIMEOUT_MS);
39
40
  c.notify("initialized");
40
41
  }
41
42
 
@@ -72,7 +73,10 @@ export type DeviceLoginStatus =
72
73
  | { state: "done"; account: CodexAccount }
73
74
  | { state: "error"; error: string };
74
75
 
75
- interface DeviceLogin {
76
+ interface ChatGptLogin {
77
+ method: "device" | "browser";
78
+ browser?: BrowserLoginCallback;
79
+ callbackAbort?: AbortController;
76
80
  client: LoginClient; home: string; label?: string;
77
81
  status: DeviceLoginStatus;
78
82
  timer: ReturnType<typeof setTimeout>;
@@ -80,10 +84,14 @@ interface DeviceLogin {
80
84
  * home must survive this window even if the browser walks away. */
81
85
  finalizing: boolean;
82
86
  }
83
- const deviceLogins = new Map<string, DeviceLogin>();
87
+ const pendingLogins = new Map<string, ChatGptLogin>();
88
+
89
+ type ChatGptLoginStart = { id: string; userCode: string; verificationUrl: string; authUrl: string };
90
+ type CompletionNotification = { success?: boolean; error?: string | null };
84
91
 
85
92
  /** How long an unfinished authorization may sit before it is reaped. */
86
93
  const DEVICE_LOGIN_TTL = 200_000;
94
+ const BROWSER_LOGIN_TTL = 600_000;
87
95
  /** How long a finished result stays readable for the polling browser. */
88
96
  const RESULT_GRACE_MS = 120_000;
89
97
 
@@ -92,12 +100,21 @@ function unref(t: ReturnType<typeof setTimeout>): ReturnType<typeof setTimeout>
92
100
  return t;
93
101
  }
94
102
 
103
+ function hasPendingBrowserLogin(): boolean {
104
+ return [...pendingLogins.values()].some((p) => p.method === "browser" && p.status.state === "pending");
105
+ }
106
+
107
+ function isCurrentPendingLogin(id: string, p: ChatGptLogin): boolean {
108
+ return pendingLogins.get(id) === p && p.status.state === "pending" && !p.finalizing;
109
+ }
110
+
95
111
  /** Forget a flow entirely: stop its app-server, optionally drop its home. */
96
- function disposeDeviceLogin(id: string, rmHome: boolean): void {
97
- const p = deviceLogins.get(id);
112
+ function disposeLogin(id: string, rmHome: boolean): void {
113
+ const p = pendingLogins.get(id);
98
114
  if (!p) return;
99
- deviceLogins.delete(id);
115
+ pendingLogins.delete(id);
100
116
  clearTimeout(p.timer);
117
+ p.callbackAbort?.abort();
101
118
  try { p.client.close(); } catch { /* ignore */ }
102
119
  if (rmHome) { try { rmSync(p.home, { recursive: true, force: true }); } catch { /* ignore */ } }
103
120
  }
@@ -106,26 +123,30 @@ function disposeDeviceLogin(id: string, rmHome: boolean): void {
106
123
  * event that trails a success cannot overwrite it. The entry lingers for
107
124
  * RESULT_GRACE_MS — that window is what makes a lost poll response harmless. */
108
125
  function settle(id: string, status: Exclude<DeviceLoginStatus, { state: "pending" }>): void {
109
- const p = deviceLogins.get(id);
126
+ const p = pendingLogins.get(id);
110
127
  if (!p || p.status.state !== "pending") return;
111
128
  p.status = status;
112
129
  clearTimeout(p.timer);
130
+ p.callbackAbort?.abort();
113
131
  try { p.client.close(); } catch { /* ignore */ }
114
132
  // A failure leaves no account owning the dir, so the half-built home goes now.
115
133
  // A success must keep it: createCodexAccount adopted it as the account's home.
116
134
  if (status.state === "error") { try { rmSync(p.home, { recursive: true, force: true }); } catch { /* ignore */ } }
117
- p.timer = unref(setTimeout(() => { deviceLogins.delete(id); }, RESULT_GRACE_MS));
135
+ p.timer = unref(setTimeout(() => { pendingLogins.delete(id); }, RESULT_GRACE_MS));
118
136
  }
119
137
 
120
138
  /** Turn the app-server's completion notification into a persisted account. */
121
- async function finalizeDeviceLogin(id: string, notif: { success?: boolean; error?: string | null }): Promise<void> {
122
- const p = deviceLogins.get(id);
123
- if (!p || p.status.state !== "pending") return;
124
- if (!notif?.success) { settle(id, { state: "error", error: notif?.error || "login not completed" }); return; }
139
+ async function finalizeLogin(id: string, notif: CompletionNotification): Promise<void> {
140
+ const p = pendingLogins.get(id);
141
+ if (!p || p.status.state !== "pending" || p.finalizing) return;
142
+ if (!notif?.success) { settle(id, { state: "error", error: p.method === "browser" ? "ChatGPT sign-in failed. Start a new browser login and try again." : notif?.error || "login not completed" }); return; }
125
143
  p.finalizing = true;
144
+ // Authorization has completed; account/read has its own bounded timeout.
145
+ clearTimeout(p.timer);
126
146
  try {
127
- const read = await p.client.request<AccountRead>("account/read", {});
147
+ const read = await p.client.request<AccountRead>("account/read", {}, CONTROL_REQUEST_TIMEOUT_MS);
128
148
  if (!read?.account) throw new Error("login completed but account is empty");
149
+ if (p.status.state !== "pending") return;
129
150
  const account = createCodexAccount({
130
151
  id, label: p.label || read.account.email || "ChatGPT", type: "chatgpt",
131
152
  planType: read.account.planType ?? null,
@@ -138,43 +159,97 @@ async function finalizeDeviceLogin(id: string, notif: { success?: boolean; error
138
159
 
139
160
  /** Begin a ChatGPT device-code login. Returns the code/URL the user enters in a
140
161
  * browser; the caller then polls getDeviceLoginStatus. */
141
- export async function startDeviceLogin(
142
- label?: string,
143
- makeClient: () => LoginClient = () => new CodexJsonRpcClient(),
144
- ): Promise<{ id: string; userCode: string; verificationUrl: string }> {
162
+ async function startChatGptLogin(
163
+ method: "device" | "browser",
164
+ label: string | undefined,
165
+ makeClient: () => LoginClient,
166
+ ): Promise<ChatGptLoginStart> {
167
+ if (method === "browser" && hasPendingBrowserLogin()) {
168
+ throw new Error("A browser login is already in progress. Finish or cancel it first.");
169
+ }
145
170
  const id = randomUUID();
146
171
  const home = codexAccountHome(id);
147
172
  mkdirSync(home, { recursive: true, mode: 0o700 });
148
173
  const client = makeClient();
149
174
  client.onNotification((n) => {
150
175
  if (n.method === "account/login/completed") {
151
- void finalizeDeviceLogin(id, (n.params ?? {}) as { success?: boolean; error?: string | null });
176
+ void finalizeLogin(id, (n.params ?? {}) as CompletionNotification);
152
177
  }
153
178
  });
154
179
  client.onClose(() => settle(id, { state: "error", error: "login process exited" }));
155
- // Never cleared by a poll: an abandoned browser cannot leak the subprocess or
156
- // the half-built home the way a request-scoped timer could.
157
- const timer = unref(setTimeout(() => settle(id, { state: "error", error: "timed out" }), DEVICE_LOGIN_TTL));
158
- // Registered before the first I/O so a notification (or an immediate exit)
159
- // arriving mid-handshake still finds its entry.
160
- deviceLogins.set(id, { client, home, label, status: { state: "pending" }, timer, finalizing: false });
180
+ const timer = unref(setTimeout(() => settle(id, { state: "error", error: "timed out" }), method === "browser" ? BROWSER_LOGIN_TTL : DEVICE_LOGIN_TTL));
181
+ const pending: ChatGptLogin = { method, client, home, label, status: { state: "pending" }, timer, finalizing: false };
182
+ pendingLogins.set(id, pending);
161
183
  try {
184
+ if (method === "browser") await checkCodexLoginPort();
162
185
  client.start({ codexHome: home });
163
186
  await handshake(client);
164
- const start = await client.request<{ userCode?: string; verificationUrl?: string }>(
165
- "account/login/start", { type: "chatgptDeviceCode" },
187
+ const start = await client.request<{ userCode?: string; verificationUrl?: string; authUrl?: string }>(
188
+ "account/login/start", { type: method === "browser" ? "chatgpt" : "chatgptDeviceCode" }, CONTROL_REQUEST_TIMEOUT_MS,
166
189
  );
167
- return { id, userCode: start?.userCode ?? "", verificationUrl: start?.verificationUrl ?? "" };
190
+ if (method === "browser") pending.browser = parseBrowserLogin(start.authUrl ?? "");
191
+ return { id, userCode: start?.userCode ?? "", verificationUrl: start?.verificationUrl ?? "", authUrl: start?.authUrl ?? "" };
168
192
  } catch (e) {
169
- disposeDeviceLogin(id, true);
193
+ disposeLogin(id, true);
170
194
  throw e;
171
195
  }
172
196
  }
173
197
 
198
+ export async function startDeviceLogin(label?: string, makeClient: () => LoginClient = () => new CodexJsonRpcClient()) {
199
+ const { id, userCode, verificationUrl } = await startChatGptLogin("device", label, makeClient);
200
+ return { id, userCode, verificationUrl };
201
+ }
202
+
203
+ export async function startBrowserLogin(label?: string, makeClient: () => LoginClient = () => new CodexJsonRpcClient()) {
204
+ const { id, authUrl } = await startChatGptLogin("browser", label, makeClient);
205
+ return { id, authUrl };
206
+ }
207
+
208
+ /** Submission is acknowledged immediately. Only the app-server notification and
209
+ * account/read determine success, even if the forwarding HTTP request drops. */
210
+ export function submitBrowserCallback(id: string, callbackUrl: string, send: typeof fetch = fetch): void {
211
+ const p = pendingLogins.get(id);
212
+ if (!p || p.method !== "browser" || !p.browser) throw new Error("No browser login found. Start a new login.");
213
+ if (p.status.state === "done" || p.finalizing) return;
214
+ if (p.status.state === "error") throw new Error(p.status.error);
215
+ const browser = p.browser;
216
+ const target = buildCodexCallback(callbackUrl, browser);
217
+ if (browser.submitted) return;
218
+ browser.submitted = true;
219
+ const controller = new AbortController();
220
+ p.callbackAbort = controller;
221
+ const timeout = unref(setTimeout(() => controller.abort(), 30_000));
222
+ void (async () => {
223
+ try {
224
+ const response = await send(target, { redirect: "manual", signal: controller.signal });
225
+ // Do not render or log the OAuth response body or follow its redirect.
226
+ await response.body?.cancel();
227
+ if (response.status >= 400 && isCurrentPendingLogin(id, p)) {
228
+ settle(id, { state: "error", error: "Codex rejected the callback. Start a new browser login." });
229
+ }
230
+ } catch {
231
+ // A closed callback socket may mean successful login. Polling owns the result.
232
+ if (isCurrentPendingLogin(id, p)) browser.submitted = false;
233
+ } finally {
234
+ clearTimeout(timeout);
235
+ if (p.callbackAbort === controller) p.callbackAbort = undefined;
236
+ }
237
+ })();
238
+ }
239
+
240
+ export function getBrowserLoginStatus(id: string): DeviceLoginStatus {
241
+ const p = pendingLogins.get(id);
242
+ return p?.method === "browser" ? p.status : { state: "error", error: "no pending browser login for that id" };
243
+ }
244
+
245
+ export function cancelBrowserLogin(id: string): void {
246
+ if (pendingLogins.get(id)?.method === "browser") cancelDeviceLogin(id);
247
+ }
248
+
174
249
  /** Read a device-code flow's outcome. Idempotent — repeat polls are free and a
175
250
  * dropped response costs one tick, not the flow. */
176
251
  export function getDeviceLoginStatus(id: string): DeviceLoginStatus {
177
- return deviceLogins.get(id)?.status ?? { state: "error", error: "no pending device login for that id" };
252
+ return pendingLogins.get(id)?.status ?? { state: "error", error: "no pending device login for that id" };
178
253
  }
179
254
 
180
255
  /** Abandon a flow the user cancelled. A login already being written is left
@@ -182,7 +257,7 @@ export function getDeviceLoginStatus(id: string): DeviceLoginStatus {
182
257
  * authorization the user completed. A finished one keeps its home: an account
183
258
  * owns it now. */
184
259
  export function cancelDeviceLogin(id: string): void {
185
- const p = deviceLogins.get(id);
260
+ const p = pendingLogins.get(id);
186
261
  if (!p || p.finalizing) return;
187
- disposeDeviceLogin(id, p.status.state !== "done");
262
+ disposeLogin(id, p.status.state !== "done");
188
263
  }
@@ -15,6 +15,7 @@ import { getPpmDir } from "./ppm-dir.ts";
15
15
  import { configService } from "./config.service.ts";
16
16
  import { encrypt, decrypt } from "../lib/account-crypto.ts";
17
17
  import { getOrFetchUsage } from "./provider-usage/usage-registry.ts";
18
+ import { isCodexAccountUsageLimited } from "./codex-account-cooldown.ts";
18
19
  import type { UsageInfo } from "../providers/provider.interface.ts";
19
20
 
20
21
  export type CodexStrategy = "round-robin" | "fill-first" | "lowest-usage";
@@ -162,6 +163,22 @@ export function setCodexStrategy(strategy: CodexStrategy): void {
162
163
  /** Five-hour utilisation at or above which an account is skipped while others have room. */
163
164
  const FIVE_HOUR_SKIP_THRESHOLD = 0.95;
164
165
 
166
+ /**
167
+ * The utilisation an account should be judged on, as a 0-1 fraction.
168
+ *
169
+ * Not every plan has a short window. A ChatGPT Business account reports a weekly quota and
170
+ * nothing else, so reading `fiveHour` alone finds nothing there and reports the account as
171
+ * unreadable — which sends it to the back of `lowest-usage` and exempts it from the cap
172
+ * skip, for an account whose usage is in fact perfectly well known. Falling through to the
173
+ * weekly figure judges it on the only limit it actually has.
174
+ *
175
+ * `Infinity` still means genuinely unreadable: a failed fetch de-prioritises the account
176
+ * rather than reading as a flattering 0%.
177
+ */
178
+ export function codexUsageLevel(usage?: UsageInfo): number {
179
+ return usage?.fiveHour ?? usage?.sevenDay ?? Number.POSITIVE_INFINITY;
180
+ }
181
+
165
182
  /**
166
183
  * Drop accounts with no five-hour room left, unless that would leave nothing.
167
184
  *
@@ -183,6 +200,20 @@ function withFiveHourRoom(
183
200
  return room.length > 0 ? room : accts;
184
201
  }
185
202
 
203
+ /**
204
+ * Drop accounts Codex itself refused for an exhausted quota, unless that would leave nothing.
205
+ *
206
+ * Complements the five-hour skip rather than repeating it. That one reads a usage figure
207
+ * served from a two-minute cache, so an account that hit its cap moments ago still looks
208
+ * like it has room; this one reads the refusal Codex just gave us, which is current by
209
+ * definition. Soft for the same reason as the skip above: a park is a guess at when the
210
+ * quota returns, and handing back a parked account beats handing back nothing.
211
+ */
212
+ function withQuotaLeft(accts: CodexAccount[]): CodexAccount[] {
213
+ const usable = accts.filter((a) => !isCodexAccountUsageLimited(a.id));
214
+ return usable.length > 0 ? usable : accts;
215
+ }
216
+
186
217
  /**
187
218
  * Pick an account by strategy. `usageOf` (optional, from P4) enables lowest-usage;
188
219
  * without it lowest-usage falls back to round-robin.
@@ -190,16 +221,20 @@ function withFiveHourRoom(
190
221
  * `usageOf` also drives the five-hour skip, so a caller that supplies it gets an account
191
222
  * with room rather than merely the least-used one among several that are all capped.
192
223
  */
193
- export function selectCodexAccount(opts?: { strategy?: CodexStrategy; usageOf?: (id: string) => number }): CodexAccount | null {
224
+ export function selectCodexAccount(opts?: { strategy?: CodexStrategy; usageOf?: (id: string) => number; exclude?: string[] }): CodexAccount | null {
194
225
  // Disabled is a hard exclusion with no fallback, unlike the five-hour skip below. Being
195
226
  // capped means a slower turn; being switched off is the user saying don't use this, and
196
227
  // handing it back anyway because nothing else was left would ignore them.
197
- const all = listCodexAccounts().filter((a) => a.status !== "disabled");
228
+ //
229
+ // `exclude` is hard for the same reason: its only caller is the rotation away from an
230
+ // account that just refused the turn, and falling back to it would rotate in a circle.
231
+ const excluded = new Set(opts?.exclude ?? []);
232
+ const all = listCodexAccounts().filter((a) => a.status !== "disabled" && !excluded.has(a.id));
198
233
  if (all.length === 0) return null;
199
234
  if (all.length === 1) return all[0]!;
200
235
  const strategy = opts?.strategy ?? getCodexStrategy();
201
236
  const usageOf = opts?.usageOf;
202
- const accts = withFiveHourRoom(all, usageOf);
237
+ const accts = withFiveHourRoom(withQuotaLeft(all), usageOf);
203
238
  if (accts.length === 1) return accts[0]!;
204
239
  if (strategy === "fill-first") return accts[0]!;
205
240
  if (strategy === "lowest-usage" && usageOf) {
@@ -225,9 +260,10 @@ export function selectCodexAccount(opts?: { strategy?: CodexStrategy; usageOf?:
225
260
  * null rather than guessing at one.
226
261
  */
227
262
  export function peekCodexAccount(): CodexAccount | null {
228
- // Status is the one thing this can filter on without going async, so it does. Usage lives
229
- // behind a fetch and stays out of reach here; the consuming pick endpoint reads it instead.
230
- const accts = listCodexAccounts().filter((a) => a.status !== "disabled");
263
+ // Status and the quota park are the two things this can filter on without going async, so
264
+ // it does both. Usage lives behind a fetch and stays out of reach here; the consuming pick
265
+ // endpoint reads it instead.
266
+ const accts = withQuotaLeft(listCodexAccounts().filter((a) => a.status !== "disabled"));
231
267
  if (accts.length === 0) return null;
232
268
  if (accts.length === 1) return accts[0]!;
233
269
  return getCodexStrategy() === "fill-first" ? accts[0]! : null;
@@ -240,9 +276,19 @@ export async function resolveCodexAccountForSession(sessionId?: string): Promise
240
276
  const sticky = sessionId ? getSessionCodexAccount(sessionId) : null;
241
277
  // A binding is held for the prompt cache, not honoured unconditionally: an account the
242
278
  // user has switched off has to let go of the sessions sitting on it, or turning it off
243
- // would do nothing for exactly the conversations already using it.
244
- if (sticky) { const a = getCodexAccount(sticky); if (a && a.status !== "disabled") return a; }
245
- if (listCodexAccounts().filter((a) => a.status !== "disabled").length <= 1) return selectCodexAccount();
279
+ // would do nothing for exactly the conversations already using it. An account parked for
280
+ // an exhausted quota lets go for a different reason it would refuse the turn, and
281
+ // spending one to rediscover that is worse than starting on an account that can answer.
282
+ if (sticky) {
283
+ const a = getCodexAccount(sticky);
284
+ if (a && a.status !== "disabled" && !isCodexAccountUsageLimited(a.id)) return a;
285
+ }
286
+ // Usage is only worth fetching when it can change the answer, and with at most one
287
+ // account able to serve it cannot. Parked accounts count as unable for the same reason
288
+ // they are skipped below, so a pool of two where one is out of quota resolves without a
289
+ // network read at all.
290
+ const usable = listCodexAccounts().filter((a) => a.status !== "disabled" && !isCodexAccountUsageLimited(a.id));
291
+ if (usable.length <= 1) return selectCodexAccount();
246
292
  // Usage is read for every strategy, not just lowest-usage: it is also what lets the
247
293
  // selector skip an account with no five-hour room left. Reading it only for lowest-usage
248
294
  // left that skip inert under round-robin, which is the default — sessions kept landing on
@@ -252,7 +298,7 @@ export async function resolveCodexAccountForSession(sessionId?: string): Promise
252
298
  // A failed usage fetch (broken/expired account) yields {} → treat as +Infinity so
253
299
  // lowest-usage de-prioritizes it instead of preferring it as "0% used".
254
300
  const usages = await getAllCodexUsages();
255
- return selectCodexAccount({ usageOf: (id) => usages[id]?.fiveHour ?? Number.POSITIVE_INFINITY });
301
+ return selectCodexAccount({ usageOf: (id) => codexUsageLevel(usages[id]) });
256
302
  }
257
303
 
258
304
  // ── Usage (per account) ──
@@ -0,0 +1,57 @@
1
+ import { createServer } from "node:net";
2
+
3
+ export interface BrowserLoginCallback {
4
+ redirectUri: string;
5
+ state: string;
6
+ submitted: boolean;
7
+ }
8
+
9
+ /** Codex may cancel an existing listener on its preferred port when starting.
10
+ * Refuse an occupied port instead of interrupting another application's login. */
11
+ export async function checkCodexLoginPort(): Promise<void> {
12
+ await new Promise<void>((resolve, reject) => {
13
+ const server = createServer();
14
+ server.once("error", () => reject(new Error("Codex login port 1455 is busy. Finish the other browser login or use device code.")));
15
+ server.listen(1455, "127.0.0.1", () => server.close(() => resolve()));
16
+ });
17
+ }
18
+
19
+ export function parseBrowserLogin(authUrl: string): BrowserLoginCallback {
20
+ const auth = new URL(authUrl);
21
+ if (auth.protocol !== "https:" || !["auth.openai.com", "chatgpt.com"].includes(auth.hostname) || auth.username || auth.password) {
22
+ throw new Error("Codex returned an unsupported login URL.");
23
+ }
24
+ const redirect = new URL(auth.searchParams.get("redirect_uri") ?? "");
25
+ if (redirect.protocol !== "http:" || redirect.hostname !== "localhost" || !["1455", "1457"].includes(redirect.port)
26
+ || redirect.pathname !== "/auth/callback" || redirect.search || redirect.hash || redirect.username || redirect.password) {
27
+ throw new Error("Codex returned an unsupported callback URL.");
28
+ }
29
+ const state = auth.searchParams.get("state");
30
+ if (!state) throw new Error("Codex returned a login URL without state.");
31
+ return { redirectUri: redirect.href, state, submitted: false };
32
+ }
33
+
34
+ /** Never fetch the user-supplied URL. Validate it against this pending login,
35
+ * then rebuild a request to the fixed loopback listener using only code/state. */
36
+ export function buildCodexCallback(callbackUrl: string, login: BrowserLoginCallback): URL {
37
+ if (typeof callbackUrl !== "string" || callbackUrl.length > 16_384) throw new Error("Paste the full localhost callback URL.");
38
+ let url: URL;
39
+ try { url = new URL(callbackUrl.trim()); } catch { throw new Error("Paste the full localhost callback URL."); }
40
+ const expected = new URL(login.redirectUri);
41
+ if (url.origin !== expected.origin || url.pathname !== expected.pathname || url.username || url.password || url.hash) {
42
+ throw new Error("Use the localhost callback URL from this Codex login.");
43
+ }
44
+ const state = url.searchParams.get("state");
45
+ // Codex also accepts this exact onboarding suffix; it still binds to the original state.
46
+ if (url.searchParams.getAll("state").length !== 1 || (state !== login.state && state !== `${login.state}.onboarding_entrypoint=life_sciences`)) {
47
+ throw new Error("This URL belongs to a different login. Use the latest sign-in link.");
48
+ }
49
+ const code = url.searchParams.get("code");
50
+ if (url.searchParams.has("error") || !code || url.searchParams.getAll("code").length !== 1) {
51
+ throw new Error("This URL has no authorization code. Complete ChatGPT sign-in first.");
52
+ }
53
+ expected.hostname = "127.0.0.1";
54
+ expected.searchParams.set("code", code);
55
+ expected.searchParams.set("state", state!);
56
+ return expected;
57
+ }
@@ -89,7 +89,7 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
89
89
 
90
90
  // Usage runs independently — auto-refreshes on interval. Scoped to this session so the
91
91
  // account shown is the one bound to it, not whichever session ran most recently.
92
- const { usageInfo, usageLoading, lastFetchedAt, refreshUsage } =
92
+ const { usageInfo, usageLoading, lastFetchedAt, refreshUsage, reloadUsage } =
93
93
  useUsage(projectName, providerId, sessionId ?? undefined);
94
94
 
95
95
  // Draft auto-save/restore
@@ -183,6 +183,12 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
183
183
  // other source is the usage endpoint on a two-minute poll — so without this the badge
184
184
  // sits on the old account long after the switch, which reads as the button not working.
185
185
  setServingAccount({ id: accountId, label });
186
+ // The header chip reports the account BOUND to this session, and that binding has
187
+ // just changed — so re-read it now instead of leaving the chip on the previous
188
+ // account's figures for up to the two-minute poll. Before the first message a
189
+ // session has no binding at all, so those figures were blank, and the chip sat at
190
+ // "--%" beside a panel already showing the chosen account's quota.
191
+ void reloadUsage();
186
192
  // Switching costs a full prompt-cache write, so it is worth saying out loud rather
187
193
  // than letting the chip quietly change.
188
194
  toast.success("This chat will use the selected account from the next message.");
@@ -198,7 +204,7 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
198
204
  metadata: { ...metadata, pickedAccountId: accountId, pickedAccountLabel: label, pickedAccountProvider: providerId },
199
205
  });
200
206
  return null;
201
- }, [sessionId, projectName, tabId, metadata, providerId, updateTab]); // eslint-disable-line react-hooks/exhaustive-deps
207
+ }, [sessionId, projectName, tabId, metadata, providerId, updateTab, reloadUsage]); // eslint-disable-line react-hooks/exhaustive-deps
202
208
 
203
209
  /**
204
210
  * The account this chat is on, as far as the UI can tell.