@sema-agent/core 2.0.1 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/dist/agents/observer.d.ts +14 -0
  2. package/dist/agents/observer.js +66 -12
  3. package/dist/agents/send-message-tool.js +164 -88
  4. package/dist/agents/subagent.d.ts +11 -4
  5. package/dist/agents/subagent.js +166 -62
  6. package/dist/core/context-edit.js +16 -3
  7. package/dist/core/file-snapshot-store.js +10 -1
  8. package/dist/core/memory-engine/dual-root.js +2 -0
  9. package/dist/core/memory-engine/engine.d.ts +4 -0
  10. package/dist/core/memory-engine/engine.js +6 -1
  11. package/dist/core/runner/prepare-memory.d.ts +4 -0
  12. package/dist/core/runner/prepare-memory.js +4 -1
  13. package/dist/core/runner/prepare-task.d.ts +9 -2
  14. package/dist/core/runner/prepare-task.js +68 -13
  15. package/dist/core/runner/runtask.js +919 -865
  16. package/dist/core/runner/synthetic-tools.d.ts +1 -0
  17. package/dist/core/runner/synthetic-tools.js +18 -15
  18. package/dist/core/runner/turn-attachments.d.ts +27 -3
  19. package/dist/core/runner/turn-attachments.js +101 -12
  20. package/dist/core/task-registry-agent.d.ts +9 -1
  21. package/dist/core/task-registry-agent.js +23 -2
  22. package/dist/core/task-registry-monitor.js +79 -24
  23. package/dist/core/task-registry-shared.d.ts +13 -1
  24. package/dist/core/task-registry-shared.js +21 -0
  25. package/dist/core/task-registry.d.ts +2 -0
  26. package/dist/core/task-registry.js +24 -26
  27. package/dist/core/tool-result-budget.js +2 -2
  28. package/dist/core/tool-result-store.d.ts +2 -0
  29. package/dist/core/tool-result-store.js +27 -2
  30. package/dist/core/types.d.ts +4 -2
  31. package/dist/core/workflow-journal-store.d.ts +13 -0
  32. package/dist/engine/session/import-validate.js +29 -0
  33. package/dist/engine/session/memory-repo.js +5 -0
  34. package/dist/index.d.ts +1 -1
  35. package/dist/index.js +1 -1
  36. package/dist/orchestration/workflow-size-guideline.d.ts +6 -1
  37. package/dist/orchestration/workflow-size-guideline.js +19 -9
  38. package/dist/orchestration/workflow.d.ts +1 -0
  39. package/dist/orchestration/workflow.js +44 -1
  40. package/dist/prompt-assembly/assemble.js +3 -7
  41. package/dist/prompt-assembly/event-registry.js +1 -1
  42. package/dist/prompt-assembly/packs/sema-default.js +8 -5
  43. package/dist/prompts/coordinator.d.ts +1 -1
  44. package/dist/prompts/coordinator.js +45 -0
  45. package/dist/prompts/default.d.ts +4 -5
  46. package/dist/prompts/default.js +16 -18
  47. package/dist/prompts/simple-sections.d.ts +3 -1
  48. package/dist/prompts/simple-sections.js +11 -1
  49. package/dist/stores/cc/task-list-store.js +3 -3
  50. package/dist/stores/file/memory-store.d.ts +3 -0
  51. package/dist/stores/file/memory-store.js +39 -12
  52. package/dist/stores/file/tool-result-store.js +16 -2
  53. package/dist/stores/file/workflow-journal-store.d.ts +23 -0
  54. package/dist/stores/file/workflow-journal-store.js +140 -3
  55. package/dist/tools/fs/bash-readonly-classifier.js +21 -2
  56. package/dist/tools/fs/fs-bash.d.ts +1 -0
  57. package/dist/tools/fs/fs-bash.js +10 -3
  58. package/dist/tools/fs/fs-read.js +12 -12
  59. package/dist/tools/fs/fs-search-tools.js +42 -7
  60. package/dist/tools/fs/fs-write.js +18 -6
  61. package/dist/tools/fs/index.d.ts +1 -0
  62. package/dist/tools/fs/index.js +2 -1
  63. package/dist/tools/fs/safety.d.ts +4 -0
  64. package/dist/tools/fs/safety.js +113 -10
  65. package/dist/tools/fs/search.d.ts +1 -0
  66. package/dist/tools/fs/search.js +23 -3
  67. package/dist/tools/monitor.js +1 -1
  68. package/dist/tools/task-list.d.ts +1 -0
  69. package/dist/tools/task-list.js +13 -2
  70. package/dist/tools/web.js +36 -6
  71. package/package.json +3 -2
