agentel 0.2.8 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/collector.js CHANGED
@@ -29,6 +29,11 @@ function startCollector(env = process.env, options = {}) {
29
29
  writeJsonResponse(res, { partialSuccess: {}, agentlog: { stored: record.file } });
30
30
  })
31
31
  .catch((error) => {
32
+ if (error && error.code === "PAYLOAD_TOO_LARGE") {
33
+ writeJsonResponse(res, { error: error.message }, 413);
34
+ res.once("finish", () => req.destroy());
35
+ return;
36
+ }
32
37
  writeJsonResponse(res, { error: error.message }, 500);
33
38
  });
34
39
  });
@@ -80,12 +85,44 @@ function writeTelemetryPayload(req, body, env = process.env) {
80
85
  return { file: payloadFile };
81
86
  }
82
87
 
83
- function collectBody(req) {
88
+ // OTLP batches from local agents are typically well under a megabyte; the cap
89
+ // only exists so a misconfigured or hostile client cannot balloon collector
90
+ // memory with one unbounded request body.
91
+ const COLLECTOR_MAX_BODY_BYTES = 32 * 1024 * 1024;
92
+
93
+ function collectBody(req, maxBytes = COLLECTOR_MAX_BODY_BYTES) {
84
94
  return new Promise((resolve, reject) => {
85
95
  const chunks = [];
86
- req.on("data", (chunk) => chunks.push(chunk));
87
- req.on("end", () => resolve(Buffer.concat(chunks)));
88
- req.on("error", reject);
96
+ let received = 0;
97
+ const cleanup = () => {
98
+ req.removeListener("data", onData);
99
+ req.removeListener("end", onEnd);
100
+ req.removeListener("error", onError);
101
+ };
102
+ const onData = (chunk) => {
103
+ received += chunk.length;
104
+ if (received > maxBytes) {
105
+ // Stop buffering but leave the socket alive so the 413 response can
106
+ // still be written; the handler destroys the connection afterwards.
107
+ cleanup();
108
+ const error = new Error(`request body exceeds ${maxBytes} bytes`);
109
+ error.code = "PAYLOAD_TOO_LARGE";
110
+ reject(error);
111
+ return;
112
+ }
113
+ chunks.push(chunk);
114
+ };
115
+ const onEnd = () => {
116
+ cleanup();
117
+ resolve(Buffer.concat(chunks));
118
+ };
119
+ const onError = (error) => {
120
+ cleanup();
121
+ reject(error);
122
+ };
123
+ req.on("data", onData);
124
+ req.on("end", onEnd);
125
+ req.on("error", onError);
89
126
  });
90
127
  }
91
128
 
@@ -108,6 +145,7 @@ function looksLikeJson(body) {
108
145
  }
109
146
 
110
147
  module.exports = {
148
+ collectBody,
111
149
  startCollector,
112
150
  writeTelemetryPayload
113
151
  };
package/src/config.js CHANGED
@@ -4,7 +4,7 @@ const crypto = require("crypto");
4
4
  const os = require("os");
5
5
  const path = require("path");
6
6
  const { ensureBaseDirs, paths, readJson, writeJson } = require("./paths");
7
- const { IMPORT_SOURCE_ORDER, enabledImportSources } = require("./sources");
7
+ const { IMPORT_SOURCE_ORDER, canonicalImportSource, enabledImportSources } = require("./sources");
8
8
 
9
9
  function defaultConfig(env = process.env) {
10
10
  const p = paths(env);
@@ -53,6 +53,14 @@ function defaultConfig(env = process.env) {
53
53
  webChatsDefaultScope: "local",
54
54
  revealCache: true
55
55
  },
56
+ artifacts: {
57
+ // Copy images/PDFs referenced by tool calls (screenshots in /tmp etc.)
58
+ // into the archive before they are deleted. Off by default because it
59
+ // grows the archive beyond conversation text.
60
+ enabled: false,
61
+ maxFileBytes: 25 * 1024 * 1024,
62
+ maxSessionBytes: 100 * 1024 * 1024
63
+ },
56
64
  createdAt: new Date().toISOString()
57
65
  };
58
66
  }
