@sema-agent/core 1.451.0 → 1.452.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/dist/agents/observer.js +3 -0
  2. package/dist/agents/subagent.js +51 -12
  3. package/dist/core/ask-question.js +5 -5
  4. package/dist/core/background-agent-store.d.ts +2 -0
  5. package/dist/core/background-agent-store.js +5 -1
  6. package/dist/core/exec-output-tail.d.ts +18 -1
  7. package/dist/core/exec-output-tail.js +38 -5
  8. package/dist/core/lsp-session.d.ts +1 -0
  9. package/dist/core/lsp-session.js +24 -6
  10. package/dist/core/lsp.d.ts +10 -0
  11. package/dist/core/lsp.js +63 -6
  12. package/dist/core/mailbox-store.d.ts +3 -2
  13. package/dist/core/mailbox-store.js +19 -4
  14. package/dist/core/memory.js +1 -1
  15. package/dist/core/runner/prepare-task.js +10 -1
  16. package/dist/core/runner/runtask.js +1 -1
  17. package/dist/core/session-reconcile.js +5 -2
  18. package/dist/core/task-notification.d.ts +1 -0
  19. package/dist/core/task-registry.d.ts +15 -9
  20. package/dist/core/task-registry.js +290 -53
  21. package/dist/core/tool-result-store.js +2 -2
  22. package/dist/core/tools.d.ts +5 -0
  23. package/dist/core/tools.js +3 -0
  24. package/dist/core/types.d.ts +1 -0
  25. package/dist/core/workflow-journal-store.d.ts +2 -0
  26. package/dist/core/workflow-journal-store.js +14 -0
  27. package/dist/engine/execution-env/node-execution-env.d.ts +1 -0
  28. package/dist/engine/execution-env/node-execution-env.js +130 -20
  29. package/dist/engine/lsp/node-lsp-manager.d.ts +3 -1
  30. package/dist/engine/lsp/node-lsp-manager.js +22 -5
  31. package/dist/engine/lsp/stdio-lsp-transport.d.ts +1 -1
  32. package/dist/engine/lsp/stdio-lsp-transport.js +17 -6
  33. package/dist/index.d.ts +2 -2
  34. package/dist/index.js +2 -2
  35. package/dist/orchestration/run-workflow-tool.d.ts +1 -0
  36. package/dist/orchestration/run-workflow-tool.js +29 -6
  37. package/dist/orchestration/workflow.d.ts +9 -0
  38. package/dist/orchestration/workflow.js +80 -5
  39. package/dist/stores/cc/mailbox-store.js +6 -1
  40. package/dist/stores/file/background-agent-store.js +3 -2
  41. package/dist/stores/file/mailbox-store.d.ts +1 -1
  42. package/dist/stores/file/mailbox-store.js +9 -7
  43. package/dist/tools/fs/encoding.d.ts +5 -0
  44. package/dist/tools/fs/encoding.js +6 -0
  45. package/dist/tools/fs/index.js +184 -120
  46. package/dist/tools/fs/notebook.d.ts +43 -0
  47. package/dist/tools/fs/notebook.js +141 -0
  48. package/dist/tools/fs/repo-map.js +2 -2
  49. package/dist/tools/fs/search.js +141 -12
  50. package/dist/tools/gitea-issue.js +4 -2
  51. package/dist/tools/monitor.js +12 -8
  52. package/dist/tools/scheduler-tools.js +16 -16
  53. package/dist/tools/task-list.js +34 -12
  54. package/dist/tools/web.d.ts +2 -0
  55. package/dist/tools/web.js +105 -19
  56. package/dist/tools/worktree.js +14 -14
  57. package/package.json +1 -1
@@ -1,13 +1,14 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { Type } from "typebox";
3
- import { defineTool } from "../../core/tools.js";
3
+ import { defineTool, errorResult } from "../../core/tools.js";
4
4
  import { createTaskOutputTool, createTaskStopTool, defaultTaskRegistry } from "../../core/task-registry.js";
5
5
  import { hasBackgroundShell } from "../../core/background-shell.js";
6
6
  import { delimitUntrusted } from "../../core/untrusted-text.js";
7
7
  import { clipWithFilePointer } from "../../core/tool-errors.js";
8
8
  import { sha256, resolveKey, violationText, requireRead, checkStale, checkEditMatch, checkNoChange, hasBinaryExtension, isBinaryContent, fileArgPath, imageMimeForRead, imageMagicMatches, similarNameSuggestion, resolveQuoteMatch, adaptNewStringQuotes, deletionOldString, countOccurrences, OVERSIZE_READ_ESCAPE_HINT, isBlockedDevicePath, normalizeAbsPathLexically, } from "./safety.js";
9
- import { decodeTextBytes, encodeTextForFile, normalizeEditText } from "./encoding.js";
9
+ import { decodeTextBytes, encodeTextForFile, normalizeEditText, normalizeFileText, splitLeadingBom } from "./encoding.js";
10
10
  import { runGrepDetailed, runGlobDetailed, splitAbsoluteGlobPattern, shellQuote } from "./search.js";
11
+ import { isNotebookPath, parseNotebookCells, renderNotebookCells, stripNotebookImageData, NOTEBOOK_IMAGE_BASE64_BUDGET } from "./notebook.js";
11
12
  import { makeRepoMapTool } from "./repo-map.js";
12
13
  import { ghRateLimitHint } from "./gh-rate-limit.js";
13
14
  import { MCP_IMAGE_MAX_BASE64, IMAGE_TARGET_RAW_SIZE, sharpImageDownsampler } from "../../core/mcp.js";
@@ -35,10 +36,13 @@ function decodeEditBytes(bytes, path) {
35
36
  return { ok: false, message: `Error (Edit): "${path}" is too large to edit (${formatByteSize(bytes.byteLength)}). Maximum editable file size is ${formatByteSize(MAX_EDIT_BYTES)}.` };
36
37
  }
37
38
  }
39
+ function persistedTextOf(encoded) {
40
+ return decodeTextBytes(typeof encoded === "string" ? Buffer.from(encoded, "utf8") : encoded).text;
41
+ }
38
42
  async function notReadRefusalText(env, toolName, key, v, signal) {
39
43
  const base = `Error (${toolName}): ${v.message}`;
40
44
  const info = await env.fileInfo(key, signal);
41
- return info.ok && info.value.kind !== "directory" && info.value.size > MAX_READ_BYTES ? `${base} ${OVERSIZE_READ_ESCAPE_HINT}` : base;
45
+ return info.ok && info.value.kind !== "directory" && info.value.size > MAX_READ_BYTES && !isNotebookPath(key) ? `${base} ${OVERSIZE_READ_ESCAPE_HINT}` : base;
42
46
  }
43
47
  const MAX_IMAGE_READ_BYTES = 5 * 1024 * 1024;
44
48
  const MAX_IMAGE_DOWNSAMPLE_INPUT_BYTES = 64 * 1024 * 1024;
@@ -107,7 +111,7 @@ export function isReadDedupStubResult(resultText) {
107
111
  resultText.includes("has not changed on disk. Use that content instead of re-reading"));
108
112
  }
109
113
  export function seedReadFileStateFromContext(state, key, content) {
110
- state.set(key, { hash: sha256(content), totalLines: countLines(content), truncated: false, lastReadAt: Date.now(), seededFromContext: true });
114
+ state.set(key, { hash: sha256(normalizeFileText(content)), totalLines: countLines(content), truncated: false, lastReadAt: Date.now(), seededFromContext: true });
111
115
  }
112
116
  export function applyCompactionToReadFileState(state, attachedComplete, preserveKeys = []) {
113
117
  const preserve = new Set(preserveKeys);
@@ -371,6 +375,9 @@ async function readPdfFile(env, path, key, pages, signal, downsamplerOpt, cwd, p
371
375
  },
372
376
  };
373
377
  }
