@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
@@ -2,6 +2,7 @@ import type { ChatEvent } from "../provider.interface.ts";
2
2
  import type { TurnUsage } from "../../shared/turn-usage.ts";
3
3
  import { redactTruncate } from "./codex-redact.ts";
4
4
  import { diffToOldNew, changeToToolUse } from "./codex-patch.ts";
5
+ import { parseSubagentActivity, subagentToolResult, subagentToolUse } from "./codex-subagent-thread.ts";
5
6
 
6
7
  /** ThreadItem variants that are NOT tool calls (text/metadata). Everything else
7
8
  * is treated as a tool so nothing is ever silently hidden — known types get a
@@ -172,6 +173,11 @@ export function mapCodexEvent(notif: Notif, sessionId: string): ChatEvent[] {
172
173
  case "item/started": {
173
174
  const item = asObj(p.item) as Item;
174
175
  if (item.type === "contextCompaction") return [{ type: "system", subtype: "compacting" }];
176
+ // A spawned agent is a card, not a tool: its start and its completion are
177
+ // two records naming one thread, and the generic mapping rendered each as
178
+ // a card of its own with the raw item as its body.
179
+ const subagent = parseSubagentActivity(item);
180
+ if (subagent) return [subagentToolUse(subagent)];
175
181
  if (item.type && !NON_TOOL_ITEM_TYPES.has(item.type)) return [itemToToolUse(item)];
176
182
  return [];
177
183
  }
@@ -183,6 +189,11 @@ export function mapCodexEvent(notif: Notif, sessionId: string): ChatEvent[] {
183
189
  case "item/completed": {
184
190
  const item = asObj(p.item) as Item;
185
191
  if (item.type === "contextCompaction") return [{ type: "system", subtype: "compact_done" }];
192
+ const subagent = parseSubagentActivity(item);
193
+ // Answers the card opened at `started` — keyed on the thread, because the
194
+ // two records carry different item ids. The agent's own report is not on
195
+ // this record; the card picks it up when the transcript is read back.
196
+ if (subagent) return [subagent.done ? subagentToolResult(subagent) : subagentToolUse(subagent)];
186
197
  if (item.type && !NON_TOOL_ITEM_TYPES.has(item.type)) {
187
198
  // Image generation is announced before the picture exists: at `started`
188
199
  // there is no saved file and no revised prompt, so the call it produced
@@ -5,6 +5,11 @@ import { homedir } from "node:os";
5
5
  import { redactTruncate } from "./codex-redact.ts";
6
6
  import { parseApplyPatch, changeToToolUse } from "./codex-patch.ts";
7
7
  import { mapRolloutItem } from "./codex-rollout-items.ts";
8
+ import { completeLines, parseLine, readRolloutHeader, type RolloutHeader } from "./codex-rollout-header.ts";
9
+ import {
10
+ finalAssistantText, subagentToolResult, subagentToolUse, transcriptToEvents,
11
+ type SubagentTranscript,
12
+ } from "./codex-subagent-thread.ts";
8
13
 
9
14
  /**
10
15
  * Independent parser for Codex rollout JSONL transcripts
@@ -18,30 +23,12 @@ import { mapRolloutItem } from "./codex-rollout-items.ts";
18
23
  * scaffolding).
19
24
  */
20
25
 
21
- interface RolloutLine {
22
- timestamp?: string;
23
- type?: string;
24
- payload?: Record<string, unknown>;
25
- }
26
-
27
26
  /** Normalize a path for cross-platform comparison (case-insensitive on win32). */
28
27
  function normPath(p: string): string {
29
28
  const r = resolve(p);
30
29
  return process.platform === "win32" ? r.toLowerCase() : r;
31
30
  }
32
31
 
33
- /** Split into complete, newline-terminated lines only (drop a trailing partial). */
34
- function completeLines(text: string): string[] {
35
- const lines = text.split("\n");
36
- // If the text does not end in a newline, the last element is a partial line.
37
- if (!text.endsWith("\n")) lines.pop();
38
- return lines.filter((l) => l.trim() !== "");
39
- }
40
-
41
- function parseLine(line: string): RolloutLine | null {
42
- try { return JSON.parse(line) as RolloutLine; } catch { return null; }
43
- }
44
-
45
32
  /** Extract plain text from a response_item message content array. */
