@giovannijecha/jecode 0.8.4 → 0.8.5

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 (53) hide show
  1. package/README.md +8 -6
  2. package/dist/accounts.js +47 -10
  3. package/dist/atomic.js +24 -14
  4. package/dist/batch.js +37 -4
  5. package/dist/bounded-file.js +212 -0
  6. package/dist/commands.js +2 -0
  7. package/dist/config.js +2 -1
  8. package/dist/context/automatic.js +35 -0
  9. package/dist/context/compactor.js +32 -2
  10. package/dist/context/manual.js +20 -3
  11. package/dist/context/request-projection.js +130 -0
  12. package/dist/controller-request.js +33 -11
  13. package/dist/credential-commands.js +22 -6
  14. package/dist/credentials.js +56 -18
  15. package/dist/directory-anchor.js +91 -0
  16. package/dist/file-identity.js +12 -0
  17. package/dist/model-command.js +5 -4
  18. package/dist/openai-account-command.js +13 -2
  19. package/dist/process-lease.js +329 -0
  20. package/dist/provider-commands.js +11 -5
  21. package/dist/provider-errors.js +37 -4
  22. package/dist/provider-label.js +13 -0
  23. package/dist/providers/anthropic-stream.js +4 -1
  24. package/dist/providers/anthropic-wire.js +8 -3
  25. package/dist/providers/anthropic.js +39 -19
  26. package/dist/providers/catalog.js +4 -4
  27. package/dist/providers/failure.js +181 -0
  28. package/dist/providers/http.js +82 -23
  29. package/dist/providers/ollama-stream.js +5 -1
  30. package/dist/providers/ollama.js +31 -19
  31. package/dist/providers/openai-codex.js +67 -41
  32. package/dist/providers/openai-stream.js +26 -2
  33. package/dist/providers/openai.js +51 -24
  34. package/dist/providers/sse.js +52 -8
  35. package/dist/request-identity.js +32 -0
  36. package/dist/sessions/lease.js +132 -49
  37. package/dist/sessions/runtime.js +15 -8
  38. package/dist/sessions/store.js +366 -142
  39. package/dist/settings.js +62 -10
  40. package/dist/stable-directory.js +148 -0
  41. package/dist/store-lock.js +68 -84
  42. package/dist/tools/args.js +2 -2
  43. package/dist/tools/fs.js +124 -102
  44. package/dist/tools/search.js +65 -100
  45. package/dist/tools/text-boundary.js +7 -33
  46. package/dist/tui/app-workflows.js +32 -4
  47. package/dist/tui/components/footer.js +1 -1
  48. package/dist/tui/feedback.js +4 -0
  49. package/dist/tui/session-view.js +7 -2
  50. package/dist/tui/workspace.js +21 -7
  51. package/dist/user-store.js +23 -31
  52. package/package.json +3 -4
  53. package/dist/tools/ripgrep.js +0 -230
package/dist/tools/fs.js CHANGED
@@ -1,7 +1,8 @@
1
1
  // Filesystem tools: read, list, write, edit.
2
- import { constants } from "node:fs";
3
2
  import * as fs from "node:fs/promises";
4
3
  import * as path from "node:path";
4
+ import { withStableFile } from "../bounded-file.js";
5
+ import { readStableDirectory } from "../stable-directory.js";
5
6
  import { optionalBool, optionalInt, optionalString, requireString } from "./args.js";
6
7
  import { assertDirectWritableInRoot, displayPath, resolveDirectWritableInRoot, resolveExistingInRoot, } from "./paths.js";
7
8
  import { assertEditableText, assertReplacementFits, leadingText, MAX_EDITABLE_CHARS, MAX_EDITABLE_LINES, readEditableText, } from "./text-boundary.js";
@@ -10,6 +11,7 @@ const MAX_READ_CHARS = 60_000;
10
11
  const MAX_LIST_CHARS = 60_000;
11
12
  const MAX_LIST_ENTRIES = 2_000;
12
13
  const READ_CHUNK_BYTES = 64 * 1024;
14
+ const MAX_READ_SCAN_BYTES = 16 * 1024 * 1024;
13
15
  const DEFAULT_MUTATION_DEPENDENCIES = { atomicWrite };
