atom-agent 1.4.0 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/README.md +221 -224
  3. package/dist/App.js +922 -341
  4. package/dist/adapters.js +502 -21
  5. package/dist/agent/goal-evaluator.js +3 -0
  6. package/dist/agent/loop.js +250 -434
  7. package/dist/agent/tool-pipeline.js +398 -0
  8. package/dist/agent/turn-events.js +12 -0
  9. package/dist/cli.js +57 -8
  10. package/dist/compact.js +72 -8
  11. package/dist/config.js +19 -0
  12. package/dist/context-manager.js +6 -2
  13. package/dist/extensions.js +6 -0
  14. package/dist/file-diffs.js +108 -0
  15. package/dist/kilo.js +1 -1
  16. package/dist/local-discovery.js +2 -2
  17. package/dist/media.js +276 -0
  18. package/dist/overflow.js +140 -0
  19. package/dist/policy.js +8 -0
  20. package/dist/providers.js +11 -3
  21. package/dist/scheduler.js +38 -9
  22. package/dist/session-revert.js +125 -0
  23. package/dist/sessions.js +101 -0
  24. package/dist/snapshots.js +69 -0
  25. package/dist/system.js +2 -89
  26. package/dist/telemetry.js +79 -5
  27. package/dist/todos.js +241 -0
  28. package/dist/tools/filesystem.js +102 -22
  29. package/dist/tools/registry.js +184 -45
  30. package/dist/tools/ripgrep.js +7 -6
  31. package/dist/tools/search.js +172 -17
  32. package/dist/tools/shared.js +6 -0
  33. package/dist/tools.js +7 -39
  34. package/dist/ui/diff-panel.js +1 -1
  35. package/dist/ui/diff-view.js +13 -5
  36. package/dist/ui/diff.js +67 -0
  37. package/dist/ui/errors.js +20 -6
  38. package/dist/ui/input.js +24 -20
  39. package/dist/ui/live-tail.js +36 -1
  40. package/dist/ui/markdown.js +9 -4
  41. package/dist/ui/modals.js +7 -5
  42. package/dist/ui/paint-scheduler.js +120 -0
  43. package/dist/ui/palette.js +4 -2
  44. package/dist/ui/pickers.js +4 -1
  45. package/dist/ui/side-by-side.js +81 -22
  46. package/dist/ui/status-bar.js +63 -8
  47. package/dist/ui/stream-store.js +7 -0
  48. package/dist/ui/theme.js +23 -1
  49. package/dist/ui/todo-panel.js +5 -2
  50. package/dist/ui/tool-inspector.js +33 -4
  51. package/dist/ui/transcript.js +8 -5
  52. package/dist/web/events.js +93 -0
  53. package/dist/web/runtime.js +790 -0
  54. package/dist/web/server.js +570 -0
  55. package/dist/web/ui/app.js +1925 -0
  56. package/dist/web/ui/index.html +135 -0
  57. package/dist/web/ui/styles.css +515 -0
  58. package/dist/zen.js +532 -34
  59. package/documentation/cli.md +5 -5
  60. package/documentation/configuration.md +11 -6
  61. package/documentation/development.md +4 -3
  62. package/documentation/goals.md +1 -1
  63. package/documentation/index.md +4 -4
  64. package/documentation/providers.md +2 -3
  65. package/documentation/skills.md +3 -3
  66. package/documentation/tools.md +8 -3
  67. package/documentation/troubleshooting.md +1 -1
  68. package/package.json +3 -2
package/dist/telemetry.js CHANGED
@@ -52,9 +52,10 @@
52
52
  import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
53
53
  import * as path from "node:path";
54
54
  import { randomUUID } from "node:crypto";
55
+ import { fileURLToPath } from "node:url";
55
56
  import { scrubSecrets } from "./policy.js";
56
57
  import { atomDir } from "./auth.js";
57
- export const TELEMETRY_VERSION = 1;
58
+ export const TELEMETRY_VERSION = 2;
58
59
  export const TELEMETRY_DIRNAME = "telemetry";
