agentel 0.2.6 → 0.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.js CHANGED
@@ -9,7 +9,7 @@ const readline = require("readline");
9
9
  const { spawnSync } = require("child_process");
10
10
  const { pathToFileURL } = require("url");
11
11
  const { disableAutostart, enableAutostart, autostartStatus, recallInvocation } = require("./autostart");
12
- const { archiveRoot, countSessions, ensureConversationMarkdown, ensureSessionView, findSessionById, listSessions, readEvents, readTranscript, revealSession, sessionViewPathForSession, usageTokenSummary, VIEW_SCHEMA_VERSION, writeSession } = require("./archive");
12
+ const { archiveRoot, cachedListSessionsSnapshot, countSessions, ensureConversationMarkdown, ensureSessionView, findSessionById, isWebChatProvider, listSessions, readEvents, readTranscript, revealSession, sessionViewPathForSession, usageTokenSummary, VIEW_SCHEMA_VERSION, walk, writeSession } = require("./archive");
13
13
  const { logsCommand } = require("./commands/logs");
14
14
  const { serverCommand: runServerCommand } = require("./commands/server");
15
15
  const { effectiveImportSources, getConfigKey, initConfig, loadConfig, normalizeRemoteEndpointInput, saveConfig, setConfigKey } = require("./config");
@@ -18,7 +18,7 @@ const { discoverCliHistory, importCliHistory, importWebChat, importWindsurfTraje
18
18
  const { runMcpServer } = require("./mcp");
19
19
  const { defaultRedactionConfig, loadRedactionConfig, redactionBuiltInNames, redactionLabel, saveRedactionConfig } = require("./redaction");
20
20
  const { buildIndex, listHistorySessions, listRecentSessions, readIndexSummary, searchPastSessions } = require("./search");
21
- const { IMPORT_SOURCE_ORDER } = require("./sources");
21
+ const { HISTORY_PROVIDER_OPTIONS, IMPORT_SOURCE_ORDER } = require("./sources");
22
22
  const { ensureBaseDirs, ensureDir, paths, readJson, writeJson } = require("./paths");
23
23
  const { runSupervisorForeground, startSupervisorDetached, stopSupervisor, supervisorStatus } = require("./supervisor");
24
24
  const { configureRemoteFromFlags, hasRemoteTarget, listRemoteSnapshots, replaceRemoteArchive, snapshotArchive, syncArchive, wipeRemoteArchive } = require("./sync");
@@ -27,7 +27,19 @@ const { listWebAccounts, renameWebAccount } = require("./web-accounts");
27
27
  const { webExportInstructions } = require("./web-export-instructions");
28
28
 
29
29
  const HISTORY_AUTH_COOKIE = "agentlog_history";
30
- const SESSION_WEB_PAYLOAD_VERSION = 3;
30
+ const SESSION_WEB_PAYLOAD_VERSION = 6;
31
+ // Bumped when any list-derived payload shape changes (recent/tree/repo-sessions/stats).
32
+ // Combined with the listSessions snapshot fingerprint to form the etag, so a
33
+ // client receiving a 304 is guaranteed to have a payload that still matches the
34
+ // current renderer.
35
+ const LIST_PAYLOAD_VERSION = 1;
36
+ // Memoize statsPayload between requests. Stats are archive-wide aggregates
37
+ // derived purely from the listSessions snapshot, so the snapshot fingerprint
38
+ // + filter shape is a complete cache key. Bounded at a small number of
39
+ // distinct filter shapes (today: includeWebChats true/false × the empty
40
+ // filter set) to keep memory trivial.
41
+ const _statsPayloadCache = new Map();
42
+ const STATS_PAYLOAD_CACHE_LIMIT = 8;
31
43
 
32
44
  async function main(argv = process.argv.slice(2), env = process.env) {
33
45
  const { positionals, flags } = parseArgs(argv);
@@ -153,10 +165,12 @@ async function initCommand(flags, env) {
153
165
  });
154
166
  discoveryProgress.done();
155
167
  const importSources = await chooseImportSources(flags, discovered);
168
+ let backfillSince = "";
156
169
 
157
170
  if (!flags["skip-import"]) {
158
171
  const since = await chooseImportSince(flags);
159
172
  if (since) {
173
+ backfillSince = since;
160
174
  printSection("Backfill");
161
175
  printMuted(`Backing up existing history into ${fullPath(cfg.storage.root || paths(env).data)}`);
162
176
  const progress = createProgressReporter();
@@ -170,6 +184,7 @@ async function initCommand(flags, env) {
170
184
  }
171
185
 
172
186
  const watcherSetup = await chooseWatcherSources(flags, discovered, importSources);
187
+ if (backfillSince) applyUpdateSincePreference(cfg, backfillSince);
173
188
  cfg.imports.sources = watcherSetup.sources;
174
189
  cfg.imports.autoDiscoverSources = watcherSetup.autoDiscoverSources;
175
190
  saveConfig(cfg, env);
@@ -1445,6 +1460,7 @@ function printConfigSetupSummary(cfg, env, loginStatus = null, changed = false)
1445
1460
  printCheck("Config", fullPath(paths(env).config));
1446
1461
  printCheck("Watcher sources", (cfg.imports?.sources || []).join(", ") || "none");
1447
1462
  printCheck("Import window", `${cfg.imports?.defaultSinceDays ?? 30} days`);
1463
+ printCheck("Update rebuild window", defaultImportSince(cfg));
1448
1464
  printCheck("Auto-discover sources", cfg.imports?.autoDiscoverSources === false ? "disabled" : "enabled");
1449
1465
  printCheck("Sync interval", formatConfigInterval(cfg.sync?.intervalMinutes ?? 30));
1450
1466
  if (loginStatus) {
@@ -1796,19 +1812,35 @@ async function updateCommand(flags, env) {
1796
1812
  return;
1797
1813
  }
1798
1814
 
1799
- if (running.running) {
1800
- const stopped = stopSupervisor(env);
1801
- if (stopped) {
1802
- printCheck("Watcher", "stop signal sent");
1803
- await waitForSupervisorExit(running.pid);
1815
+ const preservedWebChats = preserveUpdateWebChatArchives(env);
1816
+ let restoredWebChats = false;
1817
+ try {
1818
+ if (running.running) {
1819
+ const stopped = stopSupervisor(env);
1820
+ if (stopped) {
1821
+ printCheck("Watcher", "stop signal sent");
1822
+ await waitForSupervisorExit(running.pid);
1823
+ }
1804
1824
  }
1805
- }
1806
1825
 
1807
- for (const target of targets) {
1808
- fs.rmSync(target.path, { recursive: true, force: true });
1809
- printCheck("Removed", fullPath(target.path));
1826
+ for (const target of targets) {
1827
+ fs.rmSync(target.path, { recursive: true, force: true });
1828
+ printCheck("Removed", fullPath(target.path));
1829
+ }
1830
+ ensureBaseDirs(p);
1831
+ const restored = restoreUpdateWebChatArchives(preservedWebChats, env);
1832
+ restoredWebChats = true;
1833
+ cleanupUpdatePreservedWebChats(preservedWebChats);
1834
+ if (restored.sessionCount) printCheck("Preserved web chats", `${restored.sessionCount} session(s)`);
1835
+ } catch (error) {
1836
+ if (!restoredWebChats) {
1837
+ const restored = restoreUpdateWebChatArchives(preservedWebChats, env);
1838
+ restoredWebChats = true;
1839
+ if (restored.sessionCount) printMuted(`Restored preserved web chats after update interruption: ${restored.sessionCount} session(s).`);
1840
+ }
1841
+ cleanupUpdatePreservedWebChats(preservedWebChats);
1842
+ throw error;
1810
1843
  }
1811
- ensureBaseDirs(p);
1812
1844
 
1813
1845
  printSection("Import");
1814
1846
  const progress = flags.json ? null : createProgressReporter();
@@ -1852,7 +1884,128 @@ async function updateCommand(flags, env) {
1852
1884
  }
1853
1885
  }
1854
1886
 
1855
- printMuted("Config, redaction settings, web account labels, source histories, and recall integrations were not changed.");
1887
+ printMuted("Config, redaction settings, web account labels, web chat archives, source histories, and recall integrations were not changed.");
1888
+ }
1889
+
1890
+ function preserveUpdateWebChatArchives(env) {
1891
+ const sessions = listSessions(env).filter(updatePreservedWebChatSession);
1892
+ if (!sessions.length) return { tempRoot: "", sessions: [], sessionCount: 0, fileCount: 0 };
1893
+ const archive = archiveRoot(env);
1894
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "agentlog-update-web-chats-"));
1895
+ const copied = new Set();
1896
+ let fileCount = 0;
1897
+ for (const session of sessions) {
1898
+ for (const sourcePath of updateWebChatArchivePaths(session)) {
1899
+ if (copyUpdateArchivePath(sourcePath, archive, tempRoot, copied)) fileCount++;
1900
+ }
1901
+ }
1902
+ return { tempRoot, sessions, sessionCount: sessions.length, fileCount };
1903
+ }
1904
+
1905
+ function updatePreservedWebChatSession(session) {
1906
+ if (isWebChatProvider(session?.provider, session?.sourceType)) return true;
1907
+ return /^\[(chatgpt|claude)\]conversations\//i.test(String(session?.scopeCanonical || ""));
1908
+ }
1909
+
1910
+ function updateWebChatArchivePaths(session) {
1911
+ const values = [
1912
+ session?.conversationPath,
1913
+ session?.metadataPath,
1914
+ session?.transcriptPath,
1915
+ session?.eventPath,
1916
+ session?.viewPath,
1917
+ session?.rawPath
1918
+ ];
1919
+ const rawRecords = Array.isArray(session?.rawFiles) ? session.rawFiles : [];
1920
+ for (const record of rawRecords) {
1921
+ values.push(record?.archivedPath, record?.sharedRawPath);
1922
+ }
1923
+ if (session?.rawPath) {
1924
+ const manifest = readJson(path.join(session.rawPath, "manifest.json"), null);
1925
+ for (const record of Array.isArray(manifest?.files) ? manifest.files : []) {
1926
+ values.push(record?.archivedPath, record?.sharedRawPath);
1927
+ }
1928
+ }
1929
+ return values.filter(Boolean);
1930
+ }
1931
+
1932
+ function copyUpdateArchivePath(sourcePath, archive, tempRoot, copied) {
1933
+ const archiveRootPath = path.resolve(archive);
1934
+ const resolved = path.resolve(String(sourcePath || ""));
1935
+ const relative = path.relative(archiveRootPath, resolved);
1936
+ if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) return false;
1937
+ if (copied.has(relative)) return false;
1938
+ const stat = safeUpdateStat(resolved);
1939
+ if (!stat) return false;
1940
+ const target = path.join(tempRoot, relative);
1941
+ ensureDir(path.dirname(target));
1942
+ if (stat.isDirectory()) {
1943
+ fs.cpSync(resolved, target, { recursive: true, force: true, preserveTimestamps: true });
1944
+ } else if (stat.isFile()) {
1945
+ fs.copyFileSync(resolved, target);
1946
+ } else {
1947
+ return false;
1948
+ }
1949
+ copied.add(relative);
1950
+ return true;
1951
+ }
1952
+
1953
+ function restoreUpdateWebChatArchives(snapshot, env) {
1954
+ if (!snapshot?.tempRoot) return { sessionCount: 0, fileCount: 0 };
1955
+ const archive = archiveRoot(env);
1956
+ ensureDir(archive);
1957
+ let fileCount = 0;
1958
+ walk(snapshot.tempRoot, (file) => {
1959
+ const relative = path.relative(snapshot.tempRoot, file);
1960
+ if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) return;
1961
+ const target = path.join(archive, relative);
1962
+ ensureDir(path.dirname(target));
1963
+ fs.copyFileSync(file, target);
1964
+ fileCount++;
1965
+ });
1966
+ writeUpdatePreservedWebChatManifest(snapshot.sessions || [], env);
1967
+ return { sessionCount: snapshot.sessionCount || (snapshot.sessions || []).length, fileCount };
1968
+ }
1969
+
1970
+ function writeUpdatePreservedWebChatManifest(sessions, env) {
1971
+ if (!sessions.length) return;
1972
+ const entries = sessions
1973
+ .map((session) => {
1974
+ const metadata = readJson(session.metadataPath, null) || session;
1975
+ if (!metadata?.sessionId) return null;
1976
+ return {
1977
+ sessionId: metadata.sessionId,
1978
+ provider: metadata.provider,
1979
+ repoCanonical: metadata.repoCanonical || "",
1980
+ scopeCanonical: metadata.scopeCanonical || "",
1981
+ startedAt: metadata.startedAt || "",
1982
+ conversationPath: metadata.conversationPath || session.conversationPath || "",
1983
+ metadataPath: session.metadataPath || "",
1984
+ transcriptPath: metadata.transcriptPath || session.transcriptPath || "",
1985
+ eventPath: metadata.eventPath || session.eventPath || "",
1986
+ viewPath: metadata.viewPath || session.viewPath || "",
1987
+ rawPath: metadata.rawPath || session.rawPath || ""
1988
+ };
1989
+ })
1990
+ .filter(Boolean)
1991
+ .sort((a, b) => String(b.startedAt).localeCompare(String(a.startedAt)));
1992
+ if (!entries.length) return;
1993
+ const manifestPath = path.join(archiveRoot(env), "sessions", "manifest.json");
1994
+ ensureDir(path.dirname(manifestPath));
1995
+ writeJson(manifestPath, { sessions: entries });
1996
+ }
1997
+
1998
+ function cleanupUpdatePreservedWebChats(snapshot) {
1999
+ if (snapshot?.tempRoot) fs.rmSync(snapshot.tempRoot, { recursive: true, force: true });
2000
+ }
2001
+
2002
+ function safeUpdateStat(target) {
2003
+ try {
2004
+ return fs.statSync(target);
2005
+ } catch (error) {
2006
+ if (error.code === "ENOENT") return null;
2007
+ throw error;
2008
+ }
1856
2009
  }
1857
2010
 
