@molecule/api-ai-tools 1.8.2 → 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/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-17T02:50:55.123Z
6
+ Generated: 2026-10-17T05:02:46.912Z
7
7
  -->
8
8
 
9
9
  # @molecule/api-ai-tools
@@ -115,6 +115,24 @@ interface LocatedCommandSegment {
115
115
  * inside a nested `sh -c` string, for the same reason as {@link start}.
116
116
  */
117
117
  assignmentOffset: number | null
118
+ /**
119
+ * The offset in the command one past the statement's last word — where an option of this
120
+ * statement's program belongs, before whatever closes it. Null inside a nested `sh -c` string.
121
+ */
122
+ end: number | null
123
+ /**
124
+ * The offset in the command of the word in command position — the program, once every wrapper
125
+ * in front of it is taken away (`npx --prefix /app <here>playwright test`). Null inside a
126
+ * nested `sh -c` string, and for a statement that only sets variables.
127
+ */
128
+ programOffset: number | null
129
+ /**
130
+ * The separator the shell split at immediately before this statement — `|`, `||`, `&&`, `;`,
131
+ * a newline or `&` — or '' when it is the first statement of the command. It is the parse's own
132
+ * answer, so it is known even where the offsets are not, and a caller deciding whether two
133
+ * statements are one pipeline (`|`, never `;`) asks it instead of reading the text between them.
134
+ */
135
+ separator: string
118
136
  }
119
137
  ```
120
138
 
@@ -235,6 +253,19 @@ interface ToolSchema {
235
253
  }
236
254
  ```
237
255
 
256
+ #### `LeadingSleep`
257
+
258
+ The leading `sleep` a command opens with, and the command that follows it.
259
+
260
+ ```typescript
261
+ interface LeadingSleep {
262
+ /** How many seconds the statement would have slept (GNU sleep sums its operands). */
263
+ seconds: number
264
+ /** What follows the leading statement, trimmed — '' when the sleep was the whole command. */
265
+ rest: string
266
+ }
267
+ ```
268
+
238
269
  #### `SuiteRunInFlight`
239
270
 
240
271
  One suite run, as the rule that decides whether a second may start beside it reads it.
@@ -258,6 +289,20 @@ interface SuiteRunInFlight {
258
289
  }
259
290
  ```
260
291
 
292
+ #### `InstalledPackageFacts`
293
+
294
+ What the filesystem says about a write target under `node_modules` — the half
295
+ of the decision a path's spelling cannot make.
296
+
297
+ ```typescript
298
+ interface InstalledPackageFacts {
299
+ /** Whether the `node_modules` entry the path goes through is a symbolic link. */
300
+ readonly symlink: boolean
301
+ /** Whether a package is really installed there: a `package.json` resolves under the entry. */
302
+ readonly installed: boolean
303
+ }
304
+ ```
305
+
261
306
  #### `TraceRequest`
262
307
 
263
308
  One request a traced page made, as `*.network` records it.
@@ -553,6 +598,27 @@ interface SuiteFilterStrip {
553
598
  }
554
599
  ```
555
600
 
601
+ #### `SuiteRunPipelinePlacement`
602
+
603
+ Whether a finished run's exit code belongs to a stage after the suite runner.
604
+
605
+ ```typescript
606
+ interface SuiteRunPipelinePlacement {
607
+ /**
608
+ * True when a pipe this tool must not drop still follows the suite runner: the code in the
609
+ * result is that stage's, not the run's.
610
+ */
611
+ piped: boolean
612
+ /**
613
+ * True when the suite (or a stage of its pipeline) sits inside a nested `sh -c '<command>'`
614
+ * string, so the parse cannot place it in the command's own text. The caller then says so
615
+ * plainly instead of implying a stage it can point at — and keeps the command's own code when
616
+ * no pipe follows the runner, because on that command the pipeline's last stage IS the run.
617
+ */
618
+ unplaced: boolean
619
+ }
620
+ ```
621
+
556
622
  #### `FailedTest`
557
623
 
558
624
  One failed test, as a running suite's log names it.
@@ -658,6 +724,19 @@ Re-export SkillEntry as DiscoveredSkill for backwards compatibility.
658
724
  type DiscoveredSkill = SkillEntry