59
60
  export const TELEMETRY_SESSIONS_DIRNAME = "sessions";
60
61
  export const TELEMETRY_DASHBOARD_FILENAME = "dashboard.html";
@@ -413,7 +414,8 @@ function isRecord(value) {
413
414
  function validateTelemetrySession(data) {
414
415
  if (!isRecord(data))
415
416
  return null;
416
- if (data["version"] !== TELEMETRY_VERSION)
417
+ const ver = data["version"];
418
+ if (ver !== TELEMETRY_VERSION && ver !== 1)
417
419
  return null;
418
420
  const sessionId = data["sessionId"];
419
421
  const startedAt = data["startedAt"];
@@ -426,6 +428,17 @@ function validateTelemetrySession(data) {
426
428
  return null;
427
429
  const subagents = Array.isArray(data["subagents"]) ? data["subagents"] : [];
428
430
  const events = Array.isArray(data["events"]) ? data["events"] : [];
431
+ // v1 → v2 migration: inputTruncated defaults from preview length, loop
432
+ // gains additive phase fields only when the writer reported them.
433
+ const migratedTurns = turns.map((t) => {
434
+ const rec = t;
435
+ if (typeof rec["inputTruncated"] !== "boolean") {
436
+ const preview = typeof rec["inputPreview"] === "string" ? rec["inputPreview"] : "";
437
+ const chars = typeof rec["inputChars"] === "number" ? rec["inputChars"] : preview.length;
438
+ rec["inputTruncated"] = chars > preview.length;
439
+ }
440
+ return t;
441
+ });
429
442
  return {
430
443
  version: TELEMETRY_VERSION,
431
444
  sessionId,
@@ -435,7 +448,7 @@ function validateTelemetrySession(data) {
435
448
  project: typeof data["project"] === "string" ? data["project"] : null,
436
449
  provider: typeof data["provider"] === "string" ? data["provider"] : "unknown",
437
450
  model: typeof data["model"] === "string" ? data["model"] : "unknown",
438
- turns: turns,
451
+ turns: migratedTurns,
439
452
  subagents,
440
453
  events,
441
454
  compactionUsage: isRecord(data["compactionUsage"]) ? data["compactionUsage"] : {},
@@ -578,6 +591,30 @@ export function summarizeTelemetry(sessions) {
578
591
  .sort((a, b) => b.calls - a.calls || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
579
592
  return agg;
580
593
  }
594
+ // App version stamped onto session records (Temp-session gap: every record
595
+ // carried atomVersion:null, so traces from different builds were
596
+ // indistinguishable). Resolved once from the package manifest beside the
597
+ // source tree — dist/ mirrors src/, so ../package.json holds in both
598
+ // layouts. Cached, never throws: null when unreadable keeps today's shape.
599
+ let cachedAtomVersion;
600
+ export function resolveAtomVersion() {
601
+ if (cachedAtomVersion !== undefined)
602
+ return cachedAtomVersion;
603
+ try {
604
+ const here = fileURLToPath(import.meta.url);
605
+ const raw = readFileSync(path.join(path.dirname(here), "..", "package.json"), "utf8");
606
+ const v = JSON.parse(raw).version;
607
+ cachedAtomVersion = typeof v === "string" && v.length > 0 ? v : null;
608
+ }
609
+ catch {
610
+ cachedAtomVersion = null;
611
+ }
612
+ return cachedAtomVersion;
613
+ }
614
+ // Test seam: drop the cached manifest read (production never calls this).
615
+ export function resetAtomVersion() {
616
+ cachedAtomVersion = undefined;
617
+ }
581
618
  // In-memory trace for one session plus atomic turn-boundary persistence.
582
619
  // Every public method is safe to call with null/undefined turn ids and never
583
620
  // throws; when disabled, all record methods are no-ops.
@@ -607,7 +644,7 @@ export class TelemetryRecorder {
607
644
  sessionId: this.sessionId,
608
645
  startedAt: toIso(startedMs),
609
646
  endedAt: null,
610
- atomVersion: opts.atomVersion ?? null,
647
+ atomVersion: opts.atomVersion !== undefined ? opts.atomVersion : resolveAtomVersion(),
611
648
  project: opts.project !== undefined ? opts.project : projectBasename(),
612
649
  provider: opts.provider ?? "unknown",
613
650
  model: opts.model ?? "unknown",
@@ -679,6 +716,7 @@ export class TelemetryRecorder {
679
716
  durationMs: null,
680
717
  inputPreview: preview.preview,
681
718
  inputChars: typeof input === "string" ? input.length : 0,
719
+ inputTruncated: preview.truncated,
682
720
  provider: meta.provider,
683
721
  model: meta.model,
684
722
  effort: meta.effort,
@@ -773,7 +811,9 @@ export class TelemetryRecorder {
773
811
  : 0,
774
812
  usage: clean,
775
813
  usageReported: info.usageReported === true && clean !== undefined,
776
- reasoningLabel: typeof info.reasoningLabel === "string" ? info.reasoningLabel : undefined,
814
+ reasoningLabel: typeof info.reasoningLabel === "string" && info.reasoningLabel.trim().length > 2
815
+ ? info.reasoningLabel
816
+ : undefined,
777
817
  toolCallCount: typeof info.toolCallCount === "number" ? info.toolCallCount : 0,
778
818
  finishReason: info.finishReason === "tool_calls" || info.finishReason === "error" ? info.finishReason : "final",
779
819
  error: typeof info.error === "string" ? info.error.slice(0, 500) : undefined,
@@ -870,6 +910,26 @@ export class TelemetryRecorder {
870
910
  if (bMs !== undefined)
871
911
  loop.bottleneckMs = bMs;
872
912
  }
913
+ const slowest = s["slowestModel"];
914
+ const sName = typeof slowest?.["id"] === "string" ? slowest["id"] : undefined;
915
+ const sMs = num(slowest?.["durationMs"]) ?? undefined;
916
+ if (sName && sName.length > 0) {
917
+ loop.slowestModelName = sName.slice(0, 80);
918
+ if (sMs !== undefined)
919
+ loop.slowestModelMs = sMs;
920
+ }
921
+ const modelTotal = num(s["modelTotalMs"]) ?? undefined;
922
+ if (modelTotal !== undefined)
923
+ loop.modelTotalMs = modelTotal;
924
+ const toolTotal = num(s["toolTotalMs"]) ?? undefined;
925
+ if (toolTotal !== undefined)
926
+ loop.toolTotalMs = toolTotal;
927
+ const dom = s["dominantPhase"];
928
+ if (dom === "model" || dom === "tool")
929
+ loop.dominantPhase = dom;
930
+ else if (modelTotal !== undefined || toolTotal !== undefined) {
931
+ loop.dominantPhase = (modelTotal ?? 0) >= (toolTotal ?? 0) ? "model" : "tool";
932
+ }
873
933
  turn.loop = loop;
874
934
  }
875
935
  catch {
@@ -1006,7 +1066,21 @@ export class TelemetryRecorder {
1006
1066
  : outcome === "cancelled"
1007
1067
  ? "(cancelled)"
1008
1068
  : "(failed)";
1069
+ // Preserve any streamed partial for post-mortem instead of nulling
1070
+ // the reply outright; dashboard renders partial distinctly.
1071
+ if (typeof replyOrError === "string" && replyOrError.length > 0) {
1072
+ const scrubbed = this.scrub(replyOrError);
1073
+ turn.partialReplyPreview = truncatePreview(scrubbed, TELEMETRY_INPUT_PREVIEW_CHARS).preview;
1074
+ }
1075
+ else {
1076
+ turn.partialReplyPreview = null;
1077
+ }
1009
1078
  turn.replyPreview = null;
1079
+ // Timeline entry for failed turns only: cancellations are
1080
+ // user-initiated noise, and the turn record already keeps the error.
1081
+ if (outcome === "failed") {
1082
+ this.recordEvent("info", `turn ${turn.id} ${outcome}: ${(turn.error ?? "").slice(0, 200)}`);
1083
+ }
1010
1084
  }
1011
1085
  else {
1012
1086
  const scrubbed = this.scrub(typeof replyOrError === "string" ? replyOrError : "");
package/dist/todos.js ADDED
@@ -0,0 +1,241 @@
1
+ // Per-session todos (ticket 05): a task checklist scoped to each session
2
+ // that persists across compactions and restarts, so the agent's plan and
3
+ // progress are never lost when old chat text is summarized away.
4
+ //
5
+ // Storage: namespaced under the multi-session record's generic
6
+ // `metadata.todos` key (src/sessions.ts) via the existing
7
+ // updateSession/getSession APIs — no schema edits, no new files on disk.
8
+ // Todos live OUTSIDE compacted chat text, so compaction (summary+tail)
9
+ // can never summarize them away; the summary only carries a context
10
+ // backstop (see formatGoalForCompact), never the record of truth.
11
+ //
12
+ // Status vocabulary is exactly src/tools/todo.ts's
13
+ // ("pending" | "in_progress" | "completed") — matched, not reinvented.
14
+ // Runtime invariants (at most one in_progress, completed-stays-completed,
15
+ // all-completed clears) stay owned by the tools/todo.ts layer; this module
16
+ // validates SHAPE only, so a saved record always replays through
17
+ // todowriteTool cleanly.
18
+ //
19
+ // Totality (mirroring goal.ts): serialize/restore never throw. Old records
20
+ // without the todos key read as an empty list; a malformed todos value
21
+ // degrades to empty WITHOUT failing the session load (same posture as the
22
+ // goal field). The pure CRUD ops below throw Error on invalid input —
23
+ // those are programmer errors with explicit messages, never silent.
24
+ //
25
+ // Import budget: type-only import from ./tools/todo.js (erased at compile,
26
+ // zero runtime coupling) plus no value imports — this module never touches
27
+ // App, sessions, compact, config, or the tool registry.
28
+ // Namespace inside Session.metadata. Never read or write metadata.filediffs
29
+ // (ticket 06 owns it).
30
+ export const TODOS_METADATA_KEY = "todos";
31
+ const TODO_STATUSES = [
32
+ "pending",
33
+ "in_progress",
34
+ "completed",
35
+ ];
36
+ const TODO_PRIORITIES = ["high", "medium", "low"];
37
+ function isRecord(value) {
38
+ return typeof value === "object" && value !== null && !Array.isArray(value);
39
+ }
40
+ // Shape check mirroring todowriteTool's item validation (same vocabulary,
41
+ // same strictness): non-empty content, known status, known priority when
42
+ // present, string activeForm when present. Unknown extra keys are ignored.
43
+ // Returns a clean deep copy, or null when invalid.
44
+ export function validateTodoRecord(value) {
45
+ try {
46
+ if (!isRecord(value))
47
+ return null;
48
+ if (typeof value["content"] !== "string" || value["content"].length === 0) {
49
+ return null;
50
+ }
51
+ const status = value["status"];
52
+ if (status !== "pending" &&
53
+ status !== "in_progress" &&
54
+ status !== "completed") {
55
+ return null;
56
+ }
57
+ const clean = {
58
+ content: value["content"],
59
+ status,
60
+ };
61
+ if (value["priority"] !== undefined) {
62
+ const priority = value["priority"];
63
+ if (priority !== "high" && priority !== "medium" && priority !== "low") {
64
+ return null;
65
+ }
66
+ clean.priority = priority;
67
+ }
68
+ if (value["activeForm"] !== undefined) {
69
+ if (typeof value["activeForm"] !== "string")
70
+ return null;
71
+ if (value["activeForm"].length > 0)
72
+ clean.activeForm = value["activeForm"];
73
+ }
74
+ return clean;
75
+ }
76
+ catch {
77
+ return null;
78
+ }
79
+ }
80
+ function cleanTodoList(list) {
81
+ return list.map((t) => ({ ...t }));
82
+ }
83
+ function checkedIndex(length, index, verb) {
84
+ if (typeof index !== "number" ||
85
+ !Number.isFinite(index) ||
86
+ Math.floor(index) !== index) {
87
+ throw new Error(`${verb}: index must be an integer (got ${String(index)})`);
88
+ }
89
+ if (index < 1 || index > length) {
90
+ throw new Error(`${verb}: index ${index} out of range (list has ${length} item(s))`);
91
+ }
92
+ return index - 1;
93
+ }
94
+ // Append one item (1-based position = end). Throws Error on a malformed item.
95
+ export function createTodo(list, item) {
96
+ const clean = validateTodoRecord(item);
97
+ if (!clean) {
98
+ throw new Error(`createTodo: item must be {content: non-empty string, status: "pending" | "in_progress" | "completed", priority?: "high" | "medium" | "low", activeForm?: string}`);
99
+ }
100
+ return [...cleanTodoList(list), clean];
101
+ }
102
+ // Patch ONE item by 1-based index (status transitions flow through here —
103
+ // completeTodo below is the named common case). Unknown patch keys are
104
+ // ignored; an empty patch is a no-op copy. Throws Error on a bad index or
105
+ // an invalid patched value.
106
+ export function updateTodo(list, index, patch) {
107
+ const at = checkedIndex(list.length, index, "updateTodo");
108
+ const p = isRecord(patch) ? { ...patch } : {};
109
+ const next = { ...list[at] };
110
+ if (p["status"] !== undefined) {
111
+ const status = p["status"];
112
+ if (status !== "pending" &&
113
+ status !== "in_progress" &&
114
+ status !== "completed") {
115
+ throw new Error(`updateTodo: status must be one of "pending", "in_progress", "completed" (got ${JSON.stringify(status) ?? String(status)})`);
116
+ }
117
+ next.status = status;
118
+ }
119
+ if (p["content"] !== undefined) {
120
+ if (typeof p["content"] !== "string" || p["content"].length === 0) {
121
+ throw new Error(`updateTodo: content must be a non-empty string`);
122
+ }
123
+ next.content = p["content"];
124
+ }
125
+ if (p["priority"] !== undefined) {
126
+ const priority = p["priority"];
127
+ if (priority !== "high" && priority !== "medium" && priority !== "low") {
128
+ throw new Error(`updateTodo: priority must be one of "high", "medium", "low" (got ${JSON.stringify(priority) ?? String(priority)})`);
129
+ }
130
+ next.priority = priority;
131
+ }
132
+ if (p["activeForm"] !== undefined) {
133
+ if (typeof p["activeForm"] !== "string") {
134
+ throw new Error(`updateTodo: activeForm must be a string`);
135
+ }
136
+ if (p["activeForm"].length > 0) {
137
+ next.activeForm = p["activeForm"];
138
+ }
139
+ else {
140
+ delete next.activeForm;
141
+ }
142
+ }
143
+ const out = cleanTodoList(list);
144
+ out[at] = next;
145
+ return out;
146
+ }
147
+ // Mark one item completed by 1-based index. Throws Error on a bad index.
148
+ export function completeTodo(list, index) {
149
+ return updateTodo(list, index, { status: "completed" });
150
+ }
151
+ // Move the item at 1-based `from` to 1-based `to` (order = array order).
152
+ // Throws Error on a bad index.
153
+ export function reorderTodo(list, from, to) {
154
+ const fromAt = checkedIndex(list.length, from, "reorderTodo");
155
+ const toAt = checkedIndex(list.length, to, "reorderTodo");
156
+ if (fromAt === toAt)
157
+ return cleanTodoList(list);
158
+ const out = cleanTodoList(list);
159
+ const [moved] = out.splice(fromAt, 1);
160
+ out.splice(toAt, 0, moved);
161
+ return out;
162
+ }
163
+ // Serialize the live list for a session save: a deep copy (the save must
164
+ // never alias live state). Total: never throws; unserializable input reads
165
+ // as an empty list.
166
+ export function serializeTodosForPersist(list) {
167
+ try {
168
+ if (!Array.isArray(list))
169
+ return [];
170
+ const out = [];
171
+ for (const item of list) {
172
+ const clean = validateTodoRecord(item);
173
+ if (!clean)
174
+ return [];
175
+ const persisted = {
176
+ content: clean.content,
177
+ status: clean.status,
178
+ };
179
+ if (clean.priority !== undefined)
180
+ persisted.priority = clean.priority;
181
+ if (clean.activeForm !== undefined)
182
+ persisted.activeForm = clean.activeForm;
183
+ out.push(persisted);
184
+ }
185
+ return out;
186
+ }
187
+ catch {
188
+ return [];
189
+ }
190
+ }
191
+ // Restore a saved list: valid items come back verbatim (states intact, order
192
+ // intact); a missing key, a non-array, or ANY malformed item degrades to an
193
+ // empty list — never a throw, never a partial list (a half-restored plan is
194
+ // worse than a visibly empty one). Old records without the todos key land
195
+ // here safely.
196
+ export function restoreTodosFromPersist(value) {
197
+ try {
198
+ if (value === null || value === undefined)
199
+ return [];
200
+ if (!Array.isArray(value))
201
+ return [];
202
+ const out = [];
203
+ for (const item of value) {
204
+ const clean = validateTodoRecord(item);
205
+ if (!clean)
206
+ return [];
207
+ out.push(clean);
208
+ }
209
+ return out;
210
+ }
211
+ catch {
212
+ return [];
213
+ }
214
+ }
215
+ // Read the checklist out of a session record's metadata (the switch-restore
216
+ // path): absent or corrupt reads as []. Never throws.
217
+ export function readSessionTodos(metadata) {
218
+ try {
219
+ if (!isRecord(metadata))
220
+ return [];
221
+ return restoreTodosFromPersist(metadata[TODOS_METADATA_KEY]);
222
+ }
223
+ catch {
224
+ return [];
225
+ }
226
+ }
227
+ // Stamp the checklist into a metadata object for an updateSession patch (the
228
+ // per-turn persist path): every other key (extension state, filediffs, …)
229
+ // passes through untouched — only metadata.todos is set. Never throws.
230
+ export function withSessionTodos(metadata, list) {
231
+ try {
232
+ const base = isRecord(metadata)
233
+ ? { ...metadata }
234
+ : {};
235
+ base[TODOS_METADATA_KEY] = serializeTodosForPersist(list);
236
+ return base;
237
+ }
238
+ catch {
239
+ return { [TODOS_METADATA_KEY]: [] };
240
+ }
241
+ }
@@ -2,12 +2,13 @@
2
2
  // bytes first (see snapshots.ts); validation failures return before capture.
3
3
  import { promises as fsp } from "node:fs";
4
4
  import * as path from "node:path";
5
+ import { MEDIA_MAX_BYTES, mediaDescriptor, oversizeImageError, saveMedia, sniffImageMime, sniffPdf, unsupportedBinaryError, } from "../media.js";
5
6
  import { capturePriorBytes } from "../snapshots.js";
6
7
  import { contentHash, fingerprintKey, readFingerprints } from "./fingerprints.js";
7
8
  import { appendOverflow } from "./overflow.js";
8
9
  import { getCachedRead, invalidatePath, normalizeReadWindow, setCachedRead } from "./read-cache.js";
9
10
  import { invalidateListingsForFile } from "./dir-cache.js";
10
- import { err, invalidCall, READ_CHAR_CAP, resolveSandbox, truncateHead } from "./shared.js";
11
+ import { err, invalidCall, READ_CHAR_CAP, READ_FILE_MAX_BYTES, resolveSandbox, truncateHead } from "./shared.js";
11
12
  // offset/limit are 1-based line numbers. Output capped at ~64KB.
12
13
  export async function readTool(args, cwd = process.cwd()) {
13
14
  try {
@@ -26,6 +27,62 @@ export async function readTool(args, cwd = process.cwd()) {
26
27
  const lines = entries.map((e) => (e.isDirectory() ? `${e.name}/` : e.name));
27
28
  return `Directory listing for ${args.path}:\n${lines.join("\n")}`;
28
29
  }
30
+ // Vision-input peek FIRST: image magic bytes sit in the first 12 bytes,
31
+ // so every file is classified with one tiny read. Supported images
32
+ // (PNG/JPEG/GIF/WebP) take the media path with their own MEDIA_MAX_BYTES
33
+ // cap; PDF magic gets convert-first guidance at any size; everything
34
+ // else falls into the existing guarded text path untouched.
35
+ const fileSize = st.size ?? 0;
36
+ let peek = null;
37
+ try {
38
+ const fh = await fsp.open(r.abs, "r");
39
+ try {
40
+ const buf = Buffer.alloc(12);
41
+ const { bytesRead } = await fh.read(buf, 0, 12, 0);
42
+ peek = buf.subarray(0, bytesRead);
43
+ }
44
+ finally {
45
+ await fh.close();
46
+ }
47
+ }
48
+ catch {
49
+ // peek never breaks reads — null falls through to the text path
50
+ }
51
+ if (peek !== null) {
52
+ const mime = sniffImageMime(peek);
53
+ if (mime !== null) {
54
+ if (fileSize > MEDIA_MAX_BYTES)
55
+ return oversizeImageError(args.path, fileSize);
56
+ let raw;
57
+ try {
58
+ raw = await fsp.readFile(r.abs);
59
+ }
60
+ catch {
61
+ return err(`cannot read file: ${args.path}`);
62
+ }
63
+ const { id } = await saveMedia(raw, mime, args.path);
64
+ // Fingerprint on the utf8 decoding so a later edit compares
65
+ // consistently with editTool's own read+hash.
66
+ readFingerprints.set(fingerprintKey(r.abs), contentHash(raw.toString("utf8")));
67
+ return (`Image read successfully: ${args.path} (${mime}, ${raw.length} bytes, attached as vision input).\n` +
68
+ mediaDescriptor(id, mime, raw.length));
69
+ }
70
+ if (sniffPdf(peek)) {
71
+ return unsupportedBinaryError(args.path, "pdf", fileSize);
72
+ }
73
+ }
74
+ // OOM guard: never materialize a whole file past READ_FILE_MAX_BYTES
75
+ // (UTF-16 doubling + split/join copies can OOM the heap on one read).
76
+ // The size is known from the stat above, so this costs no extra I/O.
77
+ try {
78
+ const size = st.size ?? 0;
79
+ if (size > READ_FILE_MAX_BYTES) {
80
+ return err(`file too large to read (${size} bytes > 1MB): ${args.path}. Narrow with grep/glob first`);
81
+ }
82
+ }
83
+ catch {
84
+ // size check never breaks reads (the read below still applies its cap)
85
+ }
29
86
  // Read-cache fast path: same abs + window + unchanged mtime/size skips
30
87
  // disk I/O. The stored hash refreshes the stale-read fingerprint so
31
88
  // read→read→edit chains keep working without re-hashing.
@@ -48,32 +105,42 @@ export async function readTool(args, cwd = process.cwd()) {
48
105
  catch {
49
106
  return err(`cannot read file: ${args.path}`);
50
107
  }
51
- const hash = contentHash(text);
52
- readFingerprints.set(fingerprintKey(r.abs), hash);
53
- if (text.length === 0)
54
- return "";
55
- const offset = Math.max(1, Math.floor(args.offset ?? 1));
56
- const limit = Math.max(1, Math.floor(args.limit ?? Number.MAX_SAFE_INTEGER));
57
- const window = text.split("\n").slice(offset - 1, offset - 1 + limit);
58
- let out = window.map((line, i) => `${offset + i}: ${line}`).join("\n");
59
- if (out.length > READ_CHAR_CAP) {
60
- const full = out;
61
- const t = truncateHead(full, READ_CHAR_CAP, "\n[truncated: output exceeded 64KB]");
62
- out = appendOverflow(t.head, t.note, "file output", full);
63
- }
64
- try {
65
- const statInfo = { mtimeMs: st.mtimeMs ?? 0, size: st.size ?? 0 };
66
- setCachedRead(r.abs, normOffset, normLimit, out, statInfo, hash);
67
- }
68
- catch {
69
- // cache store never breaks reads
70
- }
71
- return out;
108
+ return readTextResult(r.abs, args, text, st, normOffset, normLimit);
72
109
  }
73
110
  catch (e) {
74
111
  return err(e instanceof Error ? e.message : String(e));
75
112
  }
76
113
  }
114
+ // Shared text path for readTool: fingerprint + line window + 64KB cap +
115
+ // cache store. The small-file caller reuses its already-read bytes;
116
+ // the over-cap caller arrives here after the media peek.
117
+ function readTextResult(abs, args, text, st, normOffset, normLimit) {
118
+ const stat = st;
119
+ const hash = contentHash(text);
120
+ readFingerprints.set(fingerprintKey(abs), hash);
121
+ if (text.length === 0)
122
+ return "";
123
+ const offset = Math.max(1, Math.floor(args.offset ?? 1));
124
+ const limit = Math.max(1, Math.floor(args.limit ?? Number.MAX_SAFE_INTEGER));
125
+ const window = text.split("\n").slice(offset - 1, offset - 1 + limit);
126
+ let out = window.map((line, i) => `${offset + i}: ${line}`).join("\n");
127
+ if (out.length > READ_CHAR_CAP) {
128
+ const full = out;
129
+ const t = truncateHead(full, READ_CHAR_CAP, "\n[truncated: output exceeded 64KB]");
130
+ out = appendOverflow(t.head, t.note, "file output", full);
131
+ }
132
+ try {
133
+ const statInfo = { mtimeMs: stat.mtimeMs ?? 0, size: stat.size ?? 0 };
134
+ const { offset: o, limit: l } = normOffset !== undefined && normLimit !== undefined
135
+ ? { offset: normOffset, limit: normLimit }
136
+ : normalizeReadWindow(args.offset, args.limit);
137
+ setCachedRead(abs, o, l, out, statInfo, hash);
138
+ }
139
+ catch {
140
+ // cache store never breaks reads
141
+ }
142
+ return out;
143
+ }
77
144
  export async function writeTool(args, cwd = process.cwd()) {
78
145
  try {
79
146
  const r = resolveSandbox(args?.path, cwd);
@@ -110,6 +177,19 @@ export async function editTool(args, cwd = process.cwd()) {
110
177
  }
111
178
  if (typeof args.newString !== "string")
112
179
  return err("newString must be a string");
180
+ // OOM guard (same rationale as readTool above): an edit materializes
181
+ // the whole file plus split/join copies, so refuse past the cap with
182
+ // guidance instead of risking the heap. Missing paths keep the legacy
183
+ // "no such file" error below.
184
+ try {
185
+ const st = await fsp.stat(r.abs);
186
+ if (st.isFile() && st.size > READ_FILE_MAX_BYTES) {
187
+ return err(`file too large to edit (${st.size} bytes > 1MB): ${args.path}. Use bash for targeted changes to huge files`);
188
+ }
189
+ }
190
+ catch {
191
+ // stat failure falls through to the read below (missing → its error)
192
+ }
113
193
  let text;
114
194
  try {
115
195
  text = await fsp.readFile(r.abs, "utf8");