@@ -15,10 +15,10 @@ export function createReadFileTool(env, state, rootCanonical, cwdRef, additional
15
15
  "\n" +
16
16
  "- `file_path` may be relative (resolved against the tracked working directory) or absolute (within the configured roots).\n" +
17
17
  "- 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" +
18
- "- When you already know which part of the file you need, only read that part. This can be important for larger files.\n" +
18
+ "- You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters\n" +
19
19
  "- Results are returned using cat -n format, with line numbers starting at 1\n" +
20
20
  "- 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" +
21
- "- Reading a directory, a missing file, or an empty file returns an error or warning rather than content.\n" +
21
+ "- Reading a directory, a missing file, or an empty file returns an error or system reminder rather than content.\n" +
22
22
  "- 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" +
23
23
  "- You must read a file before editing it.\n" +
24
24
  "- 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.",
@@ -26,7 +26,7 @@ export function createReadFileTool(env, state, rootCanonical, cwdRef, additional
26
26
  "\n" +
27
27
  "Usage:\n" +
28
28
  "- 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" +
29
- "- When you already know which part of the file you need, only read that part. This can be important for larger files.\n" +
29
+ "- You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters\n" +
30
30
  "- Results are returned using cat -n format, with each line prefixed by its line number and a tab\n" +
31
31
  "- This tool allows the model to read images (eg PNG, JPG, etc). When reading an image file the contents are presented visually.\n" +
32
32
  "- This tool can only read files, not directories. To read a directory, use Glob/Grep or an ls command via the Bash tool.\n" +
@@ -34,7 +34,7 @@ export function createReadFileTool(env, state, rootCanonical, cwdRef, additional
34
34
  "- 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" +
35
35
  "- Reads Jupyter notebooks (.ipynb) as cells with outputs (offset/limit do not apply).\n" +
36
36
  "- 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" +
37
- "- If you read a file that exists but has empty contents you will receive a warning in place of file contents.\n" +
37
+ "- If you read a file that exists but has empty contents you will receive a system reminder warning in place of file contents.\n" +
38
38
  "- 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.",
39
39
  parameters: Type.Object({
40
40
  ...FILE_PATH_PARAMS,
@@ -129,7 +129,7 @@ export function createReadFileTool(env, state, rootCanonical, cwdRef, additional
129
129
  `(${info.value.size} bytes > ${SLICED_READ_MAX_BYTES}-byte cap — the reader loads the whole file into memory before slicing). ` +
130
130
  `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.`);
131
131
  }
132
- if (!isNb && info.value.size > MAX_READ_BYTES && offset === undefined && limit === undefined) {
132
+ if (!isNb && info.value.size > MAX_READ_BYTES && limit === undefined) {
133
133
  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.`);
134
134
  }
135
135
  }
@@ -147,7 +147,7 @@ export function createReadFileTool(env, state, rootCanonical, cwdRef, additional
147
147
  `(${readSize} bytes > ${SLICED_READ_MAX_BYTES}-byte cap — the reader loads the whole file into memory before slicing). ` +
148
148
  `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.`);
149
149
  }
150
- if (!isNb && readSize > MAX_READ_BYTES && offset === undefined && limit === undefined) {
150
+ if (!isNb && readSize > MAX_READ_BYTES && limit === undefined) {
151
151
  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.`);
152
152
  }
153
153
  if (pdfMagicMatches(readBin.value)) {
@@ -158,7 +158,7 @@ export function createReadFileTool(env, state, rootCanonical, cwdRef, additional
158
158
  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.`);
159
159
  }
160
160
  const content = decoded.text;
161
- if (isBinaryContent(content.slice(0, 4096))) {
161
+ if (isBinaryContent(content.slice(0, 8192))) {
162
162
  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. ` +
163
163
  `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.`);
164
164
  }
@@ -193,10 +193,10 @@ export function createReadFileTool(env, state, rootCanonical, cwdRef, additional
193
193
  ` cat "${path}" | jq '.cells[] | select(.cell_type=="code") | .source' # All code sources`);
194
194
  }
195
195
  const prevNb = state.get(r.key);
196
- if (prevNb?.seededFromContext && prevNb.hash === hash) {
196
+ if (prevNb?.seededFromContext && !prevNb.isPartialView && prevNb.hash === hash) {
197
197
  return seededFileUnchangedReminder(r.key);
198
198
  }
199
- if (prevNb && prevNb.hash === hash && prevNb.view && prevNb.view.start === 1 && prevNb.view.end === total) {
199
+ if (prevNb && !prevNb.isPartialView && prevNb.hash === hash && prevNb.view && prevNb.view.start === 1 && prevNb.view.end === total) {
200
200
  return `[${path}: unchanged since you last read it (lines 1-${total} of ${total}); content omitted to save context]`;
201
201
  }
202
202
  state.set(r.key, { hash, totalLines: countLines(content), truncated: false, view: { start: 1, end: total }, lastReadAt: Date.now() });
@@ -250,10 +250,10 @@ export function createReadFileTool(env, state, rootCanonical, cwdRef, additional
250
250
  }
251
251
  const truncated = start > 1 || end < total || pageMarker !== undefined;
252
252
  const prev = state.get(r.key);
253
- if (total > 0 && prev?.seededFromContext && start === 1 && effLimit === undefined && prev.hash === hash) {
253
+ if (total > 0 && prev?.seededFromContext && !prev.isPartialView && start === 1 && effLimit === undefined && prev.hash === hash) {
254
254
  return seededFileUnchangedReminder(r.key);
255
255
  }
256
- if (total > 0 && prev && prev.hash === hash && prev.view && prev.view.start === start && prev.view.end === end) {
256
+ if (total > 0 && prev && !prev.isPartialView && prev.hash === hash && prev.view && prev.view.start === start && prev.view.end === end) {
257
257
  return `[${path}: unchanged since you last read it (lines ${start}-${end} of ${total}); content omitted to save context]`;
258
258
  }
259
259
  state.set(r.key, {
@@ -265,7 +265,7 @@ export function createReadFileTool(env, state, rootCanonical, cwdRef, additional
265
265
  lastReadAt: Date.now(),
266
266
  });
267
267
  if (total === 0)
268
- return `<system-reminder>Warning: the file exists but is shorter than the provided offset (${start}). The file has 1 lines.</system-reminder>`;
268
+ return `<system-reminder>Warning: the file exists but the contents are empty.</system-reminder>`;
269
269
  const header = pageMarker ?? (truncated ? `[${path}: lines ${start}-${end} of ${total}${end < total ? " — use offset to see more" : ""}]\n` : "");
270
270
  return {
271
271
  content: `${nbFallbackPrefix}${header}${body}${READ_CYBER_REMINDER}`,
@@ -1,21 +1,22 @@
1
1
  import { Type } from "typebox";
2
2
  import { defineTool, errorResult } from "../../core/tools.js";
3
3
  import { resolveKey, violationText } from "./safety.js";
4
- import { runGrepDetailed, runGlobDetailed, splitAbsoluteGlobPattern } from "./search.js";
4
+ import { runGrepDetailed, runGlobDetailed, splitAbsoluteGlobPattern, invalidGlobTokens } from "./search.js";
5
5
  export function createGrepTool(env, rootCanonical, additionalRoots) {
6
6
  return defineTool({
7
7
  name: "Grep",
8
8
  contract: { contractId: "core.grep@1", implementationRevision: "1" },
9
9
  description: "Content search built on ripgrep. Prefer this over `grep`/`rg` via Bash — results integrate with the permission UI and file links.\n" +
10
+ "\n" +
10
11
  '- Full regex syntax (e.g. "log.*Error", "function\\s+\\w+"). Ripgrep, not grep — escape literal braces (`interface\\{\\}`).\n' +
11
12
  '- Filter with `glob` (e.g. "**/*.tsx") or `type` (e.g. "js", "py", "rust").\n' +
12
13
  '- `output_mode`: "content" (matching lines), "files_with_matches" (paths only, default), or "count".\n' +
13
14
  "- `multiline: true` for patterns that span lines.\n" +
14
15
  "- Uses ripgrep when available (respects .gitignore, skips binary files and VCS directories), otherwise a JS fallback that skips node_modules/build/… and parses .gitignore. Hidden files/directories ARE searched.\n" +
15
- "- Use Agent tool for open-ended searches requiring multiple rounds",
16
+ "- Use Agent tool (if available) for open-ended searches requiring multiple rounds",
16
17
  parameters: Type.Object({
17
18
  pattern: Type.String({ description: "The regular expression pattern to search for in file contents" }),
18
- path: Type.Optional(Type.String({ description: "Restrict to a sub-directory (relative to root)." })),
19
+ path: Type.Optional(Type.String({ description: "File or directory to search in (rg PATH). Relative paths resolve against the root; defaults to the whole root." })),
19
20
  glob: Type.Optional(Type.String({ description: 'Glob pattern to filter files (e.g. "*.js", "*.{ts,tsx}")' })),
20
21
  type: Type.Optional(Type.String({ description: 'File type to search (rg --type): "js", "py", "rust", "go", "java", etc. More efficient than glob for standard file types.' })),
21
22
  output_mode: Type.Optional(Type.Union([Type.Literal("content"), Type.Literal("files_with_matches"), Type.Literal("count")], {
@@ -41,6 +42,23 @@ export function createGrepTool(env, rootCanonical, additionalRoots) {
41
42
  effect: "read",
42
43
  execute: async (args, ctx) => {
43
44
  const a = args;
45
+ for (const [name, value] of [
46
+ ["head_limit", a.head_limit],
47
+ ["offset", a.offset],
48
+ ]) {
49
+ if (value !== undefined && (!Number.isInteger(value) || value < 0)) {
50
+ return errorResult(`Error (Grep): ${name} must be a whole number of 0 or more, got ${value}.${name === "head_limit" ? " Pass 0 for unlimited." : ""}`);
51
+ }
52
+ }
53
+ if (typeof a.glob === "string" && a.glob.length > 0) {
54
+ const badGlobs = invalidGlobTokens(a.glob);
55
+ if (badGlobs.length > 0) {
56
+ return errorResult(`Error (Grep): rejected the glob ${badGlobs.map((g) => JSON.stringify(g)).join(", ")} without searching — ` +
57
+ `an unterminated \`[\` class or \`{\` group cannot be compiled as a glob, and matching it literally would ` +
58
+ `silently narrow the search to a file with that exact name (an empty result would NOT mean "no matches"). ` +
59
+ `Close the bracket/brace, or drop \`glob\` and filter with \`path\`/\`type\` instead.`);
60
+ }
61
+ }
44
62
  let scoped = a.path;
45
63
  if (a.path !== undefined) {
46
64
  const r = await resolveKey(env, rootCanonical, a.path, ctx.signal, rootCanonical, additionalRoots);
@@ -77,9 +95,14 @@ export function createGrepTool(env, rootCanonical, additionalRoots) {
77
95
  const m = /^(.*?):(\d+):/.exec(l);
78
96
  return m ? m[1] : l;
79
97
  };
98
+ const capMarker = /^…\[capped at (\d+) of (\d+)\]$/m.exec(text);
99
+ const cappedAt = capMarker ? Number(capMarker[1]) : undefined;
100
+ const capTotal = capMarker ? Number(capMarker[2]) : undefined;
101
+ const appliedOffset = typeof a.offset === "number" && a.offset > 0 ? { appliedOffset: a.offset } : {};
102
+ const appliedLimit = cappedAt !== undefined ? { appliedLimit: cappedAt } : {};
80
103
  let detailFields;
81
104
  if (mode === "files_with_matches") {
82
- detailFields = { filenames: rows, numFiles: rows.length };
105
+ detailFields = { filenames: rows, numFiles: rows.length, totalFiles: capTotal ?? rows.length, ...appliedLimit, ...appliedOffset };
83
106
  }
84
107
  else if (mode === "count") {
85
108
  const filenames = [...new Set(rows.map((l) => (l.includes(":") ? l.slice(0, l.lastIndexOf(":")) : l)))];
@@ -92,7 +115,14 @@ export function createGrepTool(env, rootCanonical, additionalRoots) {
92
115
  else
93
116
  malformed = true;
94
117
  }
95
- detailFields = { filenames, numFiles: filenames.length, ...(malformed ? {} : { numMatches }) };
118
+ detailFields = {
119
+ filenames,
120
+ numFiles: filenames.length,
121
+ content: rows.join("\n"),
122
+ ...(malformed ? {} : { numMatches }),
123
+ ...appliedLimit,
124
+ ...appliedOffset,
125
+ };
96
126
  }
97
127
  else {
98
128
  const filenames = [...new Set(rows.map(contentPathOf))];
@@ -103,6 +133,9 @@ export function createGrepTool(env, rootCanonical, additionalRoots) {
103
133
  numFiles: filenames.length,
104
134
  content: joined.length > GREP_CONTENT_PREVIEW_CHARS ? `${joined.slice(0, GREP_CONTENT_PREVIEW_CHARS)}\n…[truncated — full text in the tool output]` : joined,
105
135
  numLines: rows.length,
136
+ totalLines: capTotal ?? rows.length,
137
+ ...appliedLimit,
138
+ ...appliedOffset,
106
139
  };
107
140
  }
108
141
  return {
@@ -126,10 +159,12 @@ export function createGlobTool(env, rootCanonical, additionalRoots) {
126
159
  "- On environments that do not report modification times, results fall back to alphabetical order\n" +
127
160
  "- Paths are RELATIVE to the root; ignored trees (node_modules/build/.gitignore) are skipped unless your pattern names them explicitly (e.g. `dist/**`)\n" +
128
161
  "- Use this tool when you need to find files by name patterns; use `path` to scope to a sub-directory\n" +
129
- "- When you are doing an open ended search that may require multiple rounds of globbing and grepping, use the Agent tool instead",
162
+ "- When you are doing an open ended search that may require multiple rounds of globbing and grepping, use the Agent tool instead (if available)",
130
163
  parameters: Type.Object({
131
164
  pattern: Type.String({ description: "Glob pattern (`*`, `**`, `?`)." }),
132
- path: Type.Optional(Type.String({ description: "Scope the search to this sub-directory (relative to root)." })),
165
+ path: Type.Optional(Type.String({
166
+ description: 'Scope the search to this sub-directory (relative to root). IMPORTANT: Omit this field to search the whole root. DO NOT enter "undefined" or "null" - simply omit it for the default behavior. Must be a valid directory path if provided.',
167
+ })),
133
168
  max_results: Type.Optional(Type.Number({ description: "Cap results (default 500)." })),
134
169
  }),
135
170
  effect: "read",
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { Type } from "typebox";
3
3
  import { defineTool, errorResult } from "../../core/tools.js";
4
- import { sha256, resolveKey, violationText, requireRead, checkStale, checkEditMatch, checkNoChange, fileArgPath, resolveQuoteMatch, adaptNewStringQuotes, deletionOldString, countOccurrences, WRITE_ENCODING_DEADLOCK_ESCAPE_HINT, } from "./safety.js";
4
+ import { sha256, resolveKey, violationText, requireRead, checkStale, checkEditMatch, checkNoChange, fileArgPath, resolveQuoteMatch, adaptNewStringQuotes, resolveEscapeMatch, adaptNewStringEscapes, escapeMatchWasAttempted, ESCAPE_MATCH_MISS_NOTE, deletionOldString, countOccurrences, WRITE_ENCODING_DEADLOCK_ESCAPE_HINT, } from "./safety.js";
5
5
  import { decodeTextBytes, encodeTextForFile, normalizeEditText, normalizeFileText } from "./encoding.js";
6
6
  import { MAX_EDIT_BYTES, formatByteSize, decodeEditBytes, persistedTextOf, notReadRefusalText, enoentMessage, FILE_STATE_TRAILER, FILE_PATH_PARAMS, ipynbRedirect, countLines, } from "./fs-shared.js";
7
7
  async function gateToolWrite(hook, tool, path, key, content) {
@@ -54,12 +54,12 @@ export function createEditFileTool(env, state, rootCanonical, cwdRef, additional
54
54
  const path = fileArgPath(args);
55
55
  if (path === undefined)
56
56
  return errorResult(`Error (Edit): file_path is required.`);
57
- const ipynb = ipynbRedirect("Edit", path);
58
- if (ipynb)
59
- return errorResult(ipynb);
60
57
  const r = await resolveKey(env, rootCanonical, path, ctx.signal, cwdRef?.current, additionalRoots);
61
58
  if (!r.ok)
62
59
  return errorResult(violationText("Edit", r.violation));
60
+ if (!batch && a.old_string === a.new_string) {
61
+ return errorResult(violationText("Edit", { code: "invalid", message: "No changes to make: old_string and new_string are exactly the same." }));
62
+ }
63
63
  const singleOld = batch ? undefined : a.old_string;
64
64
  const exists = await env.exists(r.key, ctx.signal);
65
65
  if (!exists.ok)
@@ -132,6 +132,9 @@ export function createEditFileTool(env, state, rootCanonical, cwdRef, additional
132
132
  details: { type: "edit", filePath: path, originalFile: preDec.text, oldString: "", newString: newContent, replaceAll: false, edits: [{ oldString: "", newString: newContent, replaceAll: false }] },
133
133
  };
134
134
  }
135
+ const ipynb = ipynbRedirect("Edit", path);
136
+ if (ipynb)
137
+ return errorResult(ipynb);
135
138
  const notRead = requireRead(state, r.key);
136
139
  if (notRead)
137
140
  return errorResult(await notReadRefusalText(env, "Edit", r.key, notRead, ctx.signal));
@@ -170,9 +173,18 @@ export function createEditFileTool(env, state, rootCanonical, cwdRef, additional
170
173
  oldS = resolved;
171
174
  }
172
175
  }
176
+ if (oldS !== "" && !working.includes(oldS)) {
177
+ const resolvedEsc = resolveEscapeMatch(working, oldS);
178
+ if (resolvedEsc !== undefined && resolvedEsc !== oldS) {
179
+ newS = adaptNewStringEscapes(oldS, resolvedEsc, newS);
180
+ oldS = resolvedEsc;
181
+ }
182
+ }
173
183
  const match = checkEditMatch(working, oldS, e.replace_all === true, entry.truncated);
174
- if (match)
175
- return errorResult(batch ? `Error (Edit): ${where}${match.message} (no changes written the batch is atomic).` : violationText("Edit", match));
184
+ if (match) {
185
+ const escNote = match.code === "ambiguous_edit" && escapeMatchWasAttempted(e.old_string) && !working.includes(oldS) ? ESCAPE_MATCH_MISS_NOTE : "";
186
+ return errorResult(batch ? `Error (Edit): ${where}${match.message}${escNote} (no changes written — the batch is atomic).` : `${violationText("Edit", match)}${escNote}`);
187
+ }
176
188
  replacements += e.replace_all === true ? countOccurrences(working, oldS) : 1;
177
189
  const effOld = e.replace_all === true ? oldS : deletionOldString(working, oldS, newS);
178
190
  working = e.replace_all === true ? working.split(effOld).join(newS) : working.replace(effOld, () => newS);
@@ -29,6 +29,7 @@ export interface HandsToolkitOptions {
29
29
  }) => void;
30
30
  detachHub?: import("../../core/tool-detach.js").ToolDetachHub;
31
31
  execClamp?: ExecClampOption;
32
+ oneShot?: boolean;
32
33
  autoBackgroundOnTimeout?: boolean;
33
34
  readImageDownsampler?: ReadImageDownsamplerOption;
34
35
  pdfModelCapabilities?: PdfModelCapabilities;
@@ -45,11 +45,12 @@ export function createHandsToolkit(env, readFileState, rootCanonical, opts = {})
45
45
  detachHub: opts.detachHub,
46
46
  execClamp: opts.execClamp,
47
47
  ...(opts.autoBackgroundOnTimeout !== undefined ? { autoBackgroundOnTimeout: opts.autoBackgroundOnTimeout } : {}),
48
+ ...(opts.oneShot !== undefined ? { oneShot: opts.oneShot } : {}),
48
49
  }));
49
50
  if (!readOnly && mountBackgroundTaskTools && hasBackgroundShell(env)) {
50
51
  const sessionAxis = opts.sessionId !== undefined ? { sessionId: opts.sessionId } : {};
51
52
  tools.push(opts.taskRegistry
52
- ? createTaskOutputTool({ registry: opts.taskRegistry, owner: opts.taskOwner, scope: opts.taskScope, ...sessionAxis })
53
+ ? createTaskOutputTool({ registry: opts.taskRegistry, owner: opts.taskOwner, scope: opts.taskScope, ...sessionAxis, ...(opts.oneShot !== undefined ? { oneShot: opts.oneShot } : {}) })
53
54
  : createEnvTaskOutputTool(env), opts.taskRegistry
54
55
  ? createTaskStopTool({ registry: opts.taskRegistry, owner: opts.taskOwner, scope: opts.taskScope, ...sessionAxis })
55
56
  : createEnvTaskStopTool(env));
@@ -53,6 +53,10 @@ export declare function countOccurrences(haystack: string, needle: string): numb
53
53
  export declare function similarNameSuggestion(siblingNames: readonly string[], missingName: string): string | undefined;
54
54
  export declare function normalizeQuotes(s: string): string;
55
55
  export declare function resolveQuoteMatch(content: string, oldString: string): string | undefined;
56
+ export declare function resolveEscapeMatch(content: string, oldString: string): string | undefined;
57
+ export declare function adaptNewStringEscapes(oldString: string, matchedOld: string, newString: string): string;
58
+ export declare function escapeMatchWasAttempted(oldString: string): boolean;
59
+ export declare const ESCAPE_MATCH_MISS_NOTE = "\n(note: Edit also tried swapping \\uXXXX escapes and their characters; neither form matched, so the mismatch is likely elsewhere in old_string. Re-read the file and copy the exact surrounding text.)";
56
60
  export declare function adaptNewStringQuotes(matchedOld: string, newString: string): string;
57
61
  export declare function deletionOldString(content: string, oldString: string, newString: string): string;
58
62
  export declare function checkEditMatch(content: string, oldString: string, replaceAll: boolean, truncated: boolean): FsViolation | undefined;
@@ -38,17 +38,26 @@ const BLOCKED_DEVICE_PATHS = new Set([
38
38
  "/dev/stdin", "/dev/stdout", "/dev/stderr", "/dev/tty", "/dev/console",
39
39
  "/dev/fd/0", "/dev/fd/1", "/dev/fd/2",
40
40
  ]);
41
- const PROC_FD_RE = /^\/proc\/[^/]+\/fd\/[0-2]$/;
41
+ function isProcStdioFd(key) {
42
+ return key.startsWith("/proc/") && (key.endsWith("/fd/0") || key.endsWith("/fd/1") || key.endsWith("/fd/2"));
43
+ }
44
+ const PROC_SENSITIVE_SUFFIXES = ["/environ", "/cmdline", "/auxv", "/maps", "/mem", "/stat"];
45
+ function isProcSensitiveFile(key) {
46
+ return key.startsWith("/proc/") && PROC_SENSITIVE_SUFFIXES.some((suf) => key.endsWith(suf));
47
+ }
42
48
  const WIN_RESERVED_RE = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\.[^\\/]*)?$/i;
49
+ function isWinFormPath(p) {
50
+ return /^[A-Za-z]:[\\/]/.test(p) || p.startsWith("\\\\") || (!p.startsWith("/") && p.includes("\\"));
51
+ }
43
52
  function isWinReservedDeviceKey(key) {
44
- if (!(/^[A-Za-z]:[\\/]/.test(key) || key.startsWith("\\\\") || (!key.startsWith("/") && key.includes("\\"))))
53
+ if (!isWinFormPath(key))
45
54
  return false;
46
55
  const t = key.replace(/[\\/]+$/, "");
47
56
  const base = t.slice(Math.max(t.lastIndexOf("/"), t.lastIndexOf("\\")) + 1);
48
57
  return WIN_RESERVED_RE.test(base);
49
58
  }
50
59
  export function isBlockedDevicePath(key) {
51
- return BLOCKED_DEVICE_PATHS.has(key) || PROC_FD_RE.test(key) || isWinReservedDeviceKey(key);
60
+ return BLOCKED_DEVICE_PATHS.has(key) || isProcStdioFd(key) || isProcSensitiveFile(key) || isWinReservedDeviceKey(key);
52
61
  }
53
62
  export function normalizeAbsPathLexically(p) {
54
63
  if (!p.startsWith("/"))
@@ -67,12 +76,16 @@ export function normalizeAbsPathLexically(p) {
67
76
  }
68
77
  const BINARY_EXTENSIONS = new Set([
69
78
  ".exe", ".dll", ".so", ".dylib", ".o", ".a", ".obj", ".lib", ".bin", ".class", ".pyc", ".pyo", ".wasm",
70
- ".zip", ".tar", ".gz", ".tgz", ".bz2", ".xz", ".7z", ".rar", ".jar", ".war", ".ear", ".lz4", ".zst",
79
+ ".app", ".msi", ".deb", ".rpm", ".node", ".rlib",
80
+ ".zip", ".tar", ".gz", ".tgz", ".bz2", ".xz", ".7z", ".rar", ".jar", ".war", ".ear", ".lz4", ".zst", ".z",
71
81
  ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", ".tif", ".ico", ".heic", ".avif",
72
- ".mp3", ".wav", ".flac", ".ogg", ".m4a", ".aac", ".mp4", ".mov", ".avi", ".mkv", ".webm", ".wmv", ".flv",
82
+ ".mp3", ".wav", ".flac", ".ogg", ".m4a", ".aac", ".wma", ".aiff", ".opus",
83
+ ".mp4", ".mov", ".avi", ".mkv", ".webm", ".wmv", ".flv", ".m4v", ".mpeg", ".mpg",
73
84
  ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".odt", ".ods", ".odp",
74
85
  ".woff", ".woff2", ".ttf", ".otf", ".eot",
75
- ".db", ".sqlite", ".sqlite3", ".mdb", ".dat", ".pdb", ".ipa", ".apk", ".dmg", ".iso", ".img",
86
+ ".psd", ".ai", ".eps", ".sketch", ".fig", ".xd", ".blend", ".3ds", ".max", ".swf", ".fla",
87
+ ".db", ".sqlite", ".sqlite3", ".mdb", ".idx", ".dat", ".data", ".lockb",
88
+ ".pdb", ".ipa", ".apk", ".dmg", ".iso", ".img",
76
89
  ]);
77
90
  export function hasBinaryExtension(path) {
78
91
  const dot = path.lastIndexOf(".");
@@ -168,10 +181,11 @@ export async function resolveKey(env, rootCanonical, path, signal, baseCwd, addi
168
181
  return { ok: true, key };
169
182
  }
170
183
  export async function canonicalizeTarget(env, path, signal, baseCwd) {
171
- if (isUncPath(path)) {
172
- return { ok: false, message: `path "${path}" is a UNC/network path; refused (potential credential leak).` };
184
+ if (isUncPath(path) && isWinFormPath(path)) {
185
+ return { ok: true, key: path };
173
186
  }
174
- const target = baseCwd && !isAbsolutePathForm(path) ? `${baseCwd.replace(/[\\/]+$/, "")}/${path}` : path;
187
+ const spelled = path.startsWith("//") ? path.replace(/^\/+/, "/") : path;
188
+ const target = baseCwd && !isAbsolutePathForm(spelled) ? `${baseCwd.replace(/[\\/]+$/, "")}/${spelled}` : spelled;
175
189
  const absR = await env.absolutePath(target, signal);
176
190
  if (!absR.ok)
177
191
  return { ok: false, message: `cannot resolve path "${path}": ${absR.error.message}` };
@@ -249,7 +263,7 @@ export function checkNoChange(oldString, newString) {
249
263
  }
250
264
  export function checkStale(entry, currentHash) {
251
265
  if (entry.hash !== currentHash) {
252
- return { code: "stale", message: "File has been modified since it was read; read it again before editing." };
266
+ return { code: "stale", message: "File has been modified since read, either by the user or by a linter. Read it again before attempting to write it." };
253
267
  }
254
268
  return undefined;
255
269
  }
@@ -285,6 +299,95 @@ export function resolveQuoteMatch(content, oldString) {
285
299
  return undefined;
286
300
  return content.substring(idx, idx + oldString.length);
287
301
  }
302
+ const UNICODE_ESCAPE_RE = /\\u[0-9a-fA-F]{4}/;
303
+ const NON_ASCII_RE = /[\u0080-\uffff]/;
304
+ function decodeUnicodeEscapes(s) {
305
+ return s.replace(/(\\\\)|\\u([0-9a-fA-F]{4})/g, (m, dbl, hex) => (dbl !== undefined ? m : String.fromCharCode(parseInt(hex, 16))));
306
+ }
307
+ function escapedSpellingPattern(s) {
308
+ let out = "";
309
+ for (let i = 0; i < s.length; i++) {
310
+ const code = s.charCodeAt(i);
311
+ if (code >= 128) {
312
+ out += "\\\\u";
313
+ for (const d of code.toString(16).padStart(4, "0"))
314
+ out += d >= "a" ? `[${d}${d.toUpperCase()}]` : d;
315
+ }
316
+ else
317
+ out += s[i].replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
318
+ }
319
+ return out;
320
+ }
321
+ export function resolveEscapeMatch(content, oldString) {
322
+ if (oldString === "")
323
+ return undefined;
324
+ if (UNICODE_ESCAPE_RE.test(oldString)) {
325
+ const decoded = decodeUnicodeEscapes(oldString);
326
+ if (decoded !== oldString && content.includes(decoded))
327
+ return decoded;
328
+ }
329
+ if (NON_ASCII_RE.test(oldString)) {
330
+ let hit = null;
331
+ try {
332
+ hit = content.match(new RegExp(escapedSpellingPattern(oldString)));
333
+ }
334
+ catch {
335
+ hit = null;
336
+ }
337
+ if (hit)
338
+ return hit[0];
339
+ }
340
+ return undefined;
341
+ }
342
+ export function adaptNewStringEscapes(oldString, matchedOld, newString) {
343
+ if (oldString === matchedOld)
344
+ return newString;
345
+ if (NON_ASCII_RE.test(oldString)) {
346
+ let full;
347
+ try {
348
+ full = new RegExp(`^${escapedSpellingPattern(oldString)}$`);
349
+ }
350
+ catch {
351
+ full = undefined;
352
+ }
353
+ if (full?.test(matchedOld)) {
354
+ const hexOf = new Map();
355
+ let upper = 0;
356
+ let lower = 0;
357
+ for (let i = 0, at = 0; i < oldString.length; i++) {
358
+ const code = oldString.charCodeAt(i);
359
+ if (code >= 128) {
360
+ const hex = matchedOld.slice(at + 2, at + 6);
361
+ hexOf.set(code, hex);
362
+ for (const d of hex) {
363
+ if (d >= "a" && d <= "f")
364
+ lower++;
365
+ else if (d >= "A" && d <= "F")
366
+ upper++;
367
+ }
368
+ at += 6;
369
+ }
370
+ else
371
+ at += 1;
372
+ }
373
+ return newString.replace(/[\u0080-\uffff]/g, (ch) => {
374
+ const code = ch.charCodeAt(0);
375
+ const known = hexOf.get(code);
376
+ if (known !== undefined)
377
+ return `\\u${known}`;
378
+ const hex = code.toString(16).padStart(4, "0");
379
+ return `\\u${upper > lower ? hex.toUpperCase() : hex}`;
380
+ });
381
+ }
382
+ }
383
+ if (UNICODE_ESCAPE_RE.test(oldString) && decodeUnicodeEscapes(oldString) === matchedOld)
384
+ return decodeUnicodeEscapes(newString);
385
+ return newString;
386
+ }
387
+ export function escapeMatchWasAttempted(oldString) {
388
+ return UNICODE_ESCAPE_RE.test(oldString) || NON_ASCII_RE.test(oldString);
389
+ }
390
+ export const ESCAPE_MATCH_MISS_NOTE = "\n(note: Edit also tried swapping \\uXXXX escapes and their characters; neither form matched, so the mismatch is likely elsewhere in old_string. Re-read the file and copy the exact surrounding text.)";
288
391
  function isQuoteOpenerPosition(chars, i) {
289
392
  if (i === 0)
290
393
  return true;
@@ -21,6 +21,7 @@ export declare function splitAbsoluteGlobPattern(pattern: string): {
21
21
  relativePattern: string;
22
22
  } | undefined;
23
23
  export declare function shellQuote(s: string): string;
24
+ export declare function invalidGlobTokens(glob: string): string[];
24
25
  export declare function gitignoreMatcher(content: string): (rel: string, isDir: boolean) => boolean;
25
26
  export declare function buildIgnore(env: ExecutionEnv, root: string, signal?: AbortSignal): Promise<(rel: string, isDir: boolean) => boolean>;
26
27
  export interface WalkResult {
@@ -76,7 +76,7 @@ export function splitAbsoluteGlobPattern(pattern) {
76
76
  export function shellQuote(s) {
77
77
  return `'${s.replace(/'/g, `'\\''`)}'`;
78
78
  }
79
- function globTokenToRegExp(token, anchored, glob = false) {
79
+ function globTokenToRegExp(token, anchored, glob = false, report) {
80
80
  const charToRe = (ch) => ch === "*" ? "[^/]*" : ch === "?" ? "[^/]" : ch.replace(/[.+^${}()|[\]\\]/g, "\\$&");
81
81
  let re = "";
82
82
  for (let i = 0; i < token.length; i++) {
@@ -100,8 +100,11 @@ function globTokenToRegExp(token, anchored, glob = false) {
100
100
  re += `(?:${alts.join("|")})`;
101
101
  i = close;
102
102
  }
103
- else
103
+ else {
104
104
  re += "\\{";
105
+ if (report)
106
+ report.degraded = true;
107
+ }
105
108
  }
106
109
  else if (glob && c === "[") {
107
110
  let j = i + 1;
@@ -129,14 +132,27 @@ function globTokenToRegExp(token, anchored, glob = false) {
129
132
  re += cls;
130
133
  i = close;
131
134
  }
132
- else
135
+ else {
133
136
  re += "\\[";
137
+ if (report)
138
+ report.degraded = true;
139
+ }
134
140
  }
135
141
  else
136
142
  re += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
137
143
  }
138
144
  return new RegExp(`${anchored ? "^" : "(^|/)"}${re}$`);
139
145
  }
146
+ export function invalidGlobTokens(glob) {
147
+ const bad = [];
148
+ for (const token of splitGlobParam(glob)) {
149
+ const report = { degraded: false };
150
+ globTokenToRegExp(normalizeGlobToken(token), globIsAnchored(token), true, report);
151
+ if (report.degraded)
152
+ bad.push(token);
153
+ }
154
+ return bad;
155
+ }
140
156
  function normalizeGlobToken(token) {
141
157
  let t = token;
142
158
  while (t.startsWith("./"))
@@ -1095,6 +1111,10 @@ export async function runGlobDetailed(env, root, pattern, opts = {}, signal) {
1095
1111
  const error = `Error (Glob): path ${JSON.stringify(opts.path)} does not exist — check the path, or omit \`path\` to search the whole root.`;
1096
1112
  return { text: error, error, filenames: [], numFiles: 0, truncated: false, durationMs: Date.now() - t0, totalMatches: 0, countIsComplete: false };
1097
1113
  }
1114
+ if (probe.ok && probe.value.kind === "file") {
1115
+ const error = `Error (Glob): path ${JSON.stringify(opts.path)} is not a directory — \`path\` scopes the search to a directory; pass its parent directory (and name the file in \`pattern\`), or omit \`path\` to search the whole root.`;
1116
+ return { text: error, error, filenames: [], numFiles: 0, truncated: false, durationMs: Date.now() - t0, totalMatches: 0, countIsComplete: false };
1117
+ }
1098
1118
  }
1099
1119
  const pat = normalizeGlobToken(pattern);
1100
1120
  const anchored = globIsAnchored(pattern);
@@ -10,7 +10,7 @@ const MONITOR_DESCRIPTION = "Run a shell command in the background and watch its
10
10
  `exceeds its timeout is killed and reported (default ${MONITOR_DEFAULT_TIMEOUT_MS}ms, max ${MONITOR_MAX_TIMEOUT_MS}ms).\n` +
11
11
  "- stderr does NOT trigger notifications, but it is captured — read it any time with TaskOutput(task_id), " +
12
12
  "which also serves the full re-readable stdout spool.\n" +
13
- "- A monitor that produces too many events is stopped automatically (the stop notification says why).\n" +
13
+ "- A monitor that produces too many events is rate-limited: excess batches are suppressed (a notification tells you how many), and a watch that stays over the limit is stopped automatically.\n" +
14
14
  "- Set persistent: true for a session-resident watch with no watch timeout of its own; it still ends when " +
15
15
  "the execution environment's background time budget expires, when you stop it with TaskStop(task_id), or " +
16
16
  "when the session is released.\n" +
@@ -20,5 +20,6 @@ export interface TaskListStore {
20
20
  }
21
21
  export declare function normalizeTaskShape<T>(item: T): T;
22
22
  export declare function assertJsonMetadata(value: unknown, path?: string): void;
23
+ export declare function compareTaskIds(a: string, b: string): number;
23
24
  export declare function createMemoryTaskListStore(): TaskListStore;
24
25
  export declare function createTaskListTools(store?: TaskListStore): ToolSpec[];
@@ -55,6 +55,17 @@ export function assertJsonMetadata(value, path = "metadata") {
55
55
  }
56
56
  throw new Error(`Task ${path} must be JSON-serializable (found ${t === "object" ? "non-plain object" : t}).`);
57
57
  }
58
+ export function compareTaskIds(a, b) {
59
+ const num = (id) => (/^\d+$/.test(id) ? Number.parseInt(id, 10) : Number.NaN);
60
+ const [na, nb] = [num(a), num(b)];
61
+ if (Number.isFinite(na) && Number.isFinite(nb))
62
+ return na - nb || a.localeCompare(b);
63
+ if (Number.isFinite(na))
64
+ return -1;
65
+ if (Number.isFinite(nb))
66
+ return 1;
67
+ return a.localeCompare(b);
68
+ }
58
69
  export function createMemoryTaskListStore() {
59
70
  const tasks = new Map();
60
71
  let nextId = 1;
@@ -69,7 +80,7 @@ export function createMemoryTaskListStore() {
69
80
  tasks.set(id, snapTask(item));
70
81
  },
71
82
  delete: (id) => tasks.delete(id),
72
- list: () => [...tasks.values()].map(snapTask),
83
+ list: () => [...tasks.values()].sort((a, b) => compareTaskIds(a.id, b.id)).map(snapTask),
73
84
  allocateId: () => String(nextId++),
74
85
  };
75
86
  }
@@ -133,7 +144,7 @@ export function createTaskListTools(store) {
133
144
  activeForm: Type.Optional(Type.String({ description: 'Present continuous form shown when in_progress (e.g., "Running tests")' })),
134
145
  metadata: Type.Optional(Type.Record(Type.String(), Type.Unknown(), { description: "Arbitrary metadata to attach to the task" })),
135
146
  }),
136
- effect: "idempotent",
147
+ effect: "write",
137
148
  execute: (args) => serialized(tasks, async (tx) => {
138
149
  const a = args;
139
150
  if (a.metadata)