659
725
  ```
660
726
 
727
+ #### `InstalledPackageProbe`
728
+
729
+ Looks at the filesystem for {@link InstalledPackageFacts}.
730
+
731
+ The guard cannot `stat` the path itself: the tools act through a backend (a
732
+ sandbox over the network for the fleet), so the caller — which holds that
733
+ backend — supplies the look. {@link shellInstalledPackageProbe} builds one
734
+ from any shell runner.
735
+
736
+ ```typescript
737
+ type InstalledPackageProbe = (path: string) => Promise<InstalledPackageFacts>
738
+ ```
739
+
661
740
  ### Classes
662
741
 
663
742
  #### `ToolInputError`
@@ -1029,6 +1108,26 @@ function commandRunsNoTests(command: string): boolean
1029
1108
 
1030
1109
  **Returns:** True when the command invokes a runner only to print.
1031
1110
 
1111
+ #### `leadingSleepPeek(command)`
1112
+
1113
+ The leading `sleep` of a command, when the first statement is one and it asks for at least
1114
+ {@link MIN_PEEK_SLEEP_SECONDS}.
1115
+
1116
+ The statement is read by {@link locatedCommandSegments} — the same parse every other command
1117
+ classifier uses — so only a `sleep` in command position of the FIRST statement counts. The end
1118
+ of that statement is then found by walking forward from its own start: the statement is
1119
+ `sleep <duration>`, whose words carry no quotes and open no substitution, so the first unquoted
1120
+ `;`, newline or `&` ends it. A `|` is refused: dropping the left side of a pipeline would hand
1121
+ the right side its stdin instead, and a `||` fallback would run on a success that never did.
1122
+
1123
+ ```typescript
1124
+ function leadingSleepPeek(command: string): LeadingSleep | null
1125
+ ```
1126
+
1127
+ - `command` — The shell command the executor sent.
1128
+
1129
+ **Returns:** The seconds and the remaining command, or null when the command does not open with a long `sleep`.
1130
+
1032
1131
  #### `withoutHeredocs(command)`
1033
1132
 
1034
1133
  A command with its heredoc bodies removed: the runs, kills and hosts it names in a file it
@@ -1213,10 +1312,26 @@ function moleculeDistReadNote(path: string): string | null
1213
1312
 
1214
1313
  **Returns:** The note, or null when the path is not inside a package's `dist`.
1215
1314
 
1216
- #### `installedPackageWriteRefusal(path)`
1315
+ #### `shellInstalledPackageProbe(run)`
1217
1316
 
1218
- The refusal for a write whose target is inside an installed package, or null
1219
- when the path is not under a `node_modules` directory.
1317
+ Build an {@link InstalledPackageProbe} from a shell runner (a backend's
1318
+ `run`, or a sandbox's `exec`): one command reports whether the entry is a
1319
+ link and whether a `package.json` resolves under it.
1320
+
1321
+ ```typescript
1322
+ function shellInstalledPackageProbe(
1323
+ run: (command: string) => Promise<{ stdout?: string } | null | undefined>,
1324
+ ): InstalledPackageProbe
1325
+ ```
1326
+
1327
+ - `run` — Runs a command where the path lives.
1328
+
1329
+ **Returns:** The probe.
1330
+
1331
+ #### `installedPackageWriteRefusal(path, probe)`
1332
+
1333
+ The refusal for a write whose target is inside an INSTALLED package, or null
1334
+ when the path is not under `node_modules` or nothing is installed there.
1220
1335
 
1221
1336
  `node_modules` is not the project's code: nothing written there survives the
1222
1337
  next install, no deploy carries it, and a sandbox whose dependency tree is
@@ -1229,17 +1344,29 @@ answered `ok: true`, and 18 s went to noticing, `npm pack`-ing the package and
1229
1344
  putting the file back. Reading these files stays allowed (that is how the
1230
1345
  declarations above are read at all).
1231
1346
 
1232
- The restore route is named because the model that has ALREADY clobbered a file
1233
- (this refusal is too late for it) is the one who needs it, and because the
1234
- obvious repair — `npm install <pkg>` — is itself refused in the pre-installed
1235
- sandbox. `npm pack` + a `tar` extraction over the package directory is what
1236
- m91 used, and what both guards leave alone.
1347
+ What is protected is the INSTALL, not the spelling: the decision comes from
1348
+ {@link InstalledPackageFacts}, never from the path alone (m106 above). Three
1349
+ things are the run's own to remove or replace —
1350
+
1351
+ - a `node_modules` that is a SYMLINK: `rm`/`unlink` removes the link, never the
1352
+ tree it points at, so a link the run made cannot clobber anything;
1353
+ - anything under the run's own `.mol-scratch/` directory;
1354
+ - a `node_modules` with no `package.json` under it (nothing was ever installed).
1355
+
1356
+ A path INSIDE the link is still decided by what resolves there: a link into a
1357
+ real tree is refused, because removing or writing through it reaches the
1358
+
1359
+ _(description truncated — read the full text in `node_modules/@molecule/api-ai-tools/dist/tools.d.ts`.)_
1237
1360
 
1238
1361
  ```typescript
