@hienlh/ppm 0.20.0 → 0.20.2

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 (101) 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-BgYIPGVP.js → ai-resource-editor-D7C9wmwA.js} +1 -1
  7. package/dist/web/assets/{ai-settings-section-iBRCQvBR.js → ai-settings-section-BW5a07-Z.js} +1 -1
  8. package/dist/web/assets/{ai-settings-section-Cywmtnps.js → ai-settings-section-DZuwllLy.js} +1 -1
  9. package/dist/web/assets/{appearance-settings-section-mhu9T6Fk.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-Cv_pzHo7.js → code-editor-C4o1SyuO.js} +2 -2
  12. package/dist/web/assets/{conflict-editor-BnWaMSEa.js → conflict-editor-Dk65mBIe.js} +1 -1
  13. package/dist/web/assets/{csv-preview-DG4YAabr.js → csv-preview-DENi3CFz.js} +1 -1
  14. package/dist/web/assets/{database-viewer-B-wA2qT-.js → database-viewer-BiBC7ulB.js} +1 -1
  15. package/dist/web/assets/{diff-viewer-DRtzdfXk.js → diff-viewer-CSvZJHak.js} +1 -1
  16. package/dist/web/assets/{edit-diff-preview-6tzdk7Pn.js → edit-diff-preview-xdDQvPhs.js} +1 -1
  17. package/dist/web/assets/{explorer-body-uh8oJp6J.js → explorer-body-QXS0h4AF.js} +1 -1
  18. package/dist/web/assets/{explorer-window-content-DLn_4egF.js → explorer-window-content-CQgqoRsj.js} +1 -1
  19. package/dist/web/assets/{extension-manager-section-C_ZGYvWf.js → extension-manager-section-CyC7wX35.js} +1 -1
  20. package/dist/web/assets/{extension-webview-DbFWxJpj.js → extension-webview-Da8SIlRa.js} +1 -1
  21. package/dist/web/assets/{files-settings-section-BrUbJglA.js → files-settings-section-D8cebM51.js} +1 -1
  22. package/dist/web/assets/{general-settings-section-B9ZPZVso.js → general-settings-section-1HVTvlIw.js} +1 -1
  23. package/dist/web/assets/{git-log-panel-C0jnhmgC.js → git-log-panel-C7a4rp8f.js} +1 -1
  24. package/dist/web/assets/{glide-data-grid-CZVKIum-.js → glide-data-grid-BT9p_4AN.js} +1 -1
  25. package/dist/web/assets/{group-chat-tab-DnPtmbPc.js → group-chat-tab-BUHGnUyW.js} +1 -1
  26. package/dist/web/assets/index-CuYtJ5xF.css +2 -0
  27. package/dist/web/assets/{index-CHQAPZIX.js → index-kGmcC9cb.js} +5 -5
  28. package/dist/web/assets/{jira-watcher-section-Bjldyhru.js → jira-watcher-section-BxzwW4uQ.js} +1 -1
  29. package/dist/web/assets/{keybindings-store-DBVXZKp8.js → keybindings-store-DjJum4OS.js} +1 -1
  30. package/dist/web/assets/{keyboard-shortcuts-section-D7jaCPrv.js → keyboard-shortcuts-section-DDKG3BU1.js} +1 -1
  31. package/dist/web/assets/{markdown-renderer-B4B5oxQS.js → markdown-renderer-dHeak2mE.js} +2 -2
  32. package/dist/web/assets/{markdown-renderer-DwHh-jd_.js → markdown-renderer-iBw1fCI_.js} +1 -1
  33. package/dist/web/assets/{mobile-explorer-sheet-JJxZYWkV.js → mobile-explorer-sheet-CzL8SLtz.js} +1 -1
  34. package/dist/web/assets/{notification-store-CCWNUHsS.js → notification-store-DVi6Ds71.js} +1 -1
  35. package/dist/web/assets/{notifications-settings-section-DbbX7-Qp.js → notifications-settings-section-shQ-ugKh.js} +1 -1
  36. package/dist/web/assets/{pdf-preview-DxNjWa1v.js → pdf-preview-DB26KSzm.js} +1 -1
  37. package/dist/web/assets/{postgres-viewer-BScvk5ev.js → postgres-viewer-CJe2meyF.js} +1 -1
  38. package/dist/web/assets/{ppmbot-settings-section-jFA9Ncgo.js → ppmbot-settings-section-Ba4c9AI9.js} +1 -1
  39. package/dist/web/assets/{process-table-BexeYWQm.js → process-table-PXwLsMuG.js} +1 -1
  40. package/dist/web/assets/{proxy-settings-section-C_seKq56.js → proxy-settings-section-BHBhK6HC.js} +1 -1
  41. package/dist/web/assets/{query-audit-section-BWXJmPDV.js → query-audit-section-BD5ZVqDz.js} +1 -1
  42. package/dist/web/assets/{remote-desktop-mobile-sheet-B--PeL5i.js → remote-desktop-mobile-sheet-Di4QG8VX.js} +2 -2
  43. package/dist/web/assets/{remote-desktop-mobile-view-jgn8nmQa.js → remote-desktop-mobile-view-DzoZvAqc.js} +1 -1
  44. package/dist/web/assets/{remote-desktop-readiness-gate-CjDth-W_.js → remote-desktop-readiness-gate-CHO-KJ9Z.js} +1 -1
  45. package/dist/web/assets/{remote-desktop-window-content-CrHAUA03.js → remote-desktop-window-content-X324gYbW.js} +1 -1
  46. package/dist/web/assets/{schedules-settings-section-DMFn3mYL.js → schedules-settings-section-a4-h6THc.js} +1 -1
  47. package/dist/web/assets/{settings-body-Dz2vPTEP.js → settings-body-Fq7BVDw2.js} +2 -2
  48. package/dist/web/assets/{settings-tab-B5QbsvS4.js → settings-tab-CVLQy-ie.js} +1 -1
  49. package/dist/web/assets/{settings-window-content-iM2bPoX5.js → settings-window-content-Bi7M0iaZ.js} +1 -1
  50. package/dist/web/assets/{sql-query-editor-C0mGgLfX.js → sql-query-editor-D1r265fl.js} +1 -1
  51. package/dist/web/assets/{sqlite-viewer-Bs3RKf1X.js → sqlite-viewer-DHkIotRl.js} +1 -1
  52. package/dist/web/assets/{system-monitor-body-DvUmEdNe.js → system-monitor-body-y6VUkYTL.js} +2 -2
  53. package/dist/web/assets/{system-monitor-tab-CHZ5F_ZE.js → system-monitor-tab-DRAOejhk.js} +1 -1
  54. package/dist/web/assets/{system-monitor-window-content-DjzqoeCN.js → system-monitor-window-content-B5i57xHD.js} +1 -1
  55. package/dist/web/assets/{tab-host-window-content-DKeAr5mD.js → tab-host-window-content-SkyPFFlc.js} +1 -1
  56. package/dist/web/assets/{team-member-sheet-Cl9ZB3dl.js → team-member-sheet-BB63u-Lf.js} +2 -2
  57. package/dist/web/assets/{team-member-window-content-DfP7WdFD.js → team-member-window-content-D6tVTXuV.js} +1 -1
  58. package/dist/web/assets/{terminal-tab-J1Fqa_rm.js → terminal-tab-BYaHAkI5.js} +1 -1
  59. package/dist/web/assets/{tool-cards-DQghSuKX.js → tool-cards-DRJu20sL.js} +3 -3
  60. package/dist/web/assets/use-accounts-data-Ba1_uvEE.js +1 -0
  61. package/dist/web/assets/{use-monaco-theme-aa4-wjKQ.js → use-monaco-theme-Uakgj3hJ.js} +1 -1
  62. package/dist/web/assets/{use-remote-desktop-display-choice-B-TpQ-Ua.js → use-remote-desktop-display-choice-Cd3Xalca.js} +1 -1
  63. package/dist/web/assets/{use-websocket-BBslmv3D.js → use-websocket-BC_EGf5q.js} +1 -1
  64. package/dist/web/assets/{video-preview-Bd0lcK3d.js → video-preview-BUb43zWb.js} +1 -1
  65. package/dist/web/index.html +2 -2
  66. package/dist/web/sw.js +1 -1
  67. package/docs/deployment-guide.md +10 -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/edit-diff-preview.tsx +14 -7
  88. package/src/web/components/chat/usage-badge.tsx +38 -129
  89. package/src/web/components/chat/usage-panel-shell.tsx +153 -0
  90. package/src/web/components/settings/accounts/account-card.tsx +20 -3
  91. package/src/web/components/settings/accounts/codex-accounts-section.tsx +9 -2
  92. package/src/web/components/settings/accounts/codex-add-account-dialog.tsx +47 -27
  93. package/src/web/components/settings/accounts/codex-usage-rows.tsx +17 -6
  94. package/src/web/components/settings/accounts/use-codex-accounts.ts +90 -44
  95. package/src/web/components/shared/markdown-code-block.tsx +10 -5
  96. package/src/web/components/ui/tooltip.tsx +14 -1
  97. package/src/web/hooks/use-usage.ts +13 -1
  98. package/dist/web/assets/accounts-settings-section-BQyru31C.js +0 -1
  99. package/dist/web/assets/chat-tab-Bs9OD6ss.js +0 -15
  100. package/dist/web/assets/index-CoaVwAF6.css +0 -2
  101. package/dist/web/assets/use-accounts-data-KWcMgRlc.js +0 -1
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Recognising a Codex usage-limit refusal.
3
+ *
4
+ * Codex reports an exhausted quota as an ordinary `error` notification — the same shape it
5
+ * uses for a malformed request or a dead sandbox — so the wording is the only thing that
6
+ * separates "this account is out of credit until 4:21 PM" from a failure that switching
7
+ * accounts cannot fix. Matching text is therefore load-bearing here rather than cosmetic:
8
+ * it decides whether the turn moves to another account or stops in front of the user.
9
+ *
10
+ * The patterns are deliberately narrow. A false positive parks a working account for
11
+ * hours, so a match needs the phrase Codex actually uses and not the bare word "limit",
12
+ * which also shows up in context-window and file-size errors.
13
+ */
14
+
15
+ const USAGE_LIMIT_PATTERNS = [
16
+ /hit\s+your\s+(?:usage|rate)\s+limit/i,
17
+ /reached\s+your\s+usage\s+limit/i,
18
+ /usage\s+limit\s+reached/i,
19
+ ];
20
+
21
+ /** Whether this error text means the account's quota is spent. */
22
+ export function isCodexUsageLimit(message: string): boolean {
23
+ return USAGE_LIMIT_PATTERNS.some((re) => re.test(message));
24
+ }
25
+
26
+ /** The message carried by a codex `error` notification, whatever shape it arrived in. */
27
+ export function codexErrorMessage(params: unknown): string {
28
+ const p = (params && typeof params === "object" ? params : {}) as Record<string, unknown>;
29
+ const err = (p.error && typeof p.error === "object" ? p.error : {}) as Record<string, unknown>;
30
+ if (typeof err.message === "string") return err.message;
31
+ if (typeof p.message === "string") return p.message;
32
+ return "";
33
+ }
34
+
35
+ /** Turn an hour/minute into the next epoch-ms at which the clock next reads that time. */
36
+ function nextOccurrence(hour: number, minute: number, ampm?: string): number | undefined {
37
+ if (!Number.isFinite(hour) || hour > 23 || minute > 59) return undefined;
38
+ let h = hour;
39
+ if (ampm === "pm" && h < 12) h += 12;
40
+ if (ampm === "am" && h === 12) h = 0;
41
+ const now = new Date();
42
+ const reset = new Date(now);
43
+ reset.setHours(h, minute, 0, 0);
44
+ // Already past today, so the refusal must be pointing at tomorrow.
45
+ if (reset.getTime() <= now.getTime()) reset.setDate(reset.getDate() + 1);
46
+ return reset.getTime();
47
+ }
48
+
49
+ /**
50
+ * When the refused account is worth trying again, read out of the refusal itself.
51
+ *
52
+ * Codex phrases it two ways — an absolute "try again at 4:21 PM" and a relative "try again
53
+ * in 3 hours" — and both are parsed because the caller uses the answer to decide how long
54
+ * to park the account. Returns null when the message says nothing about a reset, which the
55
+ * caller treats as "park for the default window" rather than "do not park".
56
+ */
57
+ export function parseCodexUsageLimitReset(message: string): { text?: string; atMs?: number } | null {
58
+ const relative = /try\s+again\s+in\s+(\d+)\s*(second|minute|hour|day)s?/i.exec(message);
59
+ if (relative) {
60
+ const n = Number(relative[1]);
61
+ const unitMs = { second: 1000, minute: 60_000, hour: 3_600_000, day: 86_400_000 }[relative[2]!.toLowerCase()]!;
62
+ return { text: `in ${n} ${relative[2]!.toLowerCase()}${n === 1 ? "" : "s"}`, atMs: Date.now() + n * unitMs };
63
+ }
64
+ const absolute = /(?:try\s+again|resets?)\s+(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)?/i.exec(message);
65
+ if (!absolute) return null;
66
+ const text = absolute[0].replace(/^(?:try\s+again|resets?)\s+(?:at\s+)?/i, "").trim();
67
+ const atMs = nextOccurrence(Number(absolute[1]), absolute[2] ? Number(absolute[2]) : 0, absolute[3]?.toLowerCase());
68
+ return { text: text || undefined, ...(atMs != null ? { atMs } : {}) };
69
+ }
@@ -27,26 +27,43 @@ function toBucket(w: RateLimitWindow | null | undefined): LimitBucket | undefine
27
27
  };
