@molecule/api-ai-tools 1.6.4 → 1.6.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/tools.js CHANGED
@@ -6,6 +6,7 @@
6
6
  *
7
7
  * @module
8
8
  */
9
+ import { commandCutRun, CUT_AT_BUDGET, cutRunSentence } from './cut-run.js';
9
10
  import { addChangeSpan, changedSpan, countLines, renderEditExcerpt, renderSmallFile, } from './edit-excerpt.js';
10
11
  import { guardToolExecute, missingParamError, ToolInputError } from './input-normalizer.js';
11
12
  import { TOOL_SCHEMAS } from './schemas.js';
@@ -25,6 +26,16 @@ const waitedByTask = new Map();
25
26
  const logBytesByTask = new Map();
26
27
  /** The file a background command writes its own output to, when it redirects it. */
27
28
  const outputFileByTask = new Map();
29
+ /**
30
+ * How each background task was started: the command as it was actually run (after
31
+ * {@link streamingBackgroundCommand} rewrote it) and the directory it ran in.
32
+ *
33
+ * A mid-run note has to hand over the re-run of a test that just failed, and a re-run is composed
34
+ * from the command that is still walking ({@link rerunOfRunningSuite}) — a whole-suite re-run is
35
+ * what the executor must not be handed for now, and a re-run narrowed for the wrong directory
36
+ * resolves the failing test's `file:line` against the wrong suite.
37
+ */
38
+ const runByTask = new Map();
28
39
  /**
29
40
  * Task ids whose own-output file has already been looked for this process.
30
41
  * {@link outputFileByTask} is empty for every task after a restart — it is
@@ -198,12 +209,43 @@ function runningNote(clauses) {
198
209
  const joined = clauses.join('; ');
199
210
  return `${joined[0].toUpperCase()}${joined.slice(1)}.`;
200
211
  }
201
- import { checkBlockedCommand, declaredBudgetSeconds, DEFAULT_SEARCH_EXCLUDED_DIRS, directoryReadHint, emptyCallError, failureCausesNote, findPatternArgs, fusedLine, htmlOutline, isEnvFilePath, isOutsideRoot, isPlaywrightRunCommand, isValidGlob, linesWithinSerialized, MAX_BATCH_READ_FILES, MAX_FIND_RESULTS, MAX_HTML_INLINE_CHARS, MAX_OUTPUT_SIZE, MAX_READ_CALL_CHARS, MAX_READ_RETURN_CHARS, MAX_READ_SIZE, MAX_SEARCH_CONTEXT_LINES, MAX_SEARCH_RESULTS, MAX_WRITE_SIZE, outputIsWithheldUntilExit, outputRedirectTarget, parseCheckCommand, parseCheckProblem, pathArgError, printsOnlySource, READ_RESULT_ENVELOPE_CHARS, redactSecrets, redactSecretsInCode, repeatingFailure, repeatingFailureNote, resolvePath, RUNNING_FAILURE_LINES, runningCauseCommand, runningOutputSliceCommand, serializedChars, shellQuote, sliceLines, SMALL_FILE_WHOLE_CHARS, streamingBackgroundCommand, stripControlChars, suiteRunPriorityLaunch, truncateMiddle, universalCause, whitespaceTolerantReplace, } from './utilities.js';
212
+ import { checkBlockedCommand, declaredBudgetSeconds, DEFAULT_SEARCH_EXCLUDED_DIRS, directoryReadHint, emptyCallError, failureCausesNote, findPathArgs, findPatternArgs, fusedLine, htmlOutline, isCommandTimeout, isEnvFilePath, isOutsideRoot, isPlaywrightRunCommand, isSuiteRunCommand, isValidGlob, linesWithinSerialized, literalDirPrefix, MAX_BATCH_READ_FILES, MAX_FIND_RESULTS, MAX_HTML_INLINE_CHARS, MAX_OUTPUT_SIZE, MAX_READ_CALL_CHARS, MAX_READ_RETURN_CHARS, MAX_READ_SIZE, MAX_SEARCH_CONTEXT_LINES, MAX_SEARCH_RESULTS, MAX_WRITE_SIZE, outputIsWithheldUntilExit, outputRedirectTarget, parseCheckCommand, parseCheckProblem, pathArgError, printsOnlySource, READ_RESULT_ENVELOPE_CHARS, redactSecrets, redactSecretsInCode, repeatingFailure, repeatingFailureNote, resolvePath, RUNNING_FAILURE_LINES, runningCauseCommand, runningOutputSliceCommand, searchTimeoutError, serializedChars, shellQuote, sliceLines, SMALL_FILE_WHOLE_CHARS, streamingBackgroundCommand, stripControlChars, suiteRunPriorityLaunch, truncateMiddle, universalCause, whitespaceTolerantReplace, } from './utilities.js';
202
213
  /**
203
214
  * How long exec_command waits for a command it backgrounded only because the
204
215
  * command asked for more than the tool's ceiling, before handing back a task id.
205
216
  */