@@ -72,7 +80,8 @@ function loadConfig(env = process.env) {
72
80
  sync: { ...defaults.sync, ...(cfg.sync || {}) },
73
81
  index: { ...defaults.index, ...(cfg.index || {}) },
74
82
  imports: { ...defaults.imports, ...(cfg.imports || {}) },
75
- privacy: { ...defaults.privacy, ...(cfg.privacy || {}) }
83
+ privacy: { ...defaults.privacy, ...(cfg.privacy || {}) },
84
+ artifacts: { ...defaults.artifacts, ...(cfg.artifacts || {}) }
76
85
  };
77
86
  merged.imports.sources = enabledImportSources(merged.imports.sources);
78
87
  return merged;
@@ -271,9 +280,10 @@ function normalizeImportSources(value) {
271
280
  .split(/[,\s]+/)
272
281
  .map((item) => item.trim())
273
282
  .filter(Boolean);
274
- const selected = new Set(raw);
283
+ const canonicalRaw = raw.map(canonicalImportSource);
284
+ const selected = new Set(canonicalRaw);
275
285
  const ordered = IMPORT_SOURCE_ORDER.filter((source) => selected.has(source));
276
- const extras = raw.filter((source) => !ordered.includes(source));
286
+ const extras = canonicalRaw.filter((source) => !ordered.includes(source));
277
287
  return enabledImportSources([...ordered, ...extras]);
278
288
  }
279
289
 
