session-steward 0.1.0

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/lib/server.mjs ADDED
@@ -0,0 +1,753 @@
1
+ import { createServer } from "node:http";
2
+ import { randomBytes } from "node:crypto";
3
+ import { execFileSync } from "node:child_process";
4
+ import { promises as fs } from "node:fs";
5
+ import path from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ import { getProvider } from "./providers/index.mjs";
9
+ import { createProviderSettings } from "./settings.mjs";
10
+ import { classifyInstalledVersion } from "./version-support.mjs";
11
+
12
+ const {
13
+ assertDeepCleanupSupported,
14
+ formatSessionForJson,
15
+ executeSessionDeletion,
16
+ fingerprintSessionDeletion,
17
+ diagnoseStorageCompatibility,
18
+ getSessionRecord,
19
+ listSessions,
20
+ loadDeletionStore,
21
+ planSessionDeletion,
22
+ preflightSessionDeletion,
23
+ restoreSessionDeletionBackup,
24
+ verifySessionDeletion,
25
+ } = getProvider("codex");
26
+
27
+ const MAX_BODY_BYTES = 64 * 1024;
28
+ const ALLOWED_SCOPES = new Set(["core", "deep"]);
29
+ const PLAN_TTL_MS = 10 * 60 * 1000;
30
+ const OPERATION_TTL_MS = 60 * 60 * 1000;
31
+ const MAX_SAVED_PLANS = 20;
32
+ const MAX_SAVED_OPERATIONS = 50;
33
+ const PLAN_RECORD_SAMPLE_LIMIT = 20;
34
+ const PLAN_REVIEW_REQUIRED = "DELETION_PLAN_REVIEW_REQUIRED";
35
+ const publicDirectory = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "dist");
36
+ const staticAssets = new Map([
37
+ ["/", { fileName: "index.html", contentType: "text/html; charset=utf-8" }],
38
+ ]);
39
+
40
+ function readCommandVersion(command, args) {
41
+ try {
42
+ return execFileSync(command, args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || null;
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+
48
+ async function getInstalledProductVersions() {
49
+ const versions = { chatgptDesktop: null, codexCli: readCommandVersion("codex", ["--version"]) };
50
+
51
+ if (process.platform !== "darwin") {
52
+ return versions;
53
+ }
54
+
55
+ const chatGptInfoPath = "/Applications/ChatGPT.app/Contents/Info.plist";
56
+ try {
57
+ await fs.access(chatGptInfoPath);
58
+ versions.chatgptDesktop = readCommandVersion("/usr/libexec/PlistBuddy", ["-c", "Print :CFBundleShortVersionString", chatGptInfoPath]);
59
+ } catch {
60
+ }
61
+
62
+ return versions;
63
+ }
64
+
65
+ function getStaticAsset(requestPath) {
66
+ const knownAsset = staticAssets.get(requestPath);
67
+
68
+ if (knownAsset) {
69
+ return knownAsset;
70
+ }
71
+
72
+ if (!requestPath.startsWith("/assets/") || requestPath.includes("..")) {
73
+ return null;
74
+ }
75
+
76
+ const fileName = requestPath.slice(1);
77
+ const extension = path.extname(fileName);
78
+ const contentTypes = {
79
+ ".css": "text/css; charset=utf-8",
80
+ ".js": "text/javascript; charset=utf-8",
81
+ };
82
+ const contentType = contentTypes[extension];
83
+
84
+ return contentType ? { fileName, contentType } : null;
85
+ }
86
+
87
+ function sendJson(response, statusCode, payload) {
88
+ response.writeHead(statusCode, {
89
+ "Cache-Control": "no-store",
90
+ "Content-Type": "application/json; charset=utf-8",
91
+ "X-Content-Type-Options": "nosniff",
92
+ });
93
+ response.end(`${JSON.stringify(payload)}\n`);
94
+ }
95
+
96
+ function codedError(message, code) {
97
+ const error = new Error(message);
98
+ error.code = code;
99
+ return error;
100
+ }
101
+
102
+ async function sendStaticAsset(response, asset) {
103
+ const content = await fs.readFile(path.join(publicDirectory, asset.fileName));
104
+ response.writeHead(200, {
105
+ "Cache-Control": "no-store",
106
+ "Content-Type": asset.contentType,
107
+ "X-Content-Type-Options": "nosniff",
108
+ });
109
+ response.end(content);
110
+ }
111
+
112
+ async function readJsonBody(request) {
113
+ let size = 0;
114
+ const chunks = [];
115
+
116
+ for await (const chunk of request) {
117
+ size += chunk.length;
118
+
119
+ if (size > MAX_BODY_BYTES) {
120
+ throw new Error("Request body is too large.");
121
+ }
122
+
123
+ chunks.push(chunk);
124
+ }
125
+
126
+ if (chunks.length === 0) {
127
+ return {};
128
+ }
129
+
130
+ try {
131
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
132
+ } catch {
133
+ throw new Error("Request body must be valid JSON.");
134
+ }
135
+ }
136
+
137
+ function normalizeIds(value) {
138
+ if (!Array.isArray(value) || value.length === 0 || !value.every((id) => typeof id === "string")) {
139
+ throw new Error("ids must be a non-empty array of session IDs.");
140
+ }
141
+
142
+ return [...new Set(value)];
143
+ }
144
+
145
+ function getScope(value) {
146
+ if (!ALLOWED_SCOPES.has(value)) {
147
+ throw new Error("scope must be either core or deep.");
148
+ }
149
+
150
+ return value;
151
+ }
152
+
153
+ function getPositiveInteger(value, fallback, maximum = Number.MAX_SAFE_INTEGER) {
154
+ if (value === null) {
155
+ return fallback;
156
+ }
157
+
158
+ const parsed = Number.parseInt(value, 10);
159
+
160
+ if (!Number.isInteger(parsed) || parsed < 1) {
161
+ return fallback;
162
+ }
163
+
164
+ return Math.min(parsed, maximum);
165
+ }
166
+
167
+ function getLocalRequestOrigin({ hostHeader, server }) {
168
+ if (typeof hostHeader !== "string") {
169
+ return null;
170
+ }
171
+
172
+ const address = server.address();
173
+
174
+ if (!address || typeof address === "string") {
175
+ return null;
176
+ }
177
+
178
+ let expectedHost = null;
179
+
180
+ if (address.address === "127.0.0.1") {
181
+ expectedHost = `127.0.0.1:${address.port}`;
182
+ } else if (address.address === "::1") {
183
+ expectedHost = `[::1]:${address.port}`;
184
+ }
185
+
186
+ return hostHeader === expectedHost ? `http://${expectedHost}` : null;
187
+ }
188
+
189
+ function requireMutationAuthorization({ request, requestUrl, token }) {
190
+ if (request.headers.origin !== requestUrl.origin) {
191
+ throw new Error("Destructive requests must originate from this local server.");
192
+ }
193
+
194
+ if (request.headers["x-session-steward-token"] !== token) {
195
+ throw new Error("Destructive request authorization failed.");
196
+ }
197
+ }
198
+
199
+ function summarizePlan(plan, preflight) {
200
+ return {
201
+ availableDiskBytes: preflight.availableDiskBytes,
202
+ childCount: plan.childCount,
203
+ desktopStateMatchCount: preflight.desktopStateMatchCount,
204
+ desktopStateSupport: preflight.desktopStateSupport,
205
+ estimatedBackupBytes: preflight.estimatedBackupBytes,
206
+ goalRowCount: plan.goalRowCount,
207
+ historyMatchCount: plan.historyMatchCount,
208
+ logRowCount: plan.logRowCount,
209
+ memoryRowCount: plan.memoryRowCount,
210
+ missingTranscriptCount: plan.missingTranscriptPaths.length,
211
+ recordSamples: plan.records.slice(0, PLAN_RECORD_SAMPLE_LIMIT).map((record) => ({
212
+ displayName: record.displayName,
213
+ id: record.id,
214
+ })),
215
+ sessionIndexMatchCount: plan.sessionIndexMatchCount,
216
+ sessionCount: plan.ids.length,
217
+ spawnEdgeCount: plan.spawnEdgeCount,
218
+ transcriptCount: plan.transcriptPaths.length,
219
+ };
220
+ }
221
+
222
+ function summarizeVerification(verification) {
223
+ return {
224
+ complete: verification.complete,
225
+ remainingDesktopStateReferenceCount: verification.remainingDesktopStateReferences.length,
226
+ remainingGoalRecordCount: verification.remainingGoalRecords.length,
227
+ remainingHistoryEntryCount: verification.remainingHistoryEntryCount,
228
+ remainingLogRecordCount: verification.remainingLogRecords.length,
229
+ remainingMemoryRecordCount: verification.remainingMemoryRecords.length,
230
+ remainingSessionIndexEntryCount: verification.remainingSessionIndexEntryCount,
231
+ remainingThreadCount: verification.remainingThreads.length,
232
+ remainingTranscriptCount: verification.remainingTranscriptPaths.length,
233
+ };
234
+ }
235
+
236
+ function summarizeDeletionResult(result) {
237
+ return {
238
+ backupDirectory: result.backupDirectory,
239
+ deletedSessionCount: result.deletedIds.length,
240
+ deletedTranscriptCount: result.deletedTranscriptPaths.length,
241
+ skippedTranscriptCount: result.skippedTranscriptPaths.length,
242
+ };
243
+ }
244
+
245
+ function publicOperation(operation) {
246
+ return {
247
+ backupDirectory: operation.backupDirectory ?? null,
248
+ canCancel: Boolean(operation.canCancel),
249
+ canRestore: Boolean(operation.canRestore),
250
+ cancelRequested: Boolean(operation.cancelRequested),
251
+ error: operation.error ?? null,
252
+ errorCode: operation.errorCode ?? null,
253
+ id: operation.id,
254
+ message: operation.message,
255
+ phase: operation.phase,
256
+ progress: operation.progress,
257
+ result: operation.result ?? null,
258
+ restoreResult: operation.restoreResult ?? null,
259
+ status: operation.status,
260
+ verification: operation.verification ?? null,
261
+ };
262
+ }
263
+
264
+ function removeExpiredEntries(entries, ttlMs, maximum) {
265
+ const now = Date.now();
266
+
267
+ for (const [id, entry] of entries) {
268
+ if (entry.finishedAtMs && now - entry.finishedAtMs > ttlMs) entries.delete(id);
269
+ if (entry.expiresAtMs && entry.expiresAtMs <= now) entries.delete(id);
270
+ }
271
+
272
+ while (entries.size >= maximum) {
273
+ const oldestFinished = [...entries].find(([, entry]) => entry.finishedAtMs);
274
+ if (!oldestFinished) break;
275
+ entries.delete(oldestFinished[0]);
276
+ }
277
+ }
278
+
279
+ export async function startLocalServer({ codexHome, configDirectory, port = 0 }) {
280
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
281
+ throw new Error("port must be an integer between 0 and 65535.");
282
+ }
283
+
284
+ const settings = await createProviderSettings({
285
+ configDirectory,
286
+ providerHomeOverrides: codexHome === undefined ? {} : { codex: codexHome },
287
+ });
288
+ const mutationToken = randomBytes(32).toString("base64url");
289
+ let mutationInProgress = false;
290
+ let activeOperationId = null;
291
+ const deletionPlans = new Map();
292
+ const operations = new Map();
293
+ const activeTasks = new Set();
294
+
295
+ function getDeletionPlan(planId) {
296
+ const savedPlan = typeof planId === "string" ? deletionPlans.get(planId) : null;
297
+
298
+ if (!savedPlan || savedPlan.expiresAtMs <= Date.now()) {
299
+ if (savedPlan) deletionPlans.delete(planId);
300
+ throw codedError(
301
+ "This deletion preview has expired. Review the selection again.",
302
+ PLAN_REVIEW_REQUIRED,
303
+ );
304
+ }
305
+
306
+ if (savedPlan.consumed) {
307
+ throw codedError(
308
+ "This deletion preview has already been used. Review the selection again.",
309
+ PLAN_REVIEW_REQUIRED,
310
+ );
311
+ }
312
+
313
+ if (savedPlan.codexHome !== settings.getHome("codex")) {
314
+ deletionPlans.delete(planId);
315
+ throw codedError(
316
+ "The Codex session folder changed. Review the selection again.",
317
+ PLAN_REVIEW_REQUIRED,
318
+ );
319
+ }
320
+
321
+ return savedPlan;
322
+ }
323
+
324
+ function getOperation(operationId) {
325
+ const operation = operations.get(operationId);
326
+ if (!operation) throw new Error("Cleanup progress is no longer available.");
327
+ return operation;
328
+ }
329
+
330
+ async function runDeletionOperation(operation, savedPlan) {
331
+ operation.status = "running";
332
+ operation.message = "Checking the deletion preview";
333
+ operation.phase = "preflight";
334
+ operation.progress = 2;
335
+
336
+ try {
337
+ let currentStore;
338
+
339
+ try {
340
+ currentStore = await loadDeletionStore({
341
+ codexHome: savedPlan.codexHome,
342
+ recordIds: savedPlan.requestedIds,
343
+ });
344
+ } catch (error) {
345
+ if (error?.message === "One or more selected sessions are no longer available.") {
346
+ throw codedError(
347
+ "The selected sessions changed after this preview. Review the selection again.",
348
+ PLAN_REVIEW_REQUIRED,
349
+ );
350
+ }
351
+ throw error;
352
+ }
353
+
354
+ const currentPlan = await planSessionDeletion({
355
+ recordIds: savedPlan.requestedIds,
356
+ store: currentStore,
357
+ });
358
+ const currentFingerprint = await fingerprintSessionDeletion({
359
+ plan: currentPlan,
360
+ scope: savedPlan.scope,
361
+ store: currentStore,
362
+ });
363
+
364
+ if (currentFingerprint !== savedPlan.fingerprint) {
365
+ throw codedError(
366
+ "Session data changed after this preview. Review the selection again.",
367
+ PLAN_REVIEW_REQUIRED,
368
+ );
369
+ }
370
+
371
+ if (savedPlan.scope === "deep") {
372
+ await assertDeepCleanupSupported({ codexHome: savedPlan.codexHome });
373
+ }
374
+
375
+ const result = await executeSessionDeletion({
376
+ onProgress: (update) => Object.assign(operation, update),
377
+ plan: currentPlan,
378
+ scope: savedPlan.scope,
379
+ shouldCancel: () => operation.cancelRequested,
380
+ store: currentStore,
381
+ });
382
+ operation.backupDirectory = result.backupDirectory;
383
+ operation.result = summarizeDeletionResult(result);
384
+ operation.canCancel = false;
385
+ operation.message = "Checking that cleanup completed";
386
+ operation.phase = "verification";
387
+ operation.progress = 94;
388
+ const verification = await verifySessionDeletion({
389
+ plan: currentPlan,
390
+ scope: savedPlan.scope,
391
+ store: currentStore,
392
+ });
393
+ operation.verification = summarizeVerification(verification);
394
+ operation.progress = 100;
395
+
396
+ if (verification.complete) {
397
+ operation.message = "Cleanup completed";
398
+ operation.status = "completed";
399
+ } else {
400
+ operation.canRestore = true;
401
+ operation.error = "Cleanup finished, but some selected items remain. You can restore the recovery backup.";
402
+ operation.message = "Cleanup needs attention";
403
+ operation.status = "needs-attention";
404
+ }
405
+ } catch (error) {
406
+ operation.backupDirectory = error?.backupDirectory ?? null;
407
+ operation.canCancel = false;
408
+ operation.errorCode = error?.code ?? null;
409
+ operation.progress = error?.cancelled ? operation.progress : 100;
410
+
411
+ if (error?.cancelled) {
412
+ operation.error = null;
413
+ operation.message = "Cleanup cancelled before session data changed";
414
+ operation.status = "cancelled";
415
+ } else {
416
+ operation.canRestore = Boolean(operation.backupDirectory);
417
+ operation.error = error instanceof Error ? error.message : "Cleanup could not be completed.";
418
+ operation.message = "Cleanup could not be completed";
419
+ operation.status = "failed";
420
+ }
421
+ } finally {
422
+ savedPlan.consumed = true;
423
+ deletionPlans.delete(savedPlan.id);
424
+ operation.finishedAtMs = Date.now();
425
+ if (activeOperationId === operation.id) activeOperationId = null;
426
+ }
427
+ }
428
+
429
+ function startDeletionOperation(savedPlan) {
430
+ removeExpiredEntries(operations, OPERATION_TTL_MS, MAX_SAVED_OPERATIONS);
431
+ const id = randomBytes(18).toString("base64url");
432
+ const operation = {
433
+ backupDirectory: null,
434
+ canCancel: true,
435
+ canRestore: false,
436
+ cancelRequested: false,
437
+ codexHome: savedPlan.codexHome,
438
+ createdAtMs: Date.now(),
439
+ error: null,
440
+ errorCode: null,
441
+ id,
442
+ message: "Cleanup queued",
443
+ phase: "queued",
444
+ progress: 0,
445
+ result: null,
446
+ status: "queued",
447
+ verification: null,
448
+ };
449
+ operations.set(id, operation);
450
+ activeOperationId = id;
451
+ savedPlan.consumed = true;
452
+ const task = runDeletionOperation(operation, savedPlan);
453
+ activeTasks.add(task);
454
+ task.finally(() => activeTasks.delete(task));
455
+ return operation;
456
+ }
457
+
458
+ function startRestoreOperation(operation) {
459
+ operation.canCancel = false;
460
+ operation.canRestore = false;
461
+ operation.error = null;
462
+ operation.errorCode = null;
463
+ operation.message = "Restore queued";
464
+ operation.phase = "restore";
465
+ operation.progress = 0;
466
+ operation.status = "restoring";
467
+ activeOperationId = operation.id;
468
+ const task = (async () => {
469
+ try {
470
+ operation.restoreResult = await restoreSessionDeletionBackup({
471
+ backupDirectory: operation.backupDirectory,
472
+ codexHome: operation.codexHome,
473
+ onProgress: (update) => Object.assign(operation, update),
474
+ });
475
+ operation.message = "Recovery backup restored";
476
+ operation.progress = 100;
477
+ operation.status = "restored";
478
+ } catch (error) {
479
+ operation.canRestore = true;
480
+ operation.error = error instanceof Error ? error.message : "The recovery backup could not be restored.";
481
+ operation.message = "Restore could not be completed";
482
+ operation.status = "restore-failed";
483
+ } finally {
484
+ operation.finishedAtMs = Date.now();
485
+ if (activeOperationId === operation.id) activeOperationId = null;
486
+ }
487
+ })();
488
+ activeTasks.add(task);
489
+ task.finally(() => activeTasks.delete(task));
490
+ }
491
+ const server = createServer(async (request, response) => {
492
+ const localOrigin = getLocalRequestOrigin({
493
+ hostHeader: request.headers.host,
494
+ server,
495
+ });
496
+
497
+ if (!localOrigin) {
498
+ sendJson(response, 403, { error: "This request is not allowed." });
499
+ return;
500
+ }
501
+
502
+ let requestUrl;
503
+
504
+ try {
505
+ requestUrl = new URL(request.url || "/", localOrigin);
506
+ } catch {
507
+ sendJson(response, 403, { error: "This request is not allowed." });
508
+ return;
509
+ }
510
+
511
+ if (requestUrl.origin !== localOrigin) {
512
+ sendJson(response, 403, { error: "This request is not allowed." });
513
+ return;
514
+ }
515
+
516
+ try {
517
+ const staticAsset = request.method === "GET" ? getStaticAsset(requestUrl.pathname) : null;
518
+
519
+ if (staticAsset) {
520
+ await sendStaticAsset(response, staticAsset);
521
+ return;
522
+ }
523
+
524
+ if (request.method === "GET" && requestUrl.pathname === "/health") {
525
+ sendJson(response, 200, { status: "ok" });
526
+ return;
527
+ }
528
+
529
+ if (request.method === "GET" && requestUrl.pathname === "/api/config") {
530
+ sendJson(response, 200, { mutationToken, providers: settings.getAll() });
531
+ return;
532
+ }
533
+
534
+ const providerSettingsPrefix = "/api/settings/providers/";
535
+
536
+ if (
537
+ (request.method === "PUT" || request.method === "DELETE")
538
+ && requestUrl.pathname.startsWith(providerSettingsPrefix)
539
+ ) {
540
+ const providerId = decodeURIComponent(requestUrl.pathname.slice(providerSettingsPrefix.length));
541
+ requireMutationAuthorization({ request, requestUrl, token: mutationToken });
542
+
543
+ if (mutationInProgress || activeOperationId) {
544
+ sendJson(response, 409, { error: "Wait for the current change to finish before changing folders." });
545
+ return;
546
+ }
547
+
548
+ mutationInProgress = true;
549
+
550
+ try {
551
+ const provider = request.method === "DELETE"
552
+ ? await settings.resetProviderHome(providerId)
553
+ : await settings.setProviderHome(providerId, (await readJsonBody(request)).home);
554
+ deletionPlans.clear();
555
+ sendJson(response, 200, { provider });
556
+ } finally {
557
+ mutationInProgress = false;
558
+ }
559
+
560
+ return;
561
+ }
562
+
563
+ if (request.method === "GET" && requestUrl.pathname === "/api/compatibility") {
564
+ const diagnostic = await diagnoseStorageCompatibility({ codexHome: settings.getHome("codex") });
565
+ const currentVersions = await getInstalledProductVersions();
566
+ const versionSupport = Object.fromEntries(
567
+ Object.entries(diagnostic.builtFor).map(([product, supportedVersions]) => [
568
+ product,
569
+ classifyInstalledVersion({
570
+ installedVersion: currentVersions[product],
571
+ supportedVersions,
572
+ }),
573
+ ]),
574
+ );
575
+ sendJson(response, 200, { ...diagnostic, currentVersions, versionSupport });
576
+ return;
577
+ }
578
+
579
+ if (request.method === "GET" && requestUrl.pathname === "/api/sessions") {
580
+ const result = await listSessions({
581
+ codexHome: settings.getHome("codex"),
582
+ includeInternals: requestUrl.searchParams.get("includeInternals") === "true",
583
+ includeSupporting: requestUrl.searchParams.get("includeSupporting") === "true",
584
+ page: getPositiveInteger(requestUrl.searchParams.get("page"), 1),
585
+ pageSize: getPositiveInteger(requestUrl.searchParams.get("pageSize"), 25, 100),
586
+ search: requestUrl.searchParams.get("search"),
587
+ sort: requestUrl.searchParams.get("sort"),
588
+ });
589
+ sendJson(response, 200, {
590
+ ...result,
591
+ records: result.records.map(formatSessionForJson),
592
+ });
593
+ return;
594
+ }
595
+
596
+ if (request.method === "GET" && requestUrl.pathname.startsWith("/api/sessions/")) {
597
+ const id = decodeURIComponent(requestUrl.pathname.slice("/api/sessions/".length));
598
+ const record = await getSessionRecord({ codexHome: settings.getHome("codex"), id });
599
+
600
+ if (!record) {
601
+ sendJson(response, 404, { error: "Session not found." });
602
+ return;
603
+ }
604
+
605
+ sendJson(response, 200, { record: formatSessionForJson(record) });
606
+ return;
607
+ }
608
+
609
+ if (request.method === "POST" && requestUrl.pathname === "/api/deletion-plans") {
610
+ const body = await readJsonBody(request);
611
+ const ids = normalizeIds(body.ids);
612
+ const scope = getScope(body.scope);
613
+ const activeCodexHome = settings.getHome("codex");
614
+
615
+ if (scope === "deep") {
616
+ await assertDeepCleanupSupported({ codexHome: activeCodexHome });
617
+ }
618
+
619
+ const store = await loadDeletionStore({
620
+ codexHome: activeCodexHome,
621
+ recordIds: ids,
622
+ });
623
+ const plan = await planSessionDeletion({ recordIds: ids, store });
624
+ const preflight = await preflightSessionDeletion({ plan, store });
625
+ const id = randomBytes(18).toString("base64url");
626
+ const expiresAtMs = Date.now() + PLAN_TTL_MS;
627
+ const savedPlan = {
628
+ codexHome: activeCodexHome,
629
+ consumed: false,
630
+ expiresAtMs,
631
+ fingerprint: await fingerprintSessionDeletion({ plan, scope, store }),
632
+ id,
633
+ requestedIds: ids,
634
+ scope,
635
+ };
636
+ removeExpiredEntries(deletionPlans, PLAN_TTL_MS, MAX_SAVED_PLANS);
637
+ while (deletionPlans.size >= MAX_SAVED_PLANS) {
638
+ deletionPlans.delete(deletionPlans.keys().next().value);
639
+ }
640
+ deletionPlans.set(id, savedPlan);
641
+ sendJson(response, 200, {
642
+ plan: {
643
+ ...summarizePlan(plan, preflight),
644
+ expiresAtMs,
645
+ id,
646
+ },
647
+ scope,
648
+ warnings: preflight.activeThreadDetection === "unavailable"
649
+ ? ["The current Codex runtime cannot identify an active session. Confirm it is safe to delete the selected sessions."]
650
+ : [],
651
+ });
652
+ return;
653
+ }
654
+
655
+ if (request.method === "POST" && requestUrl.pathname === "/api/deletions") {
656
+ requireMutationAuthorization({ request, requestUrl, token: mutationToken });
657
+
658
+ if (mutationInProgress || activeOperationId) {
659
+ sendJson(response, 409, { error: "Another deletion is already in progress." });
660
+ return;
661
+ }
662
+
663
+ mutationInProgress = true;
664
+
665
+ try {
666
+ const body = await readJsonBody(request);
667
+ const savedPlan = getDeletionPlan(body.planId);
668
+ const operation = startDeletionOperation(savedPlan);
669
+ sendJson(response, 202, { operation: publicOperation(operation) });
670
+ } finally {
671
+ mutationInProgress = false;
672
+ }
673
+
674
+ return;
675
+ }
676
+
677
+ const operationRoute = /^\/api\/deletions\/([^/]+)(\/restore)?$/u.exec(requestUrl.pathname);
678
+
679
+ if (operationRoute) {
680
+ const operation = getOperation(decodeURIComponent(operationRoute[1]));
681
+ const restoreRoute = Boolean(operationRoute[2]);
682
+
683
+ if (request.method === "GET" && !restoreRoute) {
684
+ sendJson(response, 200, { operation: publicOperation(operation) });
685
+ return;
686
+ }
687
+
688
+ if (request.method === "DELETE" && !restoreRoute) {
689
+ requireMutationAuthorization({ request, requestUrl, token: mutationToken });
690
+ const cancelAccepted = operation.status === "queued" || (
691
+ operation.status === "running" && operation.canCancel
692
+ );
693
+ if (cancelAccepted) operation.cancelRequested = true;
694
+ sendJson(response, 200, {
695
+ cancelAccepted,
696
+ operation: publicOperation(operation),
697
+ });
698
+ return;
699
+ }
700
+
701
+ if (request.method === "POST" && restoreRoute) {
702
+ requireMutationAuthorization({ request, requestUrl, token: mutationToken });
703
+
704
+ if (!operation.canRestore || !operation.backupDirectory) {
705
+ throw new Error("A recovery restore is not available for this cleanup.");
706
+ }
707
+
708
+ if (mutationInProgress || activeOperationId) {
709
+ sendJson(response, 409, { error: "Wait for the current cleanup to finish before restoring." });
710
+ return;
711
+ }
712
+
713
+ mutationInProgress = true;
714
+ try {
715
+ startRestoreOperation(operation);
716
+ sendJson(response, 202, { operation: publicOperation(operation) });
717
+ } finally {
718
+ mutationInProgress = false;
719
+ }
720
+ return;
721
+ }
722
+ }
723
+
724
+ sendJson(response, 404, { error: "Route not found." });
725
+ } catch (error) {
726
+ const payload = {
727
+ error: error instanceof Error ? error.message : "Request failed.",
728
+ };
729
+ if (error?.code) payload.code = error.code;
730
+ sendJson(response, 400, payload);
731
+ }
732
+ });
733
+
734
+ await new Promise((resolve, reject) => {
735
+ server.once("error", reject);
736
+ server.listen({ host: "127.0.0.1", port }, resolve);
737
+ });
738
+
739
+ const address = server.address();
740
+
741
+ if (!address || typeof address === "string") {
742
+ throw new Error("Local server did not expose a TCP port.");
743
+ }
744
+
745
+ return {
746
+ close: async () => {
747
+ await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
748
+ await Promise.allSettled([...activeTasks]);
749
+ },
750
+ port: address.port,
751
+ token: mutationToken,
752
+ };
753
+ }