@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.
@@ -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,16 +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, in 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.
404
529
  */
405
530
  function splitSegments(command) {
531
+ const source = blankHeredocBodies(command);
406
532
  const parts = [];
407
533
  let buffer = '';
534
+ /** The offset in the command of the current buffer's first character. */
535
+ let bufferStart = 0;
408
536
  /** The nestings this position sits inside, outermost first. */
409
537
  const stack = [];
410
- for (let i = 0; i < command.length; i++) {
411
- const char = command[i] ?? '';
412
- 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];
413
541
  const top = stack[stack.length - 1];
414
542
  // A single-quoted string expands nothing at all: no escape, no substitution, and no quote
415
543
  // but its own ends it.
@@ -466,25 +594,46 @@ function splitSegments(command) {
466
594
  continue;
467
595
  }
468
596
  if ((char === '&' && next === '&') || (char === '|' && next === '|')) {
469
- parts.push(buffer);
597
+ parts.push({ text: buffer, start: bufferStart, after: char + next });
470
598
  buffer = '';
471
599
  i += 1;
600
+ bufferStart = i + 1;
472
601
  continue;
473
602
  }
474
603
  if (char === ';' || char === '|' || char === '\n') {
475
- parts.push(buffer);
604
+ parts.push({ text: buffer, start: bufferStart, after: char });
476
605
  buffer = '';
606
+ bufferStart = i + 1;
477
607
  continue;
478
608
  }
479
- if (char === '&' && command[i - 1] !== '>' && next !== '>') {
480
- parts.push(buffer);
609
+ if (char === '&' && source[i - 1] !== '>' && next !== '>') {
610
+ parts.push({ text: buffer, start: bufferStart, after: '&' });
481
611
  buffer = '';
612
+ bufferStart = i + 1;
482
613
  continue;
483
614
  }
484
615
  buffer += char;
485
616
  }
486
- parts.push(buffer);
487
- return parts.map((part) => part.trim()).filter(Boolean);
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;
488
637
  }
489
638
  /**
490
639
  * One segment's words: quotes kept whole, grouping parentheses dropped, and a `(` written
@@ -506,6 +655,38 @@ function segmentWords(text) {
506
655
  }
507
656
  return cleaned;
508
657
  }
658
+ /** {@link WORD} anchored at an offset, for walking a statement's raw words. */
659
+ const WORD_AT = new RegExp(WORD.source, 'y');
660
+ /**
661
+ * A statement's own leading `NAME=value` words, and where they end.
662
+ *
663
+ * The shell binds these to the command the statement runs, so they are the environment THAT
664
+ * program sees — which is why a caller writing a variable for a run has to write it here and not
665
+ * at the start of the whole command.
666
+ *
667
+ * @param text - One statement's text.
668
+ * @returns The assignments as written, and the offset in `text` just before the word that runs.
669
+ */
670
+ function leadingAssignments(text) {
671
+ const words = [];
672
+ let at = 0;
673
+ for (let guard = 0; guard < MAX_WRAPPERS; guard++) {
674
+ while (at < text.length && /\s/.test(text[at] ?? ''))
675
+ at += 1;
676
+ // Grouping in front of the statement (`(npx …)`, `{ …`): not a word the shell assigns with.
677
+ if (text[at] === '(' || text[at] === '{') {
678
+ at += 1;
679
+ continue;
680
+ }
681
+ WORD_AT.lastIndex = at;
682
+ const match = WORD_AT.exec(text);
683
+ if (!match || !ENV_ASSIGNMENT.test(match[0]))
684
+ return { words, end: at };
685
+ words.push(match[0]);
686
+ at += match[0].length;
687
+ }
688
+ return { words, end: at };
689
+ }
509
690
  /**
510
691
  * The segments of a command, each with its program and arguments — the answer every classifier
511
692
  * that reads a command as "this runs the suite" is asking for.
@@ -514,66 +695,151 @@ function segmentWords(text) {
514
695
  * @returns One entry per statement or pipeline stage that runs something, in order.
515
696
  */