1239
- function installedPackageWriteRefusal(path: string): string | null
1362
+ function installedPackageWriteRefusal(
1363
+ path: string,
1364
+ probe?: InstalledPackageProbe,
1365
+ ): Promise<string | null>
1240
1366
  ```
1241
1367
 
1242
1368
  - `path` — An absolute path a tool is about to write.
1369
+ - `probe` — Looks at the filesystem where the path lives.
1243
1370
 
1244
1371
  **Returns:** The one-line refusal naming the package, or null when nothing is installed there.
1245
1372
 
@@ -1602,23 +1729,40 @@ function stripTrailingSuiteFilters(command: string): SuiteFilterStrip
1602
1729
 
1603
1730
  **Returns:** The rewritten command and the stages dropped.
1604
1731
 
1732
+ #### `suiteRunPipelinePlacement(command)`
1733
+
1734
+ Where the suite runner's exit code comes from: the runner itself, or a stage piped after it.
1735
+
1736
+ {@link suiteRunPipedAfterRunner}'s one question, with the honest third answer kept apart. The
1737
+ code a finished run reports is its LAST pipeline stage's, so a stage after the runner makes the
1738
+ code the stage's — but only when such a stage EXISTS. The platform used to report "piped" for
1739
+ any run whose statements it could not place in the text, which one heredoc body was enough to
1740
+ cause: on X1 xr-molecule-flash-m105 (+08:12) a config written in the same `exec_command` made
1741
+ the run come back with no exit code at all, and the note under it claimed a stage after the
1742
+ runner that was not there. The parse now places every statement (`locatedCommandSegments`), so
1743
+ "unplaced" is the rare, named case rather than a guess dressed as an answer.
1744
+
1745
+ ```typescript
1746
+ function suiteRunPipelinePlacement(command: string): SuiteRunPipelinePlacement
1747
+ ```
1748
+
1749
+ - `command` — The shell command, after the streaming rewrite when one was applied.
1750
+
1751
+ **Returns:** Whether a stage follows the runner, and whether the run could be placed at all.
1752
+
1605
1753
  #### `suiteRunPipedAfterRunner(command)`
1606
1754
 
1607
1755
  Whether the command's last step runs a suite whose output still passes through a stage after
1608
1756
  the runner — one {@link stripTrailingSuiteFilters} could not prove is output-only (`| tee log`,
1609
1757
  `| xargs …`). The pipeline's exit status is that stage's, not the run's.
1610
1758
 
1611
- A command whose stages cannot be placed in the text (irregular whitespace inside the run) is
1612
- reported as piped too: whether a pipe follows the runner is then UNKNOWN, and an unknown exit
1613
- code that gets trusted is a failing run read as a pass — the failure this rule exists to stop.
1614
-
1615
1759
  ```typescript
1616
1760
  function suiteRunPipedAfterRunner(command: string): boolean
1617
1761
  ```
1618
1762
 
1619
1763
  - `command` — The shell command, after the streaming rewrite when one was applied.
1620
1764
 
1621
- **Returns:** True when a pipe this tool must not drop still follows the suite runner, or when the command runs a suite the parse cannot place.
1765
+ **Returns:** True when a pipe this tool must not drop still follows the suite runner.
1622
1766
 
1623
1767
  #### `suiteRunPriorityLaunch(command, launch)`
1624
1768
 
@@ -2231,9 +2375,12 @@ executor reads, so one round of fixes covers all of them rather than the one cau
2231
2375
  stopped early happened to reach.
2232
2376
 
2233
2377
  A test the request excludes is not one of the causes — it is dropped and named once, at the
2234
- end, so a result never asks to fix a test it also says to ignore. Where the caller knows how
2235
- the run was started, the note also hands over the narrowed re-run of exactly those tests
2236
- ({@link rerunOfRunningSuite}) instead of leaving the executor to re-run the whole suite.
2378
+ end, so a result never asks to fix a test it also says to ignore. A test the runner itself
2379
+ declared `flaky` (it failed once and passed on its config retry) is not a cause either: it is
2380
+ named apart from them, once, as what the tally called it ({@link flakyTestsDeclared}). Where
2381
+ the caller knows how the run was started, the note also hands over the narrowed re-run of
2382
+ exactly those tests ({@link rerunOfRunningSuite}) instead of leaving the executor to re-run the
2383
+ whole suite.
2237
2384
 
2238
2385
  ```typescript