206
217
  const OVER_CEILING_GRACE_MS = 45_000;
218
+ /**
219
+ * The closing advice on an over-budget message for a command that is not a test
220
+ * suite: the work it was chaining is what to split.
221
+ */
222
+ export const SPLIT_WORK_ADVICE = 'Run a smaller unit per command (one test file, one build step) instead of chaining ' +
223
+ 'a build and a whole suite.';
224
+ /**
225
+ * The closing advice on an over-budget message for a command that IS a suite run
226
+ * (Playwright, Vitest, a `hurl` run — {@link isSuiteRunCommand}): its files are not
227
+ * a bigger unit to break up.
228
+ *
229
+ * X1 flash-m36 asked for this half and the message kept saying "run a smaller unit"
230
+ * for two months. It is the wrong direction for a suite, and measurably: a run
231
+ * stopped here reports NO tally, so nothing in the result says which test or request
232
+ * hung — the run has to be sent again in the background (off this budget, its output
233
+ * written to a log that can be read mid-run) or, when a consumer knows the runner's
234
+ * per-request limit, with each request bounded. xr-molecule-flash-m93 (+06:22,
235
+ * 2026-10-16) lost 4.8 minutes to a Hurl run whose ONE unanswered request had hurl's
236
+ * 300 s default per request holding the file, ten seconds past this tool's budget.
237
+ */
238
+ export const SUITE_OVER_BUDGET_ADVICE = 'A test suite is not a bigger unit to split: stopped here it reports no tally at all, so nothing ' +
239
+ 'in this result says which test or request never finished — run it in the background ' +
240
+ '(`run_in_background`, then `wait_for_task`), which takes it off this budget and writes its output ' +
241
+ "to a log, and read that log (or the app's own log) for what it was waiting on.";
242
+ /**
243
+ * How long a tree walk (`find_files`, `search_files`) may run before the backend's
244
+ * own deadline stops it. Both tools answer that deadline in their own words
245
+ * (see searchTimeoutError) rather than forwarding the sandbox SDK's `timeoutMs`
246
+ * sentence, which names an option the executor cannot set.
247
+ */
248
+ const SEARCH_TIMEOUT_MS = 10_000;
207
249
  /**
208
250
  * The share of its budget a command must use before the "you asked for more than
209
251
  * this tool's ceiling" note is worth attaching. Below it the clamp changed nothing
@@ -234,6 +276,52 @@ export function moleculeDistReadNote(path) {
234
276
  ? ' The declarations above are generated from it.'
235
277
  : ' For one signature, read the matching .d.ts file.'));
236
278
  }
279
+ /**
280
+ * The refusal for a write whose target is inside an installed package, or null
281
+ * when the path is not under a `node_modules` directory.
282
+ *
283
+ * `node_modules` is not the project's code: nothing written there survives the
284
+ * next install, no deploy carries it, and a sandbox whose dependency tree is
285
+ * shared (the pre-installed superset) breaks every other run through it. X0
286
+ * xr-molecule-flash-m91 (+04:43) called `write_file` on
287
+ * `node_modules/@molecule/api-resource-comment/dist/numbers.d.ts` while reading
288
+ * the package's declarations and clobbered its runtime module a call later
289
+ * (`module.exports = {}` over the ESM handler its own code imports) — the write
290
+ * answered `ok: true`, and 18 s went to noticing, `npm pack`-ing the package and
291
+ * putting the file back. Reading these files stays allowed (that is how the
292
+ * declarations above are read at all).
293
+ *
294
+ * The restore route is named because the model that has ALREADY clobbered a file
295
+ * (this refusal is too late for it) is the one who needs it, and because the
296
+ * obvious repair — `npm install <pkg>` — is itself refused in the pre-installed
297
+ * sandbox. `npm pack` + a `tar` extraction over the package directory is what
298
+ * m91 used, and what both guards leave alone.
299
+ *
300
+ * @param path - An absolute path a tool is about to write.
301
+ * @returns The one-line refusal naming the package, or null when nothing is installed there.
302
+ */
303
+ export function installedPackageWriteRefusal(path) {
304
+ // A path may sit in a NESTED node_modules (`app/node_modules/...`) as well as the
305
+ // hoisted root one: both are installed packages.
306
+ const at = /(?:^|\/)node_modules(?:\/|$)/.exec(path);
307
+ if (!at)
308
+ return null;
309
+ const named = /(?:^|\/)node_modules\/((?:@[^/]+\/)?[^/]+)(?:\/|$)/.exec(path);
310
+ const name = named?.[1] ?? null;
311
+ const scoped = at[0].startsWith('/') ? path.slice(0, at.index) : '';
312
+ const dir = name ? `${scoped}/node_modules/${name}` : null;
313
+ const what = name ? `the installed package ${name}` : 'an installed package';
314
+ const change = 'Installed packages are read-only: the next install replaces that file and a deploy never ' +
315
+ 'carries it. Change its behaviour from your app instead (its options, your own handler, a ' +
316
+ 'bond), and where the package itself is wrong, report it rather than patching it here. ' +
317
+ 'Reading these files is fine.';
318
+ const restore = name && dir
319
+ ? ` If you have already overwritten one there, restore the package before anything else: ` +
320
+ `\`npm pack ${name}@<version>\` (the version is in ${dir}/package.json), then ` +
321
+ `\`tar -xzf <the tarball npm pack printed> --strip-components=1 -C ${dir}\` — never a hand-edit.`
322
+ : '';
323
+ return `Not written: ${path} is inside ${what} (node_modules). ${change}${restore}`;
324
+ }
237
325
  /**
238
326
  * Build a complete set of AI agent tools bound to an execution backend.
239
327
  *
@@ -336,6 +424,22 @@ export function buildTools(backend, config) {
336
424
  }
337
425
  return pathGuards ? resolvePath(path, root) : path;
338
426
  }
427
+ /**
428
+ * Resolve the path a WRITING tool was given, refusing a target inside an
429
+ * installed package ({@link installedPackageWriteRefusal}). Reads are not sent
430
+ * through this: reading `node_modules` is how a package's declarations are
431
+ * read at all, and only the write is unrecoverable.
432
+ *
433
+ * @param path - Relative or absolute path requested by the tool input.
434
+ * @returns The normalized path, when writing it is allowed.
435
+ */
436
+ function resolveWrite(path) {
437
+ const resolved = resolve(path);
438
+ const refusal = installedPackageWriteRefusal(resolved);
439
+ if (refusal)
440
+ throw new ToolInputError(refusal);
441
+ return resolved;
442
+ }
339
443
  /** Paths read with a window in this tool set, to tell paging from one targeted read. */