14
16
  export const readFile = {
15
17
  name: "read_file",
@@ -27,22 +29,32 @@ export const readFile = {
27
29
  required: ["path"],
28
30
  },
29
31
  async run(args, ctx) {
30
- const root = await resolveExistingInRoot(ctx.root, ".");
31
- const target = await resolveExistingInRoot(root, requireString(args, "path"));
32
- const offset = optionalInt(args, "offset");
33
- const limit = optionalInt(args, "limit");
34
- const { text, truncated } = await readRange(target, offset, limit, ctx.signal);
35
- if (truncated) {
36
- return {
37
- output: `${text}\n\n[truncated at ${MAX_READ_CHARS} characters — read a narrower range]`,
38
- summary: `truncated at ${MAX_READ_CHARS} characters`,
39
- };
40
- }
41
- if (text === "")
42
- return { output: "[file is empty]", summary: "empty" };
43
- return { output: text, summary: `${count(text, "line")}` };
32
+ return runReadFile(args, ctx);
44
33
  },
45
34
  };
35
+ export async function runReadFile(args, ctx, dependencies = {}) {
36
+ const root = await resolveExistingInRoot(ctx.root, ".");
37
+ const target = await resolveExistingInRoot(root, requireString(args, "path"));
38
+ const offset = optionalInt(args, "offset");
39
+ const limit = optionalInt(args, "limit");
40
+ const { text, truncated, scanCapped } = await readRange(root, target, offset, limit, ctx.signal, dependencies);
41
+ if (truncated) {
42
+ return {
43
+ output: `${text}\n\n[truncated at ${MAX_READ_CHARS} characters — read a narrower range]`,
44
+ summary: `truncated at ${MAX_READ_CHARS} characters`,
45
+ };
46
+ }
47
+ if (scanCapped) {
48
+ const notice = `[read stopped after scanning ${MAX_READ_SCAN_BYTES} bytes — use a smaller offset or search_text]`;
49
+ return {
50
+ output: text === "" ? notice : `${text}\n\n${notice}`,
51
+ summary: `scan capped at ${MAX_READ_SCAN_BYTES} bytes`,
52
+ };
53
+ }
54
+ if (text === "")
55
+ return { output: "[file is empty]", summary: "empty" };
56
+ return { output: text, summary: `${count(text, "line")}` };
57
+ }
46
58
  export const listDir = {
47
59
  name: "list_dir",
48
60
  description: "List the entries of a directory inside the workspace. Directories end with a slash.",
@@ -56,39 +68,44 @@ export const listDir = {
56
68
  required: [],
57
69
  },
58
70
  async run(args, ctx) {
59
- const root = await resolveExistingInRoot(ctx.root, ".");
60
- const requested = optionalString(args, "path");
61
- const relative = requested === undefined || requested.trim() === "" ? "." : requested;
62
- const target = await resolveExistingInRoot(root, relative);
63
- const entries = [];
64
- let chars = 0;
65
- let truncated = false;
66
- const directory = await fs.opendir(target);
67
- for await (const entry of directory) {
68
- const label = entry.isDirectory() ? `${entry.name}/` : entry.name;
69
- const separator = entries.length === 0 ? 0 : 1;
70
- if (entries.length >= MAX_LIST_ENTRIES ||
71
- chars + separator + label.length > MAX_LIST_CHARS) {
72
- truncated = true;
73
- break;
74
- }
75
- entries.push(label);
76
- chars += separator + label.length;
77
- }
78
- if (entries.length === 0)
79
- return { output: "[empty directory]", summary: "empty" };
80
- const listing = entries
81
- .sort((a, b) => a.localeCompare(b))
82
- .join("\n");
83
- if (truncated) {
84
- return {
85
- output: `${listing}\n\n[truncated after ${entries.length} entries]`,
86
- summary: `${entries.length}+ entries`,
87
- };
88
- }
89
- return { output: listing, summary: plural(entries.length, "entry", "entries") };
71
+ return runListDir(args, ctx);
90
72
  },
91
73
  };
