@arnilo/prism-coding-agent 0.0.5 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/read.js CHANGED
@@ -2,8 +2,8 @@
2
2
  * Read tool: read a file from the host filesystem.
3
3
  *
4
4
  * Behavioral port of pi's core/tools/read for @arnilo/prism-coding-agent, adapted to Prism's
5
- * `ToolDefinition` contract. Faithfully ports pi's text path (offset/limit `truncateHead`
6
- * continuation notices) and image path (magic-byte MIME → `ImageContent` with base64). Drops pi's
5
+ * `ToolDefinition` contract. Keeps pi's offset/limit continuation behavior while streaming one
6
+ * bounded text page; image path remains magic-byte MIME → bounded `ImageContent`. Drops pi's
7
7
  * TUI (`renderCall`/`renderResult`, theme/syntax-highlight, compact classifications, key hints) and
8
8
  * the model-aware non-vision note (Prism's `ToolExecutionContext` has no model field).
9
9
  *
@@ -21,10 +21,12 @@
21
21
  */
22
22
  import { Buffer } from "node:buffer";
23
23
  import { constants } from "node:fs";
24
- import { access as fsAccess, open, readFile as fsReadFile, stat as fsStat } from "node:fs/promises";
24
+ import { access as fsAccess, open, stat as fsStat } from "node:fs/promises";
25
+ import { readFileBounded } from "./bounded-file.js";
25
26
  import { enforceExecutionPolicy } from "./execution-policy.js";
26
27
  import { resolveReadPathAsync } from "./path-utils.js";
27
- import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, truncateHead, } from "./truncate.js";
28
+ import { DEFAULT_MAX_BYTES, DEFAULT_MAX_IMAGE_BYTES, DEFAULT_MAX_LINES, DEFAULT_MAX_TEXT_SCAN_BYTES, HARD_MAX_BYTES, HARD_MAX_IMAGE_BYTES, HARD_MAX_LINES, HARD_MAX_TEXT_SCAN_BYTES, validateCodingLimit, } from "./limits.js";
29
+ import { formatSize } from "./truncate.js";
28
30
  // --- magic-byte image MIME detection (faithful port of pi utils/mime.js, pure JS, no deps) ---
29
31
  const IMAGE_TYPE_SNIFF_BYTES = 4100;
30
32
  const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
@@ -143,9 +145,119 @@ function startsWithAscii(buffer, offset, text) {
143
145
  }
144
146
  // --- read tool ---
145
147
  /** Default maximum image file size before read/transform (10 MB). */