516
697
  export function commandSegments(command) {
517
- return segmentsOf(command, 0);
698
+ return locatedSegmentsOf(command, 0).flatMap((located) => located.segment === null ? [] : [located.segment]);
518
699
  }
519
700
  /**
520
- * The bound a command puts on its own run: the largest `timeout <N>` any of its segments runs
521
- * under (`timeout 280 hurl --test …` → 280), or null when it carries none.
701
+ * Where a statement's word sits in its own raw text, as {@link segmentWords} counts the words.
522
702
  *
523
- * This is the bound `man timeout` enforces by KILLING the command — so a run that used it did not
524
- * finish, whatever the pipeline's own exit status says. It is read here, from the same parse every
525
- * other command reader uses, rather than matched as text: `timeout` in an argument
526
- * (`grep -n 'timeout 280' notes.md`) bounds nothing, and `-k 5` is the grace before the kill, not
527
- * the bound ({@link WRAPPER_VALUE_FLAGS}).
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.
528
706
  *
529
- * @param command - The shell command.
530
- * @returns The seconds the command allows itself, or null.
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.
531
710
  */
532
- export function commandTimeoutSeconds(command) {
533
- let bound = null;
534
- for (const segment of segmentsOf(command, 0)) {
535
- if (segment.timeoutSeconds === null)
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]))
536
717
  continue;
537
- bound = bound === null ? segment.timeoutSeconds : Math.max(bound, segment.timeoutSeconds);
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;
538
733
  }
539
- return bound;
734
+ return null;
540
735
  }
541
736
  /**
542
- * {@link commandSegments}'s recursion, so a `sh -c '<command>'` is opened as far as
543
- * {@link MAX_NESTING} allows.
737
+ * Every statement of a command, with its own assignments and where it sits — assignment-only
738
+ * statements included, because the variables those persist are exactly what a later statement of
739
+ * the same command reads.
740
+ *
741
+ * @param command - The shell command.
742
+ * @returns One entry per statement or pipeline stage, in order.
743
+ */
744
+ export function locatedCommandSegments(command) {
745
+ return locatedSegmentsOf(command, 0);
746
+ }
747
+ /**
748
+ * {@link locatedCommandSegments}'s recursion, so a `sh -c '<command>'` is opened as far as
749
+ * {@link MAX_NESTING} allows. A nested statement's offsets are cleared: they are positions in
750
+ * the nested string, not in the command this walk started from.
544
751
  *
545
752
  * @param command - The shell command.
546
753
  * @param depth - How many `sh -c` strings this command already sits inside.
547
- * @returns The segments.
754
+ * @returns The located statements.
548
755
  */
549
- function segmentsOf(command, depth) {
756
+ function locatedSegmentsOf(command, depth) {
550
757
  if (typeof command !== 'string' || depth > MAX_NESTING)
551
758
  return [];
552
759
  const out = [];
553
- for (const text of splitSegments(command)) {
554
- const words = segmentWords(text);
555
- if (words.length === 0)
556
- continue;
760
+ for (const part of splitSegments(command)) {
761
+ const words = segmentWords(part.text);
557
762
  const start = commandStart(words);
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
+ };
558
771
  if (start.nested !== null) {
559
- out.push(...segmentsOf(start.nested, depth + 1));
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
+ }
560
789
  continue;
561
790
  }
562
791
  const word = words[start.at];
563
- // Only wrappers and assignments: nothing this segment runs.
564
- if (word === undefined)
792
+ if (word === undefined) {
793
+ // Only wrappers and assignments: nothing this statement runs, but the variables it sets
794
+ // persist in the shell the rest of the command runs in.
795
+ if (leading.words.length > 0)
796
+ out.push({
797
+ segment: null,
798
+ assignments: leading.words,
799
+ ...placed,
800
+ programOffset: null,
801
+ separator: part.before,
802
+ });
565
803
  continue;
804
+ }
566
805
  const program = programName(word);
567
806
  const args = words.slice(start.at + 1);
568
807
  out.push({
569
- program,
570
- args,
571
- text: [program, ...args].join(' '),
572
- timeoutSeconds: start.timeoutSeconds,
808
+ segment: {
809
+ program,
810
+ args,
811
+ text: [program, ...args].join(' '),
812
+ timeoutSeconds: start.timeoutSeconds,
813
+ },
814
+ assignments: leading.words,
815
+ ...placed,
816
+ separator: part.before,
573
817
  });
574
818
  }
575
819
  return out;
576
820
  }