74
+ export async function runListDir(args, ctx, dependencies = {}) {
75
+ const root = await resolveExistingInRoot(ctx.root, ".");
76
+ const requested = optionalString(args, "path");
77
+ const relative = requested === undefined || requested.trim() === "" ? "." : requested;
78
+ const target = await resolveExistingInRoot(root, relative);
79
+ const inspected = await readStableDirectory(root, target, {
80
+ maxEntries: MAX_LIST_ENTRIES + 1,
81
+ signal: ctx.signal,
82
+ beforeOpen: dependencies.beforeOpen,
83
+ });
84
+ const entries = [];
85
+ let chars = 0;
86
+ let truncated = inspected.capped;
87
+ for (const entry of inspected.entries) {
88
+ const label = entry.kind === "directory" ? `${entry.name}/` : entry.name;
89
+ const separator = entries.length === 0 ? 0 : 1;
90
+ if (entries.length >= MAX_LIST_ENTRIES ||
91
+ chars + separator + label.length > MAX_LIST_CHARS) {
92
+ truncated = true;
93
+ break;
94
+ }
95
+ entries.push(label);
96
+ chars += separator + label.length;
97
+ }
98
+ if (entries.length === 0)
99
+ return { output: "[empty directory]", summary: "empty" };
100
+ const listing = entries.join("\n");
101
+ if (truncated) {
102
+ return {
103
+ output: `${listing}\n\n[truncated after ${entries.length} entries]`,
104
+ summary: `${entries.length}+ entries`,
105
+ };
106
+ }
107
+ return { output: listing, summary: plural(entries.length, "entry", "entries") };
108
+ }
92
109
  export const writeFile = {
93
110
  name: "write_file",
94
111
  description: "Create a file, or replace its entire contents. Parent directories are " +
@@ -204,81 +221,86 @@ export async function runEditFile(args, ctx, dependencies = DEFAULT_MUTATION_DEP
204
221
  summary: plural(made, "replacement", "replacements"),
205
222
  };
206
223
  }
