@hienlh/ppm 0.20.1 → 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 (98) hide show
  1. package/CHANGELOG.md +16 -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/package.json +1 -1
  68. package/src/providers/codex-app-server/codex-event-mapper.ts +11 -0
  69. package/src/providers/codex-app-server/codex-history.ts +118 -45
  70. package/src/providers/codex-app-server/codex-provider.ts +277 -15
  71. package/src/providers/codex-app-server/codex-rollout-header.ts +99 -0
  72. package/src/providers/codex-app-server/codex-rollout-items.ts +6 -0
  73. package/src/providers/codex-app-server/codex-rollout-transfer.ts +58 -0
  74. package/src/providers/codex-app-server/codex-subagent-thread.ts +103 -0
  75. package/src/providers/codex-app-server/codex-usage-limit.ts +69 -0
  76. package/src/providers/codex-app-server/codex-usage-parser.ts +31 -14
  77. package/src/server/routes/chat.ts +23 -2
  78. package/src/server/routes/codex-accounts.ts +32 -3
  79. package/src/server/ws/chat.ts +15 -11
  80. package/src/services/codex-account-cooldown.ts +56 -0
  81. package/src/services/codex-account-login.ts +109 -34
  82. package/src/services/codex-account.service.ts +56 -10
  83. package/src/services/codex-login-callback.ts +57 -0
  84. package/src/web/components/chat/chat-tab.tsx +8 -2
  85. package/src/web/components/chat/codex-usage-panel.tsx +129 -165
  86. package/src/web/components/chat/usage-badge.tsx +38 -129
  87. package/src/web/components/chat/usage-panel-shell.tsx +153 -0
  88. package/src/web/components/settings/accounts/account-card.tsx +20 -3
  89. package/src/web/components/settings/accounts/codex-accounts-section.tsx +9 -2
  90. package/src/web/components/settings/accounts/codex-add-account-dialog.tsx +47 -27
  91. package/src/web/components/settings/accounts/codex-usage-rows.tsx +17 -6
  92. package/src/web/components/settings/accounts/use-codex-accounts.ts +90 -44
  93. package/src/web/components/ui/tooltip.tsx +14 -1
  94. package/src/web/hooks/use-usage.ts +13 -1
  95. package/dist/web/assets/accounts-settings-section-_sG1AqtH.js +0 -1
  96. package/dist/web/assets/chat-tab-CJi4uYb9.js +0 -15
  97. package/dist/web/assets/index-CoaVwAF6.css +0 -2
  98. package/dist/web/assets/use-accounts-data-BbOb2QNP.js +0 -1
