@molecule/api-ai-tools 1.6.2 → 1.6.5

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
@@ -25,6 +25,16 @@ const waitedByTask = new Map();
25
25
  const logBytesByTask = new Map();
26
26
  /** The file a background command writes its own output to, when it redirects it. */
27
27
  const outputFileByTask = new Map();
28
+ /**
29
+ * How each background task was started: the command as it was actually run (after
30
+ * {@link streamingBackgroundCommand} rewrote it) and the directory it ran in.
31
+ *
32
+ * A mid-run note has to hand over the re-run of a test that just failed, and a re-run is composed
33
+ * from the command that is still walking ({@link rerunOfRunningSuite}) — a whole-suite re-run is
34
+ * what the executor must not be handed for now, and a re-run narrowed for the wrong directory
35
+ * resolves the failing test's `file:line` against the wrong suite.
36
+ */
37
+ const runByTask = new Map();
28
38
  /**
29
39
  * Task ids whose own-output file has already been looked for this process.
30
40
  * {@link outputFileByTask} is empty for every task after a restart — it is
@@ -63,13 +73,21 @@ const taskPidsCommand = (taskId) => `R=$(cat /tmp/${taskId}.pid 2>/dev/null); `
63
73
  /**
64
74
  * The command that stops a running background task, read from the sandbox's own pid files.
65
75
  *
76
+ * THE one builder for it, so every note that hands the executor a way to stop a run hands over the
77
+ * same pid-only form: a `pkill -f <something>` matches the shell running it (its argv IS the
78
+ * command), and `molecule-dev/api/src/ai/pattern-kill-guard.ts` holds exactly that — X1
79
+ * xr-molecule-flash-m87's stuck note suggested `kill $(cat /tmp/<id>.pid) 2>/dev/null; pkill -f
80
+ * <id>.sh`, which the platform's own guard then refused, and the same shape cost a refused call at
81
+ * +21:57. The pid tree is the whole of what the pattern was for: the detached shell and every
82
+ * process under it.
83
+ *
66
84
  * @param backend - The execution backend.
67
85
  * @param taskId - The task's `mol-bg-…` handle.
68
86
  * @returns The command to hand the executor — `kill <pids>` — falling back to the task's own pid
69
87
  * file when the walk comes back empty (a sandbox without `pgrep`, or a pid file already gone,
70
88
  * in which case the pid-file form is still the one the platform's guard names).
71
89
  */