@@ -288,6 +298,7 @@ module.exports = {
288
298
  effectiveImportSources,
289
299
  getConfigKey,
290
300
  initConfig,
301
+ defaultDeviceName,
291
302
  loadConfig,
292
303
  normalizeRemoteEndpointInput,
293
304
  saveConfig,
package/src/diffs.js ADDED
@@ -0,0 +1,156 @@
1
+ "use strict";
2
+
3
+ const MAX_STRUCTURED_PATCH_HUNKS = 2000;
4
+ const MAX_STRUCTURED_PATCH_LINES = 50000;
5
+ const MAX_STRUCTURED_DIFF_CHARS = 2 * 1024 * 1024;
6
+
7
+ function normalizeStructuredPatch(value) {
8
+ if (!Array.isArray(value) || !value.length) return [];
9
+ const hunks = [];
10
+ let lineCount = 0;
11
+ for (const raw of value) {
12
+ if (!raw || typeof raw !== "object") continue;
13
+ const lines = Array.isArray(raw.lines)
14
+ ? raw.lines.map((line) => String(line == null ? "" : line)).filter((line) => /^[ +\\-]/.test(line))
15
+ : [];
16
+ if (!lines.length) continue;
17
+ lineCount += lines.length;
18
+ if (lineCount > MAX_STRUCTURED_PATCH_LINES) break;
19
+ const hunk = {
20
+ oldStart: positiveInt(raw.oldStart ?? raw.old_start) || 1,
21
+ newStart: positiveInt(raw.newStart ?? raw.new_start) || 1,
22
+ lines
23
+ };
24
+ const file = firstString(raw.file, raw.path, raw.filePath, raw.filename);
25
+ if (file) hunk.file = file;
26
+ if (Number.isFinite(Number(raw.oldLines ?? raw.old_lines))) hunk.oldLines = Math.max(0, Math.floor(Number(raw.oldLines ?? raw.old_lines)));
27
+ if (Number.isFinite(Number(raw.newLines ?? raw.new_lines))) hunk.newLines = Math.max(0, Math.floor(Number(raw.newLines ?? raw.new_lines)));
28
+ hunks.push(hunk);
29
+ if (hunks.length >= MAX_STRUCTURED_PATCH_HUNKS) break;
30
+ }
31
+ return hunks;
32
+ }
33
+
34
+ function structuredPatchFromToolArguments(args) {
35
+ if (!args) return [];
36
+ if (typeof args === "string") return structuredPatchFromDiffText(args);
37
+ if (Array.isArray(args)) {
38
+ const hunks = [];
39
+ for (const item of args) {
40
+ hunks.push(...structuredPatchFromToolArguments(item));
41
+ if (hunks.length >= MAX_STRUCTURED_PATCH_HUNKS) break;
42
+ }
43
+ return hunks.slice(0, MAX_STRUCTURED_PATCH_HUNKS);
44
+ }
45
+ if (typeof args !== "object") return [];
46
+ const existing = normalizeStructuredPatch(args.structuredPatch || args.structured_patch);
47
+ if (existing.length) return existing;
48
+ const hunks = [];
49
+ for (const key of ["diff", "patch", "input", "text", "content"]) {
50
+ if (typeof args[key] === "string") hunks.push(...structuredPatchFromDiffText(args[key], { defaultFile: firstString(args.path, args.file, args.file_path, args.filePath) }));
51
+ if (hunks.length >= MAX_STRUCTURED_PATCH_HUNKS) break;
52
+ }
53
+ if (!hunks.length) {
54
+ for (const key of ["changes", "edits", "hunks", "diffs", "fileDiffs", "file_diffs"]) {
55
+ if (args[key] != null) hunks.push(...structuredPatchFromToolArguments(args[key]));
56
+ if (hunks.length >= MAX_STRUCTURED_PATCH_HUNKS) break;
57
+ }
58
+ }
59
+ return hunks.slice(0, MAX_STRUCTURED_PATCH_HUNKS);
60
+ }
61
+
62
+ function structuredPatchFromDiffText(diff, options = {}) {
63
+ const text = String(diff || "");
64
+ if (!text.trim() || text.length > MAX_STRUCTURED_DIFF_CHARS) return [];
65
+ const lines = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
66
+ if (lines[lines.length - 1] === "") lines.pop();
67
+ const hunkRe = /^@@\s+-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s+@@/;
68
+ const hunks = [];
69
+ let current = null;
70
+ let currentFile = firstString(options.defaultFile);
71
+ let lineCount = 0;
72
+
73
+ const finish = () => {
74
+ if (!current || !current.lines.length) {
75
+ current = null;
76
+ return;
77
+ }
78
+ hunks.push(current);
79
+ current = null;
80
+ };
81
+
82
+ for (const rawLine of lines) {
83
+ const line = String(rawLine);
84
+ const hunk = hunkRe.exec(line);
85
+ if (hunk) {
86
+ finish();
87
+ current = {
88
+ oldStart: Number(hunk[1]),
89
+ newStart: Number(hunk[3]),
90
+ oldLines: hunk[2] == null ? 1 : Number(hunk[2]),
91
+ newLines: hunk[4] == null ? 1 : Number(hunk[4]),
92
+ lines: []
93
+ };
94
+ if (currentFile) current.file = currentFile;
95
+ continue;
96
+ }
97
+
98
+ const nextFile = diffMetadataFile(line);
99
+ if (nextFile) {
100
+ finish();
101
+ currentFile = nextFile;
102
+ continue;
103
+ }
104
+ if (line.startsWith("\")) continue;
105
+
106
+ if (!current) continue;
107
+ if (line.startsWith("+") && !line.startsWith("+++")) current.lines.push(line);
108
+ else if (line.startsWith("-") && !line.startsWith("---")) current.lines.push(line);
109
+ else if (line.startsWith(" ")) current.lines.push(line);
110
+ else current.lines.push(" " + line);
111
+ lineCount += 1;
112
+ if (lineCount > MAX_STRUCTURED_PATCH_LINES || hunks.length >= MAX_STRUCTURED_PATCH_HUNKS) break;
113
+ }
114
+ finish();
115
+ return normalizeStructuredPatch(hunks);
116
+ }
117
+
118
+ function diffMetadataFile(line) {
119
+ const text = String(line || "");
120
+ const git = text.match(/^diff --git\s+a\/(.+?)\s+b\/(.+)$/);
121
+ if (git) return stripDiffFilePrefix(git[2]);
122
+ const update = text.match(/^\*\*\*\s+(?:Update|Add|Delete)\s+File:\s+(.+)$/);
123
+ if (update) return update[1].trim();
124
+ const plus = text.match(/^\+\+\+\s+(.+)$/);
125
+ if (plus) {
126
+ const file = stripDiffFilePrefix(plus[1]);
127
+ return file === "/dev/null" ? "" : file;
128
+ }
129
+ return "";
130
+ }
131
+
132
+ function stripDiffFilePrefix(value) {
133
+ let text = String(value || "").trim();
134
+ if (!text) return "";
135
+ text = text.split(/\t/)[0].trim();
136
+ if ((text.startsWith("a/") || text.startsWith("b/")) && text.length > 2) return text.slice(2);
137
+ return text;
138
+ }
139
+
140
+ function positiveInt(value) {
141
+ const number = Math.floor(Number(value));
142
+ return Number.isFinite(number) && number > 0 ? number : 0;
143
+ }
144
+
145
+ function firstString(...values) {
146
+ for (const value of values) {
147
+ if (typeof value === "string" && value.trim()) return value.trim();
148
+ }
149
+ return "";
150
+ }
151
+
152
+ module.exports = {
153
+ normalizeStructuredPatch,
154
+ structuredPatchFromDiffText,
155
+ structuredPatchFromToolArguments
156
+ };
package/src/doctor.js CHANGED
@@ -9,6 +9,7 @@ const { discoverCliHistory } = require("./importers");
9
9
  const { canonicalSourceType, parserVersionForSource } = require("./parser-versions");
10
10
  const { paths, readJson } = require("./paths");
11
11
  const { hasRemoteTarget } = require("./sync");
12
+ const { CLAUDE_CODE_REPAIR_COMMAND, CLAUDE_CODE_REPAIR_PREVIEW_COMMAND, summarizeUnavailableSourceSessions, unavailableSourceSessions } = require("./unavailable-sources");
12
13
 
13
14
  function runDoctor(env = process.env, options = {}) {
14
15
  reportProgress(options, "Doctor", "checking configuration", 1, 5);
@@ -30,6 +31,14 @@ function runDoctor(env = process.env, options = {}) {
30
31
  reportProgress(options, "Doctor", "checking parser versions", 4, 5);
31
32
  const parsers = parserVersionHealth(env);
32
33
  add(checks, "parser versions", parsers.outdated.length === 0, parserVersionSummary(parsers), parserVersionRemediation(parsers));
34
+ const unavailableSources = summarizeUnavailableSourceSessions(unavailableSourceSessions(env), { env });
35
+ add(
36
+ checks,
37
+ "unavailable sources",
38
+ unavailableSources.totalSessions === 0,
39
+ unavailableSourceSummary(unavailableSources),
40
+ unavailableSourceRemediation(unavailableSources)
41
+ );
33
42
 
34
43
  reportProgress(options, "Doctor", "discovering local sources", 5, 5);
35
44
  const discovery = discoverCliHistory(env, { onProgress: options.onProgress });
@@ -49,6 +58,7 @@ function runDoctor(env = process.env, options = {}) {
49
58
  trackedFiles: Object.keys(importState.files || {}).length
50
59
  },
51
60
  coverage,
61
+ unavailableSources,
52
62
  parsers,
53
63
  recommendations
54
64
  };
@@ -62,11 +72,18 @@ function sourceCoverage(discovery, cfg) {
62
72
  coverageRow("codex-sdk", "Codex SDK jobs", discovery.codexSdk, configured),
63
73
  coverageRow("claude", "Claude Code CLI", discovery.claude, configured),
64
74
  coverageRow("claude-code-desktop", "Claude Code Desktop", discovery.claudeCodeDesktop, configured),
65
- coverageRow("claude-workspace", "Claude Workspace", discovery.claudeWorkspace, configured),
75
+ coverageRow("claude-cowork", "Claude Cowork", discovery.claudeWorkspace, configured),
66
76
  coverageRow("claude-sdk", "Claude SDK jobs", discovery.claudeSdk, configured),
67
77
  coverageRow("gemini-cli", "Gemini CLI", discovery.geminiCli, configured),
68
- coverageRow("antigravity", "Antigravity", discovery.antigravity, configured),
78
+ coverageRow("antigravity-cli", "Antigravity CLI", discovery.antigravityCli, configured),
79
+ coverageRow("antigravity", "Antigravity 2.0", discovery.antigravity, configured),
80
+ coverageRow("antigravity-ide", "Antigravity IDE", discovery.antigravityIde, configured),
69
81
  coverageRow("devin-cli", "Devin CLI", discovery.devinCli, configured),
82
+ coverageRow("devin-desktop", "Devin Desktop", discovery.devinDesktop, configured),
83
+ coverageRow("copilot-cli", "GitHub Copilot CLI", discovery.copilotCli, configured),
84
+ coverageRow("factory", "Factory Droid", discovery.factory, configured),
85
+ coverageRow("grok-build", "Grok Build", discovery.grokBuild, configured),
86
+ coverageRow("pi", "pi", discovery.pi, configured),
70
87
  coverageRow("cursor", "Cursor", discovery.cursor, configured),
71
88
  coverageRow("cline", "Cline", discovery.cline, configured),
72
89
  coverageRow("opencode-cli", "OpenCode CLI", discovery.opencodeCli, configured),
@@ -86,7 +103,8 @@ function coverageRow(source, label, result = {}, configured) {
86
103
  label,
87
104
  configured: configured.has(source)
88
105
  || (source === "claude-code-desktop" && configured.has("claude-desktop"))
89
- || (source === "claude-workspace" && configured.has("claude-desktop"))
106
+ || (source === "claude-cowork" && configured.has("claude-desktop"))
107
+ || (source === "claude-cowork" && configured.has("claude-workspace"))
90
108
  || (source.startsWith("opencode-") && configured.has("opencode")),
91
109
  sessions: result?.sessions || 0,
92
110
  oldest: result?.oldest || "",
@@ -145,6 +163,20 @@ function parserVersionRemediation(parsers) {
145
163
  return "Run the affected import commands listed in Recommendations.";
146
164
  }
147
165
 
166
+ function unavailableSourceSummary(summary) {
167
+ if (!summary?.totalSessions) return "no preserved archives depend on missing source files";
168
+ const missing = summary.totalMissingSources ? `; ${summary.totalMissingSources} missing source path${summary.totalMissingSources === 1 ? "" : "s"}` : "";
169
+ return `${summary.totalSessions} preserved unavailable source session${summary.totalSessions === 1 ? "" : "s"}${missing}`;
170
+ }
171
+
172
+ function unavailableSourceRemediation(summary) {
173
+ if (!summary?.totalSessions) return "";
174
+ if (summary.claudeCodeRepair?.restoreCount) {
175
+ return `Run \`${CLAUDE_CODE_REPAIR_PREVIEW_COMMAND}\`, then \`${CLAUDE_CODE_REPAIR_COMMAND} --yes\`, then \`agentlog update --yes --since all --sources claude\`.`;
176
+ }
177
+ return "Restore the missing source application history from backup, then run `agentlog update --yes --since all`; otherwise keep the preserved archive as historical data.";
178
+ }
179
+
148
180
  function buildRecommendations(checks, coverage, parsers) {
149
181
  const recommendations = [];
150
182
  for (const check of checks) {
@@ -199,12 +231,17 @@ function parserUpdateCommand(sourceType) {
199
231
  "cli-history": "claude",
200
232
  "claude-sdk-history": "claude-sdk",
201
233
  "claude-code-desktop-metadata": "claude-code-desktop",
202
- "claude-workspace-desktop": "claude-workspace",
234
+ "claude-workspace-desktop": "claude-cowork",
203
235
  "cursor-workspace-sqlite": "cursor",
204
236
  "cursor-global-sqlite": "cursor",
205
237
  "cursor-raw-sqlite-salvage": "cursor",
206
238
  "cursor-agent-transcripts": "cursor",
207
239
  "devin-cli-history": "devin-cli",
240
+ "devin-desktop-acp-events": "devin-desktop",
241
+ "copilot-cli-history": "copilot-cli",
242
+ "factory-droid-history": "factory",
243
+ "grok-build-history": "grok-build",
244
+ "pi-cli-history": "pi",
208
245
  "gemini-cli-history": "gemini-cli",
209
246
  "cline-task-history": "cline",
210
247
  "opencode-cli-history": "opencode-cli",
@@ -215,7 +252,13 @@ function parserUpdateCommand(sourceType) {
215
252
  "opencode-history": "opencode",
216
253
  "opencode-sqlite-history": "opencode",
217
254
  "aider-chat-history": "aider",
218
- "antigravity-history": "antigravity"
255
+ "antigravity-history": "antigravity",
256
+ "antigravity-transcript-log": "antigravity",
257
+ "antigravity-brain": "antigravity",
258
+ "antigravity-cli-transcript-log": "antigravity-cli",
259
+ "antigravity-cli-brain": "antigravity-cli",
260
+ "antigravity-ide-transcript-log": "antigravity-ide",
261
+ "antigravity-ide-brain": "antigravity-ide"
219
262
  }[canonicalSourceType(sourceType)];
220
263
  return source ? `agentlog import --source ${source} --since all` : "";
221
264
  }
@@ -50,7 +50,20 @@ function assistantMessages(event, message, provider, context, timestamp, content
50
50
  toolCalls: toolCalls.length ? toolCalls : undefined
51
51
  };
52
52
  const result = [];
53
- if (thinking) result.push(supplementaryMessage(provider, "Claude thinking", thinking, timestamp, "thinking", metadata.model));
53
+ const hasVisibleAssistantWork = Boolean(text || toolCalls.length);
54
+ if (thinking) {
55
+ result.push(
56
+ supplementaryMessage(
57
+ provider,
58
+ "Claude thinking",
59
+ thinking,
60
+ timestamp,
61
+ "thinking",
62
+ metadata.model,
63
+ hasVisibleAssistantWork ? {} : compactMetadata({ ...metadata, toolCalls: undefined })
64
+ )
65
+ );
66
+ }
54
67
  if (text || toolCalls.length) result.push({ role: "assistant", content: text, timestamp, metadata });
55
68
  return result;
56
69
  }
@@ -102,6 +115,7 @@ function claudeEventMetadata(event) {
102
115
  parentToolUseID: firstString(event.parentToolUseID, event.parent_tool_use_id),
103
116
  toolUseID: firstString(event.toolUseID, event.tool_use_id),
104
117
  isSidechain: typeof event.isSidechain === "boolean" ? event.isSidechain : undefined,
118
+ isMeta: typeof event.isMeta === "boolean" ? event.isMeta : (typeof event.payload?.isMeta === "boolean" ? event.payload.isMeta : undefined),
105
119
  isVisibleInTranscriptOnly: typeof event.isVisibleInTranscriptOnly === "boolean" ? event.isVisibleInTranscriptOnly : undefined,
106
120
  isCompactSummary: typeof event.isCompactSummary === "boolean" ? event.isCompactSummary : undefined,
107
121
  userType: firstString(event.userType, event.user_type),
@@ -133,13 +147,14 @@ function claudeApiErrorMetadata(event) {
133
147
  });
134
148
  }
135
149
 
136
- function supplementaryMessage(provider, title, content, timestamp, summaryKind, model = "") {
150
+ function supplementaryMessage(provider, title, content, timestamp, summaryKind, model = "", metadata = {}) {
137
151
  return {
138
152
  role: "assistant",
139
153
  content: `### ${title}\n\n${String(content || "").trim()}`,
140
154
  timestamp,
141
155
  metadata: {
142
156
  provider,
157
+ ...compactMetadata(metadata),
143
158
  eventType: `claude-${summaryKind}`,
144
159
  supplementary: true,
145
160
  summaryKind,
@@ -236,6 +251,15 @@ function remoteFileHistorySnapshotMessage(event, provider, timestamp) {
236
251
  const snapshotTimestamp = timestamp || eventTimestamp(snapshot);
237
252
  const backups = snapshot.trackedFileBackups && typeof snapshot.trackedFileBackups === "object" ? snapshot.trackedFileBackups : {};
238
253
  const paths = Object.keys(backups).sort();
254
+ const backupEntries = paths.map((filePath) => {
255
+ const backup = backups[filePath] && typeof backups[filePath] === "object" ? backups[filePath] : {};
256
+ return compactMetadata({
257
+ path: filePath,
258
+ backupFileName: firstString(backup.backupFileName, backup.backup_file_name) || undefined,
259
+ version: numberValue(backup.version) ?? undefined,
260
+ backupTime: firstString(backup.backupTime, backup.backup_time) || undefined
261
+ });
262
+ });
239
263
  const action = event.isSnapshotUpdate ? "updated" : "recorded";
240
264
  const target = paths.length ? `: ${namesPreview(paths)}` : "";
241
265
  return remoteContextMessage(provider, event, snapshotTimestamp, "remote_control_file_history", `Remote Control file history snapshot ${action}${target}`, {
@@ -244,7 +268,8 @@ function remoteFileHistorySnapshotMessage(event, provider, timestamp) {
244
268
  isSnapshotUpdate: typeof event.isSnapshotUpdate === "boolean" ? event.isSnapshotUpdate : undefined,
245
269
  timestamp: snapshotTimestamp || undefined,
246
270
  backupFileCount: paths.length || undefined,
247
- paths: paths.length ? paths : undefined
271
+ paths: paths.length ? paths : undefined,
272
+ backups: backupEntries.length ? backupEntries : undefined
248
273
  })
249
274
  });
250
275
  }
@@ -454,10 +479,32 @@ function normalizeToolResult(part, provider, event, context = {}) {
454
479
  collapsed: output.split("\n").length > 18,
455
480
  status: part.is_error ? "error" : "completed",
456
481
  sourceToolUseID: firstString(event?.sourceToolUseID, event?.source_tool_use_id) || undefined,
457
- structuredContent: event?.mcpMeta?.structuredContent && typeof event.mcpMeta.structuredContent === "object" ? event.mcpMeta.structuredContent : undefined
482
+ structuredContent: event?.mcpMeta?.structuredContent && typeof event.mcpMeta.structuredContent === "object" ? event.mcpMeta.structuredContent : undefined,
483
+ structuredPatch: sanitizeStructuredPatch(event?.toolUseResult?.structuredPatch)
458
484
  });
459
485
  }
460
486
 
487
+ // Claude Code's Edit/Write results include jsdiff-style hunks with absolute
488
+ // file line numbers; keep them so viewers can render numbered diffs.
489
+ function sanitizeStructuredPatch(hunks) {
490
+ if (!Array.isArray(hunks) || !hunks.length) return undefined;
491
+ const sanitized = [];
492
+ for (const hunk of hunks) {
493
+ if (!hunk || typeof hunk !== "object" || !Array.isArray(hunk.lines)) continue;
494
+ const oldStart = Number(hunk.oldStart);
495
+ const newStart = Number(hunk.newStart);
496
+ if (!Number.isFinite(oldStart) || !Number.isFinite(newStart)) continue;
497
+ sanitized.push({
498
+ oldStart,
499
+ oldLines: Number(hunk.oldLines) || 0,
500
+ newStart,
501
+ newLines: Number(hunk.newLines) || 0,
502
+ lines: hunk.lines.map((line) => String(line ?? ""))
503
+ });
504
+ }
505
+ return sanitized.length ? sanitized : undefined;
506
+ }
507
+
461
508
  function extractToolResultOutput(part, eventToolUseResult) {
462
509
  const direct = extractText(part.content ?? part.output ?? part.result ?? part.text);
463
510
  const extra = extractText(eventToolUseResult?.stdout ?? eventToolUseResult?.stderr ?? eventToolUseResult?.output ?? eventToolUseResult?.content);