@@ -0,0 +1,99 @@
1
+ import { mapRolloutItem } from "./codex-rollout-items.ts";
2
+
3
+ /**
4
+ * Line primitives for a codex rollout JSONL, plus the header a session list
5
+ * needs: which project the thread ran in, whether it is a thread of its own,
6
+ * and what to call it.
7
+ *
8
+ * Kept apart from the transcript parser because listing reads every rollout in
9
+ * the tree while parsing reads one — the listing path must be able to stop at
10
+ * the first few records instead of walking a multi-megabyte transcript.
11
+ */
12
+
13
+ export interface RolloutLine {
14
+ timestamp?: string;
15
+ type?: string;
16
+ payload?: Record<string, unknown>;
17
+ }
18
+
19
+ /** Split into complete, newline-terminated lines only (drop a trailing partial). */
20
+ export function completeLines(text: string): string[] {
21
+ const lines = text.split("\n");
22
+ // If the text does not end in a newline, the last element is a partial line.
23
+ if (!text.endsWith("\n")) lines.pop();
24
+ return lines.filter((l) => l.trim() !== "");
25
+ }
26
+
27
+ export function parseLine(line: string): RolloutLine | null {
28
+ try { return JSON.parse(line) as RolloutLine; } catch { return null; }
29
+ }
30
+
31
+ export interface RolloutHeader {
32
+ id?: string;
33
+ cwd?: string;
34
+ timestamp?: string;
35
+ /**
36
+ * The thread that spawned this one. Present ONLY on a subagent's own rollout,
37
+ * which codex writes into the same sessions tree as a real conversation — so
38
+ * this is what separates "a session the user started" from "one step inside
39
+ * one".
40
+ */
41
+ parentThreadId?: string;
42
+ /** First user prompt, trimmed to a session-list label. */
43
+ title?: string;
44
+ }
45
+
46
+ /** How far into a rollout to look for the opening prompt before giving up. */
47
+ const TITLE_SCAN_LINES = 2000;
48
+ const TITLE_MAX_CHARS = 80;
49
+
50
+ /** First user prompt → a one-line label. */
51
+ function toTitle(text: string): string | undefined {
52
+ const flat = text.replace(/\s+/g, " ").trim();
53
+ if (!flat) return undefined;
54
+ return flat.length > TITLE_MAX_CHARS ? `${flat.slice(0, TITLE_MAX_CHARS - 1)}…` : flat;
55
+ }
56
+
57
+ /**
58
+ * Read the `session_meta` header, and (when `withTitle`) the first user prompt.
59
+ *
60
+ * A subagent rollout has no user prompt at all — its task arrives as encrypted
61
+ * scaffolding — so the title scan is skipped for one rather than walking the
62
+ * whole file to find nothing.
63
+ */
64
+ export function readRolloutHeader(text: string, opts?: { withTitle?: boolean }): RolloutHeader | null {
65
+ let header: RolloutHeader | null = null;
66
+ let scanned = 0;
67
+
68
+ for (const line of completeLines(text)) {
69
+ const rec = parseLine(line);
70
+ if (!rec) continue;
71
+ const p = rec.payload ?? {};
72
+
73
+ if (rec.type === "session_meta" && !header) {
74
+ header = {
75
+ id: typeof p.id === "string" ? p.id : undefined,
76
+ cwd: typeof p.cwd === "string" ? p.cwd : undefined,
77
+ timestamp: typeof p.timestamp === "string" ? p.timestamp : rec.timestamp,
78
+ parentThreadId: typeof p.parent_thread_id === "string" ? p.parent_thread_id : undefined,
79
+ };
80
+ if (!opts?.withTitle || header.parentThreadId) return header;
81
+ continue;
82
+ }
83
+
84
+ if (!header || rec.type !== "event_msg") continue;
85
+ if (++scanned > TITLE_SCAN_LINES) break;
86
+
87
+ if (p.type === "user_message" && typeof p.message === "string") {
88
+ header.title = toTitle(p.message);
89
+ if (header.title) return header;
90
+ } else if (p.type === "item_completed") {
91
+ const mapped = mapRolloutItem(p.item);
92
+ if (mapped.kind === "user") {
93
+ header.title = toTitle(mapped.text);
94
+ if (header.title) return header;
95
+ }
96
+ }
97
+ }
98
+ return header;
99
+ }
@@ -1,5 +1,6 @@
1
1
  import type { ChatEvent } from "../provider.interface.ts";
2
2
  import { redactTruncate } from "./codex-redact.ts";
3
+ import { parseSubagentActivity, type SubagentActivity } from "./codex-subagent-thread.ts";
3
4
 
