@molecule/api-ai-tools 1.8.1 → 1.8.3

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.d.ts CHANGED
@@ -60,8 +60,47 @@ export declare const SUITE_OVER_BUDGET_ADVICE: string;
60
60
  */
61
61
  export declare function moleculeDistReadNote(path: string): string | null;
62
62
  /**
63
- * The refusal for a write whose target is inside an installed package, or null
64
- * when the path is not under a `node_modules` directory.
63
+ * What the filesystem says about a write target under `node_modules` — the half
64
+ * of the decision a path's spelling cannot make.
65
+ *
66
+ * @remarks
67
+ * A path that reads `node_modules` is not necessarily an install: a run links
68
+ * `node_modules` beside its e2e copy to borrow the hoisted tree, and that link
69
+ * is its own. X0 xr-molecule-flash-m106 (+07:58) made exactly that link, found
70
+ * it dangling (everything was hoisted to `/workspace/node_modules`), and was
71
+ * refused three times for trying to remove it — 40 s and a "3rd time this turn
72
+ * … ask the user" escalation, in an unattended run, over a broken symlink in
73
+ * the run's own scratch directory. So the guard asks what is there.
74
+ */
75
+ export interface InstalledPackageFacts {
76
+ /** Whether the `node_modules` entry the path goes through is a symbolic link. */
77
+ readonly symlink: boolean;
78
+ /** Whether a package is really installed there: a `package.json` resolves under the entry. */
79
+ readonly installed: boolean;
80
+ }
81
+ /**
82
+ * Looks at the filesystem for {@link InstalledPackageFacts}.
83
+ *
84
+ * The guard cannot `stat` the path itself: the tools act through a backend (a
85
+ * sandbox over the network for the fleet), so the caller — which holds that
86
+ * backend — supplies the look. {@link shellInstalledPackageProbe} builds one
87
+ * from any shell runner.
88
+ */
89
+ export type InstalledPackageProbe = (path: string) => Promise<InstalledPackageFacts>;
90
+ /**
91
+ * Build an {@link InstalledPackageProbe} from a shell runner (a backend's
92
+ * `run`, or a sandbox's `exec`): one command reports whether the entry is a
93
+ * link and whether a `package.json` resolves under it.
94
+ *
95
+ * @param run - Runs a command where the path lives.
96
+ * @returns The probe.
97
+ */
98
+ export declare function shellInstalledPackageProbe(run: (command: string) => Promise<{
99
+ stdout?: string;
100
+ } | null | undefined>): InstalledPackageProbe;
101
+ /**
102
+ * The refusal for a write whose target is inside an INSTALLED package, or null
103
+ * when the path is not under `node_modules` or nothing is installed there.
65
104
  *
66
105
  * `node_modules` is not the project's code: nothing written there survives the
67
106
  * next install, no deploy carries it, and a sandbox whose dependency tree is
@@ -74,6 +113,19 @@ export declare function moleculeDistReadNote(path: string): string | null;
74
113
  * putting the file back. Reading these files stays allowed (that is how the
75
114
  * declarations above are read at all).
76
115
  *
116
+ * What is protected is the INSTALL, not the spelling: the decision comes from
117
+ * {@link InstalledPackageFacts}, never from the path alone (m106 above). Three
118
+ * things are the run's own to remove or replace —
119
+ *
120
+ * - a `node_modules` that is a SYMLINK: `rm`/`unlink` removes the link, never the
121
+ * tree it points at, so a link the run made cannot clobber anything;
122
+ * - anything under the run's own `.mol-scratch/` directory;
123
+ * - a `node_modules` with no `package.json` under it (nothing was ever installed).
124
+ *
125
+ * A path INSIDE the link is still decided by what resolves there: a link into a
126
+ * real tree is refused, because removing or writing through it reaches the
127
+ * install.
128
+ *
77
129
  * The restore route is named because the model that has ALREADY clobbered a file
78
130
  * (this refusal is too late for it) is the one who needs it, and because the
79
131
  * obvious repair — `npm install <pkg>` — is itself refused in the pre-installed
@@ -81,9 +133,10 @@ export declare function moleculeDistReadNote(path: string): string | null;
81
133
  * m91 used, and what both guards leave alone.
82
134
  *
83
135
  * @param path - An absolute path a tool is about to write.
136
+ * @param probe - Looks at the filesystem where the path lives.
84
137
  * @returns The one-line refusal naming the package, or null when nothing is installed there.
85
138
  */
