atom-agent 1.0.0 → 1.2.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.
Files changed (46) hide show
  1. package/CHANGELOG.md +62 -2
  2. package/README.md +17 -16
  3. package/dist/App.js +1010 -77
  4. package/dist/adapters.js +108 -8
  5. package/dist/agent/gates.js +14 -1
  6. package/dist/agent/loop-guard.js +182 -0
  7. package/dist/agent/loop.js +781 -329
  8. package/dist/agent/normalize.js +151 -0
  9. package/dist/cli.js +16 -2
  10. package/dist/compact.js +128 -2
  11. package/dist/env-block.js +43 -5
  12. package/dist/scheduler.js +101 -21
  13. package/dist/sessions.js +524 -0
  14. package/dist/system.js +89 -12
  15. package/dist/telemetry-dashboard.js +19 -1
  16. package/dist/telemetry.js +55 -0
  17. package/dist/tools/dir-cache.js +214 -0
  18. package/dist/tools/filesystem.js +43 -3
  19. package/dist/tools/read-cache.js +160 -0
  20. package/dist/tools/registry.js +80 -0
  21. package/dist/tools/ripgrep.js +256 -0
  22. package/dist/tools/search.js +147 -80
  23. package/dist/tools/shared.js +39 -0
  24. package/dist/tools/shell.js +26 -5
  25. package/dist/tools/todo.js +1 -1
  26. package/dist/tools/web.js +6 -6
  27. package/dist/tools.js +3 -0
  28. package/dist/ui/diff-panel.js +55 -0
  29. package/dist/ui/diff-view.js +117 -0
  30. package/dist/ui/diff.js +422 -0
  31. package/dist/ui/highlight.js +120 -0
  32. package/dist/ui/live-host.js +18 -0
  33. package/dist/ui/live-tail.js +9 -3
  34. package/dist/ui/markdown.js +26 -2
  35. package/dist/ui/modals.js +22 -5
  36. package/dist/ui/palette.js +12 -2
  37. package/dist/ui/side-by-side.js +144 -0
  38. package/dist/ui/status-bar.js +20 -4
  39. package/dist/ui/status-host.js +22 -0
  40. package/dist/ui/stream-store.js +48 -0
  41. package/dist/ui/theme.js +6 -0
  42. package/dist/ui/todo-panel.js +10 -2
  43. package/dist/ui/tool-inspector.js +7 -1
  44. package/dist/ui/transcript.js +105 -39
  45. package/dist/zen.js +97 -20
  46. package/package.json +1 -1