28
28
  }
29
29
 
30
+ /** A window at least this long is a plan's long bucket rather than its short one. */
31
+ const LONG_WINDOW_MINS = 24 * 60;
32
+
30
33
  /**
31
- * Map codex `account/rateLimits/read` → PPM UsageInfo. `primary` window ≈ short
32
- * (5h-like) bucket, `secondary` ≈ long (weekly-like) bucket. Empty/missing → {}.
34
+ * Map codex `account/rateLimits/read` → PPM UsageInfo.
35
+ *
36
+ * `primary` and `secondary` are positions, not meanings, and each window states its own
37
+ * length in `windowDurationMins` — so that is what decides which bucket it belongs to. A
38
+ * Plus plan sends a 300-minute window followed by a 10080-minute one, which is the layout
39
+ * the old positional reading assumed. A ChatGPT Business plan sends the 10080-minute weekly
40
+ * window **alone, in the primary slot**, and reading position as meaning labelled that
41
+ * account's weekly quota "5-Hour" — complete with a reset nearly seven days out — while
42
+ * leaving its actual Weekly row empty.
43
+ *
44
+ * Empty/missing → `{}`, which the UI reads as "the quota could not be read" and is a
45
+ * different thing from a plan that genuinely has only one window.
33
46
  */
34
47
  export function parseCodexUsage(res: GetAccountRateLimitsResponse | null | undefined): UsageInfo {
35
48
  const snap = res?.rateLimits;
36
49
  if (!snap) return {};
37
- const primary = snap.primary ?? null;
38
- const secondary = snap.secondary ?? null;
39
50
  const out: UsageInfo = {};
40
- if (primary?.usedPercent != null) {
41
- out.fiveHour = primary.usedPercent / 100;
42
- out.fiveHourResetsAt = toIso(primary.resetsAt);
43
- out.session = toBucket(primary);
44
- }
45
- if (secondary?.usedPercent != null) {
46
- out.sevenDay = secondary.usedPercent / 100;
47
- out.sevenDayResetsAt = toIso(secondary.resetsAt);
48
- out.weekly = toBucket(secondary);
49
- }
51
+ const windows = [snap.primary ?? null, snap.secondary ?? null];
52
+ windows.forEach((w, slot) => {
53
+ if (w?.usedPercent == null) return;
54
+ // A window with no declared duration leaves nothing to read, so the slot it arrived in
55
+ // is the best guess remaining: primary is the short one wherever both are present.
56
+ const isLong = w.windowDurationMins != null ? w.windowDurationMins >= LONG_WINDOW_MINS : slot === 1;
57
+ if (isLong) {
58
+ out.sevenDay = w.usedPercent / 100;
59
+ out.sevenDayResetsAt = toIso(w.resetsAt);
60
+ out.weekly = toBucket(w);
61
+ } else {
62
+ out.fiveHour = w.usedPercent / 100;
63
+ out.fiveHourResetsAt = toIso(w.resetsAt);
64
+ out.session = toBucket(w);
65
+ }
66
+ });
50
67
  if (snap.planType) out.activeAccountLabel = String(snap.planType);
51
68
  return out;
52
69
  }