4
5
  /**
5
6
  * Rollout `item_completed` records → PPM chat events.
@@ -25,6 +26,9 @@ export type RolloutItemMapping =
25
26
  | { kind: "user"; text: string }
26
27
  | { kind: "assistant"; text: string }
27
28
  | { kind: "events"; events: ChatEvent[] }
29
+ // A spawned thread: the parser turns it into a card and hangs the child
30
+ // transcript under it, which needs the sessions dir it cannot reach itself.
31
+ | { kind: "subagent"; activity: SubagentActivity }
28
32
  | { kind: "ignore" };
29
33
 
30
34
  const IGNORED_ITEM_TYPES = new Set([
@@ -129,6 +133,8 @@ export function mapRolloutItem(item: unknown): RolloutItemMapping {
129
133
  const type = typeof it.type === "string" ? it.type : "";
130
134
 
131
135
  if (IGNORED_ITEM_TYPES.has(type)) return { kind: "ignore" };
136
+ const subagent = parseSubagentActivity(it);
137
+ if (subagent) return { kind: "subagent", activity: subagent };
132
138
  if (type === "UserMessage") {
133
139
  const text = textFrom(it.content);
134
140
  return text ? { kind: "user", text } : { kind: "ignore" };
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Moving a conversation's transcript into the CODEX_HOME that is about to serve it.
3
+ *
4
+ * Codex resolves a thread only against its OWN sessions directory. The `path` argument to
5
+ * `thread/resume` looks like an escape from that, and it is not: a path outside the running
6
+ * app-server's CODEX_HOME is refused with the same "no rollout found for thread id" as
7
+ * passing no path at all. Verified directly — the identical request succeeds when the
8
+ * app-server runs on the home the rollout lives in, and fails when it runs on another.
9
+ *
10
+ * So a session that changes account — because the user picked a different one, or because a
11
+ * usage limit forced the move — has its history sitting in a directory the new account's
12
+ * app-server will not look in, and resuming it is impossible until the file is there. It
13
+ * gets copied rather than moved: the account it came from may still be serving other
14
+ * sessions whose own history sits in the same tree, and the original is what PPM reads for
15
+ * any view of the conversation that is not this live thread.
16
+ *
17
+ * From here codex appends to the copy, so the newest history is in the account now serving
18
+ * the session — which is why `codexSessionsDirs` searches the bound account's directory
19
+ * first.
20
+ */
21
+
22
+ import { copyFileSync, existsSync, mkdirSync } from "node:fs";
23
+ import { dirname, join, relative, resolve } from "node:path";
24
+
25
+ /** Case-insensitive on win32, so two spellings of one directory compare equal. */
26
+ function samePath(a: string, b: string): boolean {
27
+ const norm = (p: string) => (process.platform === "win32" ? resolve(p).toLowerCase() : resolve(p));
28
+ return norm(a) === norm(b);
29
+ }
30
+
31
+ /**
32
+ * Ensure a rollout is inside `targetSessionsDir`, and return the path to use.
33
+ *
34
+ * A rollout already in the target directory is returned untouched — the common case, where
35
+ * the session is still on the account that wrote it, and nothing should be copied.
36
+ *
37
+ * The relative layout is preserved (`YYYY/MM/DD/rollout-….jsonl`) because that is where
38
+ * codex's own scan expects to find it.
39
+ *
40
+ * Failures are surfaced rather than swallowed: the caller's next act is a resume that
41
+ * cannot work without this, and a silent failure there turns into a conversation that
42
+ * quietly starts over as a new thread.
43
+ */
44
+ export function localizeRollout(
45
+ rolloutPath: string,
46
+ foundInSessionsDir: string,
47
+ targetSessionsDir: string,
48
+ ): string {
49
+ if (samePath(foundInSessionsDir, targetSessionsDir)) return rolloutPath;
50
+ const dest = join(targetSessionsDir, relative(foundInSessionsDir, rolloutPath));
51
+ if (existsSync(dest)) return dest;
52
+ const destDir = dirname(dest);
53
+ // Only create what is missing. A recursive mkdir over an existing Windows directory
54
+ // carrying the read-only attribute throws EEXIST rather than succeeding quietly.
55
+ if (!existsSync(destDir)) mkdirSync(destDir, { recursive: true });
56
+ copyFileSync(rolloutPath, dest);
57
+ return dest;
58
+ }
@@ -0,0 +1,103 @@
1
+ import type { ChatEvent, ChatMessage } from "../provider.interface.ts";
2
+ import { redactTruncate } from "./codex-redact.ts";
3
+
4
+ /**
5
+ * Subagent threads, rendered as one Agent card in the conversation that spawned
6
+ * them.
7
+ *
8
+ * Codex writes a spawned subagent as a rollout of its own, in the same sessions
9
+ * tree and with the same `cwd` as a real conversation. Left alone it surfaces
10
+ * twice over: once as a top-level session that opens mid-stream (it has no user
11
+ * prompt — the task is handed over as encrypted scaffolding), and once as two
12
+ * bare `SubAgentActivity` cards in the parent. Folding the child transcript into
13
+ * the parent's card is what makes it one step of one conversation again.
14
+ */
15
+
16
+ /** Codex spells the item PascalCase in a rollout and camelCase on the wire. */
17
+ export const SUBAGENT_ITEM_TYPES = new Set(["SubAgentActivity", "subAgentActivity"]);
18
+
19
+ export interface SubagentActivity {
20
+ /** Rollout id of the spawned thread — the link to its transcript. */
21
+ threadId: string;
22
+ /** Codex's name for the agent, e.g. `/root/simplify_login`. */
23
+ path: string;
24
+ done: boolean;
25
+ }
26
+
27
+ /** The transcript of a spawned thread, ready to hang under its card. */
28
+ export interface SubagentTranscript {
29
+ events: ChatEvent[];
30
+ /** The agent's closing message — its report back to the parent. */
31
+ finalText: string;
32
+ }
33
+
34
+ function str(v: unknown): string | undefined {
35
+ return typeof v === "string" && v ? v : undefined;
36
+ }
37
+
38
+ /** A `SubAgentActivity` item → normalized activity, or null if it is not one. */
39
+ export function parseSubagentActivity(item: unknown): SubagentActivity | null {
40
+ if (!item || typeof item !== "object") return null;
41
+ const it = item as Record<string, unknown>;
42
+ if (!SUBAGENT_ITEM_TYPES.has(String(it.type ?? ""))) return null;
43
+ const threadId = str(it.agent_thread_id) ?? str(it.agentThreadId);
44
+ if (!threadId) return null;
45
+ const kind = String(it.kind ?? "");
46
+ return {
47
+ threadId,
48
+ path: str(it.agent_path) ?? str(it.agentPath) ?? "subagent",
49
+ done: kind === "completed" || kind === "failed",
50
+ };
51
+ }
52
+
53
+ /**
54
+ * Card id derived from the spawned thread.
55
+ *
56
+ * The started and completed records carry DIFFERENT item ids (`call_…` vs
57
+ * `subagent-completed-…`), so pairing on the item id leaves the call unanswered
58
+ * and the completion orphaned as a second card. The thread they both name is
59
+ * the stable key.
60
+ */
61
+ export function subagentCardId(threadId: string): string {
62
+ return `subagent-${threadId}`;
63
+ }
64
+
65
+ /** The spawn, as the Agent card the chat already knows how to expand. */
66
+ export function subagentToolUse(activity: SubagentActivity, transcript?: SubagentTranscript | null): ChatEvent {
67
+ return {
68
+ type: "tool_use",
69
+ tool: "Agent",
70
+ input: { description: activity.path },
71
+ toolUseId: subagentCardId(activity.threadId),
72
+ ...(transcript?.events.length ? { children: transcript.events } : {}),
73
+ };
74
+ }
75
+
76
+ /** The completion, answering the card above with the agent's own report. */
77
+ export function subagentToolResult(activity: SubagentActivity, transcript?: SubagentTranscript | null): ChatEvent {
78
+ return {
79
+ type: "tool_result",
80
+ output: redactTruncate(transcript?.finalText || `${activity.path} finished`),
81
+ toolUseId: subagentCardId(activity.threadId),
82
+ };
83
+ }
84
+
85
+ /** A parsed child transcript → the flat event list a card nests. */
86
+ export function transcriptToEvents(messages: ChatMessage[]): ChatEvent[] {
87
+ const events: ChatEvent[] = [];
88
+ for (const m of messages) {
89
+ // An assistant turn with tool calls already carries its text as the last event.
90
+ if (m.events?.length) events.push(...m.events);
91
+ else if (m.content) events.push({ type: "text", content: m.content });
92
+ }
93
+ return events;
94
+ }
95
+
96
+ /** The agent's closing message, which is what it reported back. */
97
+ export function finalAssistantText(messages: ChatMessage[]): string {
98
+ for (let i = messages.length - 1; i >= 0; i--) {
99
+ const m = messages[i]!;
100
+ if (m.role === "assistant" && m.content) return m.content;
101
+ }
102
+ return "";
103
+ }
@@ -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
+ }