86
- export declare function installedPackageWriteRefusal(path: string): string | null;
139
+ export declare function installedPackageWriteRefusal(path: string, probe?: InstalledPackageProbe): Promise<string | null>;
87
140
  /**
88
141
  * Build a complete set of AI agent tools bound to an execution backend.
89
142
  *
@@ -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;AA0G9C;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAsB,eAAe,CAAC,OAAO,EAAE,gBAAgB,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAahG;AAyHD,OAAO,KAAK,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA;AAkEnE;;;GAGG;AACH,eAAO,MAAM,iBAAiB,QAEA,CAAA;AAE9B;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,wBAAwB,QAI6C,CAAA;AAmBlF;;;;;;;;;;;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;AAgDD;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,gBAAgB,EAAE,MAAM,CAAC,EAAE,eAAe,GAAG,MAAM,EAAE,CAyzExF"}
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;AAgJ9C;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAsB,eAAe,CAAC,OAAO,EAAE,gBAAgB,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAahG;AAiOD,OAAO,KAAK,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA;AAmEnE;;;GAGG;AACH,eAAO,MAAM,iBAAiB,QAEA,CAAA;AAE9B;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,wBAAwB,QAI6C,CAAA;AAmBlF;;;;;;;;;;;GAWG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAUhE;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,qBAAqB;IACpC,iFAAiF;IACjF,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAA;IACzB,8FAA8F;IAC9F,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAA;CAC5B;AAED;;;;;;;GAOG;AACH,MAAM,MAAM,qBAAqB,GAAG,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,qBAAqB,CAAC,CAAA;AAoBpF;;;;;;;GAOG;AACH,wBAAgB,0BAA0B,CACxC,GAAG,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC;IAAE,MAAM,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,GAAG,SAAS,CAAC,GACxE,qBAAqB,CAkBvB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AACH,wBAAsB,4BAA4B,CAChD,IAAI,EAAE,MAAM,EACZ,KAAK,CAAC,EAAE,qBAAqB,GAC5B,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAiCxB;AAgDD;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,gBAAgB,EAAE,MAAM,CAAC,EAAE,eAAe,GAAG,MAAM,EAAE,CAu2ExF"}
package/dist/tools.js CHANGED
@@ -10,6 +10,7 @@ import { commandCutRun, CUT_AT_BUDGET, cutRunSentence, suiteRunExitCode } from '
10
10
  import { addChangeSpan, changedSpan, countLines, renderEditExcerpt, renderSmallFile, } from './edit-excerpt.js';
11
11
  import { guardToolExecute, missingParamError, ToolInputError } from './input-normalizer.js';
12
12
  import { TOOL_SCHEMAS } from './schemas.js';
13
+ import { leadingSleepPeek } from './sleep-peek.js';
13
14
  /**
14
15
  * Total waiting, per background task, after which wait_for_task reports it
15
16
  * stuck instead of waiting again. Env-tunable so a test can exercise it.
@@ -44,6 +45,36 @@ const runByTask = new Map();
44
45
  const FILTERED_EXIT_NOTE = 'No exit code is reported for this run: a stage after the test runner produced the pipeline’s ' +
45
46
  'code (or the code says 0 beside a failure in the tally), so read the pass/fail tally in the ' +
46
47
  'log instead.';
48
+ /**
49
+ * What a finished run says when its suite sits inside a `sh -c '<command>'` string: the stages are
50
+ * positions in THAT string, so the platform cannot point at one in the command's own text. Said
51
+ * instead of {@link FILTERED_EXIT_NOTE} — that note names a stage after the runner, and this run
52
+ * cannot be read as having one; a command with nothing piped after the runner keeps its code.
53
+ */
54
+ const UNPLACED_KEPT_NOTE = 'The suite sits inside a `sh -c` string, so its stages cannot be placed in the command’s own ' +
55
+ 'text; nothing follows the runner there, so the exit code above is the run’s own — read the ' +
56
+ 'pass/fail tally in the log for the verdict.';
57
+ /** {@link UNPLACED_KEPT_NOTE} when between them a stage after the runner produced the code. */
58
+ const UNPLACED_PIPED_NOTE = 'The suite sits inside a `sh -c` string, so its stages cannot be placed in the command’s own ' +
59
+ 'text, and a stage after the runner may have produced the pipeline’s code: none is reported, ' +
60
+ 'so read the pass/fail tally in the log instead.';
61
+ /**
62
+ * The sentence a finished suite run's result carries about its exit code, or null when the code it
63
+ * reports is the run's own and needs no explanation.
64
+ *
65
+ * {@link FILTERED_EXIT_NOTE} names a stage after the runner, so it is said only when the parse
66
+ * found one; a run whose stages cannot be placed says THAT instead, and keeps the command's own
67
+ * code when nothing follows the runner.
68
+ *
69
+ * @param placement - Where the code came from ({@link suiteRunPipelinePlacement}).
70
+ * @param reported - The code the result reports, or null when it reports none.
71
+ * @returns The note, or null.
72
+ */
73
+ function suiteExitCodeNote(placement, reported) {
74
+ if (placement.unplaced)
75
+ return reported === null ? UNPLACED_PIPED_NOTE : UNPLACED_KEPT_NOTE;
76
+ return reported === null ? FILTERED_EXIT_NOTE : null;
77
+ }
47
78
  /**
48
79
  * Task ids whose own-output file has already been looked for this process.
49
80
  * {@link outputFileByTask} is empty for every task after a restart — it is
@@ -229,8 +260,106 @@ function runningNote(clauses) {
229
260
  const joined = clauses.join('; ');
230
261
  return `${joined[0].toUpperCase()}${joined.slice(1)}.`;
231
262
  }
263
+ /**
264
+ * The background task a leading `sleep` was about to peek at, and its state now — or null when the
265
+ * command has no task of this process's to report, in which case the sleep runs as written (the
266
+ * tool's own budget bounds it, as before).
267
+ *
268
+ * X1 xr-molecule-flash-m105 (+08:49 → +10:53, 2026-10-17) hand-polled with `sleep N; tail …`
269
+ * three times. The state the executor wanted was always one the platform already held: two tasks
270
+ * had ended 0.1 s in (exit 127, and `Error: No tests found`) and the third was live at 72 of 139
271
+ * with failures the log already carried. So a command that opens with a long sleep is answered
272
+ * from the same reads `wait_for_task` makes — the exit file, and the failure-lines-plus-tail slice
273
+ * of the log — and the sleep is dropped.
274
+ *
275
+ * The command names WHAT it is peeking at (`/tmp/<id>.log`, or the file the task redirects its own
276
+ * output to), and that task is the answer whether it has ended or not: the two dead peeks are
277
+ * exactly this shape. With no name to follow, the newest LIVE task is the answer — the point of
278
+ * the rule is that a sleep never outlives a run it could have been waiting on — and with no live
279
+ * task there is nothing to report.
280
+ *
281
+ * @param backend - The execution backend the task runs on.
282
+ * @param command - The command whose leading sleep is being replaced.
283
+ * @param seconds - The seconds that leading sleep asked for.
284
+ * @param ceilingMs - The wait ceiling a `wait_for_task` would have, so the ETA it states is the
285
+ * one an actual wait would act on.
286
+ * @returns One sentence naming the task's state and how to get its result, plus the log slice the
287
+ * caller attaches as `outputSoFar`; null when no task answers this command.
288
+ */
289
+ async function backgroundTaskState(backend, command, seconds, ceilingMs) {
290
+ const ids = [...runByTask.keys()];
291
+ if (!ids.length)
292
+ return null;
293
+ const exitCodeOf = async (id) => {
294
+ try {
295
+ const raw = await backend.readFile(`/tmp/${id}.exit`);
296
+ const code = Number.parseInt(raw.trim(), 10);
297
+ return Number.isFinite(code) ? code : 0;
298
+ }
299
+ catch (_error) {
300
+ // No exit file yet: the task is still running. Not an error.
301
+ return null;
302
+ }
303
+ };
304
+ const names = (id) => {
305
+ if (command.includes(`/tmp/${id}.log`))
306
+ return true;
307
+ const own = outputFileByTask.get(id);
308
+ return own !== undefined && command.includes(own);
309
+ };
310
+ const newestFirst = [...ids].reverse();
311
+ const namedId = newestFirst.find(names);
312
+ let target = namedId
313
+ ? { id: namedId, exitCode: await exitCodeOf(namedId) }
314
+ : null;
315
+ if (!target) {
316
+ // Bounded: a long session can leave many finished tasks behind, and each look costs a read.
317
+ for (const id of newestFirst.slice(0, 8)) {
318
+ const exitCode = await exitCodeOf(id);
319
+ if (exitCode === null) {
320
+ target = { id, exitCode };
321
+ break;
322
+ }
323
+ }
324
+ }
325
+ if (!target)
326
+ return null;
327
+ let slice = '';
328
+ try {
329
+ const r = await backend.run(runningOutputSliceCommand(`/tmp/${target.id}.log`, outputFileByTask.get(target.id)), { timeout: 5_000 });
330
+ slice = r.stdout ?? '';
331
+ }
332
+ catch (_error) {
333
+ // No slice: the note still says the task's state and how to get its result.
334
+ }
335
+ const secondsText = `Did not sleep ${Math.round(seconds)} s: background task \`${target.id}\``;
336
+ if (target.exitCode === null) {
337
+ const facts = waitProgressClauses({
338
+ taskId: target.id,
339
+ slice,
340
+ stillWriting: false,
341
+ bytes: null,
342
+ ceilingMs,
343
+ waitedMs: 0,
344
+ });
345
+ const failures = slice.trim() ? '; the failures already in its log are in outputSoFar' : '';
346
+ return {
347
+ note: `${secondsText} is live (${facts.join('; ')})${failures} — ` +
348
+ `\`wait_for_task ${target.id}\` returns its result the moment it ends.`,
349
+ slice,
350
+ };
351
+ }
352
+ const counter = logProgress(slice);
353
+ const progress = counter ? `, at ${counter.done} of ${counter.total} in its log` : '';
354
+ const failures = slice.trim() ? '; the failures in its log are in outputSoFar' : '';
355
+ return {
356
+ note: `${secondsText} had already ended (exit code ${target.exitCode})${progress}${failures} — ` +
357
+ `\`wait_for_task ${target.id}\` returns its result now.`,
358
+ slice,
359
+ };
360
+ }
232
361
  import { diagnosisTraceCommand } from './trace-artifacts.js';