821
+ /**
822
+ * The bound a command puts on its own run: the largest `timeout <N>` any of its segments runs
823
+ * under (`timeout 280 hurl --test …` → 280), or null when it carries none.
824
+ *
825
+ * This is the bound `man timeout` enforces by KILLING the command — so a run that used it did not
826
+ * finish, whatever the pipeline's own exit status says. It is read here, from the same parse every
827
+ * other command reader uses, rather than matched as text: `timeout` in an argument
828
+ * (`grep -n 'timeout 280' notes.md`) bounds nothing, and `-k 5` is the grace before the kill, not
829
+ * the bound ({@link WRAPPER_VALUE_FLAGS}).
830
+ *
831
+ * @param command - The shell command.
832
+ * @returns The seconds the command allows itself, or null.
833
+ */
834
+ export function commandTimeoutSeconds(command) {
835
+ let bound = null;
836
+ for (const segment of commandSegments(command)) {
837
+ if (segment.timeoutSeconds === null)
838
+ continue;
839
+ bound = bound === null ? segment.timeoutSeconds : Math.max(bound, segment.timeoutSeconds);
840
+ }
841
+ return bound;
842
+ }
577
843
  /**
578
844
  * Whether any segment of the command runs a program whose command-position text matches
579
845
  * `pattern` — the one question a runner-kind table asks.
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';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkIG;AAEH,cAAc,qBAAqB,CAAA;AACnC,cAAc,uBAAuB,CAAA;AACrC,cAAc,oBAAoB,CAAA;AAClC,cAAc,uBAAuB,CAAA;AACrC,cAAc,cAAc,CAAA;AAC5B,cAAc,uBAAuB,CAAA;AACrC,cAAc,kBAAkB,CAAA;AAChC,cAAc,cAAc,CAAA;AAC5B,cAAc,oBAAoB,CAAA;AAClC,cAAc,oBAAoB,CAAA;AAClC,cAAc,YAAY,CAAA;AAC1B,cAAc,sBAAsB,CAAA;AACpC,cAAc,YAAY,CAAA;AAC1B,cAAc,gBAAgB,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkIG;AAEH,cAAc,qBAAqB,CAAA;AACnC,cAAc,uBAAuB,CAAA;AACrC,cAAc,oBAAoB,CAAA;AAClC,cAAc,uBAAuB,CAAA;AACrC,cAAc,cAAc,CAAA;AAC5B,cAAc,uBAAuB,CAAA;AACrC,cAAc,kBAAkB,CAAA;AAChC,cAAc,cAAc,CAAA;AAC5B,cAAc,iBAAiB,CAAA;AAC/B,cAAc,oBAAoB,CAAA;AAClC,cAAc,oBAAoB,CAAA;AAClC,cAAc,YAAY,CAAA;AAC1B,cAAc,sBAAsB,CAAA;AACpC,cAAc,YAAY,CAAA;AAC1B,cAAc,gBAAgB,CAAA"}
package/dist/index.js 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';
@@ -0,0 +1,51 @@
1
+ /**
2
+ * A leading `sleep` in an executor's command: the seconds it would have waited, and the command
3
+ * that follows it.
4
+ *
5
+ * X1 xr-molecule-flash-m105 (+08:49 → +10:53, 2026-10-17) spent 225 s of its 60-minute clock
6
+ * inside `exec_command` calls of the shape `sleep N; tail <task log>`. The platform's own launch
7
+ * note said "peek with exec_command: tail <log>" beside "never sleep", and the only concrete
8
+ * instruction was the peek, so the executor built the poll by hand. Two of its three sleeps were
9
+ * provably dead — the task had ended 0.1 s in (`playwright: not found`, exit 127) and another had
10
+ * already printed `Error: No tests found` — and the third spent 120 s asleep beside a live
11
+ * 139-test run whose failures were already in the log.
12
+ *
13
+ * The platform does not have to answer "what is the task doing" from a sleep: `wait_for_task`
14
+ * already returns the moment the task ends, early on a proven repeating cause, and with the
15
+ * progress and failures the log holds. This module answers only the parsing question — is the
16
+ * command's FIRST statement a `sleep` of {@link MIN_PEEK_SLEEP_SECONDS} seconds or more, and what
17
+ * follows it — from the package's own shell parse ({@link locatedCommandSegments}), so a `sleep`
18
+ * named in an argument (`grep -n 'sleep 60' notes.md`) or written after another statement
19
+ * (`cd x && sleep 60`) is not one.
20
+ *
21
+ * @module
22
+ */
23
+ /**
24
+ * The shortest leading sleep the platform drops: below it the wall clock it costs is noise, and a
25
+ * short settle (`sleep 2; curl …`) is a legitimate thing to run.
26
+ */
27
+ export declare const MIN_PEEK_SLEEP_SECONDS = 5;
28
+ /** The leading `sleep` a command opens with, and the command that follows it. */
29
+ export interface LeadingSleep {
30
+ /** How many seconds the statement would have slept (GNU sleep sums its operands). */
31
+ seconds: number;
32
+ /** What follows the leading statement, trimmed — '' when the sleep was the whole command. */
33
+ rest: string;
34
+ }
35
+ /**
36
+ * The leading `sleep` of a command, when the first statement is one and it asks for at least
37
+ * {@link MIN_PEEK_SLEEP_SECONDS}.
38
+ *
39
+ * The statement is read by {@link locatedCommandSegments} — the same parse every other command
40
+ * classifier uses — so only a `sleep` in command position of the FIRST statement counts. The end
41
+ * of that statement is then found by walking forward from its own start: the statement is
42
+ * `sleep <duration>`, whose words carry no quotes and open no substitution, so the first unquoted
43
+ * `;`, newline or `&` ends it. A `|` is refused: dropping the left side of a pipeline would hand
44
+ * the right side its stdin instead, and a `||` fallback would run on a success that never did.
45
+ *
46
+ * @param command - The shell command the executor sent.
47
+ * @returns The seconds and the remaining command, or null when the command does not open with a
48
+ * long `sleep`.
49
+ */
50
+ export declare function leadingSleepPeek(command: string): LeadingSleep | null;
51
+ //# sourceMappingURL=sleep-peek.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sleep-peek.d.ts","sourceRoot":"","sources":["../src/sleep-peek.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAIH;;;GAGG;AACH,eAAO,MAAM,sBAAsB,IAAI,CAAA;AAEvC,iFAAiF;AACjF,MAAM,WAAW,YAAY;IAC3B,qFAAqF;IACrF,OAAO,EAAE,MAAM,CAAA;IACf,6FAA6F;IAC7F,IAAI,EAAE,MAAM,CAAA;CACb;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,MAAM,GAAG,YAAY,GAAG,IAAI,CAyBrE"}
@@ -0,0 +1,76 @@
1
+ /**
2
+ * A leading `sleep` in an executor's command: the seconds it would have waited, and the command
3
+ * that follows it.
4
+ *
5
+ * X1 xr-molecule-flash-m105 (+08:49 → +10:53, 2026-10-17) spent 225 s of its 60-minute clock
6
+ * inside `exec_command` calls of the shape `sleep N; tail <task log>`. The platform's own launch
7
+ * note said "peek with exec_command: tail <log>" beside "never sleep", and the only concrete
8
+ * instruction was the peek, so the executor built the poll by hand. Two of its three sleeps were
9
+ * provably dead — the task had ended 0.1 s in (`playwright: not found`, exit 127) and another had
10
+ * already printed `Error: No tests found` — and the third spent 120 s asleep beside a live
11
+ * 139-test run whose failures were already in the log.
12
+ *
13
+ * The platform does not have to answer "what is the task doing" from a sleep: `wait_for_task`
14
+ * already returns the moment the task ends, early on a proven repeating cause, and with the
15
+ * progress and failures the log holds. This module answers only the parsing question — is the
16
+ * command's FIRST statement a `sleep` of {@link MIN_PEEK_SLEEP_SECONDS} seconds or more, and what
17
+ * follows it — from the package's own shell parse ({@link locatedCommandSegments}), so a `sleep`
18
+ * named in an argument (`grep -n 'sleep 60' notes.md`) or written after another statement
19
+ * (`cd x && sleep 60`) is not one.
20
+ *
21
+ * @module
22
+ */
23
+ import { durationSeconds, locatedCommandSegments } from './command-position.js';
24
+ /**
25
+ * The shortest leading sleep the platform drops: below it the wall clock it costs is noise, and a
26
+ * short settle (`sleep 2; curl …`) is a legitimate thing to run.
27
+ */
28
+ export const MIN_PEEK_SLEEP_SECONDS = 5;
29
+ /**
30
+ * The leading `sleep` of a command, when the first statement is one and it asks for at least
31
+ * {@link MIN_PEEK_SLEEP_SECONDS}.
32
+ *
33
+ * The statement is read by {@link locatedCommandSegments} — the same parse every other command
34
+ * classifier uses — so only a `sleep` in command position of the FIRST statement counts. The end
35
+ * of that statement is then found by walking forward from its own start: the statement is
36
+ * `sleep <duration>`, whose words carry no quotes and open no substitution, so the first unquoted
37
+ * `;`, newline or `&` ends it. A `|` is refused: dropping the left side of a pipeline would hand
38
+ * the right side its stdin instead, and a `||` fallback would run on a success that never did.
39
+ *
40
+ * @param command - The shell command the executor sent.
41
+ * @returns The seconds and the remaining command, or null when the command does not open with a
42
+ * long `sleep`.
43
+ */
44
+ export function leadingSleepPeek(command) {
45
+ if (typeof command !== 'string')
46
+ return null;
47
+ const first = locatedCommandSegments(command)[0];
48
+ if (!first || first.start === null || first.segment === null)
49
+ return null;
50
+ if (first.segment.program !== 'sleep')
51
+ return null;
52
+ // GNU sleep sums its operands (`sleep 45 15`); every one of them has to be a duration.
53
+ let seconds = 0;
54
+ for (const arg of first.segment.args) {
55
+ const part = durationSeconds(arg);
56
+ if (part === null)
57
+ return null;
58
+ seconds += part;
59
+ }
60
+ if (seconds < MIN_PEEK_SLEEP_SECONDS)
61
+ return null;
62
+ let end = command.length;
63
+ for (let i = first.start; i < command.length; i++) {
64
+ const char = command[i] ?? '';
65
+ if (char === '|')
66
+ return null;
67
+ if (char === ';' || char === '\n' || char === '&') {
68
+ end = i;
69
+ break;
70
+ }
71
+ }
72
+ let at = end;
73
+ while (at < command.length && /[;&\s]/.test(command[at] ?? ''))
74
+ at += 1;
75
+ return { seconds, rest: command.slice(at).trim() };
76
+ }