@molecule/api-ai-tools 1.6.4 → 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.
@@ -0,0 +1,208 @@
1
+ /**
2
+ * The ONE rule that decides whether a suite run may start while another is already walking, and
3
+ * the command readings both sides of it need.
4
+ *
5
+ * Two sites ask "may a second run start now?" about the same pair of runs — the mid-run note that
6
+ * tells the executor what to do next while the suite it is waiting on is still going
7
+ * ({@link ./utilities.js} `repeatingFailureNote`), and the hold that refuses the run when it
8
+ * arrives (molecule-dev's `suite-overlap-hold.ts`) — and until xr-molecule-flash-m89 they answered
9
+ * it separately. That run's +19:59 `wait_for_task` handed over a repeating cause with "the test
10
+ * alone reproduces the first one … start on them now, while the run keeps walking"; 23 s later the
11
+ * executor sent exactly that re-run, and the overlap hold refused it — "A playwright suite is
12
+ * already running against this app and database … Wait for it." Two orders, one run, and the round
13
+ * trip between them was the whole cost.
14
+ *
15
+ * So the rule lives here ({@link suiteRunInTheWay}) and both of them call it: a run may start only
16
+ * when nothing in flight is already measuring the same app and database. What "the same app and
17
+ * database" MEANS lives here too ({@link suiteCommandTarget}) — the note and the hold have to key
18
+ * a command the same way before they can agree on whether two of them collide, and the re-run a
19
+ * note hands over is built from the same reading of the running command
20
+ * ({@link scopedRerunCommand}).
21
+ *
22
+ * The same treatment for the other pair that answered one question twice: whether a run walks the
23
+ * WHOLE suite or a part of it. The rewriter that decides whether a background Playwright run gets
24
+ * `--retries=0` ({@link ./utilities.js} `streamingBackgroundCommand`) and the platform's own notes
25
+ * (molecule-dev's `suite-cli-override.ts`) asked it separately — a regex there, a predicate here —
26
+ * until xr-molecule-flash-m89 showed the two answers were never the same question
27
+ * ({@link isDiagnosisRun}).
28
+ *
29
+ * @module
30
+ */
31
+ import { commandSegments } from './command-position.js';
32
+ import { commandRunsNoTests } from './no-test-run.js';
33
+ /**
34
+ * A command with its heredoc bodies removed: the runs, kills and hosts it names in a file it
35
+ * WRITES are not what the command itself does.
36
+ *
37
+ * The header is kept (`cat > run.sh <<'EOF' …EOF` leaves `cat > run.sh`), which is what the
38
+ * readers here want: everything before the body is still readable as the command's own words.
39
+ *
40
+ * @param command - The shell command.
41
+ * @returns The command without its heredoc bodies.
42
+ */
43
+ export function withoutHeredocs(command) {
44
+ return command.replace(/<<-?\s*(['"]?)(\w+)\1[\s\S]*?\n\s*\2\b/g, ' ');
45
+ }
46
+ /**
47
+ * The server a suite command points at, as a comparable key: the host it names (`HOST=`,
48
+ * `BASE_URL=`, `PLAYWRIGHT_BASE_URL=`, `--variable host=`, or a bare URL), or `local` for a run
49
+ * that names none — a suite run in the sandbox goes to the sandbox's own app unless the command
50
+ * says otherwise. Every localhost spelling collapses to `local` (one sandbox, one app, one
51
+ * database).
52
+ *
53
+ * @param command - The suite command.
54
+ * @returns The target key.
55
+ */
56
+ export function suiteCommandTarget(command) {
57
+ const text = withoutHeredocs(command);
58
+ const named = /(?:\b(?:HOST|BASE_URL|PLAYWRIGHT_BASE_URL|E2E_BASE_URL)|--variable\s+host)\s*=\s*['"]?([^\s'"|;&]+)/i.exec(text)?.[1] ?? /https?:\/\/[^\s'"|;&]+/i.exec(text)?.[0];
59
+ if (!named)
60
+ return 'local';
61
+ const value = named.trim();
62
+ let host;
63
+ try {
64
+ const url = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(value) ? value : `http://${value}`);
65
+ host = url.hostname.toLowerCase();
66
+ if (!/^(?:localhost|127\.0\.0\.1|0\.0\.0\.0|::1|\[::1\])$/.test(host))
67
+ return url.port ? `${host}:${url.port}` : host;
68
+ }
69
+ catch (_error) {
70
+ // Not a URL at all (a shell variable the command does not expand): compare
71
+ // the token itself, so the same spelling still reads as the same target.
72
+ return value.toLowerCase();
73
+ }
74
+ return 'local';
75
+ }
76
+ /**
77
+ * The whole-suite command made scoped to the tests it should re-run instead: the same
78
+ * invocation — same directory, same settings — with the named `file:line`s in place of the whole
79
+ * suite, `--last-failed` dropped (it re-runs the tests that never ran too, X1 flash-m60) and
80
+ * `--retries=0` set (a re-run of a known failure does not need to pay for retries).
81
+ *
82
+ * @param command - The whole-suite run's command.
83
+ * @param locations - The failing `file:line`s to re-run, as the run's own log printed them — or,
84
+ * for a run that started outside the suite, the absolute form Playwright matches each file
85
+ * argument against.
86
+ * @returns The scoped command, or the command unchanged when it runs no Playwright suite.
87
+ */
88
+ export function scopedRerunCommand(command, locations) {
89
+ const at = command.search(/\bplaywright\s+test\b/);
90
+ if (at === -1)
91
+ return command;
92
+ const head = command.slice(0, at);
93
+ const rest = command.slice(at);
94
+ // Only the first statement runs the suite; the rest (`| tail -50`, `&& echo done`)
95
+ // is left exactly as it was.
96
+ const end = rest.search(/&&|\|\||;|\|/);
97
+ const statement = end === -1 ? rest : rest.slice(0, end);
98
+ const tail = end === -1 ? '' : rest.slice(end);
99
+ const scoped = statement
100
+ .replace(/\s*--last-failed\b/g, '')
101
+ .replace(/\s*--retries(?:[= ]\d+)?/g, '')
102
+ .replace(/^(playwright\s+test)\b/, `$1 ${locations.join(' ')} --retries=0`);
103
+ return `${head}${scoped}${tail}`;
104
+ }
105
+ /**
106
+ * The run in flight that makes a second run a hazard, or null when nothing is in the way.
107
+ *
108
+ * This is the ONE rule the mid-run note and the suite-overlap hold both answer with: a suite run
109
+ * may start only when this returns null, because two suites against one app and database share its
110
+ * rows — the failures one causes the other cannot be told from real ones. A run that invokes no
111
+ * suite is never in the way, and neither is one the candidate command stops first.
112
+ *
113
+ * @param candidate - The run about to start, by the app and database it would measure
114
+ * ({@link suiteCommandTarget}).
115
+ * @param inFlight - The suite runs already going, in the order the caller found them.
116
+ * @returns The first run in the way, or null.
117
+ */
118
+ export function suiteRunInTheWay(candidate, inFlight) {
119
+ return (inFlight.find((run) => run.runsSuite && run.target === candidate.target && !run.stoppedByCommand) ?? null);
120
+ }
121
+ /**
122
+ * The Playwright invocation of a command, as its own command-position text (`playwright test …`),
123
+ * or null when the command starts no Playwright run.
124
+ *
125
+ * The runner's own word has to END where the sub-command starts (`(?=\s|$)`, not a `\b`): a
126
+ * segment can be a fragment beginning at a `playwright test` printed INSIDE a string
127
+ * (`echo "then npx playwright test" && npx playwright test`), and a match that ends against the
128
+ * closing quote is that echo. A version-pinned `playwright@1.44.0 test` is the same run.
129
+ */
130
+ const PLAYWRIGHT_RUN = /^(?:@playwright\/test|playwright)(?:@\S+)?\s+test(?=\s|$)/;
131
+ /**
132
+ * What narrows a Playwright run to a part of the suite: a named file or line, a title filter, or
133
+ * the tests the run before it printed (`--last-failed`, `--only-changed`).
134
+ */
135
+ const NARROWED_RUN = /\.(?:spec|test)\.[cm]?[jt]sx?\b|\.[cm]?[jt]sx?:\d+|(?:^|\s)(?:-g|--grep(?:-invert)?|--last-failed|--only-changed)\b/;
136
+ /**
137
+ * The Playwright invocation's text when the command starts one, or null.
138
+ *
139
+ * @param command - The command as the executor sent it.
140
+ * @returns The invocation's command-position text, or null.
141
+ */
142
+ function playwrightRun(command) {
143
+ return (commandSegments(withoutHeredocs(command)).find((segment) => PLAYWRIGHT_RUN.test(segment.text))
144
+ ?.text ?? null);
145
+ }
146
+ /**
147
+ * Whether a `--max-failures` BOUNDS the walk: the flag is given a count above zero.
148
+ *
149
+ * Playwright's own spelling of "no cap" is zero — `maxFailures: 0` "disables the feature" — and
150
+ * that is the flag the platform's `testRunNote` asks for on the uncapped whole-suite run ("run the
151
+ * whole suite once with `--max-failures=0`"), which is why this is not a plain flag test.
152
+ *
153
+ * @param command - The command as the executor sent it.
154
+ * @returns True when a failure cap would stop the walk.
155
+ */
156
+ function boundsFailures(command) {
157
+ const flag = /(?:^|[\s;&|(])--max-failures(?![-\w])/.exec(command);
158
+ if (!flag)
159
+ return false;
160
+ const value = /(?:^|[\s;&|(])--max-failures(?:=|\s+)(['"]?)(\d[\d_]*)/.exec(command)?.[2];
161
+ // A `--max-failures` with no count at all is not the uncapped spelling of anything: it is read
162
+ // as a cap, the way Playwright's own default reading of the flag is a count of failures.
163
+ if (value === undefined)
164
+ return true;
165
+ return Number(value.replace(/_/g, '')) !== 0;
166
+ }
167
+ /**
168
+ * Whether a command walks a PART of a suite instead of all of it: a cap that stops it at a failure
169
+ * count, the failures the last run printed (`--last-failed`, `--only-changed`), a title filter, or
170
+ * named files/lines.
171
+ *
172
+ * THE one reading of that question, in one place. Two sites act on the answer and must never
173
+ * disagree:
174
+ *
175
+ * - the command rewriter that adds `--retries=0` to a background Playwright run
176
+ * ({@link ./utilities.js} `streamingBackgroundCommand`) — a diagnosis walks each failure once,
177
+ * while the run the suite is judged by keeps the suite's own retries; and
178
+ * - the platform's own notes (molecule-dev's `suite-cli-override.ts`), which must not tell the
179
+ * executor to drop the `--retries=0` the platform itself has just added — the contradiction X1
180
+ * flash-m74 and m75 filed, and m76 filed a third time.
181
+ *
182
+ * Until xr-molecule-flash-m89 they were a regex and a predicate that only LOOKED alike, and the
183
+ * gap was exactly the run the platform had asked for: its `testRunNote` says "run the whole suite
184
+ * once with `--max-failures=0`", and the rewriter read that cap flag as a capped diagnosis and
185
+ * stripped the suite's retries from the uncapped proof run that follows a deploy — the one run the
186
+ * graded run is compared against (+25:03, +31:39, +36:40 and +49:57, the last two against the
187
+ * deployed URL, where a network blip is likeliest and the retries it dropped are the ones that
188
+ * absorb it).
189
+ *
190
+ * `--max-failures=0` is Playwright's own "no cap" ({@link boundsFailures}), so it is not a
191
+ * diagnosis. A command that starts no Playwright run is not one either (false): the question is
192
+ * about a run already read as a Playwright suite.
193
+ *
194
+ * @param command - The command as the executor sent it.
195
+ * @returns True when the run is a diagnosis or a re-run, not the whole suite.
196
+ */
197
+ export function isDiagnosisRun(command) {
198
+ if (boundsFailures(command))
199
+ return true;
200
+ const run = playwrightRun(command);
201
+ if (run === null)
202
+ return false;
203
+ // A run that only collects and prints (`playwright test --list`, `--version`) walks no part of
204
+ // the suite either, and is not the whole-suite run the retries belong on.
205
+ if (commandRunsNoTests(run))
206
+ return true;
207
+ return NARROWED_RUN.test(run);
208
+ }
package/dist/tools.d.ts CHANGED
@@ -39,6 +39,31 @@ import type { ExecutionBackend, ToolBuildConfig } from './types.js';
39
39
  * @returns The note, or null when the path is not inside a package's `dist`.
40
40
  */
41
41
  export declare function moleculeDistReadNote(path: string): string | null;
42
+ /**
43
+ * The refusal for a write whose target is inside an installed package, or null
44
+ * when the path is not under a `node_modules` directory.
45
+ *
46
+ * `node_modules` is not the project's code: nothing written there survives the
47
+ * next install, no deploy carries it, and a sandbox whose dependency tree is
48
+ * shared (the pre-installed superset) breaks every other run through it. X0
49
+ * xr-molecule-flash-m91 (+04:43) called `write_file` on
50
+ * `node_modules/@molecule/api-resource-comment/dist/numbers.d.ts` while reading
51
+ * the package's declarations and clobbered its runtime module a call later
52
+ * (`module.exports = {}` over the ESM handler its own code imports) — the write
53
+ * answered `ok: true`, and 18 s went to noticing, `npm pack`-ing the package and
54
+ * putting the file back. Reading these files stays allowed (that is how the
55
+ * declarations above are read at all).
56
+ *
57
+ * The restore route is named because the model that has ALREADY clobbered a file
58
+ * (this refusal is too late for it) is the one who needs it, and because the
59
+ * obvious repair — `npm install <pkg>` — is itself refused in the pre-installed
60
+ * sandbox. `npm pack` + a `tar` extraction over the package directory is what
61
+ * m91 used, and what both guards leave alone.
62
+ *
63
+ * @param path - An absolute path a tool is about to write.
64
+ * @returns The one-line refusal naming the package, or null when nothing is installed there.
65
+ */
66
+ export declare function installedPackageWriteRefusal(path: string): string | null;
42
67
  /**
43
68
  * Build a complete set of AI agent tools bound to an execution backend.
44
69
  *
@@ -1 +1 @@
1
- {"version":3,"file":"tools.d.ts","sourceRoot":"","sources":["../src/tools.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAA;AAsE9C;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAsB,eAAe,CAAC,OAAO,EAAE,gBAAgB,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAahG;AAwHD,OAAO,KAAK,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA;AAoEnE;;;;;;;;;;;GAWG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAUhE;AAED;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,gBAAgB,EAAE,MAAM,CAAC,EAAE,eAAe,GAAG,MAAM,EAAE,CA65DxF"}
1
+ {"version":3,"file":"tools.d.ts","sourceRoot":"","sources":["../src/tools.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAA;AAiF9C;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAsB,eAAe,CAAC,OAAO,EAAE,gBAAgB,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAahG;AAwHD,OAAO,KAAK,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA;AAoEnE;;;;;;;;;;;GAWG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAUhE;AAED;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,4BAA4B,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAsBxE;AAED;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,gBAAgB,EAAE,MAAM,CAAC,EAAE,eAAe,GAAG,MAAM,EAAE,CAgjExF"}
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
@@ -234,6 +244,52 @@ export function moleculeDistReadNote(path) {
234
244
  ? ' The declarations above are generated from it.'
235
245
  : ' For one signature, read the matching .d.ts file.'));
236
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
+ }
237
293
  /**
238
294
  * Build a complete set of AI agent tools bound to an execution backend.
239
295
  *
@@ -336,6 +392,22 @@ export function buildTools(backend, config) {
336
392
  }
337
393
  return pathGuards ? resolvePath(path, root) : path;
338
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
+ }
339
411
  /** Paths read with a window in this tool set, to tell paging from one targeted read. */
340
412
  const windowedReads = new Set();
341
413
  /** The text each path was last handed back WHOLE as, so that correction fires once per version. */
@@ -887,7 +959,7 @@ export function buildTools(backend, config) {
887
959
  const argErr = pathArgError(input.path, 'write_file');
888
960
  if (argErr)
889
961
  return { error: argErr };
890
- const path = resolve(input.path);
962
+ const path = resolveWrite(input.path);
891
963
  const content = input.content;
892
964
  if (content.length > MAX_WRITE_SIZE)
893
965
  return {
@@ -936,7 +1008,7 @@ export function buildTools(backend, config) {
936
1008
  const argErr = pathArgError(input.path, 'edit_file');
937
1009
  if (argErr)
938
1010
  return { error: argErr };
939
- const path = resolve(input.path);
1011
+ const path = resolveWrite(input.path);
940
1012
  // Support both formats: { replacements: [...] } (batch) and { old_string, new_string } (single, backwards-compatible)
941
1013
  let replacements = input.replacements;
942
1014
  // Accept the single format when BOTH are strings — `typeof` (not truthy) so
@@ -973,13 +1045,35 @@ export function buildTools(backend, config) {
973
1045
  }
974
1046
  const oldContent = content;
975
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 = [];
976
1051
  // Where each applied replacement landed, in the coordinates of the
977
1052
  // latest content — rendered into the result so the model sees the edit.
978
1053
  let spans = [];
979
- 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,
980
1074
  // Models sometimes send the camelCase spelling inside an item, where
981
1075
  // the input normalizer (top-level keys only) does not reach.
982
- replaceAll: replaceAllCamel, } of replacements) {
1076
+ replaceAll: replaceAllCamel, },] of replacements.entries()) {
983
1077
  // Validate each replacement is well-formed BEFORE touching content. A
984
1078
  // weak model sometimes emits a batch element missing a field or with a
985
1079
  // non-string value; left unchecked that either crashes (`undefined`
@@ -1081,15 +1175,74 @@ export function buildTools(backend, config) {
1081
1175
  .map((l) => l.trim())
1082
1176
  .filter((l) => l.length >= 8)),
1083
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;
1084
1218
  for (const anchor of distinctiveLines) {
1085
1219
  const hits = fileLines.reduce((acc, l, i) => {
1086
1220
  if (l.includes(anchor))
1087
1221
  acc.push(i);
1088
1222
  return acc;
1089
1223
  }, []);
1090
- if (hits.length === 1)
1091
- 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 };
1092
1241
  }
1242
+ if (best)
1243
+ return showDivergence(best.base, best.k);
1244
+ if (firstUniqueHit !== null)
1245
+ return showSnippet(firstUniqueHit);
1093
1246
  // Fallback: the first non-blank line, even if not unique (better than a
1094
1247
  // bare "re-read" when the model just needs to see nearby real content).
1095
1248
  const firstLine = oldString
@@ -1149,10 +1302,36 @@ export function buildTools(backend, config) {
1149
1302
  // a self-inflicted syntax error written with ok:true that the executor
1150
1303
  // then can't locate. count === 1 here, so indexOf is the unique site.
1151
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
+ }
1152
1319
  const spliced = content.slice(0, at) + newString + content.slice(at + oldString.length);
1153
1320
  spans = addChangeSpan(spans, content, spliced);
1154
1321
  content = spliced;
1155
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');
1156
1335
  const alreadyNote = alreadyApplied > 0
1157
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'}.`
1158
1337
  : null;
@@ -1164,7 +1343,16 @@ export function buildTools(backend, config) {
1164
1343
  (identical > 0
1165
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.`
1166
1345
  : 'Nothing changed: the file is exactly as it was.');
1167
- 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
+ };
1168
1356
  }
1169
1357
  if (onFileDiff)
1170
1358
  onFileDiff({ path, oldContent, newContent: content });
@@ -1190,10 +1378,13 @@ export function buildTools(backend, config) {
1190
1378
  const excerpt = renderEditExcerpt(finalContent, spans);
1191
1379
  // A replacement whose old text ended in a newline and whose new text does not
1192
1380
  // 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.
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));
1194
1384
  const dropped = replacements
1195
1385
  .map((r, i) => ({ ...r, position: i + 1 }))
1196
- .filter((r) => typeof r.old_string === 'string' &&
1386
+ .filter((r) => !refused.has(r.position) &&
1387
+ typeof r.old_string === 'string' &&
1197
1388
  typeof r.new_string === 'string' &&
1198
1389
  r.old_string.endsWith('\n') &&
1199
1390
  r.new_string !== '' &&
@@ -1232,7 +1423,7 @@ export function buildTools(backend, config) {
1232
1423
  return {
1233
1424
  path,
1234
1425
  ok: true,
1235
- replacementsApplied: replacements.length - alreadyApplied,
1426
+ replacementsApplied: replacements.length - alreadyApplied - skipped.length,
1236
1427
  ...(fusedNote
1237
1428
  ? { syntaxError: fusedNote }
1238
1429
  : {
@@ -1251,6 +1442,7 @@ export function buildTools(backend, config) {
1251
1442
  : {}),
1252
1443
  ...syntax,
1253
1444
  }),
1445
+ ...(skippedNote ? { skippedNote } : {}),
1254
1446
  ...(alreadyApplied > 0 ? { alreadyApplied, note: alreadyNote } : {}),
1255
1447
  totalLines: countLines(finalContent),
1256
1448
  ...(excerpt ? { excerpt: sanitizeFileContent(excerpt, path) } : {}),
@@ -1369,7 +1561,7 @@ export function buildTools(backend, config) {
1369
1561
  }
1370
1562
  },
1371
1563
  async create_directory(input) {
1372
- const path = resolve(input.path);
1564
+ const path = resolveWrite(input.path);
1373
1565
  const parentPath = path.substring(0, path.lastIndexOf('/')) || root;
1374
1566
  const symlinkErr = await checkSymlink(parentPath);
1375
1567
  if (symlinkErr)
@@ -1385,8 +1577,10 @@ export function buildTools(backend, config) {
1385
1577
  }
1386
1578
  },
1387
1579
  async rename_file(input) {
1388
- const oldPath = resolve(input.old_path);
1389
- 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);
1390
1584
  const oldSymlinkErr = await checkSymlink(oldPath);
1391
1585
  if (oldSymlinkErr)
1392
1586
  return { error: oldSymlinkErr };
@@ -1408,7 +1602,7 @@ export function buildTools(backend, config) {
1408
1602
  }
1409
1603
  },
1410
1604
  async delete_file(input) {
1411
- const path = resolve(input.path);
1605
+ const path = resolveWrite(input.path);
1412
1606
  const symlinkErr = await checkSymlink(path);
1413
1607
  if (symlinkErr)
1414
1608
  return { error: symlinkErr };
@@ -1508,6 +1702,7 @@ export function buildTools(backend, config) {
1508
1702
  : null;
1509
1703
  if (ownFile)
1510
1704
  outputFileByTask.set(id, ownFile);
1705
+ runByTask.set(id, { command: streaming.command, cwd });
1511
1706
  await backend.writeFile(script, `${streaming.command}\n`);
1512
1707
  await backend.run(`nohup sh -c ${shellQuote(prioritized.launch)} ` +
1513
1708
  `> /dev/null 2>&1 & echo $! > /tmp/${id}.pid`, { cwd, timeout: 15_000 });
@@ -1848,6 +2043,11 @@ export function buildTools(backend, config) {
1848
2043
  // Only a cause that is failing the whole run gets a stop command — and only then is the
1849
2044
  // pid walk paid for.
1850
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);
1851
2051
  repeatingCauseNoted.add(taskId);
1852
2052
  const earlyMs = Date.now() - startedAt;
1853
2053
  waitedByTask.set(taskId, waitedBefore + earlyMs);
@@ -1860,6 +2060,7 @@ export function buildTools(backend, config) {
1860
2060
  log,
1861
2061
  stop: universal ? await taskStopCommand(backend, taskId) : '',
1862
2062
  universal,
2063
+ ...(started ? { run: started } : {}),
1863
2064
  }),
1864
2065
  };
1865
2066
  }