1858
2011
  async function waitForSupervisorExit(pid, timeoutMs = 5000) {
@@ -1907,11 +2060,41 @@ function updateTargets(env) {
1907
2060
  }
1908
2061
 
1909
2062
  function defaultImportSince(config) {
1910
- const value = config?.imports?.defaultSinceDays;
1911
- if (String(value || "").trim().toLowerCase() === "all") return "all";
1912
- const days = Number(value);
1913
- if (!Number.isFinite(days) || days <= 0) return "30d";
1914
- return `${Math.round(days)}d`;
2063
+ const updateSince = normalizeUpdateSincePreference(config?.imports?.updateSince);
2064
+ if (updateSince) return updateSince;
2065
+ return "all";
2066
+ }
2067
+
2068
+ function shouldRememberUpdateSince(source, flags = {}) {
2069
+ if (!(flags.since || flags["import-since"])) return false;
2070
+ return String(source || "").trim().toLowerCase() === "all";
2071
+ }
2072
+
2073
+ function rememberUpdateSincePreference(since, env) {
2074
+ const cfg = loadConfig(env);
2075
+ if (!applyUpdateSincePreference(cfg, since)) return false;
2076
+ cfg.updatedAt = new Date().toISOString();
2077
+ saveConfig(cfg, env);
2078
+ return true;
2079
+ }
2080
+
2081
+ function applyUpdateSincePreference(cfg, since) {
2082
+ const normalized = normalizeUpdateSincePreference(since);
2083
+ if (!normalized) return false;
2084
+ cfg.imports = {
2085
+ ...(cfg.imports || {}),
2086
+ updateSince: normalized
2087
+ };
2088
+ return true;
2089
+ }
2090
+
2091
+ function normalizeUpdateSincePreference(value) {
2092
+ const text = String(value || "").trim().toLowerCase();
2093
+ if (!text) return "";
2094
+ if (text === "all") return "all";
2095
+ const match = text.match(/^(\d+)([dhm])$/);
2096
+ if (!match) return "";
2097
+ return `${Math.max(1, Math.round(Number(match[1])))}${match[2]}`;
1915
2098
  }
1916
2099
 
1917
2100
  function coalesceTargets(targets) {
@@ -2191,27 +2374,34 @@ async function importCommand(args, flags, env) {
2191
2374
  if (flags.instructions || flags.instruction || flags.docs) {
2192
2375
  return printWebExportInstructions(instructions, flags);
2193
2376
  }
2194
- let importFile = flags.file || args[1] || "";
2195
- if (!importFile) return printWebExportInstructions(instructions, flags);
2377
+ let importFiles = webImportFiles(args, flags);
2378
+ if (!importFiles.length) {
2379
+ if (process.stdin.isTTY && !flags.json) return interactiveWebChatImport(sub, instructions, flags, env);
2380
+ return printWebExportInstructions(instructions, flags);
2381
+ }
2196
2382
  let username = flags.username || "";
2197
2383
  let displayName = flags["display-name"] || flags.displayName || "";
2198
2384
  if (!username && process.stdin.isTTY) {
2199
2385
  username = (await ask(`${sub} account username/email: `)).trim();
2200
2386
  if (!displayName) displayName = (await ask(`Display name [${username}]: `)).trim() || username;
2201
2387
  }
2202
- const result = importWebChat(instructions.source, path.resolve(importFile), {
2388
+ importFiles = importFiles.map((file) => resolveUserPath(file, { base: process.cwd() }));
2389
+ const progress = flags.json ? null : createProgressReporter();
2390
+ const result = importWebChat(instructions.source, importFiles.length === 1 ? importFiles[0] : importFiles, {
2203
2391
  scope: flags.scope || "local",
2204
2392
  dryRun: flags["dry-run"],
2205
2393
  username,
2206
2394
  displayName,
2207
- accountId: flags["account-id"] || flags.accountId || ""
2395
+ accountId: flags["account-id"] || flags.accountId || "",
2396
+ onProgress: progress?.update
2208
2397
  }, env);
2398
+ progress?.done();
2209
2399
  if (flags.json) {
2210
2400
  console.log(JSON.stringify(result, null, 2));
2211
2401
  return;
2212
2402
  }
2213
2403
  printPageTitle("agentlog import", `${sub} ${flags["dry-run"] ? "dry run" : "complete"}`);
2214
- printCheck("File", fullPath(result.file));
2404
+ printCheck(importFiles.length === 1 ? "File" : "Files", importFiles.length === 1 ? fullPath(result.file) : importFiles.map(fullPath).join(", "));
2215
2405
  printCheck("Account", `${result.displayName || result.username} (${result.accountId})`);
2216
2406
  printCheck("Conversations", String(result.conversations));
2217
2407
  printCheck("Imported", String(result.imported));
@@ -2226,12 +2416,13 @@ async function importCommand(args, flags, env) {
2226
2416
  : source === "all"
2227
2417
  ? loadConfig(env).imports?.sources
2228
2418
  : undefined;
2419
+ const since = flags.since || flags["import-since"] || "30d";
2229
2420
  const progress = flags.json ? null : createProgressReporter();
2230
2421
  const results = importCliHistory(
2231
2422
  {
2232
2423
  source,
2233
2424
  sources: explicitSources,
2234
- since: flags.since || "30d",
2425
+ since,
2235
2426
  repos,
2236
2427
  dryRun: flags["dry-run"],
2237
2428
  explainSkips: Boolean(flags["explain-skips"]),
@@ -2240,6 +2431,9 @@ async function importCommand(args, flags, env) {
2240
2431
  env
2241
2432
  );
2242
2433
  progress?.done();
2434
+ if (!flags["dry-run"] && shouldRememberUpdateSince(source, flags)) {
2435
+ rememberUpdateSincePreference(since, env);
2436
+ }
2243
2437
  if (flags.json) {
2244
2438
  console.log(JSON.stringify(results, null, 2));
2245
2439
  return;
@@ -2248,6 +2442,92 @@ async function importCommand(args, flags, env) {
2248
2442
  printImportResults(results, { explainSkips: Boolean(flags["explain-skips"]) });
2249
2443
  }
2250
2444
 
2445
+ function webImportFiles(args = [], flags = {}) {
2446
+ const files = [];
2447
+ const flagValue = flags.file || flags.files;
2448
+ if (Array.isArray(flagValue)) files.push(...flagValue);
2449
+ else if (flagValue) files.push(...String(flagValue).split(","));
2450
+ files.push(...args.slice(1));
2451
+ return files.map((item) => String(item || "").trim()).filter(Boolean);
2452
+ }
2453
+
2454
+ async function interactiveWebChatImport(sub, instructions, flags = {}, env = process.env) {
2455
+ if (!instructions) throw new Error("unknown web export instruction source");
2456
+ printPageTitle("agentlog import", `${instructions.source} walkthrough`);
2457
+ printWebExportWalkthroughIntro(instructions);
2458
+
2459
+ const defaultPath = defaultWebExportPath(instructions.source);
2460
+ const defaultPathLabel = defaultPath ? compactPath(defaultPath) : "";
2461
+ const importFiles = [];
2462
+ while (true) {
2463
+ const prompt = importFiles.length
2464
+ ? "Additional export path (blank when done): "
2465
+ : `Export path${defaultPathLabel ? ` [${defaultPathLabel}]` : ""}: `;
2466
+ let answer = (await ask(prompt)).trim();
2467
+ if (!answer && !importFiles.length && defaultPath) answer = defaultPath;
2468
+ if (!answer) break;
2469
+ importFiles.push(resolveUserPath(answer, { base: process.cwd() }));
2470
+ }
2471
+ if (!importFiles.length) {
2472
+ printMuted("No export path provided.");
2473
+ printWebExportInstructionBlock(instructions);
2474
+ return;
2475
+ }
2476
+
2477
+ const usernameDefault = flags.username || "";
2478
+ const usernamePrompt = usernameDefault ? `Account username/email [${usernameDefault}]: ` : "Account username/email: ";
2479
+ const username = flags.username || (await ask(usernamePrompt)).trim();
2480
+ if (!username) throw new Error(`--username is required for a new ${instructions.label} export account`);
2481
+ const displayDefault = flags["display-name"] || flags.displayName || username;
2482
+ const displayName = flags["display-name"] || flags.displayName || ((await ask(`Display name [${displayDefault}]: `)).trim() || displayDefault);
2483
+ const shouldDryRun = Boolean(flags["dry-run"]);
2484
+ const runOptions = {
2485
+ scope: flags.scope || "local",
2486
+ dryRun: shouldDryRun,
2487
+ username,
2488
+ displayName,
2489
+ accountId: flags["account-id"] || flags.accountId || ""
2490
+ };
2491
+ const target = importFiles.length === 1 ? importFiles[0] : importFiles;
2492
+ const progress = createProgressReporter();
2493
+ const result = importWebChat(instructions.source, target, { ...runOptions, onProgress: progress.update }, env);
2494
+ progress.done();
2495
+ printWebChatImportResult(sub, result, importFiles, shouldDryRun);
2496
+ }
2497
+
2498
+ function printWebExportWalkthroughIntro(instructions) {
2499
+ printSection(`${instructions.label} Export`);
2500
+ if (instructions.source === "chatgpt") {
2501
+ printMuted("If you have OpenAI-export.zip and it is large, unzip it first.");
2502
+ printMuted("Best path: the extracted OpenAI-export/User Online Activity folder.");
2503
+ printMuted("If your export is split, enter the parent folder or enter each Conversations__...chatgpt...part folder one at a time, then press Enter on a blank line.");
2504
+ } else {
2505
+ printMuted(`Enter the ${instructions.fileDescription} path. Press Enter on a blank line when done.`);
2506
+ }
2507
+ printCheck("Request page", instructions.requestUrl);
2508
+ printCheck("Help", instructions.helpUrl);
2509
+ }
2510
+
2511
+ function printWebChatImportResult(sub, result, importFiles, dryRun) {
2512
+ printPageTitle("agentlog import", `${sub} ${dryRun ? "dry run" : "complete"}`);
2513
+ printCheck(importFiles.length === 1 ? "File" : "Files", importFiles.length === 1 ? fullPath(result.file) : importFiles.map(fullPath).join(", "));
2514
+ printCheck("Account", `${result.displayName || result.username} (${result.accountId})`);
2515
+ printCheck("Conversations", String(result.conversations));
2516
+ printCheck("Imported", String(result.imported));
2517
+ printCheck("Skipped", String(result.skipped));
2518
+ for (const error of result.errors || []) console.log(` ! ${error}`);
2519
+ }
2520
+
2521
+ function defaultWebExportPath(source) {
2522
+ if (source === "chatgpt") {
2523
+ const activity = path.join(os.homedir(), "Downloads", "OpenAI-export", "User Online Activity");
2524
+ if (fs.existsSync(activity)) return activity;
2525
+ const root = path.join(os.homedir(), "Downloads", "OpenAI-export");
2526
+ if (fs.existsSync(root)) return root;
2527
+ }
2528
+ return "";
2529
+ }
2530
+
2251
2531
  async function accountsCommand(args, flags, env, title = "agentlog accounts") {
2252
2532
  const action = args[0] || "list";
2253
2533
  if (action === "list") {
@@ -2427,7 +2707,11 @@ function readSessionView(sessionId, env) {
2427
2707
 
2428
2708
  function sessionViewPayload(view, env = process.env, options = {}) {
2429
2709
  const baked = ensureSessionView(view.session, env);
2430
- const transcriptMessages = viewerTranscriptMessages(baked.transcript_messages || [], options);
2710
+ const transcriptMessages = viewerTranscriptMessages(baked.transcript_messages || [], {
2711
+ ...options,
2712
+ session: view.session,
2713
+ env
2714
+ });
2431
2715
  const canonicalEvents = viewerCanonicalEvents(baked.canonical_events || [], options);
2432
2716
  const resumeCommand = resumeCommandForSession(view.session);
2433
2717
  const payload = {
@@ -2444,6 +2728,7 @@ function sessionViewPayload(view, env = process.env, options = {}) {
2444
2728
  chat_display_name: view.session.chatDisplayName || undefined,
2445
2729
  chat_project_path: view.session.chatProjectPath || undefined,
2446
2730
  conversation_kind: view.session.conversationKind || undefined,
2731
+ parent_composer_id: view.session.parentComposerId || undefined,
2447
2732
  pinned: Boolean(view.session.pinned) || undefined,
2448
2733
  cwd: view.session.cwd || undefined,
2449
2734
  title: view.session.title || undefined,
@@ -2469,8 +2754,44 @@ function sessionViewPayload(view, env = process.env, options = {}) {
2469
2754
  }
2470
2755
 
2471
2756
  function viewerTranscriptMessages(messages, options = {}) {
2472
- if (!options.compactTranscriptMetadata) return messages;
2473
- return messages.map(compactViewerTranscriptMessage);
2757
+ const withAttachments = options.session?.sessionId
2758
+ ? messages.map((message, index) => viewerMessageWithAttachmentUrls(message, options.session, index, options.env || process.env))
2759
+ : messages;
2760
+ if (!options.compactTranscriptMetadata) return withAttachments;
2761
+ return withAttachments.map(compactViewerTranscriptMessage);
2762
+ }
2763
+
2764
+ function viewerMessageWithAttachmentUrls(message, session, position, env = process.env) {
2765
+ if (!message || typeof message !== "object") return message;
2766
+ const metadata = message.metadata && typeof message.metadata === "object" ? message.metadata : null;
2767
+ const attachments = Array.isArray(metadata?.attachments) ? metadata.attachments : [];
2768
+ if (!attachments.length) return message;
2769
+ const messageIndex = Number.isFinite(Number(message.index)) ? Number(message.index) : position;
2770
+ return {
2771
+ ...message,
2772
+ metadata: {
2773
+ ...metadata,
2774
+ attachments: attachments.map((attachment, attachmentIndex) => ({
2775
+ ...attachment,
2776
+ ...viewerAttachmentAvailability(session, message, attachment, attachmentIndex, env, messageIndex)
2777
+ }))
2778
+ }
2779
+ };
2780
+ }
2781
+
2782
+ function viewerAttachmentAvailability(session, message, attachment, attachmentIndex, env, messageIndex) {
2783
+ if (attachment?.url) return { url: attachment.url, available: true };
2784
+ const resolved = resolveSessionAttachmentRecord(session, message, attachment, attachmentIndex, env, { probeOnly: true });
2785
+ if (!resolved) return { available: false };
2786
+ if (resolved.cleanup) resolved.cleanup();
2787
+ return {
2788
+ url: sessionAttachmentUrl(session.sessionId, messageIndex, attachmentIndex),
2789
+ available: true
2790
+ };
2791
+ }
2792
+
2793
+ function sessionAttachmentUrl(sessionId, messageIndex, attachmentIndex) {
2794
+ return `/api/session-attachment?id=${encodeURIComponent(sessionId)}&message=${encodeURIComponent(String(messageIndex))}&attachment=${encodeURIComponent(String(attachmentIndex))}`;
2474
2795
  }
2475
2796
 
2476
2797
  function compactViewerTranscriptMessage(message) {
@@ -2652,19 +2973,30 @@ function historyWebCommand(flags, env) {
2652
2973
  }
2653
2974
  if (url.pathname === "/api/recent") {
2654
2975
  const filters = historyFiltersFromSearchParams(url.searchParams, 20);
2655
- writeJsonResponse(res, listRecentSessions(filters.limit, filters, env));
2976
+ respondWithListSnapshot(req, res, "recent", filterShapeKey(filters), env, () =>
2977
+ listRecentSessions(filters.limit, filters, env)
2978
+ );
2656
2979
  return;
2657
2980
  }
2658
2981
  if (url.pathname === "/api/tree") {
2659
2982
  const filters = historyFiltersFromSearchParams(url.searchParams, 20);
2660
- writeJsonResponse(res, sessionTreePayload(filters, env));
2983
+ respondWithListSnapshot(req, res, "tree", filterShapeKey(filters), env, () =>
2984
+ sessionTreePayload(filters, env)
2985
+ );
2661
2986
  return;
2662
2987
  }
2663
2988
  if (url.pathname === "/api/repo-sessions") {
2664
2989
  const filters = historyFiltersFromSearchParams(url.searchParams, 20);
2665
2990
  const repoKey = url.searchParams.get("repo_key") || "";
2666
2991
  const offset = Math.max(0, Number(url.searchParams.get("offset") || 0));
2667
- writeJsonResponse(res, repoSessionsPayload(repoKey, filters, offset, env));
2992
+ respondWithListSnapshot(
2993
+ req,
2994
+ res,
2995
+ "repo-sessions",
2996
+ repoSessionsFilterKey(repoKey, filters, offset),
2997
+ env,
2998
+ () => repoSessionsPayload(repoKey, filters, offset, env)
2999
+ );
2668
3000
  return;
2669
3001
  }
2670
3002
  if (url.pathname === "/api/search" || url.pathname === "/search") {
@@ -2677,6 +3009,10 @@ function historyWebCommand(flags, env) {
2677
3009
  writeJsonResponse(res, results);
2678
3010
  return;
2679
3011
  }
3012
+ if (url.pathname === "/api/session-attachment") {
3013
+ serveSessionAttachment(res, url.searchParams, env);
3014
+ return;
3015
+ }
2680
3016
  if (url.pathname === "/api/session") {
2681
3017
  try {
2682
3018
  const includeMarkdown = url.searchParams.get("markdown") === "1";
@@ -2713,7 +3049,9 @@ function historyWebCommand(flags, env) {
2713
3049
  }
2714
3050
  if (url.pathname === "/api/stats") {
2715
3051
  const filters = statsFiltersFromSearchParams(url.searchParams);
2716
- writeJsonResponse(res, statsPayload(filters, env));
3052
+ respondWithListSnapshot(req, res, "stats", filterShapeKey({ ...filters, limit: 0 }), env, (snapshot) =>
3053
+ memoizedStatsPayload(snapshot, filters, env)
3054
+ );
2717
3055
  return;
2718
3056
  }
2719
3057
  if (isHome) {
@@ -2775,6 +3113,7 @@ function statsFiltersFromSearchParams(params) {
2775
3113
  function sessionTreePayload(filters, env) {
2776
3114
  const pageSize = clampLimit(filters.limit, 20);
2777
3115
  const sessions = listHistorySessions(filters, env);
3116
+ const sourceOptionSessions = listHistorySessions(historySourceOptionFilters(filters), env);
2778
3117
  const repos = new Map();
2779
3118
  for (const session of sessions) {
2780
3119
  const repoKey = sessionRepoKey(session);
@@ -2805,10 +3144,60 @@ function sessionTreePayload(filters, env) {
2805
3144
  return {
2806
3145
  generated_at: new Date().toISOString(),
2807
3146
  count: sessions.length,
3147
+ available_source_options: availableHistorySourceOptions(sourceOptionSessions),
2808
3148
  groups
2809
3149
  };
2810
3150
  }
2811
3151
 
3152
+ function historySourceOptionFilters(filters) {
3153
+ return {
3154
+ ...(filters || {}),
3155
+ provider: "",
3156
+ source: "",
3157
+ sourceType: "",
3158
+ source_type: ""
3159
+ };
3160
+ }
3161
+
3162
+ function availableHistorySourceOptions(sessions) {
3163
+ const rows = Array.isArray(sessions) ? sessions : [];
3164
+ return HISTORY_PROVIDER_OPTIONS
3165
+ .map((option) => {
3166
+ const count = rows.filter((session) => historySourceOptionMatchesSession(option.source, session)).length;
3167
+ return count ? { value: option.source, label: option.label, count } : null;
3168
+ })
3169
+ .filter(Boolean);
3170
+ }
3171
+
3172
+ function historySourceOptionMatchesSession(source, session) {
3173
+ const provider = String(session?.provider || "").toLowerCase();
3174
+ const sourceType = String(session?.source_type || session?.sourceType || "").toLowerCase();
3175
+ const sourceTypes = {
3176
+ "codex-cli": ["codex-cli-history", "cli-history"],
3177
+ "codex-desktop": ["codex-desktop-history"],
3178
+ "codex-sdk": ["codex-sdk-history"],
3179
+ "claude-code-desktop": ["claude-code-desktop-metadata"],
3180
+ "claude-workspace": ["claude-workspace-desktop"],
3181
+ "claude-sdk": ["claude-sdk-history"],
3182
+ "devin-cli": ["devin-cli-history"],
3183
+ cline: ["cline-task-history"],
3184
+ "opencode-cli": ["opencode-cli-history", "opencode-cli-sqlite-history"],
3185
+ "opencode-desktop": ["opencode-desktop-history", "opencode-desktop-sqlite-history"],
3186
+ "opencode-web": ["opencode-web-sqlite-history"],
3187
+ aider: ["aider-chat-history"]
3188
+ }[source];
3189
+ if (sourceTypes) return sourceTypes.includes(sourceType);
3190
+ const providers = {
3191
+ chatgpt: ["chatgpt"],
3192
+ claude: ["claude_code"],
3193
+ "claude-web": ["claude_web"],
3194
+ "gemini-cli": ["gemini_cli"],
3195
+ antigravity: ["antigravity"],
3196
+ cursor: ["cursor"]
3197
+ }[source];
3198
+ return providers ? providers.includes(provider) : false;
3199
+ }
3200
+
2812
3201
  function historySessionUpdatedAt(session) {
2813
3202
  if (session.time_status === "recovered-time-unknown") return "";
2814
3203
  return session.ended_at || session.started_at || session.archived_at || "";
@@ -2850,6 +3239,7 @@ function statsPayloadForSessions(sessions, options = {}) {
2850
3239
  let totalInputTokens = 0;
2851
3240
  let totalOutputTokens = 0;
2852
3241
  let totalCacheTokens = 0;
3242
+ let totalReasoningTokens = 0;
2853
3243
  let totalEstimatedTokens = 0;
2854
3244
  let totalConversations = 0;
2855
3245
  let totalMessages = 0;
@@ -2873,6 +3263,7 @@ function statsPayloadForSessions(sessions, options = {}) {
2873
3263
  totalInputTokens += usageTokens.tokens_input;
2874
3264
  totalOutputTokens += usageTokens.tokens_output;
2875
3265
  totalCacheTokens += usageTokens.tokens_cache;
3266
+ totalReasoningTokens += usageTokens.tokens_reasoning;
2876
3267
  totalEstimatedTokens += usageTokens.tokens_estimated;
2877
3268
  totalConversations += 1;
2878
3269
  totalMessages += Number.isFinite(messageCount) ? messageCount : 0;
@@ -2900,7 +3291,7 @@ function statsPayloadForSessions(sessions, options = {}) {
2900
3291
  dayBucket.models[modelGroup].conversations += 1;
2901
3292
  dayBucket.models[modelGroup].user_messages += userMessageCount;
2902
3293
 
2903
- if (!dailyActivity.has(dayKey)) dailyActivity.set(dayKey, { sessions: 0, conversations: 0, user_messages: 0, tokens: 0, tokens_input: 0, tokens_output: 0, tokens_cache: 0, tokens_estimated: 0, messages: 0 });
3294
+ if (!dailyActivity.has(dayKey)) dailyActivity.set(dayKey, { sessions: 0, conversations: 0, user_messages: 0, tokens: 0, tokens_input: 0, tokens_output: 0, tokens_cache: 0, tokens_reasoning: 0, tokens_estimated: 0, messages: 0 });
2904
3295
  const activity = dailyActivity.get(dayKey);
2905
3296
  activity.sessions += 1;
2906
3297
  activity.conversations += 1;
@@ -2912,7 +3303,7 @@ function statsPayloadForSessions(sessions, options = {}) {
2912
3303
  if (!isWebChatStatsProvider(provider)) {
2913
3304
  const repoKey = sessionRepoKey(session);
2914
3305
  const repoLabel = sessionRepoLabel(session);
2915
- if (!repoMap.has(repoKey)) repoMap.set(repoKey, { repo_key: repoKey, repo_display: repoLabel, providers: {}, companies: {}, models: {}, tokens: 0, tokens_input: 0, tokens_output: 0, tokens_cache: 0, tokens_estimated: 0, conversations: 0, user_messages: 0 });
3306
+ if (!repoMap.has(repoKey)) repoMap.set(repoKey, { repo_key: repoKey, repo_display: repoLabel, providers: {}, companies: {}, models: {}, tokens: 0, tokens_input: 0, tokens_output: 0, tokens_cache: 0, tokens_reasoning: 0, tokens_estimated: 0, conversations: 0, user_messages: 0 });
2916
3307
  const repoBucket = repoMap.get(repoKey);
2917
3308
  repoBucket.repo_display = repoLabel;
2918
3309
  if (!repoBucket.providers[provider]) repoBucket.providers[provider] = statsBucket();
@@ -2934,7 +3325,7 @@ function statsPayloadForSessions(sessions, options = {}) {
2934
3325
  if (dayKey) {
2935
3326
  if (!dailyRepoMap.has(dayKey)) dailyRepoMap.set(dayKey, new Map());
2936
3327
  const dailyRepos = dailyRepoMap.get(dayKey);
2937
- if (!dailyRepos.has(repoKey)) dailyRepos.set(repoKey, { repo_key: repoKey, repo_display: repoLabel, providers: {}, companies: {}, models: {}, tokens: 0, tokens_input: 0, tokens_output: 0, tokens_cache: 0, tokens_estimated: 0, conversations: 0, user_messages: 0 });
3328
+ if (!dailyRepos.has(repoKey)) dailyRepos.set(repoKey, { repo_key: repoKey, repo_display: repoLabel, providers: {}, companies: {}, models: {}, tokens: 0, tokens_input: 0, tokens_output: 0, tokens_cache: 0, tokens_reasoning: 0, tokens_estimated: 0, conversations: 0, user_messages: 0 });
2938
3329
  const dailyRepoBucket = dailyRepos.get(repoKey);
2939
3330
  dailyRepoBucket.repo_display = repoLabel;
2940
3331
  if (!dailyRepoBucket.providers[provider]) dailyRepoBucket.providers[provider] = statsBucket();
@@ -3015,6 +3406,7 @@ function statsPayloadForSessions(sessions, options = {}) {
3015
3406
  tokens_input: entry.tokens_input,
3016
3407
  tokens_output: entry.tokens_output,
3017
3408
  tokens_cache: entry.tokens_cache,
3409
+ tokens_reasoning: entry.tokens_reasoning,
3018
3410
  tokens_estimated: entry.tokens_estimated,
3019
3411
  conversations: entry.conversations,
3020
3412
  user_messages: entry.user_messages,
@@ -3037,6 +3429,7 @@ function statsPayloadForSessions(sessions, options = {}) {
3037
3429
  tokens_input: entry.tokens_input,
3038
3430
  tokens_output: entry.tokens_output,
3039
3431
  tokens_cache: entry.tokens_cache,
3432
+ tokens_reasoning: entry.tokens_reasoning,
3040
3433
  tokens_estimated: entry.tokens_estimated,
3041
3434
  conversations: entry.conversations,
3042
3435
  user_messages: entry.user_messages,
@@ -3083,7 +3476,7 @@ function statsPayloadForSessions(sessions, options = {}) {
3083
3476
  const favoriteProviderEntry = byProvider[0] || null;
3084
3477
  const dailyActivityList = activitySorted.map((day) => {
3085
3478
  const entry = dailyActivity.get(day);
3086
- return { date: day, sessions: entry.sessions, conversations: entry.conversations, user_messages: entry.user_messages, messages: entry.messages, tokens: entry.tokens, tokens_input: entry.tokens_input, tokens_output: entry.tokens_output, tokens_cache: entry.tokens_cache, tokens_estimated: entry.tokens_estimated };
3479
+ return { date: day, sessions: entry.sessions, conversations: entry.conversations, user_messages: entry.user_messages, messages: entry.messages, tokens: entry.tokens, tokens_input: entry.tokens_input, tokens_output: entry.tokens_output, tokens_cache: entry.tokens_cache, tokens_reasoning: entry.tokens_reasoning, tokens_estimated: entry.tokens_estimated };
3087
3480
  });
3088
3481
  const monthAgg = new Map();
3089
3482
  let peakDayTokens = 0;
@@ -3135,6 +3528,7 @@ function statsPayloadForSessions(sessions, options = {}) {
3135
3528
  sdk_total_input_tokens: sdkStats ? sdkStats.total_input_tokens : undefined,
3136
3529
  sdk_total_output_tokens: sdkStats ? sdkStats.total_output_tokens : undefined,
3137
3530
  sdk_total_cache_tokens: sdkStats ? sdkStats.total_cache_tokens : undefined,
3531
+ sdk_total_reasoning_tokens: sdkStats ? sdkStats.total_reasoning_tokens : undefined,
3138
3532
  sdk_total_estimated_tokens: sdkStats ? sdkStats.total_estimated_tokens : undefined,
3139
3533
  message_count: totalMessages,
3140
3534
  user_message_count: totalUserMessages,
@@ -3142,6 +3536,7 @@ function statsPayloadForSessions(sessions, options = {}) {
3142
3536
  total_input_tokens: totalInputTokens,
3143
3537
  total_output_tokens: totalOutputTokens,
3144
3538
  total_cache_tokens: totalCacheTokens,
3539
+ total_reasoning_tokens: totalReasoningTokens,
3145
3540
  total_estimated_tokens: totalEstimatedTokens,
3146
3541
  active_days: activeDays,
3147
3542
  current_streak: currentStreak,
@@ -3261,7 +3656,7 @@ function statsUsageForSession(session) {
3261
3656
  }
3262
3657
 
3263
3658
  function statsBucket() {
3264
- return { tokens: 0, tokens_input: 0, tokens_output: 0, tokens_cache: 0, tokens_estimated: 0, conversations: 0, user_messages: 0 };
3659
+ return { tokens: 0, tokens_input: 0, tokens_output: 0, tokens_cache: 0, tokens_reasoning: 0, tokens_estimated: 0, conversations: 0, user_messages: 0 };
3265
3660
  }
3266
3661
 
3267
3662
  function statsUserMessageCount(session) {
@@ -3275,6 +3670,7 @@ function addStatsTokens(bucket, usageTokens) {
3275
3670
  bucket.tokens_input += usageTokens.tokens_input;
3276
3671
  bucket.tokens_output += usageTokens.tokens_output;
3277
3672
  bucket.tokens_cache += usageTokens.tokens_cache;
3673
+ bucket.tokens_reasoning += usageTokens.tokens_reasoning;
3278
3674
  bucket.tokens_estimated += usageTokens.tokens_estimated;
3279
3675
  }
3280
3676
 
@@ -3282,15 +3678,32 @@ function statsTokenBreakdown(usage) {
3282
3678
  let input = positiveStatsNumber(usage?.inputTokens);
3283
3679
  let output = positiveStatsNumber(usage?.outputTokens);
3284
3680
  const cache = positiveStatsNumber(usage?.cacheInputTokens);
3285
- const splitTokens = input + output;
3286
- const totalTokens = Number(usage?.totalTokens || 0);
3287
- if (!splitTokens && Number.isFinite(totalTokens) && totalTokens > 0) input = totalTokens;
3681
+ const cacheIncluded =
3682
+ usage?.cacheInputTokensIncludedInInput === true ||
3683
+ usage?.cache_input_tokens_included_in_input === true ||
3684
+ usage?.cacheReadTokensIncludedInInput === true ||
3685
+ usage?.cache_read_tokens_included_in_input === true;
3686
+ const countedCache = cacheIncluded ? 0 : cache;
3687
+ const reasoning = positiveStatsNumber(usage?.reasoningOutputTokens);
3688
+ const reasoningIncluded =
3689
+ usage?.reasoningOutputTokensIncludedInOutput === true ||
3690
+ usage?.reasoning_output_tokens_included_in_output === true ||
3691
+ usage?.reasoningTokensIncludedInOutput === true ||
3692
+ usage?.reasoning_tokens_included_in_output === true;
3693
+ const countedReasoning = reasoningIncluded ? 0 : reasoning;
3694
+ const splitTokens = input + output + countedCache + countedReasoning;
3695
+ const totalTokens = positiveStatsNumber(usage?.totalTokens);
3696
+ if (!splitTokens && totalTokens > 0) input = totalTokens;
3697
+ const categoryTokens = input + output + countedCache + countedReasoning;
3698
+ const authoritative = usage?.authoritativeTotalTokens === true || usage?.authoritative_total_tokens === true;
3699
+ const tokens = authoritative && totalTokens ? totalTokens : Math.max(totalTokens, categoryTokens);
3288
3700
  return {
3289
- tokens: input + output,
3701
+ tokens,
3290
3702
  tokens_input: input,
3291
3703
  tokens_output: output,
3292
3704
  tokens_cache: cache,
3293
- tokens_estimated: usage?.estimated ? input + output : 0
3705
+ tokens_reasoning: reasoning,
3706
+ tokens_estimated: usage?.estimated ? tokens : 0
3294
3707
  };
3295
3708
  }
3296
3709
 
@@ -3470,6 +3883,90 @@ function writeNotModifiedSessionResponse(res, etag) {
3470
3883
  res.end();
3471
3884
  }
3472
3885
 
3886
+ /**
3887
+ * Compute an etag for a list-derived endpoint (recent/tree/repo-sessions/stats).
3888
+ * Combines the listSessions snapshot fingerprint with the endpoint kind,
3889
+ * filter shape, and renderer version. The fingerprint changes iff any
3890
+ * session's metadata mtime/size changed or the count changed, so the etag
3891
+ * automatically invalidates when the supervisor imports new history.
3892
+ */
3893
+ function listSnapshotEtag(snapshot, kind, filterKey) {
3894
+ if (!snapshot || !snapshot.fingerprint) return "";
3895
+ const components = [
3896
+ snapshot.fingerprint,
3897
+ String(kind || ""),
3898
+ String(filterKey || ""),
3899
+ `v${LIST_PAYLOAD_VERSION}`
3900
+ ].join("|");
3901
+ const digest = crypto.createHash("sha1").update(components).digest("base64url");
3902
+ return `"list-${digest}"`;
3903
+ }
3904
+
3905
+ function filterShapeKey(filters) {
3906
+ if (!filters || typeof filters !== "object") return "default";
3907
+ return [
3908
+ `repo=${filters.repo || ""}`,
3909
+ `provider=${filters.provider || ""}`,
3910
+ `sourceType=${filters.sourceType || ""}`,
3911
+ `since=${filters.since || ""}`,
3912
+ `limit=${filters.limit ?? ""}`,
3913
+ `webChats=${filters.includeWebChats === false ? "0" : "1"}`
3914
+ ].join("&");
3915
+ }
3916
+
3917
+ function repoSessionsFilterKey(repoKey, filters, offset) {
3918
+ return `${repoKey || ""}|${offset || 0}|${filterShapeKey(filters)}`;
3919
+ }
3920
+
3921
+ /**
3922
+ * Wrap a list endpoint with snapshot-based etag handling.
3923
+ *
3924
+ * - `kind` is a stable string per endpoint (e.g. "recent", "tree").
3925
+ * - `filterKey` describes the shape of filters/pagination so distinct queries
3926
+ * get distinct etags.
3927
+ * - `compute(snapshot)` builds the payload. The snapshot is passed so callers
3928
+ * can avoid re-walking the archive.
3929
+ *
3930
+ * Returns true once the response has been written (either 304 or full body),
3931
+ * letting the dispatcher `return` immediately. Errors fall through to the
3932
+ * caller so existing handlers keep their error shape.
3933
+ */
3934
+ function respondWithListSnapshot(req, res, kind, filterKey, env, compute) {
3935
+ const snapshot = cachedListSessionsSnapshot(env);
3936
+ const etag = listSnapshotEtag(snapshot, kind, filterKey);
3937
+ if (etag && requestMatchesEtag(req, etag)) {
3938
+ res.writeHead(304, {
3939
+ ...securityHeaders("application/json; charset=utf-8"),
3940
+ ...sessionCacheHeaders(etag)
3941
+ });
3942
+ res.end();
3943
+ return true;
3944
+ }
3945
+ const payload = compute(snapshot);
3946
+ writeJsonResponse(res, payload, 200, { headers: sessionCacheHeaders(etag) });
3947
+ return true;
3948
+ }
3949
+
3950
+ function memoizedStatsPayload(snapshot, filters, env) {
3951
+ const filterKey = filterShapeKey({ ...filters, limit: 0 });
3952
+ const cacheKey = `${snapshot.fingerprint}|${filterKey}`;
3953
+ const cached = _statsPayloadCache.get(cacheKey);
3954
+ if (cached) {
3955
+ // Refresh LRU position so frequently-used payloads don't get evicted by
3956
+ // less-used filter combinations.
3957
+ _statsPayloadCache.delete(cacheKey);
3958
+ _statsPayloadCache.set(cacheKey, cached);
3959
+ return cached;
3960
+ }
3961
+ const payload = statsPayload(filters, env);
3962
+ _statsPayloadCache.set(cacheKey, payload);
3963
+ while (_statsPayloadCache.size > STATS_PAYLOAD_CACHE_LIMIT) {
3964
+ const oldest = _statsPayloadCache.keys().next().value;
3965
+ _statsPayloadCache.delete(oldest);
3966
+ }
3967
+ return payload;
3968
+ }
3969
+
3473
3970
  function writeJsonResponse(res, payload, status = 200, options = {}) {
3474
3971
  res.writeHead(status, {
3475
3972
  ...securityHeaders("application/json; charset=utf-8"),
@@ -3478,6 +3975,469 @@ function writeJsonResponse(res, payload, status = 200, options = {}) {
3478
3975
  res.end(options.pretty ? JSON.stringify(payload, null, 2) : JSON.stringify(payload));
3479
3976
  }
3480
3977
 
3978
+ function serveSessionAttachment(res, params, env = process.env) {
3979
+ let resolved;
3980
+ try {
3981
+ resolved = resolveSessionAttachmentFile(params, env);
3982
+ } catch (error) {
3983
+ writeJsonResponse(res, { error: error.message }, 404);
3984
+ return;
3985
+ }
3986
+ let stat;
3987
+ try {
3988
+ stat = fs.statSync(resolved.file);
3989
+ } catch (error) {
3990
+ if (resolved.cleanup) resolved.cleanup();
3991
+ writeJsonResponse(res, { error: error.message }, 404);
3992
+ return;
3993
+ }
3994
+ const headers = {
3995
+ ...securityHeaders(resolved.contentType || "application/octet-stream"),
3996
+ "cache-control": "private, max-age=3600",
3997
+ "content-length": String(stat.size),
3998
+ "content-disposition": contentDispositionInline(resolved.name || path.basename(resolved.file))
3999
+ };
4000
+ res.writeHead(200, headers);
4001
+ const stream = fs.createReadStream(resolved.file);
4002
+ let cleaned = false;
4003
+ const cleanup = () => {
4004
+ if (cleaned) return;
4005
+ cleaned = true;
4006
+ if (resolved.cleanup) resolved.cleanup();
4007
+ };
4008
+ stream.on("error", (error) => {
4009
+ cleanup();
4010
+ res.destroy(error);
4011
+ });
4012
+ stream.on("close", cleanup);
4013
+ res.on("close", cleanup);
4014
+ stream.pipe(res);
4015
+ }
4016
+
4017
+ function contentDispositionInline(name) {
4018
+ const safe = String(name || "attachment")
4019
+ .replace(/[\\/\r\n"]/g, "_")
4020
+ .slice(0, 180) || "attachment";
4021
+ return `inline; filename="${safe}"`;
4022
+ }
4023
+
4024
+ function resolveSessionAttachmentFile(params, env = process.env, options = {}) {
4025
+ const sessionId = paramValue(params, "id");
4026
+ const messageNumber = Number(paramValue(params, "message"));
4027
+ const attachmentNumber = Number(paramValue(params, "attachment"));
4028
+ if (!sessionId) throw new Error("missing session id");
4029
+ if (!Number.isInteger(messageNumber) || messageNumber < 0) throw new Error("missing message index");
4030
+ if (!Number.isInteger(attachmentNumber) || attachmentNumber < 0) throw new Error("missing attachment index");
4031
+ const session = findSessionById(sessionId, env);
4032
+ if (!session) throw new Error(`no archived session found for ${sessionId}`);
4033
+ const messages = readTranscript(session.transcriptPath);
4034
+ const message = messages.find((item) => Number(item?.index) === messageNumber) || messages[messageNumber];
4035
+ const attachments = Array.isArray(message?.metadata?.attachments) ? message.metadata.attachments : [];
4036
+ const attachment = attachments[attachmentNumber];
4037
+ if (!attachment) throw new Error("attachment not found");
4038
+ const record = resolveSessionAttachmentRecord(session, message, attachment, attachmentNumber, env, options);
4039
+ if (!record) throw new Error("attachment file was not included in the raw export");
4040
+ return record;
4041
+ }
4042
+
4043
+ function resolveSessionAttachmentRecord(session, message, attachment, attachmentNumber, env = process.env, options = {}) {
4044
+ const context = attachmentResolutionContext(session, message, attachment, attachmentNumber);
4045
+ const resolved = resolveAttachmentFromRawManifests(session, context, env, options);
4046
+ if (!resolved) return null;
4047
+ return {
4048
+ ...resolved,
4049
+ name: attachment.name || resolved.name,
4050
+ contentType: attachmentContentType(attachment, resolved.name || resolved.file)
4051
+ };
4052
+ }
4053
+
4054
+ function paramValue(params, key) {
4055
+ if (params && typeof params.get === "function") return params.get(key) || "";
4056
+ return params?.[key] || "";
4057
+ }
4058
+
4059
+ function attachmentResolutionContext(session, message, attachment, attachmentIndex) {
4060
+ const conversationEntryPath = webRawReferenceEntryPath(session);
4061
+ const conversationDir = conversationEntryPath ? posixDirname(conversationEntryPath) : "";
4062
+ const conversationPart = conversationDir.split("/").filter(Boolean).pop() || "";
4063
+ const assetPointers = Array.isArray(message?.metadata?.assetPointers) ? message.metadata.assetPointers : [];
4064
+ const names = new Set();
4065
+ const ids = new Set();
4066
+ const pointers = new Set();
4067
+ addAttachmentName(names, attachment?.name);
4068
+ addAttachmentId(ids, attachment?.id);
4069
+ addAttachmentId(ids, attachment?.libraryFileId);
4070
+ addAttachmentPointer(pointers, attachment?.assetPointer);
4071
+ if (assetPointers.length === 1 || assetPointers.length === attachmentIndex + 1) {
4072
+ addAttachmentPointer(pointers, assetPointers[attachmentIndex]?.assetPointer);
4073
+ }
4074
+ for (const pointer of pointers) addAttachmentId(ids, pointer);
4075
+ return {
4076
+ attachment,
4077
+ attachmentIndex,
4078
+ names: [...names],
4079
+ ids: [...ids],
4080
+ pointers: [...pointers],
4081
+ conversationEntryPath,
4082
+ conversationDir,
4083
+ conversationPart,
4084
+ isImage: attachmentIsImage(attachment)
4085
+ };
4086
+ }
4087
+
4088
+ function addAttachmentName(set, value) {
4089
+ const base = posixBasename(String(value || "").trim());
4090
+ if (base) set.add(base);
4091
+ }
4092
+
4093
+ function addAttachmentId(set, value) {
4094
+ const token = attachmentToken(value);
4095
+ if (token) set.add(token);
4096
+ }
4097
+
4098
+ function addAttachmentPointer(set, value) {
4099
+ const text = String(value || "").trim();
4100
+ if (text) set.add(text);
4101
+ }
4102
+
4103
+ function attachmentToken(value) {
4104
+ return posixBasename(String(value || "")
4105
+ .trim()
4106
+ .toLowerCase()
4107
+ .replace(/^file-service:\/\//, "")
4108
+ .replace(/^sandbox:\/\//, "")
4109
+ .replace(/^attachment:\/\//, "")
4110
+ .replace(/[?#].*$/, ""));
4111
+ }
4112
+
4113
+ function webRawReferenceEntryPath(session) {
4114
+ const ref = (Array.isArray(session?.rawFiles) ? session.rawFiles : [])
4115
+ .find((item) => item?.filename === "web-export-reference" && item.entryPath);
4116
+ return ref?.entryPath || "";
4117
+ }
4118
+
4119
+ function resolveAttachmentFromRawManifests(session, context, env = process.env, options = {}) {
4120
+ const manifests = webRawExportManifests(session, env);
4121
+ for (const manifest of manifests) {
4122
+ const direct = bestDirectRawAttachment(manifest, context);
4123
+ if (direct) return direct;
4124
+ }
4125
+ for (const manifest of manifests) {
4126
+ const zipped = bestZipRawAttachment(manifest, context, options);
4127
+ if (zipped) return zipped;
4128
+ }
4129
+ return null;
4130
+ }
4131
+
4132
+ function webRawExportManifests(session, env = process.env) {
4133
+ const manifests = [];
4134
+ for (const raw of Array.isArray(session?.rawFiles) ? session.rawFiles : []) {
4135
+ if (raw?.filename !== "web-export-reference" && !/web export raw manifest/i.test(String(raw?.note || ""))) continue;
4136
+ const manifestPath = raw.archivedPath || "";
4137
+ const manifest = manifestPath ? readJson(manifestPath, null) : null;
4138
+ if (manifest && typeof manifest === "object") manifests.push({ manifest, reference: raw, manifestPath });
4139
+ }
4140
+ const sessionManifestPath = session?.rawPath ? path.join(session.rawPath, "manifest.json") : "";
4141
+ const sessionManifest = sessionManifestPath ? readJson(sessionManifestPath, null) : null;
4142
+ if (sessionManifest && typeof sessionManifest === "object") {
4143
+ manifests.push({ manifest: sessionManifest, reference: null, manifestPath: sessionManifestPath });
4144
+ }
4145
+ return manifests;
4146
+ }
4147
+
4148
+ function bestDirectRawAttachment(rawManifest, context) {
4149
+ let best = null;
4150
+ for (const file of Array.isArray(rawManifest?.manifest?.files) ? rawManifest.manifest.files : []) {
4151
+ if (!file?.archivedPath || !file.entryPath) continue;
4152
+ if (isZipPath(file.entryPath) || isZipPath(file.archivedPath)) continue;
4153
+ const score = attachmentPathScore(file.entryPath, context);
4154
+ if (score < 100) continue;
4155
+ if (!best || score > best.score) best = { score, file };
4156
+ }
4157
+ if (!best) return null;
4158
+ return {
4159
+ kind: "file",
4160
+ file: best.file.archivedPath,
4161
+ name: posixBasename(best.file.entryPath),
4162
+ entryPath: best.file.entryPath
4163
+ };
4164
+ }
4165
+
4166
+ function bestZipRawAttachment(rawManifest, context, options = {}) {
4167
+ let best = null;
4168
+ for (const container of zipContainersForManifest(rawManifest)) {
4169
+ const match = bestZipContainerAttachment(container, context);
4170
+ if (!match) continue;
4171
+ if (!best || match.score > best.score) best = match;
4172
+ }
4173
+ if (!best) return null;
4174
+ if (options.probeOnly) {
4175
+ return {
4176
+ kind: "zip-entry",
4177
+ name: best.name,
4178
+ entryPath: best.entryPath,
4179
+ containerPath: best.containerPath
4180
+ };
4181
+ }
4182
+ const extracted = extractZipChainToTemp(best.containerPath, best.chain, best.name);
4183
+ return {
4184
+ kind: "zip-entry",
4185
+ file: extracted.file,
4186
+ cleanup: extracted.cleanup,
4187
+ name: best.name,
4188
+ entryPath: best.entryPath,
4189
+ containerPath: best.containerPath
4190
+ };
4191
+ }
4192
+
4193
+ function zipContainersForManifest(rawManifest) {
4194
+ const result = [];
4195
+ const seen = new Set();
4196
+ const add = (item, entryPath = "") => {
4197
+ const archivedPath = item?.archivedPath || item?.containerPath || "";
4198
+ if (!archivedPath || !isZipPath(archivedPath) || seen.has(archivedPath)) return;
4199
+ seen.add(archivedPath);
4200
+ result.push({ path: archivedPath, entryPath: entryPath || item.entryPath || item.originalPath || path.basename(archivedPath) });
4201
+ };
4202
+ for (const container of Array.isArray(rawManifest?.manifest?.containers) ? rawManifest.manifest.containers : []) add(container);
4203
+ for (const file of Array.isArray(rawManifest?.manifest?.files) ? rawManifest.manifest.files : []) {
4204
+ if (isZipPath(file?.entryPath || "")) add(file);
4205
+ }
4206
+ return result;
4207
+ }
4208
+
4209
+ function bestZipContainerAttachment(container, context) {
4210
+ const entries = cachedZipEntries(container.path);
4211
+ const containerPrefix = isZipPath(container.entryPath) ? container.entryPath.replace(/\.zip$/i, "") : container.entryPath;
4212
+ const directZipIsConversationPart = zipEntryMatchesConversationPart(container.entryPath, context);
4213
+ let best = null;
4214
+ for (const entry of entries) {
4215
+ if (!safeZipMemberName(entry)) continue;
4216
+ if (isZipPath(entry)) {
4217
+ if (!openAiConversationExportPath(entry) && !zipEntryMatchesConversationPart(entry, context)) continue;
4218
+ if (context.conversationPart && !zipEntryMatchesConversationPart(entry, context)) continue;
4219
+ const nestedBest = bestNestedZipAttachment(container.path, entry, context);
4220
+ if (nestedBest && (!best || nestedBest.score > best.score)) best = nestedBest;
4221
+ continue;
4222
+ }
4223
+ if (!directZipIsConversationPart && context.conversationPart && openAiConversationExportPath(container.entryPath)) continue;
4224
+ const score = Math.max(
4225
+ attachmentPathScore(entry, context),
4226
+ attachmentPathScore(containerPrefix ? `${containerPrefix}/${entry}` : entry, context)
4227
+ );
4228
+ if (score < 100) continue;
4229
+ if (!best || score > best.score) {
4230
+ best = {
4231
+ score,
4232
+ containerPath: container.path,
4233
+ chain: [entry],
4234
+ entryPath: containerPrefix ? `${containerPrefix}/${entry}` : entry,
4235
+ name: posixBasename(entry)
4236
+ };
4237
+ }
4238
+ }
4239
+ return best;
4240
+ }
4241
+
4242
+ function bestNestedZipAttachment(containerPath, nestedEntry, context) {
4243
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "agentlog-zip-list-"));
4244
+ const tempZip = path.join(tempDir, "nested.zip");
4245
+ try {
4246
+ extractZipMemberToFile(containerPath, nestedEntry, tempZip);
4247
+ let best = null;
4248
+ for (const entry of listZipEntries(tempZip)) {
4249
+ if (!safeZipMemberName(entry) || isZipPath(entry)) continue;
4250
+ const nestedPrefix = nestedEntry.replace(/\.zip$/i, "");
4251
+ const score = Math.max(
4252
+ attachmentPathScore(entry, context),
4253
+ attachmentPathScore(`${nestedPrefix}/${entry}`, context)
4254
+ );
4255
+ if (score < 100) continue;
4256
+ if (!best || score > best.score) {
4257
+ best = {
4258
+ score,
4259
+ containerPath,
4260
+ chain: [nestedEntry, entry],
4261
+ entryPath: `${nestedPrefix}/${entry}`,
4262
+ name: posixBasename(entry)
4263
+ };
4264
+ }
4265
+ }
4266
+ return best;
4267
+ } finally {
4268
+ fs.rmSync(tempDir, { recursive: true, force: true });
4269
+ }
4270
+ }
4271
+
4272
+ const ZIP_ENTRY_CACHE = new Map();
4273
+
4274
+ function cachedZipEntries(file) {
4275
+ let stat = null;
4276
+ try {
4277
+ stat = fs.statSync(file);
4278
+ } catch {
4279
+ return [];
4280
+ }
4281
+ const key = `${file}:${stat.size}:${Math.trunc(stat.mtimeMs)}`;
4282
+ if (ZIP_ENTRY_CACHE.has(key)) return ZIP_ENTRY_CACHE.get(key);
4283
+ const entries = listZipEntries(file);
4284
+ ZIP_ENTRY_CACHE.set(key, entries);
4285
+ return entries;
4286
+ }
4287
+
4288
+ function listZipEntries(file) {
4289
+ const result = spawnSync("unzip", ["-Z1", file], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
4290
+ if (result.status !== 0) return [];
4291
+ return result.stdout.split(/\r?\n/).filter(Boolean);
4292
+ }
4293
+
4294
+ function extractZipChainToTemp(containerPath, chain, name) {
4295
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "agentlog-attachment-"));
4296
+ let currentZip = containerPath;
4297
+ try {
4298
+ for (let index = 0; index < chain.length; index++) {
4299
+ const entry = chain[index];
4300
+ const last = index === chain.length - 1;
4301
+ const target = path.join(tempDir, last ? `attachment${path.extname(name || entry) || ".bin"}` : `nested-${index}.zip`);
4302
+ extractZipMemberToFile(currentZip, entry, target);
4303
+ currentZip = target;
4304
+ }
4305
+ return {
4306
+ file: currentZip,
4307
+ cleanup: () => fs.rmSync(tempDir, { recursive: true, force: true })
4308
+ };
4309
+ } catch (error) {
4310
+ fs.rmSync(tempDir, { recursive: true, force: true });
4311
+ throw error;
4312
+ }
4313
+ }
4314
+
4315
+ function extractZipMemberToFile(zipPath, entryName, target) {
4316
+ if (!safeZipMemberName(entryName)) throw new Error("unsafe zip entry path");
4317
+ ensureDir(path.dirname(target));
4318
+ const fd = fs.openSync(target, "w", 0o600);
4319
+ try {
4320
+ const result = spawnSync("unzip", ["-p", zipPath, entryName], { stdio: ["ignore", fd, "pipe"] });
4321
+ if (result.status !== 0) {
4322
+ const message = result.stderr ? result.stderr.toString("utf8").trim() : "";
4323
+ throw new Error(`failed to extract attachment${message ? `: ${message}` : ""}`);
4324
+ }
4325
+ } finally {
4326
+ fs.closeSync(fd);
4327
+ }
4328
+ }
4329
+
4330
+ function attachmentPathScore(entryPath, context) {
4331
+ const lower = comparableAttachmentPath(entryPath);
4332
+ if (!lower || lower.endsWith("/")) return 0;
4333
+ const base = posixBasename(lower);
4334
+ const baseNoExt = stripExtension(base);
4335
+ let score = 0;
4336
+ if (context.conversationDir && (lower.startsWith(`${context.conversationDir.toLowerCase()}/`) || lower.includes(`/${context.conversationDir.toLowerCase()}/`))) {
4337
+ score += 25;
4338
+ }
4339
+ if (context.conversationPart && lower.includes(context.conversationPart.toLowerCase())) score += 20;
4340
+ for (const name of context.names) {
4341
+ const normalizedName = posixBasename(comparableAttachmentPath(name));
4342
+ const normalizedNameNoExt = stripExtension(normalizedName);
4343
+ if (!normalizedName) continue;
4344
+ if (base === normalizedName || lower.endsWith(`/${normalizedName}`)) score = Math.max(score, 180);
4345
+ else if (baseNoExt === normalizedNameNoExt) score = Math.max(score, 145);
4346
+ else if (normalizedNameNoExt.length >= 6 && baseNoExt.includes(normalizedNameNoExt)) score = Math.max(score, 105);
4347
+ }
4348
+ for (const id of context.ids) {
4349
+ const token = attachmentToken(id);
4350
+ if (!token) continue;
4351
+ if (baseNoExt === token) score = Math.max(score, 175);
4352
+ else if (base.startsWith(`${token}.`) || base.startsWith(`${token}-`) || base.startsWith(`${token}_`)) score = Math.max(score, 165);
4353
+ else if (token.length >= 6 && base.includes(token)) score = Math.max(score, 115);
4354
+ }
4355
+ if (context.isImage && imageExtension(entryPath)) score += 20;
4356
+ if (isArchiveMetadataName(entryPath) && score < 160) return 0;
4357
+ return score;
4358
+ }
4359
+
4360
+ function comparableAttachmentPath(value) {
4361
+ return String(value || "")
4362
+ .replace(/\\/g, "/")
4363
+ .replace(/[\u00a0\u202f]/g, " ")
4364
+ .replace(/\s+/g, " ")
4365
+ .toLowerCase();
4366
+ }
4367
+
4368
+ function zipEntryMatchesConversationPart(entryPath, context) {
4369
+ const part = String(context?.conversationPart || "").toLowerCase();
4370
+ if (!part) return false;
4371
+ const withoutZip = String(entryPath || "").replace(/\\/g, "/").replace(/\.zip$/i, "").toLowerCase();
4372
+ return withoutZip === part || withoutZip.endsWith(`/${part}`);
4373
+ }
4374
+
4375
+ function openAiConversationExportPath(name) {
4376
+ const text = String(name || "");
4377
+ return /(^|\/)Conversations__[^/]*chatgpt[^/]*(?:\/|$)/i.test(text) || /(^|\/)Conversations__[^/]*chatgpt[^/]*\.zip$/i.test(text);
4378
+ }
4379
+
4380
+ function safeZipMemberName(name) {
4381
+ const text = String(name || "");
4382
+ if (!text || text.includes("\0") || path.isAbsolute(text)) return false;
4383
+ return !text.split(/[\\/]+/).some((part) => part === "..");
4384
+ }
4385
+
4386
+ function isZipPath(value) {
4387
+ return /\.zip$/i.test(String(value || ""));
4388
+ }
4389
+
4390
+ function isArchiveMetadataName(value) {
4391
+ return /\.(json|jsonl|ndjson|html?|csv)$/i.test(String(value || ""));
4392
+ }
4393
+
4394
+ function posixBasename(value) {
4395
+ return String(value || "").replace(/\\/g, "/").split("/").filter(Boolean).pop() || "";
4396
+ }
4397
+
4398
+ function posixDirname(value) {
4399
+ const parts = String(value || "").replace(/\\/g, "/").split("/").filter(Boolean);
4400
+ parts.pop();
4401
+ return parts.join("/");
4402
+ }
4403
+
4404
+ function stripExtension(value) {
4405
+ return String(value || "").replace(/\.[^.]+$/, "");
4406
+ }
4407
+
4408
+ function attachmentIsImage(attachment) {
4409
+ return /^image\//i.test(String(attachment?.mimeType || attachment?.pointerMimeType || "")) ||
4410
+ imageExtension(attachment?.name || "");
4411
+ }
4412
+
4413
+ function imageExtension(value) {
4414
+ return /\.(png|jpe?g|gif|webp|bmp|tiff?|heic|heif|avif)$/i.test(String(value || ""));
4415
+ }
4416
+
4417
+ function attachmentContentType(attachment, filename = "") {
4418
+ const explicit = String(attachment?.mimeType || attachment?.pointerMimeType || "").trim();
4419
+ if (/^[A-Za-z0-9!#$&^_.+-]+\/[A-Za-z0-9!#$&^_.+-]+$/.test(explicit)) return explicit;
4420
+ const ext = path.extname(String(filename || attachment?.name || "")).toLowerCase();
4421
+ const types = {
4422
+ ".png": "image/png",
4423
+ ".jpg": "image/jpeg",
4424
+ ".jpeg": "image/jpeg",
4425
+ ".gif": "image/gif",
4426
+ ".webp": "image/webp",
4427
+ ".bmp": "image/bmp",
4428
+ ".tif": "image/tiff",
4429
+ ".tiff": "image/tiff",
4430
+ ".heic": "image/heic",
4431
+ ".heif": "image/heif",
4432
+ ".avif": "image/avif",
4433
+ ".pdf": "application/pdf",
4434
+ ".txt": "text/plain",
4435
+ ".csv": "text/csv",
4436
+ ".json": "application/json"
4437
+ };
4438
+ return types[ext] || "application/octet-stream";
4439
+ }
4440
+
3481
4441
  function writeUnauthorizedHistoryResponse(res, pathname) {
3482
4442
  if (isHistoryApiPath(pathname)) {
3483
4443
  writeJsonResponse(res, { error: "unauthorized", code: "unauthorized" }, 401);
@@ -4852,19 +5812,29 @@ async function chooseDataRoot(flags, env) {
4852
5812
 
4853
5813
  printSection("Archive Storage");
4854
5814
  printMuted("Redacted transcripts, metadata, and indexes live in one data directory.");
4855
- printMuted(`Enter a path relative to ${fullPath(os.homedir())}/, a ~/... path, or an absolute path.`);
5815
+ printMuted("Enter a path relative to ~/, a ~/... path, or an absolute path.");
4856
5816
  printMuted("Press Enter to use the suggested data directory.");
4857
- const answer = (await ask(` Data directory [${fullPath(defaultRoot)}]: `)).trim();
5817
+ const answer = (await ask(` Data directory [${compactPath(defaultRoot)}]: `)).trim();
4858
5818
  return answer ? resolveUserPath(answer) : defaultRoot;
4859
5819
  }
4860
5820
 
4861
- function resolveUserPath(value) {
4862
- const input = String(value || "").trim();
5821
+ function resolveUserPath(value, options = {}) {
5822
+ const input = normalizeUserPathInput(value);
4863
5823
  if (!input) return "";
4864
5824
  if (input === "~") return os.homedir();
4865
5825
  if (input.startsWith("~/")) return path.resolve(os.homedir(), input.slice(2));
4866
5826
  if (path.isAbsolute(input)) return path.resolve(input);
4867
- return path.resolve(os.homedir(), input);
5827
+ const base = options.base ? path.resolve(options.base) : os.homedir();
5828
+ return path.resolve(base, input);
5829
+ }
5830
+
5831
+ function normalizeUserPathInput(value) {
5832
+ let input = String(value || "").trim();
5833
+ while (input.length >= 2 && ((input.startsWith("'") && input.endsWith("'")) || (input.startsWith('"') && input.endsWith('"')))) {
5834
+ input = input.slice(1, -1).trim();
5835
+ }
5836
+ input = input.replace(/^['"]+|['"]+$/g, "");
5837
+ return input.replace(/\\([\\ "'()&;<>|\[\]{}?*])/g, "$1");
4868
5838
  }
4869
5839
 
4870
5840
  async function chooseSetupSettings(flags) {
@@ -5319,10 +6289,10 @@ function createProgressReporter(options = {}) {
5319
6289
  const file = event.path ? ` ${path.basename(event.path).slice(0, 36)}` : "";
5320
6290
  const eventMode = event.kind || mode;
5321
6291
  const label = String(event.provider || "work").padEnd(eventMode === "discovery" ? 16 : 12);
5322
- const detail =
5323
- eventMode === "discovery" || eventMode === "sync"
5324
- ? event.message || "scanning"
5325
- : `imported=${event.imported || 0} skipped=${event.skipped || 0} errors=${event.errors || 0}`;
6292
+ const detail = event.message
6293
+ || (eventMode === "discovery" || eventMode === "sync"
6294
+ ? "scanning"
6295
+ : `imported=${event.imported || 0} skipped=${event.skipped || 0} errors=${event.errors || 0}`);
5326
6296
  const count = hasTotal ? `${current}/${total}` : `${current}`;
5327
6297
  const line = `${label} [${bar}] ${percent}% ${count} ${detail}${file}`;
5328
6298
  process.stdout.write(`\r${line.padEnd(lastLine.length)}`);
@@ -5584,6 +6554,9 @@ details[open] > summary .folder-icon-open{display:block}
5584
6554
  .session-meta-right .meta-project-block{display:inline-flex;align-items:center;min-width:0;max-width:min(100%,28rem);line-height:1.25}
5585
6555
  .session-meta-right .repo-label-github,.session-meta-right .repo-label-webchat,.session-meta-right .repo-label-plain{min-height:0;line-height:1.25}
5586
6556
  .session-meta-right .repo-label-github .github-repo-icon{width:13px;height:13px}
6557
+ .parent-session-link{display:inline-flex;align-items:center;gap:4px;border:1px solid #e2e8f0;background:#fff;color:#334155;border-radius:6px;padding:2px 7px;font:12px/1.25 inherit;font-weight:600;white-space:nowrap;cursor:pointer}
6558
+ .parent-session-link:hover{background:#f8fafc;border-color:#cbd5e1}
6559
+ .parent-session-link svg{width:13px;height:13px}
5587
6560
  .session-stat.model{max-width:min(400px,46vw);gap:5px}
5588
6561
  .session-model-brand{display:inline-flex;align-items:center;justify-content:center;flex:0 0 auto}
5589
6562
  .session-stat.model .session-model-text{display:inline-block;max-width:100%;overflow:hidden;text-overflow:ellipsis;vertical-align:bottom}
@@ -5670,8 +6643,22 @@ mark.search-match.search-match-current{background:#fde047;color:#422006;box-shad
5670
6643
  .bubble p{margin:0 0 8px;overflow-wrap:anywhere}
5671
6644
  .bubble p:last-child{margin-bottom:0}
5672
6645
  .plain-message{white-space:pre-wrap;overflow-wrap:anywhere}
6646
+ .attachment-list{display:grid;gap:8px;margin-top:9px}
6647
+ .tool-body + .attachment-list{margin-top:10px}
6648
+ .attachment-card{display:grid;grid-template-columns:auto minmax(0,1fr);align-items:center;gap:9px;max-width:min(420px,100%);padding:7px 9px;border:1px solid #e5e7eb;border-radius:8px;background:#fff;color:#334155;text-decoration:none}
6649
+ .attachment-card:hover{background:#f8fafc;border-color:#cbd5e1}
6650
+ .attachment-card.image{align-items:start}
6651
+ .attachment-thumb{display:inline-flex;align-items:center;justify-content:center;width:34px;height:34px;border-radius:7px;background:#f1f5f9;color:#475569;overflow:hidden;flex:0 0 auto}
6652
+ .attachment-thumb svg{width:17px;height:17px}
6653
+ .attachment-thumb.image{width:96px;height:72px;background:#f8fafc;border:1px solid #eef2f7}
6654
+ .attachment-thumb.image img{width:100%;height:100%;object-fit:cover;display:block}
6655
+ .attachment-copy{display:grid;gap:1px;min-width:0}
6656
+ .attachment-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#172033;font-size:12px;font-weight:600}
6657
+ .attachment-meta{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#64748b;font-size:11px;line-height:1.35}
5673
6658
  .bubble a{color:#2457a6;text-decoration:underline;text-underline-offset:2px}
6659
+ .bubble a.attachment-card{color:#334155;text-decoration:none}
5674
6660
  .bubble .inline-code{border:1px solid #e5e7eb;border-radius:4px;background:#f8fafc;padding:1px 4px;font:12px/1.35 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
6661
+ .citation-chip{display:inline-flex;align-items:center;height:17px;margin:0 1px;padding:0 5px;border-radius:999px;background:#eef2ff;color:#3730a3;font-size:10.5px;font-weight:650;line-height:1;vertical-align:baseline}
5675
6662
  .unsupported-device-block{display:flex;align-items:center;gap:8px;margin:5px 0 7px;padding:7px 9px;border:1px solid #e5e7eb;border-left:3px solid #cbd5e1;border-radius:7px;background:#f8fafc;color:#64748b;font-size:12px;font-weight:500;line-height:1.35}
5676
6663
  .unsupported-device-block:first-child{margin-top:0}
5677
6664
  .unsupported-device-block:last-child{margin-bottom:0}
@@ -5687,6 +6674,35 @@ mark.search-match.search-match-current{background:#fde047;color:#422006;box-shad
5687
6674
  .session-summary-card{border-left-color:#D97757;background:#fff}
5688
6675
  .session-summary-body{padding:11px 13px 12px;color:#334155;font-size:13px;line-height:1.55;background:#fff}
5689
6676
  .session-summary-body .md-heading:first-child{margin-top:0}
6677
+ .session-subagents-card{border-left-color:#7c3aed;background:#fff}
6678
+ .session-subagent-run-card{border-left-color:#7c3aed;background:#fff}
6679
+ .session-subagents-body{padding:0 12px 12px;background:#fff;color:#334155}
6680
+ .subagent-section-title{margin:10px 0 2px;color:#64748b;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.04em}
6681
+ .subagent-list{display:grid;gap:0}
6682
+ .subagent-row{display:grid;gap:5px;padding:10px 0;border-top:1px solid #f1f5f9}
6683
+ .subagent-row:first-child{border-top:0}
6684
+ .subagent-head{display:flex;align-items:center;gap:6px;min-width:0}
6685
+ .subagent-name{font-size:13px;font-weight:650;color:#172033;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
6686
+ .subagent-description{font-size:12.5px;line-height:1.45;color:#475569}
6687
+ .subagent-preview{font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;color:#64748b;background:#fafbfc;border-radius:6px;padding:7px 8px;white-space:pre-wrap;overflow-wrap:anywhere}
6688
+ .subagent-tools{display:flex;flex-wrap:wrap;gap:5px}
6689
+ .subagent-chip{display:inline-flex;align-items:center;min-height:17px;max-width:220px;padding:0 6px;border-radius:999px;background:#f1f5f9;color:#475569;font-size:11px;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
6690
+ .subagent-open{justify-self:start;margin-top:2px;border:1px solid #e2e8f0;background:#fff;color:#334155;border-radius:6px;padding:3px 7px;font:12px/1.2 inherit;cursor:pointer}
6691
+ .subagent-open:hover{background:#f8fafc;border-color:#cbd5e1}
6692
+ .session-modal{position:fixed;inset:0;z-index:80;display:flex;align-items:center;justify-content:center;padding:28px;background:rgba(15,23,42,.34);backdrop-filter:blur(2px)}
6693
+ .session-modal[hidden]{display:none}
6694
+ .session-modal-shell{display:flex;flex-direction:column;width:min(960px,calc(100vw - 48px));height:min(82vh,860px);border:1px solid #dbe3ee;border-radius:10px;background:#fff;box-shadow:0 24px 80px rgba(15,23,42,.28);overflow:hidden}
6695
+ .session-modal-head{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;padding:13px 15px;border-bottom:1px solid #e5e7eb;background:#fff}
6696
+ .session-modal-title{min-width:0}
6697
+ .session-modal-title strong{display:block;color:#172033;font-size:15px;line-height:1.25;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
6698
+ .session-modal-meta{display:flex;flex-wrap:wrap;gap:5px;margin-top:5px;color:#64748b;font-size:12px}
6699
+ .session-modal-close{display:inline-flex;align-items:center;justify-content:center;flex:0 0 auto;width:30px;height:30px;border:1px solid #e2e8f0;border-radius:7px;background:#fff;color:#475569;cursor:pointer}
6700
+ .session-modal-close:hover{background:#f8fafc;border-color:#cbd5e1}
6701
+ .session-modal-close-glyph{font-size:16px;font-weight:650;line-height:1}
6702
+ .session-modal-body{flex:1 1 auto;min-height:0;overflow:auto;padding:18px 18px 28px;background:#fff}
6703
+ .session-modal-body .message{max-width:100%}
6704
+ .session-modal-body .bubble{max-width:min(100%,760px)}
6705
+ .session-modal-body.inline-empty{display:flex;align-items:center;justify-content:center;color:#64748b}
5690
6706
  .context-card summary{display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:9px;min-height:38px;padding:7px 10px 7px 11px;cursor:pointer;list-style:none;transition:background .12s ease}
5691
6707
  .context-prefix{display:inline-flex;align-items:center;gap:6px}
5692
6708
  .context-end{display:inline-flex;align-items:center;gap:6px}
@@ -5959,6 +6975,18 @@ mark.search-match.search-match-current{background:#fde047;color:#422006;box-shad
5959
6975
  <button id="jumpEnd" class="jump-end" type="button" title="Jump to end" hidden><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14"/><path d="m19 12-7 7-7-7"/></svg></button>
5960
6976
  </section>
5961
6977
  </main>
6978
+ <div id="sessionModal" class="session-modal" hidden role="dialog" aria-modal="true" aria-labelledby="sessionModalTitle">
6979
+ <div class="session-modal-shell">
6980
+ <div class="session-modal-head">
6981
+ <div class="session-modal-title">
6982
+ <strong id="sessionModalTitle">Subagent transcript</strong>
6983
+ <div id="sessionModalMeta" class="session-modal-meta"></div>
6984
+ </div>
6985
+ <button id="sessionModalClose" class="session-modal-close" type="button" aria-label="Close subagent transcript"><span class="session-modal-close-glyph" aria-hidden="true">X</span></button>
6986
+ </div>
6987
+ <div id="sessionModalBody" class="session-modal-body"></div>
6988
+ </div>
6989
+ </div>
5962
6990
  <section id="statsPane" class="stats-pane" aria-label="Stats">
5963
6991
  <div class="stats-metrics" id="statsMetrics"></div>
5964
6992
  <div class="stats-section">
@@ -6054,6 +7082,11 @@ const copyDetailsButton = document.getElementById('copyDetailsButton');
6054
7082
  const copyResumeButton = document.getElementById('copyResumeButton');
6055
7083
  const copyDetailsWrap = document.getElementById('copyDetailsWrap');
6056
7084
  const copyResumeWrap = document.getElementById('copyResumeWrap');
7085
+ const sessionModal = document.getElementById('sessionModal');
7086
+ const sessionModalTitle = document.getElementById('sessionModalTitle');
7087
+ const sessionModalMeta = document.getElementById('sessionModalMeta');
7088
+ const sessionModalBody = document.getElementById('sessionModalBody');
7089
+ const sessionModalClose = document.getElementById('sessionModalClose');
6057
7090
  const form = document.getElementById('filters');
6058
7091
  const queryInput = document.getElementById('q');
6059
7092
 
@@ -6071,6 +7104,7 @@ const repoOffsets = new Map();
6071
7104
  let currentSessionId = '';
6072
7105
  let currentSessionPayload = null;
6073
7106
  let viewMode = 'readable';
7107
+ let viewScrollRestoreSerial = 0;
6074
7108
  let sidebarWidth = 390;
6075
7109
  let activeSearchTerm = '';
6076
7110
  let pendingSessionId = '';
@@ -6710,6 +7744,7 @@ function formatTokenCount(value) {
6710
7744
  function usageTokenSummary(usage) {
6711
7745
  if (!usage || typeof usage !== 'object') return { inputTokens: 0, outputTokens: 0, totalTokens: 0, extraTokens: 0, estimated: false };
6712
7746
  const estimated = usage.estimated === true || usage.estimated === 'true';
7747
+ const authoritativeTotalTokens = usage.authoritativeTotalTokens === true || usage.authoritative_total_tokens === true;
6713
7748
  const inputTokens = positiveTokenNumber(firstUsageNumber(
6714
7749
  usage.inputTokens,
6715
7750
  usage.input_tokens,
@@ -6749,22 +7784,36 @@ function usageTokenSummary(usage) {
6749
7784
  firstUsageNumber(usage.cachedContentTokenCount, usage.cached_content_token_count),
6750
7785
  firstUsageNumber(usage.cachedTokens, usage.cached_tokens, usage.cacheTokens, usage.cache_tokens, usage.cached)
6751
7786
  );
7787
+ const cacheInputTokensIncludedInInput =
7788
+ usage.cacheInputTokensIncludedInInput === true ||
7789
+ usage.cache_input_tokens_included_in_input === true ||
7790
+ usage.cacheReadTokensIncludedInInput === true ||
7791
+ usage.cache_read_tokens_included_in_input === true;
7792
+ const countedCacheInputTokens = cacheInputTokensIncludedInInput ? 0 : cacheInputTokens;
6752
7793
  const reasoningOutputTokens = sumPositiveTokenNumbers(
6753
7794
  firstUsageNumber(usage.reasoningOutputTokens, usage.reasoning_output_tokens),
6754
7795
  firstUsageNumber(usage.thoughtsTokens, usage.thoughts_tokens, usage.thoughtsTokenCount, usage.thoughts_token_count),
6755
7796
  firstUsageNumber(usage.reasoningTokens, usage.reasoning_tokens, usage.reasoningTokenCount, usage.reasoning_token_count)
6756
7797
  );
7798
+ const reasoningOutputTokensIncludedInOutput =
7799
+ usage.reasoningOutputTokensIncludedInOutput === true ||
7800
+ usage.reasoning_output_tokens_included_in_output === true ||
7801
+ usage.reasoningTokensIncludedInOutput === true ||
7802
+ usage.reasoning_tokens_included_in_output === true;
7803
+ const countedReasoningOutputTokens = reasoningOutputTokensIncludedInOutput ? 0 : reasoningOutputTokens;
6757
7804
  const toolUsePromptTokens = sumPositiveTokenNumbers(
6758
7805
  firstUsageNumber(usage.toolUsePromptTokens, usage.tool_use_prompt_tokens, usage.toolUsePromptTokenCount, usage.tool_use_prompt_token_count)
6759
7806
  );
6760
- const extraTokens = cacheInputTokens + reasoningOutputTokens + toolUsePromptTokens;
7807
+ const extraTokens = countedCacheInputTokens + countedReasoningOutputTokens + toolUsePromptTokens;
6761
7808
  const splitTokens = inputTokens + outputTokens;
6762
7809
  const cumulativeTokens = totalInputTokens + totalOutputTokens;
6763
7810
  const categoryTokens = splitTokens + extraTokens;
6764
- const totalTokens = explicitTotalTokens || categoryTokens
6765
- ? Math.max(explicitTotalTokens, categoryTokens)
6766
- : cumulativeTokens;
6767
- return { inputTokens, outputTokens, cacheInputTokens, reasoningOutputTokens, toolUsePromptTokens, totalTokens, extraTokens, estimated };
7811
+ const totalTokens = authoritativeTotalTokens && explicitTotalTokens
7812
+ ? explicitTotalTokens
7813
+ : explicitTotalTokens || categoryTokens
7814
+ ? Math.max(explicitTotalTokens, categoryTokens)
7815
+ : cumulativeTokens;
7816
+ return { inputTokens, outputTokens, cacheInputTokens, countedCacheInputTokens, cacheInputTokensIncludedInInput, reasoningOutputTokens, countedReasoningOutputTokens, reasoningOutputTokensIncludedInOutput, toolUsePromptTokens, totalTokens, extraTokens, authoritativeTotalTokens, estimated };
6768
7817
  }
6769
7818
 
6770
7819
  function firstUsageNumber() {
@@ -6964,11 +8013,15 @@ function sessionDisplayTitle(payload) {
6964
8013
 
6965
8014
  function renderTree(payload, options) {
6966
8015
  const opts = options || {};
8016
+ const sourceFilterReset = updateSourceFilterOptions(payload.available_source_options || []);
6967
8017
  treeTitle.textContent = 'Sessions';
6968
8018
  treeCount.textContent = String(payload.count || 0);
6969
8019
  tree.innerHTML = '';
6970
8020
  if (sidebarHead) sidebarHead.classList.remove('tree-scrolled');
6971
8021
  repoOffsets.clear();
8022
+ if (sourceFilterReset && !opts.skipSourceReload) {
8023
+ window.setTimeout(() => loadTree({ skipSourceReload: true }).catch((error) => { setEmptySession(error.message); }), 0);
8024
+ }
6972
8025
  if (!payload.groups || !payload.groups.length) {
6973
8026
  tree.innerHTML = '<div class="inline-empty">No archived sessions matched these filters.</div>';
6974
8027
  if (!opts.skipAutoLoad) setEmptySession('No archived sessions matched these filters.');
@@ -7268,6 +8321,14 @@ function renderSession(payload) {
7268
8321
  if (sessionMetaRight) {
7269
8322
  const innerParts = [];
7270
8323
  if (payload.source_type) innerParts.push('<span class="meta-source">' + esc(sourceTypeLabel(payload.provider, payload.source_type)) + '</span>');
8324
+ if (payload.parent_composer_id) {
8325
+ if (innerParts.length) innerParts.push('<span class="meta-dot" aria-hidden="true">\u00b7</span>');
8326
+ innerParts.push(
8327
+ '<button class="parent-session-link" type="button" data-parent-session="' + esc(payload.parent_composer_id) + '" title="Open parent session">' +
8328
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m12 19-7-7 7-7"/><path d="M19 12H5"/></svg>' +
8329
+ '<span>Parent session</span></button>'
8330
+ );
8331
+ }
7271
8332
  const repoRaw = payload.repo_display || payload.repo || payload.scope || '';
7272
8333
  if (repoRaw) {
7273
8334
  if (innerParts.length) innerParts.push('<span class="meta-dot" aria-hidden="true">\u00b7</span>');
@@ -7283,7 +8344,16 @@ function renderSession(payload) {
7283
8344
  sessionMetaRight.innerHTML = innerParts.length
7284
8345
  ? '<div class="session-meta-right-inner">' + innerParts.join('') + '</div>'
7285
8346
  : '';
8347
+ const parentLink = sessionMetaRight.querySelector('[data-parent-session]');
8348
+ if (parentLink) {
8349
+ parentLink.addEventListener('click', (event) => {
8350
+ event.preventDefault();
8351
+ event.stopPropagation();
8352
+ loadSession(parentLink.getAttribute('data-parent-session') || '', { preserveCurrent: true });
8353
+ });
8354
+ }
7286
8355
  const titleBits = [];
8356
+ if (payload.parent_composer_id) titleBits.push('Parent session: ' + payload.parent_composer_id);
7287
8357
  if (payload.time_status === 'recovered-time-unknown') titleBits.push('Recovered time unknown');
7288
8358
  else if (payload.started_at) titleBits.push('Started ' + formatWhen(payload.started_at));
7289
8359
  if (payload.session_id) titleBits.push('Session ID: ' + payload.session_id);
@@ -7312,7 +8382,7 @@ function renderSession(payload) {
7312
8382
  );
7313
8383
  copyResumeButton.classList.remove('copied');
7314
8384
  jumpEnd.hidden = false;
7315
- setView(viewMode);
8385
+ setView(viewMode, { preserveScroll: false });
7316
8386
  for (const button of tree.querySelectorAll('.session-node')) {
7317
8387
  button.classList.toggle('active', button.dataset.id === payload.session_id);
7318
8388
  }
@@ -7330,17 +8400,20 @@ function renderMessages(messages, sessionSummary) {
7330
8400
  readableView.className = '';
7331
8401
  readableView.innerHTML = '';
7332
8402
  const summaryText = sessionSummaryText(sessionSummary);
8403
+ const subagentsElement = sessionSubagentsElement(sessionSummary);
8404
+ const subagentRunItems = subagentRunTimelineItems(sessionSummary);
7333
8405
  const visibleMessages = summaryText
7334
8406
  ? (messages || []).filter((message) => message?.metadata?.summaryKind !== 'conversation_summary')
7335
8407
  : (messages || []);
7336
- const renderItems = pairedToolRenderItems(visibleMessages);
7337
- if (!renderItems.length && !summaryText) {
8408
+ const renderItems = mergeTimelineRenderItems(pairedToolRenderItems(visibleMessages), subagentRunItems);
8409
+ if (!renderItems.length && !summaryText && !subagentsElement) {
7338
8410
  readableView.className = 'inline-empty';
7339
8411
  readableView.textContent = 'No transcript messages are available for this session.';
7340
8412
  return;
7341
8413
  }
7342
8414
  const summaryElement = sessionSummaryElement(sessionSummary);
7343
8415
  if (summaryElement) readableView.appendChild(summaryElement);
8416
+ if (subagentsElement) readableView.appendChild(subagentsElement);
7344
8417
  const scheduleNext = typeof window.requestAnimationFrame === 'function'
7345
8418
  ? (fn) => window.requestAnimationFrame(fn)
7346
8419
  : (fn) => window.setTimeout(fn, 0);
@@ -7374,6 +8447,25 @@ function renderMessages(messages, sessionSummary) {
7374
8447
  renderChunk();
7375
8448
  }
7376
8449
 
8450
+ function mergeTimelineRenderItems(items, extras) {
8451
+ if (!Array.isArray(extras) || !extras.length) return items || [];
8452
+ return [
8453
+ ...(items || []).map((item, index) => ({ ...item, _timelineOrder: index * 2 })),
8454
+ ...extras.map((item, index) => ({ ...item, _timelineOrder: index * 2 + 1 }))
8455
+ ]
8456
+ .sort((left, right) => renderItemTimestampMs(left) - renderItemTimestampMs(right) || left._timelineOrder - right._timelineOrder)
8457
+ .map((item) => {
8458
+ const { _timelineOrder, ...rest } = item;
8459
+ return rest;
8460
+ });
8461
+ }
8462
+
8463
+ function renderItemTimestampMs(item) {
8464
+ const value = item?.message?.timestamp || item?.lastTimestamp || '';
8465
+ const time = value ? new Date(value).getTime() : NaN;
8466
+ return Number.isFinite(time) ? time : Number.MAX_SAFE_INTEGER;
8467
+ }
8468
+
7377
8469
  function pairedToolRenderItems(messages) {
7378
8470
  const pairings = pairedToolResultIndexes(messages);
7379
8471
  const skippedResultIndexes = new Set(pairings.duplicateResultIndexes);
@@ -7600,6 +8692,246 @@ function summarySourceLabel(source) {
7600
8692
  return 'summary';
7601
8693
  }
7602
8694
 
8695
+ function sessionSubagents(sessionSummary) {
8696
+ const definitionValue = sessionSummary && sessionSummary.claudeSubagents;
8697
+ const definitions = definitionValue && typeof definitionValue === 'object' && Array.isArray(definitionValue.definitions)
8698
+ ? definitionValue.definitions.filter((item) => item && item.name)
8699
+ : [];
8700
+ if (!definitions.length) return null;
8701
+ return { ...(definitionValue || {}), definitions };
8702
+ }
8703
+
8704
+ function sessionSubagentRuns(sessionSummary) {
8705
+ const sources = [
8706
+ { key: 'claudeSubagentRuns', provider: 'claude_code', providerLabel: 'Claude' },
8707
+ { key: 'codexSubagentRuns', provider: 'codex', providerLabel: 'Codex' }
8708
+ ];
8709
+ const runs = [];
8710
+ for (const source of sources) {
8711
+ const runValue = sessionSummary && sessionSummary[source.key];
8712
+ const sourceRuns = runValue && typeof runValue === 'object' && Array.isArray(runValue.runs)
8713
+ ? runValue.runs.filter((item) => item && (item.agentId || item.agentNickname || item.title || item.sessionId))
8714
+ : [];
8715
+ for (const run of sourceRuns) {
8716
+ runs.push({ provider: source.provider, providerLabel: source.providerLabel, ...run });
8717
+ }
8718
+ }
8719
+ if (!runs.length) return null;
8720
+ return { runs };
8721
+ }
8722
+
8723
+ function subagentRunTimelineItems(sessionSummary) {
8724
+ const runSummary = sessionSubagentRuns(sessionSummary);
8725
+ if (!runSummary) return [];
8726
+ return runSummary.runs.map((run, index) => {
8727
+ const timestamp = run.startedAt || run.endedAt || '';
8728
+ return {
8729
+ type: 'subagent-run',
8730
+ run,
8731
+ message: {
8732
+ role: 'system',
8733
+ content: formatSubagentRunPlainText(run),
8734
+ timestamp,
8735
+ metadata: { provider: run.provider || 'unknown', generatedContext: true, contextKind: 'subagent_run' }
8736
+ },
8737
+ lastTimestamp: run.endedAt || timestamp,
8738
+ _subagentIndex: index
8739
+ };
8740
+ });
8741
+ }
8742
+
8743
+ function sessionSubagentsElement(sessionSummary) {
8744
+ const subagents = sessionSubagents(sessionSummary);
8745
+ if (!subagents) return null;
8746
+ const definitionCount = Number(subagents.count || subagents.definitions.length) || subagents.definitions.length;
8747
+ const chips = [];
8748
+ if (definitionCount) chips.push(definitionCount + ' definition' + (definitionCount === 1 ? '' : 's'));
8749
+ if (subagents.projectCount) chips.push(subagents.projectCount + ' project');
8750
+ if (subagents.userCount) chips.push(subagents.userCount + ' user');
8751
+ if (Array.isArray(subagents.shadowedNames) && subagents.shadowedNames.length) chips.push(subagents.shadowedNames.length + ' shadowed');
8752
+ const row = document.createElement('article');
8753
+ row.className = 'message context session-subagents-message';
8754
+ const bubble = document.createElement('div');
8755
+ bubble.className = 'bubble';
8756
+ const details = document.createElement('details');
8757
+ details.className = 'context-card session-subagents-card';
8758
+ details.open = true;
8759
+ details.innerHTML =
8760
+ '<summary>' +
8761
+ '<span class="context-prefix"><span class="context-caret"></span><span class="context-glyph">' + contextIconSvg('subagents') + '</span></span>' +
8762
+ '<span class="context-copy"><span class="context-title">Claude subagents</span>' +
8763
+ '<span class="context-meta">' + chips.slice(0, 4).map((chip) => '<span class="context-chip">' + esc(chip) + '</span>').join('') + '</span></span>' +
8764
+ '<span class="context-end"></span>' +
8765
+ '</summary>';
8766
+ const end = details.querySelector('.context-end');
8767
+ if (end) end.appendChild(messageCopyButton(formatSubagentsPlainText(subagents)));
8768
+ const body = document.createElement('div');
8769
+ body.className = 'session-subagents-body';
8770
+ body.innerHTML =
8771
+ '<div class="subagent-list">' +
8772
+ subagents.definitions.map(renderSubagentDefinition).join('') +
8773
+ '</div>';
8774
+ details.appendChild(body);
8775
+ bubble.appendChild(details);
8776
+ row.appendChild(bubble);
8777
+ return row;
8778
+ }
8779
+
8780
+ function renderSubagentDefinition(definition) {
8781
+ const chips = [];
8782
+ if (definition.scope) chips.push(definition.scope);
8783
+ if (definition.model) chips.push(definition.model);
8784
+ if (definition.relativePath) chips.push(definition.relativePath);
8785
+ const tools = Array.isArray(definition.tools) ? definition.tools : [];
8786
+ return '<div class="subagent-row">' +
8787
+ '<div class="subagent-head"><span class="subagent-name" title="' + esc(definition.name || '') + '">' + esc(definition.name || 'subagent') + '</span>' +
8788
+ chips.map((chip) => '<span class="subagent-chip">' + esc(chip) + '</span>').join('') +
8789
+ '</div>' +
8790
+ (definition.description ? '<div class="subagent-description">' + esc(definition.description) + '</div>' : '') +
8791
+ (tools.length ? '<div class="subagent-tools">' + tools.map((tool) => '<span class="subagent-chip">' + esc(tool) + '</span>').join('') + '</div>' : '') +
8792
+ (definition.instructionPreview ? '<pre class="subagent-preview">' + esc(definition.instructionPreview) + '</pre>' : '') +
8793
+ '</div>';
8794
+ }
8795
+
8796
+ function subagentRunElement(run) {
8797
+ const chips = [];
8798
+ if (run.agentNickname) chips.push(run.agentNickname);
8799
+ if (run.agentRole || run.agentType) chips.push(run.agentRole || run.agentType);
8800
+ if (run.status) chips.push(run.status);
8801
+ if (run.agentId) chips.push(run.agentId);
8802
+ if (Array.isArray(run.models) && run.models.length) chips.push(run.models.join(', '));
8803
+ if (run.messageCount) chips.push(formatFullNumber(run.messageCount) + ' messages');
8804
+ if (run.toolCallCount) chips.push(formatFullNumber(run.toolCallCount) + ' tools');
8805
+ const title = run.title || run.agentNickname || run.agentType || run.agentRole || run.agentId || 'subagent run';
8806
+ const preview = [run.promptPreview ? 'Prompt: ' + run.promptPreview : '', run.resultPreview ? 'Result: ' + run.resultPreview : ''].filter(Boolean).join('\\n\\n');
8807
+ const row = document.createElement('article');
8808
+ row.className = 'message context session-subagent-run-message';
8809
+ const bubble = document.createElement('div');
8810
+ bubble.className = 'bubble';
8811
+ const details = document.createElement('details');
8812
+ details.className = 'context-card session-subagent-run-card';
8813
+ details.innerHTML =
8814
+ '<summary>' +
8815
+ '<span class="context-prefix"><span class="context-caret"></span><span class="context-glyph">' + contextIconSvg('subagents') + '</span></span>' +
8816
+ '<span class="context-copy"><span class="context-title">Subagent: ' + esc(title) + '</span>' +
8817
+ '<span class="context-meta">' + chips.slice(0, 4).map((chip) => '<span class="context-chip">' + esc(chip) + '</span>').join('') + '</span></span>' +
8818
+ '<span class="context-end"><span class="context-time"' + timeAttr(run.startedAt || run.endedAt) + '>' + esc(relativeTime(run.startedAt || run.endedAt)) + '</span></span>' +
8819
+ '</summary>';
8820
+ const end = details.querySelector('.context-end');
8821
+ if (end) end.appendChild(messageCopyButton(formatSubagentRunPlainText(run)));
8822
+ const body = document.createElement('div');
8823
+ body.className = 'session-subagents-body';
8824
+ body.innerHTML =
8825
+ '<div class="subagent-list"><div class="subagent-row">' +
8826
+ (run.relativePath ? '<div class="subagent-description">' + esc(run.relativePath) + '</div>' : '') +
8827
+ (preview ? '<pre class="subagent-preview">' + esc(preview) + '</pre>' : '') +
8828
+ (run.sessionId ? '<button class="subagent-open" type="button" data-subagent-session="' + esc(run.sessionId) + '">Open transcript</button>' : '') +
8829
+ '</div></div>';
8830
+ const open = body.querySelector('[data-subagent-session]');
8831
+ if (open) {
8832
+ open.addEventListener('click', (event) => {
8833
+ event.preventDefault();
8834
+ event.stopPropagation();
8835
+ openSubagentModal(open.getAttribute('data-subagent-session') || '');
8836
+ });
8837
+ }
8838
+ details.appendChild(body);
8839
+ bubble.appendChild(details);
8840
+ row.appendChild(bubble);
8841
+ return row;
8842
+ }
8843
+
8844
+ function formatSubagentsPlainText(subagents) {
8845
+ const lines = ['Claude subagent definitions'];
8846
+ for (const definition of subagents.definitions || []) {
8847
+ lines.push('');
8848
+ lines.push(definition.name || 'subagent');
8849
+ const meta = [definition.scope, definition.model, definition.relativePath].filter(Boolean).join(' | ');
8850
+ if (meta) lines.push(meta);
8851
+ if (definition.description) lines.push(definition.description);
8852
+ if (Array.isArray(definition.tools) && definition.tools.length) lines.push('Tools: ' + definition.tools.join(', '));
8853
+ if (definition.instructionPreview) lines.push(definition.instructionPreview);
8854
+ }
8855
+ return lines.join('\\n');
8856
+ }
8857
+
8858
+ function formatSubagentRunPlainText(run) {
8859
+ const lines = [run.title || run.agentNickname || run.agentType || run.agentRole || run.agentId || 'Subagent run'];
8860
+ const meta = [run.providerLabel, run.agentNickname, run.agentRole || run.agentType, run.status, run.agentId, run.agentPath, run.relativePath].filter(Boolean).join(' | ');
8861
+ if (meta) lines.push(meta);
8862
+ if (run.startedAt || run.endedAt) lines.push([run.startedAt, run.endedAt].filter(Boolean).join(' - '));
8863
+ if (run.messageCount) lines.push(formatFullNumber(run.messageCount) + ' messages');
8864
+ if (run.toolCallCount) lines.push(formatFullNumber(run.toolCallCount) + ' tool calls');
8865
+ if (run.sessionId) lines.push('Child session: ' + run.sessionId);
8866
+ if (run.promptPreview) lines.push('Prompt: ' + run.promptPreview);
8867
+ if (run.resultPreview) lines.push('Result: ' + run.resultPreview);
8868
+ return lines.join('\\n');
8869
+ }
8870
+
8871
+ async function openSubagentModal(sessionId) {
8872
+ if (!sessionId || !sessionModal || !sessionModalBody) return;
8873
+ sessionModal.hidden = false;
8874
+ if (sessionModalTitle) sessionModalTitle.textContent = 'Subagent transcript';
8875
+ if (sessionModalMeta) sessionModalMeta.innerHTML = '<span class="context-chip">' + esc(sessionId) + '</span>';
8876
+ sessionModalBody.className = 'session-modal-body inline-empty';
8877
+ sessionModalBody.textContent = 'Loading subagent transcript...';
8878
+ try {
8879
+ const { payload } = await fetchSessionPayload(sessionId);
8880
+ if (!payload || payload.session_id !== sessionId || sessionModal.hidden) return;
8881
+ renderSubagentModalPayload(payload);
8882
+ } catch (error) {
8883
+ if (sessionModalBody) {
8884
+ sessionModalBody.className = 'session-modal-body inline-empty';
8885
+ sessionModalBody.textContent = error.message || 'Unable to load subagent transcript.';
8886
+ }
8887
+ }
8888
+ }
8889
+
8890
+ function renderSubagentModalPayload(payload) {
8891
+ if (!sessionModalBody) return;
8892
+ if (sessionModalTitle) sessionModalTitle.textContent = sessionDisplayTitle(payload) || 'Subagent transcript';
8893
+ if (sessionModalMeta) {
8894
+ const parts = [];
8895
+ if (payload.source_type) parts.push(sourceTypeLabel(payload.provider, payload.source_type));
8896
+ if (payload.started_at) parts.push(relativeTime(payload.started_at));
8897
+ if (payload.messages != null) parts.push(formatFullNumber(payload.messages) + ' messages');
8898
+ if (payload.parent_composer_id) parts.push('parent ' + payload.parent_composer_id);
8899
+ sessionModalMeta.innerHTML = parts.map((part) => '<span class="context-chip">' + esc(part) + '</span>').join('');
8900
+ }
8901
+ sessionModalBody.className = 'session-modal-body';
8902
+ sessionModalBody.innerHTML = '';
8903
+ const messages = Array.isArray(payload.transcript_messages) ? payload.transcript_messages : [];
8904
+ if (!messages.length) {
8905
+ sessionModalBody.className = 'session-modal-body inline-empty';
8906
+ sessionModalBody.textContent = 'No transcript messages are available for this subagent.';
8907
+ return;
8908
+ }
8909
+ const previousPayload = currentSessionPayload;
8910
+ currentSessionPayload = payload;
8911
+ try {
8912
+ let previousTimestamp = '';
8913
+ for (const item of pairedToolRenderItems(messages)) {
8914
+ const message = item.message || {};
8915
+ const gap = renderTimeGap(previousTimestamp, message.timestamp);
8916
+ if (gap) sessionModalBody.appendChild(gap);
8917
+ sessionModalBody.appendChild(messageElement(message, item));
8918
+ previousTimestamp = item.lastTimestamp || message.timestamp || previousTimestamp;
8919
+ }
8920
+ collapseLongToolChains(sessionModalBody, 10);
8921
+ } finally {
8922
+ currentSessionPayload = previousPayload;
8923
+ }
8924
+ }
8925
+
8926
+ function closeSubagentModal() {
8927
+ if (!sessionModal) return;
8928
+ sessionModal.hidden = true;
8929
+ if (sessionModalBody) {
8930
+ sessionModalBody.className = 'session-modal-body';
8931
+ sessionModalBody.innerHTML = '';
8932
+ }
8933
+ }
8934
+
7603
8935
  function renderTimeGap(previous, current) {
7604
8936
  const diffSec = timeGapSeconds(previous, current);
7605
8937
  if (diffSec < 5) return null;
@@ -7693,18 +9025,20 @@ function highlightSearchMatches(root, term) {
7693
9025
 
7694
9026
  function messageElement(message, options = {}) {
7695
9027
  if (options.type === 'tool-group') return toolGroupElement(options);
9028
+ if (options.type === 'subagent-run') return subagentRunElement(options.run || {});
7696
9029
  const context = generatedContextForMessage(message);
7697
9030
  if (context) return contextMessageElement(message, context);
7698
9031
  const role = String(message.role || 'unknown').toLowerCase();
7699
9032
  const content = String(message.content || '');
7700
9033
  const toolCalls = messageToolCalls(message, options.pairedToolResults || []);
7701
9034
  const toolResult = role === 'tool' ? parseToolResult(content, message) : null;
7702
- const contentWithoutTools = toolCalls.length ? stripToolInvocationLines(content) : content;
9035
+ const contentWithoutTools = isViewerStructuredToolCallMessage(message) ? '' : toolCalls.length ? stripToolInvocationLines(content) : content;
7703
9036
  const toolOnly = toolCalls.length && !contentWithoutTools.trim();
7704
9037
  const className = toolOnly ? 'tool' : ['user', 'assistant', 'system', 'tool'].includes(role) ? role : 'assistant';
7705
9038
  const bodyContent = toolResult
7706
9039
  ? renderToolResult(toolResult)
7707
9040
  : renderMessageBodyWithTools(className, contentWithoutTools, toolCalls);
9041
+ const attachmentsHtml = renderMessageAttachments(message);
7708
9042
  const row = document.createElement('article');
7709
9043
  row.className = 'message ' + className;
7710
9044
  if (toolCalls.length) row.classList.add('tool-call-turn');
@@ -7729,7 +9063,7 @@ function messageElement(message, options = {}) {
7729
9063
  '<div class="message-head"><span class="role">' + esc(roleLabel(role)) + '</span>' +
7730
9064
  '<span class="message-time"' + timeAttr(message.timestamp) + '>' + esc(relativeTime(message.timestamp)) + '</span>' +
7731
9065
  usageChipHtml + '</div>';
7732
- bubble.innerHTML = headHtml + bodyContent;
9066
+ bubble.innerHTML = headHtml + bodyContent + attachmentsHtml;
7733
9067
  const head = bubble.querySelector('.message-head');
7734
9068
  if (head) head.appendChild(messageCopyButton(content));
7735
9069
  row.appendChild(bubble);
@@ -7864,6 +9198,7 @@ function contextTitle(kind) {
7864
9198
  local_command_caveat: 'Local command',
7865
9199
  local_command_stdout: 'Local command',
7866
9200
  system_reminder: 'System reminder',
9201
+ subagents: 'Claude subagents',
7867
9202
  compaction: 'Compaction',
7868
9203
  metadata: 'Context metadata'
7869
9204
  };
@@ -7917,6 +9252,86 @@ function xmlTagText(text, tag) {
7917
9252
  return source.slice(bodyStart, end).trim();
7918
9253
  }
7919
9254
 
9255
+ function renderMessageAttachments(message) {
9256
+ const attachments = messageAttachments(message);
9257
+ if (!attachments.length) return '';
9258
+ return '<div class="attachment-list">' + attachments.map(renderAttachmentCard).join('') + '</div>';
9259
+ }
9260
+
9261
+ function messageAttachments(message) {
9262
+ const metadata = message && message.metadata && typeof message.metadata === 'object' ? message.metadata : {};
9263
+ const attachments = Array.isArray(metadata.attachments) ? metadata.attachments : [];
9264
+ const attachedPointers = new Set(attachments.map((attachment) => attachment && attachment.assetPointer).filter(Boolean));
9265
+ const result = attachments.map((attachment, index) => ({ ...attachment, index, kind: 'attachment' }));
9266
+ const pointers = Array.isArray(metadata.assetPointers) ? metadata.assetPointers : [];
9267
+ for (const pointer of pointers) {
9268
+ if (!pointer || !pointer.assetPointer || attachedPointers.has(pointer.assetPointer)) continue;
9269
+ result.push({
9270
+ index: result.length,
9271
+ kind: 'asset',
9272
+ name: pointer.assetPointer,
9273
+ assetPointer: pointer.assetPointer,
9274
+ mimeType: pointer.mimeType,
9275
+ pointerContentType: pointer.contentType,
9276
+ width: pointer.width,
9277
+ height: pointer.height,
9278
+ size: pointer.size
9279
+ });
9280
+ }
9281
+ return result;
9282
+ }
9283
+
9284
+ function renderAttachmentCard(attachment) {
9285
+ const name = attachmentDisplayName(attachment);
9286
+ const meta = attachmentMetaLabel(attachment);
9287
+ const title = [name, meta, attachment.assetPointer || ''].filter(Boolean).join('\\n');
9288
+ const isImage = attachmentIsImageForDisplay(attachment);
9289
+ const hasUrl = attachment.available !== false && typeof attachment.url === 'string' && attachment.url;
9290
+ const thumb = isImage && hasUrl
9291
+ ? '<span class="attachment-thumb image"><img loading="lazy" src="' + esc(attachment.url) + '" alt="' + esc(name) + '"></span>'
9292
+ : '<span class="attachment-thumb">' + attachmentIconSvg(isImage ? 'image' : 'file') + '</span>';
9293
+ const body =
9294
+ '<span class="attachment-copy"><span class="attachment-name">' + esc(name) + '</span>' +
9295
+ (meta ? '<span class="attachment-meta">' + esc(meta) + '</span>' : '') +
9296
+ (!hasUrl ? '<span class="attachment-meta">not available in raw export</span>' : '') +
9297
+ '</span>';
9298
+ const html = thumb + body;
9299
+ if (!hasUrl) return '<div class="attachment-card" title="' + esc(title) + '">' + html + '</div>';
9300
+ return '<a class="attachment-card' + (isImage ? ' image' : '') + '" href="' + esc(attachment.url) + '" target="_blank" rel="noopener" title="' + esc(title) + '">' + html + '</a>';
9301
+ }
9302
+
9303
+ function attachmentDisplayName(attachment) {
9304
+ return String(attachment && (attachment.name || attachment.id || attachment.assetPointer || 'attachment')).split(/[\\\\/]+/).pop() || 'attachment';
9305
+ }
9306
+
9307
+ function attachmentMetaLabel(attachment) {
9308
+ const bits = [];
9309
+ if (attachment.mimeType) bits.push(attachment.mimeType);
9310
+ else if (attachment.pointerContentType) bits.push(String(attachment.pointerContentType).replace(/_/g, ' '));
9311
+ if (attachment.width && attachment.height) bits.push(String(attachment.width) + 'x' + String(attachment.height));
9312
+ if (attachment.size) bits.push(formatAttachmentBytes(attachment.size));
9313
+ return bits.join(' · ');
9314
+ }
9315
+
9316
+ function formatAttachmentBytes(value) {
9317
+ const bytes = Number(value || 0);
9318
+ if (!Number.isFinite(bytes) || bytes <= 0) return '';
9319
+ if (bytes < 1024) return Math.round(bytes) + ' B';
9320
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(bytes >= 10 * 1024 ? 0 : 1).replace(/\\.0$/, '') + ' KB';
9321
+ return (bytes / (1024 * 1024)).toFixed(bytes >= 10 * 1024 * 1024 ? 0 : 1).replace(/\\.0$/, '') + ' MB';
9322
+ }
9323
+
9324
+ function attachmentIsImageForDisplay(attachment) {
9325
+ return /^image\\//i.test(String(attachment && attachment.mimeType || '')) || /\\.(png|jpe?g|gif|webp|bmp|tiff?|heic|heif|avif)$/i.test(attachmentDisplayName(attachment));
9326
+ }
9327
+
9328
+ function attachmentIconSvg(kind) {
9329
+ if (kind === 'image') {
9330
+ return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="18" height="18" x="3" y="3" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.1-3.1a2 2 0 0 0-2.8 0L6 21"/></svg>';
9331
+ }
9332
+ return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/></svg>';
9333
+ }
9334
+
7920
9335
  function renderMessageBodyWithTools(className, content, toolCalls) {
7921
9336
  const body = className === 'user' ? renderPlainText(content) : renderRichText(content);
7922
9337
  const stack = toolCalls.length > 1
@@ -8020,7 +9435,9 @@ function messageToolCalls(message, pairedResultMessages) {
8020
9435
  const eventCalls = canonicalEventsForMessage(message, 'tool.called').map(toolCallFromEvent).filter(Boolean);
8021
9436
  if (eventCalls.length) return attachPairedToolResults(eventCalls, pairedResultMessages);
8022
9437
  const metadataCalls = Array.isArray(message?.metadata?.toolCalls) ? message.metadata.toolCalls : [];
9438
+ const structuredCall = metadataCalls.length ? null : viewerStructuredToolCall(message);
8023
9439
  const textCalls = toolInvocationsFromText(message?.content || '');
9440
+ if (structuredCall) return attachPairedToolResults([structuredCall], pairedResultMessages);
8024
9441
  if (metadataCalls.length) {
8025
9442
  const calls = metadataCalls.map((meta, index) => {
8026
9443
  const text = textCalls[index] || {};
@@ -8069,6 +9486,62 @@ function messageToolCalls(message, pairedResultMessages) {
8069
9486
  return attachPairedToolResults(calls.filter((call) => call.kind || call.title || call.argument), pairedResultMessages);
8070
9487
  }
8071
9488
 
9489
+ function isViewerStructuredToolCallMessage(message) {
9490
+ return Boolean(viewerStructuredToolCall(message));
9491
+ }
9492
+
9493
+ function viewerStructuredToolCall(message) {
9494
+ const meta = message && message.metadata && typeof message.metadata === 'object' ? message.metadata : {};
9495
+ const recipient = String(meta.recipient || '').trim();
9496
+ if (!recipient || recipient === 'all' || recipient === 'assistant') return null;
9497
+ if (String(meta.source || '') !== 'chatgpt-export' && String(currentSessionPayload?.provider || '') !== 'chatgpt') return null;
9498
+ const content = String(message?.content || '').trim();
9499
+ if (!content) return null;
9500
+ const parsed = parseToolArgumentsForViewer(content);
9501
+ const kind = viewerChatGptToolName(recipient, parsed);
9502
+ const preview = typeof parsed === 'object' && parsed
9503
+ ? JSON.stringify(parsed, null, 2)
9504
+ : content;
9505
+ return toolCard({
9506
+ kind,
9507
+ name: kind,
9508
+ title: roleLabel(kind.replace(/[._-]+/g, ' ')),
9509
+ category: recipient.includes('web.') || kind.includes('web.') ? 'web' : '',
9510
+ rawCategory: 'chatgpt_tool_call',
9511
+ status: 'tool_call',
9512
+ argument: summarizeViewerToolArguments(parsed) || content.slice(0, 240),
9513
+ inputPreview: preview.slice(0, 2000),
9514
+ arguments: parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null
9515
+ });
9516
+ }
9517
+
9518
+ function parseToolArgumentsForViewer(value) {
9519
+ try { return JSON.parse(String(value || '').trim()); } catch { return String(value || '').trim(); }
9520
+ }
9521
+
9522
+ function viewerChatGptToolName(recipient, args) {
9523
+ if (recipient === 'web.run' && args && typeof args === 'object' && !Array.isArray(args)) {
9524
+ if (args.search_query || args.searchQuery) return 'web.search';
9525
+ if (args.open) return 'web.open';
9526
+ if (args.image_query || args.imageQuery) return 'web.image_search';
9527
+ if (args.finance) return 'web.finance';
9528
+ if (args.weather) return 'web.weather';
9529
+ if (args.sports) return 'web.sports';
9530
+ if (args.time) return 'web.time';
9531
+ }
9532
+ return recipient || 'tool';
9533
+ }
9534
+
9535
+ function summarizeViewerToolArguments(value) {
9536
+ if (!value || typeof value !== 'object') return String(value || '').slice(0, 240);
9537
+ for (const key of ['query', 'pattern', 'command', 'cmd', 'prompt']) {
9538
+ if (value[key]) return String(value[key]).slice(0, 240);
9539
+ }
9540
+ if (Array.isArray(value.search_query) && value.search_query[0]?.q) return String(value.search_query[0].q).slice(0, 240);
9541
+ if (Array.isArray(value.open) && value.open[0]?.ref_id) return String(value.open[0].ref_id).slice(0, 240);
9542
+ return Object.entries(value).slice(0, 3).map(([key, item]) => key + ': ' + (typeof item === 'string' ? item : JSON.stringify(item))).join(', ').slice(0, 240);
9543
+ }
9544
+
8072
9545
  function attachPairedToolResults(calls, pairedResultMessages) {
8073
9546
  if (!Array.isArray(pairedResultMessages) || !pairedResultMessages.length) return calls;
8074
9547
  const results = pairedResultMessages
@@ -8515,6 +9988,7 @@ function contextIconSvg(kind) {
8515
9988
  command_message: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="4 17 10 11 4 5"/><line x1="12" x2="20" y1="19" y2="19"/></svg>',
8516
9989
  local_command: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="4 17 10 11 4 5"/><line x1="12" x2="20" y1="19" y2="19"/></svg>',
8517
9990
  system_reminder: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="12" x2="12" y1="8" y2="12"/><line x1="12" x2="12.01" y1="16" y2="16"/></svg>',
9991
+ subagents: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M16 18a4 4 0 0 0-8 0"/><circle cx="12" cy="10" r="3"/><path d="M4 20a3 3 0 0 1 3-3"/><path d="M20 20a3 3 0 0 0-3-3"/><path d="M6 9a2 2 0 1 0 0 4"/><path d="M18 9a2 2 0 1 1 0 4"/></svg>',
8518
9992
  conversation_summary: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a4 4 0 0 1-4 4H8l-5 3V7a4 4 0 0 1 4-4h10a4 4 0 0 1 4 4z"/><path d="M8 8h8"/><path d="M8 12h6"/></svg>',
8519
9993
  metadata: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="12" x2="12" y1="16" y2="12"/><line x1="12" x2="12.01" y1="8" y2="8"/></svg>'
8520
9994
  };
@@ -8553,22 +10027,23 @@ function parseToolResult(content, message) {
8553
10027
  const event = canonicalEventsForMessage(message, 'tool.completed')[0];
8554
10028
  if (event) {
8555
10029
  const result = toolResultFromMetadata(event.body?.toolResult || {}, event.indexed || {}, event.body?.text || content);
8556
- if (result) return result;
10030
+ if (result) return refineViewerToolResult(result, event.body?.text || content);
8557
10031
  }
8558
10032
  if (message?.metadata?.toolResult) {
8559
10033
  const result = toolResultFromMetadata(message.metadata.toolResult, {}, content);
8560
- if (result) return result;
10034
+ if (result) return refineViewerToolResult(result, content);
8561
10035
  }
8562
10036
  const text = String(content || '').trim();
8563
10037
  if (!text) return null;
8564
- const structured = parseFileViewResult(text) || parseCommandResult(text) || parseSkillResult(text) || parseSearchResult(text);
10038
+ const structured = parseChatGptFileToolResult(text) || parseFileViewResult(text) || parseCommandResult(text) || parseSkillResult(text) || parseSearchResult(text);
8565
10039
  if (structured) return structured;
8566
10040
  return genericToolResult(text);
8567
10041
  }
8568
10042
 
8569
10043
  function toolResultFromMetadata(result, indexed, fallbackText) {
8570
- const output = String(result.output || result.text || result.content || fallbackText || '').trimEnd();
8571
- if (!output && !result.summary && !indexed.summary) return null;
10044
+ const output = formatChatGptCitationMarkersForPlainText(String(result.output || result.text || result.content || fallbackText || '').trimEnd());
10045
+ const summary = formatChatGptCitationMarkersForPlainText(result.summary || indexed.summary || '');
10046
+ if (!output && !summary) return null;
8572
10047
  const category = normalizedToolCategory(result.category || indexed.toolCategory || result.rawCategory || '', result.kind || indexed.title || '');
8573
10048
  return {
8574
10049
  id: result.id || result.callId || result.call_id || result.toolCallId || result.tool_call_id || result.toolUseId || result.tool_use_id || '',
@@ -8579,14 +10054,62 @@ function toolResultFromMetadata(result, indexed, fallbackText) {
8579
10054
  category,
8580
10055
  categoryLabel: result.categoryLabel || toolCategoryLabel(category),
8581
10056
  icon: result.icon || indexed.toolIcon || toolIcon(category, result.kind || indexed.title || ''),
8582
- detail: result.summary || indexed.summary || firstLine(output),
8583
- count: result.lineCount ? result.lineCount + ' line' + (Number(result.lineCount) === 1 ? '' : 's') : lineCountLabel(output || result.summary || indexed.summary || ''),
8584
- output: output || result.summary || indexed.summary || '',
10057
+ detail: summary || firstLine(output),
10058
+ count: result.lineCount ? result.lineCount + ' line' + (Number(result.lineCount) === 1 ? '' : 's') : lineCountLabel(output || summary || ''),
10059
+ output: output || summary || '',
8585
10060
  lineStart: Number(result.startLine || result.start_line || result.lineStart || result.line_start || 0) || 0,
8586
10061
  collapsed: Boolean(result.collapsed) || String(output).split('\\n').length > 18
8587
10062
  };
8588
10063
  }
8589
10064
 
10065
+ function refineViewerToolResult(result, fallbackText) {
10066
+ const chatgpt = parseChatGptFileToolResult(result?.output || fallbackText || '');
10067
+ if (!chatgpt) return result;
10068
+ return {
10069
+ ...result,
10070
+ ...chatgpt,
10071
+ id: result.id || chatgpt.id || '',
10072
+ header: chatgpt.header || result.header || 'Tool result',
10073
+ output: chatgpt.output || result.output || '',
10074
+ count: chatgpt.count || result.count || ''
10075
+ };
10076
+ }
10077
+
10078
+ function parseChatGptFileToolResult(text) {
10079
+ const value = String(text || '').trim();
10080
+ if (!value) return null;
10081
+ if (/^All the files uploaded by the user have been fully loaded\\./i.test(value)) {
10082
+ return {
10083
+ header: 'Tool result · Files',
10084
+ kind: 'Uploaded files loaded',
10085
+ category: 'read',
10086
+ categoryLabel: 'Files',
10087
+ icon: 'R',
10088
+ detail: firstLine(formatChatGptCitationMarkersForPlainText(value)),
10089
+ count: lineCountLabel(value),
10090
+ output: formatChatGptCitationMarkersForPlainText(value),
10091
+ collapsed: false
10092
+ };
10093
+ }
10094
+ if (!/<PARSED TEXT FOR PAGE:\\s*\\d+\\s*\\/\\s*\\d+>/i.test(value)) return null;
10095
+ const pages = Array.from(value.matchAll(/<PARSED TEXT FOR PAGE:\\s*(\\d+)\\s*\\/\\s*(\\d+)>/gi));
10096
+ const pageCount = pages.reduce((max, match) => Math.max(max, Number(match[2]) || 0), 0);
10097
+ const citation = chatGptCitationMarkerDetail(value.match(/\uE200filecite\uE202([^\uE201]+)\uE201/i)?.[1] || '');
10098
+ const detail = [citation, pageCount ? pageCount + ' page' + (pageCount === 1 ? '' : 's') : ''].filter(Boolean).join(' · ');
10099
+ const output = formatChatGptCitationMarkersForPlainText(value);
10100
+ return {
10101
+ header: 'Tool result · Files',
10102
+ kind: 'Parsed uploaded file',
10103
+ category: 'read',
10104
+ categoryLabel: 'Files',
10105
+ icon: 'R',
10106
+ detail: detail || 'Uploaded file parsed text',
10107
+ count: lineCountLabel(output),
10108
+ output,
10109
+ collapsed: true
10110
+ };
10111
+ }
10112
+
8590
10113
  function parseFileViewResult(text) {
8591
10114
  const match = text.match(/^<file-view\\b([^>]*)>\\n?([\\s\\S]*?)(?:\\n?<\\/file-view>\\s*)?$/);
8592
10115
  if (!match) return null;
@@ -8671,7 +10194,7 @@ function renderToolResult(result, options) {
8671
10194
  }
8672
10195
 
8673
10196
  function renderToolOutput(output, options = {}) {
8674
- const text = String(output || '');
10197
+ const text = formatChatGptCitationMarkersForPlainText(String(output || ''));
8675
10198
  const lines = text.split('\\n');
8676
10199
  if (lines.length <= 1) return '<pre class="tool-output">' + esc(text) + '</pre>';
8677
10200
  const start = Number(options.lineStart || 0) || 1;
@@ -8864,6 +10387,13 @@ function renderInline(value) {
8864
10387
  function renderInlinePlain(value) {
8865
10388
  let html = esc(value);
8866
10389
  const redactions = [];
10390
+ const citations = [];
10391
+ html = html.replace(/\uE200([A-Za-z_]*cite)\uE202([^\uE201]+)\uE201/g, function (_, kind, ref) {
10392
+ const index = citations.length;
10393
+ const title = chatGptCitationMarkerTitle(kind, ref);
10394
+ citations.push('<span class="citation-chip" title="' + esc(title) + '">' + esc(citationLabel(kind, ref)) + '</span>');
10395
+ return '\\u0000CITATION_' + index + '\\u0000';
10396
+ });
8867
10397
  html = html.replace(/\\[REDACTED(?::|\\s+)([^\\]\\n]+)\\]/g, function (_, kind) {
8868
10398
  const index = redactions.length;
8869
10399
  const label = redactionLabel(kind);
@@ -8877,9 +10407,49 @@ function renderInlinePlain(value) {
8877
10407
  html = html.replace(/\\u0000REDACTION_(\\d+)\\u0000/g, function (_, index) {
8878
10408
  return redactions[Number(index)] || '';
8879
10409
  });
10410
+ html = html.replace(/\\u0000CITATION_(\\d+)\\u0000/g, function (_, index) {
10411
+ return citations[Number(index)] || '';
10412
+ });
8880
10413
  return html;
8881
10414
  }
8882
10415
 
10416
+ function citationLabel(kind, ref) {
10417
+ const type = String(kind || '').toLowerCase();
10418
+ const parts = chatGptCitationMarkerParts(ref);
10419
+ if (type.includes('file')) {
10420
+ const line = parts.find((part) => /^L\\d+/i.test(part));
10421
+ if (line) return line.length <= 14 ? line : 'file';
10422
+ const file = parts.find((part) => /file/i.test(part)) || '';
10423
+ const shortFile = file.replace(/^turn\\d+/, '').replace(/^[-_:]+/, '');
10424
+ return shortFile && shortFile.length <= 12 ? shortFile : 'file';
10425
+ }
10426
+ const text = parts[parts.length - 1] || String(ref || '').trim();
10427
+ const short = text.replace(/^turn\\d+/, '').replace(/^[-_:]+/, '');
10428
+ return short && short.length <= 12 ? short : 'cite';
10429
+ }
10430
+
10431
+ function chatGptCitationMarkerTitle(kind, ref) {
10432
+ const label = /file/i.test(String(kind || '')) ? 'ChatGPT file citation' : 'ChatGPT citation';
10433
+ const detail = chatGptCitationMarkerDetail(ref);
10434
+ return detail ? label + ': ' + detail : label;
10435
+ }
10436
+
10437
+ function chatGptCitationMarkerDetail(ref) {
10438
+ return chatGptCitationMarkerParts(ref).join(' ');
10439
+ }
10440
+
10441
+ function chatGptCitationMarkerParts(ref) {
10442
+ return String(ref || '').split('\uE202').map((part) => part.trim()).filter(Boolean);
10443
+ }
10444
+
10445
+ function formatChatGptCitationMarkersForPlainText(value) {
10446
+ return String(value || '').replace(/\uE200([A-Za-z_]*cite)\uE202([^\uE201]+)\uE201/g, function (_, kind, ref) {
10447
+ const label = /file/i.test(String(kind || '')) ? 'file citation' : 'citation';
10448
+ const detail = chatGptCitationMarkerDetail(ref);
10449
+ return detail ? '[' + label + ': ' + detail + ']' : '[' + label + ']';
10450
+ });
10451
+ }
10452
+
8883
10453
  function renderMarkdownLink(label, href) {
8884
10454
  const value = String(href || '').trim();
8885
10455
  if (!/^(https?:|mailto:|file:|\\/|#)/i.test(value)) {
@@ -8904,7 +10474,47 @@ function compactSkillPath(value) {
8904
10474
  return '.../' + parts.slice(-2).join('/');
8905
10475
  }
8906
10476
 
8907
- function setView(mode) {
10477
+ function clampScrollRatio(value) {
10478
+ const number = Number(value);
10479
+ if (!Number.isFinite(number)) return 0;
10480
+ return Math.max(0, Math.min(1, number));
10481
+ }
10482
+
10483
+ function captureRelativeScrollPosition() {
10484
+ const scroller = document.querySelector('.detail-scroll');
10485
+ if (!scroller) return null;
10486
+ const maxScroll = Math.max(0, scroller.scrollHeight - scroller.clientHeight);
10487
+ return {
10488
+ scroller,
10489
+ ratio: maxScroll > 0 ? clampScrollRatio(scroller.scrollTop / maxScroll) : 0
10490
+ };
10491
+ }
10492
+
10493
+ function restoreRelativeScrollPosition(position, serial) {
10494
+ if (!position || !position.scroller) return;
10495
+ if (serial !== viewScrollRestoreSerial) return;
10496
+ const scroller = position.scroller;
10497
+ const maxScroll = Math.max(0, scroller.scrollHeight - scroller.clientHeight);
10498
+ scroller.scrollTop = Math.round(maxScroll * clampScrollRatio(position.ratio));
10499
+ if (typeof updateJumpEndVisibility === 'function') updateJumpEndVisibility();
10500
+ }
10501
+
10502
+ function scheduleRelativeScrollRestore(position, serial) {
10503
+ if (!position) return;
10504
+ const schedule = window.requestAnimationFrame
10505
+ ? (fn) => window.requestAnimationFrame(fn)
10506
+ : (fn) => window.setTimeout(fn, 0);
10507
+ schedule(() => {
10508
+ restoreRelativeScrollPosition(position, serial);
10509
+ schedule(() => restoreRelativeScrollPosition(position, serial));
10510
+ });
10511
+ }
10512
+
10513
+ function setView(mode, options) {
10514
+ const opts = options || {};
10515
+ const preserveScroll = opts.preserveScroll !== false && mode !== viewMode;
10516
+ const scrollPosition = preserveScroll ? captureRelativeScrollPosition() : null;
10517
+ const restoreSerial = ++viewScrollRestoreSerial;
8908
10518
  viewMode = mode;
8909
10519
  const markdown = mode === 'markdown';
8910
10520
  readableButton.classList.toggle('active', !markdown);
@@ -8913,9 +10523,15 @@ function setView(mode) {
8913
10523
  markdownButton.setAttribute('aria-pressed', markdown ? 'true' : 'false');
8914
10524
  readableView.style.display = markdown ? 'none' : 'block';
8915
10525
  markdownView.style.display = markdown ? 'block' : 'none';
8916
- if (markdown) ensureMarkdownLoaded().catch((error) => {
8917
- markdownView.textContent = error.message || 'Markdown could not be loaded.';
8918
- });
10526
+ const markdownLoad = markdown
10527
+ ? ensureMarkdownLoaded().catch((error) => {
10528
+ markdownView.textContent = error.message || 'Markdown could not be loaded.';
10529
+ })
10530
+ : null;
10531
+ if (preserveScroll) {
10532
+ scheduleRelativeScrollRestore(scrollPosition, restoreSerial);
10533
+ if (markdownLoad) markdownLoad.finally(() => scheduleRelativeScrollRestore(scrollPosition, restoreSerial));
10534
+ }
8919
10535
  }
8920
10536
 
8921
10537
  async function ensureMarkdownLoaded() {
@@ -9064,7 +10680,8 @@ function treeSignature(payload) {
9064
10680
  const sessions = (group.sessions || []).map((session) => session.session_id + '=' + (session.updated_at || session.ended_at || session.started_at || '')).join('|');
9065
10681
  return (group.repo_key || '') + '#' + (group.count || 0) + ':' + sessions;
9066
10682
  }).join('||');
9067
- return groupSig + '::' + (payload.count || 0);
10683
+ const sourceSig = (payload.available_source_options || []).map((option) => option.value || '').join(',');
10684
+ return groupSig + '::' + (payload.count || 0) + '::sources=' + sourceSig;
9068
10685
  }
9069
10686
 
9070
10687
  function startLiveRefresh() {
@@ -9262,14 +10879,27 @@ recentButton.onclick = () => {
9262
10879
  setSelectValue('scope', 'all');
9263
10880
  loadTree().catch((error) => { setEmptySession(error.message); });
9264
10881
  };
9265
- readableButton.onclick = () => setView('readable');
9266
- markdownButton.onclick = () => setView('markdown');
10882
+ readableButton.onclick = () => setView('readable', { preserveScroll: true });
10883
+ markdownButton.onclick = () => setView('markdown', { preserveScroll: true });
9267
10884
  copyDetailsButton.onclick = () => {
9268
10885
  copySessionDetails();
9269
10886
  };
9270
10887
  copyResumeButton.onclick = () => {
9271
10888
  copyResumeCommand();
9272
10889
  };
10890
+ if (sessionModalClose) sessionModalClose.onclick = closeSubagentModal;
10891
+ if (sessionModal) {
10892
+ sessionModal.addEventListener('click', (event) => {
10893
+ if (event.target === sessionModal) closeSubagentModal();
10894
+ });
10895
+ }
10896
+ window.addEventListener('keydown', (event) => {
10897
+ if (event.key === 'Escape' && sessionModal && !sessionModal.hidden) {
10898
+ closeSubagentModal();
10899
+ event.preventDefault();
10900
+ event.stopPropagation();
10901
+ }
10902
+ });
9273
10903
  jumpEnd.onclick = () => {
9274
10904
  const scroller = document.querySelector('.detail-scroll');
9275
10905
  scroller.scrollTo({ top: scroller.scrollHeight, behavior: 'smooth' });
@@ -9500,7 +11130,7 @@ function statsBreakdownGroupId(entry) {
9500
11130
  }
9501
11131
 
9502
11132
  function emptyStatsTotals() {
9503
- return { tokens: 0, tokens_input: 0, tokens_output: 0, tokens_cache: 0, tokens_estimated: 0, conversations: 0, user_messages: 0 };
11133
+ return { tokens: 0, tokens_input: 0, tokens_output: 0, tokens_cache: 0, tokens_reasoning: 0, tokens_estimated: 0, conversations: 0, user_messages: 0 };
9504
11134
  }
9505
11135
 
9506
11136
  function addStatsTotals(target, entry) {
@@ -9509,6 +11139,7 @@ function addStatsTotals(target, entry) {
9509
11139
  target.tokens_input += Number(entry.tokens_input || 0);
9510
11140
  target.tokens_output += Number(entry.tokens_output || 0);
9511
11141
  target.tokens_cache += Number(entry.tokens_cache || 0);
11142
+ target.tokens_reasoning += Number(entry.tokens_reasoning || 0);
9512
11143
  target.tokens_estimated += Number(entry.tokens_estimated || 0);
9513
11144
  target.conversations += Number(entry.conversations ?? entry.sessions ?? 0);
9514
11145
  target.user_messages += Number(entry.user_messages || 0);
@@ -9593,7 +11224,7 @@ function statsRepoRowsForRange(payload) {
9593
11224
  for (const row of Array.isArray(day.repos) ? day.repos : []) {
9594
11225
  const key = row.repo_key || row.repo_display || 'unknown';
9595
11226
  if (!repos.has(key)) {
9596
- repos.set(key, { repo_key: row.repo_key || key, repo_display: row.repo_display || key, providers: {}, companies: {}, models: {}, tokens: 0, tokens_input: 0, tokens_output: 0, tokens_cache: 0, tokens_estimated: 0, conversations: 0, user_messages: 0 });
11227
+ repos.set(key, { repo_key: row.repo_key || key, repo_display: row.repo_display || key, providers: {}, companies: {}, models: {}, tokens: 0, tokens_input: 0, tokens_output: 0, tokens_cache: 0, tokens_reasoning: 0, tokens_estimated: 0, conversations: 0, user_messages: 0 });
9597
11228
  }
9598
11229
  const target = repos.get(key);
9599
11230
  target.repo_display = row.repo_display || target.repo_display;
@@ -9647,7 +11278,7 @@ function renderStats(payload) {
9647
11278
  }
9648
11279
 
9649
11280
  function statsTokenMetric() {
9650
- if (statsShowInputTokens && statsShowOutputTokens && !statsShowCacheTokens) return 'tokens';
11281
+ if (statsShowInputTokens && statsShowOutputTokens && statsShowCacheTokens) return 'tokens';
9651
11282
  if (statsShowInputTokens && !statsShowOutputTokens && !statsShowCacheTokens) return 'tokens_input';
9652
11283
  if (!statsShowInputTokens && statsShowOutputTokens && !statsShowCacheTokens) return 'tokens_output';
9653
11284
  if (!statsShowInputTokens && !statsShowOutputTokens && statsShowCacheTokens) return 'tokens_cache';
@@ -9676,7 +11307,7 @@ function statsMetricValue(entry, metric) {
9676
11307
  }
9677
11308
 
9678
11309
  function isTokenMetric(metric) {
9679
- return metric === 'tokens' || metric === 'tokens_input' || metric === 'tokens_output' || metric === 'tokens_cache' || metric === 'tokens_selected';
11310
+ return metric === 'tokens' || metric === 'tokens_input' || metric === 'tokens_output' || metric === 'tokens_cache' || metric === 'tokens_reasoning' || metric === 'tokens_selected';
9680
11311
  }
9681
11312
 
9682
11313
  function statsActivityMetric() {
@@ -10014,6 +11645,7 @@ function renderStatsMetrics(payload) {
10014
11645
  const totalInTok = Number(payload.total_input_tokens || 0);
10015
11646
  const totalOutTok = Number(payload.total_output_tokens || 0);
10016
11647
  const totalCacheTok = Number(payload.total_cache_tokens || 0);
11648
+ const totalReasonTok = Number(payload.total_reasoning_tokens || 0);
10017
11649
  const totalEstimatedTok = Number(payload.total_estimated_tokens || 0);
10018
11650
  const sdkSessions = Number(payload.sdk_session_count || 0);
10019
11651
  const sdkTokens = Number(payload.sdk_total_tokens || 0);
@@ -10028,6 +11660,7 @@ function renderStatsMetrics(payload) {
10028
11660
  const usageParts = [];
10029
11661
  if (totalInTok || totalOutTok) usageParts.push(formatCompactNumber(totalInTok) + ' in / ' + formatCompactNumber(totalOutTok) + ' out');
10030
11662
  if (totalCacheTok) usageParts.push(formatCompactNumber(totalCacheTok) + ' cache');
11663
+ if (totalReasonTok) usageParts.push(formatCompactNumber(totalReasonTok) + ' reasoning');
10031
11664
  if (totalEstimatedTok) usageParts.push('~' + formatCompactNumber(totalEstimatedTok) + ' estimated');
10032
11665
  tokenSub = usageParts.join(' · ');
10033
11666
  } else {
@@ -10184,7 +11817,7 @@ function renderHeatmapSection(payload) {
10184
11817
  });
10185
11818
  renderSecondaryHeatmap(payload.split_stats && payload.split_stats.sdk, 'statsSdkActivitySub', 'statsSdkHeatmap', range, metric, {
10186
11819
  emptySubText: '',
10187
- emptyText: 'No SDK jobs imported.'
11820
+ emptyText: 'No SDK jobs imported. Import once with agentlog import --sources codex-sdk,claude-sdk --since all. Keep them updated with agentlog config sources add codex-sdk,claude-sdk.'
10188
11821
  });
10189
11822
  }
10190
11823
 
@@ -10571,7 +12204,7 @@ function setupCustomSelects() {
10571
12204
  const input = field.querySelector('input');
10572
12205
  const trigger = field.querySelector('.select-trigger');
10573
12206
  if (field.dataset.select === 'provider') hydrateProviderSelectOptions(field);
10574
- const options = Array.from(field.querySelectorAll('.select-option'));
12207
+ const options = bindSelectFieldOptions(field);
10575
12208
  const selectedOption = options.find((item) => (item.dataset.value || '') === input.value) || options.find((item) => item.classList.contains('active')) || options[0];
10576
12209
  if (selectedOption) setSelectTriggerLabel(field, selectedOption);
10577
12210
  updateSelectFieldState(field);
@@ -10582,23 +12215,65 @@ function setupCustomSelects() {
10582
12215
  }
10583
12216
  field.classList.toggle('open');
10584
12217
  };
10585
- for (const option of options) {
10586
- option.onclick = (event) => {
10587
- event.stopPropagation();
10588
- input.value = option.dataset.value || '';
10589
- setSelectTriggerLabel(field, option);
10590
- options.forEach((item) => item.classList.toggle('active', item === option));
10591
- updateSelectFieldState(field);
10592
- field.classList.remove('open');
10593
- refreshForFilterChange();
10594
- };
10595
- }
10596
12218
  }
10597
12219
  document.addEventListener('click', () => {
10598
12220
  for (const field of document.querySelectorAll('.select-field.open')) field.classList.remove('open');
10599
12221
  });
10600
12222
  }
10601
12223
 
12224
+ function bindSelectFieldOptions(field) {
12225
+ const input = field.querySelector('input');
12226
+ const options = Array.from(field.querySelectorAll('.select-option'));
12227
+ for (const option of options) {
12228
+ option.onclick = (event) => {
12229
+ event.stopPropagation();
12230
+ input.value = option.dataset.value || '';
12231
+ setSelectTriggerLabel(field, option);
12232
+ options.forEach((item) => item.classList.toggle('active', item === option));
12233
+ updateSelectFieldState(field);
12234
+ field.classList.remove('open');
12235
+ refreshForFilterChange();
12236
+ };
12237
+ }
12238
+ return options;
12239
+ }
12240
+
12241
+ function updateSourceFilterOptions(sourceOptions) {
12242
+ const field = document.querySelector('.select-field[data-select="provider"]');
12243
+ if (!field) return false;
12244
+ const input = field.querySelector('input');
12245
+ const menu = field.querySelector('.select-menu');
12246
+ if (!input || !menu) return false;
12247
+ const options = normalizeSourceFilterOptions(sourceOptions);
12248
+ const current = input.value || '';
12249
+ const values = new Set(options.map((option) => option.value));
12250
+ const reset = Boolean(current && !values.has(current));
12251
+ menu.innerHTML =
12252
+ '<button class="select-option" type="button" data-value="" data-label="All sources">All sources</button>' +
12253
+ options.map((option) =>
12254
+ '<button class="select-option" type="button" data-value="' + esc(option.value) + '" data-label="' + esc(option.label) + '">' +
12255
+ esc(option.label) +
12256
+ '</button>'
12257
+ ).join('');
12258
+ hydrateProviderSelectOptions(field);
12259
+ bindSelectFieldOptions(field);
12260
+ setSelectValue('provider', reset ? '' : current);
12261
+ return reset;
12262
+ }
12263
+
12264
+ function normalizeSourceFilterOptions(sourceOptions) {
12265
+ const seen = new Set();
12266
+ const out = [];
12267
+ for (const option of Array.isArray(sourceOptions) ? sourceOptions : []) {
12268
+ const value = String(option?.value || option?.source || '').trim();
12269
+ const label = String(option?.label || value).trim();
12270
+ if (!value || seen.has(value)) continue;
12271
+ seen.add(value);
12272
+ out.push({ value, label: label || value });
12273
+ }
12274
+ return out;
12275
+ }
12276
+
10602
12277
  function hydrateProviderSelectOptions(field) {
10603
12278
  for (const option of field.querySelectorAll('.select-option')) {
10604
12279
  if (!option.dataset.label) option.dataset.label = option.textContent.trim();
@@ -10679,6 +12354,12 @@ function setupKeyboardShortcuts() {
10679
12354
  const isInput = target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable);
10680
12355
 
10681
12356
  if (event.key === 'Escape') {
12357
+ if (sessionModal && !sessionModal.hidden) {
12358
+ closeSubagentModal();
12359
+ event.preventDefault();
12360
+ event.stopPropagation();
12361
+ return;
12362
+ }
10682
12363
  const searchInput = document.getElementById('q');
10683
12364
  if (searchInput && (searchInput.value || activeSearchTerm)) {
10684
12365
  searchInput.value = '';
@@ -10908,8 +12589,8 @@ Start here:
10908
12589
  Archive and import:
10909
12590
  init interactive setup and optional first import
10910
12591
  import import local Codex, Claude, Gemini, Devin, Cursor, Cline, OpenCode, Aider, and Antigravity history
10911
- import chatgpt [path] show ChatGPT export instructions; with path, import a ZIP/folder
10912
- import claude-web [path] show Claude.ai export instructions; with path, import a ZIP/folder
12592
+ import chatgpt [path] guided ChatGPT export import; with path, import ZIP/folder
12593
+ import claude-web [path] guided Claude.ai export import; with path, import ZIP/folder
10913
12594
  import windsurf <path> import downloaded Windsurf trajectory Markdown file/folder
10914
12595
  import accounts list or rename ChatGPT/Claude.ai export accounts
10915
12596
  sync choose a remote, preview, confirm, then upload archive objects
@@ -10992,8 +12673,8 @@ Import sources:
10992
12673
  all configured default local sources
10993
12674
 
10994
12675
  Web export sources:
10995
- chatgpt instructions for requesting a ChatGPT export; imports downloaded ZIP/folder
10996
- claude-web instructions for requesting a Claude.ai export; imports downloaded ZIP/folder
12676
+ chatgpt guided import for ChatGPT/OpenAI export ZIPs or folders
12677
+ claude-web guided import for Claude.ai export ZIPs or folders
10997
12678
  windsurf downloaded Cascade trajectory Markdown file/folder
10998
12679
 
10999
12680
  Examples:
@@ -11018,10 +12699,11 @@ Examples:
11018
12699
  agentlog import chatgpt
11019
12700
  agentlog import claude-web
11020
12701
  agentlog import chatgpt ~/Downloads/chatgpt-export.zip --username you@example.com
11021
- agentlog import claude-web ~/Downloads/claude-export --username brian --display-name Brian --scope local
12702
+ agentlog import chatgpt "~/Downloads/OpenAI-export/User Online Activity" --username you@example.com
12703
+ agentlog import claude-web ~/Downloads/claude-export --username you --display-name "Personal Claude" --scope local
11022
12704
  agentlog import windsurf ~/Downloads/cascade-chat-conversation.md
11023
12705
  agentlog import windsurf ~/windsurf-cascade-export
11024
- agentlog import accounts rename claude-web brian --display-name "Personal Claude"
12706
+ agentlog import accounts rename claude-web you --display-name "Personal Claude"
11025
12707
 
11026
12708
  Details:
11027
12709
  --since accepts 30d, 12h, 60m, ISO dates, or all.
@@ -11029,7 +12711,8 @@ Details:
11029
12711
  --dry-run shows what would be imported without writing archive files.
11030
12712
  --explain-skips includes per-session skip reasons for supported sources.
11031
12713
  --json prints machine-readable import results.
11032
- ChatGPT and Claude.ai imports print export instructions when the path is omitted.
12714
+ ChatGPT and Claude.ai imports prompt for export paths and account labels when omitted in a terminal.
12715
+ Use --instructions to print export instructions without starting the walkthrough.
11033
12716
  Windsurf imports prompt for the export path when omitted in a terminal.
11034
12717
  Windsurf local cache scanning is disabled because current Cascade transcripts are encrypted binary stores. Use the Windsurf "Download trajectory" Markdown export with \`agentlog import windsurf <file-or-folder>\`.
11035
12718
  See docs/history-source-handling.md for source-specific storage paths.
@@ -11156,7 +12839,7 @@ Usage:
11156
12839
  Examples:
11157
12840
  agentlog accounts list
11158
12841
  agentlog accounts rename
11159
- agentlog accounts rename claude-web brian --display-name "Personal Claude"
12842
+ agentlog accounts rename claude-web you --display-name "Personal Claude"
11160
12843
  `,
11161
12844
  config: `
11162
12845
  agentlog config
@@ -11390,7 +13073,7 @@ Usage:
11390
13073
  agentlog update [--yes] [--dry-run] [--since 30d|all] [--sources a,b,c] [--sync] [--no-index] [--no-restart]
11391
13074
 
11392
13075
  What it removes:
11393
- archive/index data local archive objects and search indexes
13076
+ archive/index data local agent archive objects and search indexes
11394
13077
  import state file/session fingerprints so sources are read again
11395
13078
  cache/spool reveal cache and pending local spool files
11396
13079
  sync state local upload bookkeeping; remote objects are not deleted
@@ -11399,13 +13082,14 @@ What it keeps:
11399
13082
  config.json storage, source, sync, privacy, and watcher preferences
11400
13083
  redaction.yaml redaction rules
11401
13084
  web account labels ChatGPT/Claude.ai account display names
13085
+ web chat archives manually imported ChatGPT/Claude.ai archive objects
11402
13086
  source histories Codex, Claude, Gemini, Devin, Cursor, etc. source data
11403
13087
  recall integrations agent command/skill files
11404
13088
 
11405
13089
  Options:
11406
13090
  --dry-run show targets and preferences without deleting or importing
11407
13091
  --yes skip confirmation
11408
- --since 30d|90d|all import window; defaults to imports.defaultSinceDays
13092
+ --since 30d|90d|all import window; defaults to the saved rebuild window, then all
11409
13093
  --sources a,b,c override configured sources for this run
11410
13094
  --sync upload changed objects after reimport; use sync replace to remove stale remote objects
11411
13095
  --no-index skip rebuilding the search index
@@ -11564,13 +13248,19 @@ module.exports = {
11564
13248
  _historyWebInternals: {
11565
13249
  HISTORY_AUTH_COOKIE,
11566
13250
  constantTimeEqual,
13251
+ filterShapeKey,
11567
13252
  historyHtml,
11568
13253
  isHistoryApiPath,
13254
+ listSnapshotEtag,
13255
+ memoizedStatsPayload,
11569
13256
  parseCookies,
11570
13257
  readSessionMarkdown,
11571
13258
  readSessionView,
13259
+ resolveSessionAttachmentFile,
11572
13260
  resumeCommandForSession,
13261
+ repoSessionsFilterKey,
11573
13262
  securityHeaders,
13263
+ sessionTreePayload,
11574
13264
  sessionViewPayload,
11575
13265
  sessionCookie,
11576
13266
  statsPayload,