pi-agent-flow 1.1.0 → 1.2.2

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.
@@ -9,6 +9,7 @@ import * as fs from "node:fs/promises";
9
9
  import * as os from "node:os";
10
10
  import * as path from "node:path";
11
11
  import { Type } from "@sinclair/typebox";
12
+ import { Text, TruncatedText } from "@mariozechner/pi-tui";
12
13
 
13
14
  // ---------------------------------------------------------------------------
14
15
  // Schema
@@ -83,6 +84,14 @@ interface FileOpInput {
83
84
  l?: number;
84
85
  }
85
86
 
87
+ interface ContextMapEntry {
88
+ kind: string;
89
+ name: string;
90
+ startLine: number;
91
+ endLine: number;
92
+ parent?: string;
93
+ }
94
+
86
95
  interface OpResult {
87
96
  op: "read" | "write" | "edit" | "delete";
88
97
  path: string;
@@ -91,6 +100,11 @@ interface OpResult {
91
100
  bytes?: number;
92
101
  blocksChanged?: number;
93
102
  totalLines?: number;
103
+ contextMap?: boolean;
104
+ language?: string;
105
+ symbols?: ContextMapEntry[];
106
+ symbolsTruncated?: boolean;
107
+ warning?: string;
94
108
  truncated?: boolean;
95
109
  nextOffset?: number;
96
110
  error?: string;
@@ -101,6 +115,22 @@ interface ReadTruncationResult {
101
115
  content: string;
102
116
  truncated: boolean;
103
117
  nextOffset?: number;
118
+ linesRead: number;
119
+ }
120
+
121
+ interface ReadOptions {
122
+ /**
123
+ * When false, readWithOffsetLimit ignores regular batch MAX_LINES and total
124
+ * MAX_BYTES caps. batch_read applies its own safe full-file and targeted-read
125
+ * guards before calling this helper.
126
+ */
127
+ truncate?: boolean;
128
+ toolName?: "batch" | "batch_read";
129
+ }
130
+
131
+ interface ExecuteOptions {
132
+ readOptions?: ReadOptions;
133
+ includeLimitWarnings?: boolean;
104
134
  }
105
135
 
106
136
  // ---------------------------------------------------------------------------
@@ -109,6 +139,9 @@ interface ReadTruncationResult {
109
139
 
110
140
  const MAX_LINES = 2000;
111
141
  const MAX_BYTES = 50 * 1024; // 50KB
142
+ const SAFE_FULL_READ_LIMIT = 300;
143
+ const TARGETED_READ_LINE_LIMIT = 1000;
144
+ const MAX_CONTEXT_MAP_ENTRIES = 100;
112
145
 
113
146
  function normalizeToLF(text: string): string {
114
147
  return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
@@ -132,14 +165,366 @@ function stripBom(content: string): { bom: string; text: string } {
132
165
  : { bom: "", text: content };
133
166
  }
134
167
 
168
+ type ContextLanguage =
169
+ | "typescript"
170
+ | "javascript"
171
+ | "python"
172
+ | "terraform"
173
+ | "hcl"
174
+ | "yaml"
175
+ | "dockerfile"
176
+ | "plain";
177
+
178
+ interface FileContextMap {
179
+ language: ContextLanguage;
180
+ symbols: ContextMapEntry[];
181
+ symbolsTruncated?: boolean;
182
+ }
183
+
184
+ function isBatchRead(options: ExecuteOptions): boolean {
185
+ return options.readOptions?.toolName === "batch_read";
186
+ }
187
+
188
+ function isFullFileRead(op: FileOpInput, totalLines: number): boolean {
189
+ const start = op.s ?? 1;
190
+ if (start !== 1) return false;
191
+ return op.l === undefined || op.l >= totalLines;
192
+ }
193
+
194
+ function buildBatchReadSafetyWarning(): string {
195
+ return `[batch_read safety] Raw content truncated at ${TARGETED_READ_LINE_LIMIT} lines to preserve context. Adjust your 's' and 'l' parameters to read further.`;
196
+ }
197
+
198
+ function detectContextLanguage(filePath: string): ContextLanguage {
199
+ const base = path.basename(filePath);
200
+ const lowerBase = base.toLowerCase();
201
+ const ext = path.extname(lowerBase);
202
+
203
+ if (
204
+ lowerBase === "dockerfile" ||
205
+ lowerBase.startsWith("dockerfile.") ||
206
+ lowerBase === ".dockerfile" ||
207
+ lowerBase.endsWith(".dockerfile")
208
+ ) {
209
+ return "dockerfile";
210
+ }
211
+
212
+ if ([".ts", ".tsx", ".mts", ".cts"].includes(ext)) return "typescript";
213
+ if ([".js", ".jsx", ".mjs", ".cjs"].includes(ext)) return "javascript";
214
+ if ([".py", ".pyw"].includes(ext)) return "python";
215
+ if ([".tf", ".tfvars"].includes(ext)) return "terraform";
216
+ if (ext === ".hcl") return "hcl";
217
+ if ([".yml", ".yaml"].includes(ext)) return "yaml";
218
+ return "plain";
219
+ }
220
+
221
+ function buildFileContextMap(filePath: string, lines: string[]): FileContextMap {
222
+ const language = detectContextLanguage(filePath);
223
+ let symbols: ContextMapEntry[] = [];
224
+
225
+ try {
226
+ switch (language) {
227
+ case "typescript":
228
+ case "javascript":
229
+ symbols = extractTsJsSymbols(lines);
230
+ break;
231
+ case "python":
232
+ symbols = extractPythonSymbols(lines);
233
+ break;
234
+ case "terraform":
235
+ case "hcl":
236
+ symbols = extractTerraformSymbols(lines);
237
+ break;
238
+ case "yaml":
239
+ symbols = extractYamlSymbols(lines);
240
+ break;
241
+ case "dockerfile":
242
+ symbols = extractDockerfileSymbols(lines);
243
+ break;
244
+ default:
245
+ symbols = [];
246
+ }
247
+ } catch {
248
+ symbols = [];
249
+ }
250
+
251
+ const sorted = symbols
252
+ .filter((entry) => entry.startLine > 0 && entry.endLine >= entry.startLine)
253
+ .sort((a, b) => a.startLine - b.startLine || a.endLine - b.endLine);
254
+
255
+ return {
256
+ language,
257
+ symbols: sorted.slice(0, MAX_CONTEXT_MAP_ENTRIES),
258
+ symbolsTruncated: sorted.length > MAX_CONTEXT_MAP_ENTRIES || undefined,
259
+ };
260
+ }
261
+
262
+ function countLeadingWhitespace(line: string): number {
263
+ return line.match(/^\s*/)?.[0].length ?? 0;
264
+ }
265
+
266
+ function findBraceBlockEnd(lines: string[], startIndex: number): number {
267
+ let depth = 0;
268
+ let sawOpenBrace = false;
269
+
270
+ for (let i = startIndex; i < lines.length; i++) {
271
+ for (const char of lines[i]) {
272
+ if (char === "{") {
273
+ depth++;
274
+ sawOpenBrace = true;
275
+ } else if (char === "}") {
276
+ depth--;
277
+ }
278
+ }
279
+ if (sawOpenBrace && depth <= 0) return i + 1;
280
+ }
281
+
282
+ return sawOpenBrace ? lines.length : startIndex + 1;
283
+ }
284
+
285
+ function findStatementEnd(lines: string[], startIndex: number): number {
286
+ for (let i = startIndex; i < lines.length; i++) {
287
+ if (lines[i].includes(";")) return i + 1;
288
+ }
289
+ return startIndex + 1;
290
+ }
291
+
292
+ function findIndentedBlockEnd(lines: string[], startIndex: number, indent: number): number {
293
+ for (let i = startIndex + 1; i < lines.length; i++) {
294
+ const trimmed = lines[i].trim();
295
+ if (!trimmed || trimmed.startsWith("#")) continue;
296
+ if (countLeadingWhitespace(lines[i]) <= indent) return i;
297
+ }
298
+ return lines.length;
299
+ }
300
+
301
+ function findYamlBlockEnd(lines: string[], startIndex: number, indent: number): number {
302
+ for (let i = startIndex + 1; i < lines.length; i++) {
303
+ const trimmed = lines[i].trim();
304
+ if (!trimmed || trimmed.startsWith("#")) continue;
305
+ if (trimmed === "---" || countLeadingWhitespace(lines[i]) <= indent) return i;
306
+ }
307
+ return lines.length;
308
+ }
309
+
310
+ function extractTsJsSymbols(lines: string[]): ContextMapEntry[] {
311
+ const entries: ContextMapEntry[] = [];
312
+ const classes: ContextMapEntry[] = [];
313
+
314
+ for (let i = 0; i < lines.length; i++) {
315
+ const line = lines[i];
316
+ let match = line.match(/^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)\s*\(/);
317
+ if (match) {
318
+ entries.push({ kind: "function", name: match[1], startLine: i + 1, endLine: findBraceBlockEnd(lines, i) });
319
+ continue;
320
+ }
321
+
322
+ match = line.match(/^\s*(?:export\s+)?(?:default\s+)?class\s+([A-Za-z_$][\w$]*)\b/);
323
+ if (match) {
324
+ const entry = { kind: "class", name: match[1], startLine: i + 1, endLine: findBraceBlockEnd(lines, i) };
325
+ entries.push(entry);
326
+ classes.push(entry);
327
+ continue;
328
+ }
329
+
330
+ match = line.match(/^\s*(?:export\s+)?interface\s+([A-Za-z_$][\w$]*)\b/);
331
+ if (match) {
332
+ entries.push({ kind: "interface", name: match[1], startLine: i + 1, endLine: findBraceBlockEnd(lines, i) });
333
+ continue;
334
+ }
335
+
336
+ match = line.match(/^\s*(?:export\s+)?type\s+([A-Za-z_$][\w$]*)\b/);
337
+ if (match) {
338
+ entries.push({ kind: "type", name: match[1], startLine: i + 1, endLine: line.includes("{") ? findBraceBlockEnd(lines, i) : findStatementEnd(lines, i) });
339
+ continue;
340
+ }
341
+
342
+ match = line.match(/^\s*(?:export\s+)?enum\s+([A-Za-z_$][\w$]*)\b/);
343
+ if (match) {
344
+ entries.push({ kind: "enum", name: match[1], startLine: i + 1, endLine: findBraceBlockEnd(lines, i) });
345
+ continue;
346
+ }
347
+
348
+ match = line.match(/^\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:function\b|(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>)/);
349
+ if (match) {
350
+ entries.push({ kind: "function", name: match[1], startLine: i + 1, endLine: line.includes("{") ? findBraceBlockEnd(lines, i) : findStatementEnd(lines, i) });
351
+ }
352
+ }
353
+
354
+ const methodNameBlacklist = new Set(["if", "for", "while", "switch", "catch", "function"]);
355
+ for (const classEntry of classes) {
356
+ for (let i = classEntry.startLine; i < classEntry.endLine - 1 && i < lines.length; i++) {
357
+ const match = lines[i].match(/^\s*(?:(?:public|private|protected|static|async|override|readonly)\s+)*([A-Za-z_$][\w$]*)\s*(?:<[^>]+>)?\s*\([^)]*\)\s*(?::\s*[^={]+)?\s*\{/);
358
+ if (!match || methodNameBlacklist.has(match[1])) continue;
359
+ entries.push({
360
+ kind: "method",
361
+ name: `${classEntry.name}.${match[1]}`,
362
+ parent: classEntry.name,
363
+ startLine: i + 1,
364
+ endLine: findBraceBlockEnd(lines, i),
365
+ });
366
+ }
367
+ }
368
+
369
+ return entries;
370
+ }
371
+
372
+ function extractPythonSymbols(lines: string[]): ContextMapEntry[] {
373
+ const entries: ContextMapEntry[] = [];
374
+ const classes: Array<ContextMapEntry & { indent: number }> = [];
375
+
376
+ for (let i = 0; i < lines.length; i++) {
377
+ const match = lines[i].match(/^(\s*)class\s+([A-Za-z_]\w*)\b/);
378
+ if (!match) continue;
379
+ const indent = match[1].length;
380
+ const entry = { kind: "class", name: match[2], startLine: i + 1, endLine: findIndentedBlockEnd(lines, i, indent), indent };
381
+ classes.push(entry);
382
+ entries.push(entry);
383
+ }
384
+
385
+ for (let i = 0; i < lines.length; i++) {
386
+ const match = lines[i].match(/^(\s*)(?:async\s+)?def\s+([A-Za-z_]\w*)\s*\(/);
387
+ if (!match) continue;
388
+ const indent = match[1].length;
389
+ const parent = classes.find((cls) => i + 1 > cls.startLine && i + 1 < cls.endLine && indent > cls.indent);
390
+ entries.push({
391
+ kind: parent ? "method" : "function",
392
+ name: parent ? `${parent.name}.${match[2]}` : match[2],
393
+ parent: parent?.name,
394
+ startLine: i + 1,
395
+ endLine: findIndentedBlockEnd(lines, i, indent),
396
+ });
397
+ }
398
+
399
+ return entries;
400
+ }
401
+
402
+ function extractTerraformSymbols(lines: string[]): ContextMapEntry[] {
403
+ const entries: ContextMapEntry[] = [];
404
+
405
+ for (let i = 0; i < lines.length; i++) {
406
+ const line = lines[i];
407
+ let match = line.match(/^\s*(resource|data)\s+"([^"]+)"\s+"([^"]+)"\s*\{/);
408
+ if (match) {
409
+ entries.push({ kind: match[1], name: `${match[2]}.${match[3]}`, startLine: i + 1, endLine: findBraceBlockEnd(lines, i) });
410
+ continue;
411
+ }
412
+
413
+ match = line.match(/^\s*(module|variable|output|provider)\s+"([^"]+)"\s*\{/);
414
+ if (match) {
415
+ entries.push({ kind: match[1], name: match[2], startLine: i + 1, endLine: findBraceBlockEnd(lines, i) });
416
+ continue;
417
+ }
418
+
419
+ match = line.match(/^\s*(locals|terraform)\s*\{/);
420
+ if (match) {
421
+ entries.push({ kind: match[1], name: match[1], startLine: i + 1, endLine: findBraceBlockEnd(lines, i) });
422
+ continue;
423
+ }
424
+
425
+ match = line.match(/^([A-Za-z_][\w-]*)\s*=/);
426
+ if (match) {
427
+ entries.push({ kind: "assignment", name: match[1], startLine: i + 1, endLine: i + 1 });
428
+ }
429
+ }
430
+
431
+ return entries;
432
+ }
433
+
434
+ function extractYamlSymbols(lines: string[]): ContextMapEntry[] {
435
+ const entries: ContextMapEntry[] = [];
436
+
437
+ let docStart = 0;
438
+ for (let i = 0; i <= lines.length; i++) {
439
+ if (i < lines.length && lines[i].trim() !== "---") continue;
440
+ const docEnd = i === lines.length ? lines.length : i;
441
+ const docLines = lines.slice(docStart, docEnd);
442
+ const kindLine = docLines.findIndex((line) => /^kind:\s*\S+/.test(line));
443
+ const nameLine = docLines.findIndex((line) => /^\s{2}name:\s*\S+/.test(line));
444
+ if (kindLine >= 0 && nameLine >= 0) {
445
+ const kind = docLines[kindLine].replace(/^kind:\s*/, "").trim();
446
+ const name = docLines[nameLine].replace(/^\s{2}name:\s*/, "").trim();
447
+ entries.push({ kind, name, startLine: docStart + 1, endLine: Math.max(docStart + 1, docEnd) });
448
+ }
449
+ docStart = i + 1;
450
+ }
451
+
452
+ for (let i = 0; i < lines.length; i++) {
453
+ const topLevel = lines[i].match(/^([A-Za-z_][\w.-]*):\s*/);
454
+ if (topLevel) {
455
+ const key = topLevel[1];
456
+ entries.push({ kind: "section", name: key, startLine: i + 1, endLine: findYamlBlockEnd(lines, i, 0) });
457
+
458
+ if (["jobs", "services", "volumes", "networks"].includes(key)) {
459
+ for (let j = i + 1; j < lines.length; j++) {
460
+ const trimmed = lines[j].trim();
461
+ if (!trimmed || trimmed.startsWith("#")) continue;
462
+ const indent = countLeadingWhitespace(lines[j]);
463
+ if (indent <= 0) break;
464
+ const child = lines[j].match(/^\s{2}([A-Za-z0-9_.-]+):\s*/);
465
+ if (!child) continue;
466
+ const childKind = key === "jobs" ? "job" : key.slice(0, -1);
467
+ entries.push({ kind: childKind, name: child[1], startLine: j + 1, endLine: findYamlBlockEnd(lines, j, 2) });
468
+ }
469
+ }
470
+ }
471
+
472
+ const step = lines[i].match(/^\s*-\s+(?:name:\s*(.+)|uses:\s*(.+)|run:\s*(.+))/);
473
+ if (step) {
474
+ const name = (step[1] ?? step[2] ?? step[3] ?? "step").trim();
475
+ entries.push({ kind: "step", name: name.slice(0, 120), startLine: i + 1, endLine: i + 1 });
476
+ }
477
+ }
478
+
479
+ return entries;
480
+ }
481
+
482
+ function findDockerInstructionEnd(lines: string[], startIndex: number): number {
483
+ let endIndex = startIndex;
484
+ while (endIndex + 1 < lines.length && lines[endIndex].trimEnd().endsWith("\\")) {
485
+ endIndex++;
486
+ }
487
+ return endIndex + 1;
488
+ }
489
+
490
+ function extractDockerfileSymbols(lines: string[]): ContextMapEntry[] {
491
+ const entries: ContextMapEntry[] = [];
492
+ const fromLines: number[] = [];
493
+
494
+ for (let i = 0; i < lines.length; i++) {
495
+ if (/^\s*FROM\s+/i.test(lines[i])) fromLines.push(i);
496
+ }
497
+
498
+ for (let idx = 0; idx < fromLines.length; idx++) {
499
+ const i = fromLines[idx];
500
+ const nextFrom = fromLines[idx + 1] ?? lines.length;
501
+ const match = lines[i].match(/^\s*FROM\s+(\S+)(?:\s+AS\s+(\S+))?/i);
502
+ const image = match?.[1] ?? "unknown";
503
+ const alias = match?.[2];
504
+ entries.push({ kind: "stage", name: alias ? `${alias} FROM ${image}` : image, startLine: i + 1, endLine: nextFrom });
505
+ }
506
+
507
+ const important = /^(RUN|COPY|ADD|ENTRYPOINT|CMD|EXPOSE|ENV|ARG|WORKDIR)\b\s*(.*)/i;
508
+ for (let i = 0; i < lines.length; i++) {
509
+ const match = lines[i].trim().match(important);
510
+ if (!match) continue;
511
+ entries.push({ kind: match[1].toUpperCase(), name: (match[2] || match[1]).slice(0, 120), startLine: i + 1, endLine: findDockerInstructionEnd(lines, i) });
512
+ }
513
+
514
+ return entries;
515
+ }
516
+
135
517
  function readWithOffsetLimit(
136
518
  content: string,
137
519
  offset?: number,
138
520
  limit?: number,
139
521
  filePath?: string,
522
+ options: ReadOptions = {},
140
523
  ): ReadTruncationResult {
141
524
  const allLines = content.split("\n");
142
525
  const totalFileLines = allLines.length;
526
+ const shouldTruncate = options.truncate !== false;
527
+ const toolName = options.toolName ?? "batch";
143
528
 
144
529
  // Validate offset
145
530
  if (offset !== undefined && offset > totalFileLines) {
@@ -161,25 +546,30 @@ function readWithOffsetLimit(
161
546
  let truncated = false;
162
547
  let nextOffset: number | undefined;
163
548
 
164
- // Apply max-lines cap
165
- if (selectedLines.length > MAX_LINES) {
549
+ // Apply max-lines cap for regular batch reads. batch_read clamps oversized
550
+ // targeted reads before this helper and context-maps large full-file reads.
551
+ if (shouldTruncate && selectedLines.length > MAX_LINES) {
166
552
  selectedLines = selectedLines.slice(0, MAX_LINES);
167
553
  truncated = true;
168
554
  }
169
555
 
556
+ // A single selected line that exceeds the byte cap is not safely splittable by
557
+ // line-oriented offsets, so keep the existing hard error in both modes.
558
+ for (let i = 0; i < selectedLines.length; i++) {
559
+ if (Buffer.byteLength(selectedLines[i], "utf-8") > MAX_BYTES) {
560
+ const lineDisplay = startLine + i + 1;
561
+ throw new Error(
562
+ `Line ${lineDisplay} exceeds limit. Try: ${toolName} with o:"read", s:${lineDisplay}, l:10, or use bash: head -c ... ${filePath ?? "<file>"}`,
563
+ );
564
+ }
565
+ }
566
+
170
567
  // Join and check byte size
171
568
  let result = selectedLines.join("\n");
172
569
 
173
- // If first line alone exceeds byte limit, give a specific hint
174
- if (selectedLines.length >= 1 && Buffer.byteLength(selectedLines[0], "utf-8") > MAX_BYTES) {
175
- const startLineDisplay = startLine + 1;
176
- throw new Error(
177
- `Line ${startLineDisplay} exceeds limit. Try: batch with o:"read", s:${startLineDisplay}, l:10, or use bash: head -c ... ${filePath ?? "<file>"}`,
178
- );
179
- }
180
-
181
- // Truncate by bytes if needed
182
- if (Buffer.byteLength(result, "utf-8") > MAX_BYTES) {
570
+ // Truncate by total bytes for regular batch reads only. batch_read relies on
571
+ // its line-oriented safety guards and still rejects an individual huge line.
572
+ if (shouldTruncate && Buffer.byteLength(result, "utf-8") > MAX_BYTES) {
183
573
  let byteAccum = 0;
184
574
  let keepLines = 0;
185
575
  for (let i = 0; i < selectedLines.length; i++) {
@@ -208,7 +598,7 @@ function readWithOffsetLimit(
208
598
  result += `\n\n[${remaining} more lines in file. Use s=${nextOffset} to continue.]`;
209
599
  }
210
600
 
211
- return { content: result, truncated, nextOffset };
601
+ return { content: result, truncated, nextOffset, linesRead: selectedLines.length };
212
602
  }
213
603
 
214
604
  function levenshtein(a: string, b: string): number {
@@ -277,8 +667,6 @@ function getErrorHint(error: string): string {
277
667
  return "Merge overlapping edits into one.";
278
668
  if (error.includes("No changes"))
279
669
  return "File already has this content. No edit needed.";
280
- if (error.includes("Path traversal"))
281
- return "Use a path within the working directory.";
282
670
  if (error.includes("is not readable") || error.includes("not readable"))
283
671
  return "Check file permissions.";
284
672
  if (error.includes("ENOENT") || error.includes("no such file"))
@@ -557,101 +945,7 @@ export function isWithinDirectory(child: string, parent: string): boolean {
557
945
 
558
946
  async function validatePath(inputPath: string, cwd: string): Promise<string> {
559
947
  const expandedPath = expandTilde(inputPath);
560
- const resolved = path.resolve(cwd, expandedPath);
561
- const normalizedResolved = path.normalize(resolved);
562
- const normalizedCwd = path.normalize(cwd);
563
- if (
564
- normalizedResolved !== normalizedCwd &&
565
- !isWithinDirectory(normalizedResolved, normalizedCwd)
566
- ) {
567
- throw new Error(
568
- `Path traversal detected: ${inputPath} resolves outside working directory.`,
569
- );
570
- }
571
-
572
- // Resolve cwd and file symlinks to prevent traversal via symlink targets.
573
- // cwd must also be resolved (e.g. macOS /var -> /private/var).
574
- const realCwd = await fs.realpath(cwd);
575
-
576
- let realPath: string;
577
- try {
578
- realPath = await fs.realpath(resolved);
579
- } catch {
580
- const normalizedRealCwd = path.normalize(realCwd);
581
-
582
- // Check if the final component is a broken symlink pointing outside cwd.
583
- try {
584
- const lstat = await fs.lstat(resolved);
585
- if (lstat.isSymbolicLink()) {
586
- const linkTarget = await fs.readlink(resolved);
587
- const realLinkDir = await fs.realpath(path.dirname(resolved));
588
- const resolvedTarget = path.resolve(realLinkDir, linkTarget);
589
- const normalizedTarget = path.normalize(resolvedTarget);
590
- if (
591
- normalizedTarget !== normalizedRealCwd &&
592
- !isWithinDirectory(normalizedTarget, normalizedRealCwd)
593
- ) {
594
- throw new Error(
595
- `Path traversal detected: ${inputPath} symlink points outside working directory.`,
596
- );
597
- }
598
- return resolved;
599
- }
600
- } catch (lstatErr: any) {
601
- if (lstatErr.code !== "ENOENT") throw lstatErr;
602
- // Not a symlink, proceed to ancestor fallback
603
- }
604
-
605
- // File doesn't exist yet (e.g. write creates new file).
606
- // Walk up to the nearest existing ancestor and validate it is within realCwd.
607
- let ancestor = path.dirname(resolved);
608
- let ancestorReal: string | null = null;
609
- while (ancestor && ancestor !== path.dirname(ancestor)) {
610
- try {
611
- ancestorReal = await fs.realpath(ancestor);
612
- break;
613
- } catch {
614
- ancestor = path.dirname(ancestor);
615
- }
616
- }
617
- if (!ancestorReal) {
618
- throw new Error(`Path not found: ${inputPath}`);
619
- }
620
- const normalizedAncestor = path.normalize(ancestorReal);
621
- if (
622
- normalizedAncestor !== normalizedRealCwd &&
623
- !isWithinDirectory(normalizedAncestor, normalizedRealCwd)
624
- ) {
625
- throw new Error(
626
- `Path traversal detected: ${inputPath} ancestor directory is outside working directory.`,
627
- );
628
- }
629
- return resolved;
630
- }
631
-
632
- // Validate resolved real path is within realCwd
633
- const normalizedReal = path.normalize(realPath);
634
- const normalizedRealCwd = path.normalize(realCwd);
635
- if (
636
- normalizedReal !== normalizedRealCwd &&
637
- !isWithinDirectory(normalizedReal, normalizedRealCwd)
638
- ) {
639
- throw new Error(
640
- `Path traversal detected: ${inputPath} symlink points outside working directory.`,
641
- );
642
- }
643
-
644
- // If the requested path is a symlink, return the original path so that
645
- // operations like delete can act on the symlink itself.
646
- try {
647
- const lstat = await fs.lstat(resolved);
648
- if (lstat.isSymbolicLink()) {
649
- return resolved;
650
- }
651
- } catch {
652
- // ignore
653
- }
654
- return realPath;
948
+ return path.resolve(cwd, expandedPath);
655
949
  }
656
950
 
657
951
  // ---------------------------------------------------------------------------
@@ -758,6 +1052,7 @@ async function executeOperations(
758
1052
  operations: FileOpInput[],
759
1053
  cwd: string,
760
1054
  signal?: AbortSignal,
1055
+ options: ExecuteOptions = {},
761
1056
  ): Promise<{ summary: string; contentText: string; results: OpResult[] }> {
762
1057
  const results: OpResult[] = [];
763
1058
  let failed = false;
@@ -765,6 +1060,7 @@ async function executeOperations(
765
1060
  const counts = { read: 0, write: 0, edit: 0, delete: 0, error: 0, skipped: 0 };
766
1061
  const errors: { path: string; op: string; message: string; hint?: string }[] = [];
767
1062
  const truncatedFiles: { path: string; shown: number; total: number; nextOffset?: number }[] = [];
1063
+ const includeLimitWarnings = options.includeLimitWarnings ?? true;
768
1064
 
769
1065
  for (const op of operations) {
770
1066
  if (signal?.aborted) {
@@ -801,15 +1097,42 @@ async function executeOperations(
801
1097
  const allLines = text.split("\n");
802
1098
  const totalFileLines = allLines.length;
803
1099
 
804
- const { content: readContent, truncated, nextOffset } =
805
- readWithOffsetLimit(text, op.s, op.l, op.p);
1100
+ if (isBatchRead(options) && isFullFileRead(op, totalFileLines) && totalFileLines > SAFE_FULL_READ_LIMIT) {
1101
+ const context = buildFileContextMap(op.p, allLines);
1102
+ results.push({
1103
+ op: "read",
1104
+ path: op.p,
1105
+ status: "ok",
1106
+ totalLines: totalFileLines,
1107
+ contextMap: true,
1108
+ language: context.language !== "plain" ? context.language : undefined,
1109
+ symbols: context.symbols.length > 0 ? context.symbols : undefined,
1110
+ symbolsTruncated: context.symbolsTruncated,
1111
+ });
1112
+ counts.read++;
1113
+ break;
1114
+ }
806
1115
 
807
- if (truncated || (op.l !== undefined && (op.s ?? 1) - 1 + op.l < totalFileLines)) {
808
- const shownLines = truncated
809
- ? (op.l !== undefined
810
- ? Math.min(op.l, MAX_LINES)
811
- : MAX_LINES)
812
- : op.l!;
1116
+ let effectiveLimit = op.l;
1117
+ let safetyTruncated = false;
1118
+ let safetyWarning: string | undefined;
1119
+ if (isBatchRead(options) && !isFullFileRead(op, totalFileLines)) {
1120
+ if (effectiveLimit === undefined || effectiveLimit > TARGETED_READ_LINE_LIMIT) {
1121
+ effectiveLimit = TARGETED_READ_LINE_LIMIT;
1122
+ safetyTruncated = true;
1123
+ safetyWarning = buildBatchReadSafetyWarning();
1124
+ }
1125
+ }
1126
+
1127
+ const { content: readContent, truncated, nextOffset, linesRead } =
1128
+ readWithOffsetLimit(text, op.s, effectiveLimit, op.p, options.readOptions);
1129
+ const finalContent = safetyWarning
1130
+ ? `${readContent}\n\n${safetyWarning}`
1131
+ : readContent;
1132
+ const finalTruncated = truncated || safetyTruncated;
1133
+
1134
+ if (finalTruncated || (includeLimitWarnings && effectiveLimit !== undefined && (op.s ?? 1) - 1 + effectiveLimit < totalFileLines)) {
1135
+ const shownLines = finalTruncated ? linesRead : effectiveLimit!;
813
1136
  truncatedFiles.push({
814
1137
  path: op.p,
815
1138
  shown: shownLines,
@@ -822,9 +1145,10 @@ async function executeOperations(
822
1145
  op: "read",
823
1146
  path: op.p,
824
1147
  status: "ok",
825
- content: readContent,
1148
+ content: finalContent,
826
1149
  totalLines: totalFileLines,
827
- truncated: truncated || undefined,
1150
+ warning: safetyWarning,
1151
+ truncated: finalTruncated || undefined,
828
1152
  nextOffset,
829
1153
  });
830
1154
  counts.read++;
@@ -995,11 +1319,108 @@ function buildSummary(
995
1319
  return parts.join("\n");
996
1320
  }
997
1321
 
1322
+ function shortenPath(p: string): string {
1323
+ const home = os.homedir();
1324
+ return p.startsWith(home) ? `~${p.slice(home.length)}` : p;
1325
+ }
1326
+
1327
+ type BatchTheme = {
1328
+ fg: (color: string, text: string) => string;
1329
+ bold: (s: string) => string;
1330
+ bg: (color: string, text: string) => string;
1331
+ };
1332
+
1333
+ function extractBatchOps(args: Record<string, unknown>): Array<{ o: string; p: string; e?: unknown[] }> {
1334
+ let rawOps: unknown[];
1335
+ if (Array.isArray(args.o)) rawOps = args.o;
1336
+ else if (Array.isArray(args.op)) rawOps = args.op;
1337
+ else if (Array.isArray(args.operations)) rawOps = args.operations;
1338
+ else if (Array.isArray(args)) rawOps = args;
1339
+ else rawOps = [];
1340
+
1341
+ return rawOps
1342
+ .filter((op): op is Record<string, unknown> => !!op && typeof op === "object")
1343
+ .map((op) => {
1344
+ const opName = String(op.o ?? op.op ?? "?");
1345
+ const opPath = String(op.p ?? op.path ?? "?");
1346
+ const edits = Array.isArray(op.e) ? op.e : Array.isArray(op.edits) ? op.edits : undefined;
1347
+ return { o: opName, p: opPath, e: edits };
1348
+ });
1349
+ }
1350
+
1351
+ function formatBatchCall(args: Record<string, unknown>): string {
1352
+ const ops = extractBatchOps(args);
1353
+ if (ops.length === 0) return "batch (empty)";
1354
+
1355
+ const parts: string[] = [];
1356
+ for (const op of ops) {
1357
+ const shortPath = shortenPath(op.p);
1358
+ if (op.o === "edit" && op.e && op.e.length > 1) {
1359
+ parts.push(`edit ${shortPath} (${op.e.length} blocks)`);
1360
+ } else {
1361
+ parts.push(`${op.o} ${shortPath}`);
1362
+ }
1363
+ }
1364
+
1365
+ if (parts.length <= 3) {
1366
+ return parts.join(", ");
1367
+ }
1368
+ return `${parts.slice(0, 2).join(", ")} +${parts.length - 2} more`;
1369
+ }
1370
+
1371
+ function renderBatchCall(args: Record<string, unknown>, theme: BatchTheme): Text {
1372
+ const summary = formatBatchCall(args);
1373
+ return new Text(theme.fg("muted", "batch ") + theme.fg("accent", summary), 0, 0);
1374
+ }
1375
+
1376
+ function renderBatchResult(
1377
+ result: { content?: Array<{ type: string; text?: string }> },
1378
+ expanded: boolean,
1379
+ _theme: BatchTheme,
1380
+ _args?: Record<string, unknown>,
1381
+ ): Text | TruncatedText {
1382
+ const fullText = result.content?.find((c) => c.type === "text")?.text ?? "";
1383
+ if (!expanded) {
1384
+ const summary = fullText.split("\n")[0] ?? "";
1385
+ return new TruncatedText(summary, 0, 0);
1386
+ }
1387
+ return new Text(fullText, 0, 0);
1388
+ }
1389
+
1390
+ function buildContextMapText(result: OpResult): string {
1391
+ const title = result.language || result.symbols ? "context map" : "file summary";
1392
+ const lines: string[] = [`\n--- ${result.path} ${title} ---`];
1393
+ lines.push(`Total lines: ${result.totalLines ?? 0}`);
1394
+ if (result.language) lines.push(`Language: ${result.language}`);
1395
+ lines.push("");
1396
+ lines.push(`Full-file content omitted because file exceeds SAFE_FULL_READ_LIMIT=${SAFE_FULL_READ_LIMIT} lines.`);
1397
+ lines.push("Use targeted reads with s/l, for example:");
1398
+ lines.push(`{ "o": "read", "p": "${result.path}", "s": <startLine>, "l": <lineCount> }`);
1399
+
1400
+ if (result.symbols && result.symbols.length > 0) {
1401
+ lines.push("");
1402
+ lines.push("Context map:");
1403
+ for (const entry of result.symbols) {
1404
+ lines.push(`- ${entry.kind} ${entry.name} ${entry.startLine}-${entry.endLine}`);
1405
+ }
1406
+ if (result.symbolsTruncated) {
1407
+ lines.push(`... [Context map truncated. Over ${MAX_CONTEXT_MAP_ENTRIES} entries detected. Use targeted reads to explore further.]`);
1408
+ }
1409
+ } else if (result.language) {
1410
+ lines.push("");
1411
+ lines.push("No context map entries detected for this structured file.");
1412
+ }
1413
+
1414
+ return lines.join("\n");
1415
+ }
1416
+
998
1417
  function buildContentText(summary: string, results: OpResult[]): string {
999
1418
  const sections: string[] = [summary];
1000
1419
 
1001
1420
  for (const r of results) {
1002
- if (r.op === "read" && r.status === "ok" && r.content) {
1421
+ if (r.op === "read" && r.status === "ok" && r.contextMap) {
1422
+ sections.push(buildContextMapText(r));
1423
+ } else if (r.op === "read" && r.status === "ok" && r.content) {
1003
1424
  const lineInfo = r.totalLines !== undefined ? ` (${r.totalLines} lines)` : "";
1004
1425
  sections.push(`\n--- ${r.path}${lineInfo} ---\n${r.content}`);
1005
1426
  } else if (r.op === "edit" && r.status === "ok") {
@@ -1021,6 +1442,147 @@ function buildContentText(summary: string, results: OpResult[]): string {
1021
1442
  // Tool definition factory
1022
1443
  // ---------------------------------------------------------------------------
1023
1444
 
1445
+ export const BatchReadParams = Type.Object({
1446
+ o: Type.Array(
1447
+ Type.Object({
1448
+ o: Type.Literal("read"),
1449
+ p: Type.String({ description: "Path to the file (relative or absolute)" }),
1450
+ s: Type.Optional(
1451
+ Type.Number({
1452
+ minimum: 1,
1453
+ description:
1454
+ "1-indexed line number to start reading from (offset). Used with o: 'read'.",
1455
+ }),
1456
+ ),
1457
+ l: Type.Optional(
1458
+ Type.Number({
1459
+ minimum: 1,
1460
+ description:
1461
+ "Maximum number of lines to read (limit). Used with o: 'read'.",
1462
+ }),
1463
+ ),
1464
+ }),
1465
+ {
1466
+ description:
1467
+ "Ordered list of read operations. Executed sequentially. On failure, remaining operations are skipped.",
1468
+ },
1469
+ ),
1470
+ });
1471
+
1472
+ function prepareBatchReadArguments(input: unknown): { o: FileOpInput[] } | unknown {
1473
+ const prepared = prepareArguments(input);
1474
+ const ops = Array.isArray(prepared) ? prepared : (prepared as { o: unknown[] }).o;
1475
+ if (!Array.isArray(ops)) return { o: [] };
1476
+
1477
+ for (const op of ops) {
1478
+ if (!op || typeof op !== "object") continue;
1479
+ const obj = op as Record<string, unknown>;
1480
+ const opType = String(obj.o ?? obj.op ?? "").toLowerCase();
1481
+ if (opType && opType !== "read") {
1482
+ throw new Error(`batch_read only supports read operations. Received: ${opType}`);
1483
+ }
1484
+ }
1485
+ return prepared;
1486
+ }
1487
+
1488
+ function renderBatchReadCall(args: Record<string, unknown>, theme: BatchTheme): Text {
1489
+ const summary = formatBatchCall(args);
1490
+ return new Text(theme.fg("muted", "batch_read ") + theme.fg("accent", summary), 0, 0);
1491
+ }
1492
+
1493
+ export function createBatchReadTool() {
1494
+ return {
1495
+ name: "batch_read",
1496
+ label: "batch_read",
1497
+ description: [
1498
+ "Batch read-only file operations — run multiple read ops in a single call.",
1499
+ "Each operation is independent and executes sequentially in array order; on failure, remaining operations are skipped.",
1500
+ `Full-file reads up to ${SAFE_FULL_READ_LIMIT} lines return raw content; larger full-file reads return a context map for code/infra files or total lines for plain text.`,
1501
+ `Targeted reads with l over ${TARGETED_READ_LINE_LIMIT} are clamped with a continuation warning; a single line over the byte limit still errors.`,
1502
+ "Use `o: \"read\"` with `s` (offset) and `l` (limit) for targeted reading. Prefer this over bash sed/head/tail.",
1503
+ "Best for reading multiple files or sections in one call.",
1504
+ ].join("\n"),
1505
+ promptSnippet: "Batch read-only file operations — run multiple read ops in one call",
1506
+ promptGuidelines: [
1507
+ "Use batch_read to perform multiple file reads in a single call rather than separate tool calls.",
1508
+ "Prefer batch_read when reading 2+ files or multiple sections of the same file.",
1509
+ `Small full-file reads (<=${SAFE_FULL_READ_LIMIT} lines) return raw content; larger full-file reads return navigable context maps or line counts.`,
1510
+ `Use targeted reads with s/l around context-map entries; targeted reads are capped at ${TARGETED_READ_LINE_LIMIT} lines.`,
1511
+ "Do not retry the same full-file read when a context map is returned.",
1512
+ ],
1513
+ parameters: BatchReadParams,
1514
+ prepareArguments: prepareBatchReadArguments,
1515
+
1516
+ async execute(
1517
+ _toolCallId: string,
1518
+ input: unknown,
1519
+ signal: AbortSignal | undefined,
1520
+ _onUpdate: unknown,
1521
+ ctx: { cwd: string },
1522
+ ) {
1523
+ let prepared: unknown;
1524
+ try {
1525
+ prepared = prepareBatchReadArguments(input);
1526
+ } catch (err) {
1527
+ const message = err instanceof Error ? err.message : String(err);
1528
+ return {
1529
+ content: [{ type: "text", text: `Error: ${message}` }],
1530
+ isError: true,
1531
+ };
1532
+ }
1533
+
1534
+ const ops = Array.isArray(prepared)
1535
+ ? (prepared as FileOpInput[])
1536
+ : (prepared as { o: FileOpInput[] }).o;
1537
+
1538
+ if (!Array.isArray(ops) || ops.length === 0) {
1539
+ return {
1540
+ content: [
1541
+ { type: "text", text: "Error: o array is required and must not be empty." },
1542
+ ],
1543
+ isError: true,
1544
+ };
1545
+ }
1546
+
1547
+ // Defensive validation: reject any non-read operations
1548
+ for (const op of ops) {
1549
+ if (op.o !== "read") {
1550
+ return {
1551
+ content: [
1552
+ {
1553
+ type: "text",
1554
+ text: `Error: batch_read only supports read operations. Received ${op.o} for ${op.p}.`,
1555
+ },
1556
+ ],
1557
+ isError: true,
1558
+ };
1559
+ }
1560
+ }
1561
+
1562
+ if (signal?.aborted) {
1563
+ return {
1564
+ content: [{ type: "text", text: "Operation aborted." }],
1565
+ isError: true,
1566
+ };
1567
+ }
1568
+
1569
+ const { contentText, results } = await executeOperations(ops, ctx.cwd, signal, {
1570
+ readOptions: { truncate: false, toolName: "batch_read" },
1571
+ includeLimitWarnings: false,
1572
+ });
1573
+
1574
+ return {
1575
+ content: [{ type: "text", text: contentText }],
1576
+ details: { results },
1577
+ };
1578
+ },
1579
+
1580
+ renderCall: (args: Record<string, unknown>, theme: BatchTheme) => renderBatchReadCall(args, theme),
1581
+ renderResult: (result: any, { expanded }: { expanded: boolean }, theme: BatchTheme, args?: Record<string, unknown>) =>
1582
+ renderBatchResult(result, expanded, theme, args),
1583
+ };
1584
+ }
1585
+
1024
1586
  export function createBatchTool() {
1025
1587
  return {
1026
1588
  name: "batch",
@@ -1079,5 +1641,9 @@ export function createBatchTool() {
1079
1641
  details: { results },
1080
1642
  };
1081
1643
  },
1644
+
1645
+ renderCall: (args: Record<string, unknown>, theme: BatchTheme) => renderBatchCall(args, theme),
1646
+ renderResult: (result: any, { expanded }: { expanded: boolean }, theme: BatchTheme, args?: Record<string, unknown>) =>
1647
+ renderBatchResult(result, expanded, theme, args),
1082
1648
  };
1083
1649
  }