codex-snapshots 0.1.0 → 0.1.1

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 (51) hide show
  1. package/README.md +101 -6
  2. package/bin/codex-snapshot.mjs +1 -6326
  3. package/deploy/aliyun/README.md +311 -0
  4. package/deploy/aliyun/backup-share-data.sh +109 -0
  5. package/deploy/aliyun/check-ecs-status.sh +149 -0
  6. package/deploy/aliyun/codex-snapshot-share.env.example +29 -0
  7. package/deploy/aliyun/codex-snapshot-share.service +26 -0
  8. package/deploy/aliyun/configure-github-pages-api.sh +141 -0
  9. package/deploy/aliyun/configure-local-publisher.sh +197 -0
  10. package/deploy/aliyun/deploy-to-ecs.sh +669 -0
  11. package/deploy/aliyun/deploy.env.example +52 -0
  12. package/deploy/aliyun/doctor.mjs +398 -0
  13. package/deploy/aliyun/install-share-api.sh +252 -0
  14. package/deploy/aliyun/install-system-deps.sh +84 -0
  15. package/deploy/aliyun/nginx-codex-snapshots.bootstrap.conf +34 -0
  16. package/deploy/aliyun/nginx-codex-snapshots.conf +52 -0
  17. package/deploy/aliyun/preflight.mjs +321 -0
  18. package/deploy/aliyun/restore-share-data.sh +141 -0
  19. package/deploy/aliyun/verify-public-share.mjs +404 -0
  20. package/dist/cli/codex-snapshot.mjs +2654 -0
  21. package/dist/core/privacy.js +81 -0
  22. package/dist/core/snapshot.js +1 -0
  23. package/dist/renderers/markdown.mjs +81 -0
  24. package/dist/renderers/transcript.js +195 -0
  25. package/dist/server/http.js +10 -0
  26. package/dist/server/local-security.js +66 -0
  27. package/dist/server/local-viewer-app.mjs +1670 -0
  28. package/dist/server/local-viewer.mjs +210 -0
  29. package/dist/server/share-api.mjs +1149 -0
  30. package/dist/server/share-store.js +136 -0
  31. package/dist/shared/sanitize.js +126 -0
  32. package/dist/shared/transcript.js +1 -0
  33. package/dist/sources/index.mjs +2 -0
  34. package/dist/sources/local-history.mjs +2221 -0
  35. package/package.json +42 -14
  36. package/scripts/build-site.mjs +71 -0
  37. package/scripts/launch-agent.mjs +19 -227
  38. package/scripts/serve-site.mjs +2 -2
  39. package/scripts/test-aliyun-deploy-config.sh +230 -0
  40. package/scripts/test-share-api.mjs +967 -0
  41. package/scripts/test-site-config.mjs +100 -0
  42. package/scripts/test-static-site.mjs +403 -0
  43. package/scripts/write-site-config.mjs +161 -0
  44. package/server/share-api.mjs +1 -771
  45. package/site/assets/config.js +3 -0
  46. package/site/assets/share.js +43 -106
  47. package/site/assets/site.css +3 -605
  48. package/site/assets/site.js +15 -92
  49. package/site/favicon.svg +7 -0
  50. package/site/index.html +3 -83
  51. package/site/share/index.html +3 -8