146
- export const DEFAULT_MAX_IMAGE_BYTES = 10_000_000;
148
+ export { DEFAULT_MAX_IMAGE_BYTES } from "./limits.js";
149
+ async function readLocalTextPage(path, options) {
150
+ const handle = await open(path, "r");
151
+ let fileSize;
152
+ try {
153
+ fileSize = (await handle.stat()).size;
154
+ }
155
+ catch (error) {
156
+ await handle.close();
157
+ throw error;
158
+ }
159
+ const chunks = [];
160
+ let retainedBytes = 0;
161
+ let outputLines = 0;
162
+ let lineNumber = 1;
163
+ let scannedBytes = 0;
164
+ let lineChunkStart = 0;
165
+ let lineByteStart = 0;
166
+ const targetLines = options.limit ?? options.maxLines;
167
+ const content = (trimFinalLf) => {
168
+ const buffer = Buffer.concat(chunks, retainedBytes);
169
+ const end = trimFinalLf && buffer[buffer.length - 1] === 0x0a ? buffer.length - 1 : buffer.length;
170
+ return buffer.subarray(0, end).toString("utf-8");
171
+ };
172
+ const partial = (truncatedBy, nextOffset, firstLineExceedsLimit = false, totalLines) => ({
173
+ content: firstLineExceedsLimit ? "" : content(true),
174
+ startLine: options.offset,
175
+ outputLines,
176
+ hasMore: true,
177
+ nextOffset,
178
+ truncatedBy,
179
+ firstLineExceedsLimit,
180
+ scannedBytes,
181
+ totalLines,
182
+ totalBytes: fileSize,
183
+ });
184
+ try {
185
+ const buffer = Buffer.allocUnsafe(64 * 1024);
186
+ while (true) {
187
+ if (options.signal?.aborted)
188
+ throw new Error("Operation aborted");
189
+ if (scannedBytes >= options.maxScanBytes) {
190
+ if (scannedBytes >= fileSize)
191
+ break;
192
+ throw new Error(`Text read exceeded ${formatSize(options.maxScanBytes)} scan limit before reaching the requested page`);
193
+ }
194
+ const length = Math.min(buffer.length, options.maxScanBytes - scannedBytes);
195
+ const { bytesRead } = await handle.read(buffer, 0, length, null);
196
+ if (bytesRead === 0)
197
+ break;
198
+ scannedBytes += bytesRead;
199
+ let cursor = 0;
200
+ while (cursor < bytesRead) {
201
+ const newline = buffer.indexOf(0x0a, cursor);
202
+ const end = newline === -1 || newline >= bytesRead ? bytesRead : newline + 1;
203
+ const selected = lineNumber >= options.offset && outputLines < targetLines;
204
+ if (selected) {
205
+ const piece = buffer.subarray(cursor, end);
206
+ if (retainedBytes + piece.length > options.maxBytes) {
207
+ chunks.length = lineChunkStart;
208
+ retainedBytes = lineByteStart;
209
+ return partial("bytes", lineNumber, outputLines === 0);
210
+ }
211
+ chunks.push(Buffer.from(piece));
212
+ retainedBytes += piece.length;
213
+ }
214
+ cursor = end;
215
+ if (newline === -1 || newline >= bytesRead)
216
+ break;
217
+ if (selected) {
218
+ outputLines++;
219
+ if (outputLines >= targetLines) {
220
+ let totalLines;
221
+ if (scannedBytes === fileSize) {
222
+ let remainingNewlines = 0;
223
+ for (let index = cursor; index < bytesRead; index++) {
224
+ if (buffer[index] === 0x0a)
225
+ remainingNewlines++;
226
+ }
227
+ totalLines = lineNumber + remainingNewlines + 1;
228
+ }
229
+ return partial(options.limit === undefined ? "lines" : null, lineNumber + 1, false, totalLines);
230
+ }
231
+ }
232
+ lineNumber++;
233
+ lineChunkStart = chunks.length;
234
+ lineByteStart = retainedBytes;
235
+ }
236
+ }
237
+ if (lineNumber < options.offset) {
238
+ throw new Error(`Offset ${options.offset} is beyond end of file (${lineNumber} lines total)`);
239
+ }
240
+ if (lineNumber >= options.offset && outputLines < targetLines)
241
+ outputLines++;
242
+ return {
243
+ content: content(false),
244
+ startLine: options.offset,
245
+ outputLines,
246
+ hasMore: false,
247
+ truncatedBy: null,
248
+ firstLineExceedsLimit: false,
249
+ scannedBytes,
250
+ totalLines: lineNumber,
251
+ totalBytes: scannedBytes,
252
+ };
253
+ }
254
+ finally {
255
+ await handle.close();
256
+ }
257
+ }
147
258
  const defaultReadOperations = {
148
- readFile: (path) => fsReadFile(path),
259
+ readFile: (path, options) => readFileBounded(path, options.maxBytes, options.signal),
260
+ readText: readLocalTextPage,
149
261
  access: (path) => fsAccess(path, constants.R_OK),
150
262
  statFile: async (path) => {
151
263
  const info = await fsStat(path);
@@ -160,13 +272,14 @@ async function loadImageBuffer(absolutePath, mimeType, ops, options) {
160
272
  if (options.signal?.aborted) {
161
273
  throw new Error("Operation aborted");
162
274
  }
163
- if (ops.statFile) {
164
- const { size } = await ops.statFile(absolutePath);
165
- if (size > options.maxImageBytes) {
166
- throw new Error(imageSizeError(size, options.maxImageBytes));
167
- }
275
+ const { size } = await ops.statFile(absolutePath, { signal: options.signal });
276
+ if (size > options.maxImageBytes) {
277
+ throw new Error(imageSizeError(size, options.maxImageBytes));
168
278
  }
169
- let buffer = await ops.readFile(absolutePath);
279
+ let buffer = await ops.readFile(absolutePath, {
280
+ maxBytes: options.maxImageBytes,
281
+ signal: options.signal,
282
+ });
170
283
  if (buffer.length > options.maxImageBytes) {
171
284
  throw new Error(imageSizeError(buffer.length, options.maxImageBytes));
172
285
  }
@@ -195,10 +308,11 @@ function errorResult(toolCallId, message) {
195
308
  };
196
309
  }
197
310
  export function createReadTool(cwd, options) {
198
- const ops = { ...defaultReadOperations, ...options?.operations };
199
- const maxLines = options?.maxLines ?? DEFAULT_MAX_LINES;
200
- const maxBytes = options?.maxBytes ?? DEFAULT_MAX_BYTES;
201
- const maxImageBytes = options?.maxImageBytes ?? DEFAULT_MAX_IMAGE_BYTES;
311
+ const ops = options?.operations ?? defaultReadOperations;
312
+ const maxLines = validateCodingLimit("maxLines", options?.maxLines ?? DEFAULT_MAX_LINES, HARD_MAX_LINES);
313
+ const maxBytes = validateCodingLimit("maxBytes", options?.maxBytes ?? DEFAULT_MAX_BYTES, HARD_MAX_BYTES);
314
+ const maxImageBytes = validateCodingLimit("maxImageBytes", options?.maxImageBytes ?? DEFAULT_MAX_IMAGE_BYTES, HARD_MAX_IMAGE_BYTES);
315
+ const maxScanBytes = validateCodingLimit("maxScanBytes", options?.maxScanBytes ?? DEFAULT_MAX_TEXT_SCAN_BYTES, HARD_MAX_TEXT_SCAN_BYTES);
202
316
  return {
203
317
  name: "read",
204
318
  description: `Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp); images are returned as image content. For text files, output is truncated to ${maxLines} lines or ${maxBytes / 1024}KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.`,
@@ -224,6 +338,10 @@ export function createReadTool(cwd, options) {
224
338
  return errorResult(toolCallId, "path is required and must be a non-empty string.");
225
339
  }
226
340
  try {
341
+ const startLine = validateCodingLimit("offset", offset ?? 1, Number.MAX_SAFE_INTEGER);
342
+ const requestedLines = limit === undefined
343
+ ? undefined
344
+ : validateCodingLimit("limit", limit, HARD_MAX_LINES);
227
345
  const absolutePath = await resolveReadPathAsync(path, cwd);
228
346
  const policyCheck = await enforceExecutionPolicy(options?.executionPolicy, {
229
347
  kind: "read",
@@ -235,7 +353,7 @@ export function createReadTool(cwd, options) {
235
353
  if (!policyCheck.allowed)
236
354
  return policyCheck.result;
237
355
  const allowedPath = policyCheck.action.paths?.[0] ?? absolutePath;
238
- await ops.access(allowedPath);
356
+ await ops.access(allowedPath, { signal: context.signal });
239
357
  const mimeType = ops.detectImageMimeType ? await ops.detectImageMimeType(allowedPath) : undefined;
240
358
  if (mimeType) {
241
359
  const { buffer, resized } = await loadImageBuffer(allowedPath, mimeType, ops, {
@@ -254,59 +372,55 @@ export function createReadTool(cwd, options) {
254
372
  metadata: { image: { mimeType, resized, bytes: buffer.length } },
255
373
  };
256
374
  }
257
- // Text path: faithful port of pi's offset/limit → truncateHead → continuation logic.
258
- const buffer = await ops.readFile(allowedPath);
259
- const textContent = buffer.toString("utf-8");
260
- const allLines = textContent.split("\n");
261
- const totalFileLines = allLines.length;
262
- // 1-indexed offset → 0-indexed array access.
263
- const startLine = offset ? Math.max(0, offset - 1) : 0;
264
- const startLineDisplay = startLine + 1;
265
- if (startLine >= allLines.length) {
266
- throw new Error(`Offset ${offset} is beyond end of file (${allLines.length} lines total)`);
375
+ const page = await ops.readText(allowedPath, {
376
+ offset: startLine,
377
+ limit: requestedLines,
378
+ maxLines,
379
+ maxBytes,
380
+ maxScanBytes,
381
+ signal: context.signal,
382
+ });
383
+ const contentBytes = Buffer.byteLength(page.content, "utf-8");
384
+ if (contentBytes > maxBytes || page.outputLines > (requestedLines ?? maxLines)) {
385
+ throw new Error("ReadOperations.readText returned data beyond the requested bounds");
267
386
  }
268
- let selectedContent;
269
- let userLimitedLines;
270
- if (limit !== undefined) {
271
- const endLine = Math.min(startLine + limit, allLines.length);
272
- selectedContent = allLines.slice(startLine, endLine).join("\n");
273
- userLimitedLines = endLine - startLine;
274
- }
275
- else {
276
- selectedContent = allLines.slice(startLine).join("\n");
277
- }
278
- const truncation = truncateHead(selectedContent, { maxLines, maxBytes });
279
- let outputText;
280
- if (truncation.firstLineExceedsLimit) {
281
- // First line alone exceeds the byte limit — point at a shell fallback.
282
- const firstLineSize = formatSize(Buffer.byteLength(allLines[startLine], "utf-8"));
283
- outputText = `[Line ${startLineDisplay} is ${firstLineSize}, exceeds ${formatSize(maxBytes)} limit. Use the shell tool: sed -n '${startLineDisplay}p' ${path} | head -c ${maxBytes}]`;
387
+ const totalLines = page.totalLines ?? page.startLine + page.outputLines + (page.hasMore ? 1 : 0) - 1;
388
+ const truncation = {
389
+ content: page.content,
390
+ truncated: page.truncatedBy !== null,
391
+ truncatedBy: page.truncatedBy,
392
+ totalLines,
393
+ totalBytes: page.totalBytes ?? page.scannedBytes,
394
+ outputLines: page.outputLines,
395
+ outputBytes: contentBytes,
396
+ lastLinePartial: false,
397
+ firstLineExceedsLimit: page.firstLineExceedsLimit,
398
+ maxLines,
399
+ maxBytes,
400
+ totalLinesKnown: page.totalLines !== undefined,
401
+ totalBytesKnown: page.totalBytes !== undefined,
402
+ };
403
+ let outputText = page.content;
404
+ if (page.firstLineExceedsLimit) {
405
+ outputText = `[Line ${page.startLine} exceeds ${formatSize(maxBytes)} limit. Use the shell tool: sed -n '${page.startLine}p' ${path} | head -c ${maxBytes}]`;
284
406
  }
285
- else if (truncation.truncated) {
286
- const endLineDisplay = startLineDisplay + truncation.outputLines - 1;
287
- const nextOffset = endLineDisplay + 1;
288
- outputText = truncation.content;
289
- if (truncation.truncatedBy === "lines") {
290
- outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines}. Use offset=${nextOffset} to continue.]`;
407
+ else if (page.hasMore && page.nextOffset !== undefined) {
408
+ const endLine = page.startLine + page.outputLines - 1;
409
+ if (page.totalLines !== undefined) {
410
+ const remaining = page.totalLines - endLine;
411
+ outputText += page.truncatedBy
412
+ ? `\n\n[Showing lines ${page.startLine}-${endLine} of ${page.totalLines}${page.truncatedBy === "bytes" ? ` (${formatSize(maxBytes)} limit)` : ""}. Use offset=${page.nextOffset} to continue.]`
413
+ : `\n\n[${remaining} more lines in file. Use offset=${page.nextOffset} to continue.]`;
291
414
  }
292
415
  else {
293
- outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines} (${formatSize(maxBytes)} limit). Use offset=${nextOffset} to continue.]`;
416
+ outputText += `\n\n[Showing lines ${page.startLine}-${endLine}${page.truncatedBy === "bytes" ? ` (${formatSize(maxBytes)} limit)` : ""}. Use offset=${page.nextOffset} to continue.]`;
294
417
  }
295
418
  }
296
- else if (userLimitedLines !== undefined && startLine + userLimitedLines < allLines.length) {
297
- // User limit stopped early but the file has more content.
298
- const remaining = allLines.length - (startLine + userLimitedLines);
299
- const nextOffset = startLine + userLimitedLines + 1;
300
- outputText = `${truncation.content}\n\n[${remaining} more lines in file. Use offset=${nextOffset} to continue.]`;
301
- }
302
- else {
303
- outputText = truncation.content;
304
- }
305
419
  return {
306
420
  toolCallId,
307
421
  name: "read",
308
422
  content: [{ type: "text", text: outputText }],
309
- metadata: { truncation },
423
+ metadata: { truncation, scannedBytes: page.scannedBytes },
310
424
  };
311
425
  }
312
426
  catch (error) {
package/dist/shell.d.ts CHANGED
@@ -59,6 +59,10 @@ export interface ShellToolOptions {
59
59
  maxLines?: number;
60
60
  /** Max bytes kept in the tail snapshot (default 50KB). */
61
61
  maxBytes?: number;
62
+ /** Default wall timeout in seconds (default 600, hard cap 3600). */
63
+ timeout?: number;
64
+ /** Maximum combined raw stdout/stderr retained or spilled (default 64 MiB). */
65
+ maxTotalOutputBytes?: number;
62
66
  /** Temp-file prefix for spilled full output (default "prism-shell"). */
63
67
  tempFilePrefix?: string;
64
68
  }
package/dist/shell.js CHANGED
@@ -25,7 +25,8 @@ import { constants, existsSync } from "node:fs";
25
25
  import { access as fsAccess } from "node:fs/promises";
26
26
  import { enforceExecutionPolicy } from "./execution-policy.js";
27
27
  import { OutputAccumulator } from "./output-accumulator.js";
28
- import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize } from "./truncate.js";
28
+ import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, DEFAULT_MAX_TOTAL_OUTPUT_BYTES, DEFAULT_SHELL_TIMEOUT_SECONDS, HARD_MAX_BYTES, HARD_MAX_LINES, HARD_MAX_TOTAL_OUTPUT_BYTES, HARD_SHELL_TIMEOUT_SECONDS, validateCodingLimit, } from "./limits.js";
29
+ import { formatSize } from "./truncate.js";
29
30
  const EXIT_STDIO_GRACE_MS = 100;
30
31
  // --- spawn internals (re-ported from pi utils/shell.js + utils/child-process.js) ---
31
32
  /** Resolve the shell binary + args. shellPath → SHELL env → /bin/bash → sh. */
@@ -160,6 +161,7 @@ export function waitForChildProcess(child) {
160
161
  export function createLocalBashOperations(options) {
161
162
  return {
162
163
  exec: async (command, cwd, { onData, signal, timeout, env }) => {
164
+ const timeoutSeconds = validateCodingLimit("timeout", timeout ?? DEFAULT_SHELL_TIMEOUT_SECONDS, HARD_SHELL_TIMEOUT_SECONDS);
163
165
  const shellConfig = getShellConfig(options?.shellPath);
164
166
  try {
165
167
  await fsAccess(cwd, constants.F_OK);
@@ -184,13 +186,11 @@ export function createLocalBashOperations(options) {
184
186
  killProcessTree(child.pid);
185
187
  };
186
188
  try {
187
- if (timeout !== undefined && timeout > 0) {
188
- timeoutHandle = setTimeout(() => {
189
- timedOut = true;
190
- if (child.pid)
191
- killProcessTree(child.pid);
192
- }, timeout * 1000);
193
- }
189
+ timeoutHandle = setTimeout(() => {
190
+ timedOut = true;
191
+ if (child.pid)
192
+ killProcessTree(child.pid);
193
+ }, timeoutSeconds * 1000);
194
194
  child.stdout?.on("data", onData);
195
195
  child.stderr?.on("data", onData);
196
196
  if (signal) {
@@ -203,7 +203,7 @@ export function createLocalBashOperations(options) {
203
203
  if (signal?.aborted)
204
204
  throw new Error("aborted");
205
205
  if (timedOut)
206
- throw new Error(`timeout:${timeout}`);
206
+ throw new Error(`timeout:${timeoutSeconds}`);
207
207
  return { exitCode };
208
208
  }
209
209
  finally {
@@ -224,13 +224,13 @@ function formatOutput(snapshot, lastLineBytes, emptyText = "(no output)") {
224
224
  const endLine = truncation.totalLines;
225
225
  if (truncation.lastLinePartial) {
226
226
  const lastLineSize = formatSize(lastLineBytes);
227
- text += `\n\n[Showing last ${formatSize(truncation.outputBytes)} of line ${endLine} (line is ${lastLineSize}). Full output: ${snapshot.fullOutputPath}]`;
227
+ text += `\n\n[Showing last ${formatSize(truncation.outputBytes)} of line ${endLine} (line is ${lastLineSize}).${snapshot.fullOutputPath ? ` Full output: ${snapshot.fullOutputPath}` : ""}]`;
228
228
  }
229
229
  else if (truncation.truncatedBy === "lines") {
230
- text += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}. Full output: ${snapshot.fullOutputPath}]`;
230
+ text += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}.${snapshot.fullOutputPath ? ` Full output: ${snapshot.fullOutputPath}` : ""}]`;
231
231
  }
232
232
  else {
233
- text += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(truncation.maxBytes)} limit). Full output: ${snapshot.fullOutputPath}]`;
233
+ text += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(truncation.maxBytes)} limit).${snapshot.fullOutputPath ? ` Full output: ${snapshot.fullOutputPath}` : ""}]`;
234
234
  }
235
235
  }
236
236
  return { text, truncation, fullOutputPath: snapshot.fullOutputPath };
@@ -243,18 +243,22 @@ export function createShellTool(cwd, options) {
243
243
  const ops = options?.operations ?? createLocalBashOperations({ shellPath: options?.shellPath });
244
244
  const commandPrefix = options?.commandPrefix;
245
245
  const spawnHook = options?.spawnHook;
246
- const maxLines = options?.maxLines ?? DEFAULT_MAX_LINES;
247
- const maxBytes = options?.maxBytes ?? DEFAULT_MAX_BYTES;
246
+ const maxLines = validateCodingLimit("maxLines", options?.maxLines ?? DEFAULT_MAX_LINES, HARD_MAX_LINES);
247
+ const maxBytes = validateCodingLimit("maxBytes", options?.maxBytes ?? DEFAULT_MAX_BYTES, HARD_MAX_BYTES);
248
+ const defaultTimeout = validateCodingLimit("timeout", options?.timeout ?? DEFAULT_SHELL_TIMEOUT_SECONDS, HARD_SHELL_TIMEOUT_SECONDS);
249
+ const maxTotalOutputBytes = validateCodingLimit("maxTotalOutputBytes", options?.maxTotalOutputBytes ?? DEFAULT_MAX_TOTAL_OUTPUT_BYTES, HARD_MAX_TOTAL_OUTPUT_BYTES);
250
+ if (maxTotalOutputBytes < maxBytes)
251
+ throw new Error("maxTotalOutputBytes must be at least maxBytes");
248
252
  const tempFilePrefix = options?.tempFilePrefix ?? "prism-shell";
249
253
  return {
250
254
  name: "shell",
251
255
  exclusive: true,
252
- description: `Execute a shell command in the current working directory. Returns combined stdout and stderr. Output is truncated to the last ${maxLines} lines or ${maxBytes / 1024}KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.`,
256
+ description: `Execute a shell command in the current working directory. Returns combined stdout and stderr. Output is truncated to the last ${maxLines} lines or ${maxBytes / 1024}KB and capped at ${formatSize(maxTotalOutputBytes)} total. Truncated successful output is saved to a temp file. Timeout defaults to ${defaultTimeout} seconds.`,
253
257
  parameters: {
254
258
  type: "object",
255
259
  properties: {
256
260
  command: { type: "string", description: "Shell command to execute" },
257
- timeout: { type: "number", description: "Timeout in seconds (optional, no default timeout)" },
261
+ timeout: { type: "number", description: `Timeout in seconds (default ${defaultTimeout}, max ${HARD_SHELL_TIMEOUT_SECONDS})` },
258
262
  },
259
263
  required: ["command"],
260
264
  additionalProperties: false,
@@ -262,7 +266,7 @@ export function createShellTool(cwd, options) {
262
266
  async execute(args, context) {
263
267
  const toolCallId = context.toolCallId;
264
268
  const command = typeof args.command === "string" ? args.command : "";
265
- const timeout = typeof args.timeout === "number" ? args.timeout : undefined;
269
+ const timeoutInput = typeof args.timeout === "number" ? args.timeout : undefined;
266
270
  if (command.length === 0) {
267
271
  return {
268
272
  toolCallId,
@@ -271,6 +275,14 @@ export function createShellTool(cwd, options) {
271
275
  error: { message: "command is required and must be a non-empty string." },
272
276
  };
273
277
  }
278
+ let timeout;
279
+ try {
280
+ timeout = validateCodingLimit("timeout", timeoutInput ?? defaultTimeout, HARD_SHELL_TIMEOUT_SECONDS);
281
+ }
282
+ catch (error) {
283
+ const message = error instanceof Error ? error.message : String(error);
284
+ return { toolCallId, name: "shell", content: [{ type: "text", text: message }], error: { message } };
285
+ }
274
286
  const resolvedCommand = commandPrefix ? `${commandPrefix}\n${command}` : command;
275
287
  let spawnContext = spawnHook
276
288
  ? spawnHook({ command: resolvedCommand, cwd, env: { ...process.env } })
@@ -288,77 +300,88 @@ export function createShellTool(cwd, options) {
288
300
  if (policyCheck.action.command) {
289
301
  spawnContext = { ...spawnContext, command: policyCheck.action.command };
290
302
  }
291
- const output = new OutputAccumulator({ maxLines, maxBytes, tempFilePrefix });
303
+ const outputAbort = new AbortController();
304
+ const output = new OutputAccumulator({
305
+ maxLines,
306
+ maxBytes,
307
+ maxTotalOutputBytes,
308
+ tempFilePrefix,
309
+ onLimit: () => outputAbort.abort("output-limit"),
310
+ onStorageError: () => outputAbort.abort("output-storage-error"),
311
+ });
312
+ const signal = context.signal
313
+ ? AbortSignal.any([context.signal, outputAbort.signal])
314
+ : outputAbort.signal;
292
315
  let acceptingOutput = true;
293
316
  const handleData = (data) => {
294
- if (!acceptingOutput)
295
- return;
296
- output.append(data);
317
+ if (acceptingOutput)
318
+ output.append(data);
297
319
  };
298
- const finishOutput = async () => {
320
+ const finishOutput = async (retainSpill) => {
299
321
  acceptingOutput = false;
300
322
  output.finish();
301
323
  const snapshot = output.snapshot({ persistIfTruncated: true });
302
- await output.closeTempFile();
303
- return snapshot;
324
+ if (retainSpill) {
325
+ await output.closeTempFile();
326
+ return snapshot;
327
+ }
328
+ const hadStorageError = output.hasStorageError();
329
+ try {
330
+ await output.cleanupTempFile();
331
+ }
332
+ catch (error) {
333
+ if (!hadStorageError)
334
+ throw error;
335
+ }
336
+ return { ...snapshot, fullOutputPath: undefined };
304
337
  };
305
- // Safety net: never leak an unhandled throw to the host runtime.
306
338
  try {
307
- let exitCode;
339
+ let exitCode = null;
340
+ let executionError;
308
341
  try {
309
- const result = await ops.exec(spawnContext.command, spawnContext.cwd, {
342
+ exitCode = (await ops.exec(spawnContext.command, spawnContext.cwd, {
310
343
  onData: handleData,
311
- signal: context.signal,
344
+ signal,
312
345
  timeout,
313
346
  env: spawnContext.env,
314
- });
315
- exitCode = result.exitCode;
347
+ })).exitCode;
348
+ }
349
+ catch (error) {
350
+ executionError = error;
316
351
  }
317
- catch (err) {
318
- const snapshot = await finishOutput();
352
+ if (executionError !== undefined || output.isOutputLimitExceeded() || output.hasStorageError() || context.signal?.aborted) {
353
+ const outputLimitExceeded = output.isOutputLimitExceeded();
354
+ const outputStorageFailed = output.hasStorageError();
355
+ const snapshot = await finishOutput(false);
319
356
  const { text } = formatOutput(snapshot, output.getLastLineBytes(), "");
320
- const message = err instanceof Error ? err.message : String(err);
321
- const meta = {
322
- exitCode: null,
323
- truncation: snapshot.truncation,
324
- fullOutputPath: snapshot.fullOutputPath,
325
- };
326
- if (message === "aborted") {
327
- return {
328
- toolCallId,
329
- name: "shell",
330
- content: [{ type: "text", text: appendStatus(text, "[Command aborted]") }],
331
- error: { message: "Command aborted" },
332
- metadata: meta,
333
- };
334
- }
335
- if (message.startsWith("timeout:")) {
336
- const timeoutSecs = message.split(":")[1];
337
- const status = `Command timed out after ${timeoutSecs} seconds`;
338
- return {
339
- toolCallId,
340
- name: "shell",
341
- content: [{ type: "text", text: appendStatus(text, `[${status}]`) }],
342
- error: { message: status },
343
- metadata: meta,
344
- };
345
- }
346
- // Spawn error (missing cwd, shell ENOENT, …): message is already host-friendly.
357
+ const rawMessage = executionError instanceof Error ? executionError.message : String(executionError ?? "");
358
+ const status = outputLimitExceeded
359
+ ? `Command output exceeded ${formatSize(maxTotalOutputBytes)} limit`
360
+ : outputStorageFailed
361
+ ? "Command output spill failed"
362
+ : rawMessage.startsWith("timeout:")
363
+ ? `Command timed out after ${rawMessage.split(":")[1]} seconds`
364
+ : context.signal?.aborted
365
+ ? "Command aborted"
366
+ : rawMessage || "Command failed";
347
367
  return {
348
368
  toolCallId,
349
369
  name: "shell",
350
- content: [{ type: "text", text: appendStatus(text, message) }],
351
- error: { message },
352
- metadata: meta,
370
+ content: [{ type: "text", text: appendStatus(text, `[${status}]`) }],
371
+ error: { message: status },
372
+ metadata: {
373
+ exitCode: null,
374
+ truncation: snapshot.truncation,
375
+ totalOutputBytes: output.getTotalRawBytes(),
376
+ outputLimitExceeded,
377
+ outputStorageFailed,
378
+ },
353
379
  };
354
380
  }
355
- const snapshot = await finishOutput();
356
- const formatted = formatOutput(snapshot, output.getLastLineBytes());
357
- let text = formatted.text;
358
- // Non-zero exit is not a tool error: surface exit code in a footer + metadata.
359
- if (exitCode !== 0 && exitCode !== null) {
381
+ const snapshot = await finishOutput(true);
382
+ let text = formatOutput(snapshot, output.getLastLineBytes()).text;
383
+ if (exitCode !== 0 && exitCode !== null)
360
384
  text = appendStatus(text, `[Command exited with code ${exitCode}]`);
361
- }
362
385
  return {
363
386
  toolCallId,
364
387
  name: "shell",
@@ -367,17 +390,18 @@ export function createShellTool(cwd, options) {
367
390
  exitCode,
368
391
  truncation: snapshot.truncation,
369
392
  fullOutputPath: snapshot.fullOutputPath,
393
+ totalOutputBytes: output.getTotalRawBytes(),
394
+ outputLimitExceeded: false,
370
395
  },
371
396
  };
372
397
  }
373
398
  catch (err) {
399
+ try {
400
+ await output.cleanupTempFile();
401
+ }
402
+ catch { /* retain primary error */ }
374
403
  const message = err instanceof Error ? err.message : String(err);
375
- return {
376
- toolCallId,
377
- name: "shell",
378
- content: [{ type: "text", text: message }],
379
- error: { message },
380
- };
404
+ return { toolCallId, name: "shell", content: [{ type: "text", text: message }], error: { message } };
381
405
  }
382
406
  },
383
407
  };
@@ -9,8 +9,7 @@
9
9
  *
10
10
  * Never returns partial lines (except the documented tail single-line edge case).
11
11
  */
12
- export declare const DEFAULT_MAX_LINES = 2000;
13
- export declare const DEFAULT_MAX_BYTES: number;
12
+ export { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from "./limits.js";
14
13
  export interface TruncationOptions {
15
14
  /** Maximum number of lines (default: 2000) */
16
15
  maxLines?: number;
@@ -24,10 +23,14 @@ export interface TruncationResult {
24
23
  truncated: boolean;
25
24
  /** Which limit was hit: "lines", "bytes", or null if not truncated */
26
25
  truncatedBy: "lines" | "bytes" | null;
27
- /** Total number of lines in the original content */
26
+ /** Total lines when known; otherwise a scanned lower bound. */
28
27
  totalLines: number;
29
- /** Total number of bytes in the original content */
28
+ /** Whether `totalLines` is exact (streamed text pages may stop early). */
29
+ totalLinesKnown?: boolean;
30
+ /** Total bytes when known; otherwise scanned bytes. */
30
31
  totalBytes: number;
32
+ /** Whether `totalBytes` is exact (streamed text pages may stop early). */
33
+ totalBytesKnown?: boolean;
31
34
  /** Number of complete lines in the truncated output */
32
35
  outputLines: number;
33
36
  /** Number of bytes in the truncated output */