233
- import { checkBlockedCommand, declaredBudgetSeconds, DEFAULT_SEARCH_EXCLUDED_DIRS, directoryReadHint, earlyReturnCause, 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, suiteRunPipedAfterRunner, suiteRunPriorityLaunch, truncateMiddle, universalCause, whitespaceTolerantReplace, } from './utilities.js';
362
+ import { checkBlockedCommand, declaredBudgetSeconds, DEFAULT_SEARCH_EXCLUDED_DIRS, directoryReadHint, earlyReturnCause, 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, suiteRunPipelinePlacement, suiteRunPriorityLaunch, truncateMiddle, universalCause, whitespaceTolerantReplace, } from './utilities.js';
234
363
  /**
235
364
  * How long exec_command waits for a command it backgrounded only because the
236
365
  * command asked for more than the tool's ceiling, before handing back a task id.
@@ -298,8 +427,51 @@ export function moleculeDistReadNote(path) {
298
427
  : ' For one signature, read the matching .d.ts file.'));
299
428
  }
300
429
  /**
301
- * The refusal for a write whose target is inside an installed package, or null
302
- * when the path is not under a `node_modules` directory.
430
+ * The `node_modules` entry a path goes through, and the package name after it
431
+ * (`null` when the path IS the entry).
432
+ *
433
+ * @param path - An absolute path.
434
+ * @returns The entry and the name, or null when no `node_modules` component is in the path.
435
+ */
436
+ function nodeModulesParts(path) {
437
+ // A path may sit in a NESTED node_modules (`app/node_modules/...`) as well as the
438
+ // hoisted root one: both can be installed trees.
439
+ const at = /(?:^|\/)node_modules(?:\/|$)/.exec(path);
440
+ if (!at)
441
+ return null;
442
+ const head = path.slice(0, at.index);
443
+ const entry = `${head}${head === '' || head.endsWith('/') ? '' : '/'}node_modules`;
444
+ const named = /node_modules\/((?:@[^/]+\/)?[^/]+)(?:\/|$)/.exec(path.slice(at.index));
445
+ return { entry, name: named?.[1] ?? null };
446
+ }
447
+ /**
448
+ * Build an {@link InstalledPackageProbe} from a shell runner (a backend's
449
+ * `run`, or a sandbox's `exec`): one command reports whether the entry is a
450
+ * link and whether a `package.json` resolves under it.
451
+ *
452
+ * @param run - Runs a command where the path lives.
453
+ * @returns The probe.
454
+ */
455
+ export function shellInstalledPackageProbe(run) {
456
+ return async (path) => {
457
+ const parts = nodeModulesParts(path);
458
+ if (!parts)
459
+ return { symlink: false, installed: false };
460
+ const { entry, name } = parts;
461
+ // A bare `@scope` is not a package, but removing it takes every package under
462
+ // it: treat the scope directory like the entry itself and look beneath it.
463
+ const scopeOnly = name !== null && /^@[^/]+$/.test(name);
464
+ const install = name !== null && !scopeOnly
465
+ ? `if [ -e ${shellQuote(`${entry}/${name}/package.json`)} ]; then echo installed; fi`
466
+ : `if find ${shellQuote(name === null ? entry : `${entry}/${name}`)} -maxdepth 3 -name package.json -print -quit 2>/dev/null | grep -q .; then echo installed; fi`;
467
+ const out = String((await run(`if [ -L ${shellQuote(entry)} ]; then echo symlink; fi; ${install}`))?.stdout ??
468
+ '');
469
+ return { symlink: out.includes('symlink'), installed: out.includes('installed') };
470
+ };
471
+ }
472
+ /**
473
+ * The refusal for a write whose target is inside an INSTALLED package, or null
474
+ * when the path is not under `node_modules` or nothing is installed there.
303
475
  *
304
476
  * `node_modules` is not the project's code: nothing written there survives the
305
477
  * next install, no deploy carries it, and a sandbox whose dependency tree is
@@ -312,6 +484,19 @@ export function moleculeDistReadNote(path) {
312
484
  * putting the file back. Reading these files stays allowed (that is how the
313
485
  * declarations above are read at all).
314
486
  *
487
+ * What is protected is the INSTALL, not the spelling: the decision comes from
488
+ * {@link InstalledPackageFacts}, never from the path alone (m106 above). Three
489
+ * things are the run's own to remove or replace —
490
+ *
491
+ * - a `node_modules` that is a SYMLINK: `rm`/`unlink` removes the link, never the
492
+ * tree it points at, so a link the run made cannot clobber anything;
493
+ * - anything under the run's own `.mol-scratch/` directory;
494
+ * - a `node_modules` with no `package.json` under it (nothing was ever installed).
495
+ *
496
+ * A path INSIDE the link is still decided by what resolves there: a link into a
497
+ * real tree is refused, because removing or writing through it reaches the
498
+ * install.
499
+ *
315
500
  * The restore route is named because the model that has ALREADY clobbered a file
316
501
  * (this refusal is too late for it) is the one who needs it, and because the
317
502
  * obvious repair — `npm install <pkg>` — is itself refused in the pre-installed
@@ -319,19 +504,34 @@ export function moleculeDistReadNote(path) {
319
504
  * m91 used, and what both guards leave alone.
320
505
  *
321
506
  * @param path - An absolute path a tool is about to write.
507
+ * @param probe - Looks at the filesystem where the path lives.
322
508
  * @returns The one-line refusal naming the package, or null when nothing is installed there.
323
509
  */