378
+ function pdfResultToToolReturn(r) {
379
+ return typeof r === "string" ? errorResult(r) : r;
380
+ }
374
381
  export function makeReadFileTool(env, state, rootCanonical, cwdRef, additionalRoots, imageDownsampler, pdfCapabilities, bgOutputReadExemption) {
375
382
  return defineTool({
376
383
  name: "Read",
@@ -381,7 +388,7 @@ export function makeReadFileTool(env, state, rootCanonical, cwdRef, additionalRo
381
388
  "- By default, it reads the whole file (from `offset`, 1-based); a very large file is served as a partial view with an explicit marker and the next-page call.\n" +
382
389
  "- When you already know which part of the file you need, only read that part. This can be important for larger files.\n" +
383
390
  "- Results are returned using cat -n format, with line numbers starting at 1\n" +
384
- "- Reads images (PNG, JPG, …) and presents them visually. Reads PDFs via the `pages` parameter (e.g. \"1-5\", max 20 pages/request; a PDF whose detected page count exceeds 10 requires `pages`). Reads Jupyter notebooks (.ipynb) as JSON text.\n" +
391
+ "- Reads images (PNG, JPG, …) and presents them visually. Reads PDFs via the `pages` parameter (e.g. \"1-5\", max 20 pages/request; a PDF whose detected page count exceeds 10 requires `pages`). Reads Jupyter notebooks (.ipynb) as cells with outputs (offset/limit do not apply).\n" +
385
392
  "- Reading a directory, a missing file, or an empty file returns an error or warning rather than content.\n" +
386
393
  "- Other binaries (archives, executables) are refused. A whole-file read of a text file over 256 KB is refused — read it in slices with explicit offset/limit, or Grep it instead.\n" +
387
394
  "- You must read a file before editing it.\n" +
@@ -396,6 +403,7 @@ export function makeReadFileTool(env, state, rootCanonical, cwdRef, additionalRo
396
403
  "- This tool can only read files, not directories. To read a directory, use Glob/Grep or an ls command via the Bash tool.\n" +
397
404
  "- You must read a file before editing it.\n" +
398
405
  "- Reads PDFs (.pdf): the document is provided to the model directly (text layer included). Use the `pages` parameter (e.g., \"1-5\") to read a page range; a PDF whose detected page count exceeds 10 requires `pages`. Maximum 20 pages per request.\n" +
406
+ "- Reads Jupyter notebooks (.ipynb) as cells with outputs (offset/limit do not apply).\n" +
399
407
  "- Other binaries (archives, executables) are refused. A whole-file read of a text file over 256 KB is refused — read it in slices with explicit offset/limit, or Grep it instead.\n" +
400
408
  "- If you read a file that exists but has empty contents you will receive a warning in place of file contents.\n" +
401
409
  "- Do NOT re-read a file you just edited to verify — Edit/Write would have errored if the change failed, and the harness tracks file state for you.",
@@ -410,35 +418,36 @@ export function makeReadFileTool(env, state, rootCanonical, cwdRef, additionalRo
410
418
  const { offset, limit, pages } = args;
411
419
  const path = fileArgPath(args);
412
420
  if (path === undefined)
413
- return `Error (Read): file_path is required.`;
421
+ return errorResult(`Error (Read): file_path is required.`);
414
422
  const r = await resolveKey(env, rootCanonical, path, ctx.signal, cwdRef?.current, additionalRoots, bgOutputReadExemption === undefined ? undefined : (key) => bgOutputReadExemption(key, ctx));
415
423
  if (!r.ok)
416
- return violationText("Read", r.violation);
424
+ return errorResult(violationText("Read", r.violation));
425
+ const isNb = isNotebookPath(r.key);
417
426
  const imageMime = imageMimeForRead(r.key);
418
427
  if (imageMime !== undefined) {
419
428
  const meta = await env.fileInfo(r.key, ctx.signal);
420
429
  if (!meta.ok) {
421
430
  const ex = await env.exists(r.key, ctx.signal);
422
431
  if (ex.ok && !ex.value)
423
- return `Error (Read): ${await enoentMessage(env, r.key, cwdRef?.current ?? rootCanonical, ctx.signal)}`;
424
- return `Error (Read): cannot stat image "${path}" to verify its size before reading: ${meta.error.message}`;
432
+ return errorResult(`Error (Read): ${await enoentMessage(env, r.key, cwdRef?.current ?? rootCanonical, ctx.signal)}`);
433
+ return errorResult(`Error (Read): cannot stat image "${path}" to verify its size before reading: ${meta.error.message}`);
425
434
  }
426
435
  if (meta.value.kind === "directory") {
427
- return `Error (Read): "${path}" is a directory, not a file; use glob/grep or list it with the shell.`;
436
+ return errorResult(`Error (Read): "${path}" is a directory, not a file; use glob/grep or list it with the shell.`);
428
437
  }
429
438
  const downsampler = imageDownsampler === false ? undefined : (imageDownsampler ?? (await (autoDownsampler ??= sharpImageDownsampler())));
430
439
  const readCap = downsampler ? MAX_IMAGE_DOWNSAMPLE_INPUT_BYTES : MAX_IMAGE_READ_BYTES;
431
440
  if (meta.value.size > readCap) {
432
- return `Error (Read): image "${path}" is too large to read (${meta.value.size} bytes > ${readCap}-byte cap).`;
441
+ return errorResult(`Error (Read): image "${path}" is too large to read (${meta.value.size} bytes > ${readCap}-byte cap).`);
433
442
  }
434
443
  const bin = await env.readBinaryFile(r.key, ctx.signal);
435
444
  if (!bin.ok)
436
- return `Error (Read): cannot read image "${path}": ${bin.error.message}`;
445
+ return errorResult(`Error (Read): cannot read image "${path}": ${bin.error.message}`);
437
446
  if (bin.value.byteLength > readCap) {
438
- return `Error (Read): image "${path}" is too large to read (${bin.value.byteLength} bytes > ${readCap}-byte cap).`;
447
+ return errorResult(`Error (Read): image "${path}" is too large to read (${bin.value.byteLength} bytes > ${readCap}-byte cap).`);
439
448
  }
440
449
  if (!imageMagicMatches(bin.value, imageMime)) {
441
- return `Error (Read): "${path}" has an image extension but its content is not a valid ${imageMime} (empty, truncated, or mis-named).`;
450
+ return errorResult(`Error (Read): "${path}" has an image extension but its content is not a valid ${imageMime} (empty, truncated, or mis-named).`);
442
451
  }
443
452
  let base64;
444
453
  let mime = imageMime;
@@ -453,7 +462,7 @@ export function makeReadFileTool(env, state, rootCanonical, cwdRef, additionalRo
453
462
  }
454
463
  base64 ??= Buffer.from(bin.value).toString("base64");
455
464
  if (base64.length > MCP_IMAGE_MAX_BASE64) {
456
- return (`Error (Read): image "${path}" is too large to send (${bin.value.byteLength} bytes; base64 ${base64.length} chars exceeds the ${MCP_IMAGE_MAX_BASE64}-char / 5MB API image limit` +
465
+ return errorResult(`Error (Read): image "${path}" is too large to send (${bin.value.byteLength} bytes; base64 ${base64.length} chars exceeds the ${MCP_IMAGE_MAX_BASE64}-char / 5MB API image limit` +
457
466
  (downsampler
458
467
  ? "; downsampling could not bring it under the limit)."
459
468
  : `; raw images over ${IMAGE_TARGET_RAW_SIZE} bytes always exceed it — shrink it first, e.g. with an image tool via bash).`));
@@ -476,58 +485,62 @@ export function makeReadFileTool(env, state, rootCanonical, cwdRef, additionalRo
476
485
  };
477
486
  }
478
487
  if (r.key.toLowerCase().endsWith(".pdf")) {
479
- return await readPdfFile(env, path, r.key, pages, ctx.signal, imageDownsampler, cwdRef?.current ?? rootCanonical, undefined, pdfCapabilities);
488
+ return pdfResultToToolReturn(await readPdfFile(env, path, r.key, pages, ctx.signal, imageDownsampler, cwdRef?.current ?? rootCanonical, undefined, pdfCapabilities));
480
489
  }
481
490
  if (hasBinaryExtension(r.key)) {
482
- return `Error (Read): "${path}" appears to be a binary file (by extension); this tool reads UTF-8 text only.`;
491
+ return errorResult(`Error (Read): "${path}" appears to be a binary file (by extension); this tool reads UTF-8 text only.`);
483
492
  }
484
493
  const info = await env.fileInfo(r.key, ctx.signal);
485
494
  if (info.ok) {
486
495
  if (info.value.kind === "directory") {
487
- return `Error (Read): "${path}" is a directory, not a file; use glob/grep or list it with the shell.`;
496
+ return errorResult(`Error (Read): "${path}" is a directory, not a file; use glob/grep or list it with the shell.`);
488
497
  }
489
498
  if (info.value.size > SLICED_READ_MAX_BYTES) {
490
- return (`Error (Read): "${path}" is too large to read with this tool even as an offset/limit slice ` +
499
+ return errorResult(`Error (Read): "${path}" is too large to read with this tool even as an offset/limit slice ` +
491
500
  `(${info.value.size} bytes > ${SLICED_READ_MAX_BYTES}-byte cap — the reader loads the whole file into memory before slicing). ` +
492
501
  `Stream a portion with bash instead, e.g. \`sed -n '1,200p' <file>\` for a line range or \`head -c 65536 <file>\` for the leading bytes, or use grep to search it.`);
493
502
  }
494
- if (info.value.size > MAX_READ_BYTES && offset === undefined && limit === undefined) {
495
- return `Error (Read): "${path}" is too large to read in full (${info.value.size} bytes > ${MAX_READ_BYTES}-byte cap); pass an explicit offset/limit to read a slice, or use grep to search it instead.`;
503
+ if (!isNb && info.value.size > MAX_READ_BYTES && offset === undefined && limit === undefined) {
504
+ return errorResult(`Error (Read): "${path}" is too large to read in full (${info.value.size} bytes > ${MAX_READ_BYTES}-byte cap); pass an explicit offset/limit to read a slice, or use grep to search it instead.`);
496
505
  }
497
506
  }
498
507
  else {
499
508
  const ex = await env.exists(r.key, ctx.signal);
500
509
  if (ex.ok && !ex.value)
501
- return `Error (Read): ${await enoentMessage(env, r.key, cwdRef?.current ?? rootCanonical, ctx.signal)}`;
510
+ return errorResult(`Error (Read): ${await enoentMessage(env, r.key, cwdRef?.current ?? rootCanonical, ctx.signal)}`);
502
511
  }
503
512
  const readBin = await env.readBinaryFile(r.key, ctx.signal);
504
513
  if (!readBin.ok)
505
- return `Error (Read): cannot read "${path}": ${readBin.error.message}`;
514
+ return errorResult(`Error (Read): cannot read "${path}": ${readBin.error.message}`);
506
515
  const readSize = readBin.value.byteLength;
507
516
  if (readSize > SLICED_READ_MAX_BYTES) {
508
- return (`Error (Read): "${path}" is too large to read with this tool even as an offset/limit slice ` +
517
+ return errorResult(`Error (Read): "${path}" is too large to read with this tool even as an offset/limit slice ` +
509
518
  `(${readSize} bytes > ${SLICED_READ_MAX_BYTES}-byte cap — the reader loads the whole file into memory before slicing). ` +
510
519
  `Stream a portion with bash instead, e.g. \`sed -n '1,200p' <file>\` for a line range or \`head -c 65536 <file>\` for the leading bytes, or use grep to search it.`);
511
520
  }
512
- if (readSize > MAX_READ_BYTES && offset === undefined && limit === undefined) {
513
- return `Error (Read): "${path}" is too large to read in full (${readSize} bytes > ${MAX_READ_BYTES}-byte cap); pass an explicit offset/limit to read a slice, or use grep to search it instead.`;
521
+ if (!isNb && readSize > MAX_READ_BYTES && offset === undefined && limit === undefined) {
522
+ return errorResult(`Error (Read): "${path}" is too large to read in full (${readSize} bytes > ${MAX_READ_BYTES}-byte cap); pass an explicit offset/limit to read a slice, or use grep to search it instead.`);
514
523
  }
515
524
  if (pdfMagicMatches(readBin.value)) {
516
- return await readPdfFile(env, path, r.key, pages, ctx.signal, imageDownsampler, cwdRef?.current ?? rootCanonical, readBin.value, pdfCapabilities);
525
+ return pdfResultToToolReturn(await readPdfFile(env, path, r.key, pages, ctx.signal, imageDownsampler, cwdRef?.current ?? rootCanonical, readBin.value, pdfCapabilities));
517
526
  }
518
527
  const decoded = decodeTextBytes(readBin.value);
519
528
  if (decoded.malformed) {
520
- return `Error (Read): "${path}" has a UTF-16 BOM but a truncated (odd-length) body — the file is corrupt or mis-labelled; repair/convert it with bash (e.g. \`iconv\`) first.`;
529
+ return errorResult(`Error (Read): "${path}" has a UTF-16 BOM but a truncated (odd-length) body — the file is corrupt or mis-labelled; repair/convert it with bash (e.g. \`iconv\`) first.`);
521
530
  }
522
531
  const content = decoded.text;
523
532
  if (isBinaryContent(content.slice(0, 4096))) {
524
- return (`Error (Read): "${path}" appears to be a binary file (non-text content); this tool reads UTF-8 and BOM-marked UTF-16LE text only. ` +
533
+ return errorResult(`Error (Read): "${path}" appears to be a binary file (non-text content); this tool reads UTF-8 and BOM-marked UTF-16LE text only. ` +
525
534
  `If it is UTF-16 without a BOM or a legacy encoding, convert it first (e.g. \`iconv -f UTF-16LE -t UTF-8\`) or inspect/transform it with bash.`);
526
535
  }
536
+ const nbParsed = isNb ? parseNotebookCells(content, path) : undefined;
537
+ const nbForced = nbParsed !== undefined && nbParsed.ok;
538
+ const effOffset = nbForced ? undefined : offset;
539
+ const effLimit = nbForced ? undefined : limit;
527
540
  const lines = content.length === 0 ? [] : content.split("\n");
528
541
  const total = lines.length;
529
- const start = Math.max(1, Math.floor(offset ?? 1));
530
- const max = limit !== undefined ? Math.max(1, Math.floor(limit)) : Number.MAX_SAFE_INTEGER;
542
+ const start = Math.max(1, Math.floor(effOffset ?? 1));
543
+ const max = effLimit !== undefined ? Math.max(1, Math.floor(effLimit)) : Number.MAX_SAFE_INTEGER;
531
544
  if (total > 0 && start > total) {
532
545
  return `<system-reminder>Warning: the file exists but is shorter than the provided offset (${start}). The file has ${total} lines.</system-reminder>`;
533
546
  }
@@ -538,13 +551,41 @@ export function makeReadFileTool(env, state, rootCanonical, cwdRef, additionalRo
538
551
  end = total;
539
552
  }
540
553
  const hash = sha256(content);
554
+ let nbFallbackPrefix = "";
555
+ if (nbParsed !== undefined) {
556
+ if (nbParsed.ok) {
557
+ const parsed = nbParsed;
558
+ const rendered = renderNotebookCells(parsed.cells, NOTEBOOK_IMAGE_BASE64_BUDGET);
559
+ if (rendered.textChars > MAX_READ_OUTPUT_CHARS) {
560
+ return errorResult(`Error (Read): notebook "${path}" projects to ~${Math.round(rendered.textChars / 4)} tokens of cell content, over the 25000-token limit ` +
561
+ `(offset/limit do not apply to notebooks — the whole notebook is always projected). Inspect portions with Bash + jq instead, e.g.\n` +
562
+ ` cat "${path}" | jq '.cells[:20]' # First 20 cells\n` +
563
+ ` cat "${path}" | jq '.cells | length' # Count total cells\n` +
564
+ ` cat "${path}" | jq '.cells[] | select(.cell_type=="code") | .source' # All code sources`);
565
+ }
566
+ const prevNb = state.get(r.key);
567
+ if (prevNb?.seededFromContext && prevNb.hash === hash) {
568
+ return seededFileUnchangedReminder(r.key);
569
+ }
570
+ if (prevNb && prevNb.hash === hash && prevNb.view && prevNb.view.start === 1 && prevNb.view.end === total) {
571
+ return `[${path}: unchanged since you last read it (lines 1-${total} of ${total}); content omitted to save context]`;
572
+ }
573
+ state.set(r.key, { hash, totalLines: countLines(content), truncated: false, view: { start: 1, end: total }, lastReadAt: Date.now() });
574
+ const bodyBlocks = rendered.blocks.length > 0 ? rendered.blocks : [{ type: "text", text: "[notebook has 0 cells]" }];
575
+ return {
576
+ content: [...bodyBlocks, { type: "text", text: READ_CYBER_REMINDER }],
577
+ details: { type: "notebook", file: { filePath: path, cells: parsed.cells.map(stripNotebookImageData) } },
578
+ };
579
+ }
580
+ nbFallbackPrefix = "[not a parseable notebook — showing raw file text]\n";
581
+ }
541
582
  const renderBody = (sl) => sl.map((l, i) => `${start + i}\t${l}`).join("\n");
542
583
  let body = renderBody(slice);
543
584
  let pageMarker;
544
585
  if (body.length > MAX_READ_OUTPUT_CHARS) {
545
- const defaultFullRead = start <= 1 && limit === undefined;
586
+ const defaultFullRead = start <= 1 && effLimit === undefined;
546
587
  if (!defaultFullRead) {
547
- return `Error (Read): the requested range of "${path}" is ~${Math.round(body.length / 4)} tokens, over the 25000-token limit. Narrow it with offset/limit, or use grep to search the file.`;
588
+ return errorResult(`Error (Read): the requested range of "${path}" is ~${Math.round(body.length / 4)} tokens, over the 25000-token limit. Narrow it with offset/limit, or use grep to search the file.`);
548
589
  }
549
590
  const approxTokens = Math.round(body.length / 4);
550
591
  let n = Math.max(1, Math.min(slice.length, Math.floor(slice.length * (MAX_READ_OUTPUT_CHARS / body.length) * 0.95)));
@@ -580,7 +621,7 @@ export function makeReadFileTool(env, state, rootCanonical, cwdRef, additionalRo
580
621
  }
581
622
  const truncated = start > 1 || end < total || pageMarker !== undefined;
582
623
  const prev = state.get(r.key);
583
- if (total > 0 && prev?.seededFromContext && start === 1 && limit === undefined && prev.hash === hash) {
624
+ if (total > 0 && prev?.seededFromContext && start === 1 && effLimit === undefined && prev.hash === hash) {
584
625
  return seededFileUnchangedReminder(r.key);
585
626
  }
586
627
  if (total > 0 && prev && prev.hash === hash && prev.view && prev.view.start === start && prev.view.end === end) {
@@ -598,7 +639,7 @@ export function makeReadFileTool(env, state, rootCanonical, cwdRef, additionalRo
598
639
  return `<system-reminder>Warning: the file exists but is shorter than the provided offset (${start}). The file has 1 lines.</system-reminder>`;
599
640
  const header = pageMarker ?? (truncated ? `[${path}: lines ${start}-${end} of ${total}${end < total ? " — use offset to see more" : ""}]\n` : "");
600
641
  return {
601
- content: `${header}${body}${READ_CYBER_REMINDER}`,
642
+ content: `${nbFallbackPrefix}${header}${body}${READ_CYBER_REMINDER}`,
602
643
  details: {
603
644
  type: "text",
604
645
  file: {
@@ -653,107 +694,115 @@ export function makeEditFileTool(env, state, rootCanonical, cwdRef, additionalRo
653
694
  ? a.edits
654
695
  : [{ old_string: a.old_string ?? "", new_string: a.new_string ?? "", ...(a.replace_all !== undefined ? { replace_all: a.replace_all } : {}) }];
655
696
  if (!batch && (typeof a.old_string !== "string" || typeof a.new_string !== "string")) {
656
- return `Error (Edit): old_string and new_string are required.`;
697
+ return errorResult(`Error (Edit): old_string and new_string are required.`);
657
698
  }
658
699
  for (let i = 0; i < edits.length; i++) {
659
700
  const e = edits[i];
660
701
  if (!e || typeof e.old_string !== "string" || typeof e.new_string !== "string") {
661
- return `Error (Edit): edit ${i + 1} of ${edits.length}: old_string and new_string must both be strings (no changes written — the batch is atomic).`;
702
+ return errorResult(`Error (Edit): edit ${i + 1} of ${edits.length}: old_string and new_string must both be strings (no changes written — the batch is atomic).`);
662
703
  }
663
704
  }
664
705
  const path = fileArgPath(args);
665
706
  if (path === undefined)
666
- return `Error (Edit): file_path is required.`;
707
+ return errorResult(`Error (Edit): file_path is required.`);
667
708
  const ipynb = ipynbRedirect("Edit", path);
668
709
  if (ipynb)
669
- return ipynb;
710
+ return errorResult(ipynb);
670
711
  const r = await resolveKey(env, rootCanonical, path, ctx.signal, cwdRef?.current, additionalRoots);
671
712
  if (!r.ok)
672
- return violationText("Edit", r.violation);
713
+ return errorResult(violationText("Edit", r.violation));
673
714
  const singleOld = batch ? undefined : a.old_string;
674
715
  const exists = await env.exists(r.key, ctx.signal);
675
716
  if (!exists.ok)
676
- return `Error (Edit): cannot stat "${path}": ${exists.error.message}`;
717
+ return errorResult(`Error (Edit): cannot stat "${path}": ${exists.error.message}`);
677
718
  if (!exists.value) {
678
719
  if (singleOld === "") {
679
720
  const created = a.new_string ?? "";
680
721
  if (created === "")
681
- return violationText("Edit", { code: "invalid", message: "No changes to make: old_string and new_string are exactly the same." });
722
+ return errorResult(violationText("Edit", { code: "invalid", message: "No changes to make: old_string and new_string are exactly the same." }));
682
723
  const gated = await gateToolWrite(beforeWrite, "Edit", path, r.key, created);
683
724
  if (gated !== undefined)
684
- return gated;
725
+ return errorResult(gated);
685
726
  const write = env.writeFileExclusive !== undefined
686
727
  ? await env.writeFileExclusive(r.key, created, ctx.signal)
687
728
  : await env.writeFile(r.key, created, ctx.signal);
688
729
  if (!write.ok) {
689
730
  if (write.error.code === "already_exists") {
690
- return `Error (Edit): cannot create "${path}": file already exists (created concurrently since the existence check). Read the file first, then edit it normally (or Write after the Read to overwrite it).`;
731
+ return errorResult(`Error (Edit): cannot create "${path}": file already exists (created concurrently since the existence check). Read the file first, then edit it normally (or Write after the Read to overwrite it).`);
691
732
  }
692
- return `Error (Edit): cannot write "${path}": ${write.error.message}`;
733
+ return errorResult(`Error (Edit): cannot write "${path}": ${write.error.message}`);
693
734
  }
694
- const createdNorm = normalizeEditText(created);
735
+ const createdNorm = normalizeFileText(created);
695
736
  state.set(r.key, { hash: sha256(createdNorm), totalLines: countLines(createdNorm), truncated: false, lastReadAt: Date.now() });
696
737
  return {
697
738
  content: `Created ${path} (${countLines(createdNorm)} lines).`,
698
739
  details: { type: "create", filePath: path, content: created },
699
740
  };
700
741
  }
701
- return `Error (Edit): ${await enoentMessage(env, r.key, cwdRef?.current ?? rootCanonical, ctx.signal)}`;
742
+ return errorResult(`Error (Edit): ${await enoentMessage(env, r.key, cwdRef?.current ?? rootCanonical, ctx.signal)}`);
702
743
  }
703
744
  const editInfo = await env.fileInfo(r.key, ctx.signal);
704
745
  if (editInfo.ok && editInfo.value.size > MAX_EDIT_BYTES) {
705
- return `Error (Edit): "${path}" is too large to edit (${formatByteSize(editInfo.value.size)}). Maximum editable file size is ${formatByteSize(MAX_EDIT_BYTES)}.`;
746
+ return errorResult(`Error (Edit): "${path}" is too large to edit (${formatByteSize(editInfo.value.size)}). Maximum editable file size is ${formatByteSize(MAX_EDIT_BYTES)}.`);
706
747
  }
707
748
  if (singleOld === "") {
708
749
  const preBin = await env.readBinaryFile(r.key, ctx.signal);
709
750
  if (!preBin.ok)
710
- return `Error (Edit): cannot read "${path}": ${preBin.error.message}`;
751
+ return errorResult(`Error (Edit): cannot read "${path}": ${preBin.error.message}`);
711
752
  if (preBin.value.byteLength > MAX_EDIT_BYTES) {
712
- return `Error (Edit): "${path}" is too large to edit (${formatByteSize(preBin.value.byteLength)}). Maximum editable file size is ${formatByteSize(MAX_EDIT_BYTES)}.`;
753
+ return errorResult(`Error (Edit): "${path}" is too large to edit (${formatByteSize(preBin.value.byteLength)}). Maximum editable file size is ${formatByteSize(MAX_EDIT_BYTES)}.`);
713
754
  }
714
755
  const preDecResult = decodeEditBytes(preBin.value, path);
715
756
  if (!preDecResult.ok)
716
- return preDecResult.message;
757
+ return errorResult(preDecResult.message);
717
758
  const preDec = preDecResult.value;
718
759
  if (preDec.malformed)
719
- return `Error (Edit): "${path}" has a truncated UTF-16 body; repair/convert it with bash first.`;
760
+ return errorResult(`Error (Edit): "${path}" has a truncated UTF-16 body; repair/convert it with bash first.`);
720
761
  if (preDec.text.trim() !== "")
721
- return `Error (Edit): Cannot create new file - file already exists.`;
762
+ return errorResult(`Error (Edit): Cannot create new file - file already exists.`);
763
+ const priorRead = state.get(r.key);
764
+ if (priorRead !== undefined) {
765
+ const staleCreate = checkStale(priorRead, sha256(preDec.text));
766
+ if (staleCreate)
767
+ return errorResult(violationText("Edit", staleCreate));
768
+ }
722
769
  const newContent = normalizeEditText(a.new_string ?? "");
723
770
  if (newContent === preDec.text)
724
- return violationText("Edit", { code: "invalid", message: "No changes to make: old_string and new_string are exactly the same." });
771
+ return errorResult(violationText("Edit", { code: "invalid", message: "No changes to make: old_string and new_string are exactly the same." }));
725
772
  const gated = await gateToolWrite(beforeWrite, "Edit", path, r.key, newContent);
726
773
  if (gated !== undefined)
727
- return gated;
728
- const write = await env.writeFile(r.key, encodeTextForFile(newContent, preDec.encoding, preDec.endings), ctx.signal);
774
+ return errorResult(gated);
775
+ const encodedOverwrite = encodeTextForFile(newContent, preDec.encoding, preDec.endings);
776
+ const write = await env.writeFile(r.key, encodedOverwrite, ctx.signal);
729
777
  if (!write.ok)
730
- return `Error (Edit): cannot write "${path}": ${write.error.message}`;
731
- state.set(r.key, { hash: sha256(newContent), totalLines: countLines(newContent), truncated: false, lastReadAt: Date.now() });
778
+ return errorResult(`Error (Edit): cannot write "${path}": ${write.error.message}`);
779
+ const persistedOverwrite = persistedTextOf(encodedOverwrite);
780
+ state.set(r.key, { hash: sha256(persistedOverwrite), totalLines: countLines(persistedOverwrite), truncated: false, lastReadAt: Date.now() });
732
781
  return {
733
- content: `Created ${path} (${countLines(newContent)} lines).`,
782
+ content: `The file ${path} has been updated successfully.${FILE_STATE_TRAILER}`,
734
783
  details: { type: "edit", filePath: path, originalFile: preDec.text, oldString: "", newString: newContent, replaceAll: false, edits: [{ oldString: "", newString: newContent, replaceAll: false }] },
735
784
  };
736
785
  }
737
786
  const notRead = requireRead(state, r.key);
738
787
  if (notRead)
739
- return await notReadRefusalText(env, "Edit", r.key, notRead, ctx.signal);
788
+ return errorResult(await notReadRefusalText(env, "Edit", r.key, notRead, ctx.signal));
740
789
  const readBin = await env.readBinaryFile(r.key, ctx.signal);
741
790
  if (!readBin.ok)
742
- return `Error (Edit): cannot read "${path}": ${readBin.error.message}`;
791
+ return errorResult(`Error (Edit): cannot read "${path}": ${readBin.error.message}`);
743
792
  if (readBin.value.byteLength > MAX_EDIT_BYTES) {
744
- return `Error (Edit): "${path}" is too large to edit (${formatByteSize(readBin.value.byteLength)}). Maximum editable file size is ${formatByteSize(MAX_EDIT_BYTES)}.`;
793
+ return errorResult(`Error (Edit): "${path}" is too large to edit (${formatByteSize(readBin.value.byteLength)}). Maximum editable file size is ${formatByteSize(MAX_EDIT_BYTES)}.`);
745
794
  }
746
795
  const decodedResult = decodeEditBytes(readBin.value, path);
747
796
  if (!decodedResult.ok)
748
- return decodedResult.message;
797
+ return errorResult(decodedResult.message);
749
798
  const decoded = decodedResult.value;
750
799
  if (decoded.malformed)
751
- return `Error (Edit): "${path}" has a truncated UTF-16 body; repair/convert it with bash first.`;
800
+ return errorResult(`Error (Edit): "${path}" has a truncated UTF-16 body; repair/convert it with bash first.`);
752
801
  const original = decoded.text;
753
802
  const entry = state.get(r.key);
754
803
  const stale = checkStale(entry, sha256(original));
755
804
  if (stale)
756
- return violationText("Edit", stale);
805
+ return errorResult(violationText("Edit", stale));
757
806
  let working = original;
758
807
  let replacements = 0;
759
808
  const appliedEdits = [];
@@ -764,7 +813,7 @@ export function makeEditFileTool(env, state, rootCanonical, cwdRef, additionalRo
764
813
  let newS = normalizeEditText(e.new_string);
765
814
  const noChange = checkNoChange(oldS, newS);
766
815
  if (noChange)
767
- return batch ? `Error (Edit): ${where}${noChange.message}` : violationText("Edit", noChange);
816
+ return errorResult(batch ? `Error (Edit): ${where}${noChange.message}` : violationText("Edit", noChange));
768
817
  if (oldS !== "" && !working.includes(oldS)) {
769
818
  const resolved = resolveQuoteMatch(working, oldS);
770
819
  if (resolved !== undefined && resolved !== oldS) {
@@ -774,7 +823,7 @@ export function makeEditFileTool(env, state, rootCanonical, cwdRef, additionalRo
774
823
  }
775
824
  const match = checkEditMatch(working, oldS, e.replace_all === true, entry.truncated);
776
825
  if (match)
777
- return batch ? `Error (Edit): ${where}${match.message} (no changes written — the batch is atomic).` : violationText("Edit", match);
826
+ return errorResult(batch ? `Error (Edit): ${where}${match.message} (no changes written — the batch is atomic).` : violationText("Edit", match));
778
827
  replacements += e.replace_all === true ? countOccurrences(working, oldS) : 1;
779
828
  const effOld = e.replace_all === true ? oldS : deletionOldString(working, oldS, newS);
780
829
  working = e.replace_all === true ? working.split(effOld).join(newS) : working.replace(effOld, () => newS);
@@ -782,11 +831,13 @@ export function makeEditFileTool(env, state, rootCanonical, cwdRef, additionalRo
782
831
  }
783
832
  const gated = await gateToolWrite(beforeWrite, "Edit", path, r.key, working);
784
833
  if (gated !== undefined)
785
- return gated;
786
- const write = await env.writeFile(r.key, encodeTextForFile(working, decoded.encoding, decoded.endings), ctx.signal);
834
+ return errorResult(gated);
835
+ const encodedEdit = encodeTextForFile(working, decoded.encoding, decoded.endings);
836
+ const write = await env.writeFile(r.key, encodedEdit, ctx.signal);
787
837
  if (!write.ok)
788
- return `Error (Edit): cannot write "${path}": ${write.error.message}`;
789
- state.set(r.key, { hash: sha256(working), totalLines: countLines(working), truncated: false, lastReadAt: Date.now() });
838
+ return errorResult(`Error (Edit): cannot write "${path}": ${write.error.message}`);
839
+ const persistedEdit = persistedTextOf(encodedEdit);
840
+ state.set(r.key, { hash: sha256(persistedEdit), totalLines: countLines(persistedEdit), truncated: false, lastReadAt: Date.now() });
790
841
  const a0 = appliedEdits[0];
791
842
  return {
792
843
  content: batch
@@ -823,54 +874,56 @@ export function makeWriteFileTool(env, state, rootCanonical, cwdRef, additionalR
823
874
  const { content } = args;
824
875
  const path = fileArgPath(args);
825
876
  if (path === undefined)
826
- return `Error (Write): file_path is required.`;
877
+ return errorResult(`Error (Write): file_path is required.`);
827
878
  const ipynb = ipynbRedirect("Write", path);
828
879
  if (ipynb)
829
- return ipynb;
880
+ return errorResult(ipynb);
830
881
  const r = await resolveKey(env, rootCanonical, path, ctx.signal, cwdRef?.current, additionalRoots);
831
882
  if (!r.ok)
832
- return violationText("Write", r.violation);
883
+ return errorResult(violationText("Write", r.violation));
833
884
  const exists = await env.exists(r.key, ctx.signal);
834
885
  if (!exists.ok)
835
- return `Error (Write): cannot stat "${path}": ${exists.error.message}`;
886
+ return errorResult(`Error (Write): cannot stat "${path}": ${exists.error.message}`);
836
887
  let originalFile;
837
888
  if (exists.value) {
838
889
  const notRead = requireRead(state, r.key);
839
890
  if (notRead) {
840
- return (`${violationText("Write", notRead)} ` +
891
+ return errorResult(`${violationText("Write", notRead)} ` +
841
892
  `(If the Read tool refuses this file (binary/unknown encoding), overwrite or convert it with bash instead — e.g. \`rm\` + rewrite, or \`iconv\`.)`);
842
893
  }
843
894
  const readBin = await env.readBinaryFile(r.key, ctx.signal);
844
895
  if (!readBin.ok)
845
- return `Error (Write): cannot re-read "${path}" to verify it is unchanged: ${readBin.error.message}`;
896
+ return errorResult(`Error (Write): cannot re-read "${path}" to verify it is unchanged: ${readBin.error.message}`);
846
897
  const decodedPrev = decodeTextBytes(readBin.value);
847
898
  if (decodedPrev.malformed)
848
- return `Error (Write): "${path}" has a truncated UTF-16 body; repair/convert it with bash first.`;
899
+ return errorResult(`Error (Write): "${path}" has a truncated UTF-16 body; repair/convert it with bash first.`);
849
900
  const stale = checkStale(state.get(r.key), sha256(decodedPrev.text));
850
901
  if (stale)
851
- return violationText("Write", stale);
902
+ return errorResult(violationText("Write", stale));
852
903
  originalFile = decodedPrev.text;
853
904
  const gated = await gateToolWrite(beforeWrite, "Write", path, r.key, content);
854
905
  if (gated !== undefined)
855
- return gated;
856
- const write = await env.writeFile(r.key, encodeTextForFile(content, decodedPrev.encoding, "preserve"), ctx.signal);
906
+ return errorResult(gated);
907
+ const outgoing = splitLeadingBom(content);
908
+ const writeEncoding = outgoing.hadBom && !decodedPrev.encoding.hadBom ? { ...decodedPrev.encoding, hadBom: true } : decodedPrev.encoding;
909
+ const write = await env.writeFile(r.key, encodeTextForFile(outgoing.text, writeEncoding, "preserve"), ctx.signal);
857
910
  if (!write.ok)
858
- return `Error (Write): cannot write "${path}": ${write.error.message}`;
859
- const totalLines = countLines(content);
860
- state.set(r.key, { hash: sha256(normalizeEditText(content)), totalLines, truncated: false, lastReadAt: Date.now() });
911
+ return errorResult(`Error (Write): cannot write "${path}": ${write.error.message}`);
912
+ const totalLines = countLines(outgoing.text);
913
+ state.set(r.key, { hash: sha256(normalizeFileText(content)), totalLines, truncated: false, lastReadAt: Date.now() });
861
914
  return {
862
915
  content: `The file ${path} has been updated successfully.${FILE_STATE_TRAILER}`,
863
- details: { type: "update", filePath: path, content: normalizeEditText(content), originalFile },
916
+ details: { type: "update", filePath: path, content: normalizeFileText(content), originalFile },
864
917
  };
865
918
  }
866
919
  const gatedCreate = await gateToolWrite(beforeWrite, "Write", path, r.key, content);
867
920
  if (gatedCreate !== undefined)
868
- return gatedCreate;
921
+ return errorResult(gatedCreate);
869
922
  const write = await env.writeFile(r.key, content, ctx.signal);
870
923
  if (!write.ok)
871
- return `Error (Write): cannot write "${path}": ${write.error.message}`;
924
+ return errorResult(`Error (Write): cannot write "${path}": ${write.error.message}`);
872
925
  const totalLines = countLines(content);
873
- state.set(r.key, { hash: sha256(normalizeEditText(content)), totalLines, truncated: false, lastReadAt: Date.now() });
926
+ state.set(r.key, { hash: sha256(normalizeFileText(content)), totalLines, truncated: false, lastReadAt: Date.now() });
874
927
  void originalFile;
875
928
  return {
876
929
  content: `File created successfully at: ${path}${FILE_STATE_TRAILER}`,
@@ -887,7 +940,7 @@ export function makeNotebookEditTool(env, state, rootCanonical, cwdRef, addition
887
940
  "\n" +
888
941
  "Usage:\n" +
889
942
  "- You must use the Read tool on the notebook in this conversation before editing — this tool will fail otherwise.\n" +
890
- "- `cell_id` is the cell's `id` field in the notebook JSON shown by Read. It is required for `replace` and `delete`.\n" +
943
+ "- `cell_id` is the `id` attribute shown in the Read tool's `<cell id=\"...\">` output. It is required for `replace` and `delete`.\n" +
891
944
  "- `edit_mode` defaults to `replace`. Use `insert` to add a new cell after the cell with the given `cell_id` " +
892
945
  "(or at the beginning of the notebook if `cell_id` is omitted) — `cell_type` is required when inserting. " +
893
946
  "Use `delete` to remove the cell.",
@@ -911,37 +964,37 @@ export function makeNotebookEditTool(env, state, rootCanonical, cwdRef, addition
911
964
  let cellType = a.cell_type;
912
965
  const mode = a.edit_mode ?? "replace";
913
966
  if (!notebook_path.toLowerCase().endsWith(".ipynb")) {
914
- return `Error (NotebookEdit): "${notebook_path}" is not a .ipynb file; use Edit for other file types.`;
967
+ return errorResult(`Error (NotebookEdit): "${notebook_path}" is not a .ipynb file; use Edit for other file types.`);
915
968
  }
916
969
  if (mode === "insert" && !cellType)
917
- return `Error (NotebookEdit): Cell type is required when using edit_mode=insert.`;
970
+ return errorResult(`Error (NotebookEdit): Cell type is required when using edit_mode=insert.`);
918
971
  if (mode !== "insert" && !cell_id)
919
- return `Error (NotebookEdit): cell_id is required for replace/delete.`;
972
+ return errorResult(`Error (NotebookEdit): cell_id is required for replace/delete.`);
920
973
  const r = await resolveKey(env, rootCanonical, notebook_path, ctx.signal, cwdRef?.current, additionalRoots);
921
974
  if (!r.ok)
922
- return violationText("NotebookEdit", r.violation);
975
+ return errorResult(violationText("NotebookEdit", r.violation));
923
976
  const notRead = requireRead(state, r.key);
924
977
  if (notRead)
925
- return await notReadRefusalText(env, "NotebookEdit", r.key, notRead, ctx.signal);
978
+ return errorResult(await notReadRefusalText(env, "NotebookEdit", r.key, notRead, ctx.signal));
926
979
  const readBin = await env.readBinaryFile(r.key, ctx.signal);
927
980
  if (!readBin.ok)
928
- return `Error (NotebookEdit): cannot read "${notebook_path}": ${readBin.error.message}`;
981
+ return errorResult(`Error (NotebookEdit): cannot read "${notebook_path}": ${readBin.error.message}`);
929
982
  const decodedNb = decodeTextBytes(readBin.value);
930
983
  if (decodedNb.malformed)
931
- return `Error (NotebookEdit): "${notebook_path}" has a truncated UTF-16 body; repair/convert it with bash first.`;
984
+ return errorResult(`Error (NotebookEdit): "${notebook_path}" has a truncated UTF-16 body; repair/convert it with bash first.`);
932
985
  const nbText = decodedNb.text;
933
986
  const stale = checkStale(state.get(r.key), sha256(nbText));
934
987
  if (stale)
935
- return violationText("NotebookEdit", stale);
988
+ return errorResult(violationText("NotebookEdit", stale));
936
989
  let nb;
937
990
  try {
938
991
  nb = JSON.parse(nbText);
939
992
  }
940
993
  catch {
941
- return `Error (NotebookEdit): "${notebook_path}" is not valid JSON.`;
994
+ return errorResult(`Error (NotebookEdit): "${notebook_path}" is not valid JSON.`);
942
995
  }
943
996
  if (!nb || typeof nb !== "object" || !Array.isArray(nb.cells)) {
944
- return `Error (NotebookEdit): "${notebook_path}" is not a valid notebook (no cells array).`;
997
+ return errorResult(`Error (NotebookEdit): "${notebook_path}" is not a valid notebook (no cells array).`);
945
998
  }
946
999
  const cells = nb.cells;
947
1000
  let h;
@@ -959,7 +1012,7 @@ export function makeNotebookEditTool(env, state, rootCanonical, cwdRef, addition
959
1012
  }
960
1013
  }
961
1014
  if (h === -1)
962
- return `Error (NotebookEdit): Cell with ID "${cell_id}" not found in notebook.`;
1015
+ return errorResult(`Error (NotebookEdit): Cell with ID "${cell_id}" not found in notebook.`);
963
1016
  if (mode === "insert")
964
1017
  h += 1;
965
1018
  }
@@ -996,10 +1049,10 @@ export function makeNotebookEditTool(env, state, rootCanonical, cwdRef, addition
996
1049
  const updated = JSON.stringify(nb, null, 1);
997
1050
  const gated = await gateToolWrite(beforeWrite, "NotebookEdit", notebook_path, r.key, updated);
998
1051
  if (gated !== undefined)
999
- return gated;
1052
+ return errorResult(gated);
1000
1053
  const w = await env.writeFile(r.key, encodeTextForFile(updated, decodedNb.encoding, decodedNb.endings), ctx.signal);
1001
1054
  if (!w.ok)
1002
- return `Error (NotebookEdit): cannot write "${notebook_path}": ${w.error.message}`;
1055
+ return errorResult(`Error (NotebookEdit): cannot write "${notebook_path}": ${w.error.message}`);
1003
1056
  state.set(r.key, { hash: sha256(updated), totalLines: countLines(updated), truncated: false, lastReadAt: Date.now() });
1004
1057
  const nbDetails = {
1005
1058
  type: "notebook-edit",
@@ -1062,7 +1115,7 @@ export function makeGrepTool(env, rootCanonical, additionalRoots) {
1062
1115
  if (a.path !== undefined) {
1063
1116
  const r = await resolveKey(env, rootCanonical, a.path, ctx.signal, rootCanonical, additionalRoots);
1064
1117
  if (!r.ok)
1065
- return violationText("Grep", r.violation);
1118
+ return errorResult(violationText("Grep", r.violation));
1066
1119
  scoped = r.key;
1067
1120
  }
1068
1121
  const grepRun = await runGrepDetailed(env, rootCanonical, {
@@ -1077,7 +1130,7 @@ export function makeGrepTool(env, rootCanonical, additionalRoots) {
1077
1130
  }, ctx.signal);
1078
1131
  const text = grepRun.text;
1079
1132
  if (text.startsWith("Error (grep)") || text.startsWith("Error (Grep)"))
1080
- return text;
1133
+ return errorResult(text);
1081
1134
  const mode = a.output_mode ?? "files_with_matches";
1082
1135
  const rows = text.startsWith("No matches.")
1083
1136
  ? []
@@ -1136,12 +1189,12 @@ export function makeGlobTool(env, rootCanonical, additionalRoots) {
1136
1189
  description: 'Fast file pattern matching. Supports glob patterns like "**/*.js" or "src/**/*.ts". Returns matching file paths sorted by modification time.\n' +
1137
1190
  "\n" +
1138
1191
  "- On environments that do not report modification times, results fall back to alphabetical order\n" +
1139
- "- Paths are RELATIVE to the root; ignored trees (node_modules/build/.gitignore) are skipped",
1192
+ "- Paths are RELATIVE to the root; ignored trees (node_modules/build/.gitignore) are skipped unless your pattern names them explicitly (e.g. `dist/**`)",
1140
1193
  descriptionClassic: "- Fast file pattern matching tool that works with any codebase size\n" +
1141
1194
  '- Supports glob patterns like "**/*.js" or "src/**/*.ts"\n' +
1142
1195
  "- Returns matching file paths sorted by modification time\n" +
1143
1196
  "- On environments that do not report modification times, results fall back to alphabetical order\n" +
1144
- "- Paths are RELATIVE to the root; ignored trees (node_modules/build/.gitignore) are skipped\n" +
1197
+ "- Paths are RELATIVE to the root; ignored trees (node_modules/build/.gitignore) are skipped unless your pattern names them explicitly (e.g. `dist/**`)\n" +
1145
1198
  "- Use this tool when you need to find files by name patterns; use `path` to scope to a sub-directory\n" +
1146
1199
  "- When you are doing an open ended search that may require multiple rounds of globbing and grepping, use the Agent tool instead",
1147
1200
  parameters: Type.Object({
@@ -1164,7 +1217,7 @@ export function makeGlobTool(env, rootCanonical, additionalRoots) {
1164
1217
  if (path !== undefined) {
1165
1218
  const r = await resolveKey(env, rootCanonical, path, ctx.signal, rootCanonical, additionalRoots);
1166
1219
  if (!r.ok)
1167
- return violationText("Glob", r.violation);
1220
+ return errorResult(violationText("Glob", r.violation));
1168
1221
  scoped = r.key;
1169
1222
  }
1170
1223
  const r2 = await runGlobDetailed(env, rootCanonical, pattern, { path: scoped, max: max_results }, ctx.signal);
@@ -1560,7 +1613,11 @@ async function runShell(env, cwd, toolName, command, timeoutSec, signal, cwdRef,
1560
1613
  throw new Error(diagnosis ?? `Error (${toolName}): command did not run (${res.error.code}): ${res.error.message}`);
1561
1614
  }
1562
1615
  if (res.error.code === "transport_lost") {
1563
- return `Error (${toolName}): connection to the execution environment was lost — the command's outcome is unknown and it may still be running. Verify its effects before retrying; retry only if the command is idempotent. (${res.error.message})`;
1616
+ return {
1617
+ content: `Error (${toolName}): connection to the execution environment was lost — the command's outcome is unknown and it may still be running. Verify its effects before retrying; retry only if the command is idempotent. (${res.error.message})`,
1618
+ details: { type: "bash", transportLost: true },
1619
+ isError: true,
1620
+ };
1564
1621
  }
1565
1622
  if (res.error.code === "timeout" || res.error.code === "aborted" || res.error.code === "callback_error") {
1566
1623
  const rawStdout = res.error.partialStdout ?? "";
@@ -1605,7 +1662,11 @@ async function runShell(env, cwd, toolName, command, timeoutSec, signal, cwdRef,
1605
1662
  },
1606
1663
  };
1607
1664
  }
1608
- return `Error (${toolName}): command execution failed (${res.error.code}): ${res.error.message}. Whether the command started or completed could not be determined — verify its effects before retrying.`;
1665
+ return {
1666
+ content: `Error (${toolName}): command execution failed (${res.error.code}): ${res.error.message}. Whether the command started or completed could not be determined — verify its effects before retrying.`,
1667
+ details: { type: "bash" },
1668
+ isError: true,
1669
+ };
1609
1670
  }
1610
1671
  if (res.value.detached && detach) {
1611
1672
  return await detach.onDetached(res.value.detached.shellId, res.value.stdout, res.value.stderr, res.value.detached.cause);
@@ -1724,7 +1785,7 @@ export function makeBashTool(env, rootCanonical, coAuthor = false, cwdRef = { cu
1724
1785
  const { command, timeout, run_in_background, description } = args;
1725
1786
  if (run_in_background) {
1726
1787
  if (!hasBackgroundShell(env)) {
1727
- return "Error (Bash): this environment does not support background processes (run_in_background).";
1788
+ return errorResult("Error (Bash): this environment does not support background processes (run_in_background).");
1728
1789
  }
1729
1790
  if (cwdRef && (await isDeadCwd(env, cwdRef.current))) {
1730
1791
  const diagnosis = await diagnoseDeadCwd(env, "Bash", cwdRef.current, rootCanonical, cwdRef);
@@ -1737,7 +1798,7 @@ export function makeBashTool(env, rootCanonical, coAuthor = false, cwdRef = { cu
1737
1798
  ...(requestedTimeoutSec !== undefined ? { timeout: requestedTimeoutSec } : {}),
1738
1799
  });
1739
1800
  if (!r.ok)
1740
- return `Error (Bash): ${r.error.message}`;
1801
+ return errorResult(`Error (Bash): ${r.error.message}`);
1741
1802
  const bgCaps = env.backgroundCapabilities;
1742
1803
  const appliedTimeoutSec = typeof bgCaps.defaultBgTimeoutSec === "number" && typeof bgCaps.maxBgTimeoutSec === "number"
1743
1804
  ? Math.min(requestedTimeoutSec ?? bgCaps.defaultBgTimeoutSec, bgCaps.maxBgTimeoutSec)
@@ -1783,7 +1844,7 @@ export function makeBashTool(env, rootCanonical, coAuthor = false, cwdRef = { cu
1783
1844
  kill = undefined;
1784
1845
  }
1785
1846
  if (kill !== undefined && (kill.ok || kill.error.code === "not_found")) {
1786
- return `Error (Bash): background process was started but could not be registered (${reason}); it has been terminated.`;
1847
+ return errorResult(`Error (Bash): background process was started but could not be registered (${reason}); it has been terminated.`);
1787
1848
  }
1788
1849
  const killMsg = kill !== undefined && !kill.ok ? kill.error.message : "killBackground threw";
1789
1850
  return {
@@ -1791,6 +1852,7 @@ export function makeBashTool(env, rootCanonical, coAuthor = false, cwdRef = { cu
1791
1852
  `The process could NOT be terminated — it may still be running, but it has no task_id, so TaskOutput/TaskStop cannot reach it. ` +
1792
1853
  `If it must be stopped, kill it manually (e.g. \`pkill -f\` on its command line).`,
1793
1854
  details: { type: "bash", registerFailed: true, killFailed: true },
1855
+ isError: true,
1794
1856
  };
1795
1857
  }
1796
1858
  const interimNote = outputFile !== undefined
@@ -1863,6 +1925,7 @@ export function makeBashTool(env, rootCanonical, coAuthor = false, cwdRef = { cu
1863
1925
  `it has been terminated (an unregistered background process would have no task_id and could not be read or stopped).` +
1864
1926
  tails,
1865
1927
  details: { type: "bash", detached: true, registerFailed: true },
1928
+ isError: true,
1866
1929
  };
1867
1930
  }
1868
1931
  const killMsg = kill !== undefined && !kill.ok ? kill.error.message : "killBackground threw";
@@ -1872,6 +1935,7 @@ export function makeBashTool(env, rootCanonical, coAuthor = false, cwdRef = { cu
1872
1935
  `If it must be stopped, kill it manually (e.g. \`pkill -f\` on its command line).` +
1873
1936
  tails,
1874
1937
  details: { type: "bash", detached: true, registerFailed: true, killFailed: true },
1938
+ isError: true,
1875
1939
  };
1876
1940
  }
1877
1941
  const tail = stdoutSoFar.slice(-1_000);
@@ -1934,7 +1998,7 @@ export function makeBashReadonlyTool(env, rootCanonical, allow, execClamp) {
1934
1998
  const { command, timeout } = args;
1935
1999
  const reason = coarseReadonlyCheck(command, allow);
1936
2000
  if (reason)
1937
- return `Error (Bash): ${reason}`;
2001
+ return errorResult(`Error (Bash): ${reason}`);
1938
2002
  return runShell(env, rootCanonical, "Bash", command, msTimeoutToSec(timeout), ctx.signal, undefined, undefined, execClamp, ctx.toolCallId, true);
1939
2003
  },
1940
2004
  });
@@ -1958,9 +2022,9 @@ export function makeBashOutputTool(env) {
1958
2022
  const { task_id, bash_id, filter } = args;
1959
2023
  const id = task_id ?? bash_id;
1960
2024
  if (!id)
1961
- return "Error (TaskOutput): Missing required parameter: task_id";
2025
+ return errorResult("Error (TaskOutput): Missing required parameter: task_id");
1962
2026
  if (!hasBackgroundShell(env)) {
1963
- return "Error (TaskOutput): this environment does not support background processes.";
2027
+ return errorResult("Error (TaskOutput): this environment does not support background processes.");
1964
2028
  }
1965
2029
  let rx;
1966
2030
  if (filter !== undefined) {
@@ -1968,12 +2032,12 @@ export function makeBashOutputTool(env) {
1968
2032
  rx = new RegExp(filter);
1969
2033
  }
1970
2034
  catch {
1971
- return `Error (TaskOutput): invalid filter regex: ${filter}`;
2035
+ return errorResult(`Error (TaskOutput): invalid filter regex: ${filter}`);
1972
2036
  }
1973
2037
  }
1974
2038
  const r = await env.pollBackground(id);
1975
2039
  if (!r.ok)
1976
- return `Error (TaskOutput): ${r.error.message}`;
2040
+ return errorResult(`Error (TaskOutput): ${r.error.message}`);
1977
2041
  const shape = (text) => clipShellOutput(rx ? text.split(/\r?\n/).filter((l) => rx.test(l)).join("\n") : text);
1978
2042
  const status = r.value.status === "exited" ? `exited(code ${r.value.exitCode})` : r.value.status;
1979
2043
  const dropped = r.value.bytesDroppedBeforeCursor ?? 0;
@@ -2002,15 +2066,15 @@ export function makeKillShellTool(env, registry = defaultTaskRegistry) {
2002
2066
  const { task_id, shell_id } = args;
2003
2067
  const id = task_id ?? shell_id;
2004
2068
  if (!id)
2005
- return "Missing required parameter: task_id";
2069
+ return errorResult("Missing required parameter: task_id");
2006
2070
  if (!hasBackgroundShell(env)) {
2007
- return "Error (TaskStop): this environment does not support background processes.";
2071
+ return errorResult("Error (TaskStop): this environment does not support background processes.");
2008
2072
  }
2009
2073
  registry.markStopSourceByShellId(id, "parent", env);
2010
2074
  const r = await env.killBackground(id);
2011
2075
  if (!r.ok) {
2012
2076
  registry.clearPendingStopSourceByShellId(id, "parent", env);
2013
- return `${id} is unknown or has already ended.`;
2077
+ return errorResult(`${id} is unknown or has already ended.`);
2014
2078
  }
2015
2079
  return `Terminated ${id}.`;
2016
2080
  },