package/dist/telemetry.js CHANGED
@@ -436,6 +436,8 @@ export function summarizeTelemetry(sessions) {
436
436
  compactionUsage: {},
437
437
  compactionReported: false,
438
438
  retries: 0,
439
+ cacheHits: 0,
440
+ repetitionHits: 0,
439
441
  outcomes: emptyOutcomes(),
440
442
  byTool: [],
441
443
  avgModelLatencyMs: null,
@@ -465,6 +467,14 @@ export function summarizeTelemetry(sessions) {
465
467
  agg.usageReported = true;
466
468
  }
467
469
  agg.retries += typeof t.retryCount === "number" ? t.retryCount : 0;
470
+ if (t.loop) {
471
+ if (typeof t.loop.cacheHits === "number" && t.loop.cacheHits > 0) {
472
+ agg.cacheHits += Math.floor(t.loop.cacheHits);
473
+ }
474
+ if (typeof t.loop.repetitionHits === "number" && t.loop.repetitionHits > 0) {
475
+ agg.repetitionHits += Math.floor(t.loop.repetitionHits);
476
+ }
477
+ }
468
478
  if (Array.isArray(t.modelCalls)) {
469
479
  for (const m of t.modelCalls) {
470
480
  agg.modelCalls += 1;
@@ -782,6 +792,51 @@ export class TelemetryRecorder {
782
792
  // never throw
783
793
  }
784
794
  }
795
+ // Attach the loop-harness per-turn rollup (LoopStats from the agent loop,
796
+ // wired via AgenticOpts.onLoopStats in the App). Merges onto the open turn;
797
+ // later reports overwrite (the loop reports once, but a retry-safe merge
798
+ // keeps the newest). No-op when disabled, unknown turn, or bad input.
799
+ // Never throws. Safe to call before endTurn (success path) — endTurn keeps
800
+ // turn.loop intact; failed/cancelled turns keep it too (what was attempted).
801
+ recordLoopStats(turnId, summary) {
802
+ try {
803
+ if (!this.enabled || !turnId)
804
+ return;
805
+ const turn = this.openTurns.get(turnId);
806
+ if (!turn)
807
+ return;
808
+ if (typeof summary !== "object" || summary === null)
809
+ return;
810
+ const s = summary;
811
+ const num = (v) => typeof v === "number" && Number.isFinite(v) && v >= 0 ? Math.floor(v) : undefined;
812
+ const signed = (v) => typeof v === "number" && Number.isFinite(v) ? Math.floor(v) : undefined;
813
+ const loop = {
814
+ cacheHits: num(s["cacheHits"]) ?? 0,
815
+ repetitionHits: num(s["repetitionHits"]) ?? 0,
816
+ failures: num(s["failures"]) ?? 0,
817
+ truncations: num(s["truncationNotices"] ?? s["truncations"]) ?? 0,
818
+ contextGrowthChars: signed(s["contextGrowthChars"]) ?? 0,
819
+ loopDurationMs: num(s["durationMs"] ?? s["loopDurationMs"]) ?? 0,
820
+ };
821
+ const bottleneck = s["bottleneck"];
822
+ const bName = typeof bottleneck?.["name"] === "string" ? bottleneck["name"] : undefined;
823
+ const bMs = num(bottleneck?.["durationMs"] ?? bottleneck?.["ms"] ?? s["bottleneckMs"]) ?? undefined;
824
+ if (bName && bName.length > 0) {
825
+ loop.bottleneckName = bName.slice(0, 80);
826
+ if (bMs !== undefined)
827
+ loop.bottleneckMs = bMs;
828
+ }
829
+ else if (typeof s["bottleneckName"] === "string" && s["bottleneckName"].length > 0) {
830
+ loop.bottleneckName = s["bottleneckName"].slice(0, 80);
831
+ if (bMs !== undefined)
832
+ loop.bottleneckMs = bMs;
833
+ }
834
+ turn.loop = loop;
835
+ }
836
+ catch {
837
+ // never throw
838
+ }
839
+ }
785
840
  upsertIteration(turn, step, modelCallId, toolCallId) {
786
841
  try {
787
842
  let iter = turn.iterations.find((i) => i.step === step);
@@ -0,0 +1,214 @@
1
+ // Fast directory enumeration for search tools (grep/glob).
2
+ //
3
+ // Problem (measured): every grep/glob recursively walked the repo and
4
+ // stated every entry — ~360ms per glob and ~2s per grep on a 3000-file
5
+ // tree, paid on EVERY call. Two fixes, both behavior-preserving:
6
+ //
7
+ // 1. git fast path: `git ls-files` (tracked) + `--others --exclude-standard`
8
+ // (untracked, non-ignored) lists the same tree without recursion and
9
+ // skips ignored build output. Verified: both spellings emit paths
10
+ // relative to the working directory they run in. Untracked source files
11
+ // ARE included (no "new file invisible" regression); SKIP_DIRS segments
12
+ // (node_modules/.git) are filtered after, so the "never searched"
13
+ // contract holds even for tracked junk. Any failure (non-git dir, no git
14
+ // binary, timeout) falls back to the recursive walker byte-identically.
15
+ // 2. mtime-checked listing cache + exact invalidation: a cached listing is
16
+ // reused only while the directory mtime is unchanged, AND every
17
+ // in-process mutation path invalidates (write/edit drop ancestor listings
18
+ // — nested creates don't move the parent mtime, so mtime alone is NOT
19
+ // enough; any bash execution clears all — a command can touch anything).
20
+ // Only out-of-process edits (user's editor between calls) stay TTL-bound
21
+ // (15s), documented and accepted: all tool-driven flows are exact.
22
+ //
23
+ // Deliberate non-goal: no ripgrep binary dependency. Measured `rg --files`
24
+ // spawn alone costs ~80ms on Windows — slower than the walker on typical
25
+ // repos — with regex-dialect and hidden/ignore parity risks. Revisit only if
26
+ // walker numbers stay slow after this (they don't — see benchmarks).
27
+ //
28
+ // Kill switch: ATOM_FAST_LIST=0 forces the legacy walker every time.
29
+ // Best-effort throughout; never throws across the tool boundary.
30
+ import { execFile } from "node:child_process";
31
+ import { promises as fsp } from "node:fs";
32
+ import * as path from "node:path";
33
+ import { SKIP_DIRS } from "./shared.js";
34
+ const LISTING_TTL_MS = 15_000;
35
+ const LISTING_MAX_ENTRIES = 50;
36
+ const GIT_TIMEOUT_MS = 15_000;
37
+ const listingCache = new Map();
38
+ const cacheStats = { hits: 0, misses: 0, stores: 0, gitUses: 0, walkerUses: 0 };
39
+ export function fastListEnabled() {
40
+ const raw = process.env.ATOM_FAST_LIST;
41
+ if (raw === undefined)
42
+ return true;
43
+ const v = raw.trim().toLowerCase();
44
+ return !(v === "0" || v === "false" || v === "no" || v === "off");
45
+ }
46
+ // Recursive walker (legacy contract): cwd-relative posix paths, SKIP_DIRS
47
+ // pruned, files only. Used directly when the fast path is off/unavailable.
48
+ export async function walkFiles(absDir, cwd, out) {
49
+ const entries = await fsp.readdir(absDir, { withFileTypes: true });
50
+ for (const e of entries) {
51
+ if (SKIP_DIRS.has(e.name))
52
+ continue;
53
+ const full = path.join(absDir, e.name);
54
+ if (e.isDirectory()) {
55
+ await walkFiles(full, cwd, out);
56
+ }
57
+ else if (e.isFile()) {
58
+ out.push(path.relative(cwd, full).split(path.sep).join("/"));
59
+ }
60
+ }
61
+ }
62
+ function gitFile(args, cwd) {
63
+ return new Promise((resolve, reject) => {
64
+ execFile("git", args, { cwd, timeout: GIT_TIMEOUT_MS, windowsHide: true }, (err, stdout) => {
65
+ if (err) {
66
+ reject(err instanceof Error ? err : new Error(String(err)));
67
+ return;
68
+ }
69
+ resolve(typeof stdout === "string" ? stdout : String(stdout ?? ""));
70
+ });
71
+ });
72
+ }
73
+ function toCwdRel(absDir, cwd, dirRel) {
74
+ const prefix = path.relative(cwd, absDir).split(path.sep).join("/");
75
+ if (!prefix || prefix === ".")
76
+ return dirRel;
77
+ return `${prefix}/${dirRel}`;
78
+ }
79
+ // Public for the ripgrep adapter: rg emits dir-relative paths, but the
80
+ // enumerated contract (and therefore outputs) is cwd-relative — which may
81
+ // climb out of the tree (`../../..`) when the search dir sits outside cwd.
82
+ // Exported so both paths share the one mapping (never duplicated logic).
83
+ export function rgRelToCwdRel(absDir, cwd, dirRel) {
84
+ return toCwdRel(absDir, cwd, dirRel);
85
+ }
86
+ function filterSkipped(relPaths) {
87
+ return relPaths.filter((rel) => {
88
+ if (!rel)
89
+ return false;
90
+ for (const seg of rel.split("/")) {
91
+ if (SKIP_DIRS.has(seg))
92
+ return false;
93
+ }
94
+ return true;
95
+ });
96
+ }
97
+ // Git enumeration: tracked + untracked-non-ignored in ONE invocation, as
98
+ // cwd-relative posix paths. Verified: both spellings emit paths relative to
99
+ // the directory git runs in. Outside a repo (or no git binary) the command
100
+ // fails and this returns null — the caller falls back to the walker.
101
+ async function gitListFiles(absDir, cwd) {
102
+ let raw;
103
+ try {
104
+ raw = await gitFile(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], absDir);
105
+ }
106
+ catch {
107
+ return null;
108
+ }
109
+ try {
110
+ const seen = new Set();
111
+ const out = [];
112
+ for (const entry of raw.split("\0")) {
113
+ if (!entry)
114
+ continue;
115
+ const rel = toCwdRel(absDir, cwd, entry.split(path.sep).join("/"));
116
+ if (!seen.has(rel)) {
117
+ seen.add(rel);
118
+ out.push(rel);
119
+ }
120
+ }
121
+ return filterSkipped(out);
122
+ }
123
+ catch {
124
+ return null;
125
+ }
126
+ }
127
+ async function dirMtimeMs(absDir) {
128
+ try {
129
+ return (await fsp.stat(absDir)).mtimeMs;
130
+ }
131
+ catch {
132
+ return null;
133
+ }
134
+ }
135
+ // List files under absDir as cwd-relative posix paths (walkFiles contract).
136
+ // Fast path: mtime-validated cache → git enumeration → walker fallback.
137
+ // Walker results cache too (the mtime check is equally valid for them).
138
+ export async function listFiles(absDir, cwd) {
139
+ const key = `${cwd}\n${absDir}`;
140
+ if (fastListEnabled()) {
141
+ const mtime = await dirMtimeMs(absDir);
142
+ const hit = listingCache.get(key);
143
+ if (hit && mtime !== null && hit.mtimeMs === mtime && Date.now() - hit.storedAt < LISTING_TTL_MS) {
144
+ // LRU refresh.
145
+ listingCache.delete(key);
146
+ listingCache.set(key, hit);
147
+ cacheStats.hits += 1;
148
+ return [...hit.entries];
149
+ }
150
+ cacheStats.misses += 1;
151
+ const git = await gitListFiles(absDir, cwd);
152
+ if (git !== null) {
153
+ cacheStats.gitUses += 1;
154
+ storeListing(key, git, mtime);
155
+ return [...git];
156
+ }
157
+ }
158
+ cacheStats.walkerUses += 1;
159
+ const out = [];
160
+ await walkFiles(absDir, cwd, out);
161
+ if (fastListEnabled()) {
162
+ storeListing(key, out, await dirMtimeMs(absDir));
163
+ }
164
+ return out;
165
+ }
166
+ function storeListing(key, entries, mtimeMs) {
167
+ try {
168
+ listingCache.delete(key);
169
+ while (listingCache.size >= LISTING_MAX_ENTRIES) {
170
+ const oldest = listingCache.keys().next();
171
+ if (oldest.done)
172
+ break;
173
+ listingCache.delete(oldest.value);
174
+ }
175
+ listingCache.set(key, { entries: [...entries], mtimeMs: mtimeMs ?? -1, storedAt: Date.now() });
176
+ cacheStats.stores += 1;
177
+ }
178
+ catch {
179
+ // cache failures never break search
180
+ }
181
+ }
182
+ export function clearDirListingCache() {
183
+ listingCache.clear();
184
+ }
185
+ // Drop every listing whose directory is the file itself or an ancestor of it
186
+ // (a mutation inside the tree can change the listing). Called by write/edit
187
+ // next to the read-cache invalidation. Never throws.
188
+ export function invalidateListingsForFile(absFilePath) {
189
+ try {
190
+ if (typeof absFilePath !== "string" || absFilePath.length === 0)
191
+ return;
192
+ const target = path.resolve(absFilePath);
193
+ for (const key of [...listingCache.keys()]) {
194
+ const splitAt = key.indexOf("\n");
195
+ const absDir = splitAt >= 0 ? key.slice(splitAt + 1) : key;
196
+ if (target === absDir || target.startsWith(absDir + path.sep)) {
197
+ listingCache.delete(key);
198
+ }
199
+ }
200
+ }
201
+ catch {
202
+ // never throw across the tool boundary
203
+ }
204
+ }
205
+ export function getDirListingStats() {
206
+ return { ...cacheStats, size: listingCache.size };
207
+ }
208
+ export function resetDirListingStats() {
209
+ cacheStats.hits = 0;
210
+ cacheStats.misses = 0;
211
+ cacheStats.stores = 0;
212
+ cacheStats.gitUses = 0;
213
+ cacheStats.walkerUses = 0;
214
+ }
@@ -5,7 +5,9 @@ import * as path from "node:path";
5
5
  import { capturePriorBytes } from "../snapshots.js";
6
6
  import { contentHash, fingerprintKey, readFingerprints } from "./fingerprints.js";
7
7
  import { appendOverflow } from "./overflow.js";
8
- import { err, invalidCall, READ_CHAR_CAP, resolveSandbox } from "./shared.js";
8
+ import { getCachedRead, invalidatePath, normalizeReadWindow, setCachedRead } from "./read-cache.js";
9
+ import { invalidateListingsForFile } from "./dir-cache.js";
10
+ import { err, invalidCall, READ_CHAR_CAP, resolveSandbox, truncateHead } from "./shared.js";
9
11
  // offset/limit are 1-based line numbers. Output capped at ~64KB.
10
12
  export async function readTool(args, cwd = process.cwd()) {
11
13
  try {
@@ -24,6 +26,21 @@ export async function readTool(args, cwd = process.cwd()) {
24
26
  const lines = entries.map((e) => (e.isDirectory() ? `${e.name}/` : e.name));
25
27
  return `Directory listing for ${args.path}:\n${lines.join("\n")}`;
26
28
  }
29
+ // Read-cache fast path: same abs + window + unchanged mtime/size skips
30
+ // disk I/O. The stored hash refreshes the stale-read fingerprint so
31
+ // read→read→edit chains keep working without re-hashing.
32
+ const { offset: normOffset, limit: normLimit } = normalizeReadWindow(args.offset, args.limit);
33
+ try {
34
+ const statInfo = { mtimeMs: st.mtimeMs ?? 0, size: st.size ?? 0 };
35
+ const hit = getCachedRead(r.abs, normOffset, normLimit, statInfo);
36
+ if (hit) {
37
+ readFingerprints.set(fingerprintKey(r.abs), hit.hash);
38
+ return hit.result;
39
+ }
40
+ }
41
+ catch {
42
+ // cache lookup never breaks reads
43
+ }
27
44
  let text;
28
45
  try {
29
46
  text = await fsp.readFile(r.abs, "utf8");
@@ -31,7 +48,8 @@ export async function readTool(args, cwd = process.cwd()) {
31
48
  catch {
32
49
  return err(`cannot read file: ${args.path}`);
33
50
  }
34
- readFingerprints.set(fingerprintKey(r.abs), contentHash(text));
51
+ const hash = contentHash(text);
52
+ readFingerprints.set(fingerprintKey(r.abs), hash);
35
53
  if (text.length === 0)
36
54
  return "";
37
55
  const offset = Math.max(1, Math.floor(args.offset ?? 1));
@@ -40,7 +58,15 @@ export async function readTool(args, cwd = process.cwd()) {
40
58
  let out = window.map((line, i) => `${offset + i}: ${line}`).join("\n");
41
59
  if (out.length > READ_CHAR_CAP) {
42
60
  const full = out;
43
- out = appendOverflow(full.slice(0, READ_CHAR_CAP), "\n[truncated: output exceeded 64KB]", "file output", full);
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
44
70
  }
45
71
  return out;
46
72
  }
@@ -61,6 +87,13 @@ export async function writeTool(args, cwd = process.cwd()) {
61
87
  await fsp.mkdir(path.dirname(r.abs), { recursive: true });
62
88
  await fsp.writeFile(r.abs, args.content, "utf8");
63
89
  readFingerprints.set(fingerprintKey(r.abs), contentHash(args.content));
90
+ try {
91
+ invalidatePath(r.abs);
92
+ invalidateListingsForFile(r.abs);
93
+ }
94
+ catch {
95
+ // cache invalidation never breaks writes
96
+ }
64
97
  return `Wrote ${Buffer.byteLength(args.content, "utf8")} bytes to ${args.path}`;
65
98
  }
66
99
  catch (e) {
@@ -102,6 +135,13 @@ export async function editTool(args, cwd = process.cwd()) {
102
135
  await capturePriorBytes(r.abs, `edit ${args.path}`);
103
136
  await fsp.writeFile(r.abs, next, "utf8");
104
137
  readFingerprints.set(key, contentHash(next));
138
+ try {
139
+ invalidatePath(r.abs);
140
+ invalidateListingsForFile(r.abs);
141
+ }
142
+ catch {
143
+ // cache invalidation never breaks edits
144
+ }
105
145
  return `Edited ${args.path}: replaced ${args.replaceAll ? count : 1} occurrence(s)`;
106
146
  }
107
147
  catch (e) {
@@ -0,0 +1,160 @@
1
+ // Read-through cache for file reads: repeated `read` calls for the same
2
+ // path+window skip disk I/O when the file hasn't changed. Correctness first:
3
+ //
4
+ // - Key: absolute path + offset + limit (different windows are different keys).
5
+ // - Validation: file mtimeMs + size checked on every hit (one stat, cheap).
6
+ // A mismatch → miss → re-read + re-store. External edits can never serve
7
+ // stale bytes beyond a stat race.
8
+ // - Invalidation: write/edit/delete paths call invalidatePath(abs) — the
9
+ // write/edit executors do this, so read→write→read chains never go stale.
10
+ // A global version bump (invalidateAll) covers tree-wide mutations.
11
+ // - Scope: successes only (errors never cache — a transient ENOENT must not
12
+ // poison later reads). Directory listings never cache (readdir is already
13
+ // cheap and highly mutable).
14
+ // - Bounds: LRU cap (default 100 entries) + TTL (default 30s). Disable with
15
+ // ATOM_READ_CACHE=0. All best-effort, never throws.
16
+ //
17
+ // Measurable win: explore loops re-read the same files (read after grep,
18
+ // re-read before edit, parallel batches reading shared context). Each hit
19
+ // saves a full readFile + split + 64KB-format pass.
20
+ //
21
+ // Stats (for loop instrumentation + tests): hits, misses, stores, invalidations.
22
+ import * as path from "node:path";
23
+ const DEFAULT_MAX_ENTRIES = 100;
24
+ const DEFAULT_TTL_MS = 30_000;
25
+ const cache = new Map();
26
+ const stats = { hits: 0, misses: 0, stores: 0, invalidations: 0, size: 0 };
27
+ function cacheEnabled() {
28
+ const raw = process.env.ATOM_READ_CACHE;
29
+ if (raw === undefined)
30
+ return true;
31
+ const v = raw.trim().toLowerCase();
32
+ return !(v === "0" || v === "false" || v === "no" || v === "off");
33
+ }
34
+ function ttlMs() {
35
+ const raw = process.env.ATOM_READ_CACHE_TTL_MS;
36
+ if (raw !== undefined) {
37
+ const n = Number(raw.trim());
38
+ if (Number.isFinite(n) && n > 0)
39
+ return Math.min(Math.floor(n), 300_000);
40
+ }
41
+ return DEFAULT_TTL_MS;
42
+ }
43
+ function maxEntries() {
44
+ const raw = process.env.ATOM_READ_CACHE_MAX;
45
+ if (raw !== undefined) {
46
+ const n = Number(raw.trim());
47
+ if (Number.isFinite(n) && n > 0)
48
+ return Math.min(Math.floor(n), 1000);
49
+ }
50
+ return DEFAULT_MAX_ENTRIES;
51
+ }
52
+ export function readCacheKey(abs, offset, limit) {
53
+ return `${abs}\n${offset}\n${limit}`;
54
+ }
55
+ export function normalizeReadWindow(offset, limit) {
56
+ const o = typeof offset === "number" && Number.isFinite(offset) ? Math.max(1, Math.floor(offset)) : 1;
57
+ const l = typeof limit === "number" && Number.isFinite(limit)
58
+ ? Math.max(1, Math.floor(limit))
59
+ : Number.MAX_SAFE_INTEGER;
60
+ return { offset: o, limit: l };
61
+ }
62
+ // Lookup by absolute path + window. `stat` must be the fresh
63
+ // {mtimeMs, size} of the file (the caller already stats before reading).
64
+ // Returns the cached {result, hash} on hit, null on miss. Never throws.
65
+ export function getCachedRead(abs, offset, limit, stat) {
66
+ if (!cacheEnabled()) {
67
+ stats.misses += 1;
68
+ return null;
69
+ }
70
+ const key = readCacheKey(abs, offset, limit);
71
+ const entry = cache.get(key);
72
+ if (!entry) {
73
+ stats.misses += 1;
74
+ return null;
75
+ }
76
+ if (Date.now() - entry.storedAt > ttlMs()) {
77
+ cache.delete(key);
78
+ stats.size = cache.size;
79
+ stats.misses += 1;
80
+ return null;
81
+ }
82
+ if (entry.mtimeMs !== stat.mtimeMs || entry.size !== stat.size) {
83
+ cache.delete(key);
84
+ stats.size = cache.size;
85
+ stats.misses += 1;
86
+ return null;
87
+ }
88
+ // LRU refresh: re-insert so the oldest key stays evictable first.
89
+ cache.delete(key);
90
+ cache.set(key, entry);
91
+ stats.hits += 1;
92
+ return { result: entry.result, hash: entry.hash };
93
+ }
94
+ // Store a successful file-read result. Never throws; evicts oldest first.
95
+ export function setCachedRead(abs, offset, limit, result, stat, hash) {
96
+ try {
97
+ if (!cacheEnabled() || typeof result !== "string")
98
+ return;
99
+ const key = readCacheKey(abs, offset, limit);
100
+ cache.delete(key);
101
+ while (cache.size >= maxEntries()) {
102
+ const oldest = cache.keys().next();
103
+ if (oldest.done)
104
+ break;
105
+ cache.delete(oldest.value);
106
+ }
107
+ cache.set(key, { result, hash, mtimeMs: stat.mtimeMs, size: stat.size, storedAt: Date.now() });
108
+ stats.size = cache.size;
109
+ stats.stores += 1;
110
+ }
111
+ catch {
112
+ // cache failures never break reads
113
+ }
114
+ }
115
+ // Drop every entry for one absolute path (all windows). Called by
116
+ // write/edit/delete paths. Never throws.
117
+ export function invalidatePath(abs) {
118
+ try {
119
+ const prefix = `${abs}\n`;
120
+ let dropped = 0;
121
+ for (const key of [...cache.keys()]) {
122
+ if (key === abs || key.startsWith(prefix)) {
123
+ cache.delete(key);
124
+ dropped += 1;
125
+ }
126
+ }
127
+ // Also match callers that pass a relative display path: compare resolved
128
+ // basenames as a fallback so nothing obviously stale survives.
129
+ if (dropped === 0 && typeof abs === "string") {
130
+ const resolved = path.resolve(abs);
131
+ const resolvedPrefix = `${resolved}\n`;
132
+ for (const key of [...cache.keys()]) {
133
+ if (key === resolved || key.startsWith(resolvedPrefix)) {
134
+ cache.delete(key);
135
+ dropped += 1;
136
+ }
137
+ }
138
+ }
139
+ if (dropped > 0)
140
+ stats.invalidations += dropped;
141
+ stats.size = cache.size;
142
+ }
143
+ catch {
144
+ // never throw across the tool boundary
145
+ }
146
+ }
147
+ export function clearReadCache() {
148
+ cache.clear();
149
+ stats.size = 0;
150
+ }
151
+ export function getReadCacheStats() {
152
+ return { ...stats, size: cache.size };
153
+ }
154
+ export function resetReadCacheStats() {
155
+ stats.hits = 0;
156
+ stats.misses = 0;
157
+ stats.stores = 0;
158
+ stats.invalidations = 0;
159
+ stats.size = cache.size;
160
+ }
@@ -375,6 +375,85 @@ export function describeToolCall(name, args) {
375
375
  return `⚙ ${name}`;
376
376
  }
377
377
  }
378
+ // Shared byte cap for approve-time file reads (preview + BEFORE capture).
379
+ export const APPROVAL_PREVIEW_MAX_BYTES = 1_000_000;
380
+ // Extension → highlight family (see ui/highlight.ts). Conservative: only
381
+ // extensions we are confident about; everything else stays null (plain).
382
+ const PREVIEW_LANG_BY_EXT = {
383
+ ts: "c",
384
+ tsx: "c",
385
+ mts: "c",
386
+ cts: "c",
387
+ js: "c",
388
+ jsx: "c",
389
+ mjs: "c",
390
+ cjs: "c",
391
+ go: "c",
392
+ rs: "c",
393
+ java: "c",
394
+ c: "c",
395
+ h: "c",
396
+ hh: "c",
397
+ cc: "c",
398
+ cpp: "c",
399
+ hpp: "c",
400
+ cs: "c",
401
+ swift: "c",
402
+ kt: "c",
403
+ kts: "c",
404
+ php: "c",
405
+ py: "py",
406
+ pyi: "py",
407
+ rb: "py",
408
+ sh: "sh",
409
+ bash: "sh",
410
+ zsh: "sh",
411
+ json: "data",
412
+ jsonc: "data",
413
+ yaml: "data",
414
+ yml: "data",
415
+ toml: "data",
416
+ };
417
+ export function previewLangFromPath(p) {
418
+ const base = p.split(/[\\/]/).pop() ?? p;
419
+ const dot = base.lastIndexOf(".");
420
+ if (dot <= 0 || dot === base.length - 1)
421
+ return null;
422
+ return PREVIEW_LANG_BY_EXT[base.slice(dot + 1).toLowerCase()] ?? null;
423
+ }
424
+ export function previewDiffForApproval(name, args, cwd = process.cwd()) {
425
+ try {
426
+ if (name === "edit") {
427
+ const a = args;
428
+ if (typeof a.oldString !== "string" || typeof a.newString !== "string")
429
+ return null;
430
+ const p = typeof a.path === "string" ? a.path : null;
431
+ return { oldText: a.oldString, newText: a.newString, lang: p ? previewLangFromPath(p) : null, path: p };
432
+ }
433
+ if (name === "write") {
434
+ const a = args;
435
+ if (typeof a.content !== "string" || typeof a.path !== "string" || a.path.length === 0) {
436
+ return null;
437
+ }
438
+ let oldText = null;
439
+ try {
440
+ const abs = path.resolve(cwd, a.path);
441
+ const st = fs.statSync(abs);
442
+ if (st.isFile() && st.size <= APPROVAL_PREVIEW_MAX_BYTES) {
443
+ oldText = fs.readFileSync(abs, "utf8");
444
+ }
445
+ }
446
+ catch {
447
+ oldText = null;
448
+ }
449
+ return { oldText, newText: a.content, lang: previewLangFromPath(a.path), path: a.path };
450
+ }
451
+ return null;
452
+ }
453
+ catch {
454
+ return null;
455
+ }
456
+ }
378
457
  // OpenAI-style function schemas sent as `tools` on the chat POST.
379
458
  export const TOOL_DEFINITIONS = [
380
459
  {
@@ -495,6 +574,7 @@ export const TOOL_DEFINITIONS = [
495
574
  "runInBackground=true for servers/watchers/slow builds, then poll with bash_output. " +
496
575
  "WHEN NOT to use: never for reading/writing/searching files; never destructive or exfiltrating without explicit user approval; " +
497
576
  "don't assume a TTY. " +
577
+ "Shell is cmd.exe on Windows (use dir; quote paths containing spaces) and POSIX sh elsewhere — the env block names it; never probe with ls/pwd/whoami. " +
498
578
  "Foreground returns JSON {exitCode, stdout, stderr, timedOut, ...} (streams truncate with pointers). " +
499
579
  "Background returns {backgroundTaskId, ...} immediately; the process keeps running detached. " +
500
580
  "PRIVILEGED: no sandbox beyond cwd+timeout.",