@molecule/api-ai-tools 1.5.15 → 1.5.17

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/README.md CHANGED
@@ -3,7 +3,7 @@ AUTO-GENERATED — DO NOT EDIT THIS FILE.
3
3
  Generated by `mlcl sync-docs` from the package's src/index.ts JSDoc + mlcl/registry.json.
4
4
  Edits here are overwritten on the next commit (molecule's pre-commit hook regenerates).
5
5
  To change this document, edit the module-level JSDoc in src/index.ts.
6
- Generated: 2026-10-16T05:16:44.196Z
6
+ Generated: 2026-10-16T09:30:45.054Z
7
7
  -->
8
8
 
9
9
  # @molecule/api-ai-tools
@@ -488,6 +488,24 @@ function buildAgentPrompt(ctx: PromptContext): string
488
488
 
489
489
  **Returns:** Fully assembled system prompt text for the coding agent.
490
490
 
491
+ #### `moleculeDistReadNote(path)`
492
+
493
+ The steer attached to a SUCCESSFUL read of a package's built output. The ENOENT
494
+ redirect below fires only when the read FAILS, so a model that knows the hoisted
495
+ layout reads `node_modules/@molecule/<pkg>/dist/*.js` happily and never learns the
496
+ documented API is one call away (X0 flash-m70: 24 such reads in 57 s, no note on
497
+ any of them). Carried as `distNote` rather than merged into `note` so a host that
498
+ has read_molecule_doc (molecule-dev's cadence note, which also names the functions)
499
+ can supersede it without string-matching.
500
+
501
+ ```typescript
502
+ function moleculeDistReadNote(path: string): string | null
503
+ ```
504
+
505
+ - `path` — The resolved path that was read.
506
+
507
+ **Returns:** The note, or null when the path is not inside a package's `dist`.
508
+
491
509
  #### `buildTools(backend, config)`
492
510
 
493
511
  Build a complete set of AI agent tools bound to an execution backend.
@@ -572,6 +590,22 @@ function redactSecretsInCode(s: string): string
572
590
 
573
591
  **Returns:** A redacted copy that preserves ordinary code verbatim.
574
592
 
593
+ #### `printsOnlySource(command)`
594
+
595
+ Whether a shell command only PRINTS source files (`cat a.tsx`, `sed -n '1,40p' x.ts`,
596
+ `head -50 b.hurl`): its output is file content and gets the code-safe redaction, like
597
+ read_file. Through the env-dump grade a printed `type AuthState = 'authenticated' | …`
598
+ came back as `'[REDACTED]'`, and the executor wrote the mask into a new file (X1
599
+ flash-m58). Any segment that is not such a print, or any env file named, keeps the full grade.
600
+
601
+ ```typescript
602
+ function printsOnlySource(command: string): boolean
603
+ ```
604
+
605
+ - `command` — The shell command.
606
+
607
+ **Returns:** True when every segment prints source files only.
608
+
575
609
  #### `isEnvFilePath(path)`
576
610
 
577
611
  Whether a path is an env file, for which {@link redactSecrets}' full env-dump
@@ -646,9 +680,16 @@ killed (X1 run flash-m15 lost 20 of its 60 minutes this way, 2026-10-15):
646
680
  - a trailing `| tail -N`, which holds everything back until the command ends.
647
681
  It is dropped: `wait_for_task` already returns the end of the log.
648
682
  - `playwright test` with no `--reporter`, which runs the config's reporter —
649
- often `html`, which prints nothing per test. `--reporter=line` is added
683
+ often `html`, which prints nothing per test. `--reporter=list` is added
650
684
  when the playwright run is the command's last step and nothing is piped or
651
- redirected from it ( aside); the results are the same, printed as they happen.
685
+ redirected from it: it writes each result as it finishes, and it is the
686
+ shape the platform reads a background suite back in (`suite-fail-fast.ts`'s
687
+ fail-fast note and the Tests bar both parse `list` lines). A run that names
688
+ `--reporter=line` is switched to `list` for the same reason — `line` writes
689
+ `[n/N] <title>` with no ✓/✘ mark and no duration, so on X1 flash-m62 an
690
+ 8.3-minute walk of one suite could never trip the fail-fast note.
691
+
692
+ Only the command's last step is touched, and only its reporter.
652
693
 
653
694
  ```typescript
654
695
  function streamingBackgroundCommand(command: string): { command: string; changes: string[] }
@@ -827,6 +868,25 @@ function failureBlocks(s: string): string[]
827
868
 
828
869
  **Returns:** Each block, in order (empty when there are none).
829
870
 
871
+ #### `runningOutputSliceCommand(log, own)`
872
+
873
+ The shell command whose stdout is the interesting part of a log that is still being written:
874
+ the failure lines so far (capped at {@link RUNNING_FAILURE_LINES}), then the end of the log.
875
+
876
+ A Playwright `list` run prints one line per test as it finishes and its error blocks only at
877
+ the end, so the LAST bytes of a running log are its least informative part — in X1 flash-m61
878
+ the last 3 000 bytes of a 139-test walk were the skip list, and all 61 failures were above
879
+ them, which cost the run the grep it had to make anyway.
880
+
881
+ ```typescript
882
+ function runningOutputSliceCommand(log: string, own?: string): string
883
+ ```
884
+
885
+ - `log` — The task log path.
886
+ - `own` — A file the command redirects its own output to, if any.
887
+
888
+ **Returns:** The shell command, safe to hand to the execution backend as-is.
889
+
830
890
  #### `whitespaceTolerantReplace(content, oldString, newString)`
831
891
 
832
892
  Attempt a whitespace-tolerant replacement when an exact `old_string` match
@@ -985,7 +1045,9 @@ const MAX_READ_RETURN_CHARS: number
985
1045
 
986
1046
  #### `SMALL_FILE_WHOLE_CHARS`
987
1047
 
988
- A file this small is returned whole even when a window is asked for (paging it costs a call each).
1048
+ A file this small is returned whole on a second window of it (paging it costs a call each).
1049
+ The same bound as {@link MAX_READ_RETURN_CHARS}: the windowed-read note promises that a
1050
+ file under it "fits in ONE call", and a smaller bound here broke that promise (X1 flash-m67).
989
1051
 
990
1052
  ```typescript
991
1053
  const SMALL_FILE_WHOLE_CHARS: number
@@ -1031,6 +1093,31 @@ const DEFAULT_SEARCH_EXCLUDED_DIRS: readonly [
1031
1093
  ]
1032
1094
  ```
1033
1095
 
1096
+ #### `RUNNING_FAILURE_LINES`
1097
+
1098
+ Failure lines shown from a log that is still being written.
1099
+
1100
+ ```typescript
1101
+ const RUNNING_FAILURE_LINES: 40
1102
+ ```
1103
+
1104
+ #### `RUNNING_TAIL_BYTES`
1105
+
1106
+ Bytes of a running log's end appended after its failure lines.
1107
+
1108
+ ```typescript
1109
+ const RUNNING_TAIL_BYTES: 1000
1110
+ ```
1111
+
1112
+ #### `RUNNING_FAILURE_LINE_RE`
1113
+
1114
+ The lines worth showing from a log that is still being written: a failed test's mark or
1115
+ numbered block header, and a runner's own error lines. Matched with `grep -E`.
1116
+
1117
+ ```typescript
1118
+ const RUNNING_FAILURE_LINE_RE: '^[[:space:]]*(✘|✗|×|✖|✕|[0-9]+\\)|[0-9]+ (passed|failed|flaky|skipped|did not run)|FAIL|FAILED|ERROR|Error:|error:|Traceback|AssertionError)'
1119
+ ```
1120
+
1034
1121
  #### `PARSE_CHECKABLE`
1035
1122
 
1036
1123
  Files a parse check can vouch for cheaply right after a write.
package/dist/tools.d.ts CHANGED
@@ -8,6 +8,19 @@
8
8
  */
9
9
  import type { AITool } from '@molecule/api-ai';
10
10
  import type { ExecutionBackend, ToolBuildConfig } from './types.js';
11
+ /**
12
+ * The steer attached to a SUCCESSFUL read of a package's built output. The ENOENT
13
+ * redirect below fires only when the read FAILS, so a model that knows the hoisted
14
+ * layout reads `node_modules/@molecule/<pkg>/dist/*.js` happily and never learns the
15
+ * documented API is one call away (X0 flash-m70: 24 such reads in 57 s, no note on
16
+ * any of them). Carried as `distNote` rather than merged into `note` so a host that
17
+ * has read_molecule_doc (molecule-dev's cadence note, which also names the functions)
18
+ * can supersede it without string-matching.
19
+ *
20
+ * @param path - The resolved path that was read.
21
+ * @returns The note, or null when the path is not inside a package's `dist`.
22
+ */
23
+ export declare function moleculeDistReadNote(path: string): string | null;
11
24
  /**
12
25
  * Build a complete set of AI agent tools bound to an execution backend.
13
26
  *
@@ -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;AA8B9C,OAAO,KAAK,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA;AA0CnE;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,gBAAgB,EAAE,MAAM,CAAC,EAAE,eAAe,GAAG,MAAM,EAAE,CAooDxF"}
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;AA0H9C,OAAO,KAAK,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA;AA6CnE;;;;;;;;;;;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,CAutDxF"}
package/dist/tools.js CHANGED
@@ -25,12 +25,118 @@ 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
- import { checkBlockedCommand, declaredBudgetSeconds, DEFAULT_SEARCH_EXCLUDED_DIRS, directoryReadHint, fusedLine, isEnvFilePath, isOutsideRoot, isValidGlob, MAX_BATCH_READ_BYTES, MAX_BATCH_READ_FILES, MAX_FIND_RESULTS, MAX_OUTPUT_SIZE, MAX_READ_RETURN_CHARS, MAX_READ_SIZE, MAX_SEARCH_CONTEXT_LINES, MAX_SEARCH_RESULTS, MAX_WRITE_SIZE, outputIsWithheldUntilExit, outputRedirectTarget, parseCheckCommand, parseCheckProblem, pathArgError, redactSecrets, redactSecretsInCode, resolvePath, shellQuote, sliceLines, SMALL_FILE_WHOLE_CHARS, streamingBackgroundCommand, stripControlChars, truncateMiddle, whitespaceTolerantReplace, } from './utilities.js';
28
+ /**
29
+ * Task ids whose own-output file has already been looked for this process.
30
+ * {@link outputFileByTask} is empty for every task after a restart — it is
31
+ * process memory and the task is not — so without this the recovery below
32
+ * would re-read the task's script on every single wait.
33
+ */
34
+ const outputFileProbed = new Set();
35
+ /** A millisecond span the way a model reads it: seconds under 90, else minutes. */
36
+ function durationText(ms) {
37
+ const s = Math.max(0, Math.round(ms / 1000));
38
+ const mins = Math.floor(s / 60);
39
+ return s < 90 ? `${s} s` : s % 60 ? `${mins} min ${s % 60} s` : `${mins} min`;
40
+ }
41
+ /**
42
+ * When a background command started, read from its own id: the handle is minted
43
+ * as `mol-bg-${Date.now().toString(36)}-…`, so the start stamp is in the id and
44
+ * no new state has to be kept to have it.
45
+ *
46
+ * @param taskId - The handle a run_in_background command returned.
47
+ * @returns Epoch ms, or null when the id carries no usable stamp (a hand-written
48
+ * or synthetic id) — a wrong number in a note is worse than none.
49
+ */
50
+ function taskStartedAt(taskId) {
51
+ const stamp = Number.parseInt(taskId.split('-')[2] ?? '', 36);
52
+ const now = Date.now();
53
+ return Number.isFinite(stamp) && stamp > now - 24 * 3600_000 && stamp < now + 60_000
54
+ ? stamp
55
+ : null;
56
+ }
57
+ /**
58
+ * The progress a run reports in its own log: the last `[done/total]` counter a
59
+ * Playwright reporter writes (`[86/139] [chromium] › e2e/settings.spec.ts:…`).
60
+ *
61
+ * @param log - The log slice this wait read.
62
+ * @returns The counter, or null when the log carries none.
63
+ */
64
+ function logProgress(log) {
65
+ const last = [...log.matchAll(/\[(\d+)\/(\d+)\]/g)].at(-1);
66
+ const done = Number(last?.[1]);
67
+ const total = Number(last?.[2]);
68
+ return last && total > 0 && done >= 0 && done <= total ? { done, total } : null;
69
+ }
70
+ /**
71
+ * What a wait knows about the command BESIDES "it has not exited": how long it
72
+ * has been running, whether its output is still growing, how far its own log
73
+ * says it has got, and how much longer that implies.
74
+ *
75
+ * X1 flash-m62 (2026-10-16) spent three 290 s waits on one 8.3-minute suite and
76
+ * every answer read only "Still running after 280s": the run could not tell a
77
+ * suite with 30 s left from one with 6 minutes left, so the wait cadence — not
78
+ * the suite — set the pace, and the run learned nothing for its 14 minutes of
79
+ * waiting. The elapsed time comes from the task id, the progress from the log
80
+ * this wait already read, and the time left is the rate so far, said as such.
81
+ *
82
+ * @param o - The wait's facts: `taskId`, the `slice` of the log it read,
83
+ * `stillWriting` (the log grew during this wait), `bytes` (the log's size, null
84
+ * when unreadable), and `ceilingMs` (this call's ceiling, for "one more wait").
85
+ * @returns A sentence to append to a running note, or '' when nothing is known.
86
+ */
87
+ function waitProgressNote(o) {
88
+ const startedAt = taskStartedAt(o.taskId);
89
+ const elapsedMs = startedAt === null ? null : Math.max(0, Date.now() - startedAt);
90
+ const parts = [];
91
+ if (elapsedMs !== null)
92
+ parts.push(`it has been running ${durationText(elapsedMs)}`);
93
+ if (o.stillWriting)
94
+ parts.push(`its output is still growing (${o.bytes} bytes) — slow, not stuck`);
95
+ else if (o.bytes !== null)
96
+ parts.push(`its log has not grown during this wait (${o.bytes} bytes)`);
97
+ const counter = logProgress(o.slice);
98
+ if (counter) {
99
+ parts.push(`its log is at ${counter.done} of ${counter.total}`);
100
+ if (elapsedMs !== null && counter.done > 0 && counter.done < counter.total) {
101
+ const leftMs = Math.round((elapsedMs * (counter.total - counter.done)) / counter.done);
102
+ parts.push(leftMs <= o.ceilingMs
103
+ ? `about ${durationText(leftMs)} left at that rate — one more wait covers it`
104
+ : `about ${durationText(leftMs)} left at that rate (${Math.ceil(leftMs / o.ceilingMs)} more waits)`);
105
+ }
106
+ }
107
+ if (!parts.length)
108
+ return '';
109
+ const sentence = parts.join('; ');
110
+ return ` ${sentence[0].toUpperCase()}${sentence.slice(1)}.`;
111
+ }
112
+ import { checkBlockedCommand, declaredBudgetSeconds, DEFAULT_SEARCH_EXCLUDED_DIRS, directoryReadHint, fusedLine, isEnvFilePath, isOutsideRoot, isValidGlob, MAX_BATCH_READ_BYTES, MAX_BATCH_READ_FILES, MAX_FIND_RESULTS, MAX_OUTPUT_SIZE, MAX_READ_RETURN_CHARS, MAX_READ_SIZE, MAX_SEARCH_CONTEXT_LINES, MAX_SEARCH_RESULTS, MAX_WRITE_SIZE, outputIsWithheldUntilExit, outputRedirectTarget, parseCheckCommand, parseCheckProblem, pathArgError, printsOnlySource, redactSecrets, redactSecretsInCode, resolvePath, RUNNING_FAILURE_LINES, runningOutputSliceCommand, shellQuote, sliceLines, SMALL_FILE_WHOLE_CHARS, streamingBackgroundCommand, stripControlChars, truncateMiddle, whitespaceTolerantReplace, } from './utilities.js';
29
113
  /**
30
114
  * How long exec_command waits for a command it backgrounded only because the
31
115
  * command asked for more than the tool's ceiling, before handing back a task id.
32
116
  */
33
117
  const OVER_CEILING_GRACE_MS = 45_000;
118
+ /**
119
+ * The steer attached to a SUCCESSFUL read of a package's built output. The ENOENT
120
+ * redirect below fires only when the read FAILS, so a model that knows the hoisted
121
+ * layout reads `node_modules/@molecule/<pkg>/dist/*.js` happily and never learns the
122
+ * documented API is one call away (X0 flash-m70: 24 such reads in 57 s, no note on
123
+ * any of them). Carried as `distNote` rather than merged into `note` so a host that
124
+ * has read_molecule_doc (molecule-dev's cadence note, which also names the functions)
125
+ * can supersede it without string-matching.
126
+ *
127
+ * @param path - The resolved path that was read.
128
+ * @returns The note, or null when the path is not inside a package's `dist`.
129
+ */
130
+ export function moleculeDistReadNote(path) {
131
+ const pkg = /node_modules\/@molecule\/([a-z0-9-]+)\/dist\//.exec(path)?.[1];
132
+ if (!pkg)
133
+ return null;
134
+ return (`This is @molecule/${pkg}'s compiled dist, not its API: read_molecule_doc("@molecule/${pkg}") ` +
135
+ 'returns its README.md — the functions, options and bond wiring — in one call.' +
136
+ (/\.d\.ts$/.test(path)
137
+ ? ' The declarations above are generated from it.'
138
+ : ' For one signature, read the matching .d.ts file.'));
139
+ }
34
140
  /**
35
141
  * Build a complete set of AI agent tools bound to an execution backend.
36
142
  *
@@ -135,6 +241,8 @@ export function buildTools(backend, config) {
135
241
  }
136
242
  /** Paths read with a window in this tool set, to tell paging from one targeted read. */
137
243
  const windowedReads = new Set();
244
+ /** The text each path was last handed back WHOLE as, so that correction fires once per version. */
245
+ const wholeReturned = new Map();
138
246
  /**
139
247
  * Verify that `path` does not symlink outside the project root when guards are enabled.
140
248
  *
@@ -365,6 +473,10 @@ export function buildTools(backend, config) {
365
473
  const symlinkErr = await checkSymlink(path);
366
474
  if (symlinkErr)
367
475
  return { error: symlinkErr };
476
+ // Carried on every successful read of a package's built output, so no dist read
477
+ // goes out unsteered (see moleculeDistReadNote).
478
+ const distNote = moleculeDistReadNote(path);
479
+ const noted = (r) => distNote ? { ...r, distNote } : r;
368
480
  try {
369
481
  const content = await backend.readFile(path);
370
482
  // A read that produced no usable text is verified against the file's real
@@ -376,12 +488,12 @@ export function buildTools(backend, config) {
376
488
  const verdict = await classifyEmptyRead(path, 'read_file');
377
489
  if ('error' in verdict)
378
490
  return verdict;
379
- return {
491
+ return noted({
380
492
  path,
381
493
  content: sanitizeFileContent(verdict.content, path),
382
494
  ...(verdict.content === '' ? { empty: true } : {}),
383
495
  ...(verdict.note ? { note: verdict.note } : {}),
384
- };
496
+ });
385
497
  }
386
498
  if (content.length > MAX_READ_SIZE)
387
499
  return {
@@ -395,21 +507,32 @@ export function buildTools(backend, config) {
395
507
  : window;
396
508
  // A SECOND window of the same small file: hand back all of it (one window is a targeted read). X1 flash-m50 paged files that fit in one
397
509
  // call 13 times in a row, each window answered with "read it again with no offset/limit".
510
+ // The bound is the one that note states (SMALL_FILE_WHOLE_CHARS = MAX_READ_RETURN_CHARS): 24 KB left
511
+ // RealWorld's 24,935-byte openapi.yml paged nine times under "fits in ONE call" (flash-m67).
398
512
  const small = sliceLines(content, window);
399
513
  const pagedBefore = small?.truncated ? windowedReads.has(path) : false;
400
514
  if (small?.truncated)
401
515
  windowedReads.add(path);
402
- if (small?.truncated && pagedBefore && content.length <= SMALL_FILE_WHOLE_CHARS) {
403
- return {
516
+ // Once per version of the file: after the whole text went back, a later window is a
517
+ // targeted read and gets its window (X1 flash-m67: 25 firings, 8 byte-identical repeats).
518
+ const sentWhole = wholeReturned.get(path) === content;
519
+ if (small?.truncated &&
520
+ pagedBefore &&
521
+ !sentWhole &&
522
+ content.length <= SMALL_FILE_WHOLE_CHARS) {
523
+ wholeReturned.set(path, content);
524
+ return noted({
404
525
  path,
405
526
  content: sanitizeFileContent(content, path),
527
+ offset: 1,
528
+ lines: small.totalLines,
406
529
  totalLines: small.totalLines,
407
530
  note: `The whole file is returned (${Math.round(content.length / 1024)} KB, ${small.totalLines} lines): a file this small fits in one call, so it is not paged.`,
408
- };
531
+ });
409
532
  }
410
533
  const windowed = sliceLines(content, effectiveWindow);
411
534
  if (windowed) {
412
- return {
535
+ return noted({
413
536
  path,
414
537
  content: sanitizeFileContent(windowed.text, path),
415
538
  offset: windowed.offset,
@@ -422,18 +545,21 @@ export function buildTools(backend, config) {
422
545
  // exact next window so the executor does not have to compute it.
423
546
  ...(windowed.truncated
424
547
  ? {
425
- note: content.length <= MAX_READ_RETURN_CHARS
426
- ? `Lines ${windowed.offset}-${windowed.offset + windowed.lines - 1} of ` +
427
- `${windowed.totalLines}. The whole file is ${Math.round(content.length / 1024)} KB ` +
428
- `and fits in ONE call — read it again with no offset/limit instead of paging.`
429
- : `Lines ${windowed.offset}-${windowed.offset + windowed.lines - 1} of ` +
430
- `${windowed.totalLines}. Next window: offset ${windowed.offset + windowed.lines}, ` +
431
- `limit ${windowed.lines}.`,
548
+ note: sentWhole
549
+ ? `Lines ${windowed.offset}-${windowed.offset + windowed.lines - 1} of ${windowed.totalLines}; ` +
550
+ 'the whole file, unchanged, was returned earlier in this conversation.'
551
+ : content.length <= MAX_READ_RETURN_CHARS
552
+ ? `Lines ${windowed.offset}-${windowed.offset + windowed.lines - 1} of ` +
553
+ `${windowed.totalLines}. The whole file is ${Math.round(content.length / 1024)} KB ` +
554
+ `and fits in ONE call — read it again with no offset/limit instead of paging.`
555
+ : `Lines ${windowed.offset}-${windowed.offset + windowed.lines - 1} of ` +
556
+ `${windowed.totalLines}. Next window: offset ${windowed.offset + windowed.lines}, ` +
557
+ `limit ${windowed.lines}.`,
432
558
  }
433
559
  : {}),
434
- };
560
+ });
435
561
  }
436
- return { path, content: sanitizeFileContent(content, path) };
562
+ return noted({ path, content: sanitizeFileContent(content, path) });
437
563
  }
438
564
  catch (e) {
439
565
  // Backends (e.g. the docker provider's `cat`) already prefix "Failed to read <path>: ";
@@ -921,7 +1047,15 @@ export function buildTools(backend, config) {
921
1047
  ...(fusedNote
922
1048
  ? { syntaxError: fusedNote }
923
1049
  : {
924
- ...(dropped.length > 0
1050
+ // The evidence for this note is `located`, not `dropped`: a drop joins
1051
+ // two lines only when something other than a newline (or EOF) follows
1052
+ // the replaced text. A weaker model writes one line's change as two
1053
+ // replacements — the line including its trailing newline, then the
1054
+ // line's own text — and the newline it consumed ended that line with a
1055
+ // BLANK line after it, so nothing joined while the model was told it
1056
+ // had: it spent a new round trip putting the blank line back
1057
+ // (flash-m62). Say nothing when nothing was joined.
1058
+ ...(dropped.length > 0 && located.length > 0
925
1059
  ? {
926
1060
  newlineNote: `${dropped.length === 1 ? 'A replacement' : `${dropped.length} replacements`} removed the line break after the replaced text (old_string ended with a newline, new_string does not), so the next line now continues on the same line. Check the excerpt; add the newline back if that was not intended.`,
927
1061
  }
@@ -966,7 +1100,9 @@ export function buildTools(backend, config) {
966
1100
  // search found nothing (X1 run sol-m07). A pattern that is not a valid
967
1101
  // extended regex (`foo(` — an unbalanced paren) makes grep exit 2; it is
968
1102
  // then searched as the plain text it was meant as.
969
- const grepArgs = `-rn ${globArg} ${contextArg} ${grepExcludeArgs(path)} --max-count=${MAX_SEARCH_RESULTS} -- ${shellQuote(pattern)} ${shellQuote(path)}`;
1103
+ // -H: a search scoped to one file printed `line:content` with no file, and every match
1104
+ // came back `{file: "", line: 0}` (X1 flash-m62).
1105
+ const grepArgs = `-rnH ${globArg} ${contextArg} ${grepExcludeArgs(path)} --max-count=${MAX_SEARCH_RESULTS} -- ${shellQuote(pattern)} ${shellQuote(path)}`;
970
1106
  const result = await backend.run(`{ grep -E ${grepArgs}; s=$?; if [ "$s" -eq 2 ]; then grep -F ${grepArgs}; fi; } 2>/dev/null || true`, { timeout: 10000 });
971
1107
  // grep emits `<file>:<line>:<content>`, so redaction runs PER MATCH on the
972
1108
  // content alone: the file prefix would otherwise hide an env assignment from
@@ -1268,7 +1404,12 @@ export function buildTools(backend, config) {
1268
1404
  // truncateMiddle (not truncate): a failing build/test/migration puts its
1269
1405
  // error at the TAIL, so keep the head AND the tail — head-only truncation
1270
1406
  // strands the executor with passing progress and no failure reason.
1271
- const stdout = sanitizeOutput(truncateMiddle(result.stdout, MAX_OUTPUT_SIZE));
1407
+ // A command that only prints source files returns file content: the code-safe grade,
1408
+ // as read_file uses (utilities.ts printsOnlySource).
1409
+ const grade = printsOnlySource(command)
1410
+ ? (s) => sanitizeFileContent(s, 'printed.ts')
1411
+ : sanitizeOutput;
1412
+ const stdout = grade(truncateMiddle(result.stdout, MAX_OUTPUT_SIZE));
1272
1413
  const stderr = sanitizeOutput(truncateMiddle(result.stderr, MAX_OUTPUT_SIZE));
1273
1414
  if (budgetSeconds && result.exitCode === 124) {
1274
1415
  return {
@@ -1351,6 +1492,33 @@ export function buildTools(backend, config) {
1351
1492
  // server-side costs no round trip and no context.
1352
1493
  async wait_for_task(input) {
1353
1494
  const taskId = typeof input.taskId === 'string' ? input.taskId.trim() : '';
1495
+ // Where a task writes its own output is process memory; the task is not.
1496
+ // An API process swapped mid-run (a release, a patch, a crash) leaves the
1497
+ // command running in the sandbox with nothing left that remembers the file
1498
+ // it redirects to — and then `wait_for_task` returns the task's near-empty
1499
+ // log as the command's whole result, which is the X1 flash-m48 loss all
1500
+ // over again (X1 flash-m63's +43:20 restart: the public walk's handle
1501
+ // outlived the process, its redirect target did not). The script the task
1502
+ // was started from is still on disk and carries the command, so read the
1503
+ // target back from there. The id is format-checked before any /tmp path is
1504
+ // built from it, exactly as the reads below are.
1505
+ if (!outputFileByTask.has(taskId) &&
1506
+ !outputFileProbed.has(taskId) &&
1507
+ /^mol-bg-[a-z0-9]+-[a-z0-9]+$/.test(taskId)) {
1508
+ outputFileProbed.add(taskId);
1509
+ try {
1510
+ const target = outputRedirectTarget(await backend.readFile(`/tmp/${taskId}.sh`));
1511
+ // A relative target was written against the command's own cwd, which
1512
+ // the handle does not carry; the project root is where a background
1513
+ // command runs unless the caller passed one.
1514
+ if (target)
1515
+ outputFileByTask.set(taskId, target.startsWith('/') ? target : `${root}/${target}`);
1516
+ }
1517
+ catch (_error) {
1518
+ // No script under that id: this process did not start the task, so
1519
+ // there is nothing to recover. The log stays the whole result.
1520
+ }
1521
+ }
1354
1522
  // A command that has been waited on for stuckAfterMs() in total without
1355
1523
  // exiting is not going to: on one run the executor waited five times,
1356
1524
  // 120 s each, on an e2e suite that could never start (no browser page
@@ -1432,16 +1600,44 @@ export function buildTools(backend, config) {
1432
1600
  const lastSize = logBytesByTask.get(taskId);
1433
1601
  if (grownTo !== null)
1434
1602
  logBytesByTask.set(taskId, grownTo);
1603
+ // Evidence of liveness, read the same way by every answer below: a log
1604
+ // that grew during THIS wait means the command is working. On the first
1605
+ // wait any output at all counts (there is no earlier size to compare).
1435
1606
  const stillWriting = grownTo !== null && (lastSize === undefined ? grownTo > 0 : grownTo > lastSize);
1607
+ // The log's own slice, read ONCE for whichever answer follows: it is what
1608
+ // the progress sentence is built from, and a wait that showed nothing was
1609
+ // waited blind (X1 flash-m50: 4m42s across two waits on an 11-minute run).
1610
+ // The slice leads with the failure lines, not the last bytes — a suite's
1611
+ // tail is its progress lines (X1 flash-m61: the last 3 000 bytes of a
1612
+ // 139-test walk were the skip list).
1613
+ const own = outputFileByTask.get(taskId);
1614
+ let soFar = '';
1615
+ try {
1616
+ const r = await backend.run(runningOutputSliceCommand(log, own), { timeout: 5_000 });
1617
+ soFar = r.stdout ?? '';
1618
+ }
1619
+ catch (_error) {
1620
+ // No slice: the notes still say how to peek, and the task id still
1621
+ // carries how long the command has been running.
1622
+ }
1623
+ const progress = waitProgressNote({
1624
+ taskId,
1625
+ slice: soFar,
1626
+ stillWriting,
1627
+ bytes: grownTo,
1628
+ ceilingMs: ceiling,
1629
+ });
1630
+ const sliceField = soFar.trim() ? { outputSoFar: sanitizeOutput(soFar) } : {};
1436
1631
  if (stillWriting && waitedBefore + waitedMs >= stuckAfterMs()) {
1437
1632
  waitedByTask.set(taskId, 0);
1438
1633
  return {
1439
1634
  taskId,
1440
1635
  status: 'running',
1441
1636
  waitedMs,
1637
+ ...sliceField,
1442
1638
  note: `Still running after ${Math.round((waitedBefore + waitedMs) / 1000)}s of waiting, and its ` +
1443
- `output is still growing (${grownTo} bytes) — slow, not stuck. Call wait_for_task again to ` +
1444
- `keep waiting; peek with exec_command: tail ${log}.`,
1639
+ `output is still growing (${grownTo} bytes) — slow, not stuck.${progress} Call wait_for_task ` +
1640
+ `again to keep waiting; peek with exec_command: tail ${log}.`,
1445
1641
  };
1446
1642
  }
1447
1643
  if (waitedBefore + waitedMs >= stuckAfterMs()) {
@@ -1455,26 +1651,19 @@ export function buildTools(backend, config) {
1455
1651
  `kill $(cat /tmp/${taskId}.pid) 2>/dev/null; pkill -f ${taskId}.sh) and move on; do not wait on it again.`,
1456
1652
  };
1457
1653
  }
1458
- // The output so far rides the result: a wait that shows nothing was waited blind
1459
- // (X1 flash-m50: 4m42s across two waits on an 11-minute run).
1460
- const own = outputFileByTask.get(taskId);
1461
- let soFar;
1462
- try {
1463
- const r = await backend.run(`cat ${log}${own ? ` ${shellQuote(own)}` : ''} 2>/dev/null | tail -c 3000`, { timeout: 5_000 });
1464
- soFar = r.stdout ?? '';
1465
- }
1466
- catch (_error) {
1467
- // No tail: the note still says how to peek.
1468
- soFar = '';
1469
- }
1470
1654
  return {
1471
1655
  taskId,
1472
1656
  status: 'running',
1473
1657
  waitedMs,
1474
- ...(soFar.trim() ? { outputSoFar: sanitizeOutput(soFar) } : {}),
1475
- note: `Still running after ${Math.round(waitedMs / 1000)}s. Call wait_for_task again to ` +
1476
- `keep waiting — its result carries the log output${soFar.trim() ? '; outputSoFar is the end of the log as it stands, so there is no need to tail it' : ''}. ` +
1477
- `Do other work in the meantime (another suite, a fix you have already found) rather than waiting.`,
1658
+ ...sliceField,
1659
+ note: `Still running after ${Math.round(waitedMs / 1000)}s.${progress} Call wait_for_task again to ` +
1660
+ `keep waiting — its result carries the log output. ` +
1661
+ (soFar.trim()
1662
+ ? `outputSoFar is the failure lines so far (up to ${RUNNING_FAILURE_LINES}) and then the end of ` +
1663
+ `the log, not the whole run — \`grep -n '✘' ${log} | head -40\` lists every failure, ` +
1664
+ `\`grep -n -A 20 '<test name>' ${log}\` reads one failure's error. `
1665
+ : `Nothing has been printed yet — \`grep -n '✘' ${log} | head -40\` lists failures as they appear. `) +
1666
+ `Meanwhile do work that leaves the code under test alone (read the failures so far, plan the fixes): editing app/src or api/src now changes the code the run is testing, and its result no longer counts.`,
1478
1667
  };
1479
1668
  }
1480
1669
  let output;
@@ -55,6 +55,17 @@ export declare function redactSecrets(s: string): string;
55
55
  * @returns A redacted copy that preserves ordinary code verbatim.
56
56
  */
57
57
  export declare function redactSecretsInCode(s: string): string;
58
+ /**
59
+ * Whether a shell command only PRINTS source files (`cat a.tsx`, `sed -n '1,40p' x.ts`,
60
+ * `head -50 b.hurl`): its output is file content and gets the code-safe redaction, like
61
+ * read_file. Through the env-dump grade a printed `type AuthState = 'authenticated' | …`
62
+ * came back as `'[REDACTED]'`, and the executor wrote the mask into a new file (X1
63
+ * flash-m58). Any segment that is not such a print, or any env file named, keeps the full grade.
64
+ *
65
+ * @param command - The shell command.
66
+ * @returns True when every segment prints source files only.
67
+ */
68
+ export declare function printsOnlySource(command: string): boolean;
58
69
  /**
59
70
  * Whether a path is an env file, for which {@link redactSecrets}' full env-dump
60
71
  * treatment is appropriate rather than {@link redactSecretsInCode}. Matches
@@ -108,9 +119,16 @@ export declare function outputRedirectTarget(command: string): string | null;
108
119
  * - a trailing `| tail -N`, which holds everything back until the command ends.
109
120
  * It is dropped: `wait_for_task` already returns the end of the log.
110
121
  * - `playwright test` with no `--reporter`, which runs the config's reporter —
111
- * often `html`, which prints nothing per test. `--reporter=line` is added
122
+ * often `html`, which prints nothing per test. `--reporter=list` is added
112
123
  * when the playwright run is the command's last step and nothing is piped or
113
- * redirected from it ( aside); the results are the same, printed as they happen.
124
+ * redirected from it: it writes each result as it finishes, and it is the
125
+ * shape the platform reads a background suite back in (`suite-fail-fast.ts`'s
126
+ * fail-fast note and the Tests bar both parse `list` lines). A run that names
127
+ * `--reporter=line` is switched to `list` for the same reason — `line` writes
128
+ * `[n/N] <title>` with no ✓/✘ mark and no duration, so on X1 flash-m62 an
129
+ * 8.3-minute walk of one suite could never trip the fail-fast note.
130
+ *
131
+ * Only the command's last step is touched, and only its reporter.
114
132
  *
115
133
  * @param command - The command as the executor sent it.
116
134
  * @returns The command to run and one sentence per change made (empty when none).
@@ -249,7 +267,11 @@ export declare const MAX_BATCH_READ_BYTES: number;
249
267
  * needs.
250
268
  */
251
269
  export declare const MAX_READ_RETURN_CHARS: number;
252
- /** A file this small is returned whole even when a window is asked for (paging it costs a call each). */
270
+ /**
271
+ * A file this small is returned whole on a second window of it (paging it costs a call each).
272
+ * The same bound as {@link MAX_READ_RETURN_CHARS}: the windowed-read note promises that a
273
+ * file under it "fits in ONE call", and a smaller bound here broke that promise (X1 flash-m67).
274
+ */
253
275
  export declare const SMALL_FILE_WHOLE_CHARS: number;
254
276
  /** Max search results. */
255
277
  export declare const MAX_SEARCH_RESULTS = 50;
@@ -296,6 +318,29 @@ export declare function truncateMiddle(s: string, maxLength: number): string;
296
318
  * @returns Each block, in order (empty when there are none).
297
319
  */
298
320
  export declare function failureBlocks(s: string): string[];
321
+ /** Failure lines shown from a log that is still being written. */
322
+ export declare const RUNNING_FAILURE_LINES = 40;
323
+ /** Bytes of a running log's end appended after its failure lines. */
324
+ export declare const RUNNING_TAIL_BYTES = 1000;
325
+ /**
326
+ * The lines worth showing from a log that is still being written: a failed test's mark or
327
+ * numbered block header, and a runner's own error lines. Matched with `grep -E`.
328
+ */
329
+ export declare const RUNNING_FAILURE_LINE_RE = "^[[:space:]]*(\u2718|\u2717|\u00D7|\u2716|\u2715|[0-9]+\\)|[0-9]+ (passed|failed|flaky|skipped|did not run)|FAIL|FAILED|ERROR|Error:|error:|Traceback|AssertionError)";
330
+ /**
331
+ * The shell command whose stdout is the interesting part of a log that is still being written:
332
+ * the failure lines so far (capped at {@link RUNNING_FAILURE_LINES}), then the end of the log.
333
+ *
334
+ * A Playwright `list` run prints one line per test as it finishes and its error blocks only at
335
+ * the end, so the LAST bytes of a running log are its least informative part — in X1 flash-m61
336
+ * the last 3 000 bytes of a 139-test walk were the skip list, and all 61 failures were above
337
+ * them, which cost the run the grep it had to make anyway.
338
+ *
339
+ * @param log - The task log path.
340
+ * @param own - A file the command redirects its own output to, if any.
341
+ * @returns The shell command, safe to hand to the execution backend as-is.
342
+ */
343
+ export declare function runningOutputSliceCommand(log: string, own?: string): string;
299
344
  /**
300
345
  * Attempt a whitespace-tolerant replacement when an exact `old_string` match
301
346
  * failed. Finds a contiguous run of lines in `content` whose per-line
@@ -1 +1 @@
1
- {"version":3,"file":"utilities.d.ts","sourceRoot":"","sources":["../src/utilities.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"utilities.d.ts","sourceRoot":"","sources":["../src/utilities.ts"],"names":[],"mappings":"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"}
package/dist/utilities.js CHANGED
@@ -98,9 +98,15 @@ const NON_SECRET_VALUES = new Set([
98
98
  * @param quote - The quote character the value uses (`"` or `'`).
99
99
  * @returns A String.replace replacer preserving allowlisted values.
100
100
  */
101
- const jsonValueReplacer = (quote) => (match, prefix) => {
101
+ const jsonValueReplacer = (quote) => (match, prefix, offset, whole) => {
102
102
  const value = match.slice(prefix.length);
103
103
  const inner = value.slice(1, -1);
104
+ // A TypeScript type literal is never a credential: `type AuthState = 'authenticated' | …`
105
+ // (X1 flash-m58). A union member is followed by `|`; a declaration starts `type`.
106
+ if (/^\s*\|/.test(whole.slice(offset + match.length)))
107
+ return match;
108
+ if (/\btype\s+$/.test(whole.slice(Math.max(0, offset - 16), offset)))
109
+ return match;
104
110
  // An empty value hides nothing, and a suite fixture's `"password": ""` is the fact a
105
111
  // failing assertion turns on (X1 flash-m52).
106
112
  // A version (`"jsonwebtoken": "9.0.2"`, a package name that holds a keyword) is not a
@@ -153,6 +159,61 @@ export function redactSecrets(s) {
153
159
  export function redactSecretsInCode(s) {
154
160
  return s.replace(SECRET_ENV_ASSIGNMENT, '$1=[REDACTED]');
155
161
  }
162
+ /** Source-file extensions a printing command's output is read as code for. */
163
+ const SOURCE_FILE_RE = /\.(?:[cm]?[jt]sx?|css|scss|html|vue|svelte|md|sql|hurl)$/;
164
+ /**
165
+ * Whether a shell command only PRINTS source files (`cat a.tsx`, `sed -n '1,40p' x.ts`,
166
+ * `head -50 b.hurl`): its output is file content and gets the code-safe redaction, like
167
+ * read_file. Through the env-dump grade a printed `type AuthState = 'authenticated' | …`
168
+ * came back as `'[REDACTED]'`, and the executor wrote the mask into a new file (X1
169
+ * flash-m58). Any segment that is not such a print, or any env file named, keeps the full grade.
170
+ *
171
+ * @param command - The shell command.
172
+ * @returns True when every segment prints source files only.
173
+ */
174
+ export function printsOnlySource(command) {
175
+ if (/\.env\b/.test(command))
176
+ return false;
177
+ const segments = command
178
+ .split(/\s*(?:&&|;|\n)\s*/)
179
+ .map((s) => s.trim())
180
+ .filter((s) => s && !/^cd\s/.test(s));
181
+ if (segments.length === 0)
182
+ return false;
183
+ return segments.every((seg) => {
184
+ // Words, quotes kept whole: a grep pattern like "sign\\|verify" is one word, not a pipe.
185
+ const words = seg.match(/"(?:\\.|[^"\\])*"|'[^']*'|\S+/g) ?? [];
186
+ const stages = [[]];
187
+ for (const w of words) {
188
+ if (w === '|')
189
+ stages.push([]);
190
+ else if (!/^\d?>&\d$|^2>\/dev\/null$/.test(w))
191
+ stages[stages.length - 1].push(w);
192
+ }
193
+ const [first, ...rest] = stages;
194
+ const head = first[0];
195
+ // A print of named source files: cat/head/tail/nl/sed -n, or a non-recursive grep/rg
196
+ // (X1 flash-m61 read a fixture's password masked through `grep -n … auth.hurl`).
197
+ let args = first.slice(1);
198
+ if (head === 'sed' && args[0] !== '-n')
199
+ return false;
200
+ if (head === 'grep' || head === 'rg') {
201
+ if (args.some((a) => /^-[a-zA-Z]*[rR]/.test(a) || a === '--recursive'))
202
+ return false;
203
+ const flags = args.filter((a) => a.startsWith('-'));
204
+ const rest2 = args.filter((a) => !a.startsWith('-'));
205
+ args = flags.includes('-e') ? rest2 : rest2.slice(1);
206
+ }
207
+ else if (!/^(?:cat|head|tail|nl|sed)$/.test(head ?? ''))
208
+ return false;
209
+ const files = args.filter((t) => !t.startsWith('-') && !/^'[^']*'$|^"[^"]*"$|^\d+$|^\d+,\d+p$/.test(t));
210
+ if (files.length === 0 || !files.every((f) => SOURCE_FILE_RE.test(f)))
211
+ return false;
212
+ // After it, only text filters that read no file of their own.
213
+ return rest.every((stage) => /^(?:head|tail|sort|uniq|wc|cut|nl|grep)$/.test(stage[0] ?? '') &&
214
+ !stage.slice(1).some((a) => !a.startsWith('-') && /\.[a-z]+$/i.test(a) && !/^['"]/.test(a)));
215
+ });
216
+ }
156
217
  /**
157
218
  * Whether a path is an env file, for which {@link redactSecrets}' full env-dump
158
219
  * treatment is appropriate rather than {@link redactSecretsInCode}. Matches
@@ -226,6 +287,8 @@ export function outputRedirectTarget(command) {
226
287
  }
227
288
  /** A trailing `| tail -N` / `| tail -n N` (stderr redirects before it stay). */
228
289
  const TRAILING_TAIL = /\s*\|\s*tail(?:\s+-n)?\s+[-+]?\d+\s*$/;
290
+ /** `--reporter=line` / `--reporter line`, the one reporter the platform cannot read back. */
291
+ const LINE_REPORTER_FLAG = /--reporter([= ])line\b/;
229
292
  /**
230
293
  * Rewrite a command about to run in the background so its log grows while it
231
294
  * runs. Two shapes left the log empty until exit, and an empty log is what
@@ -235,9 +298,16 @@ const TRAILING_TAIL = /\s*\|\s*tail(?:\s+-n)?\s+[-+]?\d+\s*$/;
235
298
  * - a trailing `| tail -N`, which holds everything back until the command ends.
236
299
  * It is dropped: `wait_for_task` already returns the end of the log.
237
300
  * - `playwright test` with no `--reporter`, which runs the config's reporter —
238
- * often `html`, which prints nothing per test. `--reporter=line` is added
301
+ * often `html`, which prints nothing per test. `--reporter=list` is added
239
302
  * when the playwright run is the command's last step and nothing is piped or
240
- * redirected from it ( aside); the results are the same, printed as they happen.
303
+ * redirected from it: it writes each result as it finishes, and it is the
304
+ * shape the platform reads a background suite back in (`suite-fail-fast.ts`'s
305
+ * fail-fast note and the Tests bar both parse `list` lines). A run that names
306
+ * `--reporter=line` is switched to `list` for the same reason — `line` writes
307
+ * `[n/N] <title>` with no ✓/✘ mark and no duration, so on X1 flash-m62 an
308
+ * 8.3-minute walk of one suite could never trip the fail-fast note.
309
+ *
310
+ * Only the command's last step is touched, and only its reporter.
241
311
  *
242
312
  * @param command - The command as the executor sent it.
243
313
  * @returns The command to run and one sentence per change made (empty when none).
@@ -259,12 +329,20 @@ export function streamingBackgroundCommand(command) {
259
329
  changes.push(`Dropped the trailing \`${head[0].trim()}\`: Playwright prints its pass/fail count last, and it would be cut.`);
260
330
  }
261
331
  const lastStep = out.split(/&&|\|\||;/).at(-1) ?? '';
262
- if (/\bplaywright\s+test\b/.test(lastStep) &&
263
- !/--reporter\b/.test(lastStep) &&
332
+ const finalPlaywrightRun = /\bplaywright\s+test\b/.test(lastStep) &&
264
333
  // `2>&1` only joins stderr to the log; any other pipe or redirect sends the output elsewhere.
265
- !/[|>]/.test(lastStep.replace(/\d?>&\d/g, ''))) {
266
- out = `${out.trimEnd()} --reporter=line`;
267
- changes.push('Added `--reporter=line` so each test result is written to the log as it finishes ' +
334
+ !/[|>]/.test(lastStep.replace(/\d?>&\d/g, ''));
335
+ if (finalPlaywrightRun && LINE_REPORTER_FLAG.test(lastStep)) {
336
+ // Only the last step is rewritten, and `lastStep` is a suffix of `out`.
337
+ out =
338
+ out.slice(0, out.length - lastStep.length) +
339
+ lastStep.replace(LINE_REPORTER_FLAG, '--reporter$1list');
340
+ changes.push('Switched `--reporter=line` to `--reporter=list`: it also writes each test result as it ' +
341
+ 'finishes, and it is the shape the platform reads back from a background run.');
342
+ }
343
+ else if (finalPlaywrightRun && !/--reporter\b/.test(lastStep)) {
344
+ out = `${out.trimEnd()} --reporter=list`;
345
+ changes.push('Added `--reporter=list` so each test result is written to the log as it finishes ' +
268
346
  '(the config reporter may print nothing until the end).');
269
347
  }
270
348
  return { command: out, changes };
@@ -283,7 +361,8 @@ export function streamingBackgroundCommand(command) {
283
361
  * a keyless build still succeeds, and blocking those sent it looking for
284
362
  * workarounds (X0 rehearsal 14).
285
363
  */
286
- const BLOCKED_COMMANDS = /(?:^|[;&|`]\s*|(?:sh|bash|zsh|dash)\s+-c\s+['"]?\s*)(?:\/usr\/bin\/)?(?:\benv(?:\s+(?:-0|--null|--))?\s*(?:$|[;&|>)`'"])|\bprintenv\b|\bexport\s*$|\bset\s*$|\bdeclare\s+-x|cat\s+\/etc\/environment|cat\s+\/root\/\.bashrc|cat\s+\/proc\/\d+\/environ|cat\s+\/proc\/self\/environ|strings\s+\/proc|xargs[^;&|\n]*\/proc\/[^;&|\n]*environ|less\s+\/proc|head\s+\/proc|tail\s+\/proc|xxd\s+\/proc|od\s+\/proc|base64\s+\/proc|dd\s[^\n]*\/proc|sed\s[^\n]*\/proc\/[^\n]*environ|awk\s[^\n]*\/proc\/[^\n]*environ|cp\s[^\n]*\/proc\/[^\n]*environ|(?:cat|less|head|tail|xxd|od|base64|strings|dd|cp|sed|awk)\s[^\n;&|]*\/etc\/mol(?:\/|\b))/i;
364
+ // `(?<!\\)`: a backslash-escaped `\|` is a literal in the shell (grep "a\|env"), not a pipe (X1 flash-m58).
365
+ const BLOCKED_COMMANDS = /(?<!\\)(?:^|[;&|`]\s*|(?:sh|bash|zsh|dash)\s+-c\s+['"]?\s*)(?:\/usr\/bin\/)?(?:\benv(?:\s+(?:-0|--null|--))?\s*(?:$|[;&|>)`'"])|\bprintenv\b|\bexport\s*$|\bset\s*$|\bdeclare\s+-x|cat\s+\/etc\/environment|cat\s+\/root\/\.bashrc|cat\s+\/proc\/\d+\/environ|cat\s+\/proc\/self\/environ|strings\s+\/proc|xargs[^;&|\n]*\/proc\/[^;&|\n]*environ|less\s+\/proc|head\s+\/proc|tail\s+\/proc|xxd\s+\/proc|od\s+\/proc|base64\s+\/proc|dd\s[^\n]*\/proc|sed\s[^\n]*\/proc\/[^\n]*environ|awk\s[^\n]*\/proc\/[^\n]*environ|cp\s[^\n]*\/proc\/[^\n]*environ|(?:cat|less|head|tail|xxd|od|base64|strings|dd|cp|sed|awk)\s[^\n;&|]*\/etc\/mol(?:\/|\b))/i;
287
366
  /** Block shell redirects from /proc environ. */
288
367
  const BLOCKED_PROC_REDIRECT = /(?:<\s*\/proc\/(?:\d+|self)\/environ)/i;
289
368
  /** Block shell redirects from the molecule platform secrets dir (`/etc/mol`). */
@@ -498,8 +577,12 @@ export const MAX_BATCH_READ_BYTES = 200 * 1024;
498
577
  * needs.
499
578
  */
500
579
  export const MAX_READ_RETURN_CHARS = 80 * 1024;
501
- /** A file this small is returned whole even when a window is asked for (paging it costs a call each). */
502
- export const SMALL_FILE_WHOLE_CHARS = 24 * 1024;
580
+ /**
581
+ * A file this small is returned whole on a second window of it (paging it costs a call each).
582
+ * The same bound as {@link MAX_READ_RETURN_CHARS}: the windowed-read note promises that a
583
+ * file under it "fits in ONE call", and a smaller bound here broke that promise (X1 flash-m67).
584
+ */
585
+ export const SMALL_FILE_WHOLE_CHARS = MAX_READ_RETURN_CHARS;
503
586
  /** Max search results. */
504
587
  export const MAX_SEARCH_RESULTS = 50;
505
588
  /** Max find results. */
@@ -554,6 +637,33 @@ export function truncate(s, maxLength) {
554
637
  export function truncateMiddle(s, maxLength) {
555
638
  if (s.length <= maxLength)
556
639
  return s;
640
+ // Room for the tally line it may append, so the result stays near the cap.
641
+ return withRunTally(s, truncateMiddleBody(s, Math.max(Math.floor(maxLength / 2), maxLength - 200)));
642
+ }
643
+ /**
644
+ * A test run's tally lines (` 3 failed`, ` 71 passed (4.6m)`) the cut text lost, put back at
645
+ * its end: Playwright lists every failed test after its "N failed" line, so a long list pushed
646
+ * the line itself into the cut middle and the run read as having no failures (X1 flash-m60).
647
+ *
648
+ * @param original - The whole output.
649
+ * @param cut - The truncated output.
650
+ * @returns The cut output, with any lost tally lines appended.
651
+ */
652
+ function withRunTally(original, cut) {
653
+ const tally = [
654
+ ...original.matchAll(/^\s*\d+ (?:failed|passed|flaky|skipped|did not run|interrupted)\b[^\n]{0,40}$/gm),
655
+ ].map((m) => m[0].trim());
656
+ const lost = tally.filter((line) => !cut.includes(line));
657
+ return lost.length ? `${cut}\n[run tally] ${lost.join(' · ')}` : cut;
658
+ }
659
+ /**
660
+ * {@link truncateMiddle} without the tally guard.
661
+ *
662
+ * @param s - The text.
663
+ * @param maxLength - The budget.
664
+ * @returns The cut text.
665
+ */
666
+ function truncateMiddleBody(s, maxLength) {
557
667
  // Reserve room for the elision notice so head + notice + tail stays WITHIN
558
668
  // maxLength — the cap is a real token budget the caller relies on (a head-only
559
669
  // truncate that overshoots by its suffix length breaks that contract). 140 =
@@ -624,6 +734,33 @@ export function failureBlocks(s) {
624
734
  blocks.push(current.join('\n').trimEnd());
625
735
  return blocks;
626
736
  }
737
+ /** Failure lines shown from a log that is still being written. */
738
+ export const RUNNING_FAILURE_LINES = 40;
739
+ /** Bytes of a running log's end appended after its failure lines. */
740
+ export const RUNNING_TAIL_BYTES = 1000;
741
+ /**
742
+ * The lines worth showing from a log that is still being written: a failed test's mark or
743
+ * numbered block header, and a runner's own error lines. Matched with `grep -E`.
744
+ */
745
+ export const RUNNING_FAILURE_LINE_RE = '^[[:space:]]*(✘|✗|×|✖|✕|[0-9]+\\)|[0-9]+ (passed|failed|flaky|skipped|did not run)|FAIL|FAILED|ERROR|Error:|error:|Traceback|AssertionError)';
746
+ /**
747
+ * The shell command whose stdout is the interesting part of a log that is still being written:
748
+ * the failure lines so far (capped at {@link RUNNING_FAILURE_LINES}), then the end of the log.
749
+ *
750
+ * A Playwright `list` run prints one line per test as it finishes and its error blocks only at
751
+ * the end, so the LAST bytes of a running log are its least informative part — in X1 flash-m61
752
+ * the last 3 000 bytes of a 139-test walk were the skip list, and all 61 failures were above
753
+ * them, which cost the run the grep it had to make anyway.
754
+ *
755
+ * @param log - The task log path.
756
+ * @param own - A file the command redirects its own output to, if any.
757
+ * @returns The shell command, safe to hand to the execution backend as-is.
758
+ */
759
+ export function runningOutputSliceCommand(log, own) {
760
+ const read = `cat ${log}${own ? ` ${shellQuote(own)}` : ''} 2>/dev/null`;
761
+ return (`{ ${read} | grep -aE ${shellQuote(RUNNING_FAILURE_LINE_RE)} | head -${RUNNING_FAILURE_LINES} | cut -c1-300; ` +
762
+ `echo; ${read} | tail -c ${RUNNING_TAIL_BYTES}; }`);
763
+ }
627
764
  /**
628
765
  * Attempt a whitespace-tolerant replacement when an exact `old_string` match
629
766
  * failed. Finds a contiguous run of lines in `content` whose per-line
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@molecule/api-ai-tools",
3
- "version": "1.5.15",
3
+ "version": "1.5.17",
4
4
  "description": "Shared AI agent tools with backend abstraction for sandbox and local execution",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",