agentel 0.2.5 → 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,15 +18,28 @@ 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");
25
25
  const { version } = require("./version");
26
26
  const { listWebAccounts, renameWebAccount } = require("./web-accounts");
27
+ const { webExportInstructions } = require("./web-export-instructions");
27
28
 
28
29
  const HISTORY_AUTH_COOKIE = "agentlog_history";
29
- 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;
30
43
 
31
44
  async function main(argv = process.argv.slice(2), env = process.env) {
32
45
  const { positionals, flags } = parseArgs(argv);
@@ -152,10 +165,12 @@ async function initCommand(flags, env) {
152
165
  });
153
166
  discoveryProgress.done();
154
167
  const importSources = await chooseImportSources(flags, discovered);
168
+ let backfillSince = "";
155
169
 
156
170
  if (!flags["skip-import"]) {
157
171
  const since = await chooseImportSince(flags);
158
172
  if (since) {
173
+ backfillSince = since;
159
174
  printSection("Backfill");
160
175
  printMuted(`Backing up existing history into ${fullPath(cfg.storage.root || paths(env).data)}`);
161
176
  const progress = createProgressReporter();
@@ -169,6 +184,7 @@ async function initCommand(flags, env) {
169
184
  }
170
185
 
171
186
  const watcherSetup = await chooseWatcherSources(flags, discovered, importSources);
187
+ if (backfillSince) applyUpdateSincePreference(cfg, backfillSince);
172
188
  cfg.imports.sources = watcherSetup.sources;
173
189
  cfg.imports.autoDiscoverSources = watcherSetup.autoDiscoverSources;
174
190
  saveConfig(cfg, env);
@@ -1444,6 +1460,7 @@ function printConfigSetupSummary(cfg, env, loginStatus = null, changed = false)
1444
1460
  printCheck("Config", fullPath(paths(env).config));
1445
1461
  printCheck("Watcher sources", (cfg.imports?.sources || []).join(", ") || "none");
1446
1462
  printCheck("Import window", `${cfg.imports?.defaultSinceDays ?? 30} days`);
1463
+ printCheck("Update rebuild window", defaultImportSince(cfg));
1447
1464
  printCheck("Auto-discover sources", cfg.imports?.autoDiscoverSources === false ? "disabled" : "enabled");
1448
1465
  printCheck("Sync interval", formatConfigInterval(cfg.sync?.intervalMinutes ?? 30));
1449
1466
  if (loginStatus) {
@@ -1795,19 +1812,35 @@ async function updateCommand(flags, env) {
1795
1812
  return;
1796
1813
  }
1797
1814
 
1798
- if (running.running) {
1799
- const stopped = stopSupervisor(env);
1800
- if (stopped) {
1801
- printCheck("Watcher", "stop signal sent");
1802
- 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
+ }
1803
1824
  }
1804
- }
1805
1825
 
1806
- for (const target of targets) {
1807
- fs.rmSync(target.path, { recursive: true, force: true });
1808
- 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;
1809
1843
  }
1810
- ensureBaseDirs(p);
1811
1844
 
1812
1845
  printSection("Import");
1813
1846
  const progress = flags.json ? null : createProgressReporter();
@@ -1851,7 +1884,128 @@ async function updateCommand(flags, env) {
1851
1884
  }
1852
1885
  }
1853
1886
 
1854
- 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
+ }
1855
2009
  }
1856
2010
 
1857
2011
  async function waitForSupervisorExit(pid, timeoutMs = 5000) {
@@ -1906,11 +2060,41 @@ function updateTargets(env) {
1906
2060
  }
1907
2061
 
1908
2062
  function defaultImportSince(config) {
1909
- const value = config?.imports?.defaultSinceDays;
1910
- if (String(value || "").trim().toLowerCase() === "all") return "all";
1911
- const days = Number(value);
1912
- if (!Number.isFinite(days) || days <= 0) return "30d";
1913
- 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]}`;
1914
2098
  }
1915
2099
 
1916
2100
  function coalesceTargets(targets) {
@@ -2185,33 +2369,39 @@ async function importCommand(args, flags, env) {
2185
2369
  printImportResults([result]);
2186
2370
  return;
2187
2371
  }
2188
- if (sub === "claude-web" || sub === "chatgpt") {
2189
- let importFile = flags.file || args[1] || "";
2190
- if (!importFile && process.stdin.isTTY) {
2191
- printSection(`${sub} Export`);
2192
- printMuted("Choose the official export JSON, ZIP, or extracted folder.");
2193
- importFile = (await ask(" Export path: ")).trim();
2372
+ if (sub === "claude-web" || sub === "claude_web" || sub === "chatgpt") {
2373
+ const instructions = webExportInstructions(sub);
2374
+ if (flags.instructions || flags.instruction || flags.docs) {
2375
+ return printWebExportInstructions(instructions, flags);
2376
+ }
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);
2194
2381
  }
2195
- if (!importFile) throw new Error(`usage: agentlog import ${sub} <path> --username <name> [--display-name <name>] [--account-id <id>] [--scope local|team]`);
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(sub, 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 || "";
@@ -2827,10 +3216,13 @@ function compareHistorySessionsForTree(a, b) {
2827
3216
 
2828
3217
  function statsPayload(filters, env) {
2829
3218
  const sessions = listHistorySessions({ ...filters, limit: 5000 }, env);
2830
- return statsPayloadForSessions(sessions);
3219
+ return statsPayloadForSessions(sessions, { includeSdk: statsFiltersTargetSdk(filters) });
2831
3220
  }
2832
3221
 
2833
3222
  function statsPayloadForSessions(sessions, options = {}) {
3223
+ const allSessions = Array.isArray(sessions) ? sessions : [];
3224
+ const sdkSessions = allSessions.filter(isSdkStatsSession);
3225
+ const statsSessions = options.includeSdk ? allSessions : allSessions.filter((session) => !isSdkStatsSession(session));
2834
3226
  const providerSet = new Set();
2835
3227
  const companySet = new Set();
2836
3228
  const modelGroupSet = new Set();
@@ -2847,13 +3239,14 @@ function statsPayloadForSessions(sessions, options = {}) {
2847
3239
  let totalInputTokens = 0;
2848
3240
  let totalOutputTokens = 0;
2849
3241
  let totalCacheTokens = 0;
3242
+ let totalReasoningTokens = 0;
2850
3243
  let totalEstimatedTokens = 0;
2851
3244
  let totalConversations = 0;
2852
3245
  let totalMessages = 0;
2853
3246
  let totalUserMessages = 0;
2854
3247
  let peakSessionTokens = 0;
2855
3248
  let peakSessionLabel = "";
2856
- for (const session of sessions) {
3249
+ for (const session of statsSessions) {
2857
3250
  const provider = String(session.provider || "unknown");
2858
3251
  const modelGroup = statsSessionPrimaryModel(session);
2859
3252
  const companyGroup = statsSessionCompany(session, provider, modelGroup);
@@ -2870,6 +3263,7 @@ function statsPayloadForSessions(sessions, options = {}) {
2870
3263
  totalInputTokens += usageTokens.tokens_input;
2871
3264
  totalOutputTokens += usageTokens.tokens_output;
2872
3265
  totalCacheTokens += usageTokens.tokens_cache;
3266
+ totalReasoningTokens += usageTokens.tokens_reasoning;
2873
3267
  totalEstimatedTokens += usageTokens.tokens_estimated;
2874
3268
  totalConversations += 1;
2875
3269
  totalMessages += Number.isFinite(messageCount) ? messageCount : 0;
@@ -2897,7 +3291,7 @@ function statsPayloadForSessions(sessions, options = {}) {
2897
3291
  dayBucket.models[modelGroup].conversations += 1;
2898
3292
  dayBucket.models[modelGroup].user_messages += userMessageCount;
2899
3293
 
2900
- 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 });
2901
3295
  const activity = dailyActivity.get(dayKey);
2902
3296
  activity.sessions += 1;
2903
3297
  activity.conversations += 1;
@@ -2909,7 +3303,7 @@ function statsPayloadForSessions(sessions, options = {}) {
2909
3303
  if (!isWebChatStatsProvider(provider)) {
2910
3304
  const repoKey = sessionRepoKey(session);
2911
3305
  const repoLabel = sessionRepoLabel(session);
2912
- 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 });
2913
3307
  const repoBucket = repoMap.get(repoKey);
2914
3308
  repoBucket.repo_display = repoLabel;
2915
3309
  if (!repoBucket.providers[provider]) repoBucket.providers[provider] = statsBucket();
@@ -2931,7 +3325,7 @@ function statsPayloadForSessions(sessions, options = {}) {
2931
3325
  if (dayKey) {
2932
3326
  if (!dailyRepoMap.has(dayKey)) dailyRepoMap.set(dayKey, new Map());
2933
3327
  const dailyRepos = dailyRepoMap.get(dayKey);
2934
- 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 });
2935
3329
  const dailyRepoBucket = dailyRepos.get(repoKey);
2936
3330
  dailyRepoBucket.repo_display = repoLabel;
2937
3331
  if (!dailyRepoBucket.providers[provider]) dailyRepoBucket.providers[provider] = statsBucket();
@@ -3012,6 +3406,7 @@ function statsPayloadForSessions(sessions, options = {}) {
3012
3406
  tokens_input: entry.tokens_input,
3013
3407
  tokens_output: entry.tokens_output,
3014
3408
  tokens_cache: entry.tokens_cache,
3409
+ tokens_reasoning: entry.tokens_reasoning,
3015
3410
  tokens_estimated: entry.tokens_estimated,
3016
3411
  conversations: entry.conversations,
3017
3412
  user_messages: entry.user_messages,
@@ -3034,6 +3429,7 @@ function statsPayloadForSessions(sessions, options = {}) {
3034
3429
  tokens_input: entry.tokens_input,
3035
3430
  tokens_output: entry.tokens_output,
3036
3431
  tokens_cache: entry.tokens_cache,
3432
+ tokens_reasoning: entry.tokens_reasoning,
3037
3433
  tokens_estimated: entry.tokens_estimated,
3038
3434
  conversations: entry.conversations,
3039
3435
  user_messages: entry.user_messages,
@@ -3080,7 +3476,7 @@ function statsPayloadForSessions(sessions, options = {}) {
3080
3476
  const favoriteProviderEntry = byProvider[0] || null;
3081
3477
  const dailyActivityList = activitySorted.map((day) => {
3082
3478
  const entry = dailyActivity.get(day);
3083
- 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 };
3084
3480
  });
3085
3481
  const monthAgg = new Map();
3086
3482
  let peakDayTokens = 0;
@@ -3114,21 +3510,33 @@ function statsPayloadForSessions(sessions, options = {}) {
3114
3510
  const avgMessagesPerConversation =
3115
3511
  totalConversations > 0 ? totalMessages / totalConversations : 0;
3116
3512
  const splitStats = options.includeSplit === false ? null : {
3117
- agent: statsPayloadForSessions(sessions.filter((session) => statsSessionCategory(session) === "agent"), { includeSplit: false, category: "agent" }),
3118
- chat: statsPayloadForSessions(sessions.filter((session) => statsSessionCategory(session) === "chat"), { includeSplit: false, category: "chat" })
3513
+ agent: statsPayloadForSessions(allSessions.filter((session) => !isSdkStatsSession(session) && statsSessionCategory(session) === "agent"), { includeSplit: false, category: "agent" }),
3514
+ chat: statsPayloadForSessions(allSessions.filter((session) => !isSdkStatsSession(session) && statsSessionCategory(session) === "chat"), { includeSplit: false, category: "chat" }),
3515
+ sdk: statsPayloadForSessions(sdkSessions, { includeSplit: false, includeSdk: true, category: "sdk" })
3119
3516
  };
3517
+ const sdkStats = splitStats?.sdk || null;
3120
3518
  return {
3121
3519
  category: options.category || "all",
3122
3520
  generated_at: new Date().toISOString(),
3123
3521
  session_count: totalConversations,
3124
3522
  agent_session_count: splitStats ? splitStats.agent.session_count : undefined,
3125
3523
  chat_session_count: splitStats ? splitStats.chat.session_count : undefined,
3524
+ sdk_session_count: sdkStats ? sdkStats.session_count : undefined,
3525
+ sdk_message_count: sdkStats ? sdkStats.message_count : undefined,
3526
+ sdk_user_message_count: sdkStats ? sdkStats.user_message_count : undefined,
3527
+ sdk_total_tokens: sdkStats ? sdkStats.total_tokens : undefined,
3528
+ sdk_total_input_tokens: sdkStats ? sdkStats.total_input_tokens : undefined,
3529
+ sdk_total_output_tokens: sdkStats ? sdkStats.total_output_tokens : undefined,
3530
+ sdk_total_cache_tokens: sdkStats ? sdkStats.total_cache_tokens : undefined,
3531
+ sdk_total_reasoning_tokens: sdkStats ? sdkStats.total_reasoning_tokens : undefined,
3532
+ sdk_total_estimated_tokens: sdkStats ? sdkStats.total_estimated_tokens : undefined,
3126
3533
  message_count: totalMessages,
3127
3534
  user_message_count: totalUserMessages,
3128
3535
  total_tokens: totalTokens,
3129
3536
  total_input_tokens: totalInputTokens,
3130
3537
  total_output_tokens: totalOutputTokens,
3131
3538
  total_cache_tokens: totalCacheTokens,
3539
+ total_reasoning_tokens: totalReasoningTokens,
3132
3540
  total_estimated_tokens: totalEstimatedTokens,
3133
3541
  active_days: activeDays,
3134
3542
  current_streak: currentStreak,
@@ -3221,6 +3629,20 @@ function statsSessionCategory(session) {
3221
3629
  return provider === "chatgpt" || provider === "claude_web" ? "chat" : "agent";
3222
3630
  }
3223
3631
 
3632
+ const SDK_STATS_SOURCE_TYPES = new Set(["codex-sdk-history", "claude-sdk-history"]);
3633
+
3634
+ function isSdkStatsSession(session) {
3635
+ const provider = String(session?.provider || "").toLowerCase();
3636
+ const sourceType = String(session?.sourceType || session?.source_type || "").toLowerCase();
3637
+ return provider === "claude_sdk" || SDK_STATS_SOURCE_TYPES.has(sourceType);
3638
+ }
3639
+
3640
+ function statsFiltersTargetSdk(filters = {}) {
3641
+ const provider = String(filters.provider || filters.source || "").trim().toLowerCase().replace(/[-\s]+/g, "_");
3642
+ const sourceType = String(filters.sourceType || filters.source_type || "").trim().toLowerCase();
3643
+ return provider === "codex_sdk" || provider === "claude_sdk" || SDK_STATS_SOURCE_TYPES.has(sourceType);
3644
+ }
3645
+
3224
3646
  function isWebChatStatsProvider(provider) {
3225
3647
  return provider === "chatgpt" || provider === "claude_web";
3226
3648
  }
@@ -3234,7 +3656,7 @@ function statsUsageForSession(session) {
3234
3656
  }
3235
3657
 
3236
3658
  function statsBucket() {
3237
- 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 };
3238
3660
  }
3239
3661
 
3240
3662
  function statsUserMessageCount(session) {
@@ -3248,6 +3670,7 @@ function addStatsTokens(bucket, usageTokens) {
3248
3670
  bucket.tokens_input += usageTokens.tokens_input;
3249
3671
  bucket.tokens_output += usageTokens.tokens_output;
3250
3672
  bucket.tokens_cache += usageTokens.tokens_cache;
3673
+ bucket.tokens_reasoning += usageTokens.tokens_reasoning;
3251
3674
  bucket.tokens_estimated += usageTokens.tokens_estimated;
3252
3675
  }
3253
3676
 
@@ -3255,15 +3678,32 @@ function statsTokenBreakdown(usage) {
3255
3678
  let input = positiveStatsNumber(usage?.inputTokens);
3256
3679
  let output = positiveStatsNumber(usage?.outputTokens);
3257
3680
  const cache = positiveStatsNumber(usage?.cacheInputTokens);
3258
- const splitTokens = input + output;
3259
- const totalTokens = Number(usage?.totalTokens || 0);
3260
- 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);
3261
3700
  return {
3262
- tokens: input + output,
3701
+ tokens,
3263
3702
  tokens_input: input,
3264
3703
  tokens_output: output,
3265
3704
  tokens_cache: cache,
3266
- tokens_estimated: usage?.estimated ? input + output : 0
3705
+ tokens_reasoning: reasoning,
3706
+ tokens_estimated: usage?.estimated ? tokens : 0
3267
3707
  };
3268
3708
  }
3269
3709
 
@@ -3443,6 +3883,90 @@ function writeNotModifiedSessionResponse(res, etag) {
3443
3883
  res.end();
3444
3884
  }
3445
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
+
3446
3970
  function writeJsonResponse(res, payload, status = 200, options = {}) {
3447
3971
  res.writeHead(status, {
3448
3972
  ...securityHeaders("application/json; charset=utf-8"),
@@ -3451,6 +3975,469 @@ function writeJsonResponse(res, payload, status = 200, options = {}) {
3451
3975
  res.end(options.pretty ? JSON.stringify(payload, null, 2) : JSON.stringify(payload));
3452
3976
  }
3453
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
+
3454
4441
  function writeUnauthorizedHistoryResponse(res, pathname) {
3455
4442
  if (isHistoryApiPath(pathname)) {
3456
4443
  writeJsonResponse(res, { error: "unauthorized", code: "unauthorized" }, 401);
@@ -3513,7 +4500,7 @@ function serverCommand(flags, env) {
3513
4500
  }
3514
4501
 
3515
4502
  const RECALL_TARGET_SOURCE_MAP = {
3516
- codex: ["codex-cli", "codex-desktop"],
4503
+ codex: ["codex-cli", "codex-desktop", "codex-sdk"],
3517
4504
  claude: ["claude", "claude-code-desktop", "claude-workspace"],
3518
4505
  gemini: ["gemini-cli"],
3519
4506
  antigravity: ["antigravity"],
@@ -4037,7 +5024,7 @@ function recallArchiveHints() {
4037
5024
  return `- Sessions live under \`~/.agentlog/data/agentlog/sessions/repo=<repo-or-path-key>/provider=<provider>/year=YYYY/month=MM/day=DD/session=<session_id>.conversation.md\`.
4038
5025
  - Git repositories use canonical keys like \`github.com/org/repo\`. Non-git directories may use stable \`path:<hash>\` storage keys, but history results include \`repo_display\` and \`cwd\` with the readable local path.
4039
5026
  - When the user names a repo or folder, add \`--repo "<repo-or-path>"\`; it matches canonical repo keys, \`path:<hash>\`, web scopes, local \`cwd\`, and display labels, so local paths and path fragments work.
4040
- - Useful filters include \`--provider <provider>\`, \`--since 30d\`, and \`--repo "<repo-or-path>"\`. Provider aliases are ordered as OpenAI (\`codex-cli\`, \`codex-desktop\`, \`chatgpt\`), Anthropic (\`claude\`, \`claude-code-desktop\`, \`claude-workspace\`, \`claude-web\`, \`claude-sdk\`), Google (\`gemini-cli\`, \`antigravity\`), then other local tools (\`devin-cli\`, \`cursor\`, \`cline\`, \`opencode\`, \`aider\`).
5027
+ - Useful filters include \`--provider <provider>\`, \`--since 30d\`, and \`--repo "<repo-or-path>"\`. Provider aliases are ordered as OpenAI (\`codex-cli\`, \`codex-desktop\`, \`codex-sdk\`, \`chatgpt\`), Anthropic (\`claude\`, \`claude-code-desktop\`, \`claude-workspace\`, \`claude-web\`, \`claude-sdk\`), Google (\`gemini-cli\`, \`antigravity\`), then other local tools (\`devin-cli\`, \`cursor\`, \`cline\`, \`opencode\`, \`aider\`).
4041
5028
  - If the user is asking about the current repository, start without \`--repo\` unless results are noisy; current-repo matches are already weighted higher.`;