2239
2386
  function failureCausesNote(
@@ -2414,6 +2561,15 @@ Canonical tool schemas shared by the agent runtime and documentation.
2414
2561
  const TOOL_SCHEMAS: Record<string, ToolSchema>
2415
2562
  ```
2416
2563
 
2564
+ #### `MIN_PEEK_SLEEP_SECONDS`
2565
+
2566
+ The shortest leading sleep the platform drops: below it the wall clock it costs is noise, and a
2567
+ short settle (`sleep 2; curl …`) is a legitimate thing to run.
2568
+
2569
+ ```typescript
2570
+ const MIN_PEEK_SLEEP_SECONDS: 5
2571
+ ```
2572
+
2417
2573
  #### `SPLIT_WORK_ADVICE`
2418
2574
 
2419
2575
  The closing advice on an over-budget message for a command that is not a test
@@ -21,8 +21,14 @@
21
21
  * Not command position, and therefore not an invocation: a runner named as an argument
22
22
  * (`head -30 run-api-tests-hurl.sh`, `grep -n playwright e2e/README.md`,
23
23
  * `git show HEAD:run-api-tests.sh`), and a runner named in a heredoc body — a file a command
24
- * WRITES is the caller's business, so a command with a heredoc goes through the caller's own
25
- * stripper first (`withoutHeredocs`, in `./suite-overlap.js`).
24
+ * WRITES is not a command it runs, so the bodies are blanked to spaces before anything is split
25
+ * ({@link blankHeredocBodies}); offsets stay exact, because the blanking preserves every position.
26
+ *
27
+ * The parser also answers WHERE each statement sits ({@link locatedCommandSegments}): `start`,
28
+ * `end`, `programOffset` and `assignmentOffset` are the offsets a caller splices at. They are
29
+ * token positions from this walk, never a search for re-joined text — the words are joined back
30
+ * with single spaces for matching, so `command.indexOf(segment.text)` fails on irregular spacing,
31
+ * on grouping braces, and on anything a heredoc body shifts (xr-molecule-flash-m105 +08:12).
26
32
  *
27
33
  * @module
28
34
  */
@@ -90,6 +96,24 @@ export interface LocatedCommandSegment {
90
96
  * inside a nested `sh -c` string, for the same reason as {@link start}.
91
97
  */
92
98
  assignmentOffset: number | null;
99
+ /**
100
+ * The offset in the command one past the statement's last word — where an option of this
101
+ * statement's program belongs, before whatever closes it. Null inside a nested `sh -c` string.
102
+ */
103
+ end: number | null;
104
+ /**
105
+ * The offset in the command of the word in command position — the program, once every wrapper
106
+ * in front of it is taken away (`npx --prefix /app <here>playwright test`). Null inside a
107
+ * nested `sh -c` string, and for a statement that only sets variables.
108
+ */
109
+ programOffset: number | null;
110
+ /**
111
+ * The separator the shell split at immediately before this statement — `|`, `||`, `&&`, `;`,
112
+ * a newline or `&` — or '' when it is the first statement of the command. It is the parse's own
113
+ * answer, so it is known even where the offsets are not, and a caller deciding whether two
114
+ * statements are one pipeline (`|`, never `;`) asks it instead of reading the text between them.
115
+ */
116
+ separator: string;
93
117
  }
94
118
  /**
95
119
  * Every statement of a command, with its own assignments and where it sits — assignment-only
@@ -1 +1 @@
1
- {"version":3,"file":"command-position.d.ts","sourceRoot":"","sources":["../src/command-position.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAEH,uFAAuF;AACvF,MAAM,WAAW,cAAc;IAC7B;;;OAGG;IACH,OAAO,EAAE,MAAM,CAAA;IACf,qFAAqF;IACrF,IAAI,EAAE,SAAS,MAAM,EAAE,CAAA;IACvB,4FAA4F;IAC5F,IAAI,EAAE,MAAM,CAAA;IACZ;;;OAGG;IACH,cAAc,EAAE,MAAM,GAAG,IAAI,CAAA;CAC9B;AA2MD;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAM3D;AAkVD;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,cAAc,EAAE,CAIjE;AAED;;;;;;;;;GASG;AACH,MAAM,WAAW,qBAAqB;IACpC;;;;OAIG;IACH,OAAO,EAAE,cAAc,GAAG,IAAI,CAAA;IAC9B,0FAA0F;IAC1F,WAAW,EAAE,SAAS,MAAM,EAAE,CAAA;IAC9B;;;;OAIG;IACH,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;IACpB;;;;;OAKG;IACH,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAA;CAChC;AAED;;;;;;;GAOG;AACH,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,MAAM,GAAG,qBAAqB,EAAE,CAE/E;AAqDD;;;;;;;;;;;;GAYG;AACH,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAOpE;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAK/E"}
1
+ {"version":3,"file":"command-position.d.ts","sourceRoot":"","sources":["../src/command-position.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AAEH,uFAAuF;AACvF,MAAM,WAAW,cAAc;IAC7B;;;OAGG;IACH,OAAO,EAAE,MAAM,CAAA;IACf,qFAAqF;IACrF,IAAI,EAAE,SAAS,MAAM,EAAE,CAAA;IACvB,4FAA4F;IAC5F,IAAI,EAAE,MAAM,CAAA;IACZ;;;OAGG;IACH,cAAc,EAAE,MAAM,GAAG,IAAI,CAAA;CAC9B;AA2MD;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAM3D;AA+cD;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,cAAc,EAAE,CAIjE;AAmCD;;;;;;;;;GASG;AACH,MAAM,WAAW,qBAAqB;IACpC;;;;OAIG;IACH,OAAO,EAAE,cAAc,GAAG,IAAI,CAAA;IAC9B,0FAA0F;IAC1F,WAAW,EAAE,SAAS,MAAM,EAAE,CAAA;IAC9B;;;;OAIG;IACH,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;IACpB;;;;;OAKG;IACH,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAA;IAC/B;;;OAGG;IACH,GAAG,EAAE,MAAM,GAAG,IAAI,CAAA;IAClB;;;;OAIG;IACH,aAAa,EAAE,MAAM,GAAG,IAAI,CAAA;IAC5B;;;;;OAKG;IACH,SAAS,EAAE,MAAM,CAAA;CAClB;AAED;;;;;;;GAOG;AACH,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,MAAM,GAAG,qBAAqB,EAAE,CAE/E;AA4ED;;;;;;;;;;;;GAYG;AACH,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAOpE;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAK/E"}
@@ -21,8 +21,14 @@
21
21
  * Not command position, and therefore not an invocation: a runner named as an argument
22
22
  * (`head -30 run-api-tests-hurl.sh`, `grep -n playwright e2e/README.md`,
23
23
  * `git show HEAD:run-api-tests.sh`), and a runner named in a heredoc body — a file a command
24
- * WRITES is the caller's business, so a command with a heredoc goes through the caller's own
25
- * stripper first (`withoutHeredocs`, in `./suite-overlap.js`).
24
+ * WRITES is not a command it runs, so the bodies are blanked to spaces before anything is split
25
+ * ({@link blankHeredocBodies}); offsets stay exact, because the blanking preserves every position.
26
+ *
27
+ * The parser also answers WHERE each statement sits ({@link locatedCommandSegments}): `start`,
28
+ * `end`, `programOffset` and `assignmentOffset` are the offsets a caller splices at. They are
29
+ * token positions from this walk, never a search for re-joined text — the words are joined back
30
+ * with single spaces for matching, so `command.indexOf(segment.text)` fails on irregular spacing,
31
+ * on grouping braces, and on anything a heredoc body shifts (xr-molecule-flash-m105 +08:12).
26
32
  *
27
33
  * @module
28
34
  */
@@ -380,6 +386,123 @@ function commandStart(words) {
380
386
  }
381
387
  return { at, nested: null, timeoutSeconds };
382
388
  }
389
+ /**
390
+ * A command with every heredoc BODY blanked to spaces, so its length and every offset in it are
391
+ * unchanged.
392
+ *
393
+ * A heredoc body is data the command writes, not a command it runs: `cat > playwright.config.ts
394
+ * <<'EOF' … import { defineConfig, devices } from '@playwright/test' … EOF` writes a file and runs
395
+ * `cat`. Read as shell, though, every line of the body is a statement — and the words joined back
396
+ * for matching are not the words written, so a caller that placed a statement by searching for
397
+ * them found nothing, in a command whose real statements sat all along. On X1
398
+ * xr-molecule-flash-m105 (+08:12) the body's `import { defineConfig, devices } …` alone made
399
+ * `suiteRunPipedAfterRunner` answer "piped" for a run with no pipe left, and the run's exit code
400
+ * was dropped for the rest of the session.
401
+ *
402
+ * The body runs from the line after the operator to the line whose whole content is the delimiter
403
+ * (leading TABS stripped for `<<-`, as `man bash` defines it); the terminator line is blanked too,
404
+ * and its newline kept, so the statements around it still split at the same places. Newlines
405
+ * inside the body are kept for the same reason. `<<<` is a herestring, not a heredoc, and a `<<`
406
+ * inside quotes or a comment opens nothing.
407
+ *
408
+ * @param command - The shell command.
409
+ * @returns The command with the bodies blanked to spaces, the same length as the input.
410
+ */
411
+ function blankHeredocBodies(command) {
412
+ if (!command.includes('<<'))
413
+ return command;
414
+ const chars = command.split('');
415
+ /** Overwrite a range with spaces, keeping its newlines so statements still split there. */
416
+ const blank = (from, to) => {
417
+ for (let i = from; i < to; i++)
418
+ if (chars[i] !== '\n')
419
+ chars[i] = ' ';
420
+ };
421
+ let lineStart = 0;
422
+ while (lineStart < command.length) {
423
+ const lineEndRaw = command.indexOf('\n', lineStart);
424
+ const lineEnd = lineEndRaw < 0 ? command.length : lineEndRaw;
425
+ const openers = [];
426
+ let quote = null;
427
+ for (let i = lineStart; i < lineEnd; i++) {
428
+ const char = command[i] ?? '';
429
+ if (quote !== null) {
430
+ if (char === quote)
431
+ quote = null;
432
+ continue;
433
+ }
434
+ if (char === '"' || char === "'") {
435
+ quote = char;
436
+ continue;
437
+ }
438
+ // A `#` that starts a word opens a comment: the rest of the line is not shell words.
439
+ if (char === '#' && (i === lineStart || /\s/.test(command[i - 1] ?? '')))
440
+ break;
441
+ if (char !== '<' || command[i + 1] !== '<')
442
+ continue;
443
+ // `<<<` is a herestring: it opens no body.
444
+ if (command[i + 2] === '<') {
445
+ i += 2;
446
+ continue;
447
+ }
448
+ let at = i + 2;
449
+ const stripTabs = command[at] === '-';
450
+ if (stripTabs)
451
+ at += 1;
452
+ while (command[at] === ' ' || command[at] === '\t')
453
+ at += 1;
454
+ let delimiter;
455
+ const opener = command[at];
456
+ if (opener === '"' || opener === "'") {
457
+ const close = command.indexOf(opener, at + 1);
458
+ const wordEnd = close < 0 || close > lineEnd ? lineEnd : close;
459
+ delimiter = command.slice(at + 1, wordEnd);
460
+ at = wordEnd + 1;
461
+ }
462
+ else {
463
+ let wordEnd = at;
464
+ while (wordEnd < lineEnd && !/\s/.test(command[wordEnd] ?? ''))
465
+ wordEnd += 1;
466
+ delimiter = command.slice(at, wordEnd);
467
+ at = wordEnd;
468
+ }
469
+ if (delimiter !== '')
470
+ openers.push({ delimiter, stripTabs });
471
+ i = at - 1;
472
+ }
473
+ if (openers.length === 0) {
474
+ if (lineEndRaw < 0)
475
+ break;
476
+ lineStart = lineEnd + 1;
477
+ continue;
478
+ }
479
+ // The bodies follow the line that opened them, in the order the operators were written.
480
+ let cursor = lineEndRaw < 0 ? command.length : lineEnd + 1;
481
+ for (const heredoc of openers) {
482
+ let bodyEnd = command.length;
483
+ let after = command.length;
484
+ let from = cursor;
485
+ while (from < command.length) {
486
+ const nl = command.indexOf('\n', from);
487
+ const line = command.slice(from, nl < 0 ? command.length : nl);
488
+ const compared = heredoc.stripTabs ? line.replace(/^\t+/, '') : line;
489
+ if (compared === heredoc.delimiter) {
490
+ bodyEnd = from;
491
+ after = nl < 0 ? command.length : nl + 1;
492
+ break;
493
+ }
494
+ if (nl < 0)
495
+ break;
496
+ from = nl + 1;
497
+ }
498
+ blank(cursor, bodyEnd);
499
+ blank(bodyEnd, after);
500
+ cursor = after;
501
+ }
502
+ lineStart = cursor;
503
+ }
504
+ return chars.join('');
505
+ }
383
506
  /**
384
507
  * A command's statements and pipeline stages, split where the shell splits them — inside quotes
385
508
  * and inside a command substitution, a `|`, `;` or `&&` belongs to that command, not to this one.
@@ -400,19 +523,21 @@ function commandStart(words) {
400
523
  * no runner, and every run of it reads as nothing).
401
524
  *
402
525
  * @param command - The shell command.
403
- * @returns Each segment's text and the offset in the command its first character sits at, in
404
- * order, with empty ones dropped.
526
+ * @returns Each statement's text, the offset its first character sits at, and the separator that
527
+ * ended it (`|`, `&&`, `;`, a newline, … — '' after the last one), in order, with empty ones
528
+ * dropped.
405
529
  */