324
- export function installedPackageWriteRefusal(path) {
325
- // A path may sit in a NESTED node_modules (`app/node_modules/...`) as well as the
326
- // hoisted root one: both are installed packages.
327
- const at = /(?:^|\/)node_modules(?:\/|$)/.exec(path);
328
- if (!at)
510
+ export async function installedPackageWriteRefusal(path, probe) {
511
+ const parts = nodeModulesParts(path);
512
+ if (!parts)
329
513
  return null;
330
- const named = /(?:^|\/)node_modules\/((?:@[^/]+\/)?[^/]+)(?:\/|$)/.exec(path);
331
- const name = named?.[1] ?? null;
332
- const scoped = at[0].startsWith('/') ? path.slice(0, at.index) : '';
333
- const dir = name ? `${scoped}/node_modules/${name}` : null;
334
- const what = name ? `the installed package ${name}` : 'an installed package';
514
+ const { entry, name } = parts;
515
+ // The run's own scratch directory is the run's to clean: the platform tells it
516
+ // to put throwaway work there (system prompt, "Throwaway probes go in
517
+ // `.mol-scratch/`"), so nothing in it is the pre-installed tree.
518
+ if (/(?:^|\/)\.mol-scratch(?:\/|$)/.test(path))
519
+ return null;
520
+ // No probe, or a probe that could not answer: keep the old, conservative
521
+ // reading (everything under node_modules is an install).
522
+ const facts = probe ? await probe(path).catch(() => null) : null;
523
+ const symlink = facts?.symlink ?? false;
524
+ const installed = facts?.installed ?? true;
525
+ // The entry itself — the link (or a tree with nothing in it) the run may take
526
+ // away. Removing a symlink deletes the LINK only; its target is untouched.
527
+ if (name === null && (symlink || !installed))
528
+ return null;
529
+ // A path under the entry: `installed` resolved through any link, so it is true
530
+ // exactly when a write here would land in a real installation.
531
+ if (name !== null && !installed)
532
+ return null;
533
+ const dir = name ? `${entry}/${name}` : null;
534
+ const what = name ? `the installed package ${name}` : 'an installed node_modules tree';
335
535
  const change = 'Installed packages are read-only: the next install replaces that file and a deploy never ' +
336
536
  'carries it. Change its behaviour from your app instead (its options, your own handler, a ' +
337
537
  'bond), and where the package itself is wrong, report it rather than patching it here. ' +
@@ -488,9 +688,11 @@ export function buildTools(backend, config) {
488
688
  * @param path - Relative or absolute path requested by the tool input.
489
689
  * @returns The normalized path, when writing it is allowed.
490
690
  */
491
- function resolveWrite(path) {
691
+ async function resolveWrite(path) {
492
692
  const resolved = resolve(path);
493
- const refusal = installedPackageWriteRefusal(resolved);
693
+ // The guard decides from what is installed where the path lives, so the
694
+ // backend is asked (one command, only for a path that names node_modules).
695
+ const refusal = await installedPackageWriteRefusal(resolved, shellInstalledPackageProbe((command) => backend.run(command, { timeout: 8000 })));
494
696
  if (refusal)
495
697
  throw new ToolInputError(refusal);
496
698
  return resolved;
@@ -1046,7 +1248,7 @@ export function buildTools(backend, config) {
1046
1248
  const argErr = pathArgError(input.path, 'write_file');
1047
1249
  if (argErr)
1048
1250
  return { error: argErr };
1049
- const path = resolveWrite(input.path);
1251
+ const path = await resolveWrite(input.path);
1050
1252
  const content = input.content;
1051
1253
  if (content.length > MAX_WRITE_SIZE)
1052
1254
  return {
@@ -1095,7 +1297,7 @@ export function buildTools(backend, config) {
1095
1297
  const argErr = pathArgError(input.path, 'edit_file');
1096
1298
  if (argErr)
1097
1299
  return { error: argErr };
1098
- const path = resolveWrite(input.path);
1300
+ const path = await resolveWrite(input.path);
1099
1301
  // Support both formats: { replacements: [...] } (batch) and { old_string, new_string } (single, backwards-compatible)
1100
1302
  let replacements = input.replacements;
1101
1303
  // Accept the single format when BOTH are strings — `typeof` (not truthy) so
@@ -1729,7 +1931,7 @@ export function buildTools(backend, config) {
1729
1931
  }
1730
1932
  },
1731
1933
  async create_directory(input) {
1732
- const path = resolveWrite(input.path);
1934
+ const path = await resolveWrite(input.path);
1733
1935
  const parentPath = path.substring(0, path.lastIndexOf('/')) || root;
1734
1936
  const symlinkErr = await checkSymlink(parentPath);
1735
1937
  if (symlinkErr)
@@ -1747,8 +1949,8 @@ export function buildTools(backend, config) {
1747
1949
  async rename_file(input) {
1748
1950
  // Both ends: moving a package's file OUT is as much a change to the installed
1749
1951
  // package as moving one in.
1750
- const oldPath = resolveWrite(input.old_path);
1751
- const newPath = resolveWrite(input.new_path);
1952
+ const oldPath = await resolveWrite(input.old_path);
1953
+ const newPath = await resolveWrite(input.new_path);
1752
1954
  const oldSymlinkErr = await checkSymlink(oldPath);
1753
1955
  if (oldSymlinkErr)
1754
1956
  return { error: oldSymlinkErr };
@@ -1770,7 +1972,7 @@ export function buildTools(backend, config) {
1770
1972
  }
1771
1973
  },
1772
1974
  async delete_file(input) {
1773
- const path = resolveWrite(input.path);
1975
+ const path = await resolveWrite(input.path);
1774
1976
  const symlinkErr = await checkSymlink(path);
1775
1977
  if (symlinkErr)
1776
1978
  return { error: symlinkErr };
@@ -1820,7 +2022,28 @@ export function buildTools(backend, config) {
1820
2022
  cwd,
1821
2023
  readFile: (path) => backend.readFile(path),
1822
2024
  });
1823
- const command = traced.command;
2025
+ let command = traced.command;
2026
+ // A leading `sleep N; <read of a background task's log>` is a hand-rolled poll, and the
2027
+ // platform knows the task's state better than the log's tail does (see
2028
+ // {@link backgroundTaskState}): X1 xr-molecule-flash-m105 slept 225 s this way, two of the
2029
+ // three on tasks that had already ended. The sleep is dropped and the rest of the command
2030
+ // runs at once; with no background task behind it, the sleep is left exactly as sent — the
2031
+ // tool's own budget is the existing bound.
2032
+ const sleepPeek = leadingSleepPeek(command);
2033
+ const peekedTask = sleepPeek
2034
+ ? await backgroundTaskState(backend, command, sleepPeek.seconds, Math.max(5_000, (commandBudgetMs ?? 290_000) - 10_000))
2035
+ : null;
2036
+ const sleepNote = peekedTask?.note ?? null;
2037
+ if (sleepPeek && peekedTask) {
2038
+ command = sleepPeek.rest;
2039
+ if (!command) {
2040
+ return {
2041
+ status: 'task_state',
2042
+ note: peekedTask.note,
2043
+ ...(peekedTask.slice.trim() ? { outputSoFar: sanitizeOutput(peekedTask.slice) } : {}),
2044
+ };
2045
+ }
2046
+ }
1824
2047
  // Detached: start it, hand back a handle, let the turn continue. A
1825
2048
  // command that outlives the tool's ceiling is otherwise simply lost —
1826
2049
  // measured across six agent runs, ten of them were killed at the
@@ -1937,18 +2160,22 @@ export function buildTools(backend, config) {
1937
2160
  ...(output.length > MAX_OUTPUT_SIZE
1938
2161
  ? { truncated: true, fullOutput: log }
1939
2162
  : {}),
1940
- error: `${cutRunSentence(cut)} ` +
2163
+ error: (sleepNote ? `${sleepNote} ` : '') +
2164
+ `${cutRunSentence(cut)} ` +
1941
2165
  (isSuiteRunCommand(command) ? SUITE_OVER_BUDGET_ADVICE : SPLIT_WORK_ADVICE),
1942
2166
  };
1943
2167
  }
1944
2168
  // The exit file holds the PIPELINE's code: when a stage this rewrite could not
1945
2169
  // prove is output-only still follows the suite runner (`| tee run.log`), that code
1946
- // is the filter's — report none and let the log's tally decide (m101 +25:54).
1947
- const piped = suiteRunPipedAfterRunner(streaming.command);
1948
- const reported = suiteRunExitCode(code, output, piped);
2170
+ // is the filter's — report none and let the log's tally decide (m101 +25:54). The
2171
+ // note says this only when the parse found such a stage (m105 +08:12).
2172
+ const placement = suiteRunPipelinePlacement(streaming.command);
2173
+ const reported = suiteRunExitCode(code, output, placement.piped);
2174
+ const exitNote = suiteExitCodeNote(placement, reported);
1949
2175
  const notes = [
2176
+ ...(sleepNote ? [sleepNote] : []),
1950
2177
  ...streaming.changes,
1951
- ...(reported === null ? [FILTERED_EXIT_NOTE] : []),
2178
+ ...(exitNote === null ? [] : [exitNote]),
1952
2179
  ];
1953
2180
  return {
1954
2181
  stdout,
@@ -1965,22 +2192,25 @@ export function buildTools(backend, config) {
1965
2192
  taskId: id,
1966
2193
  log,
1967
2194
  exitFile,
1968
- note: (overCeiling
1969
- ? declaredSeconds > ceilingSeconds
1970
- ? `This command asks for ${declaredSeconds}s, more than this tool's ${ceilingSeconds}s, and its ` +
1971
- 'output only appears when it ends — so it was started in the background instead. '
1972
- : `A Playwright run can take longer than this tool's ${ceilingSeconds}s, and behind a pipe its ` +
1973
- 'output only appears when it ends — so it was started in the background instead. '
1974
- : '') +
2195
+ ...(peekedTask?.slice.trim() ? { outputSoFar: sanitizeOutput(peekedTask.slice) } : {}),
2196
+ note: (sleepNote ? `${sleepNote} ` : '') +
2197
+ (overCeiling
2198
+ ? declaredSeconds > ceilingSeconds
2199
+ ? `This command asks for ${declaredSeconds}s, more than this tool's ${ceilingSeconds}s, and its ` +
2200
+ 'output only appears when it ends — so it was started in the background instead. '
2201
+ : `A Playwright run can take longer than this tool's ${ceilingSeconds}s, and behind a pipe its ` +
2202
+ 'output only appears when it ends — so it was started in the background instead. '
2203
+ : '') +
1975
2204
  `Started in the background. Its output is written to ${log} and comes back as the ` +
1976
- `stdout of wait_for_task when the command finishes (read_file cannot reach /tmp — ` +
1977
- `to peek mid-run, exec_command: tail ${log}). If you have other work, do it and ` +
1978
- `check back. If you need this result before you can continue, call wait_for_task ` +
1979
- `with this taskId — never sleep and read the log by hand.` +
2205
+ `stdout of wait_for_task when the command finishes (read_file cannot reach /tmp). If ` +
2206
+ `you have other work, do it and check back. If you need this result before you can ` +
2207
+ `continue, call wait_for_task with this taskId: it returns the moment the command ` +
2208
+ `ends, returns early when a failure cause repeats, and reports progress and the ` +
2209
+ `failures so far — never sleep and read the log by hand.` +
1980
2210
  (streaming.changes.length ? ` ${streaming.changes.join(' ')}` : '') +
1981
2211
  (prioritized.note ? ` ${prioritized.note}` : '') +
1982
2212
  (ownFile
1983
- ? ` This command writes its own output to ${ownFile}, so the task log stays empty: peek with exec_command: tail ${ownFile}.`
2213
+ ? ` This command writes its own output to ${ownFile}, so the task log stays empty: wait_for_task reads that file when the command ends and reports its progress while it runs — never sleep and tail it by hand.`
1984
2214
  : ''),
1985
2215
  };
1986
2216
  }
@@ -2063,7 +2293,8 @@ export function buildTools(backend, config) {
2063
2293
  // The facts, so a reader that can also see the suite itself (its own `.hurl` file
2064
2294
  // count) says the same sentence with the total the output alone cannot give.
2065
2295
  cutRun: cut,
2066
- error: `${cutLead} ` +
2296
+ error: (sleepNote ? `${sleepNote} ` : '') +
2297
+ `${cutLead} ` +
2067
2298
  // The tool's own budget stopping a run that printed nothing: keep the sentence that
2068
2299
  // says why the output is empty, since nothing else in the result can say it.
2069
2300
  (cut.stoppedBy === 'tool-budget'
@@ -2078,8 +2309,11 @@ export function buildTools(backend, config) {
2078
2309
  stdout,
2079
2310
  stderr,
2080
2311
  exitCode: result.exitCode,
2081
- ...(ceilingNote || traced.change
2082
- ? { note: [ceilingNote, traced.change].filter(Boolean).join(' ') }
2312
+ ...(peekedTask?.slice.trim() ? { outputSoFar: sanitizeOutput(peekedTask.slice) } : {}),
2313
+ ...(sleepNote || ceilingNote || traced.change
2314
+ ? {
2315
+ note: [sleepNote, ceilingNote, traced.change].filter(Boolean).join(' '),
2316
+ }
2083
2317
  : {}),
2084
2318
  };
2085
2319
  }
@@ -2383,8 +2617,8 @@ export function buildTools(backend, config) {
2383
2617
  status: 'running',
2384
2618
  waitedMs,
2385
2619
  ...sliceField,
2386
- note: `${progress} Call wait_for_task again to keep waiting; peek with exec_command: ` +
2387
- `tail ${log}.${midRun}`,
2620
+ note: `${progress} Call wait_for_task again to keep waiting — it returns the moment the ` +
2621
+ `command ends and reports the failures as they appear.${midRun}`,
2388
2622
  };
2389
2623
  }
2390
2624
  if (waitedBefore + waitedMs >= stuckAfterMs()) {
@@ -2484,10 +2718,13 @@ export function buildTools(backend, config) {
2484
2718
  // Same shaping as a foreground exec result, so the executor reads both
2485
2719
  // the same way. The exit file holds the PIPELINE's code: when a stage this rewrite could not
2486
2720
  // prove is output-only still follows the suite runner (`| tee run.log`), that code is the
2487
- // filter's — report none and let the log's tally decide (m101 +25:54).
2488
- const piped = run ? suiteRunPipedAfterRunner(run.command) : false;
2489
- const reported = suiteRunExitCode(exitCode, output, piped);
2490
- const noCodeNote = reported === null ? FILTERED_EXIT_NOTE : null;
2721
+ // filter's — report none and let the log's tally decide (m101 +25:54). The note says this
2722
+ // only when the parse found such a stage (m105 +08:12).
2723
+ const placement = run
2724
+ ? suiteRunPipelinePlacement(run.command)
2725
+ : { piped: false, unplaced: false };
2726
+ const reported = suiteRunExitCode(exitCode, output, placement.piped);
2727
+ const noCodeNote = suiteExitCodeNote(placement, reported);
2491
2728
  return {
2492
2729
  taskId,
2493
2730
  exitCode: reported,
@@ -1 +1 @@
1
- {"version":3,"file":"trace-artifacts.d.ts","sourceRoot":"","sources":["../src/trace-artifacts.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAMH,iEAAiE;AACjE,MAAM,WAAW,YAAY;IAC3B,gCAAgC;IAChC,MAAM,EAAE,MAAM,CAAA;IACd,iGAAiG;IACjG,IAAI,EAAE,MAAM,CAAA;IACZ,uEAAuE;IACvE,MAAM,EAAE,MAAM,CAAA;CACf;AAED,iGAAiG;AACjG,MAAM,WAAW,aAAa;IAC5B,0FAA0F;IAC1F,IAAI,EAAE,MAAM,GAAG,IAAI,CAAA;IACnB,+EAA+E;IAC/E,IAAI,EAAE,MAAM,CAAA;IACZ,6FAA6F;IAC7F,QAAQ,EAAE,YAAY,EAAE,CAAA;IACxB,0DAA0D;IAC1D,OAAO,EAAE,MAAM,EAAE,CAAA;CAClB;AAED,sDAAsD;AACtD,eAAO,MAAM,mBAAmB,KAAK,CAAA;AAErC,sDAAsD;AACtD,eAAO,MAAM,mBAAmB,IAAI,CAAA;AAEpC,4DAA4D;AAC5D,eAAO,MAAM,kBAAkB,IAAI,CAAA;AAEnC,uDAAuD;AACvD,eAAO,MAAM,oBAAoB,IAAI,CAAA;AAErC,iDAAiD;AACjD,eAAO,MAAM,uBAAuB,IAAI,CAAA;AAExC,gEAAgE;AAChE,eAAO,MAAM,oBAAoB,KAAK,CAAA;AAEtC;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,oBAAoB,QAAO,MA+LvC,CAAA;AAED;;;;;;;;GAQG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,MAAM,GAAG,aAAa,EAAE,CAyBnE;AAiBD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,SAAS,aAAa,EAAE,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CA4B/F;AAaD;;;;;;;;;;;;GAYG;AACH,wBAAgB,kBAAkB,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAG9D;AAED,iFAAiF;AACjF,eAAO,MAAM,oBAAoB,8BAA8B,CAAA;AAuG/D,oGAAoG;AACpG,MAAM,WAAW,qBAAqB;IACpC,0BAA0B;IAC1B,OAAO,EAAE,MAAM,CAAA;IACf,4EAA4E;IAC5E,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;CACtB;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAsB,qBAAqB,CAAC,IAAI,EAAE;IAChD,OAAO,EAAE,MAAM,CAAA;IACf,GAAG,EAAE,MAAM,CAAA;IACX,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAA;CAC5C,GAAG,OAAO,CAAC,qBAAqB,CAAC,CAyBjC"}
1
+ {"version":3,"file":"trace-artifacts.d.ts","sourceRoot":"","sources":["../src/trace-artifacts.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAMH,iEAAiE;AACjE,MAAM,WAAW,YAAY;IAC3B,gCAAgC;IAChC,MAAM,EAAE,MAAM,CAAA;IACd,iGAAiG;IACjG,IAAI,EAAE,MAAM,CAAA;IACZ,uEAAuE;IACvE,MAAM,EAAE,MAAM,CAAA;CACf;AAED,iGAAiG;AACjG,MAAM,WAAW,aAAa;IAC5B,0FAA0F;IAC1F,IAAI,EAAE,MAAM,GAAG,IAAI,CAAA;IACnB,+EAA+E;IAC/E,IAAI,EAAE,MAAM,CAAA;IACZ,6FAA6F;IAC7F,QAAQ,EAAE,YAAY,EAAE,CAAA;IACxB,0DAA0D;IAC1D,OAAO,EAAE,MAAM,EAAE,CAAA;CAClB;AAED,sDAAsD;AACtD,eAAO,MAAM,mBAAmB,KAAK,CAAA;AAErC,sDAAsD;AACtD,eAAO,MAAM,mBAAmB,IAAI,CAAA;AAEpC,4DAA4D;AAC5D,eAAO,MAAM,kBAAkB,IAAI,CAAA;AAEnC,uDAAuD;AACvD,eAAO,MAAM,oBAAoB,IAAI,CAAA;AAErC,iDAAiD;AACjD,eAAO,MAAM,uBAAuB,IAAI,CAAA;AAExC,gEAAgE;AAChE,eAAO,MAAM,oBAAoB,KAAK,CAAA;AAEtC;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,oBAAoB,QAAO,MA+LvC,CAAA;AAED;;;;;;;;GAQG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,MAAM,GAAG,aAAa,EAAE,CAyBnE;AAiBD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,SAAS,aAAa,EAAE,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CA4B/F;AAaD;;;;;;;;;;;;GAYG;AACH,wBAAgB,kBAAkB,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAG9D;AAED,iFAAiF;AACjF,eAAO,MAAM,oBAAoB,8BAA8B,CAAA;AA2G/D,oGAAoG;AACpG,MAAM,WAAW,qBAAqB;IACpC,0BAA0B;IAC1B,OAAO,EAAE,MAAM,CAAA;IACf,4EAA4E;IAC5E,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;CACtB;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAsB,qBAAqB,CAAC,IAAI,EAAE;IAChD,OAAO,EAAE,MAAM,CAAA;IACf,GAAG,EAAE,MAAM,CAAA;IACX,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAA;CAC5C,GAAG,OAAO,CAAC,qBAAqB,CAAC,CAyBjC"}