@@ -0,0 +1,2654 @@
1
+ #!/usr/bin/env node
2
+ // @ts-nocheck
3
+ import { execFile } from "node:child_process";
4
+ import { existsSync, readFileSync } from "node:fs";
5
+ import { createHash } from "node:crypto";
6
+ import { appendFile, mkdir, readFile, rm, unlink, writeFile } from "node:fs/promises";
7
+ import http from "node:http";
8
+ import os from "node:os";
9
+ import path from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+ import { promisify } from "node:util";
12
+ import { sanitizeSnapshotHtml as sanitizeSnapshotTurnHtml, stripAppDirectives as stripCodexAppDirectives, } from "../shared/sanitize.js";
13
+ import { renderTranscriptHtml } from "../renderers/transcript.js";
14
+ import { send, sendJson } from "../server/http.js";
15
+ import { serveLocalViewer } from "../server/local-viewer.mjs";
16
+ import { listSessions, loadSnapshot } from "../sources/index.mjs";
17
+ const packageRoot = findPackageRoot(path.dirname(fileURLToPath(import.meta.url)));
18
+ const cliPath = fileURLToPath(import.meta.url);
19
+ const VERSION = readPackageVersion(packageRoot);
20
+ const DEFAULT_LIMIT = 40;
21
+ const DEFAULT_SERVER_LIMIT = 80;
22
+ const DEFAULT_TRAE_RECORDER_PORT = 4732;
23
+ const DEFAULT_VIEWER_PORT = 4321;
24
+ const MAX_TRAE_CAPTURE_POST_BYTES = 64 * 1024 * 1024;
25
+ const DEFAULT_SNAPSHOT_SHARE_API_URL = "http://127.0.0.1:8787";
26
+ const DEFAULT_SNAPSHOT_SHARE_SITE_URL = "http://127.0.0.1:8787";
27
+ const DEFAULT_DAEMON_LABEL = "com.codex-snapshots.viewer";
28
+ const SNAPSHOT_LOGO_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Codex Snapshots"><rect width="64" height="64" rx="14" fill="#17202a"/><path d="M19 16h26a3 3 0 0 1 3 3v26a3 3 0 0 1-3 3H19a3 3 0 0 1-3-3V19a3 3 0 0 1 3-3Z" fill="none" stroke="#eef9f6" stroke-width="4"/><path d="M23 22h11M22 23v11M41 42H30M42 41V30" fill="none" stroke="#7dd3c7" stroke-width="4" stroke-linecap="round"/><circle cx="32" cy="32" r="9" fill="#f2cc60"/><path d="M27 32h10M32 27v10" stroke="#17202a" stroke-width="3" stroke-linecap="round"/></svg>`;
29
+ const execFileAsync = promisify(execFile);
30
+ let defaultShareConfigCache;
31
+ function findPackageRoot(startDir) {
32
+ let current = startDir;
33
+ for (let depth = 0; depth < 8; depth += 1) {
34
+ try {
35
+ const pkg = JSON.parse(readFileSync(path.join(current, "package.json"), "utf8"));
36
+ if (pkg?.name === "codex-snapshots") {
37
+ return current;
38
+ }
39
+ }
40
+ catch { }
41
+ const parent = path.dirname(current);
42
+ if (parent === current) {
43
+ break;
44
+ }
45
+ current = parent;
46
+ }
47
+ return path.resolve(startDir, "..");
48
+ }
49
+ function readPackageVersion(root) {
50
+ try {
51
+ const pkg = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8"));
52
+ return typeof pkg.version === "string" && pkg.version ? pkg.version : "0.0.0";
53
+ }
54
+ catch {
55
+ return "0.0.0";
56
+ }
57
+ }
58
+ main().catch((error) => {
59
+ console.error(error instanceof Error ? error.message : String(error));
60
+ process.exitCode = 1;
61
+ });
62
+ async function main() {
63
+ const parsed = parseArgs(process.argv.slice(2));
64
+ if (parsed.command === "daemon" && parsed.options.help) {
65
+ printDaemonHelp();
66
+ return;
67
+ }
68
+ if (parsed.options.help || parsed.command === "help" || !parsed.command) {
69
+ printHelp();
70
+ return;
71
+ }
72
+ const codexHome = path.resolve(parsed.options.codexHome || process.env.CODEX_HOME || path.join(os.homedir(), ".codex"));
73
+ const claudeHome = path.resolve(parsed.options.claudeHome || process.env.CLAUDE_HOME || path.join(os.homedir(), ".claude"));
74
+ const traeHome = path.resolve(parsed.options.traeHome || process.env.TRAE_HOME || path.join(os.homedir(), ".trae-cn"));
75
+ const traeAppHome = path.resolve(parsed.options.traeAppHome || process.env.TRAE_APP_HOME || path.join(os.homedir(), "Library", "Application Support", "Trae CN"));
76
+ const traeRecordingsDir = path.resolve(parsed.options.traeRecordingsDir || process.env.TRAE_RECORDINGS_DIR || path.join(os.homedir(), ".codex-snapshot", "trae-recordings"));
77
+ if (parsed.command === "list") {
78
+ const sessions = await listSessions({
79
+ codexHome,
80
+ claudeHome,
81
+ traeHome,
82
+ traeAppHome,
83
+ traeRecordingsDir,
84
+ limit: parsed.options.limit || DEFAULT_LIMIT,
85
+ cwd: parsed.options.cwd,
86
+ includeArchived: parsed.options.includeArchived,
87
+ source: parsed.options.source,
88
+ });
89
+ if (parsed.options.json) {
90
+ console.log(JSON.stringify(sessions, null, 2));
91
+ }
92
+ else {
93
+ printSessionList(sessions);
94
+ }
95
+ return;
96
+ }
97
+ if (parsed.command === "preview") {
98
+ const ref = parsed.positionals[0];
99
+ if (!ref) {
100
+ throw new Error("preview requires a session id or JSONL path");
101
+ }
102
+ const snapshot = await loadSnapshot(ref, {
103
+ codexHome,
104
+ claudeHome,
105
+ traeHome,
106
+ traeAppHome,
107
+ traeRecordingsDir,
108
+ includeTools: parsed.options.includeTools,
109
+ includeToolOutput: parsed.options.includeToolOutput,
110
+ redact: !parsed.options.noRedact,
111
+ });
112
+ if (parsed.options.json) {
113
+ console.log(JSON.stringify(snapshot, null, 2));
114
+ }
115
+ else {
116
+ console.log(renderTextPreview(snapshot));
117
+ }
118
+ return;
119
+ }
120
+ if (parsed.command === "export") {
121
+ const ref = parsed.positionals[0];
122
+ if (!ref) {
123
+ throw new Error("export requires a session id or JSONL path");
124
+ }
125
+ const format = parsed.options.format || (parsed.options.md ? "md" : "html");
126
+ const snapshot = await loadSnapshot(ref, {
127
+ codexHome,
128
+ claudeHome,
129
+ traeHome,
130
+ traeAppHome,
131
+ traeRecordingsDir,
132
+ includeTools: parsed.options.includeTools,
133
+ includeToolOutput: parsed.options.includeToolOutput,
134
+ redact: !parsed.options.noRedact,
135
+ });
136
+ const output = format === "md" ? renderMarkdown(snapshot) : renderHtml(snapshot);
137
+ if (parsed.options.output) {
138
+ await mkdir(path.dirname(path.resolve(parsed.options.output)), { recursive: true });
139
+ await writeFile(parsed.options.output, output, "utf8");
140
+ console.log(path.resolve(parsed.options.output));
141
+ }
142
+ else {
143
+ console.log(output);
144
+ }
145
+ return;
146
+ }
147
+ if (parsed.command === "publish") {
148
+ const ref = parsed.positionals[0];
149
+ if (!ref) {
150
+ throw new Error("publish requires a session id or JSONL path");
151
+ }
152
+ if (parsed.options.noRedact && !parsed.options.allowUnredacted) {
153
+ throw new Error("publish refuses --no-redact unless --allow-unredacted is also set");
154
+ }
155
+ const snapshot = await loadSnapshot(ref, {
156
+ codexHome,
157
+ claudeHome,
158
+ traeHome,
159
+ traeAppHome,
160
+ traeRecordingsDir,
161
+ includeTools: parsed.options.includeTools,
162
+ includeToolOutput: parsed.options.includeToolOutput,
163
+ redact: !parsed.options.noRedact,
164
+ });
165
+ applySafetyChecksOption(snapshot, Boolean(parsed.options.withSafety));
166
+ const result = await publishSnapshot(snapshot, {
167
+ apiUrl: parsed.options.apiUrl,
168
+ token: parsed.options.shareToken,
169
+ siteUrl: parsed.options.siteUrl,
170
+ expiresInDays: parsed.options.expiresInDays,
171
+ });
172
+ console.log(`Published ${snapshot.engineLabel || "Codex"} snapshot: ${snapshot.title}`);
173
+ console.log(`Share id: ${result.id}`);
174
+ console.log(`URL: ${result.url}`);
175
+ return;
176
+ }
177
+ if (parsed.command === "daemon") {
178
+ await runDaemonCommand(parsed.positionals[0] || "status", parsed.options);
179
+ return;
180
+ }
181
+ if (parsed.command === "serve") {
182
+ const port = parsed.options.port || DEFAULT_VIEWER_PORT;
183
+ const host = parsed.options.host || "127.0.0.1";
184
+ await serve({ codexHome, claudeHome, traeHome, traeAppHome, traeRecordingsDir, host, port });
185
+ return;
186
+ }
187
+ if (parsed.command === "record-trae") {
188
+ const port = parsed.options.port || DEFAULT_TRAE_RECORDER_PORT;
189
+ const host = parsed.options.host || "127.0.0.1";
190
+ await serveTraeRecorder({
191
+ host,
192
+ port,
193
+ traeRecordingsDir,
194
+ recordSensitiveContext: parsed.options.recordSensitiveContext,
195
+ });
196
+ return;
197
+ }
198
+ throw new Error(`unknown command: ${parsed.command}`);
199
+ }
200
+ function parseArgs(args) {
201
+ const options = {
202
+ codexHome: "",
203
+ cwd: "",
204
+ format: "",
205
+ help: false,
206
+ host: "",
207
+ includeArchived: true,
208
+ includeToolOutput: false,
209
+ includeTools: false,
210
+ json: false,
211
+ label: "",
212
+ limit: 0,
213
+ md: false,
214
+ noRedact: false,
215
+ output: "",
216
+ port: 0,
217
+ apiUrl: "",
218
+ siteUrl: "",
219
+ shareToken: "",
220
+ expiresInDays: 0,
221
+ allowUnredacted: false,
222
+ withSafety: false,
223
+ source: "codex",
224
+ claudeHome: "",
225
+ traeAppHome: "",
226
+ traeHome: "",
227
+ traeRecordingsDir: "",
228
+ recordSensitiveContext: false,
229
+ };
230
+ const positionals = [];
231
+ for (let index = 0; index < args.length; index += 1) {
232
+ const arg = args[index];
233
+ if (arg === "-h" || arg === "--help") {
234
+ options.help = true;
235
+ continue;
236
+ }
237
+ if (arg === "--version") {
238
+ console.log(VERSION);
239
+ process.exit(0);
240
+ }
241
+ if (arg === "--json") {
242
+ options.json = true;
243
+ continue;
244
+ }
245
+ if (arg === "--html") {
246
+ options.format = "html";
247
+ continue;
248
+ }
249
+ if (arg === "--md" || arg === "--markdown") {
250
+ options.format = "md";
251
+ options.md = true;
252
+ continue;
253
+ }
254
+ if (arg === "--include-tools") {
255
+ options.includeTools = true;
256
+ continue;
257
+ }
258
+ if (arg === "--include-tool-output") {
259
+ options.includeToolOutput = true;
260
+ options.includeTools = true;
261
+ continue;
262
+ }
263
+ if (arg === "--no-redact") {
264
+ options.noRedact = true;
265
+ continue;
266
+ }
267
+ if (arg === "--record-sensitive-context") {
268
+ options.recordSensitiveContext = true;
269
+ continue;
270
+ }
271
+ if (arg === "--allow-unredacted") {
272
+ options.allowUnredacted = true;
273
+ continue;
274
+ }
275
+ if (arg === "--with-safety") {
276
+ options.withSafety = true;
277
+ continue;
278
+ }
279
+ if (arg === "--live-only") {
280
+ options.includeArchived = false;
281
+ continue;
282
+ }
283
+ if (arg === "--codex-home" || arg === "--claude-home" || arg === "--trae-home" || arg === "--trae-app-home" || arg === "--trae-recordings-dir" || arg === "--cwd" || arg === "--limit" || arg === "--output" || arg === "-o" || arg === "--port" || arg === "--host" || arg === "--source" || arg === "--api-url" || arg === "--site-url" || arg === "--share-token" || arg === "--expires-in-days" || arg === "--label") {
284
+ const value = args[index + 1];
285
+ if (!value) {
286
+ throw new Error(`${arg} requires a value`);
287
+ }
288
+ if (arg === "--codex-home") {
289
+ options.codexHome = value;
290
+ }
291
+ else if (arg === "--claude-home") {
292
+ options.claudeHome = value;
293
+ }
294
+ else if (arg === "--trae-home") {
295
+ options.traeHome = value;
296
+ }
297
+ else if (arg === "--trae-app-home") {
298
+ options.traeAppHome = value;
299
+ }
300
+ else if (arg === "--trae-recordings-dir") {
301
+ options.traeRecordingsDir = value;
302
+ }
303
+ else if (arg === "--cwd") {
304
+ options.cwd = value;
305
+ }
306
+ else if (arg === "--limit") {
307
+ options.limit = readPositiveInteger(value, "--limit");
308
+ }
309
+ else if (arg === "--label") {
310
+ options.label = value;
311
+ }
312
+ else if (arg === "--output" || arg === "-o") {
313
+ options.output = value;
314
+ }
315
+ else if (arg === "--port") {
316
+ options.port = readPositiveInteger(value, "--port");
317
+ }
318
+ else if (arg === "--host") {
319
+ options.host = value;
320
+ }
321
+ else if (arg === "--source") {
322
+ if (!["codex", "claude", "trae", "all"].includes(value)) {
323
+ throw new Error("--source must be codex, claude, trae, or all");
324
+ }
325
+ options.source = value;
326
+ }
327
+ else if (arg === "--api-url") {
328
+ options.apiUrl = value;
329
+ }
330
+ else if (arg === "--site-url") {
331
+ options.siteUrl = value;
332
+ }
333
+ else if (arg === "--share-token") {
334
+ options.shareToken = value;
335
+ }
336
+ else if (arg === "--expires-in-days") {
337
+ options.expiresInDays = readPositiveInteger(value, "--expires-in-days");
338
+ }
339
+ index += 1;
340
+ continue;
341
+ }
342
+ if (arg.startsWith("-")) {
343
+ throw new Error(`unknown option: ${arg}`);
344
+ }
345
+ positionals.push(arg);
346
+ }
347
+ return {
348
+ command: positionals[0] || "",
349
+ options,
350
+ positionals: positionals.slice(1),
351
+ };
352
+ }
353
+ function readPositiveInteger(value, label) {
354
+ const parsed = Number.parseInt(value, 10);
355
+ if (!Number.isFinite(parsed) || parsed <= 0) {
356
+ throw new Error(`${label} must be a positive integer`);
357
+ }
358
+ return parsed;
359
+ }
360
+ function readNonNegativeInteger(value, label) {
361
+ const parsed = Number.parseInt(value, 10);
362
+ if (!Number.isFinite(parsed) || parsed < 0) {
363
+ throw new Error(`${label} must be a non-negative integer`);
364
+ }
365
+ return parsed;
366
+ }
367
+ async function runDaemonCommand(action, options) {
368
+ if (action === "help" || options.help) {
369
+ printDaemonHelp();
370
+ return;
371
+ }
372
+ if (action === "install") {
373
+ await installDaemon(options);
374
+ return;
375
+ }
376
+ if (action === "uninstall") {
377
+ await uninstallDaemon(options);
378
+ return;
379
+ }
380
+ if (action === "status") {
381
+ await printDaemonStatus(options);
382
+ return;
383
+ }
384
+ if (action === "logs") {
385
+ await printDaemonLogs(options);
386
+ return;
387
+ }
388
+ throw new Error(`unknown daemon command: ${action}`);
389
+ }
390
+ async function installDaemon(options) {
391
+ assertMacosDaemonSupported();
392
+ const config = resolveDaemonConfig(options);
393
+ await mkdir(config.launchAgentsDir, { recursive: true });
394
+ await mkdir(config.logsDir, { recursive: true });
395
+ await writeFile(config.plistPath, renderDaemonPlist(config), "utf8");
396
+ await bootoutDaemonIfLoaded(config);
397
+ await execLaunchctl(["bootstrap", guiDomain(), config.plistPath]);
398
+ await execLaunchctl(["kickstart", "-k", `${guiDomain()}/${config.label}`]);
399
+ console.log(`Installed ${config.label}`);
400
+ console.log(`Plist: ${config.plistPath}`);
401
+ console.log(`Logs: ${config.stdoutPath}`);
402
+ console.log(`Command: ${formatDaemonCommand(config)}`);
403
+ console.log(`Preview: http://${config.host}:${config.port}/`);
404
+ }
405
+ async function uninstallDaemon(options) {
406
+ assertMacosDaemonSupported();
407
+ const config = resolveDaemonConfig(options);
408
+ await bootoutDaemonIfLoaded(config);
409
+ await rm(config.plistPath, { force: true });
410
+ console.log(`Uninstalled ${config.label}`);
411
+ }
412
+ async function printDaemonStatus(options) {
413
+ assertMacosDaemonSupported();
414
+ const config = resolveDaemonConfig(options);
415
+ if (!existsSync(config.plistPath)) {
416
+ console.log(`Not installed: ${config.plistPath}`);
417
+ return;
418
+ }
419
+ try {
420
+ const { stdout } = await execLaunchctl(["print", `${guiDomain()}/${config.label}`]);
421
+ const state = stdout.match(/state = ([^\n]+)/)?.[1]?.trim() || "unknown";
422
+ const pid = stdout.match(/pid = (\d+)/)?.[1] || "";
423
+ console.log(`${config.label}: ${state}${pid ? `, pid=${pid}` : ""}`);
424
+ console.log(`Plist: ${config.plistPath}`);
425
+ console.log(`Preview: http://${config.host}:${config.port}/`);
426
+ }
427
+ catch (error) {
428
+ console.log(`${config.label}: installed but not loaded`);
429
+ console.log(`Plist: ${config.plistPath}`);
430
+ if (error instanceof Error && error.message) {
431
+ console.log(error.message);
432
+ }
433
+ }
434
+ }
435
+ async function printDaemonLogs(options) {
436
+ const config = resolveDaemonConfig(options);
437
+ console.log(`==> ${config.stdoutPath}`);
438
+ console.log(await tailFile(config.stdoutPath));
439
+ console.log(`==> ${config.stderrPath}`);
440
+ console.log(await tailFile(config.stderrPath));
441
+ }
442
+ async function bootoutDaemonIfLoaded(config) {
443
+ try {
444
+ await execLaunchctl(["bootout", guiDomain(), config.plistPath]);
445
+ }
446
+ catch { }
447
+ try {
448
+ await execLaunchctl(["bootout", `${guiDomain()}/${config.label}`]);
449
+ }
450
+ catch { }
451
+ }
452
+ function resolveDaemonConfig(options) {
453
+ const homeDir = os.homedir();
454
+ const label = options.label || process.env.SNAPSHOT_LAUNCH_AGENT_LABEL || DEFAULT_DAEMON_LABEL;
455
+ const launchAgentsDir = path.join(homeDir, "Library", "LaunchAgents");
456
+ const logsDir = path.join(homeDir, "Library", "Logs", "codex-snapshots");
457
+ const nodePath = process.env.SNAPSHOT_DAEMON_NODE || process.execPath;
458
+ const daemonCliPath = process.env.SNAPSHOT_DAEMON_CLI || cliPath;
459
+ const host = options.host || process.env.SNAPSHOT_DAEMON_HOST || "127.0.0.1";
460
+ const port = options.port || readOptionalPositiveInteger(process.env.SNAPSHOT_DAEMON_PORT, "SNAPSHOT_DAEMON_PORT") || DEFAULT_VIEWER_PORT;
461
+ const apiUrl = options.apiUrl || process.env.SNAPSHOT_SHARE_API_URL || DEFAULT_SNAPSHOT_SHARE_API_URL;
462
+ const siteUrl = options.siteUrl || process.env.SNAPSHOT_SHARE_SITE_URL || DEFAULT_SNAPSHOT_SHARE_SITE_URL;
463
+ const stdoutPath = path.join(logsDir, "codex-snapshot.out.log");
464
+ const stderrPath = path.join(logsDir, "codex-snapshot.err.log");
465
+ const pathEntries = [
466
+ path.dirname(nodePath),
467
+ path.join(packageRoot, "node_modules", ".bin"),
468
+ "/opt/homebrew/bin",
469
+ "/usr/local/bin",
470
+ "/usr/bin",
471
+ "/bin",
472
+ "/usr/sbin",
473
+ "/sbin",
474
+ ];
475
+ return {
476
+ apiUrl,
477
+ cliPath: daemonCliPath,
478
+ envPath: process.env.SNAPSHOT_DAEMON_PATH || Array.from(new Set(pathEntries.filter(Boolean))).join(":"),
479
+ host,
480
+ label,
481
+ launchAgentsDir,
482
+ logsDir,
483
+ nodePath,
484
+ plistPath: path.join(launchAgentsDir, `${label}.plist`),
485
+ port,
486
+ siteUrl,
487
+ stderrPath,
488
+ stdoutPath,
489
+ viewerAllowedOrigins: process.env.SNAPSHOT_VIEWER_ALLOWED_ORIGINS || "http://127.0.0.1:3000,http://localhost:3000",
490
+ };
491
+ }
492
+ function renderDaemonPlist(config) {
493
+ const env = {
494
+ PATH: config.envPath,
495
+ SNAPSHOT_SHARE_API_URL: config.apiUrl,
496
+ SNAPSHOT_SHARE_SITE_URL: config.siteUrl,
497
+ SNAPSHOT_VIEWER_ALLOWED_ORIGINS: config.viewerAllowedOrigins,
498
+ };
499
+ return `<?xml version="1.0" encoding="UTF-8"?>
500
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
501
+ <plist version="1.0">
502
+ <dict>
503
+ <key>Label</key>
504
+ <string>${xmlEscape(config.label)}</string>
505
+ <key>ProgramArguments</key>
506
+ <array>
507
+ <string>${xmlEscape(config.nodePath)}</string>
508
+ <string>${xmlEscape(config.cliPath)}</string>
509
+ <string>serve</string>
510
+ <string>--host</string>
511
+ <string>${xmlEscape(config.host)}</string>
512
+ <string>--port</string>
513
+ <string>${xmlEscape(config.port)}</string>
514
+ </array>
515
+ <key>WorkingDirectory</key>
516
+ <string>${xmlEscape(packageRoot)}</string>
517
+ <key>EnvironmentVariables</key>
518
+ <dict>
519
+ ${Object.entries(env)
520
+ .map(([key, value]) => ` <key>${xmlEscape(key)}</key>\n <string>${xmlEscape(value)}</string>`)
521
+ .join("\n")}
522
+ </dict>
523
+ <key>RunAtLoad</key>
524
+ <true/>
525
+ <key>KeepAlive</key>
526
+ <true/>
527
+ <key>ThrottleInterval</key>
528
+ <integer>10</integer>
529
+ <key>ProcessType</key>
530
+ <string>Background</string>
531
+ <key>StandardOutPath</key>
532
+ <string>${xmlEscape(config.stdoutPath)}</string>
533
+ <key>StandardErrorPath</key>
534
+ <string>${xmlEscape(config.stderrPath)}</string>
535
+ </dict>
536
+ </plist>
537
+ `;
538
+ }
539
+ function assertMacosDaemonSupported() {
540
+ if (process.platform !== "darwin") {
541
+ throw new Error("macOS LaunchAgent commands are only supported on macOS.");
542
+ }
543
+ }
544
+ function guiDomain() {
545
+ const uid = process.getuid?.() ?? Number.parseInt(process.env.UID || "", 10);
546
+ if (!Number.isFinite(uid)) {
547
+ throw new Error("Cannot determine current macOS user id.");
548
+ }
549
+ return `gui/${uid}`;
550
+ }
551
+ async function execLaunchctl(args) {
552
+ return execFileAsync("/bin/launchctl", args, {
553
+ cwd: packageRoot,
554
+ maxBuffer: 1024 * 1024,
555
+ });
556
+ }
557
+ async function tailFile(filePath, lines = 80) {
558
+ try {
559
+ const text = await readFile(filePath, "utf8");
560
+ return text.split(/\r?\n/).slice(-lines).join("\n").trimEnd() || "(empty)";
561
+ }
562
+ catch {
563
+ return "(missing)";
564
+ }
565
+ }
566
+ function formatDaemonCommand(config) {
567
+ return `${shellQuote(config.nodePath)} ${shellQuote(config.cliPath)} serve --host ${shellQuote(config.host)} --port ${shellQuote(config.port)}`;
568
+ }
569
+ function readOptionalPositiveInteger(value, label) {
570
+ return value ? readPositiveInteger(value, label) : 0;
571
+ }
572
+ function shellQuote(value) {
573
+ return `'${String(value).replace(/'/g, "'\\''")}'`;
574
+ }
575
+ function xmlEscape(value) {
576
+ return String(value)
577
+ .replace(/&/g, "&amp;")
578
+ .replace(/</g, "&lt;")
579
+ .replace(/>/g, "&gt;")
580
+ .replace(/"/g, "&quot;")
581
+ .replace(/'/g, "&apos;");
582
+ }
583
+ async function publishSnapshot(snapshot, { apiUrl, token, siteUrl, expiresInDays, shareId }) {
584
+ const requestPayload = createShareRequestPayload(snapshot, { apiUrl, siteUrl, expiresInDays, shareId });
585
+ const shareToken = resolveShareToken(token);
586
+ if (!shareToken) {
587
+ throw new Error("Missing share API token. Set SNAPSHOT_SHARE_TOKEN, pass --share-token, or create ~/.codex-snapshots-agent.json.");
588
+ }
589
+ let response;
590
+ try {
591
+ response = await fetch(`${requestPayload.apiUrl}/api/snapshots`, {
592
+ method: "POST",
593
+ headers: {
594
+ Authorization: `Bearer ${shareToken}`,
595
+ "Content-Type": "application/json",
596
+ "User-Agent": `codex-snapshot/${VERSION}`,
597
+ },
598
+ body: JSON.stringify(requestPayload.body),
599
+ });
600
+ }
601
+ catch (error) {
602
+ throw new Error(formatShareApiNetworkError(error, requestPayload.apiUrl));
603
+ }
604
+ const text = await response.text();
605
+ let payload;
606
+ try {
607
+ payload = JSON.parse(text);
608
+ }
609
+ catch {
610
+ payload = { error: text };
611
+ }
612
+ if (!response.ok) {
613
+ throw new Error(payload?.error || `Publish failed with HTTP ${response.status}`);
614
+ }
615
+ if (!payload?.id || !payload?.url) {
616
+ throw new Error("Publish response did not include a share id and URL");
617
+ }
618
+ return payload;
619
+ }
620
+ function createShareRequestPayload(snapshot, { apiUrl, siteUrl, expiresInDays, shareId }) {
621
+ const normalizedApiUrl = resolveShareApiUrl(apiUrl);
622
+ const normalizedSiteUrl = resolveShareSiteUrl(siteUrl);
623
+ if (!normalizedApiUrl) {
624
+ throw new Error("Missing share API URL. Set SNAPSHOT_SHARE_API_URL or pass --api-url.");
625
+ }
626
+ return {
627
+ apiUrl: normalizedApiUrl,
628
+ body: {
629
+ snapshot: prepareSnapshotForCloud(snapshot),
630
+ siteUrl: normalizedSiteUrl,
631
+ apiUrl: normalizedApiUrl,
632
+ expiresInDays: expiresInDays || undefined,
633
+ shareId: shareId || undefined,
634
+ },
635
+ };
636
+ }
637
+ function formatShareApiNetworkError(error, apiUrl) {
638
+ const reason = error?.cause?.code || error?.cause?.message || error?.message || String(error);
639
+ return `Could not connect to share API at ${apiUrl}: ${reason}. Start codex-snapshot-share or set SNAPSHOT_SHARE_API_URL to the running share API.`;
640
+ }
641
+ function resolveShareApiUrl(apiUrl) {
642
+ const config = readDefaultShareConfig();
643
+ return normalizeUrl(apiUrl ||
644
+ process.env.SNAPSHOT_SHARE_API_URL ||
645
+ process.env.TOKEN_BOARD_API_URL ||
646
+ process.env.NEXT_PUBLIC_TOKEN_BOARD_API_URL ||
647
+ process.env.CODEX_SNAPSHOTS_SHARE_API_URL ||
648
+ config.apiUrl ||
649
+ DEFAULT_SNAPSHOT_SHARE_API_URL);
650
+ }
651
+ function resolveShareToken(token) {
652
+ const config = readDefaultShareConfig();
653
+ return (token ||
654
+ process.env.SNAPSHOT_SHARE_TOKEN ||
655
+ process.env.CODEX_SNAPSHOTS_SHARE_TOKEN ||
656
+ process.env.TOKEN_BOARD_AGENT_TOKEN ||
657
+ process.env.TOKEN_BOARD_UPLOAD_TOKEN ||
658
+ config.token ||
659
+ "");
660
+ }
661
+ function resolveShareSiteUrl(siteUrl) {
662
+ const config = readDefaultShareConfig();
663
+ return normalizeUrl(siteUrl || process.env.SNAPSHOT_SHARE_SITE_URL || config.siteUrl || DEFAULT_SNAPSHOT_SHARE_SITE_URL);
664
+ }
665
+ function browserShareConfig() {
666
+ return {
667
+ apiUrl: resolveShareApiUrl(""),
668
+ siteUrl: resolveShareSiteUrl(""),
669
+ };
670
+ }
671
+ async function publishAllSnapshots({ codexHome, claudeHome, traeHome, traeAppHome, traeRecordingsDir, cwd, includeArchived, source, completeOnly, limit, includeTools, includeToolOutput, safety, }) {
672
+ const sessions = await listSessions({
673
+ codexHome,
674
+ claudeHome,
675
+ traeHome,
676
+ traeAppHome,
677
+ traeRecordingsDir,
678
+ limit,
679
+ cwd,
680
+ includeArchived,
681
+ source,
682
+ completeOnly,
683
+ });
684
+ const results = [];
685
+ const failures = [];
686
+ for (const session of sessions) {
687
+ const ref = session.ref || session.id;
688
+ if (!ref) {
689
+ failures.push({
690
+ id: "",
691
+ title: session.title || "Untitled session",
692
+ error: "missing session ref",
693
+ });
694
+ continue;
695
+ }
696
+ try {
697
+ const snapshot = await loadSnapshot(ref, {
698
+ codexHome,
699
+ claudeHome,
700
+ traeHome,
701
+ traeAppHome,
702
+ traeRecordingsDir,
703
+ includeTools,
704
+ includeToolOutput,
705
+ redact: true,
706
+ });
707
+ applySafetyChecksOption(snapshot, safety);
708
+ const result = await publishSnapshot(snapshot, {
709
+ apiUrl: "",
710
+ token: "",
711
+ siteUrl: "",
712
+ expiresInDays: 0,
713
+ shareId: stableSnapshotShareId(snapshot),
714
+ });
715
+ results.push({
716
+ id: snapshot.ref || ref,
717
+ title: snapshot.title || session.title || ref,
718
+ url: result.url,
719
+ });
720
+ }
721
+ catch (error) {
722
+ failures.push({
723
+ id: ref,
724
+ title: session.title || ref,
725
+ error: error instanceof Error ? error.message : String(error),
726
+ });
727
+ }
728
+ }
729
+ return {
730
+ total: sessions.length,
731
+ published: results.length,
732
+ failed: failures.length,
733
+ firstUrl: results[0]?.url || "",
734
+ sampleUrls: results.slice(0, 5),
735
+ failures: failures.slice(0, 20),
736
+ };
737
+ }
738
+ function stableSnapshotShareId(snapshot) {
739
+ const source = [
740
+ snapshot.engine || "codex",
741
+ snapshot.ref || snapshot.id || snapshot.title || "",
742
+ ].join(":");
743
+ const digest = createHash("sha256").update(source).digest("base64url").slice(0, 32);
744
+ return `snap_${digest}`;
745
+ }
746
+ function readDefaultShareConfig() {
747
+ if (defaultShareConfigCache) {
748
+ return defaultShareConfigCache;
749
+ }
750
+ defaultShareConfigCache = {
751
+ apiUrl: "",
752
+ siteUrl: "",
753
+ token: "",
754
+ };
755
+ const filePaths = [
756
+ process.env.CODEX_SNAPSHOTS_AGENT_FILE,
757
+ process.env.SNAPSHOT_SHARE_TOKEN_FILE,
758
+ process.env.TOKEN_BOARD_AGENT_FILE,
759
+ path.join(os.homedir(), ".codex-snapshots-agent.json"),
760
+ path.join(os.homedir(), ".token-board-agent.json"),
761
+ ].filter(Boolean);
762
+ for (const filePath of filePaths) {
763
+ try {
764
+ const payload = JSON.parse(readFileSync(filePath, "utf8"));
765
+ const config = {
766
+ apiUrl: firstNonEmptyString(payload.snapshotShareApiUrl, payload.snapshotSharePublicApiUrl, payload.shareApiUrl, payload.publicApiUrl, payload.apiUrl),
767
+ siteUrl: firstNonEmptyString(payload.snapshotShareSiteUrl, payload.shareSiteUrl, payload.siteUrl),
768
+ token: firstNonEmptyString(payload.snapshotShareToken, payload.agentToken, payload.token, payload.uploadToken),
769
+ };
770
+ if (config.apiUrl || config.siteUrl || config.token) {
771
+ defaultShareConfigCache = config;
772
+ return defaultShareConfigCache;
773
+ }
774
+ }
775
+ catch { }
776
+ }
777
+ return defaultShareConfigCache;
778
+ }
779
+ function firstNonEmptyString(...values) {
780
+ for (const value of values) {
781
+ if (typeof value === "string" && value.trim()) {
782
+ return value.trim();
783
+ }
784
+ }
785
+ return "";
786
+ }
787
+ function prepareSnapshotForCloud(snapshot) {
788
+ const copy = JSON.parse(JSON.stringify(snapshot));
789
+ delete copy.cwd;
790
+ delete copy.filePath;
791
+ delete copy.displayFilePath;
792
+ copy.cloudShared = true;
793
+ copy.cloudSharedAt = new Date().toISOString();
794
+ return removePrivatePathFields(copy);
795
+ }
796
+ function removePrivatePathFields(value) {
797
+ if (!value || typeof value !== "object") {
798
+ return value;
799
+ }
800
+ if (Array.isArray(value)) {
801
+ return value.map(removePrivatePathFields);
802
+ }
803
+ for (const key of ["cwd", "filePath", "displayFilePath"]) {
804
+ delete value[key];
805
+ }
806
+ for (const [key, item] of Object.entries(value)) {
807
+ if (key === "images") {
808
+ continue;
809
+ }
810
+ value[key] = removePrivatePathFields(item);
811
+ }
812
+ return value;
813
+ }
814
+ function normalizeUrl(value) {
815
+ const trimmed = String(value || "").trim().replace(/\/+$/, "");
816
+ if (!trimmed) {
817
+ return "";
818
+ }
819
+ try {
820
+ const url = new URL(trimmed);
821
+ return url.protocol === "http:" || url.protocol === "https:" ? url.toString().replace(/\/+$/, "") : "";
822
+ }
823
+ catch {
824
+ return "";
825
+ }
826
+ }
827
+ function printSessionList(sessions) {
828
+ if (!sessions.length) {
829
+ console.log("No Codex sessions found.");
830
+ return;
831
+ }
832
+ for (const session of sessions) {
833
+ const size = formatBytes(session.size).padStart(8, " ");
834
+ const date = formatDate(session.mtime).padEnd(16, " ");
835
+ const risk = session.riskCount ? ` risks:${session.riskCount}` : "";
836
+ const source = (session.engineLabel || "Codex").padEnd(11, " ");
837
+ console.log(`${source} ${session.id.slice(0, 8)} ${date} ${size} ${session.title}${risk}`);
838
+ if (session.displayCwd || session.cwd) {
839
+ console.log(` ${session.displayCwd || session.cwd}`);
840
+ }
841
+ }
842
+ }
843
+ function renderTextPreview(snapshot) {
844
+ const lines = [
845
+ `${snapshot.title}`,
846
+ `${snapshot.id}`,
847
+ `${snapshot.engineLabel || "Codex"}${snapshot.sourceDetail ? ` | ${snapshot.sourceDetail}` : ""} | ${snapshot.displayCwd || snapshot.cwd || "No cwd"} | ${formatBytes(snapshot.size)} | ${snapshot.turns.length} entries`,
848
+ "",
849
+ ...(snapshot.goalObjective ? [`Goal: ${snapshot.goalObjective}`, ""] : []),
850
+ `Risks: ${snapshot.risks.length ? snapshot.risks.map((risk) => `${risk.label}(${risk.count})`).join(", ") : "none detected"}`,
851
+ "",
852
+ ];
853
+ for (const turn of snapshot.turns) {
854
+ lines.push(`--- ${turn.role}${turn.kind === "tool" ? `:${turn.name}` : ""} #${turn.turn} ---`);
855
+ const visibleText = stripCodexAppDirectives(turn.text);
856
+ if (visibleText) {
857
+ lines.push(visibleText);
858
+ }
859
+ for (const image of turn.images || []) {
860
+ lines.push(image.src ? "[image]" : `[image unavailable: ${image.unavailableReason || "unavailable"}]`);
861
+ }
862
+ lines.push("");
863
+ }
864
+ return lines.join("\n");
865
+ }
866
+ function renderMarkdown(snapshot) {
867
+ const lines = [
868
+ `# ${escapeMarkdown(snapshot.title)}`,
869
+ "",
870
+ `- Source: \`${snapshot.engineLabel || "Codex"}\``,
871
+ ...(snapshot.sourceDetail ? [`- Source detail: \`${snapshot.sourceDetail}\``] : []),
872
+ `- Session: \`${snapshot.id}\``,
873
+ `- CWD: \`${snapshot.displayCwd || "unknown"}\``,
874
+ `- Source file: \`${snapshot.displayFilePath || "unknown"}\``,
875
+ `- Generated: \`${snapshot.generatedAt}\``,
876
+ `- Redacted: \`${snapshot.redacted ? "yes" : "no"}\``,
877
+ "",
878
+ ];
879
+ if (snapshot.goalObjective) {
880
+ lines.push("## Goal", "", snapshot.goalObjective, "");
881
+ }
882
+ if (snapshot.safetyChecks !== false) {
883
+ lines.push("## Sharing risks", "");
884
+ if (snapshot.risks.length) {
885
+ for (const risk of snapshot.risks) {
886
+ lines.push(`- **${risk.severity.toUpperCase()}** ${risk.label}: ${risk.count} match(es), turns ${risk.turns.join(", ")}`);
887
+ }
888
+ }
889
+ else {
890
+ lines.push("- No common high-risk patterns detected.");
891
+ }
892
+ if (snapshot.notices?.length) {
893
+ lines.push("", "## Notices", "");
894
+ for (const notice of snapshot.notices) {
895
+ lines.push(`- **${escapeMarkdown(notice.label)}**: ${escapeMarkdown(notice.text)}`);
896
+ }
897
+ }
898
+ lines.push("");
899
+ }
900
+ lines.push("## Transcript", "");
901
+ for (const turn of snapshot.turns) {
902
+ const heading = turn.kind === "tool" ? `Tool: ${turn.name}` : turn.role === "user" ? "User" : "Assistant";
903
+ lines.push(`### ${heading} ${turn.turn}`, "");
904
+ if (turn.kind === "tool") {
905
+ lines.push("```text", turn.text, "```", "");
906
+ }
907
+ else {
908
+ const visibleText = stripCodexAppDirectives(turn.text);
909
+ if (visibleText) {
910
+ lines.push(visibleText, "");
911
+ }
912
+ for (const image of turn.images || []) {
913
+ if (image.src) {
914
+ lines.push(`![${escapeMarkdown(image.alt || "Image attachment")}](${image.src})`, "");
915
+ }
916
+ else {
917
+ lines.push(`> [image unavailable: ${escapeMarkdown(image.unavailableReason || "unsupported image source")}]`, "");
918
+ }
919
+ }
920
+ }
921
+ }
922
+ return lines.join("\n");
923
+ }
924
+ function renderHtml(snapshot) {
925
+ const riskRows = snapshot.risks.length
926
+ ? snapshot.risks.map((risk) => `
927
+ <li class="risk risk-${escapeHtml(risk.severity)}">
928
+ <span>${escapeHtml(risk.severity.toUpperCase())}</span>
929
+ <strong>${escapeHtml(risk.label)}</strong>
930
+ <em>${risk.count} match(es), turns ${escapeHtml(risk.turns.join(", "))}</em>
931
+ </li>`).join("")
932
+ : `<li class="risk risk-low"><span>OK</span><strong>No common high-risk patterns detected</strong><em>Still review before sharing.</em></li>`;
933
+ const noticeRows = (snapshot.notices || []).map((notice) => `
934
+ <li class="risk risk-${escapeHtml(notice.severity || "medium")}">
935
+ <span>NOTE</span>
936
+ <strong>${escapeHtml(notice.label || "Notice")}</strong>
937
+ <em>${escapeHtml(notice.text || "")}</em>
938
+ </li>`).join("");
939
+ const riskPanel = snapshot.safetyChecks === false ? "" : `
940
+ <section class="risk-panel">
941
+ <div>
942
+ <p class="eyebrow">Share review</p>
943
+ <h2>${snapshot.risks.length} risk type${snapshot.risks.length === 1 ? "" : "s"} flagged</h2>
944
+ </div>
945
+ <ul>${noticeRows}${riskRows}</ul>
946
+ </section>`;
947
+ const turns = renderTranscriptHtml(snapshot.turns || [], {
948
+ emptyHtml: `<p class="empty">No shareable user or assistant messages found.</p>`,
949
+ bodyWrapper: false,
950
+ contentClassName: "markdown-body",
951
+ roleClassMode: "prefixed",
952
+ labels: {
953
+ processed: "Processed",
954
+ tool: "Tool",
955
+ imageUnavailable: "Image unavailable",
956
+ imageAltPrefix: "Image attachment",
957
+ },
958
+ });
959
+ return `<!doctype html>
960
+ <html lang="en">
961
+ <head>
962
+ <meta charset="utf-8">
963
+ <meta name="viewport" content="width=device-width, initial-scale=1">
964
+ <meta name="robots" content="noindex,nofollow">
965
+ <title>${escapeHtml(snapshot.title)} - Codex Snapshot</title>
966
+ <link rel="icon" type="image/svg+xml" href="${snapshotLogoDataUri()}">
967
+ <style>${snapshotCss()}</style>
968
+ </head>
969
+ <body>
970
+ <main class="snapshot-shell">
971
+ <header class="snapshot-header">
972
+ <div>
973
+ <p class="eyebrow">${escapeHtml(snapshot.engineLabel || "Codex")} read-only snapshot</p>
974
+ <h1>${escapeHtml(snapshot.title)}</h1>
975
+ </div>
976
+ <dl class="meta-grid">
977
+ <div><dt>Session</dt><dd>${escapeHtml(snapshot.id)}</dd></div>
978
+ <div><dt>Generated</dt><dd>${escapeHtml(formatDate(snapshot.generatedAt))}</dd></div>
979
+ <div><dt>Size</dt><dd>${escapeHtml(formatBytes(snapshot.size))}</dd></div>
980
+ <div><dt>Redacted</dt><dd>${snapshot.redacted ? "yes" : "no"}</dd></div>
981
+ ${snapshot.sourceDetail ? `<div><dt>Source detail</dt><dd>${escapeHtml(snapshot.sourceDetail)}</dd></div>` : ""}
982
+ </dl>
983
+ </header>
984
+ ${snapshot.goalObjective ? `<section class="goal-band"><span>Goal</span><p>${escapeHtml(snapshot.goalObjective)}</p></section>` : ""}
985
+ <section class="path-band">
986
+ <span>CWD</span>
987
+ <code>${escapeHtml(snapshot.displayCwd || "unknown")}</code>
988
+ </section>
989
+ ${riskPanel}
990
+ <section class="transcript">
991
+ ${turns}
992
+ </section>
993
+ <footer class="snapshot-footer">Generated by codex-snapshot ${VERSION}. Static read-only file.</footer>
994
+ </main>
995
+ </body>
996
+ </html>`;
997
+ }
998
+ function snapshotCss() {
999
+ return `
1000
+ :root {
1001
+ color-scheme: light;
1002
+ --ink: #16191f;
1003
+ --muted: #5f6978;
1004
+ --line: #d8dde5;
1005
+ --paper: #f5f1e8;
1006
+ --panel: #fffdf8;
1007
+ --panel-strong: #fef7dd;
1008
+ --green: #0d6b57;
1009
+ --red: #a33a2b;
1010
+ --amber: #a66a16;
1011
+ --blue: #245d83;
1012
+ }
1013
+ * { box-sizing: border-box; }
1014
+ body {
1015
+ margin: 0;
1016
+ background:
1017
+ linear-gradient(90deg, rgba(22, 25, 31, 0.06) 1px, transparent 1px),
1018
+ linear-gradient(rgba(22, 25, 31, 0.045) 1px, transparent 1px),
1019
+ var(--paper);
1020
+ background-size: 28px 28px;
1021
+ color: var(--ink);
1022
+ font-family: "Iowan Old Style", "Palatino Linotype", Georgia, serif;
1023
+ }
1024
+ .snapshot-shell { width: min(1180px, calc(100vw - 28px)); margin: 0 auto; padding: 24px 0 56px; }
1025
+ .snapshot-header {
1026
+ display: grid;
1027
+ grid-template-columns: minmax(0, 1fr) minmax(280px, 460px);
1028
+ gap: 24px;
1029
+ align-items: end;
1030
+ border-bottom: 3px solid var(--ink);
1031
+ padding: 24px 0 18px;
1032
+ }
1033
+ .eyebrow {
1034
+ margin: 0 0 10px;
1035
+ color: var(--blue);
1036
+ font: 700 12px/1.2 ui-monospace, SFMono-Regular, Menlo, monospace;
1037
+ text-transform: uppercase;
1038
+ letter-spacing: 0;
1039
+ }
1040
+ h1 { margin: 0; font-size: clamp(34px, 5vw, 72px); line-height: 0.95; letter-spacing: 0; overflow-wrap: anywhere; }
1041
+ h2 { margin: 0; font-size: 24px; letter-spacing: 0; }
1042
+ .meta-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin: 0; }
1043
+ .meta-grid div, .path-band, .goal-band, .risk-panel {
1044
+ border: 1px solid var(--line);
1045
+ background: rgba(255, 253, 248, 0.92);
1046
+ }
1047
+ .meta-grid div { padding: 12px; min-width: 0; }
1048
+ dt, .path-band span, .goal-band span {
1049
+ color: var(--muted);
1050
+ font: 700 11px/1.2 ui-monospace, SFMono-Regular, Menlo, monospace;
1051
+ text-transform: uppercase;
1052
+ }
1053
+ dd { margin: 5px 0 0; overflow-wrap: anywhere; font-size: 14px; }
1054
+ .path-band, .goal-band {
1055
+ display: grid;
1056
+ grid-template-columns: 72px minmax(0, 1fr);
1057
+ gap: 14px;
1058
+ align-items: center;
1059
+ margin-top: 18px;
1060
+ padding: 12px 14px;
1061
+ }
1062
+ .goal-band { align-items: start; }
1063
+ .goal-band p {
1064
+ margin: 0;
1065
+ overflow-wrap: anywhere;
1066
+ white-space: pre-wrap;
1067
+ line-height: 1.55;
1068
+ }
1069
+ code, pre {
1070
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
1071
+ font-size: 13px;
1072
+ }
1073
+ .path-band code { overflow-wrap: anywhere; }
1074
+ .risk-panel {
1075
+ display: grid;
1076
+ grid-template-columns: minmax(220px, 0.6fr) minmax(0, 1fr);
1077
+ gap: 18px;
1078
+ margin-top: 18px;
1079
+ padding: 18px;
1080
+ }
1081
+ .risk-panel ul { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; }
1082
+ .risk {
1083
+ display: grid;
1084
+ grid-template-columns: 70px minmax(0, 1fr);
1085
+ gap: 8px 12px;
1086
+ align-items: center;
1087
+ border-left: 5px solid var(--green);
1088
+ background: #f6fbf7;
1089
+ padding: 10px 12px;
1090
+ }
1091
+ .risk span { color: var(--green); font: 800 12px/1 ui-monospace, SFMono-Regular, Menlo, monospace; }
1092
+ .risk strong { font-size: 15px; }
1093
+ .risk em { grid-column: 2; color: var(--muted); font-size: 13px; font-style: normal; }
1094
+ .risk-high { border-color: var(--red); background: #fff1ee; }
1095
+ .risk-high span { color: var(--red); }
1096
+ .risk-medium { border-color: var(--amber); background: #fff8e7; }
1097
+ .risk-medium span { color: var(--amber); }
1098
+ .transcript {
1099
+ display: grid;
1100
+ gap: 52px;
1101
+ width: min(1600px, 100%);
1102
+ margin: 42px auto 0;
1103
+ }
1104
+ .turn {
1105
+ display: flex;
1106
+ min-width: 0;
1107
+ }
1108
+ .turn-user, .turn.user { justify-content: flex-end; }
1109
+ .turn-assistant, .turn-tool, .turn-process, .turn.assistant, .turn.tool, .turn.process { justify-content: flex-start; }
1110
+ .message-card {
1111
+ min-width: 0;
1112
+ max-width: min(1160px, 74%);
1113
+ border: 0;
1114
+ background: transparent;
1115
+ padding: 0;
1116
+ box-shadow: none;
1117
+ }
1118
+ .turn-user .message-card,
1119
+ .turn.user .message-card {
1120
+ max-width: min(1220px, 76%);
1121
+ border: 1px solid #d6e9e5;
1122
+ border-radius: 18px;
1123
+ background: #eef9f6;
1124
+ padding: 23px 34px 26px;
1125
+ box-shadow: 0 24px 60px -54px rgba(22, 25, 31, 0.42);
1126
+ }
1127
+ .turn-assistant .message-card,
1128
+ .turn.assistant .message-card {
1129
+ max-width: min(1120px, 72%);
1130
+ }
1131
+ .turn-tool .message-card,
1132
+ .turn.tool .message-card {
1133
+ max-width: min(1160px, 80%);
1134
+ border: 1px solid #efd99f;
1135
+ border-radius: 10px;
1136
+ background: #fff8df;
1137
+ padding: 16px 18px;
1138
+ }
1139
+ .process-details {
1140
+ width: min(1120px, 74%);
1141
+ border-top: 1px solid rgba(22, 25, 31, 0.12);
1142
+ color: rgba(22, 25, 31, 0.62);
1143
+ }
1144
+ .process-summary {
1145
+ display: inline-flex;
1146
+ align-items: center;
1147
+ gap: 9px;
1148
+ min-height: 42px;
1149
+ cursor: pointer;
1150
+ list-style: none;
1151
+ user-select: none;
1152
+ font: 800 17px/1.2 ui-monospace, SFMono-Regular, Menlo, monospace;
1153
+ }
1154
+ .process-summary::-webkit-details-marker {
1155
+ display: none;
1156
+ }
1157
+ .process-summary::after {
1158
+ width: 8px;
1159
+ height: 8px;
1160
+ border-right: 2px solid currentColor;
1161
+ border-bottom: 2px solid currentColor;
1162
+ content: "";
1163
+ transform: translateY(-2px) rotate(45deg);
1164
+ transition: transform 0.16s ease;
1165
+ }
1166
+ .process-details[open] .process-summary::after {
1167
+ transform: translateY(2px) rotate(225deg);
1168
+ }
1169
+ .process-body {
1170
+ display: grid;
1171
+ gap: 24px;
1172
+ padding: 6px 0 8px;
1173
+ }
1174
+ .process-entry {
1175
+ min-width: 0;
1176
+ }
1177
+ .process-tool {
1178
+ max-width: min(980px, 100%);
1179
+ border-left: 3px solid rgba(183, 121, 31, 0.32);
1180
+ padding-left: 12px;
1181
+ }
1182
+ .turn-meta {
1183
+ margin-bottom: 20px;
1184
+ color: var(--muted);
1185
+ font: 800 13px/1.25 ui-monospace, SFMono-Regular, Menlo, monospace;
1186
+ text-transform: uppercase;
1187
+ overflow-wrap: anywhere;
1188
+ }
1189
+ .turn-meta span { font-weight: 700; }
1190
+ .markdown-body {
1191
+ max-width: 78ch;
1192
+ color: var(--ink);
1193
+ font-size: 20px;
1194
+ line-height: 1.7;
1195
+ }
1196
+ .markdown-body > * { margin: 0; }
1197
+ .markdown-body > * + * { margin-top: 18px; }
1198
+ .markdown-body p, .markdown-body li { overflow-wrap: anywhere; }
1199
+ .markdown-body strong { font-weight: 800; }
1200
+ .markdown-body em { font-style: italic; }
1201
+ .markdown-body a { color: #155e75; text-decoration: underline; text-decoration-thickness: 1px; text-underline-offset: 3px; }
1202
+ .markdown-body code {
1203
+ border: 1px solid rgba(22, 25, 31, 0.12);
1204
+ border-radius: 8px;
1205
+ background: rgba(22, 25, 31, 0.06);
1206
+ padding: 0.08rem 0.34rem;
1207
+ font-size: 0.9em;
1208
+ }
1209
+ .markdown-body pre {
1210
+ max-width: 100%;
1211
+ overflow: auto;
1212
+ border: 1px solid #253043;
1213
+ border-radius: 8px;
1214
+ background: #111722;
1215
+ color: #edf4ff;
1216
+ padding: 14px 16px;
1217
+ font: 13px/1.55 ui-monospace, SFMono-Regular, Menlo, monospace;
1218
+ white-space: pre;
1219
+ }
1220
+ .markdown-body pre code {
1221
+ display: block;
1222
+ min-width: max-content;
1223
+ border: 0;
1224
+ background: transparent;
1225
+ padding: 0;
1226
+ color: inherit;
1227
+ }
1228
+ .markdown-body .hljs-keyword,
1229
+ .markdown-body .hljs-selector-tag,
1230
+ .markdown-body .hljs-built_in { color: #8ab4f8; }
1231
+ .markdown-body .hljs-title,
1232
+ .markdown-body .hljs-title.class_,
1233
+ .markdown-body .hljs-title.function_ { color: #f2cc60; }
1234
+ .markdown-body .hljs-string,
1235
+ .markdown-body .hljs-attr,
1236
+ .markdown-body .hljs-symbol { color: #9ccc65; }
1237
+ .markdown-body .hljs-number,
1238
+ .markdown-body .hljs-literal { color: #f8a978; }
1239
+ .markdown-body .hljs-comment { color: #7d8796; font-style: italic; }
1240
+ .markdown-body .hljs-type,
1241
+ .markdown-body .hljs-params,
1242
+ .markdown-body .hljs-variable,
1243
+ .markdown-body .hljs-property { color: #c4b5fd; }
1244
+ .markdown-body ul, .markdown-body ol {
1245
+ padding-left: 1.35rem;
1246
+ }
1247
+ .markdown-body li + li { margin-top: 0.25rem; }
1248
+ .markdown-body blockquote {
1249
+ border-left: 3px solid #ccd5df;
1250
+ margin-left: 0;
1251
+ padding-left: 14px;
1252
+ color: #4b5563;
1253
+ }
1254
+ .markdown-body h1, .markdown-body h2, .markdown-body h3 {
1255
+ line-height: 1.25;
1256
+ font-size: 1.08em;
1257
+ }
1258
+ .attachment-grid {
1259
+ display: grid;
1260
+ gap: 18px;
1261
+ margin-top: 24px;
1262
+ }
1263
+ .markdown-body > .attachment-grid { margin-top: 24px; }
1264
+ .image-attachment {
1265
+ margin: 0;
1266
+ min-width: 0;
1267
+ }
1268
+ .image-attachment img {
1269
+ display: block;
1270
+ max-width: 100%;
1271
+ max-height: 540px;
1272
+ border: 1px solid rgba(22, 25, 31, 0.18);
1273
+ border-radius: 8px;
1274
+ background: #fff;
1275
+ object-fit: contain;
1276
+ }
1277
+ .image-unavailable {
1278
+ border: 1px dashed var(--line);
1279
+ border-radius: 8px;
1280
+ padding: 16px;
1281
+ color: var(--muted);
1282
+ }
1283
+ .tool-details summary {
1284
+ min-height: 34px;
1285
+ color: var(--amber);
1286
+ font: 800 12px/1 ui-monospace, SFMono-Regular, Menlo, monospace;
1287
+ text-transform: uppercase;
1288
+ }
1289
+ pre {
1290
+ overflow: auto;
1291
+ max-height: 520px;
1292
+ margin: 8px 0 0;
1293
+ border: 1px solid #253043;
1294
+ background: #111722;
1295
+ color: #edf4ff;
1296
+ padding: 14px;
1297
+ line-height: 1.55;
1298
+ white-space: pre-wrap;
1299
+ }
1300
+ .empty, .snapshot-footer { color: var(--muted); }
1301
+ .snapshot-footer { margin-top: 24px; font: 700 12px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; }
1302
+ @media (max-width: 820px) {
1303
+ .snapshot-header, .risk-panel { grid-template-columns: 1fr; }
1304
+ .meta-grid { grid-template-columns: 1fr; }
1305
+ .risk { grid-template-columns: 1fr; }
1306
+ .risk em { grid-column: auto; }
1307
+ .transcript { gap: 36px; }
1308
+ .message-card, .process-details, .turn-user .message-card, .turn.user .message-card { max-width: 94%; }
1309
+ .turn-assistant .message-card, .turn.assistant .message-card { max-width: 100%; }
1310
+ .turn-user .message-card, .turn.user .message-card { padding: 18px 20px 20px; }
1311
+ .markdown-body { font-size: 18px; }
1312
+ }
1313
+ `;
1314
+ }
1315
+ function escapeHtml(value) {
1316
+ return String(value)
1317
+ .replace(/&/g, "&amp;")
1318
+ .replace(/</g, "&lt;")
1319
+ .replace(/>/g, "&gt;")
1320
+ .replace(/"/g, "&quot;")
1321
+ .replace(/'/g, "&#39;");
1322
+ }
1323
+ function snapshotLogoDataUri() {
1324
+ return `data:image/svg+xml,${encodeURIComponent(SNAPSHOT_LOGO_SVG)}`;
1325
+ }
1326
+ function escapeMarkdown(value) {
1327
+ return String(value).replace(/([\\`*_{}\[\]()#+\-.!|>])/g, "\\$1");
1328
+ }
1329
+ function formatBytes(bytes) {
1330
+ if (!Number.isFinite(bytes)) {
1331
+ return "0 B";
1332
+ }
1333
+ const units = ["B", "KB", "MB", "GB"];
1334
+ let value = bytes;
1335
+ let unit = 0;
1336
+ while (value >= 1024 && unit < units.length - 1) {
1337
+ value /= 1024;
1338
+ unit += 1;
1339
+ }
1340
+ return `${value >= 10 || unit === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[unit]}`;
1341
+ }
1342
+ function formatDate(value) {
1343
+ if (!value) {
1344
+ return "unknown";
1345
+ }
1346
+ const date = new Date(value);
1347
+ if (Number.isNaN(date.valueOf())) {
1348
+ return value;
1349
+ }
1350
+ return date.toISOString().replace("T", " ").slice(0, 16);
1351
+ }
1352
+ async function serve({ codexHome, claudeHome, traeHome, traeAppHome, traeRecordingsDir, host, port }) {
1353
+ await serveLocalViewer({
1354
+ codexHome,
1355
+ claudeHome,
1356
+ traeHome,
1357
+ traeAppHome,
1358
+ traeRecordingsDir,
1359
+ host,
1360
+ port,
1361
+ defaultServerLimit: DEFAULT_SERVER_LIMIT,
1362
+ snapshotLogoSvg: SNAPSHOT_LOGO_SVG,
1363
+ shareConfig: browserShareConfig(),
1364
+ listSessions,
1365
+ loadSnapshot,
1366
+ applySafetyChecksOption,
1367
+ snapshotApiResponse,
1368
+ publishAllSnapshots,
1369
+ publishSnapshot,
1370
+ createShareRequestPayload,
1371
+ stableSnapshotShareId,
1372
+ renderMarkdown,
1373
+ renderHtml,
1374
+ readPositiveInteger,
1375
+ readNonNegativeInteger,
1376
+ safeFileName,
1377
+ });
1378
+ }
1379
+ async function serveTraeRecorder({ host, port, traeRecordingsDir, recordSensitiveContext }) {
1380
+ await mkdir(traeRecordingsDir, { recursive: true });
1381
+ const endpoint = `http://${host}:${port}/capture`;
1382
+ const wsEndpoint = `ws://${host}:${port}/capture-ws`;
1383
+ const server = http.createServer(async (request, response) => {
1384
+ try {
1385
+ setCorsHeaders(response);
1386
+ if (request.method === "OPTIONS") {
1387
+ response.writeHead(204);
1388
+ response.end();
1389
+ return;
1390
+ }
1391
+ const url = new URL(request.url || "/", `http://${request.headers.host || `${host}:${port}`}`);
1392
+ if (url.pathname === "/") {
1393
+ send(response, 200, "text/html; charset=utf-8", renderTraeRecorderHome({ host, port, traeRecordingsDir, recordSensitiveContext }));
1394
+ return;
1395
+ }
1396
+ if (url.pathname === "/health") {
1397
+ sendJson(response, {
1398
+ ok: true,
1399
+ endpoint,
1400
+ wsEndpoint,
1401
+ recordingsDir: traeRecordingsDir,
1402
+ recordSensitiveContext,
1403
+ });
1404
+ return;
1405
+ }
1406
+ if (url.pathname === "/trae-recorder.js") {
1407
+ send(response, 200, "application/javascript; charset=utf-8", renderTraeRecorderScript({ endpoint, wsEndpoint, recordSensitiveContext }));
1408
+ return;
1409
+ }
1410
+ if (url.pathname === "/capture" && request.method === "POST") {
1411
+ const event = await readJsonRequest(request, MAX_TRAE_CAPTURE_POST_BYTES);
1412
+ const saved = await saveTraeCaptureEvent(event, { traeRecordingsDir, recordSensitiveContext });
1413
+ sendJson(response, saved);
1414
+ return;
1415
+ }
1416
+ send(response, 404, "text/plain; charset=utf-8", "not found");
1417
+ }
1418
+ catch (error) {
1419
+ sendJson(response, { error: error instanceof Error ? error.message : String(error) }, 500);
1420
+ }
1421
+ });
1422
+ server.on("upgrade", (request, socket) => {
1423
+ handleTraeRecorderUpgrade(request, socket, { traeRecordingsDir, recordSensitiveContext });
1424
+ });
1425
+ await new Promise((resolve, reject) => {
1426
+ server.once("error", reject);
1427
+ server.listen(port, host, resolve);
1428
+ });
1429
+ const url = `http://${host}:${port}`;
1430
+ console.log(`Trae local recorder is running at ${url}`);
1431
+ console.log(`Recorder script: import("${url}/trae-recorder.js")`);
1432
+ console.log(`Recorder WebSocket: ${wsEndpoint}`);
1433
+ console.log(`Recordings dir: ${traeRecordingsDir}`);
1434
+ console.log(`Sensitive context recording: ${recordSensitiveContext ? "enabled" : "disabled"}`);
1435
+ }
1436
+ function handleTraeRecorderUpgrade(request, socket, { traeRecordingsDir, recordSensitiveContext }) {
1437
+ try {
1438
+ const url = new URL(request.url || "/", `http://${request.headers.host || "127.0.0.1"}`);
1439
+ if (url.pathname !== "/capture-ws") {
1440
+ socket.destroy();
1441
+ return;
1442
+ }
1443
+ const key = request.headers["sec-websocket-key"];
1444
+ if (!key) {
1445
+ socket.destroy();
1446
+ return;
1447
+ }
1448
+ const accept = createHash("sha1")
1449
+ .update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`)
1450
+ .digest("base64");
1451
+ socket.write([
1452
+ "HTTP/1.1 101 Switching Protocols",
1453
+ "Upgrade: websocket",
1454
+ "Connection: Upgrade",
1455
+ `Sec-WebSocket-Accept: ${accept}`,
1456
+ "",
1457
+ "",
1458
+ ].join("\r\n"));
1459
+ let buffer = Buffer.alloc(0);
1460
+ let fragmentedOpcode = 0;
1461
+ let fragmentedPayloads = [];
1462
+ const handleTextPayload = (payload) => {
1463
+ try {
1464
+ const event = JSON.parse(payload.toString("utf8"));
1465
+ saveTraeCaptureEvent(event, {
1466
+ traeRecordingsDir,
1467
+ recordSensitiveContext,
1468
+ }).catch(() => { });
1469
+ }
1470
+ catch { }
1471
+ };
1472
+ socket.on("data", (chunk) => {
1473
+ buffer = Buffer.concat([buffer, chunk]);
1474
+ const parsed = readWebSocketFrames(buffer);
1475
+ buffer = parsed.remaining;
1476
+ for (const frame of parsed.frames) {
1477
+ if (frame.opcode === 0x8) {
1478
+ socket.end();
1479
+ return;
1480
+ }
1481
+ if (frame.opcode === 0x1) {
1482
+ if (frame.fin) {
1483
+ handleTextPayload(frame.payload);
1484
+ }
1485
+ else {
1486
+ fragmentedOpcode = frame.opcode;
1487
+ fragmentedPayloads = [frame.payload];
1488
+ }
1489
+ continue;
1490
+ }
1491
+ if (frame.opcode === 0x0 && fragmentedOpcode === 0x1) {
1492
+ fragmentedPayloads.push(frame.payload);
1493
+ if (frame.fin) {
1494
+ handleTextPayload(Buffer.concat(fragmentedPayloads));
1495
+ fragmentedOpcode = 0;
1496
+ fragmentedPayloads = [];
1497
+ }
1498
+ }
1499
+ }
1500
+ });
1501
+ }
1502
+ catch {
1503
+ socket.destroy();
1504
+ }
1505
+ }
1506
+ function readWebSocketFrames(buffer) {
1507
+ const frames = [];
1508
+ let offset = 0;
1509
+ while (buffer.length - offset >= 2) {
1510
+ const first = buffer[offset];
1511
+ const second = buffer[offset + 1];
1512
+ const fin = (first & 0x80) !== 0;
1513
+ const opcode = first & 0x0f;
1514
+ const masked = (second & 0x80) !== 0;
1515
+ let length = second & 0x7f;
1516
+ let headerLength = 2;
1517
+ if (length === 126) {
1518
+ if (buffer.length - offset < 4) {
1519
+ break;
1520
+ }
1521
+ length = buffer.readUInt16BE(offset + 2);
1522
+ headerLength = 4;
1523
+ }
1524
+ else if (length === 127) {
1525
+ if (buffer.length - offset < 10) {
1526
+ break;
1527
+ }
1528
+ const high = buffer.readUInt32BE(offset + 2);
1529
+ const low = buffer.readUInt32BE(offset + 6);
1530
+ length = high * 2 ** 32 + low;
1531
+ headerLength = 10;
1532
+ }
1533
+ const maskLength = masked ? 4 : 0;
1534
+ const totalLength = headerLength + maskLength + length;
1535
+ if (buffer.length - offset < totalLength) {
1536
+ break;
1537
+ }
1538
+ const mask = masked ? buffer.subarray(offset + headerLength, offset + headerLength + 4) : null;
1539
+ const payloadStart = offset + headerLength + maskLength;
1540
+ const payload = Buffer.from(buffer.subarray(payloadStart, payloadStart + length));
1541
+ if (mask) {
1542
+ for (let index = 0; index < payload.length; index += 1) {
1543
+ payload[index] ^= mask[index % 4];
1544
+ }
1545
+ }
1546
+ frames.push({ fin, opcode, payload });
1547
+ offset += totalLength;
1548
+ }
1549
+ return {
1550
+ frames,
1551
+ remaining: buffer.subarray(offset),
1552
+ };
1553
+ }
1554
+ function setCorsHeaders(response) {
1555
+ response.setHeader("access-control-allow-origin", "*");
1556
+ response.setHeader("access-control-allow-methods", "GET,POST,OPTIONS");
1557
+ response.setHeader("access-control-allow-headers", "content-type");
1558
+ response.setHeader("access-control-max-age", "86400");
1559
+ }
1560
+ async function readJsonRequest(request, limitBytes) {
1561
+ const chunks = [];
1562
+ let total = 0;
1563
+ for await (const chunk of request) {
1564
+ total += chunk.length;
1565
+ if (total > limitBytes) {
1566
+ throw new Error(`capture body is larger than ${formatBytes(limitBytes)}`);
1567
+ }
1568
+ chunks.push(chunk);
1569
+ }
1570
+ const text = Buffer.concat(chunks).toString("utf8");
1571
+ if (!text.trim()) {
1572
+ throw new Error("empty capture body");
1573
+ }
1574
+ return JSON.parse(text);
1575
+ }
1576
+ async function saveTraeCaptureEvent(event, { traeRecordingsDir, recordSensitiveContext }) {
1577
+ if (!event || typeof event !== "object") {
1578
+ throw new Error("capture event must be a JSON object");
1579
+ }
1580
+ const pageSession = safeCaptureId(event.pageSession || event.page?.session || `trae-${new Date().toISOString().slice(0, 10)}`);
1581
+ const body = normalizeCapturedBody(event.body);
1582
+ const chunk = normalizeCapturedBody(event.chunk);
1583
+ const sessionEvent = { ...event, body, chunk };
1584
+ const actualSessionId = extractActualTraeSessionId(sessionEvent) || "";
1585
+ const captureSessionId = extractTraeCaptureSessionId(sessionEvent, actualSessionId) || "";
1586
+ const captureFileId = safeCaptureId(captureSessionId || actualSessionId || pageSession);
1587
+ const filePath = path.join(traeRecordingsDir, `${captureFileId}.jsonl`);
1588
+ const domThreadId = cleanTraeSessionId(event.domThreadId || event.dom_thread_id || "");
1589
+ if (actualSessionId && domThreadId) {
1590
+ await migrateTraeCaptureAlias(traeRecordingsDir, domThreadId, captureFileId);
1591
+ }
1592
+ const record = {
1593
+ schema: "trae-local-recorder-event.v1",
1594
+ capturedAt: normalizeRecordedTimestamp(event.capturedAt) || new Date().toISOString(),
1595
+ pageSession,
1596
+ captureSessionId,
1597
+ captureFileId,
1598
+ domThreadId,
1599
+ sequence: Number(event.sequence || 0),
1600
+ kind: String(event.kind || "capture"),
1601
+ source: String(event.source || ""),
1602
+ requestId: event.requestId ? String(event.requestId) : "",
1603
+ wsId: event.wsId ? String(event.wsId) : "",
1604
+ eventSourceId: event.eventSourceId ? String(event.eventSourceId) : "",
1605
+ method: event.method ? String(event.method) : "",
1606
+ status: Number.isFinite(Number(event.status)) ? Number(event.status) : undefined,
1607
+ statusText: event.statusText ? String(event.statusText) : "",
1608
+ contentType: event.contentType ? String(event.contentType) : "",
1609
+ url: sanitizeCaptureUrl(event.url),
1610
+ responseUrl: sanitizeCaptureUrl(event.responseUrl),
1611
+ pageUrl: sanitizeCaptureUrl(event.page?.href || event.pageUrl),
1612
+ pageTitle: event.page?.title ? String(event.page.title) : event.pageTitle ? String(event.pageTitle) : "",
1613
+ body,
1614
+ chunk,
1615
+ bodyEncoding: event.bodyEncoding ? String(event.bodyEncoding) : "",
1616
+ actualSessionId,
1617
+ sensitiveContextRecorded: Boolean(recordSensitiveContext),
1618
+ headers: recordSensitiveContext ? normalizeCaptureHeaders(event.headers) : undefined,
1619
+ };
1620
+ await appendFile(filePath, `${JSON.stringify(record)}\n`, "utf8");
1621
+ return {
1622
+ ok: true,
1623
+ file: filePath,
1624
+ captureSessionId: record.captureSessionId,
1625
+ actualSessionId: record.actualSessionId,
1626
+ eventKind: record.kind,
1627
+ sequence: record.sequence,
1628
+ };
1629
+ }
1630
+ function normalizeCapturedBody(value) {
1631
+ if (value == null) {
1632
+ return "";
1633
+ }
1634
+ if (typeof value === "string") {
1635
+ return value;
1636
+ }
1637
+ try {
1638
+ return JSON.stringify(value);
1639
+ }
1640
+ catch {
1641
+ return String(value);
1642
+ }
1643
+ }
1644
+ function normalizeCaptureHeaders(headers) {
1645
+ if (!headers || typeof headers !== "object") {
1646
+ return undefined;
1647
+ }
1648
+ const normalized = {};
1649
+ for (const [key, value] of Object.entries(headers)) {
1650
+ normalized[String(key).toLowerCase()] = Array.isArray(value)
1651
+ ? value.map((item) => String(item))
1652
+ : String(value);
1653
+ }
1654
+ return normalized;
1655
+ }
1656
+ function sanitizeCaptureUrl(value) {
1657
+ if (!value) {
1658
+ return "";
1659
+ }
1660
+ try {
1661
+ const url = new URL(String(value));
1662
+ for (const key of [...url.searchParams.keys()]) {
1663
+ url.searchParams.set(key, "<redacted>");
1664
+ }
1665
+ url.hash = "";
1666
+ return url.toString();
1667
+ }
1668
+ catch {
1669
+ return String(value).replace(/[?&]([^=&#]+)=([^&#]+)/g, (_match, key) => `?${key}=<redacted>`);
1670
+ }
1671
+ }
1672
+ function safeCaptureId(value) {
1673
+ const clean = String(value || "")
1674
+ .trim()
1675
+ .replace(/[^A-Za-z0-9._-]+/g, "-")
1676
+ .replace(/^-+|-+$/g, "");
1677
+ if (!clean) {
1678
+ return `trae-${Date.now().toString(36)}`;
1679
+ }
1680
+ return clean.length > 96 ? `${clean.slice(0, 72)}-${stableHash(clean)}` : clean;
1681
+ }
1682
+ async function migrateTraeCaptureAlias(traeRecordingsDir, fromId, toId) {
1683
+ const fromFileId = safeCaptureId(fromId);
1684
+ const toFileId = safeCaptureId(toId);
1685
+ if (!fromFileId || !toFileId || fromFileId === toFileId) {
1686
+ return;
1687
+ }
1688
+ const fromPath = path.join(traeRecordingsDir, `${fromFileId}.jsonl`);
1689
+ const toPath = path.join(traeRecordingsDir, `${toFileId}.jsonl`);
1690
+ try {
1691
+ const existing = await readFile(fromPath, "utf8");
1692
+ if (existing.trim()) {
1693
+ await appendFile(toPath, existing.endsWith("\n") ? existing : `${existing}\n`, "utf8");
1694
+ }
1695
+ await unlink(fromPath);
1696
+ }
1697
+ catch (error) {
1698
+ if (error?.code !== "ENOENT") {
1699
+ throw error;
1700
+ }
1701
+ }
1702
+ }
1703
+ const TRAE_ACTUAL_SESSION_KEYS = new Set([
1704
+ "agentsessionid",
1705
+ "chatsessionid",
1706
+ "conversationid",
1707
+ "conversationuuid",
1708
+ "currentsessionid",
1709
+ "sessionid",
1710
+ "sessionuuid",
1711
+ "threadid",
1712
+ "taskid",
1713
+ "chatid",
1714
+ ]);
1715
+ const TRAE_CAPTURE_SESSION_KEYS = new Set([
1716
+ ...TRAE_ACTUAL_SESSION_KEYS,
1717
+ "capturesessionid",
1718
+ "domthreadid",
1719
+ ]);
1720
+ function normalizeTraeSessionKey(key) {
1721
+ return String(key || "").toLowerCase().replace(/[^a-z0-9]+/g, "");
1722
+ }
1723
+ function cleanTraeSessionId(value) {
1724
+ const clean = String(value || "").trim();
1725
+ if (!clean || clean.length < 8 || clean.length > 240) {
1726
+ return "";
1727
+ }
1728
+ if (/^(<redacted>|undefined|null|true|false)$/i.test(clean)) {
1729
+ return "";
1730
+ }
1731
+ if (/^(data:|blob:|https?:)/i.test(clean)) {
1732
+ return "";
1733
+ }
1734
+ return clean;
1735
+ }
1736
+ function extractActualTraeSessionId(event) {
1737
+ const values = [];
1738
+ const explicit = cleanTraeSessionId(event.actualSessionId || event.actual_session_id);
1739
+ if (explicit) {
1740
+ return explicit;
1741
+ }
1742
+ collectTraeSessionValues(event, TRAE_ACTUAL_SESSION_KEYS, values, 0);
1743
+ for (const payloadText of [event.body, event.chunk]) {
1744
+ if (!payloadText || typeof payloadText !== "string") {
1745
+ continue;
1746
+ }
1747
+ for (const payload of parseCapturePayloads(payloadText)) {
1748
+ collectTraeSessionValues(payload, TRAE_ACTUAL_SESSION_KEYS, values, 0);
1749
+ }
1750
+ }
1751
+ for (const value of values) {
1752
+ const clean = cleanTraeSessionId(value);
1753
+ if (clean) {
1754
+ return clean;
1755
+ }
1756
+ }
1757
+ for (const url of [event.url, event.responseUrl, event.page?.href, event.pageUrl]) {
1758
+ const fromUrl = extractTraeSessionIdFromUrl(url);
1759
+ if (fromUrl) {
1760
+ return fromUrl;
1761
+ }
1762
+ }
1763
+ return "";
1764
+ }
1765
+ function extractTraeCaptureSessionId(event, actualSessionId) {
1766
+ if (actualSessionId) {
1767
+ return actualSessionId;
1768
+ }
1769
+ const explicit = cleanTraeSessionId(event.captureSessionId || event.capture_session_id || event.page?.captureSessionId);
1770
+ if (explicit) {
1771
+ return explicit;
1772
+ }
1773
+ const values = [];
1774
+ collectTraeSessionValues(event, TRAE_CAPTURE_SESSION_KEYS, values, 0);
1775
+ for (const payloadText of [event.body, event.chunk]) {
1776
+ if (!payloadText || typeof payloadText !== "string") {
1777
+ continue;
1778
+ }
1779
+ for (const payload of parseCapturePayloads(payloadText)) {
1780
+ collectTraeSessionValues(payload, TRAE_CAPTURE_SESSION_KEYS, values, 0);
1781
+ }
1782
+ }
1783
+ for (const value of values) {
1784
+ const clean = cleanTraeSessionId(value);
1785
+ if (clean) {
1786
+ return clean;
1787
+ }
1788
+ }
1789
+ return "";
1790
+ }
1791
+ function collectTraeSessionValues(value, keys, results, depth) {
1792
+ if (!value || depth > 8 || typeof value !== "object") {
1793
+ return;
1794
+ }
1795
+ if (Array.isArray(value)) {
1796
+ for (const item of value) {
1797
+ collectTraeSessionValues(item, keys, results, depth + 1);
1798
+ }
1799
+ return;
1800
+ }
1801
+ for (const [key, child] of Object.entries(value)) {
1802
+ if (typeof child === "string" && keys.has(normalizeTraeSessionKey(key))) {
1803
+ results.push(child);
1804
+ }
1805
+ else if (child && typeof child === "object") {
1806
+ collectTraeSessionValues(child, keys, results, depth + 1);
1807
+ }
1808
+ }
1809
+ }
1810
+ function extractTraeSessionIdFromUrl(value) {
1811
+ if (!value) {
1812
+ return "";
1813
+ }
1814
+ const raw = String(value);
1815
+ try {
1816
+ const url = new URL(raw);
1817
+ for (const [key, child] of url.searchParams.entries()) {
1818
+ if (TRAE_ACTUAL_SESSION_KEYS.has(normalizeTraeSessionKey(key))) {
1819
+ const clean = cleanTraeSessionId(child);
1820
+ if (clean) {
1821
+ return clean;
1822
+ }
1823
+ }
1824
+ }
1825
+ const pathMatch = url.pathname.match(/(?:session|conversation|chat|thread|task)[/_-]([A-Za-z0-9._:-]{8,})/i);
1826
+ if (pathMatch) {
1827
+ return cleanTraeSessionId(pathMatch[1]);
1828
+ }
1829
+ }
1830
+ catch {
1831
+ const match = raw.match(/[?&#](?:[^=]*?(?:session|conversation|chat|thread|task)[^=]*?id)=([^&#]+)/i)
1832
+ || raw.match(/(?:session|conversation|chat|thread|task)[/_-]([A-Za-z0-9._:-]{8,})/i);
1833
+ if (match) {
1834
+ return cleanTraeSessionId(decodeURIComponent(match[1]));
1835
+ }
1836
+ }
1837
+ return "";
1838
+ }
1839
+ function renderTraeRecorderHome({ host, port, traeRecordingsDir, recordSensitiveContext }) {
1840
+ const scriptUrl = `http://${host}:${port}/trae-recorder.js`;
1841
+ return `<!doctype html>
1842
+ <html lang="en">
1843
+ <head>
1844
+ <meta charset="utf-8">
1845
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1846
+ <title>Trae Local Recorder</title>
1847
+ <style>
1848
+ body { margin: 40px; max-width: 920px; background: #f5efe4; color: #15191f; font: 18px/1.55 ui-serif, Georgia, serif; }
1849
+ h1 { font-size: 48px; line-height: 1; margin: 0 0 24px; }
1850
+ code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
1851
+ pre { background: #15191f; color: #fff; padding: 18px; overflow: auto; }
1852
+ .meta { color: #687386; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
1853
+ </style>
1854
+ </head>
1855
+ <body>
1856
+ <h1>Trae Local Recorder</h1>
1857
+ <p class="meta">status: running | recordings: ${escapeHtml(traeRecordingsDir)} | sensitive context: ${recordSensitiveContext ? "enabled" : "disabled"}</p>
1858
+ <p>Open Trae DevTools for the Trae window you want to record, then run:</p>
1859
+ <pre>import(${JSON.stringify(scriptUrl)})</pre>
1860
+ <p>If dynamic import is blocked, run:</p>
1861
+ <pre>fetch(${JSON.stringify(scriptUrl)}).then((r) => r.text()).then((code) => (0, eval)(code))</pre>
1862
+ </body>
1863
+ </html>`;
1864
+ }
1865
+ function renderTraeRecorderScript({ endpoint, wsEndpoint, recordSensitiveContext }) {
1866
+ return `(() => {
1867
+ const ENDPOINT = ${JSON.stringify(endpoint)};
1868
+ const WS_ENDPOINT = ${JSON.stringify(wsEndpoint)};
1869
+ const RECORD_SENSITIVE_CONTEXT = ${recordSensitiveContext ? "true" : "false"};
1870
+ const nativeFetch = window.fetch;
1871
+ const nativeFetchBound = nativeFetch.bind(window);
1872
+ const nativeWebSocket = window.WebSocket;
1873
+ if (window.__codexTraeRecorder && window.__codexTraeRecorder.installed) {
1874
+ console.info("[codex-snapshot] Trae recorder already installed", window.__codexTraeRecorder);
1875
+ return;
1876
+ }
1877
+ const recorder = {
1878
+ installed: true,
1879
+ endpoint: ENDPOINT,
1880
+ pageSession: "trae-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2),
1881
+ actualSessionId: "",
1882
+ actualSessionUpdatedAt: 0,
1883
+ actualSessionSource: "",
1884
+ captureSessionId: "",
1885
+ pageStateSessionId: "",
1886
+ domThreadId: "",
1887
+ domThreadSignature: "",
1888
+ sequence: 0,
1889
+ };
1890
+ const transport = { socket: null, queue: [], opening: false, failed: false };
1891
+ window.__codexTraeRecorder = recorder;
1892
+
1893
+ function nextId(prefix) {
1894
+ return prefix + "-" + Date.now().toString(36) + "-" + (++recorder.sequence).toString(36);
1895
+ }
1896
+
1897
+ function sanitizeUrl(value) {
1898
+ if (!value) return "";
1899
+ try {
1900
+ const url = new URL(String(value), location.href);
1901
+ for (const key of Array.from(url.searchParams.keys())) {
1902
+ url.searchParams.set(key, "<redacted>");
1903
+ }
1904
+ url.hash = "";
1905
+ return url.toString();
1906
+ } catch {
1907
+ return String(value);
1908
+ }
1909
+ }
1910
+
1911
+ const TRAE_SESSION_ID_KEYS = new Set([
1912
+ "agentsessionid",
1913
+ "chatsessionid",
1914
+ "conversationid",
1915
+ "conversationuuid",
1916
+ "currentsessionid",
1917
+ "sessionid",
1918
+ "sessionuuid",
1919
+ "threadid",
1920
+ "taskid",
1921
+ "chatid",
1922
+ ]);
1923
+
1924
+ function normalizeSessionKey(key) {
1925
+ return String(key || "").toLowerCase().replace(/[^a-z0-9]+/g, "");
1926
+ }
1927
+
1928
+ function isSessionIdKey(key) {
1929
+ const normalized = normalizeSessionKey(key);
1930
+ return TRAE_SESSION_ID_KEYS.has(normalized)
1931
+ || (normalized.startsWith("data") && TRAE_SESSION_ID_KEYS.has(normalized.slice(4)));
1932
+ }
1933
+
1934
+ function cleanSessionId(value) {
1935
+ const clean = String(value || "").trim();
1936
+ if (!clean || clean.length < 8 || clean.length > 240) return "";
1937
+ if (/^(<redacted>|undefined|null|true|false)$/i.test(clean)) return "";
1938
+ if (/^(data:|blob:|https?:)/i.test(clean)) return "";
1939
+ if (clean.includes("\\n")) return "";
1940
+ return clean;
1941
+ }
1942
+
1943
+ function rememberActualSessionId(value, source) {
1944
+ const clean = cleanSessionId(value);
1945
+ if (!clean) return "";
1946
+ recorder.actualSessionId = clean;
1947
+ recorder.actualSessionUpdatedAt = Date.now();
1948
+ recorder.actualSessionSource = source || "network";
1949
+ if (recorder.actualSessionSource !== "page-state") {
1950
+ recorder.captureSessionId = clean;
1951
+ }
1952
+ return clean;
1953
+ }
1954
+
1955
+ function rememberCaptureSessionId(value) {
1956
+ const clean = cleanSessionId(value);
1957
+ if (!clean) return "";
1958
+ if (!recorder.actualSessionId) recorder.captureSessionId = clean;
1959
+ return clean;
1960
+ }
1961
+
1962
+ function currentCaptureSessionId() {
1963
+ return (recorder.actualSessionSource !== "page-state" ? recorder.actualSessionId : "")
1964
+ || recorder.captureSessionId
1965
+ || recorder.domThreadId
1966
+ || "";
1967
+ }
1968
+
1969
+ function collectSessionIds(value, out, depth) {
1970
+ if (value == null || depth > 8) return;
1971
+ if (typeof value === "string") {
1972
+ const text = value.trim();
1973
+ if (!text || text.length > 300000) return;
1974
+ if (text[0] === "{" || text[0] === "[") {
1975
+ try {
1976
+ collectSessionIds(JSON.parse(text), out, depth + 1);
1977
+ return;
1978
+ } catch {}
1979
+ }
1980
+ for (const line of text.split(/\\r?\\n/)) {
1981
+ const item = line.trim();
1982
+ if (!item.startsWith("data:")) continue;
1983
+ const data = item.slice(5).trim();
1984
+ if (!data || data === "[DONE]") continue;
1985
+ try {
1986
+ collectSessionIds(JSON.parse(data), out, depth + 1);
1987
+ } catch {}
1988
+ }
1989
+ return;
1990
+ }
1991
+ if (Array.isArray(value)) {
1992
+ for (const item of value) collectSessionIds(item, out, depth + 1);
1993
+ return;
1994
+ }
1995
+ if (typeof value !== "object") return;
1996
+ for (const [key, child] of Object.entries(value)) {
1997
+ if (isSessionIdKey(key)) {
1998
+ const clean = cleanSessionId(child);
1999
+ if (clean) out.push(clean);
2000
+ }
2001
+ if (child && (typeof child === "object" || typeof child === "string")) {
2002
+ collectSessionIds(child, out, depth + 1);
2003
+ }
2004
+ }
2005
+ }
2006
+
2007
+ function rememberSessionIdsFromText(text) {
2008
+ const values = [];
2009
+ collectSessionIds(text, values, 0);
2010
+ return values.length ? rememberActualSessionId(values[0], "network-body") : "";
2011
+ }
2012
+
2013
+ function rememberSessionIdsFromUrl(value) {
2014
+ if (!value) return "";
2015
+ try {
2016
+ const url = new URL(String(value), location.href);
2017
+ for (const [key, child] of url.searchParams.entries()) {
2018
+ if (isSessionIdKey(key)) {
2019
+ const remembered = rememberActualSessionId(child, "network-url");
2020
+ if (remembered) return remembered;
2021
+ }
2022
+ }
2023
+ const pathMatch = url.pathname.match(/(?:session|conversation|chat|thread|task)[/_-]([A-Za-z0-9._:-]{8,})/i);
2024
+ if (pathMatch) return rememberActualSessionId(pathMatch[1], "network-url");
2025
+ } catch {}
2026
+ return "";
2027
+ }
2028
+
2029
+ function rememberSessionIdsFromCapture(url, text) {
2030
+ return rememberSessionIdsFromUrl(url) || rememberSessionIdsFromText(text);
2031
+ }
2032
+
2033
+ function rememberSessionIdsFromPageState() {
2034
+ rememberSessionIdsFromUrl(location.href);
2035
+ const values = [];
2036
+ try {
2037
+ collectSessionIds(history.state, values, 0);
2038
+ } catch {}
2039
+ try {
2040
+ for (const storage of [localStorage, sessionStorage]) {
2041
+ for (let index = 0; index < storage.length; index += 1) {
2042
+ const key = storage.key(index);
2043
+ const value = key ? storage.getItem(key) : "";
2044
+ if (isSessionIdKey(key)) {
2045
+ const clean = cleanSessionId(value);
2046
+ if (clean) values.push(clean);
2047
+ }
2048
+ collectSessionIds(value, values, 0);
2049
+ }
2050
+ }
2051
+ } catch {}
2052
+ try {
2053
+ const selectors = [
2054
+ "[data-session-id]",
2055
+ "[data-conversation-id]",
2056
+ "[data-chat-id]",
2057
+ "[data-thread-id]",
2058
+ "[data-task-id]",
2059
+ "[aria-current='true']",
2060
+ ".active",
2061
+ ".selected",
2062
+ ".current",
2063
+ ].join(",");
2064
+ const elements = Array.from(document.querySelectorAll(selectors)).slice(0, 80);
2065
+ for (const element of elements) {
2066
+ for (const attr of Array.from(element.attributes || [])) {
2067
+ if (isSessionIdKey(attr.name)) {
2068
+ const clean = cleanSessionId(attr.value);
2069
+ if (clean) values.push(clean);
2070
+ }
2071
+ }
2072
+ }
2073
+ } catch {}
2074
+ if (values.length) {
2075
+ recorder.pageStateSessionId = cleanSessionId(values[0]);
2076
+ }
2077
+ return values[0] || "";
2078
+ }
2079
+
2080
+ function headersToObject(headers) {
2081
+ if (!RECORD_SENSITIVE_CONTEXT || !headers) return undefined;
2082
+ try {
2083
+ const out = {};
2084
+ new Headers(headers).forEach((value, key) => { out[key] = value; });
2085
+ return out;
2086
+ } catch {
2087
+ return undefined;
2088
+ }
2089
+ }
2090
+
2091
+ async function readBody(value) {
2092
+ if (value == null) return "";
2093
+ if (typeof value === "string") return value;
2094
+ if (value instanceof URLSearchParams) return value.toString();
2095
+ if (typeof FormData !== "undefined" && value instanceof FormData) {
2096
+ const out = {};
2097
+ for (const [key, item] of value.entries()) {
2098
+ out[key] = typeof item === "string" ? item : "[File " + (item.name || "blob") + " " + (item.type || "application/octet-stream") + "]";
2099
+ }
2100
+ return JSON.stringify(out);
2101
+ }
2102
+ if (typeof Blob !== "undefined" && value instanceof Blob) {
2103
+ if (value.type && !/^text\\/|json|javascript|xml|x-www-form-urlencoded/i.test(value.type)) {
2104
+ return "[Blob " + value.type + " " + value.size + " bytes]";
2105
+ }
2106
+ return await value.text();
2107
+ }
2108
+ if (value instanceof ArrayBuffer) {
2109
+ return new TextDecoder().decode(value);
2110
+ }
2111
+ if (ArrayBuffer.isView(value)) {
2112
+ return new TextDecoder().decode(value);
2113
+ }
2114
+ if (typeof ReadableStream !== "undefined" && value instanceof ReadableStream) {
2115
+ return "[ReadableStream request body]";
2116
+ }
2117
+ try {
2118
+ return JSON.stringify(value);
2119
+ } catch {
2120
+ return String(value);
2121
+ }
2122
+ }
2123
+
2124
+ async function readFetchRequestBody(input, init) {
2125
+ if (init && Object.prototype.hasOwnProperty.call(init, "body")) {
2126
+ return readBody(init.body);
2127
+ }
2128
+ if (typeof Request !== "undefined" && input instanceof Request) {
2129
+ try {
2130
+ return await input.clone().text();
2131
+ } catch {
2132
+ return "";
2133
+ }
2134
+ }
2135
+ return "";
2136
+ }
2137
+
2138
+ function requestHeaders(input, init) {
2139
+ if (init && init.headers) return headersToObject(init.headers);
2140
+ if (typeof Request !== "undefined" && input instanceof Request) return headersToObject(input.headers);
2141
+ return undefined;
2142
+ }
2143
+
2144
+ async function post(kind, payload) {
2145
+ rememberSessionIdsFromPageState();
2146
+ if (payload && payload.actualSessionId) rememberActualSessionId(payload.actualSessionId);
2147
+ if (payload && payload.captureSessionId) rememberCaptureSessionId(payload.captureSessionId);
2148
+ const event = {
2149
+ schema: "trae-browser-recorder.v1",
2150
+ kind,
2151
+ capturedAt: new Date().toISOString(),
2152
+ page: { href: sanitizeUrl(location.href), title: document.title },
2153
+ pageSession: recorder.pageSession,
2154
+ sequence: ++recorder.sequence,
2155
+ ...payload,
2156
+ actualSessionId: recorder.actualSessionSource === "page-state" ? "" : recorder.actualSessionId || "",
2157
+ actualSessionIdSource: recorder.actualSessionSource || "",
2158
+ pageStateSessionId: recorder.pageStateSessionId || "",
2159
+ captureSessionId: currentCaptureSessionId(),
2160
+ domThreadId: recorder.domThreadId || "",
2161
+ };
2162
+ const body = JSON.stringify(event);
2163
+ if (sendViaWebSocket(body)) {
2164
+ return;
2165
+ }
2166
+ try {
2167
+ await nativeFetchBound(ENDPOINT, {
2168
+ method: "POST",
2169
+ mode: "cors",
2170
+ keepalive: body.length < 60000,
2171
+ headers: { "content-type": "application/json" },
2172
+ body,
2173
+ });
2174
+ } catch (error) {
2175
+ console.debug("[codex-snapshot] recorder post failed", error);
2176
+ }
2177
+ }
2178
+
2179
+ function sendViaWebSocket(body) {
2180
+ if (transport.failed || !nativeWebSocket || !WS_ENDPOINT) return false;
2181
+ if (transport.socket && transport.socket.readyState === nativeWebSocket.OPEN) {
2182
+ transport.socket.send(body);
2183
+ return true;
2184
+ }
2185
+ transport.queue.push(body);
2186
+ if (!transport.opening) {
2187
+ transport.opening = true;
2188
+ try {
2189
+ transport.socket = new nativeWebSocket(WS_ENDPOINT);
2190
+ transport.socket.addEventListener("open", () => {
2191
+ transport.opening = false;
2192
+ const pending = transport.queue.splice(0);
2193
+ for (const item of pending) transport.socket.send(item);
2194
+ });
2195
+ transport.socket.addEventListener("close", () => {
2196
+ transport.opening = false;
2197
+ transport.socket = null;
2198
+ });
2199
+ transport.socket.addEventListener("error", () => {
2200
+ transport.opening = false;
2201
+ transport.failed = true;
2202
+ transport.socket = null;
2203
+ });
2204
+ } catch {
2205
+ transport.opening = false;
2206
+ transport.failed = true;
2207
+ return false;
2208
+ }
2209
+ }
2210
+ return true;
2211
+ }
2212
+
2213
+ async function captureResponseStream(response, meta) {
2214
+ try {
2215
+ if (response.body && response.body.getReader) {
2216
+ const reader = response.body.getReader();
2217
+ const decoder = new TextDecoder();
2218
+ for (;;) {
2219
+ const result = await reader.read();
2220
+ if (result.done) break;
2221
+ const chunk = decoder.decode(result.value, { stream: true });
2222
+ if (chunk) {
2223
+ rememberSessionIdsFromText(chunk);
2224
+ await post("fetch-response-chunk", { ...meta, chunk });
2225
+ }
2226
+ }
2227
+ const tail = decoder.decode();
2228
+ if (tail) {
2229
+ rememberSessionIdsFromText(tail);
2230
+ await post("fetch-response-chunk", { ...meta, chunk: tail });
2231
+ }
2232
+ await post("fetch-response-end", meta);
2233
+ return;
2234
+ }
2235
+ const body = await response.text();
2236
+ rememberSessionIdsFromText(body);
2237
+ await post("fetch-response", { ...meta, body });
2238
+ } catch (error) {
2239
+ await post("capture-error", { ...meta, message: String(error && error.message || error) });
2240
+ }
2241
+ }
2242
+
2243
+ window.fetch = async function recordedFetch(input, init) {
2244
+ const method = String((init && init.method) || (typeof Request !== "undefined" && input instanceof Request && input.method) || "GET").toUpperCase();
2245
+ const rawRequestUrl = typeof input === "string" || input instanceof URL ? input : input && input.url;
2246
+ const requestUrl = sanitizeUrl(rawRequestUrl);
2247
+ const requestId = nextId("fetch");
2248
+ rememberSessionIdsFromUrl(rawRequestUrl);
2249
+ readFetchRequestBody(input, init).then((body) => {
2250
+ rememberSessionIdsFromCapture(rawRequestUrl, body);
2251
+ return post("fetch-request", {
2252
+ requestId,
2253
+ source: "fetch",
2254
+ url: requestUrl,
2255
+ method,
2256
+ headers: requestHeaders(input, init),
2257
+ body,
2258
+ });
2259
+ }).then(() => {}, () => {});
2260
+ const response = await nativeFetch.apply(this, arguments);
2261
+ rememberSessionIdsFromUrl(response.url);
2262
+ const meta = {
2263
+ requestId,
2264
+ source: "fetch",
2265
+ url: requestUrl,
2266
+ responseUrl: sanitizeUrl(response.url),
2267
+ method,
2268
+ status: response.status,
2269
+ statusText: response.statusText,
2270
+ contentType: response.headers.get("content-type") || "",
2271
+ headers: headersToObject(response.headers),
2272
+ };
2273
+ if (meta.contentType && !/json|text|event-stream|javascript|xml|x-www-form-urlencoded/i.test(meta.contentType)) {
2274
+ post("fetch-response-skip", meta);
2275
+ return response;
2276
+ }
2277
+ captureResponseStream(response.clone(), meta);
2278
+ return response;
2279
+ };
2280
+
2281
+ if (nativeWebSocket) {
2282
+ const NativeWebSocket = nativeWebSocket;
2283
+ window.WebSocket = new Proxy(NativeWebSocket, {
2284
+ construct(target, args) {
2285
+ const socket = new target(...args);
2286
+ const wsId = nextId("ws");
2287
+ const rawUrl = args[0];
2288
+ const url = sanitizeUrl(rawUrl);
2289
+ rememberSessionIdsFromUrl(rawUrl);
2290
+ post("ws-open", { wsId, source: "websocket", url });
2291
+ const nativeSend = socket.send;
2292
+ socket.send = function recordedSend(data) {
2293
+ readBody(data).then((body) => {
2294
+ rememberSessionIdsFromCapture(rawUrl, body);
2295
+ return post("ws-send", { wsId, source: "websocket", url, body });
2296
+ }).then(() => {}, () => {});
2297
+ return nativeSend.apply(this, arguments);
2298
+ };
2299
+ socket.addEventListener("message", (event) => {
2300
+ readBody(event.data).then((body) => {
2301
+ rememberSessionIdsFromCapture(rawUrl, body);
2302
+ return post("ws-message", { wsId, source: "websocket", url, body });
2303
+ }).then(() => {}, () => {});
2304
+ });
2305
+ socket.addEventListener("close", (event) => post("ws-close", { wsId, source: "websocket", url, code: event.code, reason: event.reason }));
2306
+ socket.addEventListener("error", () => post("ws-error", { wsId, source: "websocket", url }));
2307
+ return socket;
2308
+ },
2309
+ });
2310
+ }
2311
+
2312
+ if (window.EventSource) {
2313
+ const NativeEventSource = window.EventSource;
2314
+ window.EventSource = new Proxy(NativeEventSource, {
2315
+ construct(target, args) {
2316
+ const eventSource = new target(...args);
2317
+ const eventSourceId = nextId("sse");
2318
+ const rawUrl = args[0];
2319
+ const url = sanitizeUrl(rawUrl);
2320
+ rememberSessionIdsFromUrl(rawUrl);
2321
+ post("eventsource-open", { eventSourceId, source: "eventsource", url });
2322
+ eventSource.addEventListener("message", (event) => {
2323
+ rememberSessionIdsFromCapture(rawUrl, event.data);
2324
+ post("eventsource-message", {
2325
+ eventSourceId,
2326
+ source: "eventsource",
2327
+ url,
2328
+ body: event.data,
2329
+ });
2330
+ });
2331
+ eventSource.addEventListener("error", () => post("eventsource-error", { eventSourceId, source: "eventsource", url }));
2332
+ return eventSource;
2333
+ },
2334
+ });
2335
+ }
2336
+
2337
+ const domRecorder = {
2338
+ ids: new WeakMap(),
2339
+ sent: new Map(),
2340
+ nextId: 0,
2341
+ observer: null,
2342
+ timer: null,
2343
+ };
2344
+
2345
+ function queryFirst(root, selectors) {
2346
+ for (const selector of selectors) {
2347
+ const found = root.querySelector(selector);
2348
+ if (found) return found;
2349
+ }
2350
+ return null;
2351
+ }
2352
+
2353
+ function normalizeDomMessageText(value) {
2354
+ return String(value || "")
2355
+ .replace(/\\u0000/g, "")
2356
+ .replace(/\\u00a0/g, " ")
2357
+ .replace(/\\r\\n/g, "\\n")
2358
+ .split("\\n")
2359
+ .map((line) => line.trim())
2360
+ .filter(Boolean)
2361
+ .join("\\n")
2362
+ .trim();
2363
+ }
2364
+
2365
+ function normalizeDomCodeLanguage(value) {
2366
+ const lower = String(value || "").trim().toLowerCase();
2367
+ if (lower === "typescript" || lower === "ts") return "ts";
2368
+ if (lower === "javascript" || lower === "js") return "js";
2369
+ if (lower === "plaintext" || lower === "plain text" || lower === "text") return "text";
2370
+ if (/^(tsx|jsx|json|html|css|bash|yaml|yml|xml)$/.test(lower)) return lower === "yml" ? "yaml" : lower;
2371
+ return "";
2372
+ }
2373
+
2374
+ function inferDomCodeLanguage(element) {
2375
+ const values = [];
2376
+ let current = element;
2377
+ while (current && current.nodeType === 1 && values.length < 4) {
2378
+ values.push(current.getAttribute("data-language") || "");
2379
+ values.push(current.getAttribute("lang") || "");
2380
+ values.push(String(current.className || ""));
2381
+ current = current.parentElement;
2382
+ }
2383
+ const joined = values.join(" ");
2384
+ const classMatch = joined.match(/language-([a-z0-9+#.-]+)/i) || joined.match(/\\b(tsx|ts|typescript|jsx|js|javascript|json|html|css|bash|plaintext|text|yaml|yml|xml)\\b/i);
2385
+ return normalizeDomCodeLanguage(classMatch && classMatch[1]);
2386
+ }
2387
+
2388
+ function isDomCodeLineNumber(line) {
2389
+ return /^\\d{1,4}$/.test(String(line || "").trim());
2390
+ }
2391
+
2392
+ function normalizeDomCodeBlockText(value, language) {
2393
+ const lines = String(value || "")
2394
+ .replace(/\\u0000/g, "")
2395
+ .replace(/\\u00a0/g, " ")
2396
+ .replace(/\\r\\n/g, "\\n")
2397
+ .split("\\n")
2398
+ .map((line) => line.replace(/\\s+$/g, ""));
2399
+ while (lines.length && !lines[0].trim()) lines.shift();
2400
+ while (lines.length && !lines[lines.length - 1].trim()) lines.pop();
2401
+ const leadingLanguage = normalizeDomCodeLanguage(lines[0]);
2402
+ if (leadingLanguage) {
2403
+ language = language || leadingLanguage;
2404
+ lines.shift();
2405
+ }
2406
+ while (lines.length && isDomCodeLineNumber(lines[0])) {
2407
+ lines.shift();
2408
+ }
2409
+ return {
2410
+ language: language || "text",
2411
+ code: lines.join("\\n").trimEnd(),
2412
+ };
2413
+ }
2414
+
2415
+ function extractDomMessageText(element) {
2416
+ if (!element) return "";
2417
+ const clone = element.cloneNode(true);
2418
+ const codeBlocks = Array.from(clone.querySelectorAll("pre, .shiki, .code-block, .codeBlock, [class*='code-block'], [class*='codeBlock'], code[class*='language-']"));
2419
+ for (const block of codeBlocks) {
2420
+ if (!block.isConnected) continue;
2421
+ const raw = block.textContent || block.innerText || "";
2422
+ if (!raw.trim()) continue;
2423
+ if (block.tagName && block.tagName.toLowerCase() === "code" && !raw.includes("\\n")) continue;
2424
+ const normalized = normalizeDomCodeBlockText(raw, inferDomCodeLanguage(block));
2425
+ if (!normalized.code) continue;
2426
+ block.replaceWith(document.createTextNode("\\n\\n\`\`\`" + normalized.language + "\\n" + normalized.code + "\\n\`\`\`\\n\\n"));
2427
+ }
2428
+ return clone.innerText || clone.textContent || "";
2429
+ }
2430
+
2431
+ function isVisibleElement(element) {
2432
+ if (!element || !element.getBoundingClientRect) return false;
2433
+ const rect = element.getBoundingClientRect();
2434
+ if (rect.width <= 0 || rect.height <= 0) return false;
2435
+ const style = getComputedStyle(element);
2436
+ return style.visibility !== "hidden" && style.display !== "none" && Number(style.opacity || 1) !== 0;
2437
+ }
2438
+
2439
+ function getDomMessageId(section, role) {
2440
+ const existing = domRecorder.ids.get(section);
2441
+ if (existing) return existing;
2442
+ const id = "dom-" + role + "-" + Date.now().toString(36) + "-" + (++domRecorder.nextId).toString(36);
2443
+ domRecorder.ids.set(section, id);
2444
+ return id;
2445
+ }
2446
+
2447
+ function extractDomTurn(section, order) {
2448
+ if (!section || !isVisibleElement(section)) return null;
2449
+ const role = section.classList.contains("user")
2450
+ ? "user"
2451
+ : section.classList.contains("assistant")
2452
+ ? "assistant"
2453
+ : "";
2454
+ if (!role) return null;
2455
+ const content = role === "user"
2456
+ ? queryFirst(section, [
2457
+ ".user-chat-bubble-request__content-wrapper",
2458
+ ".user-chat-bubble-request",
2459
+ ".user-chat-line",
2460
+ ".user-chat-bubble",
2461
+ ".icube-value",
2462
+ ".value",
2463
+ ])
2464
+ : queryFirst(section, [
2465
+ ".assistant-chat-turn-content .chat-markdown",
2466
+ ".assistant-chat-turn-content",
2467
+ ".assistant-chat-turn-content-inner-agent-wrapper",
2468
+ ".chat-markdown",
2469
+ ]);
2470
+ const text = normalizeDomMessageText(content ? extractDomMessageText(content) : section.innerText || section.textContent);
2471
+ if (!text) return null;
2472
+ if (role === "assistant" && /^(Builder|思考过程|任务完成)$/i.test(text)) return null;
2473
+ return {
2474
+ role,
2475
+ text,
2476
+ messageId: getDomMessageId(section, role),
2477
+ order,
2478
+ className: String(section.className || ""),
2479
+ };
2480
+ }
2481
+
2482
+ function updateDomThreadId(turns) {
2483
+ const firstUser = turns.find((turn) => turn.role === "user");
2484
+ if (!firstUser) return;
2485
+ const signature = firstUser.text.slice(0, 240);
2486
+ if (!signature || signature === recorder.domThreadSignature) return;
2487
+ recorder.domThreadSignature = signature;
2488
+ recorder.domThreadId = "dom-thread-" + firstUser.messageId;
2489
+ const hasFreshNetworkSession = recorder.actualSessionId && Date.now() - recorder.actualSessionUpdatedAt < 5000;
2490
+ if (!hasFreshNetworkSession) {
2491
+ recorder.actualSessionId = "";
2492
+ recorder.actualSessionUpdatedAt = 0;
2493
+ recorder.actualSessionSource = "";
2494
+ recorder.captureSessionId = recorder.domThreadId;
2495
+ } else {
2496
+ recorder.captureSessionId = recorder.actualSessionId;
2497
+ }
2498
+ }
2499
+
2500
+ function findDomCaptureRoot() {
2501
+ return document.querySelector("#agent-chat-view")
2502
+ || document.querySelector(".icube-chat-view-container")
2503
+ || document.querySelector(".chat-list-wrapper")
2504
+ || document.body;
2505
+ }
2506
+
2507
+ function captureDomTurns(reason) {
2508
+ if (!document.body) return;
2509
+ const root = findDomCaptureRoot();
2510
+ if (!root) return;
2511
+ const sections = Array.from(root.querySelectorAll("section.chat-turn.user, section.chat-turn.assistant"));
2512
+ const turns = sections.map((section, index) => extractDomTurn(section, index + 1)).filter(Boolean);
2513
+ updateDomThreadId(turns);
2514
+ turns.forEach((turn) => {
2515
+ const sentKey = turn.messageId;
2516
+ const signature = turn.role + "\\u0000" + turn.text;
2517
+ if (domRecorder.sent.get(sentKey) === signature) return;
2518
+ domRecorder.sent.set(sentKey, signature);
2519
+ post("dom-message", {
2520
+ source: "dom",
2521
+ body: {
2522
+ role: turn.role,
2523
+ text: turn.text,
2524
+ messageId: turn.messageId,
2525
+ order: turn.order,
2526
+ reason,
2527
+ className: turn.className,
2528
+ timestamp: new Date().toISOString(),
2529
+ },
2530
+ });
2531
+ });
2532
+ }
2533
+
2534
+ function scheduleDomCapture(reason) {
2535
+ clearTimeout(domRecorder.captureTimer);
2536
+ domRecorder.captureTimer = setTimeout(() => captureDomTurns(reason), 300);
2537
+ }
2538
+
2539
+ function installDomCapture() {
2540
+ if (!document.body || !window.MutationObserver) {
2541
+ return;
2542
+ }
2543
+ captureDomTurns("install");
2544
+ domRecorder.observer = new MutationObserver(() => scheduleDomCapture("mutation"));
2545
+ domRecorder.observer.observe(document.body, {
2546
+ childList: true,
2547
+ characterData: true,
2548
+ subtree: true,
2549
+ });
2550
+ domRecorder.timer = setInterval(() => captureDomTurns("poll"), 2000);
2551
+ }
2552
+
2553
+ installDomCapture();
2554
+
2555
+ console.info("[codex-snapshot] Trae recorder installed. Capturing to", ENDPOINT, "pageSession=", recorder.pageSession);
2556
+ })();`;
2557
+ }
2558
+ function snapshotApiResponse(snapshot) {
2559
+ sanitizeSnapshotTurnHtml(snapshot);
2560
+ return {
2561
+ ...snapshot,
2562
+ transcriptHtml: renderTranscriptHtml(snapshot.turns || [], {
2563
+ emptyHtml: "<div class='meta'>没有找到可分享的用户或助手消息。</div>",
2564
+ includeProcessMessageMeta: true,
2565
+ includeTopLevelToolMeta: true,
2566
+ labels: {
2567
+ processed: "已处理",
2568
+ tool: "Tool",
2569
+ imageUnavailable: "Image unavailable",
2570
+ imageAltPrefix: "Image attachment",
2571
+ },
2572
+ }),
2573
+ };
2574
+ }
2575
+ function applySafetyChecksOption(snapshot, enabled) {
2576
+ snapshot.safetyChecks = Boolean(enabled);
2577
+ if (!enabled) {
2578
+ snapshot.risks = [];
2579
+ snapshot.notices = [];
2580
+ }
2581
+ return snapshot;
2582
+ }
2583
+ function safeFileName(value) {
2584
+ return value.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80) || "codex-snapshot";
2585
+ }
2586
+ function printHelp() {
2587
+ console.log(`codex-snapshot ${VERSION}
2588
+
2589
+ Usage:
2590
+ codex-snapshot list [--json] [--limit N] [--cwd DIR]
2591
+ codex-snapshot preview <session-id|path> [--json] [--include-tools] [--include-tool-output]
2592
+ codex-snapshot export <session-id|path> [--html|--md] [--output FILE] [--include-tools] [--include-tool-output]
2593
+ codex-snapshot publish <session-id|path> [--api-url URL] [--share-token TOKEN] [--site-url URL]
2594
+ codex-snapshot serve [--host 127.0.0.1] [--port 4321]
2595
+ codex-snapshot daemon install|status|logs|uninstall [--host 127.0.0.1] [--port 4321]
2596
+ codex-snapshot record-trae [--host 127.0.0.1] [--port 4732]
2597
+
2598
+ Options:
2599
+ --codex-home DIR Use a custom Codex home. Defaults to $CODEX_HOME or ~/.codex
2600
+ --claude-home DIR Use a custom Claude Code home. Defaults to $CLAUDE_HOME or ~/.claude
2601
+ --trae-home DIR Use a custom Trae home. Defaults to $TRAE_HOME or ~/.trae-cn
2602
+ --trae-app-home DIR Use a custom Trae app data home. Defaults to $TRAE_APP_HOME or ~/Library/Application Support/Trae CN
2603
+ --trae-recordings-dir DIR
2604
+ Use a custom Trae recorder output dir. Defaults to $TRAE_RECORDINGS_DIR or ~/.codex-snapshot/trae-recordings
2605
+ --source codex|claude|trae|all
2606
+ Choose which local agent history to list. Serve shows all configured sources in the UI.
2607
+ --include-tools Include tool calls in previews and exports
2608
+ --include-tool-output Include tool output as well as tool calls
2609
+ --no-redact Disable automatic redaction
2610
+ --allow-unredacted For publish only: allow publishing a --no-redact snapshot
2611
+ --with-safety For publish only: include local safety review rows in the cloud snapshot
2612
+ --api-url URL For publish only: cloud API base. Defaults to $SNAPSHOT_SHARE_API_URL,
2613
+ ~/.codex-snapshots-agent.json, or http://127.0.0.1:8787
2614
+ --site-url URL For publish only: public site base used to print the share link.
2615
+ Defaults to $SNAPSHOT_SHARE_SITE_URL, ~/.codex-snapshots-agent.json, or local share API
2616
+ --share-token TOKEN For publish only: API token. Defaults to $SNAPSHOT_SHARE_TOKEN or ~/.codex-snapshots-agent.json
2617
+ --expires-in-days N For publish only: ask the server to expire the share after N days
2618
+ --label LABEL For daemon only: LaunchAgent label. Defaults to ${DEFAULT_DAEMON_LABEL}
2619
+ --live-only Ignore archived_sessions when listing
2620
+ --record-sensitive-context
2621
+ For record-trae only: persist captured request/response headers as local recorder context
2622
+ -h, --help Show this help
2623
+
2624
+ Examples:
2625
+ codex-snapshot list --limit 20
2626
+ codex-snapshot export 019e457b --html -o snapshot.html
2627
+ codex-snapshot publish 019e457b --api-url http://127.0.0.1:8787 --site-url http://127.0.0.1:8787
2628
+ codex-snapshot serve --port 4321
2629
+ codex-snapshot daemon install
2630
+ codex-snapshot record-trae --port 4732`);
2631
+ }
2632
+ function printDaemonHelp() {
2633
+ console.log(`codex-snapshot daemon
2634
+
2635
+ Usage:
2636
+ codex-snapshot daemon install [--host 127.0.0.1] [--port 4321]
2637
+ codex-snapshot daemon status
2638
+ codex-snapshot daemon logs
2639
+ codex-snapshot daemon uninstall
2640
+
2641
+ Installs a user-level macOS LaunchAgent that starts the npm-installed
2642
+ Codex Snapshots viewer after login.
2643
+
2644
+ Environment:
2645
+ SNAPSHOT_DAEMON_NODE=/absolute/path/to/node
2646
+ SNAPSHOT_DAEMON_CLI=/absolute/path/to/codex-snapshot.mjs
2647
+ SNAPSHOT_LAUNCH_AGENT_LABEL=${DEFAULT_DAEMON_LABEL}
2648
+ SNAPSHOT_SHARE_API_URL=${DEFAULT_SNAPSHOT_SHARE_API_URL}
2649
+ SNAPSHOT_SHARE_SITE_URL=${DEFAULT_SNAPSHOT_SHARE_SITE_URL}
2650
+ SNAPSHOT_VIEWER_ALLOWED_ORIGINS=http://127.0.0.1:3000,http://localhost:3000
2651
+ `);
2652
+ }
2653
+ export { detectRisks, redactText } from "../core/privacy.js";
2654
+ export { renderHtml, renderMarkdown, };