@@ -498,6 +498,23 @@ chatRoutes.put("/sessions/:id/account", async (c) => {
498
498
  if (!bindPickedAccount(sessionId, providerId, body.accountId)) {
499
499
  return c.json(err(bindRefusalReason(providerId, body.accountId)), 400);
500
500
  }
501
+ // Codex binds an account by spawning its app-server with that account's CODEX_HOME, so a
502
+ // subprocess already running keeps serving the old account however the binding reads — the
503
+ // switch appeared to do nothing until something else happened to kill it. Dropping it while
504
+ // idle makes the next message respawn on the account the user just picked. Claude needs
505
+ // none of this: it reads the binding per turn, and there is nothing stale to clear.
506
+ if (providerId === "codex") {
507
+ const { listRunningSessions, dropIdleSubprocess } = await import("../ws/chat.ts");
508
+ // Never mid-turn. The answer being streamed would be lost, and the switch takes effect
509
+ // on the next message either way — which is exactly what the picker promises.
510
+ if (!listRunningSessions().some((s) => s.sessionId === sessionId)) {
511
+ dropIdleSubprocess(
512
+ sessionId,
513
+ "account_switch",
514
+ "Subprocess released: the session was moved to another Codex account",
515
+ );
516
+ }
517
+ }
501
518
  return c.json(ok({ accountId: body.accountId }));
502
519
  });