406
530
  function splitSegments(command) {
531
+ const source = blankHeredocBodies(command);
407
532
  const parts = [];
408
533
  let buffer = '';
409
534
  /** The offset in the command of the current buffer's first character. */
410
535
  let bufferStart = 0;
411
536
  /** The nestings this position sits inside, outermost first. */
412
537
  const stack = [];
413
- for (let i = 0; i < command.length; i++) {
414
- const char = command[i] ?? '';
415
- const next = command[i + 1];
538
+ for (let i = 0; i < source.length; i++) {
539
+ const char = source[i] ?? '';
540
+ const next = source[i + 1];
416
541
  const top = stack[stack.length - 1];
417
542
  // A single-quoted string expands nothing at all: no escape, no substitution, and no quote
418
543
  // but its own ends it.
@@ -469,33 +594,46 @@ function splitSegments(command) {
469
594
  continue;
470
595
  }
471
596
  if ((char === '&' && next === '&') || (char === '|' && next === '|')) {
472
- parts.push({ text: buffer, start: bufferStart });
597
+ parts.push({ text: buffer, start: bufferStart, after: char + next });
473
598
  buffer = '';
474
599
  i += 1;
475
600
  bufferStart = i + 1;
476
601
  continue;
477
602
  }
478
603
  if (char === ';' || char === '|' || char === '\n') {
479
- parts.push({ text: buffer, start: bufferStart });
604
+ parts.push({ text: buffer, start: bufferStart, after: char });
480
605
  buffer = '';
481
606
  bufferStart = i + 1;
482
607
  continue;
483
608
  }
484
- if (char === '&' && command[i - 1] !== '>' && next !== '>') {
485
- parts.push({ text: buffer, start: bufferStart });
609
+ if (char === '&' && source[i - 1] !== '>' && next !== '>') {
610
+ parts.push({ text: buffer, start: bufferStart, after: '&' });
486
611
  buffer = '';
487
612
  bufferStart = i + 1;
488
613
  continue;
489
614
  }
490
615
  buffer += char;
491
616
  }
492
- parts.push({ text: buffer, start: bufferStart });
493
- return parts
494
- .map((part) => ({
495
- text: part.text.trim(),
496
- start: part.start + (part.text.length - part.text.trimStart().length),
497
- }))
498
- .filter((part) => part.text !== '');
617
+ parts.push({ text: buffer, start: bufferStart, after: '' });
618
+ const out = [];
619
+ // The separator of the last part, kept across the empty ones so a statement's own separator
620
+ // survives the blank lines around it (`a &&\n b` and a heredoc's blanked body alike).
621
+ let lastAfter = null;
622
+ for (const part of parts) {
623
+ const text = part.text.trim();
624
+ if (text === '') {
625
+ lastAfter = part.after;
626
+ continue;
627
+ }
628
+ out.push({
629
+ text,
630
+ start: part.start + (part.text.length - part.text.trimStart().length),
631
+ after: part.after,
632
+ before: lastAfter ?? '',
633
+ });
634
+ lastAfter = part.after;
635
+ }
636
+ return out;
499
637
  }
500
638
  /**
501
639
  * One segment's words: quotes kept whole, grouping parentheses dropped, and a `(` written
@@ -559,6 +697,42 @@ function leadingAssignments(text) {
559
697
  export function commandSegments(command) {
560
698
  return locatedSegmentsOf(command, 0).flatMap((located) => located.segment === null ? [] : [located.segment]);
561
699
  }
700
+ /**
701
+ * Where a statement's word sits in its own raw text, as {@link segmentWords} counts the words.
702
+ *
703
+ * The words are matched for READING (quotes whole, grouping braces dropped, a leading `(` taken
704
+ * off the first word); a caller that writes into the statement needs the offset it was written
705
+ * at. Both come from this one walk, so the two can never disagree about which word is which.
706
+ *
707
+ * @param text - One statement's text.
708
+ * @param index - The word's index in {@link segmentWords}'s list.
709
+ * @returns The word's offset in `text`, or null when there is no such word.
710
+ */
711
+ function wordOffset(text, index) {
712
+ const placement = new RegExp(WORD.source, 'g');
713
+ let kept = 0;
714
+ let first = true;
715
+ for (let match = placement.exec(text); match !== null; match = placement.exec(text)) {
716
+ if (GROUPING.has(match[0]))
717
+ continue;
718
+ if (first) {
719
+ first = false;
720
+ const bare = match[0].replace(/^[({]+/, '');
721
+ // `segmentWords` shifts a first word that was nothing but grouping away, so the NEXT word
722
+ // is not stripped and is counted from zero.
723
+ if (bare === '')
724
+ continue;
725
+ if (kept === index)
726
+ return match.index + (match[0].length - bare.length);
727
+ kept += 1;
728
+ continue;
729
+ }
730
+ if (kept === index)
731
+ return match.index;
732
+ kept += 1;
733
+ }
734
+ return null;
735
+ }
562
736
  /**
563
737
  * Every statement of a command, with its own assignments and where it sits — assignment-only
564
738
  * statements included, because the variables those persist are exactly what a later statement of
@@ -587,9 +761,31 @@ function locatedSegmentsOf(command, depth) {
587
761
  const words = segmentWords(part.text);
588
762
  const start = commandStart(words);
589
763
  const leading = leadingAssignments(part.text);
764
+ /** Every offset in this statement is an offset in the command it was written in. */
765
+ const placed = {
766
+ start: part.start,
767
+ end: part.start + part.text.length,
768
+ assignmentOffset: part.start + leading.end,
769
+ programOffset: words[start.at] === undefined ? null : part.start + (wordOffset(part.text, start.at) ?? 0),
770
+ };
590
771
  if (start.nested !== null) {
591
- for (const inner of locatedSegmentsOf(start.nested, depth + 1))
592
- out.push({ ...inner, start: null, assignmentOffset: null });
772
+ const inner = locatedSegmentsOf(start.nested, depth + 1);
773
+ for (let i = 0; i < inner.length; i += 1) {
774
+ const entry = inner[i];
775
+ if (!entry)
776
+ continue;
777
+ out.push({
778
+ ...entry,
779
+ // A nested statement's positions are positions in the nested string, not in this
780
+ // command. The FIRST one keeps this statement's own separator: the shell reads the
781
+ // nested string where this statement stands.
782
+ start: null,
783
+ end: null,
784
+ assignmentOffset: null,
785
+ programOffset: null,
786
+ separator: i === 0 ? part.before : entry.separator,
787
+ });
788
+ }
593
789
  continue;
594
790
  }
595
791
  const word = words[start.at];
@@ -600,8 +796,9 @@ function locatedSegmentsOf(command, depth) {
600
796
  out.push({
601
797
  segment: null,
602
798
  assignments: leading.words,
603
- start: part.start,
604
- assignmentOffset: part.start + leading.end,
799
+ ...placed,
800
+ programOffset: null,
801
+ separator: part.before,
605
802
  });
606
803
  continue;
607
804
  }
@@ -615,8 +812,8 @@ function locatedSegmentsOf(command, depth) {
615
812
  timeoutSeconds: start.timeoutSeconds,
616
813
  },
617
814
  assignments: leading.words,
618
- start: part.start,
619
- assignmentOffset: part.start + leading.end,
815
+ ...placed,
816
+ separator: part.before,
620
817
  });
621
818
  }
622
819
  return out;
package/dist/index.d.ts CHANGED
@@ -137,6 +137,7 @@ export * from './cut-run.js';
137
137
  export * from './input-normalizer.js';
138
138
  export * from './no-test-run.js';
139
139
  export * from './schemas.js';
140
+ export * from './sleep-peek.js';
140
141
  export * from './suite-overlap.js';
141
142
  export * from './system-prompt.js';
142
143
  export * from './tools.js';