4042
5029
  }
4043
5030
 
@@ -4305,6 +5292,10 @@ function printDiscovery(label, result) {
4305
5292
 
4306
5293
  function printImportResults(results, options = {}) {
4307
5294
  for (const result of results) {
5295
+ if (result.instructions) {
5296
+ printWebExportInstructionBlock(result.instructions);
5297
+ continue;
5298
+ }
4308
5299
  const detailText = result.details ? formatDetails(result.details) : "";
4309
5300
  const details = detailText ? ` ${detailText}` : "";
4310
5301
  printCheck(
@@ -4326,6 +5317,37 @@ function printImportResults(results, options = {}) {
4326
5317
  }
4327
5318
  }
4328
5319
 
5320
+ function printWebExportInstructions(instructions, flags = {}) {
5321
+ if (!instructions) throw new Error("unknown web export instruction source");
5322
+ if (flags.json) {
5323
+ console.log(JSON.stringify({
5324
+ provider: instructions.provider,
5325
+ source: instructions.source,
5326
+ manual: true,
5327
+ instructions
5328
+ }, null, 2));
5329
+ return;
5330
+ }
5331
+ printPageTitle("agentlog import", `${instructions.source} export instructions`);
5332
+ printWebExportInstructionBlock(instructions);
5333
+ }
5334
+
5335
+ function printWebExportInstructionBlock(instructions) {
5336
+ printSection(`${instructions.label} Export`);
5337
+ printMuted(`Request and download the ${instructions.fileDescription}, then import it from disk.`);
5338
+ printCheck("Request page", instructions.requestUrl);
5339
+ printCheck("Help", instructions.helpUrl);
5340
+ printSection("Steps");
5341
+ (instructions.steps || []).forEach((step, index) => {
5342
+ console.log(` ${index + 1}. ${step}`);
5343
+ });
5344
+ if (instructions.notes?.length) {
5345
+ printSection("Notes");
5346
+ for (const note of instructions.notes) console.log(` - ${note}`);
5347
+ }
5348
+ printCommand("Import after download", instructions.importCommand);
5349
+ }
5350
+
4329
5351
  function printPageTitle(title, subtitle = "") {
4330
5352
  if (!process.stdout.isTTY) {
4331
5353
  console.log(title);
@@ -4790,19 +5812,29 @@ async function chooseDataRoot(flags, env) {
4790
5812
 
4791
5813
  printSection("Archive Storage");
4792
5814
  printMuted("Redacted transcripts, metadata, and indexes live in one data directory.");
4793
- 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.");
4794
5816
  printMuted("Press Enter to use the suggested data directory.");
4795
- const answer = (await ask(` Data directory [${fullPath(defaultRoot)}]: `)).trim();
5817
+ const answer = (await ask(` Data directory [${compactPath(defaultRoot)}]: `)).trim();
4796
5818
  return answer ? resolveUserPath(answer) : defaultRoot;
4797
5819
  }
4798
5820
 
4799
- function resolveUserPath(value) {
4800
- const input = String(value || "").trim();
5821
+ function resolveUserPath(value, options = {}) {
5822
+ const input = normalizeUserPathInput(value);
4801
5823
  if (!input) return "";
4802
5824
  if (input === "~") return os.homedir();
4803
5825
  if (input.startsWith("~/")) return path.resolve(os.homedir(), input.slice(2));
4804
5826
  if (path.isAbsolute(input)) return path.resolve(input);
4805
- 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");
4806
5838
  }
4807
5839
 
4808
5840
  async function chooseSetupSettings(flags) {
@@ -5089,6 +6121,14 @@ function importSourceOptions(discovered) {
5089
6121
  description: "Codex desktop app conversations from the local Codex state database.",
5090
6122
  defaultSelected: Boolean(discovered.codexDesktop?.sessions)
5091
6123
  },
6124
+ {
6125
+ source: "codex-sdk",
6126
+ label: "Codex SDK jobs",
6127
+ count: discovered.codexSdk?.sessions || 0,
6128
+ summary: sourceSummary(discovered.codexSdk),
6129
+ description: "Codex exec and SDK-style batch runs from the local Codex state database; disabled by default because volume can be high.",
6130
+ defaultSelected: false
6131
+ },
5092
6132
  {
5093
6133
  source: "claude",
5094
6134
  label: "Claude Code CLI",
@@ -5249,10 +6289,10 @@ function createProgressReporter(options = {}) {
5249
6289
  const file = event.path ? ` ${path.basename(event.path).slice(0, 36)}` : "";
5250
6290
  const eventMode = event.kind || mode;
5251
6291
  const label = String(event.provider || "work").padEnd(eventMode === "discovery" ? 16 : 12);
5252
- const detail =
5253
- eventMode === "discovery" || eventMode === "sync"
5254
- ? event.message || "scanning"
5255
- : `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}`);
5256
6296
  const count = hasTotal ? `${current}/${total}` : `${current}`;
5257
6297
  const line = `${label} [${bar}] ${percent}% ${count} ${detail}${file}`;
5258
6298
  process.stdout.write(`\r${line.padEnd(lastLine.length)}`);
@@ -5514,6 +6554,9 @@ details[open] > summary .folder-icon-open{display:block}
5514
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}
5515
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}
5516
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}
5517
6560
  .session-stat.model{max-width:min(400px,46vw);gap:5px}
5518
6561
  .session-model-brand{display:inline-flex;align-items:center;justify-content:center;flex:0 0 auto}
5519
6562
  .session-stat.model .session-model-text{display:inline-block;max-width:100%;overflow:hidden;text-overflow:ellipsis;vertical-align:bottom}
@@ -5600,17 +6643,66 @@ mark.search-match.search-match-current{background:#fde047;color:#422006;box-shad
5600
6643
  .bubble p{margin:0 0 8px;overflow-wrap:anywhere}
5601
6644
  .bubble p:last-child{margin-bottom:0}
5602
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}
5603
6658
  .bubble a{color:#2457a6;text-decoration:underline;text-underline-offset:2px}
6659
+ .bubble a.attachment-card{color:#334155;text-decoration:none}
5604
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}
5605
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}
5606
6663
  .unsupported-device-block:first-child{margin-top:0}
5607
6664
  .unsupported-device-block:last-child{margin-bottom:0}
5608
6665
  .unsupported-device-block:before{content:"";width:6px;height:6px;border-radius:999px;background:#94a3b8;flex:0 0 auto}
5609
6666
  .context-card{margin:4px 0;border:1px solid #e5e7eb;border-left:3px solid #cbd5e1;border-radius:8px;background:#fff;color:#475569;overflow:hidden;transition:border-color .15s ease}
6667
+ .context-line{display:flex;align-items:center;gap:8px;min-height:28px;margin:2px 0;color:#64748b;font-size:12px;line-height:1.35}
6668
+ .context-line .context-glyph{width:22px;height:22px}
6669
+ .context-line .context-title{flex:0 0 auto}
6670
+ .context-line .context-meta{flex:1 1 auto}
6671
+ .context-line .context-end{margin-left:auto}
6672
+ .context-line .message-copy{opacity:1;width:22px;height:22px;color:#94a3b8}
5610
6673
  .session-summary-message{margin-bottom:14px}
5611
6674
  .session-summary-card{border-left-color:#D97757;background:#fff}
5612
6675
  .session-summary-body{padding:11px 13px 12px;color:#334155;font-size:13px;line-height:1.55;background:#fff}
5613
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}
5614
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}
5615
6707
  .context-prefix{display:inline-flex;align-items:center;gap:6px}
5616
6708
  .context-end{display:inline-flex;align-items:center;gap:6px}
@@ -5648,13 +6740,31 @@ mark.search-match.search-match-current{background:#fde047;color:#422006;box-shad
5648
6740
  .md-table{border-collapse:collapse;width:max-content;max-width:100%;font-size:13px}
5649
6741
  .md-table th,.md-table td{border:1px solid #e5e7eb;padding:5px 7px;text-align:left;vertical-align:top}
5650
6742
  .md-table th{background:#f8fafc;font-weight:600}
5651
- .tool-callout{display:flex;align-items:flex-start;gap:10px;margin:0;padding:8px 11px 9px 11px;border:1px solid #e5e7eb;border-radius:8px;background:#fff;color:#0f172a;transition:background .15s ease}
5652
- .tool-callout:hover{background:#fafbfc}
5653
- .tool-stack{display:grid;gap:5px;margin-top:4px}
6743
+ .tool-group-card{margin:0;border:0;background:transparent}
6744
+ .tool-group-card > summary{display:flex;align-items:center;gap:8px;min-height:28px;margin:0 0 5px;color:#64748b;font-size:13px;font-weight:600;line-height:1.35;cursor:pointer;list-style:none}
6745
+ .tool-group-card > summary::-webkit-details-marker{display:none}
6746
+ .tool-group-prefix{display:inline-flex;align-items:center;gap:7px;flex:0 0 auto}
6747
+ .tool-group-caret{width:0;height:0;border-top:4px solid transparent;border-bottom:4px solid transparent;border-left:5px solid #94a3b8;transition:transform .15s ease}
6748
+ .tool-group-card[open] .tool-group-caret{transform:rotate(90deg)}
6749
+ .tool-group-title{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
6750
+ .tool-group-card[open] > .tool-stack{gap:0;border:1px solid #e5e7eb;border-radius:8px;background:#fff;overflow:hidden}
6751
+ .tool-group-card[open] > .tool-stack > .tool-callout{border:0;border-radius:0;border-bottom:1px solid #eef2f7}
6752
+ .tool-group-card[open] > .tool-stack > .tool-callout:last-child{border-bottom:0}
6753
+ .tool-group-card[open] > .tool-stack > .tool-callout > summary{min-height:26px;padding:2px 8px}
6754
+ .tool-callout{display:block;margin:0;border:1px solid #e5e7eb;border-radius:8px;background:#fff;color:#0f172a;overflow:hidden;transition:border-color .15s ease,background .15s ease}
6755
+ .tool-callout > summary{display:flex;align-items:center;gap:7px;min-height:28px;padding:3px 8px;cursor:pointer;list-style:none;transition:background .12s ease}
6756
+ .tool-callout > summary::-webkit-details-marker{display:none}
6757
+ .tool-callout > summary:before{content:"";flex:0 0 auto;width:0;height:0;border-top:4px solid transparent;border-bottom:4px solid transparent;border-left:5px solid #94a3b8;transition:transform .15s ease}
6758
+ .tool-callout[open] > summary:before{transform:rotate(90deg)}
6759
+ .tool-callout:hover,.tool-callout[open]{border-color:#dbe3ef}
6760
+ .tool-callout > summary:hover{background:#fafbfc}
6761
+ .tool-callout[open] > summary{border-bottom:1px solid #f1f5f9}
6762
+ .tool-stack{display:grid;gap:3px;margin-top:3px}
5654
6763
  .tool-stack:first-child{margin-top:0}
5655
6764
  .tool-body + .tool-stack{margin-top:8px}
5656
- .tool-glyph{display:inline-flex;align-items:center;justify-content:center;flex:0 0 auto;width:26px;height:26px;border-radius:6px;background:#f1f5f9;color:#475569;border:0}
5657
- .tool-glyph svg{width:14px;height:14px}
6765
+ .tool-stack-heading{display:flex;align-items:center;min-height:24px;margin:0 0 1px;color:#64748b;font-size:12px;font-weight:600;line-height:1.35}
6766
+ .tool-glyph{display:inline-flex;align-items:center;justify-content:center;flex:0 0 auto;width:22px;height:22px;border-radius:6px;background:#f1f5f9;color:#475569;border:0}
6767
+ .tool-glyph svg{width:13px;height:13px}
5658
6768
  .tool-callout.shell .tool-glyph{background:#f1f5f9;color:#334155}
5659
6769
  .tool-callout.edit .tool-glyph{background:#fef3c7;color:#92400e}
5660
6770
  .tool-callout.read .tool-glyph{background:#e0f2fe;color:#075985}
@@ -5663,16 +6773,21 @@ mark.search-match.search-match-current{background:#fde047;color:#422006;box-shad
5663
6773
  .tool-callout.task .tool-glyph{background:#dcfce7;color:#166534}
5664
6774
  .tool-callout.mcp .tool-glyph{background:#ffedd5;color:#9a3412}
5665
6775
  .tool-callout.skill .tool-glyph{background:#e0e7ff;color:#3730a3}
5666
- .tool-copy{display:grid;gap:3px;min-width:0;flex:1 1 auto}
5667
- .tool-title{display:flex;align-items:center;gap:6px;flex-wrap:wrap;font-size:13px;font-weight:600;line-height:1.3;color:#0f172a;letter-spacing:-0.005em}
6776
+ .tool-call-line{display:flex;align-items:baseline;gap:5px;min-width:0;flex:1 1 auto}
6777
+ .tool-action{flex:0 0 auto;color:#0f172a;font-size:12.5px;font-weight:600;line-height:1.3}
6778
+ .tool-subject{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#475569;font-size:12.5px;font-weight:500;line-height:1.3}
6779
+ .tool-callout.shell .tool-subject{font:12px/1.3 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;color:#475569}
6780
+ .tool-callout-body{display:grid;gap:6px;min-width:0;padding:6px 8px 8px 12px;background:#fff}
6781
+ .tool-call-meta{display:flex;align-items:center;gap:5px;flex-wrap:wrap;min-width:0}
5668
6782
  .tool-chip{display:inline-flex;align-items:center;height:18px;padding:0 7px;border-radius:999px;background:#f1f5f9;color:#475569;border:0;font-size:11px;font-weight:500;letter-spacing:.01em}
5669
6783
  .tool-status{display:inline-flex;align-items:center;height:18px;border-radius:999px;padding:0 7px;background:#f1f5f9;color:#64748b;border:0;font-size:11px;font-weight:600}
5670
6784
  .tool-status.completed{background:#dcfce7;color:#166534}
5671
6785
  .tool-status.pending{background:#fef9c3;color:#854d0e}
5672
6786
  .tool-status.failed,.tool-status.error{background:#fee2e2;color:#991b1b}
5673
- .tool-detail{color:#475569;font-size:12px;line-height:1.4;overflow-wrap:anywhere}
5674
- .tool-target{display:block;margin-top:3px;color:#64748b;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px}
5675
- .tool-preview{display:block;color:#1e293b;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;white-space:pre-wrap;overflow-wrap:anywhere}
6787
+ .tool-preview{display:block;margin:0;padding:6px 7px;border:1px solid #edf2f7;border-radius:7px;background:#fafbfc;color:#1e293b;overflow-x:hidden;overflow-y:visible;font:12px/1.4 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word}
6788
+ .tool-paired-result{display:grid;gap:4px;min-width:0}
6789
+ .tool-result-meta{display:flex;align-items:center;gap:8px;min-width:0;color:#64748b;font-size:11px;font-weight:600}
6790
+ .tool-result-meta span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
5676
6791
  .tool-diff{display:block;margin-top:6px;border:1px solid #e5e7eb;border-radius:6px;background:#fff;overflow:hidden;font:12px/1.55 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
5677
6792
  .tool-diff[open] .tool-diff-summary{border-bottom:1px solid #f1f5f9}
5678
6793
  .tool-diff-summary{display:flex;align-items:center;gap:8px;min-height:28px;padding:4px 10px;cursor:pointer;color:#475569;font-size:11px;font-weight:600;list-style:none;background:#fafbfc}
@@ -5681,24 +6796,28 @@ mark.search-match.search-match-current{background:#fde047;color:#422006;box-shad
5681
6796
  .tool-diff[open] .tool-diff-summary:before{transform:rotate(90deg)}
5682
6797
  .tool-diff-summary .add-count{color:#16a34a}
5683
6798
  .tool-diff-summary .del-count{color:#dc2626}
5684
- .tool-diff-body{display:block;max-height:360px;overflow:auto;background:#f8fafc}
6799
+ .tool-diff-body{display:block;overflow-x:hidden;overflow-y:visible;background:#f8fafc}
5685
6800
  .tool-diff-block{display:block}
5686
6801
  .tool-diff-block + .tool-diff-block{border-top:1px solid #e5e7eb;margin-top:4px;padding-top:4px}
5687
- .tool-diff-line{display:block;padding:0 10px;white-space:pre-wrap;overflow-wrap:anywhere;color:#0f172a}
6802
+ .tool-diff-line{display:block;padding:0 10px;white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word;color:#0f172a}
5688
6803
  .tool-diff-line.add{background:#e6ffec;color:#1a7f37}
5689
6804
  .tool-diff-line.del{background:#ffebe9;color:#a40e26}
5690
6805
  .tool-diff-line.ctx{color:#6b7280}
5691
6806
  .tool-diff-line.meta{color:#6b7280;font-weight:600;background:#f1f5f9}
5692
6807
  .tool-diff-line.hunk{color:#7c3aed;background:#faf5ff;font-weight:600}
5693
6808
  .tool-result{margin:0;border:1px solid #e5e7eb;border-radius:8px;background:#fff;overflow:hidden}
6809
+ .tool-result.inline{margin-top:1px;border-radius:7px;background:#f8fafc}
5694
6810
  .tool-result summary{display:flex;align-items:center;gap:8px;min-height:34px;padding:6px 10px 6px 11px;cursor:pointer;color:#0f172a;font-weight:600;list-style:none;transition:background .12s ease}
6811
+ .tool-result.inline summary{min-height:30px;padding:5px 9px;background:#fafbfc}
5695
6812
  .tool-result summary:hover{background:#fafbfc}
6813
+ .tool-result.inline summary:hover{background:#f1f5f9}
5696
6814
  .tool-result[open] summary{border-bottom:1px solid #f1f5f9}
5697
6815
  .tool-result summary::-webkit-details-marker{display:none}
5698
6816
  .tool-result summary:before{content:"";flex:0 0 auto;width:0;height:0;border-top:4px solid transparent;border-bottom:4px solid transparent;border-left:5px solid #94a3b8;transition:transform .15s ease}
5699
6817
  .tool-result[open] summary:before{transform:rotate(90deg)}
5700
6818
  .tool-result-kind{display:inline-flex;align-items:center;gap:7px;font-size:13px;font-weight:600;color:#0f172a;letter-spacing:-0.005em;flex:0 0 auto;white-space:nowrap}
5701
- .tool-result-kind .tool-glyph{width:22px;height:22px;border-radius:6px}
6819
+ .tool-result-kind .tool-glyph{width:20px;height:20px;border-radius:6px}
6820
+ .tool-result.inline .tool-result-kind .tool-glyph{width:20px;height:20px}
5702
6821
  .tool-result-kind .tool-glyph svg{width:12px;height:12px}
5703
6822
  .tool-result[data-category="shell"] .tool-result-kind .tool-glyph{background:#f1f5f9;color:#334155}
5704
6823
  .tool-result[data-category="edit"] .tool-result-kind .tool-glyph{background:#fef3c7;color:#92400e}
@@ -5709,7 +6828,12 @@ mark.search-match.search-match-current{background:#fde047;color:#422006;box-shad
5709
6828
  .tool-result[data-category="mcp"] .tool-result-kind .tool-glyph{background:#ffedd5;color:#9a3412}
5710
6829
  .tool-result-detail{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#64748b;font-size:12px;font-weight:500}
5711
6830
  .tool-result-count{margin-left:auto;flex:0 0 auto;color:#94a3b8;font-size:11px;font-weight:600;letter-spacing:.01em;white-space:nowrap}
5712
- .tool-output{max-width:100%;max-height:520px;margin:0;padding:10px 12px;background:#f8fafc;color:#0f172a;overflow:auto;font:12px/1.55 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;white-space:pre}
6831
+ .tool-output{max-width:100%;margin:0;padding:6px 7px;background:#f8fafc;color:#0f172a;overflow-x:hidden;overflow-y:visible;font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word}
6832
+ .tool-result.inline .tool-output{background:#fff}
6833
+ .tool-output-lines{display:grid;gap:0;padding:5px 0}
6834
+ .tool-output-line{display:grid;grid-template-columns:minmax(2.4em,max-content) minmax(0,1fr);gap:10px;min-width:0;padding:0 7px}
6835
+ .tool-line-number{color:#94a3b8;text-align:right;user-select:none}
6836
+ .tool-line-text{min-width:0;white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word}
5713
6837
  .skill-link{display:inline-flex;align-items:center;gap:4px;max-width:100%;vertical-align:middle;border:1px solid #c7d2fe;border-radius:999px;background:#eef2ff;color:#1e1b4b;padding:1px 7px 2px;font-size:.93em;line-height:1.35;white-space:nowrap}
5714
6838
  .skill-mark{font:700 11px/1 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;color:#4338ca}
5715
6839
  .skill-name,.skill-path{min-width:0;overflow:hidden;text-overflow:ellipsis}
@@ -5750,6 +6874,7 @@ mark.search-match.search-match-current{background:#fde047;color:#422006;box-shad
5750
6874
  <button class="select-option active" type="button" data-value="">All sources</button>
5751
6875
  <button class="select-option" type="button" data-value="codex-cli">Codex CLI</button>
5752
6876
  <button class="select-option" type="button" data-value="codex-desktop">Codex Desktop</button>
6877
+ <button class="select-option" type="button" data-value="codex-sdk">Codex SDK jobs</button>
5753
6878
  <button class="select-option" type="button" data-value="chatgpt">ChatGPT</button>
5754
6879
  <button class="select-option" type="button" data-value="claude">Claude Code CLI</button>
5755
6880
  <button class="select-option" type="button" data-value="claude-code-desktop">Claude Code Desktop</button>
@@ -5850,6 +6975,18 @@ mark.search-match.search-match-current{background:#fde047;color:#422006;box-shad
5850
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>
5851
6976
  </section>
5852
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>
5853
6990
  <section id="statsPane" class="stats-pane" aria-label="Stats">
5854
6991
  <div class="stats-metrics" id="statsMetrics"></div>
5855
6992
  <div class="stats-section">
@@ -5879,6 +7016,11 @@ mark.search-match.search-match-current{background:#fde047;color:#422006;box-shad
5879
7016
  <div id="statsChatActivitySub" class="stats-card-meta" hidden></div>
5880
7017
  <div id="statsChatHeatmap" class="stats-heatmap-wrap"></div>
5881
7018
  </div>
7019
+ <div class="stats-card stats-card--compact">
7020
+ <div class="stats-card-title">SDK jobs</div>
7021
+ <div id="statsSdkActivitySub" class="stats-card-meta" hidden></div>
7022
+ <div id="statsSdkHeatmap" class="stats-heatmap-wrap"></div>
7023
+ </div>
5882
7024
  </div>
5883
7025
  <div class="stats-section">
5884
7026
  <div class="stats-section-head stats-section-head--breakdown">
@@ -5940,6 +7082,11 @@ const copyDetailsButton = document.getElementById('copyDetailsButton');
5940
7082
  const copyResumeButton = document.getElementById('copyResumeButton');
5941
7083
  const copyDetailsWrap = document.getElementById('copyDetailsWrap');
5942
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');
5943
7090
  const form = document.getElementById('filters');
5944
7091
  const queryInput = document.getElementById('q');
5945
7092
 
@@ -5957,6 +7104,7 @@ const repoOffsets = new Map();
5957
7104
  let currentSessionId = '';
5958
7105
  let currentSessionPayload = null;
5959
7106
  let viewMode = 'readable';
7107
+ let viewScrollRestoreSerial = 0;
5960
7108
  let sidebarWidth = 390;
5961
7109
  let activeSearchTerm = '';
5962
7110
  let pendingSessionId = '';
@@ -6033,7 +7181,7 @@ function brandIconSvg(kind, className) {
6033
7181
 
6034
7182
  function brandKeyForSourceValue(value) {
6035
7183
  const key = String(value || '').trim().toLowerCase();
6036
- if (['codex-cli', 'codex-desktop', 'chatgpt'].includes(key)) return 'openai';
7184
+ if (['codex-cli', 'codex-desktop', 'codex-sdk', 'chatgpt'].includes(key)) return 'openai';
6037
7185
  if (['claude', 'claude-code-desktop', 'claude-workspace', 'claude-web', 'claude-sdk'].includes(key)) return 'claude';
6038
7186
  if (['gemini-cli', 'antigravity'].includes(key)) return 'gemini';
6039
7187
  if (key === 'devin-cli') return 'devin';
@@ -6360,8 +7508,8 @@ function companyColor(company) {
6360
7508
  }
6361
7509
 
6362
7510
  const MODEL_COLOR_PALETTES = {
6363
- openai: { family: 'openai', light: '#7DD3FC', base: '#0284C7', dark: PROVIDER_COLORS.codex, darker: '#4F46E5', top: '#3730A3' },
6364
- claude: { family: 'claude', light: '#FDBA74', base: '#F97316', dark: PROVIDER_COLORS.claude_code, darker: '#B45309', top: '#7F1D1D' },
7511
+ openai: { family: 'openai', light: '#BAE6FD', base: '#06B6D4', dark: PROVIDER_COLORS.codex, darker: '#1D4ED8', top: '#172554' },
7512
+ claude: { family: 'claude', light: '#FED7AA', base: '#FB923C', dark: PROVIDER_COLORS.claude_code, darker: '#9A3412', top: '#831843' },
6365
7513
  gemini: { family: 'gemini', light: '#C4B5FD', base: PROVIDER_COLORS.gemini_cli, dark: '#7E22CE', darker: '#581C87' },
6366
7514
  devin: { family: 'devin', light: '#5EEAD4', base: PROVIDER_COLORS.devin, dark: '#0F766E', darker: '#115E59' },
6367
7515
  cursor: { family: 'cursor', light: '#475569', base: PROVIDER_COLORS.cursor, dark: '#020617', darker: '#020617' },
@@ -6409,6 +7557,9 @@ function modelCapabilityColor(text, palette) {
6409
7557
  }
6410
7558
 
6411
7559
  function openAiModelColor(text, palette) {
7560
+ if (text.includes('gpt-5.5') || text.includes('gpt-5-5')) return palette.top;
7561
+ if (text.includes('gpt-5.4') || text.includes('gpt-5-4')) return palette.darker;
7562
+ if (text.includes('gpt-5.3') || text.includes('gpt-5-3')) return palette.dark;
6412
7563
  if (text.includes('gpt-5.1') || text.includes('gpt-5-1')) return palette.top;
6413
7564
  if (text.includes('gpt-5') || modelHasToken(text, ['o3'])) return palette.darker;
6414
7565
  if (text.includes('gpt-4.1') || modelHasToken(text, ['o4'])) return palette.dark;
@@ -6417,10 +7568,12 @@ function openAiModelColor(text, palette) {
6417
7568
  }
6418
7569
 
6419
7570
  function claudeModelColor(text, palette) {
6420
- if (text.includes('high-thinking') || text.includes('high_thinking')) return palette.top;
6421
- if (modelHasToken(text, ['opus'])) return palette.darker;
6422
- if (modelHasToken(text, ['sonnet'])) return palette.dark;
6423
7571
  if (modelHasToken(text, ['haiku'])) return palette.light;
7572
+ if (text.includes('claude-4.6') && modelHasToken(text, ['opus'])) return palette.top;
7573
+ if (text.includes('claude-4.5') && modelHasToken(text, ['opus'])) return palette.darker;
7574
+ if (text.includes('high-thinking') || text.includes('high_thinking')) return palette.darker;
7575
+ if (modelHasToken(text, ['opus'])) return palette.dark;
7576
+ if (modelHasToken(text, ['sonnet'])) return palette.base;
6424
7577
  return palette.base;
6425
7578
  }
6426
7579
 
@@ -6429,6 +7582,91 @@ function modelHasToken(text, tokens) {
6429
7582
  return new RegExp('(^|[^a-z0-9])(' + escaped + ')([^a-z0-9]|$)').test(text);
6430
7583
  }
6431
7584
 
7585
+ const CANONICAL_COMPANY_ORDER = ['openai', 'anthropic', 'google', 'cognition', 'cursor', 'stealth', 'unknown'];
7586
+ const CANONICAL_PROVIDER_ORDER = ['codex', 'chatgpt', 'claude_code', 'claude_desktop', 'claude_sdk', 'claude_web', 'gemini_cli', 'antigravity', 'devin', 'windsurf', 'cursor', 'cline', 'opencode', 'aider', 'unknown'];
7587
+
7588
+ function canonicalCompanyRank(company) {
7589
+ const idx = CANONICAL_COMPANY_ORDER.indexOf(String(company || '').toLowerCase());
7590
+ return idx < 0 ? CANONICAL_COMPANY_ORDER.length : idx;
7591
+ }
7592
+
7593
+ function canonicalProviderRank(provider) {
7594
+ const idx = CANONICAL_PROVIDER_ORDER.indexOf(String(provider || '').toLowerCase());
7595
+ return idx < 0 ? CANONICAL_PROVIDER_ORDER.length : idx;
7596
+ }
7597
+
7598
+ function companyForModel(text) {
7599
+ const value = String(text || '').toLowerCase();
7600
+ if (value.startsWith('composer-') || value.startsWith('cursor-') || value === 'auto' || value === 'default') return 'cursor';
7601
+ const palette = modelFamilyPalette(value);
7602
+ if (!palette) return 'unknown';
7603
+ if (palette.family === 'openai') return 'openai';
7604
+ if (palette.family === 'claude') return 'anthropic';
7605
+ if (palette.family === 'gemini' || palette.family === 'antigravity') return 'google';
7606
+ if (palette.family === 'devin' || palette.family === 'windsurf') return 'cognition';
7607
+ if (palette.family === 'cursor') return 'cursor';
7608
+ return 'unknown';
7609
+ }
7610
+
7611
+ function canonicalModelRank(model) {
7612
+ const text = String(model || '').toLowerCase();
7613
+ const company = companyForModel(text);
7614
+ const companyRank = canonicalCompanyRank(company);
7615
+ let withinRank = 950;
7616
+ if (company === 'openai') {
7617
+ if (modelHasToken(text, ['nano'])) withinRank = 920;
7618
+ else if (modelHasToken(text, ['mini'])) withinRank = 900;
7619
+ else if (text.includes('gpt-4') && !text.includes('gpt-4.1')) withinRank = 700;
7620
+ else if (text.includes('gpt-4.1')) withinRank = 600;
7621
+ else if (modelHasToken(text, ['o1'])) withinRank = 590;
7622
+ else if (modelHasToken(text, ['o3', 'o4'])) withinRank = 580;
7623
+ else if (text.includes('gpt-5.5') || text.includes('gpt-5-5')) withinRank = 100;
7624
+ else if (text.includes('gpt-5.4') || text.includes('gpt-5-4')) withinRank = 200;
7625
+ else if (text.includes('gpt-5.3') || text.includes('gpt-5-3')) withinRank = 300;
7626
+ else if (text.includes('gpt-5.1') || text.includes('gpt-5-1')) withinRank = 400;
7627
+ else if (text.includes('gpt-5')) withinRank = 500;
7628
+ } else if (company === 'anthropic') {
7629
+ if (modelHasToken(text, ['haiku'])) withinRank = 920;
7630
+ else if (text.includes('opus-4-7') || text.includes('claude-4.7')) withinRank = 100;
7631
+ else if (text.includes('claude-4.6') && modelHasToken(text, ['opus'])) withinRank = 200;
7632
+ else if (text.includes('claude-4.5') && modelHasToken(text, ['opus'])) withinRank = 300;
7633
+ else if (text.includes('claude-4') && modelHasToken(text, ['opus'])) withinRank = 400;
7634
+ else if (modelHasToken(text, ['opus'])) withinRank = 500;
7635
+ else if (text.includes('claude-4.5') && modelHasToken(text, ['sonnet'])) withinRank = 600;
7636
+ else if (text.includes('claude-4') && modelHasToken(text, ['sonnet'])) withinRank = 700;
7637
+ else if (modelHasToken(text, ['sonnet'])) withinRank = 800;
7638
+ } else if (company === 'google') {
7639
+ // Major version dictates the bucket (newer first); flash variants get +50 within their generation.
7640
+ let base = 700;
7641
+ if (text.includes('gemini-3')) base = 100;
7642
+ else if (text.includes('gemini-2')) base = 300;
7643
+ else if (text.includes('gemini-1.5')) base = 500;
7644
+ else if (text.includes('gemini-1')) base = 600;
7645
+ let modifier = 0;
7646
+ if (text.includes('flash-lite')) modifier = 80;
7647
+ else if (text.includes('flash')) modifier = 50;
7648
+ withinRank = base + modifier;
7649
+ } else if (company === 'cursor') {
7650
+ if (text === 'composer-1.5') withinRank = 100;
7651
+ else if (text === 'composer-1') withinRank = 200;
7652
+ else if (text.startsWith('composer-')) withinRank = 300;
7653
+ else if (text === 'auto') withinRank = 700;
7654
+ else if (text === 'default') withinRank = 800;
7655
+ else if (text.startsWith('cursor-')) withinRank = 600;
7656
+ }
7657
+ return companyRank * 10000 + withinRank;
7658
+ }
7659
+
7660
+ function canonicalGroupRank(group) {
7661
+ if (statsBreakdownMode === 'model') return canonicalModelRank(group);
7662
+ if (statsBreakdownMode === 'company') return canonicalCompanyRank(group);
7663
+ return canonicalProviderRank(group);
7664
+ }
7665
+
7666
+ function statsCanonicalOrderedGroups(groups) {
7667
+ return [...groups].sort((a, b) => canonicalGroupRank(a) - canonicalGroupRank(b) || String(a).localeCompare(String(b)));
7668
+ }
7669
+
6432
7670
  function statsBreakdownLabel(group) {
6433
7671
  if (statsBreakdownMode === 'model') return modelLabel(group);
6434
7672
  if (statsBreakdownMode === 'company') return companyLabel(group);
@@ -6506,6 +7744,7 @@ function formatTokenCount(value) {
6506
7744
  function usageTokenSummary(usage) {
6507
7745
  if (!usage || typeof usage !== 'object') return { inputTokens: 0, outputTokens: 0, totalTokens: 0, extraTokens: 0, estimated: false };
6508
7746
  const estimated = usage.estimated === true || usage.estimated === 'true';
7747
+ const authoritativeTotalTokens = usage.authoritativeTotalTokens === true || usage.authoritative_total_tokens === true;
6509
7748
  const inputTokens = positiveTokenNumber(firstUsageNumber(
6510
7749
  usage.inputTokens,
6511
7750
  usage.input_tokens,
@@ -6545,22 +7784,36 @@ function usageTokenSummary(usage) {
6545
7784
  firstUsageNumber(usage.cachedContentTokenCount, usage.cached_content_token_count),
6546
7785
  firstUsageNumber(usage.cachedTokens, usage.cached_tokens, usage.cacheTokens, usage.cache_tokens, usage.cached)
6547
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;
6548
7793
  const reasoningOutputTokens = sumPositiveTokenNumbers(
6549
7794
  firstUsageNumber(usage.reasoningOutputTokens, usage.reasoning_output_tokens),
6550
7795
  firstUsageNumber(usage.thoughtsTokens, usage.thoughts_tokens, usage.thoughtsTokenCount, usage.thoughts_token_count),
6551
7796
  firstUsageNumber(usage.reasoningTokens, usage.reasoning_tokens, usage.reasoningTokenCount, usage.reasoning_token_count)
6552
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;
6553
7804
  const toolUsePromptTokens = sumPositiveTokenNumbers(
6554
7805
  firstUsageNumber(usage.toolUsePromptTokens, usage.tool_use_prompt_tokens, usage.toolUsePromptTokenCount, usage.tool_use_prompt_token_count)
6555
7806
  );
6556
- const extraTokens = cacheInputTokens + reasoningOutputTokens + toolUsePromptTokens;
7807
+ const extraTokens = countedCacheInputTokens + countedReasoningOutputTokens + toolUsePromptTokens;
6557
7808
  const splitTokens = inputTokens + outputTokens;
6558
7809
  const cumulativeTokens = totalInputTokens + totalOutputTokens;
6559
7810
  const categoryTokens = splitTokens + extraTokens;
6560
- const totalTokens = explicitTotalTokens || categoryTokens
6561
- ? Math.max(explicitTotalTokens, categoryTokens)
6562
- : cumulativeTokens;
6563
- 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 };
6564
7817
  }
6565
7818
 
6566
7819
  function firstUsageNumber() {
@@ -6760,11 +8013,15 @@ function sessionDisplayTitle(payload) {
6760
8013
 
6761
8014
  function renderTree(payload, options) {
6762
8015
  const opts = options || {};
8016
+ const sourceFilterReset = updateSourceFilterOptions(payload.available_source_options || []);
6763
8017
  treeTitle.textContent = 'Sessions';
6764
8018
  treeCount.textContent = String(payload.count || 0);
6765
8019
  tree.innerHTML = '';
6766
8020
  if (sidebarHead) sidebarHead.classList.remove('tree-scrolled');
6767
8021
  repoOffsets.clear();
8022
+ if (sourceFilterReset && !opts.skipSourceReload) {
8023
+ window.setTimeout(() => loadTree({ skipSourceReload: true }).catch((error) => { setEmptySession(error.message); }), 0);
8024
+ }
6768
8025
  if (!payload.groups || !payload.groups.length) {
6769
8026
  tree.innerHTML = '<div class="inline-empty">No archived sessions matched these filters.</div>';
6770
8027
  if (!opts.skipAutoLoad) setEmptySession('No archived sessions matched these filters.');
@@ -7064,6 +8321,14 @@ function renderSession(payload) {
7064
8321
  if (sessionMetaRight) {
7065
8322
  const innerParts = [];
7066
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
+ }
7067
8332
  const repoRaw = payload.repo_display || payload.repo || payload.scope || '';
7068
8333
  if (repoRaw) {
7069
8334
  if (innerParts.length) innerParts.push('<span class="meta-dot" aria-hidden="true">\u00b7</span>');
@@ -7079,7 +8344,16 @@ function renderSession(payload) {
7079
8344
  sessionMetaRight.innerHTML = innerParts.length
7080
8345
  ? '<div class="session-meta-right-inner">' + innerParts.join('') + '</div>'
7081
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
+ }
7082
8355
  const titleBits = [];
8356
+ if (payload.parent_composer_id) titleBits.push('Parent session: ' + payload.parent_composer_id);
7083
8357
  if (payload.time_status === 'recovered-time-unknown') titleBits.push('Recovered time unknown');
7084
8358
  else if (payload.started_at) titleBits.push('Started ' + formatWhen(payload.started_at));
7085
8359
  if (payload.session_id) titleBits.push('Session ID: ' + payload.session_id);
@@ -7108,7 +8382,7 @@ function renderSession(payload) {
7108
8382
  );
7109
8383
  copyResumeButton.classList.remove('copied');
7110
8384
  jumpEnd.hidden = false;
7111
- setView(viewMode);
8385
+ setView(viewMode, { preserveScroll: false });
7112
8386
  for (const button of tree.querySelectorAll('.session-node')) {
7113
8387
  button.classList.toggle('active', button.dataset.id === payload.session_id);
7114
8388
  }
@@ -7126,16 +8400,20 @@ function renderMessages(messages, sessionSummary) {
7126
8400
  readableView.className = '';
7127
8401
  readableView.innerHTML = '';
7128
8402
  const summaryText = sessionSummaryText(sessionSummary);
8403
+ const subagentsElement = sessionSubagentsElement(sessionSummary);
8404
+ const subagentRunItems = subagentRunTimelineItems(sessionSummary);
7129
8405
  const visibleMessages = summaryText
7130
8406
  ? (messages || []).filter((message) => message?.metadata?.summaryKind !== 'conversation_summary')
7131
8407
  : (messages || []);
7132
- if (!visibleMessages.length && !summaryText) {
8408
+ const renderItems = mergeTimelineRenderItems(pairedToolRenderItems(visibleMessages), subagentRunItems);
8409
+ if (!renderItems.length && !summaryText && !subagentsElement) {
7133
8410
  readableView.className = 'inline-empty';
7134
8411
  readableView.textContent = 'No transcript messages are available for this session.';
7135
8412
  return;
7136
8413
  }
7137
8414
  const summaryElement = sessionSummaryElement(sessionSummary);
7138
8415
  if (summaryElement) readableView.appendChild(summaryElement);
8416
+ if (subagentsElement) readableView.appendChild(subagentsElement);
7139
8417
  const scheduleNext = typeof window.requestAnimationFrame === 'function'
7140
8418
  ? (fn) => window.requestAnimationFrame(fn)
7141
8419
  : (fn) => window.setTimeout(fn, 0);
@@ -7144,16 +8422,17 @@ function renderMessages(messages, sessionSummary) {
7144
8422
  const renderChunk = () => {
7145
8423
  if (renderSerial !== renderMessagesSerial) return;
7146
8424
  const fragment = document.createDocumentFragment();
7147
- const end = Math.min(visibleMessages.length, index + MESSAGE_RENDER_CHUNK_SIZE);
8425
+ const end = Math.min(renderItems.length, index + MESSAGE_RENDER_CHUNK_SIZE);
7148
8426
  for (; index < end; index++) {
7149
- const message = visibleMessages[index];
8427
+ const item = renderItems[index];
8428
+ const message = item.message;
7150
8429
  const gap = renderTimeGap(previousTimestamp, message.timestamp);
7151
8430
  if (gap) fragment.appendChild(gap);
7152
- fragment.appendChild(messageElement(message));
7153
- if (message.timestamp) previousTimestamp = message.timestamp;
8431
+ fragment.appendChild(messageElement(message, item));
8432
+ previousTimestamp = item.lastTimestamp || message.timestamp || previousTimestamp;
7154
8433
  }
7155
8434
  readableView.appendChild(fragment);
7156
- if (index < visibleMessages.length) {
8435
+ if (index < renderItems.length) {
7157
8436
  scheduleNext(renderChunk);
7158
8437
  return;
7159
8438
  }
@@ -7168,6 +8447,204 @@ function renderMessages(messages, sessionSummary) {
7168
8447
  renderChunk();
7169
8448
  }
7170
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
+
8469
+ function pairedToolRenderItems(messages) {
8470
+ const pairings = pairedToolResultIndexes(messages);
8471
+ const skippedResultIndexes = new Set(pairings.duplicateResultIndexes);
8472
+ const result = [];
8473
+ for (let index = 0; index < messages.length; index++) {
8474
+ if (skippedResultIndexes.has(index)) continue;
8475
+ const message = messages[index];
8476
+ const calls = messageToolCalls(message);
8477
+ if (!calls.length) {
8478
+ result.push({ type: 'message', message });
8479
+ continue;
8480
+ }
8481
+ const pairedResults = (pairings.byCallMessage.get(index) || [])
8482
+ .filter((resultIndex) => !skippedResultIndexes.has(resultIndex))
8483
+ .map((resultIndex) => {
8484
+ skippedResultIndexes.add(resultIndex);
8485
+ return messages[resultIndex];
8486
+ });
8487
+ let next = index + 1;
8488
+ while (pairedResults.length < calls.length && next < messages.length && isPairableToolResultMessage(messages[next])) {
8489
+ if (skippedResultIndexes.has(next)) {
8490
+ next += 1;
8491
+ continue;
8492
+ }
8493
+ pairedResults.push(messages[next]);
8494
+ skippedResultIndexes.add(next);
8495
+ next += 1;
8496
+ }
8497
+ if (pairedResults.length) {
8498
+ result.push({
8499
+ type: 'message',
8500
+ message,
8501
+ pairedToolResults: pairedResults,
8502
+ lastTimestamp: pairedResults[pairedResults.length - 1].timestamp || message.timestamp
8503
+ });
8504
+ index = next - 1;
8505
+ } else {
8506
+ result.push({ type: 'message', message });
8507
+ }
8508
+ }
8509
+ return groupConsecutiveToolItems(result);
8510
+ }
8511
+
8512
+ function groupConsecutiveToolItems(items) {
8513
+ const grouped = [];
8514
+ let run = [];
8515
+ const flush = () => {
8516
+ if (run.length > 1) {
8517
+ grouped.push({
8518
+ type: 'tool-group',
8519
+ message: run[0].message,
8520
+ items: run,
8521
+ lastTimestamp: run[run.length - 1].lastTimestamp || run[run.length - 1].message.timestamp || run[0].message.timestamp
8522
+ });
8523
+ } else if (run.length === 1) {
8524
+ grouped.push(run[0]);
8525
+ }
8526
+ run = [];
8527
+ };
8528
+ for (const item of items) {
8529
+ if (!isToolActivityRenderItem(item)) {
8530
+ flush();
8531
+ grouped.push(item);
8532
+ continue;
8533
+ }
8534
+ run.push(item);
8535
+ }
8536
+ flush();
8537
+ return grouped;
8538
+ }
8539
+
8540
+ function isToolActivityRenderItem(item) {
8541
+ if (!item || item.type !== 'message') return false;
8542
+ const message = item.message || {};
8543
+ const role = String(message.role || '').toLowerCase();
8544
+ if (role === 'tool') return Boolean(parseToolResult(message.content || '', message));
8545
+ if (role !== 'assistant') return false;
8546
+ const calls = messageToolCalls(message, item.pairedToolResults || []);
8547
+ if (!calls.length) return false;
8548
+ return !stripToolInvocationLines(message.content || '').trim();
8549
+ }
8550
+
8551
+ function pairedToolResultIndexes(messages) {
8552
+ const calls = [];
8553
+ const callsByEventId = new Map();
8554
+ const callsById = new Map();
8555
+ const callsByKind = new Map();
8556
+ const addCallKey = (map, key, callIndex) => {
8557
+ if (!key) return;
8558
+ if (!map.has(key)) map.set(key, []);
8559
+ map.get(key).push(callIndex);
8560
+ };
8561
+ for (let messagePosition = 0; messagePosition < messages.length; messagePosition++) {
8562
+ const toolCalls = messageToolCalls(messages[messagePosition]);
8563
+ for (const call of toolCalls) {
8564
+ const callIndex = calls.length;
8565
+ calls.push({ messagePosition, call });
8566
+ addCallKey(callsByEventId, call.eventId || '', callIndex);
8567
+ addCallKey(callsById, normalizedToolId(call.id), callIndex);
8568
+ addCallKey(callsByKind, normalizeToolToken(call.kind || call.name || call.title || ''), callIndex);
8569
+ }
8570
+ }
8571
+
8572
+ const resultRecords = preferredToolResultRecords(messages);
8573
+ const byCallMessage = new Map();
8574
+ const usedCalls = new Set();
8575
+ for (const record of resultRecords.records) {
8576
+ const parentEventId = record.event?.parentEventId || '';
8577
+ let callIndex = firstUnusedIndex(callsByEventId.get(parentEventId), usedCalls);
8578
+ if (callIndex < 0) callIndex = firstUnusedIndex(callsById.get(normalizedToolId(record.result.id)), usedCalls);
8579
+ if (callIndex < 0) callIndex = firstUnusedIndex(callsByKind.get(normalizeToolToken(record.result.name || record.result.kind || record.result.title || '')), usedCalls);
8580
+ if (callIndex < 0) continue;
8581
+ usedCalls.add(callIndex);
8582
+ const messagePosition = calls[callIndex].messagePosition;
8583
+ if (!byCallMessage.has(messagePosition)) byCallMessage.set(messagePosition, []);
8584
+ byCallMessage.get(messagePosition).push(record.index);
8585
+ }
8586
+ return { byCallMessage, duplicateResultIndexes: resultRecords.duplicateResultIndexes };
8587
+ }
8588
+
8589
+ function preferredToolResultRecords(messages) {
8590
+ const records = [];
8591
+ const byId = new Map();
8592
+ const duplicateResultIndexes = new Set();
8593
+ for (let index = 0; index < messages.length; index++) {
8594
+ if (!isPairableToolResultMessage(messages[index])) continue;
8595
+ const result = parseToolResult(messages[index]?.content || '', messages[index]);
8596
+ if (!result) continue;
8597
+ const event = canonicalEventsForMessage(messages[index], 'tool.completed')[0] || null;
8598
+ const record = { index, result, event };
8599
+ records.push(record);
8600
+ const id = normalizedToolId(result.id);
8601
+ if (!id) continue;
8602
+ const existing = byId.get(id);
8603
+ if (!existing) {
8604
+ byId.set(id, record);
8605
+ continue;
8606
+ }
8607
+ const preferred = preferredToolResultRecord(existing, record);
8608
+ const discarded = preferred === existing ? record : existing;
8609
+ duplicateResultIndexes.add(discarded.index);
8610
+ byId.set(id, preferred);
8611
+ }
8612
+ return {
8613
+ records: records.filter((record) => !duplicateResultIndexes.has(record.index)),
8614
+ duplicateResultIndexes
8615
+ };
8616
+ }
8617
+
8618
+ function preferredToolResultRecord(left, right) {
8619
+ return toolResultDisplayScore(right.result) > toolResultDisplayScore(left.result) ? right : left;
8620
+ }
8621
+
8622
+ function toolResultDisplayScore(result) {
8623
+ const rawCategory = normalizeToolToken(result?.rawCategory || '');
8624
+ const kind = normalizeToolToken(result?.kind || '');
8625
+ const category = normalizeToolToken(result?.category || '');
8626
+ let score = 0;
8627
+ if (['exec_command_end', 'patch_apply_end', 'mcp_tool_call_end', 'web_search_end', 'tool_result', 'tool_output'].includes(rawCategory)) score += 2000;
8628
+ if (rawCategory === 'function_call_output' || rawCategory === 'custom_tool_call_output') score -= 1000;
8629
+ if (category && category !== 'function') score += 250;
8630
+ if (kind && !kind.startsWith('call_')) score += 150;
8631
+ if (/^\$\s/.test(String(result?.detail || result?.output || ''))) score += 250;
8632
+ score += Math.min(200, String(result?.output || '').length / 200);
8633
+ return score;
8634
+ }
8635
+
8636
+ function firstUnusedIndex(indexes, used) {
8637
+ if (!Array.isArray(indexes)) return -1;
8638
+ for (const index of indexes) {
8639
+ if (!used.has(index)) return index;
8640
+ }
8641
+ return -1;
8642
+ }
8643
+
8644
+ function isPairableToolResultMessage(message) {
8645
+ return String(message?.role || '').toLowerCase() === 'tool' && Boolean(parseToolResult(message?.content || '', message));
8646
+ }
8647
+
7171
8648
  function sessionSummaryText(sessionSummary) {
7172
8649
  if (!sessionSummary || typeof sessionSummary !== 'object') return '';
7173
8650
  return String(
@@ -7179,48 +8656,284 @@ function sessionSummaryText(sessionSummary) {
7179
8656
  ).trim();
7180
8657
  }
7181
8658
 
7182
- function sessionSummaryElement(sessionSummary) {
7183
- const text = sessionSummaryText(sessionSummary);
7184
- if (!text) return null;
8659
+ function sessionSummaryElement(sessionSummary) {
8660
+ const text = sessionSummaryText(sessionSummary);
8661
+ if (!text) return null;
8662
+ const row = document.createElement('article');
8663
+ row.className = 'message context session-summary-message';
8664
+ const bubble = document.createElement('div');
8665
+ bubble.className = 'bubble';
8666
+ const details = document.createElement('details');
8667
+ details.className = 'context-card session-summary-card';
8668
+ details.open = true;
8669
+ details.innerHTML =
8670
+ '<summary>' +
8671
+ '<span class="context-prefix"><span class="context-caret"></span><span class="context-glyph">' + contextIconSvg('conversation_summary') + '</span></span>' +
8672
+ '<span class="context-copy"><span class="context-title">Conversation summary</span>' +
8673
+ '<span class="context-meta"><span class="context-chip">' + esc(summarySourceLabel(sessionSummary.source)) + '</span></span></span>' +
8674
+ '<span class="context-end"></span>' +
8675
+ '</summary>';
8676
+ const end = details.querySelector('.context-end');
8677
+ if (end) end.appendChild(messageCopyButton(text));
8678
+ const body = document.createElement('div');
8679
+ body.className = 'session-summary-body';
8680
+ body.innerHTML = renderRichText(text);
8681
+ details.appendChild(body);
8682
+ bubble.appendChild(details);
8683
+ row.appendChild(bubble);
8684
+ return row;
8685
+ }
8686
+
8687
+ function summarySourceLabel(source) {
8688
+ const value = String(source || '').trim();
8689
+ if (value === 'claude-web-export') return 'Claude.ai export';
8690
+ if (value === 'chatgpt-export') return 'ChatGPT export';
8691
+ if (value) return value.replace(/[-_]+/g, ' ');
8692
+ return 'summary';
8693
+ }
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');
7185
8807
  const row = document.createElement('article');
7186
- row.className = 'message context session-summary-message';
8808
+ row.className = 'message context session-subagent-run-message';
7187
8809
  const bubble = document.createElement('div');
7188
8810
  bubble.className = 'bubble';
7189
8811
  const details = document.createElement('details');
7190
- details.className = 'context-card session-summary-card';
7191
- details.open = true;
8812
+ details.className = 'context-card session-subagent-run-card';
7192
8813
  details.innerHTML =
7193
8814
  '<summary>' +
7194
- '<span class="context-prefix"><span class="context-caret"></span><span class="context-glyph">' + contextIconSvg('conversation_summary') + '</span></span>' +
7195
- '<span class="context-copy"><span class="context-title">Conversation summary</span>' +
7196
- '<span class="context-meta"><span class="context-chip">' + esc(summarySourceLabel(sessionSummary.source)) + '</span></span></span>' +
7197
- '<span class="context-end"></span>' +
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>' +
7198
8819
  '</summary>';
7199
8820
  const end = details.querySelector('.context-end');
7200
- if (end) end.appendChild(messageCopyButton(text));
8821
+ if (end) end.appendChild(messageCopyButton(formatSubagentRunPlainText(run)));
7201
8822
  const body = document.createElement('div');
7202
- body.className = 'session-summary-body';
7203
- body.innerHTML = renderRichText(text);
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
+ }
7204
8838
  details.appendChild(body);
7205
8839
  bubble.appendChild(details);
7206
8840
  row.appendChild(bubble);
7207
8841
  return row;
7208
8842
  }
7209
8843
 
7210
- function summarySourceLabel(source) {
7211
- const value = String(source || '').trim();
7212
- if (value === 'claude-web-export') return 'Claude.ai export';
7213
- if (value === 'chatgpt-export') return 'ChatGPT export';
7214
- if (value) return value.replace(/[-_]+/g, ' ');
7215
- return 'summary';
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
+ }
7216
8933
  }
7217
8934
 
7218
8935
  function renderTimeGap(previous, current) {
7219
- if (!previous || !current) return null;
7220
- const prev = new Date(previous).getTime();
7221
- const next = new Date(current).getTime();
7222
- if (!Number.isFinite(prev) || !Number.isFinite(next)) return null;
7223
- const diffSec = (next - prev) / 1000;
8936
+ const diffSec = timeGapSeconds(previous, current);
7224
8937
  if (diffSec < 5) return null;
7225
8938
  const label = formatGapLabel(diffSec);
7226
8939
  if (!label) return null;
@@ -7230,6 +8943,14 @@ function renderTimeGap(previous, current) {
7230
8943
  return node;
7231
8944
  }
7232
8945
 
8946
+ function timeGapSeconds(previous, current) {
8947
+ if (!previous || !current) return 0;
8948
+ const prev = new Date(previous).getTime();
8949
+ const next = new Date(current).getTime();
8950
+ if (!Number.isFinite(prev) || !Number.isFinite(next)) return 0;
8951
+ return Math.max(0, (next - prev) / 1000);
8952
+ }
8953
+
7233
8954
  function formatGapLabel(diffSec) {
7234
8955
  if (diffSec < 60) return Math.round(diffSec) + 's';
7235
8956
  if (diffSec < 3600) return Math.round(diffSec / 60) + 'm';
@@ -7302,19 +9023,22 @@ function highlightSearchMatches(root, term) {
7302
9023
  walk(root);
7303
9024
  }
7304
9025
 
7305
- function messageElement(message) {
9026
+ function messageElement(message, options = {}) {
9027
+ if (options.type === 'tool-group') return toolGroupElement(options);
9028
+ if (options.type === 'subagent-run') return subagentRunElement(options.run || {});
7306
9029
  const context = generatedContextForMessage(message);
7307
9030
  if (context) return contextMessageElement(message, context);
7308
9031
  const role = String(message.role || 'unknown').toLowerCase();
7309
9032
  const content = String(message.content || '');
7310
- const toolCalls = messageToolCalls(message);
9033
+ const toolCalls = messageToolCalls(message, options.pairedToolResults || []);
7311
9034
  const toolResult = role === 'tool' ? parseToolResult(content, message) : null;
7312
- const contentWithoutTools = toolCalls.length ? stripToolInvocationLines(content) : content;
9035
+ const contentWithoutTools = isViewerStructuredToolCallMessage(message) ? '' : toolCalls.length ? stripToolInvocationLines(content) : content;
7313
9036
  const toolOnly = toolCalls.length && !contentWithoutTools.trim();
7314
9037
  const className = toolOnly ? 'tool' : ['user', 'assistant', 'system', 'tool'].includes(role) ? role : 'assistant';
7315
9038
  const bodyContent = toolResult
7316
9039
  ? renderToolResult(toolResult)
7317
9040
  : renderMessageBodyWithTools(className, contentWithoutTools, toolCalls);
9041
+ const attachmentsHtml = renderMessageAttachments(message);
7318
9042
  const row = document.createElement('article');
7319
9043
  row.className = 'message ' + className;
7320
9044
  if (toolCalls.length) row.classList.add('tool-call-turn');
@@ -7339,14 +9063,29 @@ function messageElement(message) {
7339
9063
  '<div class="message-head"><span class="role">' + esc(roleLabel(role)) + '</span>' +
7340
9064
  '<span class="message-time"' + timeAttr(message.timestamp) + '>' + esc(relativeTime(message.timestamp)) + '</span>' +
7341
9065
  usageChipHtml + '</div>';
7342
- bubble.innerHTML = headHtml + bodyContent;
9066
+ bubble.innerHTML = headHtml + bodyContent + attachmentsHtml;
7343
9067
  const head = bubble.querySelector('.message-head');
7344
9068
  if (head) head.appendChild(messageCopyButton(content));
7345
9069
  row.appendChild(bubble);
7346
9070
  return row;
7347
9071
  }
7348
9072
 
9073
+ function toolGroupElement(group) {
9074
+ const calls = [];
9075
+ for (const item of group.items || []) {
9076
+ calls.push(...toolCardsForRenderItem(item));
9077
+ }
9078
+ const row = document.createElement('article');
9079
+ row.className = 'message tool tool-call-turn tool-group-turn';
9080
+ const bubble = document.createElement('div');
9081
+ bubble.className = 'bubble';
9082
+ bubble.innerHTML = renderToolGroupCard(calls);
9083
+ row.appendChild(bubble);
9084
+ return row;
9085
+ }
9086
+
7349
9087
  function contextMessageElement(message, context) {
9088
+ if (context.kind === 'task_notification') return contextLineElement(message, context);
7350
9089
  const row = document.createElement('article');
7351
9090
  row.className = 'message context context-' + escClass(context.kind || 'metadata');
7352
9091
  const bubble = document.createElement('div');
@@ -7371,6 +9110,25 @@ function contextMessageElement(message, context) {
7371
9110
  return row;
7372
9111
  }
7373
9112
 
9113
+ function contextLineElement(message, context) {
9114
+ const row = document.createElement('article');
9115
+ row.className = 'message context context-line-turn context-' + escClass(context.kind || 'metadata');
9116
+ const bubble = document.createElement('div');
9117
+ bubble.className = 'bubble';
9118
+ const line = document.createElement('div');
9119
+ line.className = 'context-line';
9120
+ line.innerHTML =
9121
+ '<span class="context-glyph">' + contextIconSvg(context.kind) + '</span>' +
9122
+ '<span class="context-title">' + esc(contextTitle(context.kind)) + '</span>' +
9123
+ '<span class="context-meta">' + context.chips.map((chip) => '<span class="context-chip">' + esc(chip) + '</span>').join('') + '</span>' +
9124
+ '<span class="context-end"><span class="context-time"' + timeAttr(message.timestamp) + '>' + esc(relativeTime(message.timestamp)) + '</span></span>';
9125
+ const end = line.querySelector('.context-end');
9126
+ if (end) end.appendChild(messageCopyButton(message.content || ''));
9127
+ bubble.appendChild(line);
9128
+ row.appendChild(bubble);
9129
+ return row;
9130
+ }
9131
+
7374
9132
  function generatedContextForMessage(message) {
7375
9133
  const metadata = message?.metadata || {};
7376
9134
  const content = String(message?.content || '').trim();
@@ -7440,6 +9198,7 @@ function contextTitle(kind) {
7440
9198
  local_command_caveat: 'Local command',
7441
9199
  local_command_stdout: 'Local command',
7442
9200
  system_reminder: 'System reminder',
9201
+ subagents: 'Claude subagents',
7443
9202
  compaction: 'Compaction',
7444
9203
  metadata: 'Context metadata'
7445
9204
  };
@@ -7493,12 +9252,137 @@ function xmlTagText(text, tag) {
7493
9252
  return source.slice(bodyStart, end).trim();
7494
9253
  }
7495
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
+
7496
9335
  function renderMessageBodyWithTools(className, content, toolCalls) {
7497
9336
  const body = className === 'user' ? renderPlainText(content) : renderRichText(content);
7498
- const stack = toolCalls.length ? '<div class="tool-stack">' + toolCalls.map(renderToolCallout).join('') + '</div>' : '';
9337
+ const stack = toolCalls.length > 1
9338
+ ? renderToolGroupCard(toolCalls)
9339
+ : toolCalls.length
9340
+ ? '<div class="tool-stack">' + toolCalls.map(renderToolCallout).join('') + '</div>'
9341
+ : '';
7499
9342
  return body ? '<div class="tool-body">' + body + '</div>' + stack : stack;
7500
9343
  }
7501
9344
 
9345
+ function renderToolGroupCard(calls) {
9346
+ const tools = Array.isArray(calls) ? calls : [];
9347
+ if (!tools.length) return '';
9348
+ return '<details class="tool-group-card">' +
9349
+ '<summary><span class="tool-group-prefix"><span class="tool-group-caret"></span><span class="tool-glyph">' +
9350
+ renderToolIcon({ category: dominantToolCategory(tools), kind: 'Tool activity' }) +
9351
+ '</span></span><span class="tool-group-title">' +
9352
+ esc(toolStackSummary(tools) || 'Used ' + tools.length + ' tools') +
9353
+ '</span></summary>' +
9354
+ '<div class="tool-stack">' + tools.map(renderToolCallout).join('') + '</div></details>';
9355
+ }
9356
+
9357
+ function toolCardsForRenderItem(item) {
9358
+ const message = item?.message || {};
9359
+ if (String(message.role || '').toLowerCase() === 'tool') {
9360
+ const result = parseToolResult(message.content || '', message);
9361
+ return result ? [toolCardFromResult(result)] : [];
9362
+ }
9363
+ return messageToolCalls(message, item?.pairedToolResults || []);
9364
+ }
9365
+
9366
+ function toolCardFromResult(result) {
9367
+ const category = normalizedToolCategory(result.category, result.kind);
9368
+ const detail = result.detail || result.summary || firstLine(result.output || '') || result.kind || 'Tool output';
9369
+ return toolCard({
9370
+ kind: result.name || result.kind || 'Tool output',
9371
+ title: result.kind || result.title || '',
9372
+ category,
9373
+ categoryLabel: result.categoryLabel || toolCategoryLabel(category),
9374
+ icon: result.icon || toolIcon(category, result.kind || ''),
9375
+ status: result.status || 'completed',
9376
+ argument: detail,
9377
+ rawInputSummary: detail,
9378
+ inputPreview: '',
9379
+ target: result.target || '',
9380
+ id: result.id || '',
9381
+ result,
9382
+ resultOnly: true
9383
+ });
9384
+ }
9385
+
7502
9386
  function copyIconSvg() {
7503
9387
  return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>';
7504
9388
  }
@@ -7547,13 +9431,15 @@ function detectToolName(text) {
7547
9431
  return tool ? tool.label : '';
7548
9432
  }
7549
9433
 
7550
- function messageToolCalls(message) {
9434
+ function messageToolCalls(message, pairedResultMessages) {
7551
9435
  const eventCalls = canonicalEventsForMessage(message, 'tool.called').map(toolCallFromEvent).filter(Boolean);
7552
- if (eventCalls.length) return eventCalls;
9436
+ if (eventCalls.length) return attachPairedToolResults(eventCalls, pairedResultMessages);
7553
9437
  const metadataCalls = Array.isArray(message?.metadata?.toolCalls) ? message.metadata.toolCalls : [];
9438
+ const structuredCall = metadataCalls.length ? null : viewerStructuredToolCall(message);
7554
9439
  const textCalls = toolInvocationsFromText(message?.content || '');
9440
+ if (structuredCall) return attachPairedToolResults([structuredCall], pairedResultMessages);
7555
9441
  if (metadataCalls.length) {
7556
- return metadataCalls.map((meta, index) => {
9442
+ const calls = metadataCalls.map((meta, index) => {
7557
9443
  const text = textCalls[index] || {};
7558
9444
  const kind = meta.displayName || meta.name || text.kind || 'Tool';
7559
9445
  const argument = meta.argument || meta.rawInputSummary || text.argument || '';
@@ -7569,10 +9455,11 @@ function messageToolCalls(message) {
7569
9455
  categoryLabel: meta.categoryLabel || '',
7570
9456
  icon: meta.icon || '',
7571
9457
  rawCategory: meta.rawCategory || '',
7572
- id: meta.id || '',
9458
+ id: meta.id || meta.callId || meta.call_id || meta.toolCallId || meta.tool_call_id || meta.toolUseId || meta.tool_use_id || '',
7573
9459
  arguments: meta.arguments || null
7574
9460
  });
7575
9461
  }).filter((call) => call.kind || call.title || call.argument);
9462
+ return attachPairedToolResults(calls, pairedResultMessages);
7576
9463
  }
7577
9464
  const total = Math.max(metadataCalls.length, textCalls.length);
7578
9465
  const calls = [];
@@ -7592,11 +9479,123 @@ function messageToolCalls(message) {
7592
9479
  categoryLabel: meta.categoryLabel || '',
7593
9480
  icon: meta.icon || '',
7594
9481
  rawCategory: meta.rawCategory || '',
7595
- id: meta.id || '',
9482
+ id: meta.id || meta.callId || meta.call_id || meta.toolCallId || meta.tool_call_id || meta.toolUseId || meta.tool_use_id || '',
7596
9483
  arguments: meta.arguments || null
7597
9484
  }));
7598
9485
  }
7599
- return calls.filter((call) => call.kind || call.title || call.argument);
9486
+ return attachPairedToolResults(calls.filter((call) => call.kind || call.title || call.argument), pairedResultMessages);
9487
+ }
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
+
9545
+ function attachPairedToolResults(calls, pairedResultMessages) {
9546
+ if (!Array.isArray(pairedResultMessages) || !pairedResultMessages.length) return calls;
9547
+ const results = pairedResultMessages
9548
+ .map((message) => parseToolResult(message?.content || '', message))
9549
+ .filter(Boolean);
9550
+ if (!results.length) return calls;
9551
+ const remaining = results.slice();
9552
+ return calls.map((call) => {
9553
+ const matchIndex = pairedToolResultIndex(call, remaining);
9554
+ if (matchIndex < 0) return call;
9555
+ const [result] = remaining.splice(matchIndex, 1);
9556
+ return { ...call, result };
9557
+ });
9558
+ }
9559
+
9560
+ function pairedToolResultIndex(call, results) {
9561
+ const callId = normalizedToolId(call?.id);
9562
+ if (callId) {
9563
+ const idMatch = results.findIndex((result) => normalizedToolId(result?.id) === callId);
9564
+ if (idMatch >= 0) return idMatch;
9565
+ }
9566
+ const callKind = normalizeToolToken(call?.kind || call?.name || call?.title || '');
9567
+ if (callKind) {
9568
+ const kindMatch = results.findIndex((result) => normalizeToolToken(result?.name || result?.kind || result?.title || '') === callKind);
9569
+ if (kindMatch >= 0) return kindMatch;
9570
+ }
9571
+ return results.length ? 0 : -1;
9572
+ }
9573
+
9574
+ function toolStackSummary(tools) {
9575
+ const counts = {};
9576
+ for (const tool of tools) {
9577
+ const category = normalizedToolCategory(tool.category, tool.kind);
9578
+ counts[category] = (counts[category] || 0) + 1;
9579
+ }
9580
+ const parts = [];
9581
+ if (counts.read) parts.push('explored ' + counts.read + ' ' + (counts.read === 1 ? 'file' : 'files'));
9582
+ if (counts.search) parts.push('searched ' + counts.search + ' ' + (counts.search === 1 ? 'time' : 'times'));
9583
+ if (counts.shell) parts.push('ran ' + counts.shell + ' ' + (counts.shell === 1 ? 'command' : 'commands'));
9584
+ if (counts.edit) parts.push('edited ' + counts.edit + ' ' + (counts.edit === 1 ? 'file' : 'files'));
9585
+ const known = ['read', 'search', 'shell', 'edit'];
9586
+ const other = Object.entries(counts).filter(([key]) => !known.includes(key)).reduce((sum, [, count]) => sum + count, 0);
9587
+ if (other) parts.push('used ' + other + ' ' + (other === 1 ? 'tool' : 'tools'));
9588
+ const text = parts.join(', ');
9589
+ return text ? text.charAt(0).toUpperCase() + text.slice(1) : '';
9590
+ }
9591
+
9592
+ function dominantToolCategory(tools) {
9593
+ const counts = {};
9594
+ for (const tool of tools || []) {
9595
+ const category = normalizedToolCategory(tool.category, tool.kind);
9596
+ counts[category] = (counts[category] || 0) + 1;
9597
+ }
9598
+ return Object.entries(counts).sort((a, b) => b[1] - a[1])[0]?.[0] || 'tool';
7600
9599
  }
7601
9600
 
7602
9601
  function toolCallFromEvent(event) {
@@ -7615,7 +9614,8 @@ function toolCallFromEvent(event) {
7615
9614
  categoryLabel: meta.categoryLabel || '',
7616
9615
  icon: meta.icon || event.indexed?.toolIcon || '',
7617
9616
  rawCategory: meta.rawCategory || '',
7618
- id: meta.id || '',
9617
+ id: meta.id || meta.callId || meta.call_id || meta.toolCallId || meta.tool_call_id || meta.toolUseId || meta.tool_use_id || '',
9618
+ eventId: event.eventId || '',
7619
9619
  arguments: meta.arguments || null
7620
9620
  });
7621
9621
  }
@@ -7682,6 +9682,10 @@ function normalizeToolToken(value) {
7682
9682
  return String(value || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
7683
9683
  }
7684
9684
 
9685
+ function normalizedToolId(value) {
9686
+ return String(value || '').trim().toLowerCase();
9687
+ }
9688
+
7685
9689
  function toolCategoryLabel(category) {
7686
9690
  const labels = {
7687
9691
  shell: 'Shell',
@@ -7710,18 +9714,92 @@ function escClass(value) {
7710
9714
  function renderToolCallout(tool) {
7711
9715
  const card = toolCard(tool);
7712
9716
  const isSkill = card.category === 'skill' || card.kind === 'Skill';
7713
- const title = card.title || (isSkill ? 'Skill loaded' : card.kind);
7714
- const category = card.category ? '<span class="tool-chip">' + esc(card.categoryLabel || card.category) + '</span>' : '';
7715
- const statusClass = humanToolStatusClass(card.status);
7716
- const status = card.status ? '<span class="tool-status' + (statusClass ? ' ' + statusClass : '') + '">' + esc(humanToolStatus(card.status)) + '</span>' : '';
7717
- const target = card.target ? '<span class="tool-target">' + esc(card.target) + '</span>' : '';
7718
9717
  const diff = renderToolDiff(card);
7719
- const preview = diff ? '' : '<span class="tool-preview">' + esc(card.inputPreview || card.argument || card.kind) + '</span>';
7720
- const detail = preview + target + diff;
7721
- return '<div class="tool-callout ' + escClass(card.category || 'tool') + (isSkill ? ' skill' : '') + '">' +
7722
- '<span class="tool-glyph">' + renderToolIcon(card) + '</span>' +
7723
- '<div class="tool-copy"><div class="tool-title">' + esc(title) + category + status + '</div>' +
7724
- '<div class="tool-detail">' + detail + '</div></div></div>';
9718
+ const category = normalizedToolCategory(card.category, card.kind);
9719
+ return '<details class="tool-callout ' + escClass(category) + (isSkill ? ' skill' : '') + (card.result ? ' has-result' : '') + '" data-category="' + esc(escClass(category)) + '">' +
9720
+ renderToolActivitySummary(card) +
9721
+ renderToolCalloutBody(card, diff) +
9722
+ '</details>';
9723
+ }
9724
+
9725
+ function renderToolActivitySummary(card) {
9726
+ const label = toolActivityLabel(card);
9727
+ const count = card.result?.count || '';
9728
+ const statusClass = humanToolStatusClass(card.status);
9729
+ const status = !card.result && card.status ? '<span class="tool-status' + (statusClass ? ' ' + statusClass : '') + '">' + esc(humanToolStatus(card.status)) + '</span>' : '';
9730
+ return '<summary><span class="tool-glyph">' + renderToolIcon(card) + '</span>' +
9731
+ '<span class="tool-call-line"><span class="tool-action">' + esc(label.action) + '</span>' +
9732
+ (label.subject ? '<span class="tool-subject">' + esc(label.subject) + '</span>' : '') +
9733
+ '</span>' + status +
9734
+ (count ? '<span class="tool-result-count">' + esc(count) + '</span>' : '') +
9735
+ '</summary>';
9736
+ }
9737
+
9738
+ function renderToolCalloutBody(card, diff) {
9739
+ const rows = [];
9740
+ const meta = [];
9741
+ if (card.categoryLabel) meta.push(card.categoryLabel);
9742
+ if (card.status) meta.push(humanToolStatus(card.status));
9743
+ if (card.target) meta.push(card.target);
9744
+ if (meta.length) rows.push('<div class="tool-call-meta">' + meta.map((item) => '<span class="tool-chip">' + esc(item) + '</span>').join('') + '</div>');
9745
+ const preview = card.resultOnly ? '' : String(card.inputPreview || card.argument || '').trim();
9746
+ if (preview) rows.push('<pre class="tool-preview">' + esc(preview) + '</pre>');
9747
+ if (diff) rows.push(diff);
9748
+ if (card.result) rows.push(renderPairedToolResult(card.result));
9749
+ return rows.length ? '<div class="tool-callout-body">' + rows.join('') + '</div>' : '';
9750
+ }
9751
+
9752
+ function renderPairedToolResult(result) {
9753
+ const category = normalizedToolCategory(result.category, result.kind);
9754
+ const meta = [result.kind, result.detail, result.count].filter(Boolean);
9755
+ return '<div class="tool-paired-result" data-category="' + esc(escClass(category)) + '">' +
9756
+ (meta.length ? '<div class="tool-result-meta">' + meta.map((item) => '<span>' + esc(item) + '</span>').join('') + '</div>' : '') +
9757
+ renderToolOutput(result.output || '', { lineStart: result.lineStart }) + '</div>';
9758
+ }
9759
+
9760
+ function toolActivityLabel(card) {
9761
+ const category = normalizedToolCategory(card.category, card.kind);
9762
+ const fallback = toolSubjectFallback(card);
9763
+ if (category === 'shell') return { action: 'Ran', subject: shellCommandText(card) || fallback };
9764
+ if (category === 'read') return { action: 'Read', subject: compactPathLabel(card.target || fallback) };
9765
+ if (category === 'search') return { action: 'Searched', subject: fallback };
9766
+ if (category === 'edit') return { action: 'Edited', subject: compactPathLabel(card.target || fallback) };
9767
+ if (category === 'web') return { action: webToolVerb(card), subject: fallback };
9768
+ if (category === 'task') return { action: 'Ran', subject: fallback };
9769
+ if (category === 'skill') return { action: 'Loaded', subject: fallback };
9770
+ if (category === 'mcp') return { action: 'Called', subject: fallback };
9771
+ return { action: card.title || card.kind || 'Used', subject: fallback && fallback !== (card.title || card.kind) ? fallback : '' };
9772
+ }
9773
+
9774
+ function shellCommandText(card) {
9775
+ const args = card.arguments && typeof card.arguments === 'object' && !Array.isArray(card.arguments) ? card.arguments : {};
9776
+ return firstToolString(args.cmd, args.command, args.script, card.inputPreview, card.argument);
9777
+ }
9778
+
9779
+ function webToolVerb(card) {
9780
+ const key = normalizeToolToken(card.kind || card.title || '');
9781
+ if (key.includes('fetch')) return 'Fetched';
9782
+ if (key.includes('search')) return 'Searched';
9783
+ if (key.includes('open')) return 'Opened';
9784
+ return 'Opened';
9785
+ }
9786
+
9787
+ function toolSubjectFallback(card) {
9788
+ return firstToolString(card.target, card.inputPreview, card.argument, card.title, card.kind);
9789
+ }
9790
+
9791
+ function compactPathLabel(value) {
9792
+ const text = String(value || '').trim();
9793
+ if (!text || /\s/.test(text) || !text.includes('/')) return text;
9794
+ return text.split('/').filter(Boolean).pop() || text;
9795
+ }
9796
+
9797
+ function firstToolString() {
9798
+ for (const value of arguments) {
9799
+ if (typeof value === 'string' && value.trim()) return value.trim();
9800
+ if (Array.isArray(value) && value.length) return value.map((item) => String(item || '').trim()).filter(Boolean).join(' ');
9801
+ }
9802
+ return '';
7725
9803
  }
7726
9804
 
7727
9805
  function hasPatchMarker(value) {
@@ -7818,7 +9896,7 @@ function renderToolDiff(card) {
7818
9896
  if (adds) summaryParts.push('<span class="add-count">+' + adds + '</span>');
7819
9897
  if (dels) summaryParts.push('<span class="del-count">-' + dels + '</span>');
7820
9898
  const summaryLabel = editCount > 1 ? editCount + ' edits' : label;
7821
- return '<details class="tool-diff" open>' +
9899
+ return '<details class="tool-diff">' +
7822
9900
  '<summary class="tool-diff-summary">' + esc(summaryLabel) + summaryParts.join('') + '</summary>' +
7823
9901
  '<div class="tool-diff-body">' + blocks.join('') + '</div>' +
7824
9902
  '</details>';
@@ -7910,6 +9988,7 @@ function contextIconSvg(kind) {
7910
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>',
7911
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>',
7912
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>',
7913
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>',
7914
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>'
7915
9994
  };
@@ -7948,36 +10027,89 @@ function parseToolResult(content, message) {
7948
10027
  const event = canonicalEventsForMessage(message, 'tool.completed')[0];
7949
10028
  if (event) {
7950
10029
  const result = toolResultFromMetadata(event.body?.toolResult || {}, event.indexed || {}, event.body?.text || content);
7951
- if (result) return result;
10030
+ if (result) return refineViewerToolResult(result, event.body?.text || content);
7952
10031
  }
7953
10032
  if (message?.metadata?.toolResult) {
7954
10033
  const result = toolResultFromMetadata(message.metadata.toolResult, {}, content);
7955
- if (result) return result;
10034
+ if (result) return refineViewerToolResult(result, content);
7956
10035
  }
7957
10036
  const text = String(content || '').trim();
7958
10037
  if (!text) return null;
7959
- const structured = parseFileViewResult(text) || parseCommandResult(text) || parseSkillResult(text) || parseSearchResult(text);
10038
+ const structured = parseChatGptFileToolResult(text) || parseFileViewResult(text) || parseCommandResult(text) || parseSkillResult(text) || parseSearchResult(text);
7960
10039
  if (structured) return structured;
7961
10040
  return genericToolResult(text);
7962
10041
  }
7963
10042
 
7964
10043
  function toolResultFromMetadata(result, indexed, fallbackText) {
7965
- const output = String(result.output || result.text || result.content || fallbackText || '').trimEnd();
7966
- 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;
7967
10047
  const category = normalizedToolCategory(result.category || indexed.toolCategory || result.rawCategory || '', result.kind || indexed.title || '');
7968
10048
  return {
10049
+ id: result.id || result.callId || result.call_id || result.toolCallId || result.tool_call_id || result.toolUseId || result.tool_use_id || '',
10050
+ name: result.name || result.toolName || indexed.toolName || '',
10051
+ rawCategory: result.rawCategory || '',
7969
10052
  header: 'Tool result' + (result.title || indexed.title ? ' · ' + (result.title || indexed.title) : ''),
7970
10053
  kind: result.kind || indexed.title || 'Tool output',
7971
10054
  category,
7972
10055
  categoryLabel: result.categoryLabel || toolCategoryLabel(category),
7973
10056
  icon: result.icon || indexed.toolIcon || toolIcon(category, result.kind || indexed.title || ''),
7974
- detail: result.summary || indexed.summary || firstLine(output),
7975
- count: result.lineCount ? result.lineCount + ' line' + (Number(result.lineCount) === 1 ? '' : 's') : lineCountLabel(output || result.summary || indexed.summary || ''),
7976
- 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 || '',
10060
+ lineStart: Number(result.startLine || result.start_line || result.lineStart || result.line_start || 0) || 0,
7977
10061
  collapsed: Boolean(result.collapsed) || String(output).split('\\n').length > 18
7978
10062
  };
7979
10063
  }
7980
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
+
7981
10113
  function parseFileViewResult(text) {
7982
10114
  const match = text.match(/^<file-view\\b([^>]*)>\\n?([\\s\\S]*?)(?:\\n?<\\/file-view>\\s*)?$/);
7983
10115
  if (!match) return null;
@@ -7993,6 +10125,7 @@ function parseFileViewResult(text) {
7993
10125
  detail: [attrs.path || basename, lineLabel].filter(Boolean).join(' · '),
7994
10126
  count: lineCountLabel(code),
7995
10127
  output: code,
10128
+ lineStart: Number(attrs.start_line || 0) || 0,
7996
10129
  collapsed: code.split('\\n').length > 24
7997
10130
  };
7998
10131
  }
@@ -8050,14 +10183,25 @@ function genericToolResult(text) {
8050
10183
  };
8051
10184
  }
8052
10185
 
8053
- function renderToolResult(result) {
8054
- const open = result.collapsed ? '' : ' open';
10186
+ function renderToolResult(result, options) {
10187
+ const inline = Boolean(options && options.inline);
8055
10188
  const category = normalizedToolCategory(result.category, result.kind);
8056
- return '<details class="tool-result" data-category="' + esc(escClass(category)) + '"' + open + '>' +
10189
+ return '<details class="tool-result' + (inline ? ' inline' : '') + '" data-category="' + esc(escClass(category)) + '">' +
8057
10190
  '<summary><span class="tool-result-kind"><span class="tool-glyph">' + renderToolIcon({ icon: result.icon, category, kind: result.kind }) + '</span>' + esc(result.kind) + '</span>' +
8058
10191
  (result.detail ? '<span class="tool-result-detail">' + esc(result.detail) + '</span>' : '') +
8059
10192
  (result.count ? '<span class="tool-result-count">' + esc(result.count) + '</span>' : '') +
8060
- '</summary><pre class="tool-output">' + esc(result.output || '') + '</pre></details>';
10193
+ '</summary>' + renderToolOutput(result.output || '', { lineStart: result.lineStart }) + '</details>';
10194
+ }
10195
+
10196
+ function renderToolOutput(output, options = {}) {
10197
+ const text = formatChatGptCitationMarkersForPlainText(String(output || ''));
10198
+ const lines = text.split('\\n');
10199
+ if (lines.length <= 1) return '<pre class="tool-output">' + esc(text) + '</pre>';
10200
+ const start = Number(options.lineStart || 0) || 1;
10201
+ return '<div class="tool-output tool-output-lines">' + lines.map((line, index) =>
10202
+ '<div class="tool-output-line"><span class="tool-line-number">' + esc(start + index) + '</span><span class="tool-line-text">' +
10203
+ (line ? esc(line) : '&nbsp;') + '</span></div>'
10204
+ ).join('') + '</div>';
8061
10205
  }
8062
10206
 
8063
10207
  function parseXmlishAttrs(value) {
@@ -8243,6 +10387,13 @@ function renderInline(value) {
8243
10387
  function renderInlinePlain(value) {
8244
10388
  let html = esc(value);
8245
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
+ });
8246
10397
  html = html.replace(/\\[REDACTED(?::|\\s+)([^\\]\\n]+)\\]/g, function (_, kind) {
8247
10398
  const index = redactions.length;
8248
10399
  const label = redactionLabel(kind);
@@ -8256,9 +10407,49 @@ function renderInlinePlain(value) {
8256
10407
  html = html.replace(/\\u0000REDACTION_(\\d+)\\u0000/g, function (_, index) {
8257
10408
  return redactions[Number(index)] || '';
8258
10409
  });
10410
+ html = html.replace(/\\u0000CITATION_(\\d+)\\u0000/g, function (_, index) {
10411
+ return citations[Number(index)] || '';
10412
+ });
8259
10413
  return html;
8260
10414
  }
8261
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
+
8262
10453
  function renderMarkdownLink(label, href) {
8263
10454
  const value = String(href || '').trim();
8264
10455
  if (!/^(https?:|mailto:|file:|\\/|#)/i.test(value)) {
@@ -8283,7 +10474,47 @@ function compactSkillPath(value) {
8283
10474
  return '.../' + parts.slice(-2).join('/');
8284
10475
  }
8285
10476
 
8286
- 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;
8287
10518
  viewMode = mode;
8288
10519
  const markdown = mode === 'markdown';
8289
10520
  readableButton.classList.toggle('active', !markdown);
@@ -8292,9 +10523,15 @@ function setView(mode) {
8292
10523
  markdownButton.setAttribute('aria-pressed', markdown ? 'true' : 'false');
8293
10524
  readableView.style.display = markdown ? 'none' : 'block';
8294
10525
  markdownView.style.display = markdown ? 'block' : 'none';
8295
- if (markdown) ensureMarkdownLoaded().catch((error) => {
8296
- markdownView.textContent = error.message || 'Markdown could not be loaded.';
8297
- });
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
+ }
8298
10535
  }
8299
10536
 
8300
10537
  async function ensureMarkdownLoaded() {
@@ -8443,7 +10680,8 @@ function treeSignature(payload) {
8443
10680
  const sessions = (group.sessions || []).map((session) => session.session_id + '=' + (session.updated_at || session.ended_at || session.started_at || '')).join('|');
8444
10681
  return (group.repo_key || '') + '#' + (group.count || 0) + ':' + sessions;
8445
10682
  }).join('||');
8446
- 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;
8447
10685
  }
8448
10686
 
8449
10687
  function startLiveRefresh() {
@@ -8641,14 +10879,27 @@ recentButton.onclick = () => {
8641
10879
  setSelectValue('scope', 'all');
8642
10880
  loadTree().catch((error) => { setEmptySession(error.message); });
8643
10881
  };
8644
- readableButton.onclick = () => setView('readable');
8645
- markdownButton.onclick = () => setView('markdown');
10882
+ readableButton.onclick = () => setView('readable', { preserveScroll: true });
10883
+ markdownButton.onclick = () => setView('markdown', { preserveScroll: true });
8646
10884
  copyDetailsButton.onclick = () => {
8647
10885
  copySessionDetails();
8648
10886
  };
8649
10887
  copyResumeButton.onclick = () => {
8650
10888
  copyResumeCommand();
8651
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
+ });
8652
10903
  jumpEnd.onclick = () => {
8653
10904
  const scroller = document.querySelector('.detail-scroll');
8654
10905
  scroller.scrollTo({ top: scroller.scrollHeight, behavior: 'smooth' });
@@ -8710,7 +10961,7 @@ setupStatsActivityControls();
8710
10961
  setupStatsBreakdownControls();
8711
10962
 
8712
10963
  async function loadStats() {
8713
- const elements = ['chartTokensPerDay', 'chartSessionsPerDay', 'chartTokensPerRepo', 'chartSessionsPerRepo', 'statsAgentHeatmap', 'statsChatHeatmap'].map((id) => document.getElementById(id));
10964
+ const elements = ['chartTokensPerDay', 'chartSessionsPerDay', 'chartTokensPerRepo', 'chartSessionsPerRepo', 'statsAgentHeatmap', 'statsChatHeatmap', 'statsSdkHeatmap'].map((id) => document.getElementById(id));
8714
10965
  for (const el of elements) {
8715
10966
  if (el) {
8716
10967
  el.classList.add('stats-empty');
@@ -8879,7 +11130,7 @@ function statsBreakdownGroupId(entry) {
8879
11130
  }
8880
11131
 
8881
11132
  function emptyStatsTotals() {
8882
- 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 };
8883
11134
  }
8884
11135
 
8885
11136
  function addStatsTotals(target, entry) {
@@ -8888,6 +11139,7 @@ function addStatsTotals(target, entry) {
8888
11139
  target.tokens_input += Number(entry.tokens_input || 0);
8889
11140
  target.tokens_output += Number(entry.tokens_output || 0);
8890
11141
  target.tokens_cache += Number(entry.tokens_cache || 0);
11142
+ target.tokens_reasoning += Number(entry.tokens_reasoning || 0);
8891
11143
  target.tokens_estimated += Number(entry.tokens_estimated || 0);
8892
11144
  target.conversations += Number(entry.conversations ?? entry.sessions ?? 0);
8893
11145
  target.user_messages += Number(entry.user_messages || 0);
@@ -8972,7 +11224,7 @@ function statsRepoRowsForRange(payload) {
8972
11224
  for (const row of Array.isArray(day.repos) ? day.repos : []) {
8973
11225
  const key = row.repo_key || row.repo_display || 'unknown';
8974
11226
  if (!repos.has(key)) {
8975
- 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 });
8976
11228
  }
8977
11229
  const target = repos.get(key);
8978
11230
  target.repo_display = row.repo_display || target.repo_display;
@@ -9003,8 +11255,9 @@ function renderStatsDailyCharts() {
9003
11255
  if (empty2) { empty2.classList.add('stats-empty'); empty2.textContent = 'No days in this range.'; }
9004
11256
  return;
9005
11257
  }
9006
- renderDailyChart('chartTokensPerDay', densified, groups, usageMetric, 336);
9007
- renderDailyChart('chartSessionsPerDay', densified, groups, activityMetric, 336);
11258
+ const canonicalGroups = statsCanonicalOrderedGroups(groups);
11259
+ renderDailyChart('chartTokensPerDay', densified, canonicalGroups, usageMetric, 336);
11260
+ renderDailyChart('chartSessionsPerDay', densified, canonicalGroups, activityMetric, 336);
9008
11261
  }
9009
11262
 
9010
11263
  function renderStats(payload) {
@@ -9019,12 +11272,13 @@ function renderStats(payload) {
9019
11272
  renderStatsLegend(groups, breakdownTotals);
9020
11273
  renderStatsDailyCharts();
9021
11274
  const repoRows = statsRepoRowsForRange(payload);
9022
- renderRepoChart('chartTokensPerRepo', repoRows, groups, statsTokenMetric());
9023
- renderRepoChart('chartSessionsPerRepo', repoRows, groups, statsActivityMetric());
11275
+ const canonicalGroupsForRepo = statsCanonicalOrderedGroups(groups);
11276
+ renderRepoChart('chartTokensPerRepo', repoRows, canonicalGroupsForRepo, statsTokenMetric());
11277
+ renderRepoChart('chartSessionsPerRepo', repoRows, canonicalGroupsForRepo, statsActivityMetric());
9024
11278
  }
9025
11279
 
9026
11280
  function statsTokenMetric() {
9027
- if (statsShowInputTokens && statsShowOutputTokens && !statsShowCacheTokens) return 'tokens';
11281
+ if (statsShowInputTokens && statsShowOutputTokens && statsShowCacheTokens) return 'tokens';
9028
11282
  if (statsShowInputTokens && !statsShowOutputTokens && !statsShowCacheTokens) return 'tokens_input';
9029
11283
  if (!statsShowInputTokens && statsShowOutputTokens && !statsShowCacheTokens) return 'tokens_output';
9030
11284
  if (!statsShowInputTokens && !statsShowOutputTokens && statsShowCacheTokens) return 'tokens_cache';
@@ -9053,7 +11307,7 @@ function statsMetricValue(entry, metric) {
9053
11307
  }
9054
11308
 
9055
11309
  function isTokenMetric(metric) {
9056
- 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';
9057
11311
  }
9058
11312
 
9059
11313
  function statsActivityMetric() {
@@ -9391,7 +11645,12 @@ function renderStatsMetrics(payload) {
9391
11645
  const totalInTok = Number(payload.total_input_tokens || 0);
9392
11646
  const totalOutTok = Number(payload.total_output_tokens || 0);
9393
11647
  const totalCacheTok = Number(payload.total_cache_tokens || 0);
11648
+ const totalReasonTok = Number(payload.total_reasoning_tokens || 0);
9394
11649
  const totalEstimatedTok = Number(payload.total_estimated_tokens || 0);
11650
+ const sdkSessions = Number(payload.sdk_session_count || 0);
11651
+ const sdkTokens = Number(payload.sdk_total_tokens || 0);
11652
+ const sdkInputTokens = Number(payload.sdk_total_input_tokens || 0);
11653
+ const sdkOutputTokens = Number(payload.sdk_total_output_tokens || 0);
9395
11654
  const um = Number(payload.user_message_count || 0);
9396
11655
  const tm = Number(payload.message_count || 0);
9397
11656
  const avgMsgs = Number(payload.avg_messages_per_conversation);
@@ -9401,6 +11660,7 @@ function renderStatsMetrics(payload) {
9401
11660
  const usageParts = [];
9402
11661
  if (totalInTok || totalOutTok) usageParts.push(formatCompactNumber(totalInTok) + ' in / ' + formatCompactNumber(totalOutTok) + ' out');
9403
11662
  if (totalCacheTok) usageParts.push(formatCompactNumber(totalCacheTok) + ' cache');
11663
+ if (totalReasonTok) usageParts.push(formatCompactNumber(totalReasonTok) + ' reasoning');
9404
11664
  if (totalEstimatedTok) usageParts.push('~' + formatCompactNumber(totalEstimatedTok) + ' estimated');
9405
11665
  tokenSub = usageParts.join(' · ');
9406
11666
  } else {
@@ -9416,6 +11676,13 @@ function renderStatsMetrics(payload) {
9416
11676
  value: formatFullNumber(payload.session_count || 0),
9417
11677
  sub: formatFullNumber(payload.agent_session_count || 0) + ' agent · ' + formatFullNumber(payload.chat_session_count || 0) + ' chat'
9418
11678
  },
11679
+ sdkSessions || sdkTokens ? {
11680
+ label: 'SDK jobs',
11681
+ value: formatFullNumber(sdkSessions),
11682
+ sub: (sdkTokens ? formatCompactNumber(sdkTokens) + ' tokens' : '0 tokens') +
11683
+ (sdkInputTokens || sdkOutputTokens ? ' · ' + formatCompactNumber(sdkInputTokens) + ' in / ' + formatCompactNumber(sdkOutputTokens) + ' out' : '') +
11684
+ ' · kept separate'
11685
+ } : null,
9419
11686
  {
9420
11687
  label: 'Messages',
9421
11688
  value: formatFullNumber(tm),
@@ -9472,6 +11739,7 @@ function renderStatsMetrics(payload) {
9472
11739
  }
9473
11740
  ];
9474
11741
  container.innerHTML = metrics
11742
+ .filter(Boolean)
9475
11743
  .map(function (metric) {
9476
11744
  const vt =
9477
11745
  metric.valueHtml == null &&
@@ -9545,7 +11813,11 @@ function renderHeatmapSection(payload) {
9545
11813
  renderSecondaryHeatmap(payload.split_stats && payload.split_stats.agent, 'statsAgentActivitySub', 'statsAgentHeatmap', range, metric);
9546
11814
  renderSecondaryHeatmap(payload.split_stats && payload.split_stats.chat, 'statsChatActivitySub', 'statsChatHeatmap', range, metric, {
9547
11815
  emptySubText: '',
9548
- emptyText: 'No chat activity yet. Import ChatGPT or Claude.ai exports with agentlog import chatgpt <path> --scope local or agentlog import claude-web <path> --scope local.'
11816
+ emptyText: 'No chat activity yet. Run agentlog import chatgpt or agentlog import claude-web for official export instructions.'
11817
+ });
11818
+ renderSecondaryHeatmap(payload.split_stats && payload.split_stats.sdk, 'statsSdkActivitySub', 'statsSdkHeatmap', range, metric, {
11819
+ emptySubText: '',
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.'
9549
11821
  });
9550
11822
  }
9551
11823
 
@@ -9780,15 +12052,11 @@ function renderDailyChart(elementId, daily, providers, metric, height) {
9780
12052
  return '<line class="stats-axis-line" x1="' + padding.left + '" x2="' + (padding.left + innerW) + '" y1="' + y.toFixed(2) + '" y2="' + y.toFixed(2) + '"/>'
9781
12053
  + '<text class="stats-axis-label" x="' + (padding.left - 6) + '" y="' + (y + 3).toFixed(2) + '" text-anchor="end">' + esc(formatCompactNumber(value)) + '</text>';
9782
12054
  }).join('');
9783
- let prevYm = '';
9784
- const xLabels = visibleDaily.map((day, idx) => {
9785
- const ym = day.date.slice(0, 7);
9786
- if (ym === prevYm) return '';
9787
- prevYm = ym;
9788
- const cx = padding.left + idx * xStep + xStep / 2;
9789
- const tick = formatChartMonthTick(day.date);
9790
- return '<text class="stats-axis-label" x="' + cx.toFixed(2) + '" y="' + (padding.top + innerH + 18) + '" text-anchor="middle">' + esc(tick) + '</text>';
9791
- }).join('');
12055
+ const xLabels = dailyChartMonthTicks(visibleDaily, xStep, padding.left, innerW)
12056
+ .map((tickEntry) =>
12057
+ '<text class="stats-axis-label" x="' + tickEntry.x.toFixed(2) + '" y="' + (padding.top + innerH + 18) + '" text-anchor="middle">' + esc(tickEntry.label) + '</text>'
12058
+ )
12059
+ .join('');
9792
12060
  const hits = visibleDaily.map((day, dayIdx) => {
9793
12061
  const xHit = padding.left + dayIdx * xStep;
9794
12062
  return '<rect class="stats-chart-hit" pointer-events="all" x="' + xHit.toFixed(2) + '" y="' + padding.top + '" width="' + xStep.toFixed(2) + '" height="' + innerH.toFixed(2) + '" fill="transparent" data-day-index="' + dayIdx + '"/>';
@@ -9800,6 +12068,33 @@ function renderDailyChart(elementId, daily, providers, metric, height) {
9800
12068
  bindDailyChartHover(el);
9801
12069
  }
9802
12070
 
12071
+ function dailyChartMonthTicks(daily, xStep, left, innerW) {
12072
+ const monthStarts = [];
12073
+ let prevYm = '';
12074
+ for (let idx = 0; idx < daily.length; idx += 1) {
12075
+ const day = daily[idx];
12076
+ const ym = String(day?.date || '').slice(0, 7);
12077
+ if (!/^\\d{4}-\\d{2}$/.test(ym) || ym === prevYm) continue;
12078
+ prevYm = ym;
12079
+ monthStarts.push({ idx, date: day.date, ym });
12080
+ }
12081
+ const maxLabels = Math.max(2, Math.floor(innerW / 58));
12082
+ const step = Math.max(1, Math.ceil(monthStarts.length / maxLabels));
12083
+ const selected = [];
12084
+ for (let idx = 0; idx < monthStarts.length; idx += step) selected.push(monthStarts[idx]);
12085
+ const last = monthStarts[monthStarts.length - 1];
12086
+ if (last && selected[selected.length - 1] !== last) {
12087
+ const previous = selected[selected.length - 1];
12088
+ if (previous && monthStarts.indexOf(previous) >= monthStarts.length - step) selected[selected.length - 1] = last;
12089
+ else selected.push(last);
12090
+ }
12091
+ return selected
12092
+ .map((entry) => ({
12093
+ x: left + entry.idx * xStep + xStep / 2,
12094
+ label: formatChartMonthTick(entry.date)
12095
+ }));
12096
+ }
12097
+
9803
12098
  function trimEmptyDailyChartEdges(daily, providers, metric) {
9804
12099
  const rows = Array.isArray(daily) ? daily : [];
9805
12100
  const hasValue = (day) => providers.some((provider) => statsMetricValue(day?.providers?.[provider], metric) > 0);
@@ -9909,7 +12204,7 @@ function setupCustomSelects() {
9909
12204
  const input = field.querySelector('input');
9910
12205
  const trigger = field.querySelector('.select-trigger');
9911
12206
  if (field.dataset.select === 'provider') hydrateProviderSelectOptions(field);
9912
- const options = Array.from(field.querySelectorAll('.select-option'));
12207
+ const options = bindSelectFieldOptions(field);
9913
12208
  const selectedOption = options.find((item) => (item.dataset.value || '') === input.value) || options.find((item) => item.classList.contains('active')) || options[0];
9914
12209
  if (selectedOption) setSelectTriggerLabel(field, selectedOption);
9915
12210
  updateSelectFieldState(field);
@@ -9920,23 +12215,65 @@ function setupCustomSelects() {
9920
12215
  }
9921
12216
  field.classList.toggle('open');
9922
12217
  };
9923
- for (const option of options) {
9924
- option.onclick = (event) => {
9925
- event.stopPropagation();
9926
- input.value = option.dataset.value || '';
9927
- setSelectTriggerLabel(field, option);
9928
- options.forEach((item) => item.classList.toggle('active', item === option));
9929
- updateSelectFieldState(field);
9930
- field.classList.remove('open');
9931
- refreshForFilterChange();
9932
- };
9933
- }
9934
12218
  }
9935
12219
  document.addEventListener('click', () => {
9936
12220
  for (const field of document.querySelectorAll('.select-field.open')) field.classList.remove('open');
9937
12221
  });
9938
12222
  }
9939
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
+
9940
12277
  function hydrateProviderSelectOptions(field) {
9941
12278
  for (const option of field.querySelectorAll('.select-option')) {
9942
12279
  if (!option.dataset.label) option.dataset.label = option.textContent.trim();
@@ -10017,6 +12354,12 @@ function setupKeyboardShortcuts() {
10017
12354
  const isInput = target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable);
10018
12355
 
10019
12356
  if (event.key === 'Escape') {
12357
+ if (sessionModal && !sessionModal.hidden) {
12358
+ closeSubagentModal();
12359
+ event.preventDefault();
12360
+ event.stopPropagation();
12361
+ return;
12362
+ }
10020
12363
  const searchInput = document.getElementById('q');
10021
12364
  if (searchInput && (searchInput.value || activeSearchTerm)) {
10022
12365
  searchInput.value = '';
@@ -10246,8 +12589,8 @@ Start here:
10246
12589
  Archive and import:
10247
12590
  init interactive setup and optional first import
10248
12591
  import import local Codex, Claude, Gemini, Devin, Cursor, Cline, OpenCode, Aider, and Antigravity history
10249
- import chatgpt <path> import a ChatGPT export JSON/ZIP/folder
10250
- import claude-web <path> import a Claude.ai export JSON/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
10251
12594
  import windsurf <path> import downloaded Windsurf trajectory Markdown file/folder
10252
12595
  import accounts list or rename ChatGPT/Claude.ai export accounts
10253
12596
  sync choose a remote, preview, confirm, then upload archive objects
@@ -10303,8 +12646,8 @@ agentlog import
10303
12646
  Usage:
10304
12647
  agentlog import --source <source> [--since 30d|all]
10305
12648
  agentlog import --sources <a,b,c> [--since 30d|all]
10306
- agentlog import chatgpt <path> [--username <name>] [--scope local|team]
10307
- agentlog import claude-web <path> [--username <name>] [--scope local|team]
12649
+ agentlog import chatgpt [path] [--username <name>] [--scope local|team]
12650
+ agentlog import claude-web [path] [--username <name>] [--scope local|team]
10308
12651
  agentlog import windsurf <file-or-folder>
10309
12652
  agentlog import accounts list
10310
12653
  agentlog import accounts rename <provider> <account-id-or-username> --display-name <name>
@@ -10312,6 +12655,7 @@ Usage:
10312
12655
  Import sources:
10313
12656
  codex-cli terminal Codex sessions from Codex state and rollout files
10314
12657
  codex-desktop Codex desktop app sessions from Codex state and rollout files
12658
+ codex-sdk high-volume Codex exec/SDK batch jobs; opt-in
10315
12659
  claude interactive Claude Code CLI JSONL transcripts
10316
12660
  claude-code-desktop Claude Code sessions launched from the Claude desktop app
10317
12661
  claude-workspace Claude app workspace/local-agent sessions
@@ -10329,13 +12673,14 @@ Import sources:
10329
12673
  all configured default local sources
10330
12674
 
10331
12675
  Web export sources:
10332
- chatgpt official ChatGPT export JSON/ZIP
10333
- claude-web official Claude.ai export JSON/ZIP
12676
+ chatgpt guided import for ChatGPT/OpenAI export ZIPs or folders
12677
+ claude-web guided import for Claude.ai export ZIPs or folders
10334
12678
  windsurf downloaded Cascade trajectory Markdown file/folder
10335
12679
 
10336
12680
  Examples:
10337
12681
  agentlog import --source codex-cli --since 30d
10338
12682
  agentlog import --source codex-desktop --since all
12683
+ agentlog import --source codex-sdk --since all
10339
12684
  agentlog import --source claude --since 30d
10340
12685
  agentlog import --source claude-code-desktop --since all
10341
12686
  agentlog import --source claude-workspace --since all
@@ -10351,11 +12696,14 @@ Examples:
10351
12696
  agentlog import --source cursor --since all --explain-skips
10352
12697
  agentlog import --source cursor --since all --explain-skips --json
10353
12698
  agentlog import status --json
12699
+ agentlog import chatgpt
12700
+ agentlog import claude-web
10354
12701
  agentlog import chatgpt ~/Downloads/chatgpt-export.zip --username you@example.com
10355
- 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
10356
12704
  agentlog import windsurf ~/Downloads/cascade-chat-conversation.md
10357
12705
  agentlog import windsurf ~/windsurf-cascade-export
10358
- agentlog import accounts rename claude-web brian --display-name "Personal Claude"
12706
+ agentlog import accounts rename claude-web you --display-name "Personal Claude"
10359
12707
 
10360
12708
  Details:
10361
12709
  --since accepts 30d, 12h, 60m, ISO dates, or all.
@@ -10363,7 +12711,9 @@ Details:
10363
12711
  --dry-run shows what would be imported without writing archive files.
10364
12712
  --explain-skips includes per-session skip reasons for supported sources.
10365
12713
  --json prints machine-readable import results.
10366
- In a terminal, web and Windsurf imports prompt for the export path when 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.
12716
+ Windsurf imports prompt for the export path when omitted in a terminal.
10367
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>\`.
10368
12718
  See docs/history-source-handling.md for source-specific storage paths.
10369
12719
  `;
@@ -10489,7 +12839,7 @@ Usage:
10489
12839
  Examples:
10490
12840
  agentlog accounts list
10491
12841
  agentlog accounts rename
10492
- agentlog accounts rename claude-web brian --display-name "Personal Claude"
12842
+ agentlog accounts rename claude-web you --display-name "Personal Claude"
10493
12843
  `,
10494
12844
  config: `
10495
12845
  agentlog config
@@ -10723,7 +13073,7 @@ Usage:
10723
13073
  agentlog update [--yes] [--dry-run] [--since 30d|all] [--sources a,b,c] [--sync] [--no-index] [--no-restart]
10724
13074
 
10725
13075
  What it removes:
10726
- archive/index data local archive objects and search indexes
13076
+ archive/index data local agent archive objects and search indexes
10727
13077
  import state file/session fingerprints so sources are read again
10728
13078
  cache/spool reveal cache and pending local spool files
10729
13079
  sync state local upload bookkeeping; remote objects are not deleted
@@ -10732,13 +13082,14 @@ What it keeps:
10732
13082
  config.json storage, source, sync, privacy, and watcher preferences
10733
13083
  redaction.yaml redaction rules
10734
13084
  web account labels ChatGPT/Claude.ai account display names
13085
+ web chat archives manually imported ChatGPT/Claude.ai archive objects
10735
13086
  source histories Codex, Claude, Gemini, Devin, Cursor, etc. source data
10736
13087
  recall integrations agent command/skill files
10737
13088
 
10738
13089
  Options:
10739
13090
  --dry-run show targets and preferences without deleting or importing
10740
13091
  --yes skip confirmation
10741
- --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
10742
13093
  --sources a,b,c override configured sources for this run
10743
13094
  --sync upload changed objects after reimport; use sync replace to remove stale remote objects
10744
13095
  --no-index skip rebuilding the search index
@@ -10897,13 +13248,19 @@ module.exports = {
10897
13248
  _historyWebInternals: {
10898
13249
  HISTORY_AUTH_COOKIE,
10899
13250
  constantTimeEqual,
13251
+ filterShapeKey,
10900
13252
  historyHtml,
10901
13253
  isHistoryApiPath,
13254
+ listSnapshotEtag,
13255
+ memoizedStatsPayload,
10902
13256
  parseCookies,
10903
13257
  readSessionMarkdown,
10904
13258
  readSessionView,
13259
+ resolveSessionAttachmentFile,
10905
13260
  resumeCommandForSession,
13261
+ repoSessionsFilterKey,
10906
13262
  securityHeaders,
13263
+ sessionTreePayload,
10907
13264
  sessionViewPayload,
10908
13265
  sessionCookie,
10909
13266
  statsPayload,