503
520
 
@@ -893,7 +910,7 @@ chatRoutes.post("/sessions/:id/images/strip", async (c) => {
893
910
  const mode: StripMode = (body as { mode?: unknown }).mode === "all" ? "all" : "oversized";
894
911
  const includeAttachments = (body as { includeAttachments?: unknown }).includeAttachments === true;
895
912
 
896
- const { listRunningSessions, dropSubprocessForTranscriptRewrite } = await import("../ws/chat.ts");
913
+ const { listRunningSessions, dropIdleSubprocess } = await import("../ws/chat.ts");
897
914
  if (listRunningSessions().some((s) => s.sessionId === sessionId)) {
898
915
  return c.json(err("Session is running — wait for the turn to finish"), 409);
899
916
  }
@@ -906,7 +923,11 @@ chatRoutes.post("/sessions/:id/images/strip", async (c) => {
906
923
  // re-sends the oversized attachment from memory and fails exactly as before, which is the
907
924
  // failure this endpoint exists to clear. Drop it so the turn is rebuilt from the file we
908
925
  // are about to rewrite.
909
- dropSubprocessForTranscriptRewrite(sessionId);
926
+ dropIdleSubprocess(
927
+ sessionId,
928
+ "transcript_rewritten",
929
+ "Subprocess released: the transcript was rewritten, so the next turn is rebuilt from disk",
930
+ );
910
931
 
911
932
  const result = await stripTranscriptImagesFile(found.path, mode, { includeAttachments });
912
933
  return c.json(ok({
@@ -1,7 +1,7 @@
1
1
  import { Hono } from "hono";
2
2
  import { ok, err } from "../../types/api.ts";
3
- import { listCodexAccounts, removeCodexAccount, getAllCodexUsages, getCodexStrategy, setCodexStrategy, selectCodexAccount, setCodexAccountStatus, type CodexStrategy } from "../../services/codex-account.service.ts";
4
- import { addApiKeyAccount, startDeviceLogin, getDeviceLoginStatus, cancelDeviceLogin } from "../../services/codex-account-login.ts";
3
+ import { listCodexAccounts, removeCodexAccount, getAllCodexUsages, getCodexStrategy, setCodexStrategy, selectCodexAccount, setCodexAccountStatus, codexUsageLevel, type CodexStrategy } from "../../services/codex-account.service.ts";
4
+ import { addApiKeyAccount, startDeviceLogin, getDeviceLoginStatus, cancelDeviceLogin, startBrowserLogin, submitBrowserCallback, getBrowserLoginStatus, cancelBrowserLogin } from "../../services/codex-account-login.ts";
5
5
  import { exportCodexEncrypted, importCodexEncrypted } from "../../services/codex-account-portability.ts";
6
6
 
7
7
  /** Codex multi-account management. Mounted under /api/codex-accounts (auth-guarded). */
@@ -29,7 +29,7 @@ codexAccountsRoutes.post("/pick", async (c) => {
29
29
  const usages = await getAllCodexUsages();
30
30
  // A failed usage fetch yields {} → +Infinity, which the selector reads as "unknown", not
31
31
  // as "capped": an account we could not measure stays a candidate.
32
- const picked = selectCodexAccount({ usageOf: (id) => usages[id]?.fiveHour ?? Number.POSITIVE_INFINITY });
32
+ const picked = selectCodexAccount({ usageOf: (id) => codexUsageLevel(usages[id]) });
33
33
  if (!picked) return c.json(ok(null));
34
34
  return c.json(ok({ id: picked.id, label: picked.label }));
35
35
  });
@@ -86,6 +86,35 @@ codexAccountsRoutes.delete("/device-login/:id", (c) => {
86
86
  return c.json(ok({ cancelled: true }));
87
87
  });
88
88
 
89
+ /** Browser OAuth uses the same durable polling as device-code login. */
90
+ codexAccountsRoutes.post("/browser-login", async (c) => {
91
+ const body = await c.req.json<{ label?: string }>().catch(() => ({} as { label?: string }));
92
+ if (body.label !== undefined && typeof body.label !== "string") return c.json(err("label must be a string"), 400);
93
+ c.header("Cache-Control", "no-store");
94
+ try { return c.json(ok(await startBrowserLogin(body.label))); }
95
+ catch (e) { return c.json(err((e as Error).message), 400); }
96
+ });
97
+
98
+ codexAccountsRoutes.post("/browser-login/:id/callback", async (c) => {
99
+ const body = await c.req.json<{ callbackUrl?: string }>().catch(() => ({} as { callbackUrl?: string }));
100
+ c.header("Cache-Control", "no-store");
101
+ if (typeof body.callbackUrl !== "string") return c.json(err("callbackUrl is required"), 400);
102
+ try {
103
+ submitBrowserCallback(c.req.param("id"), body.callbackUrl);
104
+ return c.json(ok({ submitted: true }));
105
+ } catch (e) { return c.json(err((e as Error).message), 400); }
106
+ });
107
+
108
+ codexAccountsRoutes.get("/browser-login/:id/status", (c) => {
109
+ c.header("Cache-Control", "no-store");
110
+ return c.json(ok(getBrowserLoginStatus(c.req.param("id"))));
111
+ });
112
+
113
+ codexAccountsRoutes.delete("/browser-login/:id", (c) => {
114
+ cancelBrowserLogin(c.req.param("id"));
115
+ return c.json(ok({ cancelled: true }));
116
+ });
117
+
89
118
  /** Download a password-encrypted backup of codex accounts (auth.json + apiKey creds). */
90
119
  codexAccountsRoutes.post("/export", async (c) => {
91
120
  const body = await c.req.json<{ password?: string; accountIds?: string[] }>().catch(() => ({} as { password?: string; accountIds?: string[] }));
@@ -164,27 +164,31 @@ function releaseSubprocess(sessionId: string, reason: string, note: string): voi
164
164
  }
165
165
 
166
166
  /**
167
- * Drop a session's live subprocess so its next turn is rebuilt from the transcript on disk.
167
+ * Drop a session's live subprocess so its next turn is rebuilt from what is on disk.
168
168
  *
169
- * Routes that rewrite the JSONL need this. The subprocess holds the conversation in memory,
170
- * so a rewrite it never learns about simply does not apply: the user strips an oversized
171
- * image, sends again, and the same image is re-sent from memory and fails identically.
172
- * `listRunningSessions()` does not cover it that skips `phase === "idle"`, and a warm idle
173
- * subprocess is exactly this case. Unlike `releaseSubprocess` it does not require the session
174
- * to be clientless, because the tab being open is the normal way to reach the strip button.
169
+ * Two kinds of caller need this, both because the subprocess holds state that a change
170
+ * made behind its back cannot reach. A route that rewrites the JSONL: the user strips an
171
+ * oversized image, sends again, and the same image is re-sent from memory and fails
172
+ * identically. And a codex account switch: the app-server was spawned with one account's
173
+ * CODEX_HOME and keeps serving that account no matter what the session's binding now says.
174
+ *
175
+ * `listRunningSessions()` does not cover either case — that skips `phase === "idle"`, and a
176
+ * warm idle subprocess is exactly what both are about. Unlike `releaseSubprocess` this does
177
+ * not require the session to be clientless, because having the tab open is the normal way
178
+ * to reach both buttons.
175
179
  */
176
- export function dropSubprocessForTranscriptRewrite(sessionId: string): void {
180
+ export function dropIdleSubprocess(sessionId: string, reason: string, note: string): void {
177
181
  const entry = activeSessions.get(sessionId);
178
182
  if (!entry) return;
179
183
  const provider = providerRegistry.get(entry.providerId);
180
184
  if (!provider?.hasStreamingSession?.(sessionId)) return;
181
- provider.abortQuery?.(sessionId, "transcript_rewritten");
185
+ provider.abortQuery?.(sessionId, reason);
182
186
  if (entry.cacheReleaseTimer) {
183
187
  clearTimeout(entry.cacheReleaseTimer);
184
188
  entry.cacheReleaseTimer = undefined;
185
189
  }
186
- console.log(`[chat] session=${sessionId} released subprocess (transcript_rewritten)`);
187
- logSessionEvent(sessionId, "INFO", "Subprocess released: the transcript was rewritten, so the next turn is rebuilt from disk");
190
+ console.log(`[chat] session=${sessionId} released subprocess (${reason})`);
191
+ logSessionEvent(sessionId, "INFO", note);
188
192
  }
189
193
 
190
194
  /** Tear down the longest-idle subprocesses once too many sessions are holding one. */
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Codex accounts parked after the app-server refused a turn for an exhausted quota.
3
+ *
4
+ * Kept apart from the account rows on purpose. `CodexAccountStatus` is the user's switch —
5
+ * on or off — and writing a quota refusal into it would show an account the user never
6
+ * disabled as switched off in Settings, with no way to tell the two apart when it came
7
+ * back. Parking is the server's own short-lived opinion about an account, so it lives in
8
+ * memory and lapses on its own.
9
+ *
10
+ * In-memory is also the honest lifetime: the park exists to stop a rotation from handing
11
+ * the next turn straight back to the account that just refused it, and a server restart
12
+ * re-learns that on the first refusal anyway.
13
+ */
14
+
15
+ /** accountId → epoch ms at which the account is worth trying again. */
16
+ const parkedUntil = new Map<string, number>();
17
+
18
+ /**
19
+ * How long an account sits out when the refusal carried no reset time.
20
+ *
21
+ * Codex's short bucket is five hours, so that is the cheapest correct guess. Guessing long
22
+ * is safe in a way guessing short is not: an account that recovers early is only skipped
23
+ * while another one can serve, whereas a park that lapses too early sends the turn back to
24
+ * an account that refuses it again.
25
+ */
26
+ const DEFAULT_PARK_MS = 5 * 60 * 60 * 1000;
27
+
28
+ /** Park an account until its quota resets. `resetAtMs` in the past falls back to the default. */
29
+ export function markCodexAccountUsageLimited(accountId: string, resetAtMs?: number): void {
30
+ const now = Date.now();
31
+ const until = resetAtMs != null && resetAtMs > now ? resetAtMs : now + DEFAULT_PARK_MS;
32
+ parkedUntil.set(accountId, until);
33
+ }
34
+
35
+ /** Whether the account is still sitting out. Lapsed entries are dropped as they are read. */
36
+ export function isCodexAccountUsageLimited(accountId: string): boolean {
37
+ const until = parkedUntil.get(accountId);
38
+ if (until == null) return false;
39
+ if (until <= Date.now()) { parkedUntil.delete(accountId); return false; }
40
+ return true;
41
+ }
42
+
43
+ /** When the account is next worth trying, or null if it is not parked. */
44
+ export function codexUsageLimitResetAt(accountId: string): number | null {
45
+ return isCodexAccountUsageLimited(accountId) ? parkedUntil.get(accountId)! : null;
46
+ }
47
+
48
+ /** Let an account back in early — a successful turn proves the park is stale. */
49
+ export function clearCodexAccountUsageLimit(accountId: string): void {
50
+ parkedUntil.delete(accountId);
51
+ }
52
+
53
+ /** Test seam: forget every park. */
54
+ export function _resetCodexCooldownsForTesting(): void {
55
+ parkedUntil.clear();
56
+ }
@@ -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
  }