@hienlh/ppm 0.20.1 → 0.20.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/CLAUDE.md +1 -0
- package/assets/skills/ppm/SKILL.md +1 -1
- package/assets/skills/ppm/references/http-api.md +5 -1
- package/dist/web/assets/accounts-settings-section-DzDmIsPe.js +1 -0
- package/dist/web/assets/{ai-resource-editor-BLctP1Pr.js → ai-resource-editor-D7C9wmwA.js} +1 -1
- package/dist/web/assets/{ai-settings-section-0hSDp7xx.js → ai-settings-section-BW5a07-Z.js} +1 -1
- package/dist/web/assets/{ai-settings-section-CTEU2uGa.js → ai-settings-section-DZuwllLy.js} +1 -1
- package/dist/web/assets/{appearance-settings-section-BkcoSV0E.js → appearance-settings-section-RfH2N1rj.js} +1 -1
- package/dist/web/assets/chat-tab-Bz0pk0_E.js +15 -0
- package/dist/web/assets/{code-editor-C4kKhamq.js → code-editor-C4o1SyuO.js} +2 -2
- package/dist/web/assets/{conflict-editor-BEULKwHk.js → conflict-editor-Dk65mBIe.js} +1 -1
- package/dist/web/assets/{csv-preview-k3eM8nFf.js → csv-preview-DENi3CFz.js} +1 -1
- package/dist/web/assets/{database-viewer-Cslir1dr.js → database-viewer-BiBC7ulB.js} +1 -1
- package/dist/web/assets/{diff-viewer-DzAIvBbn.js → diff-viewer-CSvZJHak.js} +1 -1
- package/dist/web/assets/{explorer-body-D6H-4cz9.js → explorer-body-QXS0h4AF.js} +1 -1
- package/dist/web/assets/{explorer-window-content-C5W_JzCz.js → explorer-window-content-CQgqoRsj.js} +1 -1
- package/dist/web/assets/{extension-manager-section-DwicI3CA.js → extension-manager-section-CyC7wX35.js} +1 -1
- package/dist/web/assets/{extension-webview-Z3HhC8Pd.js → extension-webview-Da8SIlRa.js} +1 -1
- package/dist/web/assets/{files-settings-section-ByV7bRbB.js → files-settings-section-D8cebM51.js} +1 -1
- package/dist/web/assets/{general-settings-section-nLksKHur.js → general-settings-section-1HVTvlIw.js} +1 -1
- package/dist/web/assets/{git-log-panel-iUXltiTB.js → git-log-panel-C7a4rp8f.js} +1 -1
- package/dist/web/assets/{glide-data-grid-C1QB4DHt.js → glide-data-grid-BT9p_4AN.js} +1 -1
- package/dist/web/assets/{group-chat-tab-ezXKVVpg.js → group-chat-tab-BUHGnUyW.js} +1 -1
- package/dist/web/assets/index-CuYtJ5xF.css +2 -0
- package/dist/web/assets/{index-CJWbFurD.js → index-kGmcC9cb.js} +5 -5
- package/dist/web/assets/{jira-watcher-section-DwARwskB.js → jira-watcher-section-BxzwW4uQ.js} +1 -1
- package/dist/web/assets/{keybindings-store-S7YLGd8B.js → keybindings-store-DjJum4OS.js} +1 -1
- package/dist/web/assets/{keyboard-shortcuts-section-BoUDvdDy.js → keyboard-shortcuts-section-DDKG3BU1.js} +1 -1
- package/dist/web/assets/{markdown-renderer-DKdfiSIX.js → markdown-renderer-dHeak2mE.js} +1 -1
- package/dist/web/assets/{markdown-renderer-DYFmMC_8.js → markdown-renderer-iBw1fCI_.js} +1 -1
- package/dist/web/assets/{mobile-explorer-sheet-CMdqLCV-.js → mobile-explorer-sheet-CzL8SLtz.js} +1 -1
- package/dist/web/assets/{notification-store-DpSYFj36.js → notification-store-DVi6Ds71.js} +1 -1
- package/dist/web/assets/{notifications-settings-section-CIf3-3xd.js → notifications-settings-section-shQ-ugKh.js} +1 -1
- package/dist/web/assets/{pdf-preview-C-7IdkIK.js → pdf-preview-DB26KSzm.js} +1 -1
- package/dist/web/assets/{postgres-viewer-BYXFYlLg.js → postgres-viewer-CJe2meyF.js} +1 -1
- package/dist/web/assets/{ppmbot-settings-section-B9dXvQ6p.js → ppmbot-settings-section-Ba4c9AI9.js} +1 -1
- package/dist/web/assets/{process-table-B6NeTWt8.js → process-table-PXwLsMuG.js} +1 -1
- package/dist/web/assets/{proxy-settings-section-CUqq7pdU.js → proxy-settings-section-BHBhK6HC.js} +1 -1
- package/dist/web/assets/{query-audit-section-C1TYrPIp.js → query-audit-section-BD5ZVqDz.js} +1 -1
- package/dist/web/assets/{remote-desktop-mobile-sheet-DuKwKe5F.js → remote-desktop-mobile-sheet-Di4QG8VX.js} +2 -2
- package/dist/web/assets/{remote-desktop-mobile-view-DxMTtsa6.js → remote-desktop-mobile-view-DzoZvAqc.js} +1 -1
- package/dist/web/assets/{remote-desktop-readiness-gate-BwjfLWqg.js → remote-desktop-readiness-gate-CHO-KJ9Z.js} +1 -1
- package/dist/web/assets/{remote-desktop-window-content-BIl4N5Hn.js → remote-desktop-window-content-X324gYbW.js} +1 -1
- package/dist/web/assets/{schedules-settings-section-C0qOb7GI.js → schedules-settings-section-a4-h6THc.js} +1 -1
- package/dist/web/assets/{settings-body-qbrJXX_A.js → settings-body-Fq7BVDw2.js} +2 -2
- package/dist/web/assets/{settings-tab-BKRgYVxf.js → settings-tab-CVLQy-ie.js} +1 -1
- package/dist/web/assets/{settings-window-content-DWsxJDu-.js → settings-window-content-Bi7M0iaZ.js} +1 -1
- package/dist/web/assets/{sql-query-editor-CsZOpCW3.js → sql-query-editor-D1r265fl.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-DUrnCaP7.js → sqlite-viewer-DHkIotRl.js} +1 -1
- package/dist/web/assets/{system-monitor-body-C5cMpg7I.js → system-monitor-body-y6VUkYTL.js} +2 -2
- package/dist/web/assets/{system-monitor-tab-C66ywbVZ.js → system-monitor-tab-DRAOejhk.js} +1 -1
- package/dist/web/assets/{system-monitor-window-content-CENaXYJ-.js → system-monitor-window-content-B5i57xHD.js} +1 -1
- package/dist/web/assets/{tab-host-window-content-HViABGoS.js → tab-host-window-content-SkyPFFlc.js} +1 -1
- package/dist/web/assets/{team-member-sheet-CK6eyBRL.js → team-member-sheet-BB63u-Lf.js} +2 -2
- package/dist/web/assets/{team-member-window-content-D6wxg37P.js → team-member-window-content-D6tVTXuV.js} +1 -1
- package/dist/web/assets/{terminal-tab-BmyTawmN.js → terminal-tab-BYaHAkI5.js} +1 -1
- package/dist/web/assets/{tool-cards-y-XJcJHo.js → tool-cards-DRJu20sL.js} +2 -2
- package/dist/web/assets/use-accounts-data-Ba1_uvEE.js +1 -0
- package/dist/web/assets/{use-monaco-theme-BkM5rMgL.js → use-monaco-theme-Uakgj3hJ.js} +1 -1
- package/dist/web/assets/{use-remote-desktop-display-choice-BT2r1td1.js → use-remote-desktop-display-choice-Cd3Xalca.js} +1 -1
- package/dist/web/assets/{use-websocket-BvVTwMjI.js → use-websocket-BC_EGf5q.js} +1 -1
- package/dist/web/assets/{video-preview-BEaofjN7.js → video-preview-BUb43zWb.js} +1 -1
- package/dist/web/index.html +2 -2
- package/dist/web/sw.js +1 -1
- package/docs/deployment-guide.md +10 -0
- package/docs/lessons-learned.md +14 -0
- package/package.json +1 -1
- package/src/providers/codex-app-server/codex-event-mapper.ts +11 -0
- package/src/providers/codex-app-server/codex-history.ts +118 -45
- package/src/providers/codex-app-server/codex-provider.ts +277 -15
- package/src/providers/codex-app-server/codex-rollout-header.ts +99 -0
- package/src/providers/codex-app-server/codex-rollout-items.ts +6 -0
- package/src/providers/codex-app-server/codex-rollout-transfer.ts +58 -0
- package/src/providers/codex-app-server/codex-subagent-thread.ts +103 -0
- package/src/providers/codex-app-server/codex-usage-limit.ts +69 -0
- package/src/providers/codex-app-server/codex-usage-parser.ts +31 -14
- package/src/server/routes/chat.ts +23 -2
- package/src/server/routes/codex-accounts.ts +32 -3
- package/src/server/ws/chat.ts +15 -11
- package/src/services/codex-account-cooldown.ts +56 -0
- package/src/services/codex-account-login.ts +109 -34
- package/src/services/codex-account.service.ts +56 -10
- package/src/services/codex-login-callback.ts +57 -0
- package/src/web/components/chat/chat-tab.tsx +8 -2
- package/src/web/components/chat/codex-usage-panel.tsx +129 -165
- package/src/web/components/chat/usage-badge.tsx +38 -129
- package/src/web/components/chat/usage-panel-shell.tsx +153 -0
- package/src/web/components/settings/accounts/account-card.tsx +20 -3
- package/src/web/components/settings/accounts/codex-accounts-section.tsx +9 -2
- package/src/web/components/settings/accounts/codex-add-account-dialog.tsx +47 -27
- package/src/web/components/settings/accounts/codex-usage-rows.tsx +17 -6
- package/src/web/components/settings/accounts/use-codex-accounts.ts +90 -44
- package/src/web/components/ui/tooltip.tsx +14 -1
- package/src/web/hooks/use-usage.ts +13 -1
- package/dist/web/assets/accounts-settings-section-_sG1AqtH.js +0 -1
- package/dist/web/assets/chat-tab-CJi4uYb9.js +0 -15
- package/dist/web/assets/index-CoaVwAF6.css +0 -2
- package/dist/web/assets/use-accounts-data-BbOb2QNP.js +0 -1
|
@@ -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 {
|
|
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`
|
|
58
|
-
*
|
|
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 =
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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. */
|
|
@@ -187,6 +235,10 @@ function extractThreadId(result: unknown): string | null {
|
|
|
187
235
|
return r?.thread?.id ?? r?.threadId ?? r?.id ?? null;
|
|
188
236
|
}
|
|
189
237
|
|
|
238
|
+
function missingRolloutError(sessionId: string): Error {
|
|
239
|
+
return new Error(`Cannot resume Codex session ${sessionId}: its transcript was not found in the available account homes for this project. Restore the original account/transcript and retry, or explicitly open a new chat.`);
|
|
240
|
+
}
|
|
241
|
+
|
|
190
242
|
/** Human label for an approval prompt (dormant in MVP under default bypass). */
|
|
191
243
|
function approvalToolLabel(method: string, params: unknown): string {
|
|
192
244
|
const p = (params && typeof params === "object" ? params : {}) as Record<string, unknown>;
|
|
@@ -225,6 +277,8 @@ export class CodexAppServerProvider implements AIProvider {
|
|
|
225
277
|
readonly name = "Codex";
|
|
226
278
|
|
|
227
279
|
private sessions = new Map<string, Session>();
|
|
280
|
+
/** Only IDs minted here may start a thread. Missing history is never proof of a new chat. */
|
|
281
|
+
private unstartedSessions = new Set<string>();
|
|
228
282
|
private live = new Map<string, LiveSession>();
|
|
229
283
|
private modelsCache: { models: ModelOption[]; expiry: number } | null = null;
|
|
230
284
|
/** Keyed by `cwd\0codexHome` — skills differ per workspace AND per account. */
|
|
@@ -246,6 +300,7 @@ export class CodexAppServerProvider implements AIProvider {
|
|
|
246
300
|
createdAt: new Date().toISOString(),
|
|
247
301
|
};
|
|
248
302
|
this.sessions.set(id, session);
|
|
303
|
+
this.unstartedSessions.add(id);
|
|
249
304
|
if (config.projectPath) setSessionMetadata(id, config.projectName, config.projectPath);
|
|
250
305
|
return session;
|
|
251
306
|
}
|
|
@@ -281,6 +336,11 @@ export class CodexAppServerProvider implements AIProvider {
|
|
|
281
336
|
sessions.push(s);
|
|
282
337
|
}
|
|
283
338
|
}
|
|
339
|
+
// A title the user set by hand outranks the one derived from the opening
|
|
340
|
+
// prompt, and it is the only place a renamed codex session is recorded.
|
|
341
|
+
const customTitles = getSessionTitles(sessions.map((s) => s.id));
|
|
342
|
+
for (const s of sessions) s.title = customTitles[s.id] ?? s.title;
|
|
343
|
+
|
|
284
344
|
// Backfill provider ownership so reopening a pre-existing codex thread routes
|
|
285
345
|
// to codex (not the default provider) even after a restart.
|
|
286
346
|
//
|
|
@@ -301,6 +361,7 @@ export class CodexAppServerProvider implements AIProvider {
|
|
|
301
361
|
async deleteSession(sessionId: string): Promise<void> {
|
|
302
362
|
this.abortQuery(sessionId, "delete");
|
|
303
363
|
this.sessions.delete(sessionId);
|
|
364
|
+
this.unstartedSessions.delete(sessionId);
|
|
304
365
|
}
|
|
305
366
|
|
|
306
367
|
// ── Streaming (multi-turn) ──
|
|
@@ -310,6 +371,7 @@ export class CodexAppServerProvider implements AIProvider {
|
|
|
310
371
|
try {
|
|
311
372
|
live = await this.connect(sessionId, opts);
|
|
312
373
|
} catch (err) {
|
|
374
|
+
this.abortQuery(sessionId, "connect_failed");
|
|
313
375
|
yield { type: "error", message: redactTruncate((err as Error)?.message ?? String(err), 512) };
|
|
314
376
|
yield { type: "done", sessionId, resultSubtype: "error_during_execution" };
|
|
315
377
|
return;
|
|
@@ -328,7 +390,15 @@ export class CodexAppServerProvider implements AIProvider {
|
|
|
328
390
|
this.startTurn(live, content, opts);
|
|
329
391
|
}
|
|
330
392
|
|
|
331
|
-
|
|
393
|
+
/**
|
|
394
|
+
* Send a turn on the live thread.
|
|
395
|
+
*
|
|
396
|
+
* `replay` marks the same turn being sent again on a different account after a usage
|
|
397
|
+
* limit. It skips the transcript append only: the user typed the message once, and a
|
|
398
|
+
* second entry would both show it twice and shift every later `rollout-N` id, which
|
|
399
|
+
* fork anchors resolve against.
|
|
400
|
+
*/
|
|
401
|
+
private startTurn(live: LiveSession, message: string, opts?: SendMessageOpts, replay = false): void {
|
|
332
402
|
if (!live.threadId) return;
|
|
333
403
|
// One turn at a time — anything sent now would be dropped without a trace.
|
|
334
404
|
// `now` additionally cuts the running turn short so this one answers next.
|
|
@@ -351,7 +421,8 @@ export class CodexAppServerProvider implements AIProvider {
|
|
|
351
421
|
});
|
|
352
422
|
return;
|
|
353
423
|
}
|
|
354
|
-
live.transcript.push({ id: nextRolloutId(live), role: "user", content: message, timestamp: new Date().toISOString() });
|
|
424
|
+
if (!replay) live.transcript.push({ id: nextRolloutId(live), role: "user", content: message, timestamp: new Date().toISOString() });
|
|
425
|
+
live.lastTurnInput = { message, opts };
|
|
355
426
|
live.currentAssistant = "";
|
|
356
427
|
live.currentEvents = [];
|
|
357
428
|
// Codex takes an image as a path, never as a payload, so the uploaded copy is what it
|
|
@@ -371,7 +442,12 @@ export class CodexAppServerProvider implements AIProvider {
|
|
|
371
442
|
const id = (res as { turn?: { id?: string } })?.turn?.id;
|
|
372
443
|
if (id && !live.activeTurnId) this.setActiveTurn(live, id);
|
|
373
444
|
}).catch((err) => {
|
|
374
|
-
|
|
445
|
+
const reason = (err as Error)?.message ?? String(err);
|
|
446
|
+
// A quota refusal can come back as the rejection of the request itself rather than
|
|
447
|
+
// as an `error` notification, and it is the same situation either way — move the
|
|
448
|
+
// turn to an account that still has room instead of showing the refusal.
|
|
449
|
+
if (isCodexUsageLimit(reason) && this.beginRotation(live, reason)) return;
|
|
450
|
+
if (!live.client.isClosed) live.channel.push({ type: "error", message: redactTruncate(reason, 256) });
|
|
375
451
|
// No turn is running, so nothing will report `turn/completed` — release the
|
|
376
452
|
// queue here or every later follow-up waits on a turn that never existed.
|
|
377
453
|
this.endTurn(live);
|
|
@@ -410,16 +486,192 @@ export class CodexAppServerProvider implements AIProvider {
|
|
|
410
486
|
live.turnInFlight = false;
|
|
411
487
|
live.activeTurnId = null;
|
|
412
488
|
live.interruptRequested = false;
|
|
489
|
+
live.lastTurnInput = undefined;
|
|
413
490
|
const next = live.pendingTurns.shift();
|
|
414
491
|
if (next) this.startTurn(live, next.message, next.opts);
|
|
415
492
|
}
|
|
416
493
|
|
|
494
|
+
// ── Usage-limit rotation ──
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Take over a turn Codex refused for an exhausted quota, if another account can serve it.
|
|
498
|
+
*
|
|
499
|
+
* Answers synchronously so the caller knows, before it decides what to show, whether the
|
|
500
|
+
* refusal is being handled or has to be reported. The rotation itself is asynchronous —
|
|
501
|
+
* it respawns a subprocess and resumes the thread — but whether one is possible is a
|
|
502
|
+
* question about which accounts exist, which needs no I/O.
|
|
503
|
+
*
|
|
504
|
+
* Returns false when nothing else could serve the turn, and the refusal then reaches the
|
|
505
|
+
* user untouched. That is the honest outcome: an error saying the quota is spent is worth
|
|
506
|
+
* more than a silent retry on the account that just said so.
|
|
507
|
+
*/
|
|
508
|
+
private beginRotation(live: LiveSession, reason: string): boolean {
|
|
509
|
+
const threadId = live.threadId;
|
|
510
|
+
if (!threadId || live.rotating) return false;
|
|
511
|
+
const currentId = getSessionCodexAccount(threadId);
|
|
512
|
+
const candidates = listCodexAccounts().filter(
|
|
513
|
+
(a) => a.status !== "disabled" && a.id !== currentId && !isCodexAccountUsageLimited(a.id),
|
|
514
|
+
);
|
|
515
|
+
if (candidates.length === 0) return false;
|
|
516
|
+
live.rotating = true;
|
|
517
|
+
void this.rotateAccount(live, threadId, currentId, reason);
|
|
518
|
+
return true;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/** Park the spent account, move the session onto a fresh one, and send the turn again. */
|
|
522
|
+
private async rotateAccount(
|
|
523
|
+
live: LiveSession,
|
|
524
|
+
threadId: string,
|
|
525
|
+
currentId: string | null,
|
|
526
|
+
reason: string,
|
|
527
|
+
): Promise<void> {
|
|
528
|
+
const pending = live.lastTurnInput;
|
|
529
|
+
const reset = parseCodexUsageLimitReset(reason);
|
|
530
|
+
if (currentId) markCodexAccountUsageLimited(currentId, reset?.atMs);
|
|
531
|
+
|
|
532
|
+
let next: CodexAccount | null = null;
|
|
533
|
+
try {
|
|
534
|
+
// Usage picks the emptiest of the remaining accounts rather than merely the next one,
|
|
535
|
+
// so a rotation forced by a limit does not land on an account that is nearly at its own.
|
|
536
|
+
const usages = await getAllCodexUsages();
|
|
537
|
+
next = selectCodexAccount({
|
|
538
|
+
usageOf: (id) => codexUsageLevel(usages[id]),
|
|
539
|
+
...(currentId ? { exclude: [currentId] } : {}),
|
|
540
|
+
});
|
|
541
|
+
} catch {
|
|
542
|
+
next = selectCodexAccount(currentId ? { exclude: [currentId] } : undefined);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
if (!next) { this.abandonRotation(live, reason, reset?.text); return; }
|
|
546
|
+
|
|
547
|
+
console.warn(`[codex] session=${threadId} usage limit — switching to ${next.id} (${next.label})`);
|
|
548
|
+
live.channel.push({
|
|
549
|
+
type: "account_retry",
|
|
550
|
+
reason: "Usage limit reached — switching account",
|
|
551
|
+
accountId: next.id,
|
|
552
|
+
accountLabel: next.label,
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
try {
|
|
556
|
+
await this.respawnOn(live, threadId, next);
|
|
557
|
+
} catch (e) {
|
|
558
|
+
console.error(`[codex] session=${threadId} rotation to ${next.id} failed: ${redactTruncate((e as Error)?.message, 200)}`);
|
|
559
|
+
this.abandonRotation(live, reason, reset?.text);
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
live.rotating = false;
|
|
564
|
+
// The refused turn never reached `turn/completed`, so the flags it set are still on.
|
|
565
|
+
live.turnInFlight = false;
|
|
566
|
+
live.activeTurnId = null;
|
|
567
|
+
live.interruptRequested = false;
|
|
568
|
+
if (pending) this.startTurn(live, pending.message, pending.opts, true);
|
|
569
|
+
else this.endTurn(live);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/** No account could take the turn — report the refusal and close the turn out. */
|
|
573
|
+
private abandonRotation(live: LiveSession, reason: string, resetText?: string): void {
|
|
574
|
+
live.rotating = false;
|
|
575
|
+
const suffix = resetText ? ` Try again ${resetText}.` : "";
|
|
576
|
+
live.channel.push({
|
|
577
|
+
type: "error",
|
|
578
|
+
message: `${redactTruncate(reason, 512)}${suffix} Add another account in Settings → Accounts, or wait for the reset.`,
|
|
579
|
+
});
|
|
580
|
+
live.channel.push({ type: "done", sessionId: live.threadId ?? "", resultSubtype: "error_during_execution" });
|
|
581
|
+
this.endTurn(live);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Put this session's app-server on another account, keeping the session itself intact.
|
|
586
|
+
*
|
|
587
|
+
* The subprocess is replaced, not the `LiveSession`: the caller of `sendMessage` is
|
|
588
|
+
* iterating this session's channel, and handing it a new one would end its stream mid-turn
|
|
589
|
+
* and leave the rest of the answer with nobody listening. Transcript, history and pending
|
|
590
|
+
* follow-ups all survive for the same reason.
|
|
591
|
+
*
|
|
592
|
+
* The outgoing client's handlers are cleared before it is closed. `close()` only asks the
|
|
593
|
+
* process to exit, so its `close` event lands well after this returns — by which time
|
|
594
|
+
* `rotating` is off again, and the inherited `onClose` would tear down the session that
|
|
595
|
+
* had just been repaired.
|
|
596
|
+
*/
|
|
597
|
+
private async respawnOn(live: LiveSession, threadId: string, account: CodexAccount): Promise<void> {
|
|
598
|
+
// Validate before replacing the client or its account binding. A missing file
|
|
599
|
+
// can mean inaccessible history, including on the first turn; it cannot justify
|
|
600
|
+
// migrating an existing provider thread onto an empty conversation.
|
|
601
|
+
const found = locateRollout(threadId, live.cwd);
|
|
602
|
+
if (!found) throw missingRolloutError(threadId);
|
|
603
|
+
const old = live.client;
|
|
604
|
+
old.onNotification(() => {});
|
|
605
|
+
old.onServerRequest(() => {});
|
|
606
|
+
old.onClose(() => {});
|
|
607
|
+
const pid = old.pid;
|
|
608
|
+
const proc = old.process;
|
|
609
|
+
old.close();
|
|
610
|
+
if (process.platform === "win32" && pid) killProcessTree(pid);
|
|
611
|
+
else if (proc) setTimeout(() => { try { proc.kill("SIGKILL"); } catch { /* dead */ } }, 2000).unref?.();
|
|
612
|
+
|
|
613
|
+
// Locate history while the outgoing account is still bound, so its current
|
|
614
|
+
// transcript wins. Then bind the account that will receive the localized copy.
|
|
615
|
+
setSessionCodexAccount(threadId, account.id);
|
|
616
|
+
|
|
617
|
+
const client = new CodexJsonRpcClient();
|
|
618
|
+
client.onNotification((n) => this.handleNotification(live, n));
|
|
619
|
+
client.onServerRequest((r) => this.handleServerRequest(live, r));
|
|
620
|
+
client.onClose(() => this.handleClose(live));
|
|
621
|
+
client.start({ cwd: live.cwd, codexHome: account.home });
|
|
622
|
+
live.client = client;
|
|
623
|
+
|
|
624
|
+
await client.request("initialize", { clientInfo: CLIENT_INFO, capabilities: CAPABILITIES }, CONTROL_REQUEST_TIMEOUT_MS);
|
|
625
|
+
client.notify("initialized");
|
|
626
|
+
|
|
627
|
+
const resumeBase = {
|
|
628
|
+
cwd: live.cwd,
|
|
629
|
+
sandbox: live.permission.sandbox,
|
|
630
|
+
approvalPolicy: live.permission.approvalPolicy,
|
|
631
|
+
...(live.model ? { model: live.model } : {}),
|
|
632
|
+
};
|
|
633
|
+
await this.resumeThread(client, threadId, found, account.home, resumeBase);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* Resume a thread from the rollout file we located, wherever it lives.
|
|
638
|
+
*
|
|
639
|
+
* The `path` matters because a rollout is written inside the CODEX_HOME of whichever
|
|
640
|
+
* account served the turn, and this app-server is running on the account serving the
|
|
641
|
+
* session NOW. Asked by thread id alone, it searches only its own home and answers "no
|
|
642
|
+
* rollout found for thread id …" for a conversation that plainly exists — which is how
|
|
643
|
+
* an account switch used to lose the history and open a brand-new thread instead.
|
|
644
|
+
*
|
|
645
|
+
* The id-only form is kept as a fallback for a codex build that predates `path`: there
|
|
646
|
+
* the argument is rejected outright, and a resume that can still work by id should not
|
|
647
|
+
* be downgraded into a new thread over a field name.
|
|
648
|
+
*/
|
|
649
|
+
private async resumeThread(
|
|
650
|
+
client: CodexJsonRpcClient,
|
|
651
|
+
threadId: string,
|
|
652
|
+
found: { path: string; sessionsDir: string },
|
|
653
|
+
codexHome: string | undefined,
|
|
654
|
+
resumeBase: Record<string, unknown>,
|
|
655
|
+
): Promise<unknown> {
|
|
656
|
+
const target = sessionsDirForHome(codexHome);
|
|
657
|
+
const path = localizeRollout(found.path, found.sessionsDir, target);
|
|
658
|
+
if (path !== found.path) {
|
|
659
|
+
console.log(`[codex] thread=${threadId} rollout copied into the serving account's home to resume`);
|
|
660
|
+
}
|
|
661
|
+
return client.request("thread/resume", { threadId, path, ...resumeBase });
|
|
662
|
+
}
|
|
663
|
+
|
|
417
664
|
private async connect(sessionId: string, opts?: SendMessageOpts): Promise<LiveSession> {
|
|
418
665
|
const meta = this.sessions.get(sessionId);
|
|
419
666
|
const cwd = meta?.projectPath || getSessionProjectPath(sessionId) || process.cwd();
|
|
420
667
|
const permission = permissionModeToCodex(opts?.permissionMode ?? this.config?.permission_mode);
|
|
421
668
|
const model = codexModel(opts?.model ?? this.config?.model);
|
|
422
669
|
|
|
670
|
+
// Only resume a rollout attributable to this project. An unknown/resumed ID
|
|
671
|
+
// without one must not silently become a fresh thread with a different identity.
|
|
672
|
+
const found = locateRollout(sessionId, cwd);
|
|
673
|
+
if (!found && !this.unstartedSessions.has(sessionId)) throw missingRolloutError(sessionId);
|
|
674
|
+
|
|
423
675
|
const client = new CodexJsonRpcClient();
|
|
424
676
|
const channel = createEventChannel();
|
|
425
677
|
const live: LiveSession = {
|
|
@@ -445,13 +697,13 @@ 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
|
|
449
|
-
|
|
450
|
-
? await client.request("thread/resume", { threadId: sessionId, ...resumeBase })
|
|
700
|
+
const result = found
|
|
701
|
+
? await this.resumeThread(client, sessionId, found, account?.home, resumeBase)
|
|
451
702
|
: await client.request("thread/start", resumeBase);
|
|
452
703
|
|
|
453
|
-
const threadId = extractThreadId(result) ?? (
|
|
704
|
+
const threadId = extractThreadId(result) ?? (found ? sessionId : null);
|
|
454
705
|
if (!threadId) throw new Error("codex thread/start returned no thread id");
|
|
706
|
+
this.unstartedSessions.delete(sessionId);
|
|
455
707
|
live.threadId = threadId;
|
|
456
708
|
|
|
457
709
|
if (threadId !== sessionId) {
|
|
@@ -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;
|
|
@@ -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
|
+
}
|