340
444
  const windowedReads = new Set();
341
445
  /** The text each path was last handed back WHOLE as, so that correction fires once per version. */
@@ -887,7 +991,7 @@ export function buildTools(backend, config) {
887
991
  const argErr = pathArgError(input.path, 'write_file');
888
992
  if (argErr)
889
993
  return { error: argErr };
890
- const path = resolve(input.path);
994
+ const path = resolveWrite(input.path);
891
995
  const content = input.content;
892
996
  if (content.length > MAX_WRITE_SIZE)
893
997
  return {
@@ -936,7 +1040,7 @@ export function buildTools(backend, config) {
936
1040
  const argErr = pathArgError(input.path, 'edit_file');
937
1041
  if (argErr)
938
1042
  return { error: argErr };
939
- const path = resolve(input.path);
1043
+ const path = resolveWrite(input.path);
940
1044
  // Support both formats: { replacements: [...] } (batch) and { old_string, new_string } (single, backwards-compatible)
941
1045
  let replacements = input.replacements;
942
1046
  // Accept the single format when BOTH are strings — `typeof` (not truthy) so
@@ -973,13 +1077,35 @@ export function buildTools(backend, config) {
973
1077
  }
974
1078
  const oldContent = content;
975
1079
  let alreadyApplied = 0;
1080
+ // Replacements refused for changing the line break at old_string's boundary and
1081
+ // nothing else — reported by position, never applied (see below).
1082
+ const skipped = [];
976
1083
  // Where each applied replacement landed, in the coordinates of the
977
1084
  // latest content — rendered into the result so the model sees the edit.
978
1085
  let spans = [];
979
- for (const { old_string: oldString, new_string: newString, replace_all: replaceAll,
1086
+ // A replacement whose ONLY effect is the line break at old_string's boundary:
1087
+ // `new_string` is `old_string` minus its trailing newline (applying it runs the
1088
+ // line after it onto the last line) or `old_string` plus one (applying it moves
1089
+ // the rest of that line down). A weaker model sends the first as an "anchor" — it
1090
+ // repeats a line it means to keep, minus its `\n` — and the join is silent when
1091
+ // it still parses: flash-m28 merged two lines three times in one run, flash-m89
1092
+ // twice in two consecutive calls (comments.ts, then articles.ts), flash-m55,
1093
+ // m62 and m89 all paid a repair round trip for it. A real edit changes some other
1094
+ // character, so this costs nothing.
1095
+ const boundaryOnly = (oldText, newText) => {
1096
+ const oldBody = oldText.replace(/\r?\n$/, '');
1097
+ // `newText !== ''` keeps a deletion out of this: `old_string: "\n"` → `""` is a
1098
+ // real request to remove a blank line, and the notes below already cover it.
1099
+ if (newText !== '' && oldBody !== oldText && newText === oldBody)
1100
+ return 'removed';
1101
+ if (!oldText.endsWith('\n') && newText === `${oldText}\n`)
1102
+ return 'added';
1103
+ return null;
1104
+ };
1105
+ for (const [index, { old_string: oldString, new_string: newString, replace_all: replaceAll,
980
1106
  // Models sometimes send the camelCase spelling inside an item, where
981
1107
  // the input normalizer (top-level keys only) does not reach.
982
- replaceAll: replaceAllCamel, } of replacements) {
1108
+ replaceAll: replaceAllCamel, },] of replacements.entries()) {
983
1109
  // Validate each replacement is well-formed BEFORE touching content. A
984
1110
  // weak model sometimes emits a batch element missing a field or with a
985
1111
  // non-string value; left unchecked that either crashes (`undefined`
@@ -1081,15 +1207,74 @@ export function buildTools(backend, config) {
1081
1207
  .map((l) => l.trim())
1082
1208
  .filter((l) => l.length >= 8)),
1083
1209
  ].sort((a, b) => b.length - a.length);
1210
+ const oldLines = oldString.split('\n');
1211
+ // Whitespace is the one difference the model cannot see in its own
1212
+ // text, so make it visible when that is the whole difference.
1213
+ const visible = (l) => l.replace(/ /g, '·').replace(/\t/g, '⇥');
1214
+ // Report where old_string and the file STOP agreeing, not where the
1215
+ // match starts. A snippet centred on the anchor is the part the model
1216
+ // already has right, so it re-sends the same old_string and fails the
1217
+ // same way (X0 m89: two failed edits, a whole-file re-read and a
1218
+ // rewrite, 9 s, for a divergence 35 lines below the anchor).
1219
+ const showDivergence = (base, k) => {
1220
+ const expected = oldLines[k];
1221
+ const actual = fileLines[base + k] ?? '(end of file)';
1222
+ const whitespaceOnly = expected.replace(/\s/g, '') === actual.replace(/\s/g, '');
1223
+ let col = 0;
1224
+ while (col < expected.length && col < actual.length && expected[col] === actual[col])
1225
+ col++;
1226
+ const howFar = whitespaceOnly
1227
+ ? ' (whitespace only — · is a space, ⇥ a tab)'
1228
+ : col > 0
1229
+ ? ` (first character difference at column ${col + 1})`
1230
+ : '';
1231
+ const start = Math.max(0, base + k - 2);
1232
+ const snippet = fileLines
1233
+ .slice(start, base + k + 6)
1234
+ .map((l, i) => `${start + i + 1}: ${l}`)
1235
+ .join('\n');
1236
+ return {
1237
+ error: `old_string not found in ${path} — it matches the file through line ${base + k}, ` +
1238
+ `then diverges at your line ${k + 1} of ${oldLines.length}${howFar}.\n` +
1239
+ `Your old_string line ${k + 1}: ${whitespaceOnly ? visible(expected) : expected}\n` +
1240
+ `The file's line ${base + k + 1}: ${whitespaceOnly ? visible(actual) : actual}\n` +
1241
+ `The file's ACTUAL content near your target (copy the exact text from here):\n${snippet}`,
1242
+ };
1243
+ };
1244
+ // The longest run of old_string lines that IS in the file decides which
1245
+ // alignment to report, so a later distinctive line can beat an earlier
1246
+ // one. k === 0 means nothing beyond the anchor itself lines up; only
1247
+ // then fall back to the old anchor snippet.
1248
+ let best = null;
1249
+ let firstUniqueHit = null;
1084
1250
  for (const anchor of distinctiveLines) {
1085
1251
  const hits = fileLines.reduce((acc, l, i) => {
1086
1252
  if (l.includes(anchor))
1087
1253
  acc.push(i);
1088
1254
  return acc;
1089
1255
  }, []);
1090
- if (hits.length === 1)
1091
- return showSnippet(hits[0]);
1256
+ if (hits.length !== 1)
1257
+ continue;
1258
+ if (firstUniqueHit === null)
1259
+ firstUniqueHit = hits[0];
1260
+ const base = hits[0] - oldLines.findIndex((l) => l.trim() === anchor);
1261
+ if (base < 0)
1262
+ continue;
1263
+ // Exact comparison: a line that differs only in trailing whitespace
1264
+ // is itself a reason the exact match failed, and it is invisible in
1265
+ // the model's own text — report it as the divergence (the message
1266
+ // renders it with · for space).
1267
+ let k = 0;
1268
+ while (k < oldLines.length && fileLines[base + k] === oldLines[k]) {
1269
+ k++;
1270
+ }
1271
+ if (k > 0 && k < oldLines.length && (best === null || k > best.k))
1272
+ best = { base, k };
1092
1273
  }
1274
+ if (best)
1275
+ return showDivergence(best.base, best.k);
1276
+ if (firstUniqueHit !== null)
1277
+ return showSnippet(firstUniqueHit);
1093
1278
  // Fallback: the first non-blank line, even if not unique (better than a
1094
1279
  // bare "re-read" when the model just needs to see nearby real content).
1095
1280
  const firstLine = oldString
@@ -1149,10 +1334,36 @@ export function buildTools(backend, config) {
1149
1334
  // a self-inflicted syntax error written with ok:true that the executor
1150
1335
  // then can't locate. count === 1 here, so indexOf is the unique site.
1151
1336
  const at = content.indexOf(oldString);
1337
+ // Refuse a replacement whose only effect is the line break at the boundary,
1338
+ // INSTEAD of applying it (flash-m89 +08:32 left `function pageLimit(req): number
1339
+ // { const raw = …` on one line and +07:25 left a comment handler unparseable,
1340
+ // twice in two calls, and the API was down between the pair). The file's own
1341
+ // next character is the evidence that decides it: text on the same line means
1342
+ // the break is moving across lines (a join, or a split), which the shape of the
1343
+ // replacement says the model did not ask for. At a line boundary — a blank line
1344
+ // after it, or the end of the file — nothing crosses lines and the edit stands.
1345
+ const boundary = boundaryOnly(oldString, newString);
1346
+ const after = content[at + oldString.length];
1347
+ if (boundary !== null && after !== undefined && after !== '\n') {
1348
+ skipped.push({ position: index + 1, shape: boundary });
1349
+ continue;
1350
+ }
1152
1351
  const spliced = content.slice(0, at) + newString + content.slice(at + oldString.length);
1153
1352
  spans = addChangeSpan(spans, content, spliced);
1154
1353
  content = spliced;
1155
1354
  }
1355
+ // One line per refused replacement: what it would have done, and the two ways to
1356
+ // say it on purpose. The rest of the batch is applied — the second half of the pair
1357
+ // above (`comments.ts`'s third replacement, `articles.ts`'s own text edit) anchors
1358
+ // on text a joined file no longer has, so applying the join takes the batch down
1359
+ // with it.
1360
+ const skippedNote = skipped.length === 0
1361
+ ? null
1362
+ : skipped
1363
+ .map(({ position, shape }) => shape === 'removed'
1364
+ ? `replacement ${position} was skipped, nothing was changed for it: its new_string is its old_string minus the trailing newline, so the only thing it changes is the line break between that text and the line after it — applying it would run the two lines together. To join two lines on purpose, include the next line in old_string.`
1365
+ : `replacement ${position} was skipped, nothing was changed for it: its new_string is its old_string plus a trailing newline, so the only thing it changes is the line break after that text — applying it would move the rest of that line down. To split a line on purpose, include the text that follows it in old_string.`)
1366
+ .join('\n');
1156
1367
  const alreadyNote = alreadyApplied > 0
1157
1368
  ? `${alreadyApplied} of these edits ${alreadyApplied === 1 ? 'was' : 'were'} already in the file (its new_string is there and its old_string is not) — nothing to do for ${alreadyApplied === 1 ? 'it' : 'them'}.`
1158
1369
  : null;
@@ -1164,7 +1375,16 @@ export function buildTools(backend, config) {
1164
1375
  (identical > 0
1165
1376
  ? `Nothing changed: ${identical === 1 ? 'the replacement’s new_string is' : `${identical} replacements’ new_string are`} identical to old_string, so the file is exactly as it was. Write the text you want in new_string.`
1166
1377
  : 'Nothing changed: the file is exactly as it was.');
1167
- return { path, ok: true, replacementsApplied: 0, alreadyApplied, note };
1378
+ // A refused boundary-newline replacement is why nothing changed: "the file is
1379
+ // exactly as it was" would read as "your edit landed".
1380
+ return {
1381
+ path,
1382
+ ok: true,
1383
+ replacementsApplied: 0,
1384
+ alreadyApplied,
1385
+ note: skippedNote ?? note,
1386
+ ...(skippedNote ? { skippedNote } : {}),
1387
+ };
1168
1388
  }
1169
1389
  if (onFileDiff)
1170
1390
  onFileDiff({ path, oldContent, newContent: content });
@@ -1190,10 +1410,13 @@ export function buildTools(backend, config) {
1190
1410
  const excerpt = renderEditExcerpt(finalContent, spans);
1191
1411
  // A replacement whose old text ended in a newline and whose new text does not
1192
1412
  // joins the line after it onto its last line. Applied as written, but said:
1193
- // flash-m28 saw two lines merged only one round trip later.
1413
+ // flash-m28 saw two lines merged only one round trip later. The refused ones
1414
+ // above were never applied, so what the file holds is not their work.
1415
+ const refused = new Set(skipped.map((s) => s.position));
1194
1416
  const dropped = replacements
1195
1417
  .map((r, i) => ({ ...r, position: i + 1 }))
1196
- .filter((r) => typeof r.old_string === 'string' &&
1418
+ .filter((r) => !refused.has(r.position) &&
1419
+ typeof r.old_string === 'string' &&
1197
1420
  typeof r.new_string === 'string' &&
1198
1421
  r.old_string.endsWith('\n') &&
1199
1422
  r.new_string !== '' &&
@@ -1232,7 +1455,7 @@ export function buildTools(backend, config) {
1232
1455
  return {
1233
1456
  path,
1234
1457
  ok: true,
1235
- replacementsApplied: replacements.length - alreadyApplied,
1458
+ replacementsApplied: replacements.length - alreadyApplied - skipped.length,
1236
1459
  ...(fusedNote
1237
1460
  ? { syntaxError: fusedNote }
1238
1461
  : {
@@ -1251,6 +1474,7 @@ export function buildTools(backend, config) {
1251
1474
  : {}),
1252
1475
  ...syntax,
1253
1476
  }),
1477
+ ...(skippedNote ? { skippedNote } : {}),
1254
1478
  ...(alreadyApplied > 0 ? { alreadyApplied, note: alreadyNote } : {}),
1255
1479
  totalLines: countLines(finalContent),
1256
1480
  ...(excerpt ? { excerpt: sanitizeFileContent(excerpt, path) } : {}),
@@ -1292,7 +1516,7 @@ export function buildTools(backend, config) {
1292
1516
  // -H: a search scoped to one file printed `line:content` with no file, and every match
1293
1517
  // came back `{file: "", line: 0}` (X1 flash-m62).
1294
1518
  const grepArgs = `-rnH ${globArg} ${contextArg} ${grepExcludeArgs(path)} --max-count=${MAX_SEARCH_RESULTS} -- ${shellQuote(pattern)} ${shellQuote(path)}`;
1295
- const result = await backend.run(`{ grep -E ${grepArgs}; s=$?; if [ "$s" -eq 2 ]; then grep -F ${grepArgs}; fi; } 2>/dev/null || true`, { timeout: 10000 });
1519
+ const result = await backend.run(`{ grep -E ${grepArgs}; s=$?; if [ "$s" -eq 2 ]; then grep -F ${grepArgs}; fi; } 2>/dev/null || true`, { timeout: SEARCH_TIMEOUT_MS });
1296
1520
  // grep emits `<file>:<line>:<content>`, so redaction runs PER MATCH on the
1297
1521
  // content alone: the file prefix would otherwise hide an env assignment from
1298
1522
  // the line-anchored env pattern, and knowing each match's own path is what
@@ -1329,6 +1553,11 @@ export function buildTools(backend, config) {
1329
1553
  return { pattern, path, matches };
1330
1554
  }
1331
1555
  catch (e) {
1556
+ if (isCommandTimeout(e))
1557
+ return {
1558
+ error: searchTimeoutError('search_files', path, pattern, SEARCH_TIMEOUT_MS / 1000, `Give \`path\` the subdirectory you mean and narrow with \`include\` ` +
1559
+ `(e.g. "*.ts"), or use find_files with the directory in the pattern.`),
1560
+ };
1332
1561
  return { error: `Search failed: ${e.message}` };
1333
1562
  }
1334
1563
  },
@@ -1350,26 +1579,59 @@ export function buildTools(backend, config) {
1350
1579
  const symlinkErr = await checkSymlink(path);
1351
1580
  if (symlinkErr)
1352
1581
  return { error: symlinkErr };
1582
+ // The directory the PATTERN itself names (`node_modules` +
1583
+ // `@molecule/api-resource-user/dist/*.d.ts`) becomes the search root. Matched
1584
+ // against the whole path with an implicit "any parent" anchor, the pattern
1585
+ // made `find` visit every entry below `path` — the whole dependency tree,
1586
+ // which hit the 10 s deadline with no result at all while the one directory
1587
+ // the model meant answered a `list_files` instantly (xr-molecule-flash-m94).
1588
+ // A bare first segment (`helpers/*`) is left alone on purpose: it means "any
1589
+ // `helpers/` below the root" (xr-molecule-flash-m77, see literalDirPrefix).
1590
+ const prefixDir = literalDirPrefix(pattern);
1591
+ let findRoot = path;
1592
+ let findArgs = findPatternArgs(pattern);
1593
+ let missingPrefix;
1353
1594
  try {
1595
+ if (prefixDir) {
1596
+ const candidate = `${path.replace(/\/+$/, '')}/${prefixDir}`;
1597
+ const probe = await backend.run(`test -d ${shellQuote(candidate)}`, {
1598
+ timeout: SEARCH_TIMEOUT_MS,
1599
+ });
1600
+ if (probe.exitCode === 0) {
1601
+ findRoot = candidate;
1602
+ // The remainder keeps the `-path` form: `*` crosses `/`, so a `.d.ts`
1603
+ // in `dist/utilities/` is still found (see findPathArgs).
1604
+ findArgs = findPathArgs(pattern.replace(/^(?:\.?\/)+/, '').slice(prefixDir.length + 1));
1605
+ }
1606
+ else {
1607
+ missingPrefix = `"${candidate}" does not exist; searched all of ${path}.`;
1608
+ }
1609
+ }
1354
1610
  // `findPatternArgs`, not `-name`: a pattern with a `/` in it
1355
1611
  // (`helpers/*`, `e2e/*.spec.ts`) matched nothing at all, because `-name`
1356
1612
  // compares it against the basename (xr-molecule-flash-m77).
1357
- const result = await backend.run(`find ${shellQuote(path)} ${findPruneArgs(path)} ${findPatternArgs(pattern)} -print 2>/dev/null | head -${MAX_FIND_RESULTS}`, { timeout: 10000 });
1613
+ const result = await backend.run(`find ${shellQuote(findRoot)} ${findPruneArgs(findRoot)} ${findArgs} -print 2>/dev/null | head -${MAX_FIND_RESULTS}`, { timeout: SEARCH_TIMEOUT_MS });
1358
1614
  const files = result.stdout.trim().split('\n').filter(Boolean);
1359
1615
  // A slash changes what the pattern means — the whole path, not the file
1360
1616
  // name — and an empty result does not say so, so the model reads it as
1361
1617
  // "no such file" and goes looking elsewhere. Name the rule on the miss.
1362
- const note = files.length === 0 && pattern.includes('/')
1618
+ const missNote = files.length === 0 && pattern.includes('/')
1363
1619
  ? `"${pattern}" contains a "/", so it was matched against each file's PATH below ${path} (a leading "*/" is implicit), not against the file name — no path matched it. list_files shows the real entries.`
1364
1620
  : undefined;
1365
- return { pattern, path, files, ...(note ? { note } : {}) };
1621
+ const notes = [missingPrefix, missNote].filter((n) => Boolean(n));
1622
+ return { pattern, path, files, ...(notes.length ? { note: notes.join(' ') } : {}) };
1366
1623
  }
1367
1624
  catch (e) {
1625
+ if (isCommandTimeout(e))
1626
+ return {
1627
+ error: searchTimeoutError('find_files', findRoot === path ? path : `${findRoot} (under ${path})`, pattern, SEARCH_TIMEOUT_MS / 1000, `Give \`path\` the directory you mean (a package's own folder rather than all of ` +
1628
+ `${path}) or use list_files on it, and keep the pattern narrow.`),
1629
+ };
1368
1630
  return { error: `Find failed: ${e.message}` };
1369
1631
  }
1370
1632
  },
1371
1633
  async create_directory(input) {
1372
- const path = resolve(input.path);
1634
+ const path = resolveWrite(input.path);
1373
1635
  const parentPath = path.substring(0, path.lastIndexOf('/')) || root;
1374
1636
  const symlinkErr = await checkSymlink(parentPath);
1375
1637
  if (symlinkErr)
@@ -1385,8 +1647,10 @@ export function buildTools(backend, config) {
1385
1647
  }
1386
1648
  },
1387
1649
  async rename_file(input) {
1388
- const oldPath = resolve(input.old_path);
1389
- const newPath = resolve(input.new_path);
1650
+ // Both ends: moving a package's file OUT is as much a change to the installed
1651
+ // package as moving one in.
1652
+ const oldPath = resolveWrite(input.old_path);
1653
+ const newPath = resolveWrite(input.new_path);
1390
1654
  const oldSymlinkErr = await checkSymlink(oldPath);
1391
1655
  if (oldSymlinkErr)
1392
1656
  return { error: oldSymlinkErr };
@@ -1408,7 +1672,7 @@ export function buildTools(backend, config) {
1408
1672
  }
1409
1673
  },
1410
1674
  async delete_file(input) {
1411
- const path = resolve(input.path);
1675
+ const path = resolveWrite(input.path);
1412
1676
  const symlinkErr = await checkSymlink(path);
1413
1677
  if (symlinkErr)
1414
1678
  return { error: symlinkErr };
@@ -1508,6 +1772,7 @@ export function buildTools(backend, config) {
1508
1772
  : null;
1509
1773
  if (ownFile)
1510
1774
  outputFileByTask.set(id, ownFile);
1775
+ runByTask.set(id, { command: streaming.command, cwd });
1511
1776
  await backend.writeFile(script, `${streaming.command}\n`);
1512
1777
  await backend.run(`nohup sh -c ${shellQuote(prioritized.launch)} ` +
1513
1778
  `> /dev/null 2>&1 & echo $! > /tmp/${id}.pid`, { cwd, timeout: 15_000 });
@@ -1630,18 +1895,31 @@ export function buildTools(backend, config) {
1630
1895
  : sanitizeOutput;
1631
1896
  const stdout = grade(truncateMiddle(result.stdout, MAX_OUTPUT_SIZE));
1632
1897
  const stderr = sanitizeOutput(truncateMiddle(result.stderr, MAX_OUTPUT_SIZE));
1633
- if (budgetSeconds && result.exitCode === 124) {
1898
+ // A run a budget stopped is NOT a run that ended: it reports `status: 'cut_at_budget'`,
1899
+ // never a 0 the pipeline produced, and names how far it got. Read from the command, the
1900
+ // output and the clock in one place (cut-run.ts) so every reader of a run's verdict reads
1901
+ // the same state (X1 xr-molecule-flash-m93: `timeout 280 hurl … | tail -60` came back
1902
+ // `exitCode: 0` after 284 s and four of thirteen files, and the executor re-sent it).
1903
+ const cut = commandCutRun(command, { exitCode: result.exitCode, stdout, stderr }, elapsedMs, budgetSeconds);
1904
+ if (cut) {
1905
+ const cutLead = cutRunSentence(cut);
1634
1906
  return {
1635
1907
  stdout,
1636
1908
  stderr,
1637
- exitCode: result.exitCode,
1638
- error: `The command was stopped after ${budgetSeconds}s, this tool's limit; the output above is ` +
1639
- 'everything it printed until then' +
1640
- (/\|\s*(tail|head)\b/.test(command)
1641
- ? ' — and a pipe through tail/head holds everything back until the command ends, so it printed nothing: run it without the pipe (use a line reporter and let the output stream)'
1909
+ status: CUT_AT_BUDGET,
1910
+ exitCode: cut.exitCode,
1911
+ // The facts, so a reader that can also see the suite itself (its own `.hurl` file
1912
+ // count) says the same sentence with the total the output alone cannot give.
1913
+ cutRun: cut,
1914
+ error: `${cutLead} ` +
1915
+ // The tool's own budget stopping a run that printed nothing: keep the sentence that
1916
+ // says why the output is empty, since nothing else in the result can say it.
1917
+ (cut.stoppedBy === 'tool-budget'
1918
+ ? `The output above is everything it printed until then${/\|\s*(tail|head)\b/.test(command)
1919
+ ? ' — and a pipe through tail/head holds everything back until the command ends, so it printed nothing: run it without the pipe (use a line reporter and let the output stream)'
1920
+ : ''}. `
1642
1921
  : '') +
1643
- '. Run a smaller unit per command (one test file, one build step) instead of chaining ' +
1644
- 'a build and a whole suite.',
1922
+ (isSuiteRunCommand(command) ? SUITE_OVER_BUDGET_ADVICE : SPLIT_WORK_ADVICE),
1645
1923
  };
1646
1924
  }
1647
1925
  return {
@@ -1848,6 +2126,11 @@ export function buildTools(backend, config) {
1848
2126
  // Only a cause that is failing the whole run gets a stop command — and only then is the
1849
2127
  // pid walk paid for.
1850
2128
  const universal = universalCause(causeLog, found);
2129
+ // How this run was started, so the note can hand over the re-run to send once it ends
2130
+ // instead of one for now — the platform refuses a second run against the same app and
2131
+ // database while this one walks (xr-molecule-flash-m89: the +19:59 note named a re-run
2132
+ // and its overlap hold refused that exact run 23 s later).
2133
+ const started = runByTask.get(taskId);
1851
2134
  repeatingCauseNoted.add(taskId);
1852
2135
  const earlyMs = Date.now() - startedAt;
1853
2136
  waitedByTask.set(taskId, waitedBefore + earlyMs);
@@ -1860,6 +2143,7 @@ export function buildTools(backend, config) {
1860
2143
  log,
1861
2144
  stop: universal ? await taskStopCommand(backend, taskId) : '',
1862
2145
  universal,
2146
+ ...(started ? { run: started } : {}),
1863
2147
  }),
1864
2148
  };
1865
2149
  }
@@ -1974,6 +2258,28 @@ export function buildTools(backend, config) {
1974
2258
  // the second and third were found by runs stopped before they walked the rest. Null for
1975
2259
  // anything that is not a run with failures in it.
1976
2260
  const causes = failureCausesNote(output);
2261
+ // A backgrounded run is under no tool budget, and the exit file hands back the PIPELINE's
2262
+ // code — so a suite killed at a `timeout 280` the command carried behind a `| tail` reports
2263
+ // 0 here too. The run's start is in its own id ({@link taskStartedAt}), so the bound it used
2264
+ // is readable without new state: say stopped, and never hand the pipeline's 0 back as the
2265
+ // run's (X1 xr-molecule-flash-m93 re-sent that run three times).
2266
+ const run = runByTask.get(taskId);
2267
+ const startedAtMs = taskStartedAt(taskId);
2268
+ const cut = run && startedAtMs !== null
2269
+ ? commandCutRun(run.command, { exitCode, stdout: output, stderr: '' }, Date.now() - startedAtMs, 0)
2270
+ : null;
2271
+ if (cut) {
2272
+ return {
2273
+ taskId,
2274
+ status: CUT_AT_BUDGET,
2275
+ exitCode: cut.exitCode,
2276
+ waitedMs,
2277
+ cutRun: cut,
2278
+ note: [cutRunSentence(cut), causes].filter((n) => Boolean(n)).join(' '),
2279
+ stdout: sanitizeOutput(truncateMiddle(output, MAX_OUTPUT_SIZE)),
2280
+ ...(output.length > MAX_OUTPUT_SIZE ? { truncated: true, fullOutput: log } : {}),
2281
+ };
2282
+ }
1977
2283
  // Same shaping as a foreground exec result, so the executor reads both
1978
2284
  // the same way.
1979
2285
  return {