72
- async function taskStopCommand(backend, taskId) {
90
+ export async function taskStopCommand(backend, taskId) {
73
91
  const fallback = `kill $(cat /tmp/${taskId}.pid)`;
74
92
  try {
75
93
  const r = await backend.run(taskPidsCommand(taskId), { timeout: 10_000 });
@@ -122,7 +140,8 @@ function logProgress(log) {
122
140
  /**
123
141
  * What a wait knows about the command BESIDES "it has not exited": how long it
124
142
  * has been running, whether its output is still growing, how far its own log
125
- * says it has got, and how much longer that implies.
143
+ * says it has got, and how much longer that implies — one fact per clause, built
144
+ * here so the note carrying them cannot state one of them twice.
126
145
  *
127
146
  * X1 flash-m62 (2026-10-16) spent three 290 s waits on one 8.3-minute suite and
128
147
  * every answer read only "Still running after 280s": the run could not tell a
@@ -131,35 +150,63 @@ function logProgress(log) {
131
150
  * waiting. The elapsed time comes from the task id, the progress from the log
132
151
  * this wait already read, and the time left is the rate so far, said as such.
133
152
  *
153
+ * X1 flash-m87 §+47:20 (2026-10-16): that suite's answer read "Still running
154
+ * after 342s of waiting, and its output is still growing (71839 bytes) — slow,
155
+ * not stuck." and then this sentence, which opens with the same growth clause
156
+ * and gives the same elapsed time in its other format — one answer, two facts,
157
+ * each said twice. The clauses ARE the facts, and {@link runningNote} is the
158
+ * only place they become a sentence, so a lead the caller writes itself cannot
159
+ * repeat one (the way `deployOwedNote` returns one sentence rather than a join).
160
+ *
134
161
  * @param o - The wait's facts: `taskId`, the `slice` of the log it read,
135
162
  * `stillWriting` (the log grew during this wait), `bytes` (the log's size, null
136
- * when unreadable), and `ceilingMs` (this call's ceiling, for "one more wait").
137
- * @returns A sentence to append to a running note, or '' when nothing is known.
163
+ * when unreadable), `ceilingMs` (this call's ceiling, for "one more wait"), and
164
+ * `waitedMs` (this call's own wait, the elapsed figure when the id carries no
165
+ * start stamp).
166
+ * @returns The clauses in reading order. The first states the elapsed time, so a
167
+ * sentence built from them always says something.
138
168
  */
139
- function waitProgressNote(o) {
169
+ function waitProgressClauses(o) {
140
170
  const startedAt = taskStartedAt(o.taskId);
141
- const elapsedMs = startedAt === null ? null : Math.max(0, Date.now() - startedAt);
142
- const parts = [];
143
- if (elapsedMs !== null)
144
- parts.push(`it has been running ${durationText(elapsedMs)}`);
171
+ const sinceStart = startedAt === null ? null : Math.max(0, Date.now() - startedAt);
172
+ // A task id this process minted always carries its start stamp; a synthetic one
173
+ // does not, and the time this call spent waiting is then the floor for how long
174
+ // the command has been running — it ran throughout that wait.
175
+ const clauses = [
176
+ sinceStart === null
177
+ ? `it has been running at least ${durationText(o.waitedMs)}`
178
+ : `it has been running ${durationText(sinceStart)}`,
179
+ ];
145
180
  if (o.stillWriting)
146
- parts.push(`its output is still growing (${o.bytes} bytes) — slow, not stuck`);
181
+ clauses.push(`its output is still growing (${o.bytes} bytes) — slow, not stuck`);
147
182
  else if (o.bytes !== null)
148
- parts.push(`its log has not grown during this wait (${o.bytes} bytes)`);
183
+ clauses.push(`its log has not grown during this wait (${o.bytes} bytes)`);
149
184
  const counter = logProgress(o.slice);
150
185
  if (counter) {
151
- parts.push(`its log is at ${counter.done} of ${counter.total}`);
152
- if (elapsedMs !== null && counter.done > 0 && counter.done < counter.total) {
153
- const leftMs = Math.round((elapsedMs * (counter.total - counter.done)) / counter.done);
154
- parts.push(leftMs <= o.ceilingMs
186
+ clauses.push(`its log is at ${counter.done} of ${counter.total}`);
187
+ // The rate is the task's own elapsed time, never this call's wait: with no
188
+ // start stamp, "34 tests in the 290 s I just waited" would be reported as the
189
+ // speed of a suite that has been walking for ten minutes.
190
+ if (sinceStart !== null && counter.done > 0 && counter.done < counter.total) {
191
+ const leftMs = Math.round((sinceStart * (counter.total - counter.done)) / counter.done);
192
+ clauses.push(leftMs <= o.ceilingMs
155
193
  ? `about ${durationText(leftMs)} left at that rate — one more wait covers it`
156
194
  : `about ${durationText(leftMs)} left at that rate (${Math.ceil(leftMs / o.ceilingMs)} more waits)`);
157
195
  }
158
196
  }
159
- if (!parts.length)
197
+ return clauses;
198
+ }
199
+ /**
200
+ * One sentence from clauses: joined by "; ", capitalised and punctuated once.
201
+ *
202
+ * @param clauses - The facts to state, each in its own clause.
203
+ * @returns The sentence, or '' when there is nothing to say.
204
+ */
205
+ function runningNote(clauses) {
206
+ if (!clauses.length)
160
207
  return '';
161
- const sentence = parts.join('; ');
162
- return ` ${sentence[0].toUpperCase()}${sentence.slice(1)}.`;
208
+ const joined = clauses.join('; ');
209
+ return `${joined[0].toUpperCase()}${joined.slice(1)}.`;
163
210
  }
164
211
  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';
165
212
  /**
@@ -197,6 +244,52 @@ export function moleculeDistReadNote(path) {
197
244
  ? ' The declarations above are generated from it.'
198
245
  : ' For one signature, read the matching .d.ts file.'));
199
246
  }
247
+ /**
248
+ * The refusal for a write whose target is inside an installed package, or null
249
+ * when the path is not under a `node_modules` directory.
250
+ *
251
+ * `node_modules` is not the project's code: nothing written there survives the
252
+ * next install, no deploy carries it, and a sandbox whose dependency tree is
253
+ * shared (the pre-installed superset) breaks every other run through it. X0
254
+ * xr-molecule-flash-m91 (+04:43) called `write_file` on
255
+ * `node_modules/@molecule/api-resource-comment/dist/numbers.d.ts` while reading
256
+ * the package's declarations and clobbered its runtime module a call later
257
+ * (`module.exports = {}` over the ESM handler its own code imports) — the write
258
+ * answered `ok: true`, and 18 s went to noticing, `npm pack`-ing the package and
259
+ * putting the file back. Reading these files stays allowed (that is how the
260
+ * declarations above are read at all).
261
+ *
262
+ * The restore route is named because the model that has ALREADY clobbered a file
263
+ * (this refusal is too late for it) is the one who needs it, and because the
264
+ * obvious repair — `npm install <pkg>` — is itself refused in the pre-installed
265
+ * sandbox. `npm pack` + a `tar` extraction over the package directory is what
266
+ * m91 used, and what both guards leave alone.
267
+ *
268
+ * @param path - An absolute path a tool is about to write.
269
+ * @returns The one-line refusal naming the package, or null when nothing is installed there.
270
+ */
271
+ export function installedPackageWriteRefusal(path) {
272
+ // A path may sit in a NESTED node_modules (`app/node_modules/...`) as well as the
273
+ // hoisted root one: both are installed packages.
274
+ const at = /(?:^|\/)node_modules(?:\/|$)/.exec(path);
275
+ if (!at)
276
+ return null;
277
+ const named = /(?:^|\/)node_modules\/((?:@[^/]+\/)?[^/]+)(?:\/|$)/.exec(path);
278
+ const name = named?.[1] ?? null;
279
+ const scoped = at[0].startsWith('/') ? path.slice(0, at.index) : '';
280
+ const dir = name ? `${scoped}/node_modules/${name}` : null;
281
+ const what = name ? `the installed package ${name}` : 'an installed package';
282
+ const change = 'Installed packages are read-only: the next install replaces that file and a deploy never ' +
283
+ 'carries it. Change its behaviour from your app instead (its options, your own handler, a ' +
284
+ 'bond), and where the package itself is wrong, report it rather than patching it here. ' +
285
+ 'Reading these files is fine.';
286
+ const restore = name && dir
287
+ ? ` If you have already overwritten one there, restore the package before anything else: ` +
288
+ `\`npm pack ${name}@<version>\` (the version is in ${dir}/package.json), then ` +
289
+ `\`tar -xzf <the tarball npm pack printed> --strip-components=1 -C ${dir}\` — never a hand-edit.`
290
+ : '';
291
+ return `Not written: ${path} is inside ${what} (node_modules). ${change}${restore}`;
292
+ }
200
293
  /**
201
294
  * Build a complete set of AI agent tools bound to an execution backend.
202
295
  *
@@ -299,6 +392,22 @@ export function buildTools(backend, config) {
299
392
  }
300
393
  return pathGuards ? resolvePath(path, root) : path;
301
394
  }
395
+ /**
396
+ * Resolve the path a WRITING tool was given, refusing a target inside an
397
+ * installed package ({@link installedPackageWriteRefusal}). Reads are not sent
398
+ * through this: reading `node_modules` is how a package's declarations are
399
+ * read at all, and only the write is unrecoverable.
400
+ *
401
+ * @param path - Relative or absolute path requested by the tool input.
402
+ * @returns The normalized path, when writing it is allowed.
403
+ */
404
+ function resolveWrite(path) {
405
+ const resolved = resolve(path);
406
+ const refusal = installedPackageWriteRefusal(resolved);
407
+ if (refusal)
408
+ throw new ToolInputError(refusal);
409
+ return resolved;
410
+ }
302
411
  /** Paths read with a window in this tool set, to tell paging from one targeted read. */
303
412
  const windowedReads = new Set();
304
413
  /** The text each path was last handed back WHOLE as, so that correction fires once per version. */
@@ -850,7 +959,7 @@ export function buildTools(backend, config) {
850
959
  const argErr = pathArgError(input.path, 'write_file');
851
960
  if (argErr)
852
961
  return { error: argErr };
853
- const path = resolve(input.path);
962
+ const path = resolveWrite(input.path);
854
963
  const content = input.content;
855
964
  if (content.length > MAX_WRITE_SIZE)
856
965
  return {
@@ -899,7 +1008,7 @@ export function buildTools(backend, config) {
899
1008
  const argErr = pathArgError(input.path, 'edit_file');
900
1009
  if (argErr)
901
1010
  return { error: argErr };
902
- const path = resolve(input.path);
1011
+ const path = resolveWrite(input.path);
903
1012
  // Support both formats: { replacements: [...] } (batch) and { old_string, new_string } (single, backwards-compatible)
904
1013
  let replacements = input.replacements;
905
1014
  // Accept the single format when BOTH are strings — `typeof` (not truthy) so
@@ -936,13 +1045,35 @@ export function buildTools(backend, config) {
936
1045
  }
937
1046
  const oldContent = content;
938
1047
  let alreadyApplied = 0;
1048
+ // Replacements refused for changing the line break at old_string's boundary and
1049
+ // nothing else — reported by position, never applied (see below).
1050
+ const skipped = [];
939
1051
  // Where each applied replacement landed, in the coordinates of the
940
1052
  // latest content — rendered into the result so the model sees the edit.
941
1053
  let spans = [];
942
- for (const { old_string: oldString, new_string: newString, replace_all: replaceAll,
1054
+ // A replacement whose ONLY effect is the line break at old_string's boundary:
1055
+ // `new_string` is `old_string` minus its trailing newline (applying it runs the
1056
+ // line after it onto the last line) or `old_string` plus one (applying it moves
1057
+ // the rest of that line down). A weaker model sends the first as an "anchor" — it
1058
+ // repeats a line it means to keep, minus its `\n` — and the join is silent when
1059
+ // it still parses: flash-m28 merged two lines three times in one run, flash-m89
1060
+ // twice in two consecutive calls (comments.ts, then articles.ts), flash-m55,
1061
+ // m62 and m89 all paid a repair round trip for it. A real edit changes some other
1062
+ // character, so this costs nothing.
1063
+ const boundaryOnly = (oldText, newText) => {
1064
+ const oldBody = oldText.replace(/\r?\n$/, '');
1065
+ // `newText !== ''` keeps a deletion out of this: `old_string: "\n"` → `""` is a
1066
+ // real request to remove a blank line, and the notes below already cover it.
1067
+ if (newText !== '' && oldBody !== oldText && newText === oldBody)
1068
+ return 'removed';
1069
+ if (!oldText.endsWith('\n') && newText === `${oldText}\n`)
1070
+ return 'added';
1071
+ return null;
1072
+ };
1073
+ for (const [index, { old_string: oldString, new_string: newString, replace_all: replaceAll,
943
1074
  // Models sometimes send the camelCase spelling inside an item, where
944
1075
  // the input normalizer (top-level keys only) does not reach.
945
- replaceAll: replaceAllCamel, } of replacements) {
1076
+ replaceAll: replaceAllCamel, },] of replacements.entries()) {
946
1077
  // Validate each replacement is well-formed BEFORE touching content. A
947
1078
  // weak model sometimes emits a batch element missing a field or with a
948
1079
  // non-string value; left unchecked that either crashes (`undefined`
@@ -1044,15 +1175,74 @@ export function buildTools(backend, config) {
1044
1175
  .map((l) => l.trim())
1045
1176
  .filter((l) => l.length >= 8)),
1046
1177
  ].sort((a, b) => b.length - a.length);
1178
+ const oldLines = oldString.split('\n');
1179
+ // Whitespace is the one difference the model cannot see in its own
1180
+ // text, so make it visible when that is the whole difference.
1181
+ const visible = (l) => l.replace(/ /g, '·').replace(/\t/g, '⇥');
1182
+ // Report where old_string and the file STOP agreeing, not where the
1183
+ // match starts. A snippet centred on the anchor is the part the model
1184
+ // already has right, so it re-sends the same old_string and fails the
1185
+ // same way (X0 m89: two failed edits, a whole-file re-read and a
1186
+ // rewrite, 9 s, for a divergence 35 lines below the anchor).
1187
+ const showDivergence = (base, k) => {
1188
+ const expected = oldLines[k];
1189
+ const actual = fileLines[base + k] ?? '(end of file)';
1190
+ const whitespaceOnly = expected.replace(/\s/g, '') === actual.replace(/\s/g, '');
1191
+ let col = 0;
1192
+ while (col < expected.length && col < actual.length && expected[col] === actual[col])
1193
+ col++;
1194
+ const howFar = whitespaceOnly
1195
+ ? ' (whitespace only — · is a space, ⇥ a tab)'
1196
+ : col > 0
1197
+ ? ` (first character difference at column ${col + 1})`
1198
+ : '';
1199
+ const start = Math.max(0, base + k - 2);
1200
+ const snippet = fileLines
1201
+ .slice(start, base + k + 6)
1202
+ .map((l, i) => `${start + i + 1}: ${l}`)
1203
+ .join('\n');
1204
+ return {
1205
+ error: `old_string not found in ${path} — it matches the file through line ${base + k}, ` +
1206
+ `then diverges at your line ${k + 1} of ${oldLines.length}${howFar}.\n` +
1207
+ `Your old_string line ${k + 1}: ${whitespaceOnly ? visible(expected) : expected}\n` +
1208
+ `The file's line ${base + k + 1}: ${whitespaceOnly ? visible(actual) : actual}\n` +
1209
+ `The file's ACTUAL content near your target (copy the exact text from here):\n${snippet}`,
1210
+ };
1211
+ };
1212
+ // The longest run of old_string lines that IS in the file decides which
1213
+ // alignment to report, so a later distinctive line can beat an earlier
1214
+ // one. k === 0 means nothing beyond the anchor itself lines up; only
1215
+ // then fall back to the old anchor snippet.
1216
+ let best = null;
1217
+ let firstUniqueHit = null;
1047
1218
  for (const anchor of distinctiveLines) {
1048
1219
  const hits = fileLines.reduce((acc, l, i) => {
1049
1220
  if (l.includes(anchor))
1050
1221
  acc.push(i);
1051
1222
  return acc;
1052
1223
  }, []);
1053
- if (hits.length === 1)
1054
- return showSnippet(hits[0]);
1224
+ if (hits.length !== 1)
1225
+ continue;
1226
+ if (firstUniqueHit === null)
1227
+ firstUniqueHit = hits[0];
1228
+ const base = hits[0] - oldLines.findIndex((l) => l.trim() === anchor);
1229
+ if (base < 0)
1230
+ continue;
1231
+ // Exact comparison: a line that differs only in trailing whitespace
1232
+ // is itself a reason the exact match failed, and it is invisible in
1233
+ // the model's own text — report it as the divergence (the message
1234
+ // renders it with · for space).
1235
+ let k = 0;
1236
+ while (k < oldLines.length && fileLines[base + k] === oldLines[k]) {
1237
+ k++;
1238
+ }
1239
+ if (k > 0 && k < oldLines.length && (best === null || k > best.k))
1240
+ best = { base, k };
1055
1241
  }
1242
+ if (best)
1243
+ return showDivergence(best.base, best.k);
1244
+ if (firstUniqueHit !== null)
1245
+ return showSnippet(firstUniqueHit);
1056
1246
  // Fallback: the first non-blank line, even if not unique (better than a
1057
1247
  // bare "re-read" when the model just needs to see nearby real content).
1058
1248
  const firstLine = oldString
@@ -1112,10 +1302,36 @@ export function buildTools(backend, config) {
1112
1302
  // a self-inflicted syntax error written with ok:true that the executor
1113
1303
  // then can't locate. count === 1 here, so indexOf is the unique site.
1114
1304
  const at = content.indexOf(oldString);
1305
+ // Refuse a replacement whose only effect is the line break at the boundary,
1306
+ // INSTEAD of applying it (flash-m89 +08:32 left `function pageLimit(req): number
1307
+ // { const raw = …` on one line and +07:25 left a comment handler unparseable,
1308
+ // twice in two calls, and the API was down between the pair). The file's own
1309
+ // next character is the evidence that decides it: text on the same line means
1310
+ // the break is moving across lines (a join, or a split), which the shape of the
1311
+ // replacement says the model did not ask for. At a line boundary — a blank line
1312
+ // after it, or the end of the file — nothing crosses lines and the edit stands.
1313
+ const boundary = boundaryOnly(oldString, newString);
1314
+ const after = content[at + oldString.length];
1315
+ if (boundary !== null && after !== undefined && after !== '\n') {
1316
+ skipped.push({ position: index + 1, shape: boundary });
1317
+ continue;
1318
+ }
1115
1319
  const spliced = content.slice(0, at) + newString + content.slice(at + oldString.length);
1116
1320
  spans = addChangeSpan(spans, content, spliced);
1117
1321
  content = spliced;
1118
1322
  }
1323
+ // One line per refused replacement: what it would have done, and the two ways to
1324
+ // say it on purpose. The rest of the batch is applied — the second half of the pair
1325
+ // above (`comments.ts`'s third replacement, `articles.ts`'s own text edit) anchors
1326
+ // on text a joined file no longer has, so applying the join takes the batch down
1327
+ // with it.
1328
+ const skippedNote = skipped.length === 0
1329
+ ? null
1330
+ : skipped
1331
+ .map(({ position, shape }) => shape === 'removed'
1332
+ ? `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.`
1333
+ : `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.`)
1334
+ .join('\n');
1119
1335
  const alreadyNote = alreadyApplied > 0
1120
1336
  ? `${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'}.`
1121
1337
  : null;
@@ -1127,7 +1343,16 @@ export function buildTools(backend, config) {
1127
1343
  (identical > 0
1128
1344
  ? `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.`
1129
1345
  : 'Nothing changed: the file is exactly as it was.');
1130
- return { path, ok: true, replacementsApplied: 0, alreadyApplied, note };
1346
+ // A refused boundary-newline replacement is why nothing changed: "the file is
1347
+ // exactly as it was" would read as "your edit landed".
1348
+ return {
1349
+ path,
1350
+ ok: true,
1351
+ replacementsApplied: 0,
1352
+ alreadyApplied,
1353
+ note: skippedNote ?? note,
1354
+ ...(skippedNote ? { skippedNote } : {}),
1355
+ };
1131
1356
  }
1132
1357
  if (onFileDiff)
1133
1358
  onFileDiff({ path, oldContent, newContent: content });
@@ -1153,10 +1378,13 @@ export function buildTools(backend, config) {
1153
1378
  const excerpt = renderEditExcerpt(finalContent, spans);
1154
1379
  // A replacement whose old text ended in a newline and whose new text does not
1155
1380
  // joins the line after it onto its last line. Applied as written, but said:
1156
- // flash-m28 saw two lines merged only one round trip later.
1381
+ // flash-m28 saw two lines merged only one round trip later. The refused ones
1382
+ // above were never applied, so what the file holds is not their work.
1383
+ const refused = new Set(skipped.map((s) => s.position));
1157
1384
  const dropped = replacements
1158
1385
  .map((r, i) => ({ ...r, position: i + 1 }))
1159
- .filter((r) => typeof r.old_string === 'string' &&
1386
+ .filter((r) => !refused.has(r.position) &&
1387
+ typeof r.old_string === 'string' &&
1160
1388
  typeof r.new_string === 'string' &&
1161
1389
  r.old_string.endsWith('\n') &&
1162
1390
  r.new_string !== '' &&
@@ -1195,7 +1423,7 @@ export function buildTools(backend, config) {
1195
1423
  return {
1196
1424
  path,
1197
1425
  ok: true,
1198
- replacementsApplied: replacements.length - alreadyApplied,
1426
+ replacementsApplied: replacements.length - alreadyApplied - skipped.length,
1199
1427
  ...(fusedNote
1200
1428
  ? { syntaxError: fusedNote }
1201
1429
  : {
@@ -1214,6 +1442,7 @@ export function buildTools(backend, config) {
1214
1442
  : {}),
1215
1443
  ...syntax,
1216
1444
  }),
1445
+ ...(skippedNote ? { skippedNote } : {}),
1217
1446
  ...(alreadyApplied > 0 ? { alreadyApplied, note: alreadyNote } : {}),
1218
1447
  totalLines: countLines(finalContent),
1219
1448
  ...(excerpt ? { excerpt: sanitizeFileContent(excerpt, path) } : {}),
@@ -1332,7 +1561,7 @@ export function buildTools(backend, config) {
1332
1561
  }
1333
1562
  },
1334
1563
  async create_directory(input) {
1335
- const path = resolve(input.path);
1564
+ const path = resolveWrite(input.path);
1336
1565
  const parentPath = path.substring(0, path.lastIndexOf('/')) || root;
1337
1566
  const symlinkErr = await checkSymlink(parentPath);
1338
1567
  if (symlinkErr)
@@ -1348,8 +1577,10 @@ export function buildTools(backend, config) {
1348
1577
  }
1349
1578
  },
1350
1579
  async rename_file(input) {
1351
- const oldPath = resolve(input.old_path);
1352
- const newPath = resolve(input.new_path);
1580
+ // Both ends: moving a package's file OUT is as much a change to the installed
1581
+ // package as moving one in.
1582
+ const oldPath = resolveWrite(input.old_path);
1583
+ const newPath = resolveWrite(input.new_path);
1353
1584
  const oldSymlinkErr = await checkSymlink(oldPath);
1354
1585
  if (oldSymlinkErr)
1355
1586
  return { error: oldSymlinkErr };
@@ -1371,7 +1602,7 @@ export function buildTools(backend, config) {
1371
1602
  }
1372
1603
  },
1373
1604
  async delete_file(input) {
1374
- const path = resolve(input.path);
1605
+ const path = resolveWrite(input.path);
1375
1606
  const symlinkErr = await checkSymlink(path);
1376
1607
  if (symlinkErr)
1377
1608
  return { error: symlinkErr };
@@ -1471,6 +1702,7 @@ export function buildTools(backend, config) {
1471
1702
  : null;
1472
1703
  if (ownFile)
1473
1704
  outputFileByTask.set(id, ownFile);
1705
+ runByTask.set(id, { command: streaming.command, cwd });
1474
1706
  await backend.writeFile(script, `${streaming.command}\n`);
1475
1707
  await backend.run(`nohup sh -c ${shellQuote(prioritized.launch)} ` +
1476
1708
  `> /dev/null 2>&1 & echo $! > /tmp/${id}.pid`, { cwd, timeout: 15_000 });
@@ -1739,8 +1971,8 @@ export function buildTools(backend, config) {
1739
1971
  status: 'stuck',
1740
1972
  waitedMs: waitedBefore,
1741
1973
  note: `You have already waited ${Math.round(waitedBefore / 1000)}s on this command and it ` +
1742
- `has not exited — it is stuck, not slow. Kill it (exec_command: kill $(cat /tmp/${taskId}.pid) ` +
1743
- `2>/dev/null; pkill -f ${taskId}.sh) and move on; do not wait on it again.`,
1974
+ `has not exited — it is stuck, not slow. Kill it (\`exec_command: ` +
1975
+ `${await taskStopCommand(backend, taskId)}\`) and move on; do not wait on it again.`,
1744
1976
  };
1745
1977
  }
1746
1978
  // The id names files under /tmp; only an id this tool minted may be used
@@ -1811,6 +2043,11 @@ export function buildTools(backend, config) {
1811
2043
  // Only a cause that is failing the whole run gets a stop command — and only then is the
1812
2044
  // pid walk paid for.
1813
2045
  const universal = universalCause(causeLog, found);
2046
+ // How this run was started, so the note can hand over the re-run to send once it ends
2047
+ // instead of one for now — the platform refuses a second run against the same app and
2048
+ // database while this one walks (xr-molecule-flash-m89: the +19:59 note named a re-run
2049
+ // and its overlap hold refused that exact run 23 s later).
2050
+ const started = runByTask.get(taskId);
1814
2051
  repeatingCauseNoted.add(taskId);
1815
2052
  const earlyMs = Date.now() - startedAt;
1816
2053
  waitedByTask.set(taskId, waitedBefore + earlyMs);
@@ -1823,6 +2060,7 @@ export function buildTools(backend, config) {
1823
2060
  log,
1824
2061
  stop: universal ? await taskStopCommand(backend, taskId) : '',
1825
2062
  universal,
2063
+ ...(started ? { run: started } : {}),
1826
2064
  }),
1827
2065
  };
1828
2066
  }
@@ -1853,13 +2091,19 @@ export function buildTools(backend, config) {
1853
2091
  // No slice: the notes still say how to peek, and the task id still
1854
2092
  // carries how long the command has been running.
1855
2093
  }
1856
- const progress = waitProgressNote({
2094
+ // ONE sentence of facts, each said once, then what to do next: the lead
2095
+ // this branch used to write ("Still running after 342s of waiting, and its
2096
+ // output is still growing (71839 bytes) — slow, not stuck") restated the
2097
+ // elapsed time and the growth clause these clauses already hold — the same
2098
+ // sentence, twice (X1 flash-m87 §+47:20).
2099
+ const progress = runningNote(waitProgressClauses({
1857
2100
  taskId,
1858
2101
  slice: soFar,
1859
2102
  stillWriting,
1860
2103
  bytes: grownTo,
1861
2104
  ceilingMs: ceiling,
1862
- });
2105
+ waitedMs: waitedBefore + waitedMs,
2106
+ }));
1863
2107
  const sliceField = soFar.trim() ? { outputSoFar: sanitizeOutput(soFar) } : {};
1864
2108
  if (stillWriting && waitedBefore + waitedMs >= stuckAfterMs()) {
1865
2109
  waitedByTask.set(taskId, 0);
@@ -1868,9 +2112,8 @@ export function buildTools(backend, config) {
1868
2112
  status: 'running',
1869
2113
  waitedMs,
1870
2114
  ...sliceField,
1871
- note: `Still running after ${Math.round((waitedBefore + waitedMs) / 1000)}s of waiting, and its ` +
1872
- `output is still growing (${grownTo} bytes) — slow, not stuck.${progress} Call wait_for_task ` +
1873
- `again to keep waiting; peek with exec_command: tail ${log}.`,
2115
+ note: `${progress} Call wait_for_task again to keep waiting; peek with exec_command: ` +
2116
+ `tail ${log}.`,
1874
2117
  };
1875
2118
  }
1876
2119
  if (waitedBefore + waitedMs >= stuckAfterMs()) {
@@ -1880,8 +2123,8 @@ export function buildTools(backend, config) {
1880
2123
  waitedMs: waitedBefore + waitedMs,
1881
2124
  note: `${Math.round((waitedBefore + waitedMs) / 1000)}s of waiting and it has not exited — it is ` +
1882
2125
  `stuck, not slow. See what it printed with exec_command (tail /tmp/${taskId}.log — ` +
1883
- `read_file cannot reach /tmp), kill it (exec_command: ` +
1884
- `kill $(cat /tmp/${taskId}.pid) 2>/dev/null; pkill -f ${taskId}.sh) and move on; do not wait on it again.`,
2126
+ `read_file cannot reach /tmp), kill it (\`exec_command: ` +
2127
+ `${await taskStopCommand(backend, taskId)}\`) and move on; do not wait on it again.`,
1885
2128
  };
1886
2129
  }
1887
2130
  return {
@@ -1889,7 +2132,10 @@ export function buildTools(backend, config) {
1889
2132
  status: 'running',
1890
2133
  waitedMs,
1891
2134
  ...sliceField,
1892
- note: `Still running after ${Math.round(waitedMs / 1000)}s.${progress} Call wait_for_task again to ` +
2135
+ // No "Still running after Ns" lead here either: the clauses already say
2136
+ // how long it has been running, and the lead said it a second time in
2137
+ // the other format (X1 flash-m87 §+47:20).
2138
+ note: `${progress} Call wait_for_task again to ` +
1893
2139
  `keep waiting — its result carries the log output. ` +
1894
2140
  (soFar.trim()
1895
2141
  ? `outputSoFar is the failure lines so far (up to ${RUNNING_FAILURE_LINES}) and then the end of ` +
@@ -691,6 +691,27 @@ export declare function failuresByCause(log: string): FailureCause[];
691
691
  * @returns The note, or null.
692
692
  */
693
693
  export declare function failureCausesNote(log: string): string | null;
694
+ /**
695
+ * The command that re-runs the tests at `locations` the way the run IN FLIGHT ran them: the same
696
+ * invocation, in the same directory, narrowed to those tests — or null when the running command
697
+ * is not a Playwright run this can narrow.
698
+ *
699
+ * The `locations` are the paths the run's own log printed, which are relative to the directory
700
+ * that run started in, so a re-run has to resolve them there: the command names that directory
701
+ * unless it already changes into one of its own, because the executor's next command starts
702
+ * wherever it starts. The returned command is for AFTER the run ends — it is a second run against
703
+ * the same app and database, and that is exactly what is refused while the run walks
704
+ * ({@link suiteRunInTheWay}).
705
+ *
706
+ * @param run - The run in flight: the command it was started with, as it was run, and the
707
+ * directory that command ran in.
708
+ * @param locations - The failing `file:line`s to re-run, as the run's log printed them.
709
+ * @returns The re-run command, or null when there is no suite to narrow.
710
+ */
711
+ export declare function rerunOfRunningSuite(run: {
712
+ command: string;
713
+ cwd?: string;
714
+ }, locations: readonly string[]): string | null;
694
715
  /**
695
716
  * The sentence that names a cause repeating among a running suite's failures, mid-run.
696
717
  *
@@ -705,10 +726,21 @@ export declare function failureCausesNote(log: string): string | null;
705
726
  * stopped eight runs at their first repeating cause in 14.5 minutes and never finished one, so
706
727
  * the note must not say to stop when the run still has other causes to name.
707
728
  *
729
+ * The run is STILL WALKING while this note is written — both callers produce it mid-run — so a
730
+ * re-run of one of its tests is a second run against the same app and database, and the platform
731
+ * refuses that ({@link suiteRunInTheWay}, the same rule its overlap hold answers with):
732
+ * xr-molecule-flash-m89 (2026-10-16) read *"Start on them now, while the run keeps walking.
733
+ * `null-fields.spec.ts:53` alone reproduces the first one."* and sent exactly that re-run 23 s
734
+ * later, where the hold answered *"A playwright suite is already running against this app and
735
+ * database … Wait for it."* The note therefore hands over the read-and-fix to do NOW and the
736
+ * re-run for after the run ends — the exact command where the caller knows how the run was
737
+ * started ({@link rerunOfRunningSuite}), the timing alone where it does not.
738
+ *
708
739
  * @param found - What {@link repeatingFailure} found.
709
740
  * @param o - The run's facts: its `taskId`, its `log` path, `stop` — the shell command that
710
- * stops it, printed only for a {@link UniversalCause} — and `universal`, what
711
- * {@link universalCause} found (absent means the run is not failing whole).
741
+ * stops it, printed only for a {@link UniversalCause} — `universal`, what
742
+ * {@link universalCause} found (absent means the run is not failing whole), and `run`, how the
743
+ * run was started ({@link rerunOfRunningSuite}) when the caller knows it.
712
744
  * @returns The note.
713
745
  */
714
746
  export declare function repeatingFailureNote(found: RepeatingFailure, o: {
@@ -716,6 +748,10 @@ export declare function repeatingFailureNote(found: RepeatingFailure, o: {
716
748
  log: string;
717
749
  stop: string;
718
750
  universal?: UniversalCause | null;
751
+ run?: {
752
+ command: string;
753
+ cwd?: string;
754
+ };
719
755
  }): string;
720
756
  /**
721
757
  * Attempt a whitespace-tolerant replacement when an exact `old_string` match