207
- async function readRange(target, offset, limit, signal) {
224
+ async function readRange(root, target, offset, limit, signal, dependencies) {
208
225
  throwIfAborted(signal);
209
226
  const firstLine = Math.max(1, offset ?? 1);
210
227
  const lineCount = limit === undefined ? undefined : Math.max(0, limit);
211
228
  const endLine = lineCount === undefined ? Number.POSITIVE_INFINITY : firstLine + lineCount;
212
229
  if (lineCount === 0)
213
- return { text: "", truncated: false };
214
- if (!(await fs.lstat(target)).isFile())
215
- throw new Error("path must be a regular file");
216
- // O_NONBLOCK prevents a path swapped to a FIFO between lstat and open from
217
- // waiting forever for a writer. The handle stat closes that race before any
218
- // content is accepted.
219
- const flags = process.platform === "win32"
220
- ? "r"
221
- : constants.O_RDONLY | (constants.O_NONBLOCK ?? 0);
222
- const handle = await fs.open(target, flags);
223
- const decoder = new TextDecoder();
224
- let text = "";
225
- let line = 1;
226
- let stopped = false;
227
- let truncated = false;
228
- const selected = (at) => at >= firstLine && at < endLine;
229
- const append = (fragment) => {
230
- if (fragment === "")
231
- return;
232
- const room = MAX_READ_CHARS - text.length;
233
- if (fragment.length > room) {
234
- text += leadingText(fragment, room);
235
- truncated = true;
236
- stopped = true;
237
- return;
238
- }
239
- text += fragment;
240
- };
241
- const consume = (chunk) => {
242
- let start = 0;
243
- while (!stopped && start < chunk.length) {
244
- const newline = chunk.indexOf("\n", start);
245
- const end = newline === -1 ? chunk.length : newline;
246
- if (selected(line))
247
- append(chunk.slice(start, end));
248
- if (stopped || newline === -1)
230
+ return { text: "", truncated: false, scanCapped: false };
231
+ const result = await withStableFile(target, {
232
+ label: "read file",
233
+ signal,
234
+ beforeOpen: dependencies.beforeOpen,
235
+ }, async (handle, opened) => {
236
+ const decoder = new TextDecoder();
237
+ let text = "";
238
+ let line = 1;
239
+ let stopped = false;
240
+ let truncated = false;
241
+ let reachedEnd = false;
242
+ const selected = (at) => at >= firstLine && at < endLine;
243
+ const append = (fragment) => {
244
+ if (fragment === "")
249
245
  return;
250
- const nextLine = line + 1;
251
- if (selected(line) && selected(nextLine))
252
- append("\n");
253
- line = nextLine;
254
- if (line >= endLine) {
246
+ const room = MAX_READ_CHARS - text.length;
247
+ if (fragment.length > room) {
248
+ text += leadingText(fragment, room);
249
+ truncated = true;
255
250
  stopped = true;
256
251
  return;
257
252
  }
258
- start = newline + 1;
259
- }
260
- };
261
- try {
262
- if (!(await handle.stat()).isFile())
263
- throw new Error("path must be a regular file");
253
+ text += fragment;
254
+ };
255
+ const consume = (chunk) => {
256
+ let start = 0;
257
+ while (!stopped && start < chunk.length) {
258
+ const newline = chunk.indexOf("\n", start);
259
+ const end = newline === -1 ? chunk.length : newline;
260
+ if (selected(line))
261
+ append(chunk.slice(start, end));
262
+ if (stopped || newline === -1)
263
+ return;
264
+ const nextLine = line + 1;
265
+ if (selected(line) && selected(nextLine))
266
+ append("\n");
267
+ line = nextLine;
268
+ if (line >= endLine) {
269
+ stopped = true;
270
+ return;
271
+ }
272
+ start = newline + 1;
273
+ }
274
+ };
264
275
  let position = 0;
265
276
  const buffer = Buffer.allocUnsafe(READ_CHUNK_BYTES);
266
- while (!stopped) {
277
+ while (!stopped && position < MAX_READ_SCAN_BYTES) {
267
278
  throwIfAborted(signal);
268
- const { bytesRead } = await handle.read(buffer, 0, buffer.length, position);
279
+ const length = Math.min(buffer.length, MAX_READ_SCAN_BYTES - position);
280
+ const { bytesRead } = await handle.read(buffer, 0, length, position);
269
281
  throwIfAborted(signal);
270
- if (bytesRead === 0)
282
+ if (bytesRead === 0) {
283
+ reachedEnd = true;
271
284
  break;
285
+ }
272
286
  position += bytesRead;
273
287
  consume(decoder.decode(buffer.subarray(0, bytesRead), { stream: true }));
274
288
  }
275
- if (!stopped)
289
+ if (!stopped && BigInt(position) >= opened.size)
290
+ reachedEnd = true;
291
+ if (!stopped && reachedEnd)
276
292
  consume(decoder.decode());
293
+ return {
294
+ text,
295
+ truncated,
296
+ scanCapped: !stopped && !reachedEnd && position >= MAX_READ_SCAN_BYTES,
297
+ };
298
+ });
299
+ const confirmed = await resolveExistingInRoot(root, target);
300
+ if (path.relative(target, confirmed) !== "") {
301
+ throw new Error("read file changed while it was being read");
277
302
  }
278
- finally {
279
- await handle.close();
280
- }
281
- return { text, truncated };
303
+ return result;
282
304
  }
283
305
  function throwIfAborted(signal) {
284
306
  if (signal?.aborted !== true)
@@ -1,9 +1,10 @@
1
1
  // Bounded, read-only workspace discovery without borrowing a shell.
2
2
  import * as fs from "node:fs/promises";
3
3
  import * as path from "node:path";
4
+ import { BoundedFileError, readBoundedFile } from "../bounded-file.js";
5
+ import { readStableDirectory, StableDirectoryError, } from "../stable-directory.js";
4
6
  import { optionalBool, optionalInt, optionalString, requireString } from "./args.js";
5
7
  import { displayPath, resolveExistingInRoot } from "./paths.js";
6
- import { trySearchWithRipgrep } from "./ripgrep.js";
7
8
  import { leadingText } from "./text-boundary.js";
8
9
  const DEFAULT_RESULTS = 100;
9
10
  const MAX_RESULTS = 500;
@@ -12,10 +13,6 @@ const MAX_FILE_BYTES = 1_000_000;
12
13
  const MAX_MATCH_LINE = 500;
13
14
  const MAX_GLOB_CHARS = 512;
14
15
  const PORTABLE_SEARCH_CONCURRENCY = 8;
15
- const RG_PREFIX_BYTES = 2_000_000;
16
- const RG_PREFIX_FILES = 500;
17
- const MIN_RG_TAIL_BYTES = 2_000_000;
18
- const MIN_RG_TAIL_FILES = 500;
19
16
  const SKIP = new Set([".git", ".hg", ".svn", "node_modules"]);
20
17
  export const findFiles = {
21
18
  name: "find_files",
@@ -84,80 +81,30 @@ export const searchText = {
84
81
  const match = pattern === undefined || pattern === "" ? () => true : glob(pattern);
85
82
  const limit = resultLimit(args);
86
83
  const found = [];
87
- const candidates = [];
88
- const prefix = [];
89
- const tail = [];
84
+ const pending = [];
90
85
  let skipped = 0;
91
- let prefixBytes = 0;
92
- let prefixFiles = 0;
93
- let tailBytes = 0;
94
- const flushPrefix = async () => {
95
- if (prefix.length === 0)
86
+ const flushPending = async () => {
87
+ if (pending.length === 0)
96
88
  return false;
97
- const searched = await portableSearch(prefix.splice(0), scoped, needle, sensitive, limit - found.length);
89
+ const searched = await portableSearch(pending.splice(0), scoped, needle, sensitive, limit - found.length);
98
90
  found.push(...searched.matches);
99
91
  skipped += searched.skipped;
100
92
  return found.length >= limit;
101
93
  };
102
- const flushCandidates = async () => {
103
- if (candidates.length === 0)
104
- return false;
105
- const inspected = await Promise.all(candidates.splice(0).map((lexical) => inspectCandidate(scoped.root, lexical)));
106
- checkAbort(ctx.signal);
107
- for (const result of inspected) {
108
- if ("error" in result) {
109
- if (!skippable(result.error))
110
- throw result.error;
111
- skipped++;
112
- continue;
113
- }
114
- const candidate = result.file;
115
- if (candidate.bytes > MAX_FILE_BYTES) {
116
- skipped++;
117
- continue;
118
- }
119
- if (prefixFiles + 1 > RG_PREFIX_FILES ||
120
- prefixBytes + candidate.bytes > RG_PREFIX_BYTES) {
121
- tail.push(candidate);
122
- tailBytes += candidate.bytes;
123
- continue;
124
- }
125
- prefixFiles++;
126
- prefixBytes += candidate.bytes;
127
- prefix.push(candidate);
128
- if (prefix.length >= PORTABLE_SEARCH_CONCURRENCY && await flushPrefix())
129
- return true;
130
- }
131
- return false;
132
- };
133
- const walked = await walk(start, scoped, async (lexical) => {
94
+ const walked = await walk(start, scoped, async (lexical, expected) => {
134
95
  const relative = displayPath(scoped.root, lexical);
135
96
  if (!match(relative))
136
97
  return false;
137
- candidates.push(lexical);
138
- return candidates.length >= PORTABLE_SEARCH_CONCURRENCY
139
- ? await flushCandidates()
140
- : false;
98
+ const bytes = Number(expected.size);
99
+ if (bytes > MAX_FILE_BYTES) {
100
+ skipped++;
101
+ return false;
102
+ }
103
+ pending.push({ path: lexical, bytes, expected });
104
+ return pending.length >= PORTABLE_SEARCH_CONCURRENCY && await flushPending();
141
105
  });
142
106
  if (found.length < limit)
143
- await flushCandidates();
144
- if (found.length < limit)
145
- await flushPrefix();
146
- const accelerated = found.length < limit && preferRipgrep(tail, tailBytes)
147
- ? await trySearchWithRipgrep({
148
- root: scoped.root,
149
- files: tail,
150
- query,
151
- caseSensitive: sensitive,
152
- limit: limit - found.length,
153
- signal: ctx.signal,
154
- })
155
- : undefined;
156
- const portable = found.length < limit && accelerated === undefined && tail.length > 0
157
- ? await portableSearch(tail, scoped, needle, sensitive, limit - found.length)
158
- : undefined;
159
- found.push(...(portable?.matches ?? accelerated?.matches.map((match) => (`${displayPath(scoped.root, match.path)}:${match.line}:${clip(match.text)}`)) ?? []));
160
- skipped += portable?.skipped ?? accelerated?.binaryPaths.length ?? 0;
107
+ await flushPending();
161
108
  const extra = skipped === 0 ? "" : ` · skipped ${skipped} binary/large/unreadable`;
162
109
  return {
163
110
  output: found.length === 0 ? "[no matches]" : found.join("\n"),
@@ -165,7 +112,7 @@ export const searchText = {
165
112
  };
166
113
  },
167
114
  };
168
- export async function portableSearch(files, ctx, needle, sensitive, limit, read = readSearchFile) {
115
+ export async function portableSearch(files, ctx, needle, sensitive, limit, read) {
169
116
  const found = [];
170
117
  let skipped = 0;
171
118
  for (let start = 0; start < files.length; start += PORTABLE_SEARCH_CONCURRENCY) {
@@ -189,19 +136,12 @@ export async function portableSearch(files, ctx, needle, sensitive, limit, read
189
136
  }
190
137
  return { matches: found, skipped };
191
138
  }
192
- async function inspectCandidate(root, lexical) {
193
- try {
194
- const file = await resolveExistingInRoot(root, lexical);
195
- return { file: { path: file, bytes: (await fs.stat(file)).size } };
196
- }
197
- catch (error) {
198
- return { error };
199
- }
200
- }
201
139
  async function searchFile(file, ctx, needle, sensitive, limit, read) {
202
140
  let data;
203
141
  try {
204
- data = await read(file.path, ctx.signal);
142
+ data = read === undefined
143
+ ? await readVerifiedSearchFile(file, ctx)
144
+ : await read(file.path, ctx.signal);
205
145
  }
206
146
  catch (error) {
207
147
  checkAbort(ctx.signal);
@@ -225,55 +165,79 @@ async function searchFile(file, ctx, needle, sensitive, limit, read) {
225
165
  }
226
166
  return found;
227
167
  }
228
- function readSearchFile(file, signal) {
229
- return signal === undefined ? fs.readFile(file) : fs.readFile(file, { signal });
230
- }
231
- function preferRipgrep(files, bytes) {
232
- return files.length >= MIN_RG_TAIL_FILES || bytes >= MIN_RG_TAIL_BYTES;
168
+ async function readVerifiedSearchFile(file, ctx) {
169
+ if (file.expected === undefined) {
170
+ throw new BoundedFileError("changed", "search file has no verified identity");
171
+ }
172
+ return readBoundedFile(file.path, MAX_FILE_BYTES, {
173
+ label: "search file",
174
+ signal: ctx.signal,
175
+ expected: file.expected,
176
+ validate: async () => {
177
+ const confirmed = await resolveExistingInRoot(ctx.root, file.path);
178
+ if (confirmed !== file.path) {
179
+ throw new BoundedFileError("changed", "search file left the workspace");
180
+ }
181
+ },
182
+ });
233
183
  }
234
184
  async function walk(start, ctx, visit) {
235
185
  const pending = [];
236
186
  let seen = 0;
187
+ let buffered = 0;
188
+ let capped = false;
237
189
  const enter = async (lexical) => {
238
190
  checkAbort(ctx.signal);
239
191
  const directory = await resolveExistingInRoot(ctx.root, lexical);
240
- let entries;
192
+ const room = MAX_VISITED - seen - buffered;
193
+ if (room <= 0) {
194
+ capped = true;
195
+ return false;
196
+ }
241
197
  try {
242
- entries = await fs.readdir(directory, { withFileTypes: true, encoding: "utf8" });
198
+ const inspected = await readStableDirectory(ctx.root, directory, {
199
+ maxEntries: room,
200
+ signal: ctx.signal,
201
+ });
202
+ if (inspected.capped)
203
+ capped = true;
204
+ const entries = [...inspected.entries];
205
+ buffered += entries.length;
206
+ pending.push({ directory, entries, next: 0 });
243
207
  }
244
208
  catch (error) {
245
209
  if (skippable(error))
246
- return;
210
+ return true;
247
211
  throw error;
248
212
  }
249
- entries.sort((a, b) => a.name.localeCompare(b.name));
250
- pending.push({ directory, entries, next: 0 });
213
+ return true;
251
214
  };
252
- await enter(start);
215
+ if (!await enter(start))
216
+ return { capped: true };
253
217
  while (pending.length > 0) {
254
218
  checkAbort(ctx.signal);
255
219
  const frame = pending[pending.length - 1];
256
220
  if (frame === undefined)
257
221
  break;
258
- const entry = frame.entries[frame.next++];
222
+ const index = frame.next++;
223
+ const entry = frame.entries[index];
259
224
  if (entry === undefined) {
260
225
  pending.pop();
261
226
  continue;
262
227
  }
263
- if (++seen > MAX_VISITED)
264
- return { capped: true };
265
- if (entry.isSymbolicLink())
266
- continue;
228
+ frame.entries[index] = undefined;
229
+ buffered--;
230
+ seen++;
267
231
  const target = path.join(frame.directory, entry.name);
268
- if (entry.isDirectory()) {
232
+ if (entry.kind === "directory") {
269
233
  if (!SKIP.has(entry.name))
270
234
  await enter(target);
271
235
  }
272
- else if (entry.isFile() && (await visit(target))) {
273
- return { capped: false };
236
+ else if (await visit(target, entry.expected)) {
237
+ return { capped };
274
238
  }
275
239
  }
276
- return { capped: false };
240
+ return { capped };
277
241
  }
278
242
  async function startAt(args, ctx) {
279
243
  const candidate = optionalString(args, "path") ?? ".";
@@ -414,5 +378,6 @@ function checkAbort(signal) {
414
378
  throw signal.reason instanceof Error ? signal.reason : new Error("aborted");
415
379
  }
416
380
  function skippable(error) {
417
- return ["EACCES", "EPERM", "ENOENT"].includes(error.code ?? "");
381
+ return error instanceof BoundedFileError || error instanceof StableDirectoryError ||
382
+ ["EACCES", "EPERM", "ENOENT"].includes(error.code ?? "");
418
383
  }
@@ -1,48 +1,22 @@
1
1
  // Shared size and truncation boundaries for text handled by workspace tools.
2
- import { constants } from "node:fs";
3
- import { lstat, open } from "node:fs/promises";
2
+ import { BoundedFileError, readBoundedText } from "../bounded-file.js";
4
3
  export { leadingText, trailingText } from "../text-boundary.js";
5
4
  export const MAX_EDITABLE_BYTES = 4_000_000;
6
5
  export const MAX_EDITABLE_CHARS = 1_000_000;
7
6
  export const MAX_EDITABLE_LINES = 20_000;
8
- const READ_CHUNK_BYTES = 64 * 1024;
9
7
  /** Read a regular UTF-8 file without allowing an unbounded allocation. */
10
8
  export async function readEditableText(file, options = {}) {
11
9
  const label = options.label ?? "file";
12
- const details = await lstat(file);
13
- if (!details.isFile())
14
- throw new Error(`${label} must be a regular file`);
15
- const flags = process.platform === "win32"
16
- ? "r"
17
- : constants.O_RDONLY | (constants.O_NONBLOCK ?? 0);
18
- const handle = await open(file, flags);
19
10
  try {
20
- const stat = await handle.stat();
21
- if (!stat.isFile())
22
- throw new Error(`${label} must be a regular file`);
23
- if (stat.size > MAX_EDITABLE_BYTES) {
24
- throw limitError(label, `${MAX_EDITABLE_BYTES} UTF-8 bytes`);
25
- }
26
- const chunks = [];
27
- let total = 0;
28
- while (total <= MAX_EDITABLE_BYTES) {
29
- const room = MAX_EDITABLE_BYTES + 1 - total;
30
- const buffer = Buffer.allocUnsafe(Math.min(READ_CHUNK_BYTES, room));
31
- const { bytesRead } = await handle.read(buffer, 0, buffer.length, total);
32
- if (bytesRead === 0)
33
- break;
34
- chunks.push(buffer.subarray(0, bytesRead));
35
- total += bytesRead;
36
- }
37
- if (total > MAX_EDITABLE_BYTES) {
38
- throw limitError(label, `${MAX_EDITABLE_BYTES} UTF-8 bytes`);
39
- }
40
- const text = Buffer.concat(chunks, total).toString("utf8");
11
+ const text = await readBoundedText(file, MAX_EDITABLE_BYTES, { label });
41
12
  assertEditableText(text, label);
42
13
  return text;
43
14
  }
44
- finally {
45
- await handle.close();
15
+ catch (error) {
16
+ if (error instanceof BoundedFileError && error.kind === "too-large") {
17
+ throw limitError(label, `${MAX_EDITABLE_BYTES} UTF-8 bytes`);
18
+ }
19
+ throw error;
46
20
  }
47
21
  }
48
22
  /** Reject whole-file writes that exceed Jecode's mutation budget. */