46
33
  function contentToText(content: unknown): string {
47
34
  if (typeof content === "string") return content;
@@ -106,10 +93,20 @@ function fnOutputToToolResult(p: Record<string, unknown>): ChatEvent {
106
93
  * tool calls come from `response_item` function_call / function_call_output and
107
94
  * are nested into the assistant turn's `events` so the chat UI renders tool cards.
108
95
  */
109
- export function parseRolloutJsonl(text: string, opts?: { preCompact?: boolean }): ChatMessage[] {
96
+ export function parseRolloutJsonl(
97
+ text: string,
98
+ opts?: {
99
+ preCompact?: boolean;
100
+ /** Reads a spawned thread's transcript so its card can nest it. */
101
+ loadSubagent?: (threadId: string) => SubagentTranscript | null;
102
+ },
103
+ ): ChatMessage[] {
110
104
  const messages: ChatMessage[] = [];
111
105
  let i = 0;
112
106
  let pendingEvents: ChatEvent[] = [];
107
+ // Kept so the completion can answer the card the spawn opened, with the
108
+ // report the child ended on.
109
+ const subagentTranscripts = new Map<string, SubagentTranscript | null>();
113
110
 
114
111
  // Newer codex records every finished step as an `item_completed` event AND
115
112
  // keeps the raw model exchange in `response_item` records. Both describe the
@@ -153,6 +150,15 @@ export function parseRolloutJsonl(text: string, opts?: { preCompact?: boolean })
153
150
  flushAssistant(mapped.text, ts);
154
151
  } else if (mapped.kind === "events") {
155
152
  pendingEvents.push(...mapped.events);
153
+ } else if (mapped.kind === "subagent") {
154
+ const { activity } = mapped;
155
+ if (!subagentTranscripts.has(activity.threadId)) {
156
+ subagentTranscripts.set(activity.threadId, opts?.loadSubagent?.(activity.threadId) ?? null);
157
+ }
158
+ const transcript = subagentTranscripts.get(activity.threadId) ?? null;
159
+ pendingEvents.push(activity.done
160
+ ? subagentToolResult(activity, transcript)
161
+ : subagentToolUse(activity, transcript));
156
162
  }
157
163
  } else if (p.type === "thread_rolled_back") {
158
164
  // codex doesn't truncate the rollout file on rollback/fork — it appends this
@@ -199,21 +205,10 @@ export function parseRolloutJsonl(text: string, opts?: { preCompact?: boolean })
199
205
  return messages;
200
206
  }
201
207
 
202
- /** Read the session_meta header (first record) from a rollout file. */
203
- function readSessionMeta(file: string): { id?: string; cwd?: string; timestamp?: string } | null {
208
+ /** Read a rollout file's header (session_meta, plus its title when asked). */
209
+ function readSessionMeta(file: string, opts?: { withTitle?: boolean }): RolloutHeader | null {
204
210
  try {
205
- const text = readFileSync(file, "utf-8");
206
- for (const line of completeLines(text)) {
207
- const rec = parseLine(line);
208
- if (rec?.type === "session_meta") {
209
- const p = rec.payload ?? {};
210
- return {
211
- id: typeof p.id === "string" ? p.id : undefined,
212
- cwd: typeof p.cwd === "string" ? p.cwd : undefined,
213
- timestamp: typeof p.timestamp === "string" ? p.timestamp : rec.timestamp,
214
- };
215
- }
216
- }
211
+ return readRolloutHeader(readFileSync(file, "utf-8"), opts);
217
212
  } catch { /* unreadable file → excluded (fail-closed) */ }
218
213
  return null;
219
214
  }
@@ -241,6 +236,12 @@ function threadIdFromName(file: string): string | null {
241
236
  * is included ONLY when its session_meta cwd resolves and matches `requestedCwd`
242
237
  * (normalized; case-insensitive on win32). Unattributable rollouts are excluded
243
238
  * — `~/.codex/sessions` holds every project's transcripts.
239
+ *
240
+ * A spawned subagent's rollout is excluded too. It carries the same cwd as the
241
+ * conversation that spawned it, so cwd alone let one subagent per spawn into the
242
+ * session list — each opening with no prompt and no ending, because a subagent's
243
+ * task never appears in its own transcript. Its work shows in the parent, on the
244
+ * Agent card for that spawn.
244
245
  */
245
246
  export function listCodexRollouts(
246
247
  sessionsDir: string,
@@ -253,9 +254,10 @@ export function listCodexRollouts(
253
254
  const sessions: SessionInfo[] = [];
254
255
 
255
256
  for (const file of files) {
256
- const meta = readSessionMeta(file);
257
+ const meta = readSessionMeta(file, { withTitle: true });
257
258
  if (!meta?.cwd) continue; // fail-closed: no cwd → exclude
258
259
  if (normPath(meta.cwd) !== target) continue;
260
+ if (meta.parentThreadId) continue; // one step of another session, not a session
259
261
 
260
262
  const id = meta.id ?? threadIdFromName(file);
261
263
  if (!id) continue;
@@ -266,7 +268,7 @@ export function listCodexRollouts(
266
268
  sessions.push({
267
269
  id,
268
270
  providerId,
269
- title: "Codex session",
271
+ title: meta.title ?? "Codex session",
270
272
  createdAt: meta.timestamp ?? new Date().toISOString(),
271
273
  updatedAt,
272
274
  });
@@ -287,20 +289,89 @@ export function listCodexRollouts(
287
289
  */
288
290
  export function findRolloutByThreadId(sessionsDir: string, threadId: string, requestedCwd?: string): string | null {
289
291
  const target = requestedCwd != null ? normPath(requestedCwd) : null;
290
- for (const file of findRolloutFiles(sessionsDir)) {
291
- if (threadIdFromName(file) !== threadId) {
292
- const meta = readSessionMeta(file);
293
- if (meta?.id !== threadId) continue;
294
- }
295
- if (target != null) {
296
- const meta = readSessionMeta(file);
297
- if (!meta?.cwd || normPath(meta.cwd) !== target) continue; // fail-closed
298
- }
292
+ const files = findRolloutFiles(sessionsDir);
293
+ // The filename carries the thread id, so try those first and read nothing for
294
+ // the rest unless the name match is rejected. Reading a transcript to identify
295
+ // it costs megabytes, and resolving one is now per spawned subagent too.
296
+ const named = (f: string) => threadIdFromName(f) === threadId;
297
+ for (const file of [...files.filter(named), ...files.filter((f) => !named(f))]) {
298
+ const byName = named(file);
299
+ const meta = byName && target == null ? null : readSessionMeta(file);
300
+ if (!byName && meta?.id !== threadId) continue;
301
+ if (target != null && (!meta?.cwd || normPath(meta.cwd) !== target)) continue; // fail-closed
299
302
  return file;
300
303
  }
301
304
  return null;
302
305
  }
303
306
 
307
+ /**
308
+ * Why a thread stopped, when it produced no message of its own.
309
+ *
310
+ * A subagent that dies on startup (a model its account cannot use, a transport
311
+ * failure) writes a transcript with nothing in it but the error, so without this
312
+ * its card in the parent is blank and says nothing about what went wrong.
313
+ */
314
+ function terminalError(text: string): string {
315
+ for (const line of completeLines(text).reverse()) {
316
+ const rec = parseLine(line);
317
+ if (rec?.type !== "event_msg") continue;
318
+ const p = rec.payload ?? {};
319
+ if (p.type !== "task_complete" && p.type !== "error") continue;
320
+ const err = p.error;
321
+ const message = typeof err === "string" ? err
322
+ : (err && typeof err === "object" ? (err as Record<string, unknown>).message : undefined);
323
+ if (typeof message === "string" && message) return unwrapErrorJson(message);
324
+ }
325
+ return "";
326
+ }
327
+
328
+ /** Codex nests the upstream API error verbatim, as JSON, inside its own message. */
329
+ function unwrapErrorJson(message: string): string {
330
+ if (!message.startsWith("{")) return message;
331
+ try {
332
+ const inner = (JSON.parse(message) as { error?: { message?: unknown } }).error?.message;
333
+ return typeof inner === "string" && inner ? inner : message;
334
+ } catch { return message; }
335
+ }
336
+
337
+ /** How deep a chain of spawned agents is followed into the parent's transcript. */
338
+ const SUBAGENT_DEPTH = 2;
339
+
340
+ /**
341
+ * Reader for a spawned thread's transcript, bound to the same sessions dir and
342
+ * the same fail-closed cwd guard as the conversation that spawned it.
343
+ *
344
+ * `seen` covers both the cycle a malformed pair of rollouts could describe and
345
+ * the same agent being named by its start and its completion.
346
+ */
347
+ function subagentLoader(
348
+ sessionsDir: string,
349
+ requestedCwd: string | undefined,
350
+ seen: Set<string>,
351
+ depth: number,
352
+ ): (threadId: string) => SubagentTranscript | null {
353
+ return (threadId) => {
354
+ if (depth <= 0 || seen.has(threadId)) return null;
355
+ seen.add(threadId);
356
+ const file = findRolloutByThreadId(sessionsDir, threadId, requestedCwd);
357
+ if (!file) return null;
358
+ try {
359
+ const text = readFileSync(file, "utf-8");
360
+ const msgs = parseRolloutJsonl(text, {
361
+ loadSubagent: subagentLoader(sessionsDir, requestedCwd, seen, depth - 1),
362
+ });
363
+ const finalText = finalAssistantText(msgs) || terminalError(text);
364
+ const events = transcriptToEvents(msgs);
365
+ // An agent that died before saying anything is answered by its own error
366
+ // record, and the parent may never have written a completion for it — so
367
+ // the reason goes inside the card rather than waiting for a result event
368
+ // that is not coming.
369
+ if (events.length === 0 && finalText) events.push({ type: "text", content: finalText });
370
+ return { events, finalText };
371
+ } catch { return null; }
372
+ };
373
+ }
374
+
304
375
  /** The compaction summary text if this rollout was compacted, else null. */
305
376
  function compactionSummary(text: string): string | null {
306
377
  for (const line of completeLines(text)) {
@@ -324,7 +395,9 @@ export function getRolloutMessages(sessionsDir: string, threadId: string, reques
324
395
  if (!file) return [];
325
396
  try {
326
397
  const text = readFileSync(file, "utf-8");
327
- const msgs = parseRolloutJsonl(text);
398
+ const msgs = parseRolloutJsonl(text, {
399
+ loadSubagent: subagentLoader(sessionsDir, requestedCwd, new Set([threadId]), SUBAGENT_DEPTH),
400
+ });
328
401
  const summary = compactionSummary(text);
329
402
  if (summary !== null) {
330
403
  msgs.unshift({
@@ -10,8 +10,19 @@ import type {
10
10
  UsageInfo,
11
11
  } from "../provider.interface.ts";
12
12
  import { configService } from "../../services/config.service.ts";
13
- import { setSessionMetadata, getSessionProjectPath, setSessionProvider, setSessionCodexAccount, getSessionCodexAccount } from "../../services/db.service.ts";
14
- import { resolveCodexAccountForSession, getCodexAccount, listCodexAccounts, peekCodexAccount } from "../../services/codex-account.service.ts";
13
+ import { setSessionMetadata, getSessionProjectPath, setSessionProvider, setSessionCodexAccount, getSessionCodexAccount, getSessionTitles } from "../../services/db.service.ts";
14
+ import {
15
+ resolveCodexAccountForSession,
16
+ getCodexAccount,
17
+ listCodexAccounts,
18
+ peekCodexAccount,
19
+ selectCodexAccount,
20
+ getAllCodexUsages,
21
+ codexUsageLevel,
22
+ type CodexAccount,
23
+ } from "../../services/codex-account.service.ts";
24
+ import { isCodexAccountUsageLimited, markCodexAccountUsageLimited } from "../../services/codex-account-cooldown.ts";
25
+ import { isCodexUsageLimit, codexErrorMessage, parseCodexUsageLimitReset } from "./codex-usage-limit.ts";
15
26
  import { killProcessTree } from "../../services/windows-process-tree.ts";
16
27
  import { homedir } from "node:os";
17
28
  import { join } from "node:path";
@@ -24,6 +35,7 @@ import { getOrFetchUsage, registerUsageSource } from "../../services/provider-us
24
35
  import { codexUsageSource } from "./codex-usage-source.ts";
25
36
  import { AMBIENT_ACCOUNT_KEY } from "../../services/provider-usage/usage-source.ts";
26
37
  import { redactTruncate } from "./codex-redact.ts";
38
+ import { localizeRollout } from "./codex-rollout-transfer.ts";
27
39
  import {
28
40
  listCodexRollouts,
29
41
  findRolloutByThreadId,
@@ -54,16 +66,22 @@ const CODEX_SESSIONS_DIR = join(homedir(), ".codex", "sessions");
54
66
  * being served from the in-process transcript, and nothing on disk was ever
55
67
  * found for it.
56
68
  *
57
- * `sessionId` narrows to the bound account when there is one; without it the
58
- * caller gets every account, which is what listing a project's sessions needs.
69
+ * `sessionId` puts the bound account's directory FIRST, because a thread that
70
+ * has been continued on that account has its newest rollout there. It no longer
71
+ * restricts the search to it: an account switch — manual, or forced by a usage
72
+ * limit — leaves the conversation's history in the home of whichever account
73
+ * was serving when it was written. Searching the bound account alone is what
74
+ * made a switched session come back empty and silently start over as a new
75
+ * thread, since resume is only attempted when a rollout is found.
59
76
  */
60
77
  function codexSessionsDirs(sessionId?: string): string[] {
61
78
  const dirs: string[] = [];
62
79
  const boundId = sessionId ? getSessionCodexAccount(sessionId) : null;
63
- const accounts = boundId
64
- ? [getCodexAccount(boundId)].filter((a): a is NonNullable<typeof a> => a != null)
65
- : listCodexAccounts();
66
- for (const account of accounts) dirs.push(join(account.home, "sessions"));
80
+ const accounts = listCodexAccounts();
81
+ const ordered = boundId
82
+ ? [...accounts.filter((a) => a.id === boundId), ...accounts.filter((a) => a.id !== boundId)]
83
+ : accounts;
84
+ for (const account of ordered) dirs.push(join(account.home, "sessions"));
67
85
  dirs.push(CODEX_SESSIONS_DIR);
68
86
  return dirs;
69
87
  }
@@ -76,6 +94,29 @@ function fromCodexSessionsDirs<T>(sessionId: string | undefined, read: (dir: str
76
94
  }
77
95
  return null;
78
96
  }
97
+
98
+ /**
99
+ * The rollout for a thread AND the sessions directory it was found in.
100
+ *
101
+ * The directory is what a plain path cannot tell us, and resuming needs it: a rollout
102
+ * belonging to another account has to be copied into the serving account's own home before
103
+ * codex will resolve it, and the copy has to keep its position relative to that directory.
104
+ */
105
+ function locateRollout(
106
+ sessionId: string,
107
+ cwd: string,
108
+ ): { path: string; sessionsDir: string } | null {
109
+ for (const dir of codexSessionsDirs(sessionId)) {
110
+ const found = findRolloutByThreadId(dir, sessionId, cwd);
111
+ if (found) return { path: found, sessionsDir: dir };
112
+ }
113
+ return null;
114
+ }
115
+
116
+ /** Where the app-server on this account writes and looks for rollouts. */
117
+ function sessionsDirForHome(codexHome?: string): string {
118
+ return codexHome ? join(codexHome, "sessions") : CODEX_SESSIONS_DIR;
119
+ }
79
120
  const CLIENT_INFO = { name: "ppm", title: "PPM", version: "0.0.0" };
80
121
  const CAPABILITIES = { experimentalApi: true, requestAttestation: false, optOutNotificationMethods: null };
81
122
  const MODELS_CACHE_TTL = 5 * 60 * 1000;
@@ -118,6 +159,13 @@ interface LiveSession {
118
159
  interruptRequested?: boolean;
119
160
  /** Follow-ups waiting for the current turn, in the order they will be sent. */
120
161
  pendingTurns: QueuedTurn[];
162
+ /** A usage-limit rotation is underway: the old app-server is being torn down and
163
+ * replaced by one running on another account. Everything the dying subprocess still
164
+ * emits belongs to the turn being abandoned, so it is dropped rather than shown. */
165
+ rotating?: boolean;
166
+ /** The turn currently in flight, kept so a rotation can send it again on the new
167
+ * account. Cleared when the turn ends — there is then nothing to replay. */
168
+ lastTurnInput?: { message: string; opts?: SendMessageOpts };
121
169
  }
122
170
 
123
171
  /** A follow-up held back because a turn was already running. */
@@ -281,6 +329,11 @@ export class CodexAppServerProvider implements AIProvider {
281
329
  sessions.push(s);
282
330
  }
283
331
  }
332
+ // A title the user set by hand outranks the one derived from the opening
333
+ // prompt, and it is the only place a renamed codex session is recorded.
334
+ const customTitles = getSessionTitles(sessions.map((s) => s.id));
335
+ for (const s of sessions) s.title = customTitles[s.id] ?? s.title;
336
+
284
337
  // Backfill provider ownership so reopening a pre-existing codex thread routes
285
338
  // to codex (not the default provider) even after a restart.
286
339
  //
@@ -328,7 +381,15 @@ export class CodexAppServerProvider implements AIProvider {
328
381
  this.startTurn(live, content, opts);
329
382
  }
330
383
 
331
- private startTurn(live: LiveSession, message: string, opts?: SendMessageOpts): void {
384
+ /**
385
+ * Send a turn on the live thread.
386
+ *
387
+ * `replay` marks the same turn being sent again on a different account after a usage
388
+ * limit. It skips the transcript append only: the user typed the message once, and a
389
+ * second entry would both show it twice and shift every later `rollout-N` id, which
390
+ * fork anchors resolve against.
391
+ */
392
+ private startTurn(live: LiveSession, message: string, opts?: SendMessageOpts, replay = false): void {
332
393
  if (!live.threadId) return;
333
394
  // One turn at a time — anything sent now would be dropped without a trace.
334
395
  // `now` additionally cuts the running turn short so this one answers next.
@@ -351,7 +412,8 @@ export class CodexAppServerProvider implements AIProvider {
351
412
  });
352
413
  return;
353
414
  }
354
- live.transcript.push({ id: nextRolloutId(live), role: "user", content: message, timestamp: new Date().toISOString() });
415
+ if (!replay) live.transcript.push({ id: nextRolloutId(live), role: "user", content: message, timestamp: new Date().toISOString() });
416
+ live.lastTurnInput = { message, opts };
355
417
  live.currentAssistant = "";
356
418
  live.currentEvents = [];
357
419
  // Codex takes an image as a path, never as a payload, so the uploaded copy is what it
@@ -371,7 +433,12 @@ export class CodexAppServerProvider implements AIProvider {
371
433
  const id = (res as { turn?: { id?: string } })?.turn?.id;
372
434
  if (id && !live.activeTurnId) this.setActiveTurn(live, id);
373
435
  }).catch((err) => {
374
- if (!live.client.isClosed) live.channel.push({ type: "error", message: redactTruncate((err as Error)?.message ?? String(err), 256) });
436
+ const reason = (err as Error)?.message ?? String(err);
437
+ // A quota refusal can come back as the rejection of the request itself rather than
438
+ // as an `error` notification, and it is the same situation either way — move the
439
+ // turn to an account that still has room instead of showing the refusal.
440
+ if (isCodexUsageLimit(reason) && this.beginRotation(live, reason)) return;
441
+ if (!live.client.isClosed) live.channel.push({ type: "error", message: redactTruncate(reason, 256) });
375
442
  // No turn is running, so nothing will report `turn/completed` — release the
376
443
  // queue here or every later follow-up waits on a turn that never existed.
377
444
  this.endTurn(live);
@@ -410,10 +477,195 @@ export class CodexAppServerProvider implements AIProvider {
410
477
  live.turnInFlight = false;
411
478
  live.activeTurnId = null;
412
479
  live.interruptRequested = false;
480
+ live.lastTurnInput = undefined;
413
481
  const next = live.pendingTurns.shift();
414
482
  if (next) this.startTurn(live, next.message, next.opts);
415
483
  }
416
484
 
485
+ // ── Usage-limit rotation ──
486
+
487
+ /**
488
+ * Take over a turn Codex refused for an exhausted quota, if another account can serve it.
489
+ *
490
+ * Answers synchronously so the caller knows, before it decides what to show, whether the
491
+ * refusal is being handled or has to be reported. The rotation itself is asynchronous —
492
+ * it respawns a subprocess and resumes the thread — but whether one is possible is a
493
+ * question about which accounts exist, which needs no I/O.
494
+ *
495
+ * Returns false when nothing else could serve the turn, and the refusal then reaches the
496
+ * user untouched. That is the honest outcome: an error saying the quota is spent is worth
497
+ * more than a silent retry on the account that just said so.
498
+ */
499
+ private beginRotation(live: LiveSession, reason: string): boolean {
500
+ const threadId = live.threadId;
501
+ if (!threadId || live.rotating) return false;
502
+ const currentId = getSessionCodexAccount(threadId);
503
+ const candidates = listCodexAccounts().filter(
504
+ (a) => a.status !== "disabled" && a.id !== currentId && !isCodexAccountUsageLimited(a.id),
505
+ );
506
+ if (candidates.length === 0) return false;
507
+ live.rotating = true;
508
+ void this.rotateAccount(live, threadId, currentId, reason);
509
+ return true;
510
+ }
511
+
512
+ /** Park the spent account, move the session onto a fresh one, and send the turn again. */
513
+ private async rotateAccount(
514
+ live: LiveSession,
515
+ threadId: string,
516
+ currentId: string | null,
517
+ reason: string,
518
+ ): Promise<void> {
519
+ const pending = live.lastTurnInput;
520
+ const reset = parseCodexUsageLimitReset(reason);
521
+ if (currentId) markCodexAccountUsageLimited(currentId, reset?.atMs);
522
+
523
+ let next: CodexAccount | null = null;
524
+ try {
525
+ // Usage picks the emptiest of the remaining accounts rather than merely the next one,
526
+ // so a rotation forced by a limit does not land on an account that is nearly at its own.
527
+ const usages = await getAllCodexUsages();
528
+ next = selectCodexAccount({
529
+ usageOf: (id) => codexUsageLevel(usages[id]),
530
+ ...(currentId ? { exclude: [currentId] } : {}),
531
+ });
532
+ } catch {
533
+ next = selectCodexAccount(currentId ? { exclude: [currentId] } : undefined);
534
+ }
535
+
536
+ if (!next) { this.abandonRotation(live, reason, reset?.text); return; }
537
+
538
+ console.warn(`[codex] session=${threadId} usage limit — switching to ${next.id} (${next.label})`);
539
+ live.channel.push({
540
+ type: "account_retry",
541
+ reason: "Usage limit reached — switching account",
542
+ accountId: next.id,
543
+ accountLabel: next.label,
544
+ });
545
+
546
+ try {
547
+ await this.respawnOn(live, threadId, next);
548
+ } catch (e) {
549
+ console.error(`[codex] session=${threadId} rotation to ${next.id} failed: ${redactTruncate((e as Error)?.message, 200)}`);
550
+ this.abandonRotation(live, reason, reset?.text);
551
+ return;
552
+ }
553
+
554
+ live.rotating = false;
555
+ // The refused turn never reached `turn/completed`, so the flags it set are still on.
556
+ live.turnInFlight = false;
557
+ live.activeTurnId = null;
558
+ live.interruptRequested = false;
559
+ if (pending) this.startTurn(live, pending.message, pending.opts, true);
560
+ else this.endTurn(live);
561
+ }
562
+
563
+ /** No account could take the turn — report the refusal and close the turn out. */
564
+ private abandonRotation(live: LiveSession, reason: string, resetText?: string): void {
565
+ live.rotating = false;
566
+ const suffix = resetText ? ` Try again ${resetText}.` : "";
567
+ live.channel.push({
568
+ type: "error",
569
+ message: `${redactTruncate(reason, 512)}${suffix} Add another account in Settings → Accounts, or wait for the reset.`,
570
+ });
571
+ live.channel.push({ type: "done", sessionId: live.threadId ?? "", resultSubtype: "error_during_execution" });
572
+ this.endTurn(live);
573
+ }
574
+
575
+ /**
576
+ * Put this session's app-server on another account, keeping the session itself intact.
577
+ *
578
+ * The subprocess is replaced, not the `LiveSession`: the caller of `sendMessage` is
579
+ * iterating this session's channel, and handing it a new one would end its stream mid-turn
580
+ * and leave the rest of the answer with nobody listening. Transcript, history and pending
581
+ * follow-ups all survive for the same reason.
582
+ *
583
+ * The outgoing client's handlers are cleared before it is closed. `close()` only asks the
584
+ * process to exit, so its `close` event lands well after this returns — by which time
585
+ * `rotating` is off again, and the inherited `onClose` would tear down the session that
586
+ * had just been repaired.
587
+ */
588
+ private async respawnOn(live: LiveSession, threadId: string, account: CodexAccount): Promise<void> {
589
+ const old = live.client;
590
+ old.onNotification(() => {});
591
+ old.onServerRequest(() => {});
592
+ old.onClose(() => {});
593
+ const pid = old.pid;
594
+ const proc = old.process;
595
+ old.close();
596
+ if (process.platform === "win32" && pid) killProcessTree(pid);
597
+ else if (proc) setTimeout(() => { try { proc.kill("SIGKILL"); } catch { /* dead */ } }, 2000).unref?.();
598
+
599
+ // Written before the rollout lookup so the new account's own directory is searched
600
+ // first — on a second rotation that is where the freshest history sits.
601
+ setSessionCodexAccount(threadId, account.id);
602
+
603
+ const client = new CodexJsonRpcClient();
604
+ client.onNotification((n) => this.handleNotification(live, n));
605
+ client.onServerRequest((r) => this.handleServerRequest(live, r));
606
+ client.onClose(() => this.handleClose(live));
607
+ client.start({ cwd: live.cwd, codexHome: account.home });
608
+ live.client = client;
609
+
610
+ await client.request("initialize", { clientInfo: CLIENT_INFO, capabilities: CAPABILITIES }, CONTROL_REQUEST_TIMEOUT_MS);
611
+ client.notify("initialized");
612
+
613
+ const resumeBase = {
614
+ cwd: live.cwd,
615
+ sandbox: live.permission.sandbox,
616
+ approvalPolicy: live.permission.approvalPolicy,
617
+ ...(live.model ? { model: live.model } : {}),
618
+ };
619
+ const found = locateRollout(threadId, live.cwd);
620
+ if (found) {
621
+ await this.resumeThread(client, threadId, found, account.home, resumeBase);
622
+ return;
623
+ }
624
+ // No rollout yet means the limit hit the very first turn, before codex had written one.
625
+ // There is nothing to resume, so the thread starts fresh — and it comes back under a
626
+ // new id, which every map keyed by the old one has to learn about or the next turn is
627
+ // sent to a thread this app-server has never heard of.
628
+ const started = await client.request("thread/start", resumeBase);
629
+ const newId = extractThreadId(started);
630
+ if (!newId || newId === threadId) return;
631
+ live.threadId = newId;
632
+ this.live.set(newId, live);
633
+ const meta = this.sessions.get(threadId);
634
+ if (meta) { this.sessions.delete(threadId); meta.id = newId; this.sessions.set(newId, meta); }
635
+ setSessionMetadata(newId, meta?.projectName, live.cwd);
636
+ setSessionProvider(newId, this.id);
637
+ setSessionCodexAccount(newId, account.id);
638
+ live.channel.push({ type: "session_migrated", oldSessionId: threadId, newSessionId: newId });
639
+ }
640
+
641
+ /**
642
+ * Resume a thread from the rollout file we located, wherever it lives.
643
+ *
644
+ * The `path` matters because a rollout is written inside the CODEX_HOME of whichever
645
+ * account served the turn, and this app-server is running on the account serving the
646
+ * session NOW. Asked by thread id alone, it searches only its own home and answers "no
647
+ * rollout found for thread id …" for a conversation that plainly exists — which is how
648
+ * an account switch used to lose the history and open a brand-new thread instead.
649
+ *
650
+ * The id-only form is kept as a fallback for a codex build that predates `path`: there
651
+ * the argument is rejected outright, and a resume that can still work by id should not
652
+ * be downgraded into a new thread over a field name.
653
+ */
654
+ private async resumeThread(
655
+ client: CodexJsonRpcClient,
656
+ threadId: string,
657
+ found: { path: string; sessionsDir: string },
658
+ codexHome: string | undefined,
659
+ resumeBase: Record<string, unknown>,
660
+ ): Promise<unknown> {
661
+ const target = sessionsDirForHome(codexHome);
662
+ const path = localizeRollout(found.path, found.sessionsDir, target);
663
+ if (path !== found.path) {
664
+ console.log(`[codex] thread=${threadId} rollout copied into the serving account's home to resume`);
665
+ }
666
+ return client.request("thread/resume", { threadId, path, ...resumeBase });
667
+ }
668
+
417
669
  private async connect(sessionId: string, opts?: SendMessageOpts): Promise<LiveSession> {
418
670
  const meta = this.sessions.get(sessionId);
419
671
  const cwd = meta?.projectPath || getSessionProjectPath(sessionId) || process.cwd();
@@ -445,12 +697,12 @@ export class CodexAppServerProvider implements AIProvider {
445
697
  const resumeBase = { cwd, sandbox: permission.sandbox, approvalPolicy: permission.approvalPolicy, ...(model ? { model } : {}) };
446
698
  // Only treat as a resume when a rollout for this id is attributable to THIS
447
699
  // project (fail-closed cwd guard) — never resume another project's thread.
448
- const isResume = !!fromCodexSessionsDirs(sessionId, (d) => findRolloutByThreadId(d, sessionId, cwd));
449
- const result = isResume
450
- ? await client.request("thread/resume", { threadId: sessionId, ...resumeBase })
700
+ const found = locateRollout(sessionId, cwd);
701
+ const result = found
702
+ ? await this.resumeThread(client, sessionId, found, account?.home, resumeBase)
451
703
  : await client.request("thread/start", resumeBase);
452
704
 
453
- const threadId = extractThreadId(result) ?? (isResume ? sessionId : null);
705
+ const threadId = extractThreadId(result) ?? (found ? sessionId : null);
454
706
  if (!threadId) throw new Error("codex thread/start returned no thread id");
455
707
  live.threadId = threadId;
456
708
 
@@ -480,6 +732,16 @@ export class CodexAppServerProvider implements AIProvider {
480
732
  }
481
733
 
482
734
  private handleNotification(live: LiveSession, notif: JsonRpcNotification): void {
735
+ // A quota refusal is an ordinary `error` notification, so it has to be recognised
736
+ // before the mapper turns it into a plain error card in front of the user.
737
+ if (notif.method === "error" && !live.rotating) {
738
+ const reason = codexErrorMessage(notif.params);
739
+ if (isCodexUsageLimit(reason) && this.beginRotation(live, reason)) return;
740
+ }
741
+ // Mid-rotation the subprocess behind this session is being replaced. Anything it still
742
+ // emits describes the turn that was refused — including its `turn/completed`, which
743
+ // would close the turn the caller is about to be given a second time.
744
+ if (live.rotating) return;
483
745
  if (notif.method === "item/agentMessage/delta") {
484
746
  const d = (notif.params as { delta?: string })?.delta;
485
747
  if (typeof d === "string") live.currentAssistant += d;