@molecule/api-ai-tools 1.9.6 → 1.9.8

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.
@@ -541,6 +541,26 @@ function splitSegments(command) {
541
541
  let buffer = '';
542
542
  /** The offset in the command of the current buffer's first character. */
543
543
  let bufferStart = 0;
544
+ /** How many `( … )` / `{ … }` groups are open at the current position. */
545
+ let groupDepth = 0;
546
+ /** The depth each character of `buffer` was written at, aligned with it (-1 when quoted). */
547
+ let depths = [];
548
+ /**
549
+ * Append shell text to the statement being read, recording the depth it was written at.
550
+ * Text inside a quote or a command substitution is recorded as -1: a `>` there is a literal
551
+ * character or a nested command's business, never the outer command's redirect.
552
+ */
553
+ const put = (text, depth = groupDepth) => {
554
+ for (let n = 0; n < text.length; n++)
555
+ depths.push(depth);
556
+ buffer += text;
557
+ };
558
+ /** Close the statement being read at `after`. */
559
+ const end = (after) => {
560
+ parts.push({ text: buffer, start: bufferStart, after, depths });
561
+ buffer = '';
562
+ depths = [];
563
+ };
544
564
  /** The nestings this position sits inside, outermost first. */
545
565
  const stack = [];
546
566
  for (let i = 0; i < source.length; i++) {
@@ -550,7 +570,7 @@ function splitSegments(command) {
550
570
  // A single-quoted string expands nothing at all: no escape, no substitution, and no quote
551
571
  // but its own ends it.
552
572
  if (top === 'single') {
553
- buffer += char;
573
+ put(char, -1);
554
574
  if (char === "'")
555
575
  stack.pop();
556
576
  continue;
@@ -559,7 +579,7 @@ function splitSegments(command) {
559
579
  // inside single quotes (handled above). The `(` is consumed here so the depth is counted once.
560
580
  if (char === '$' && next === '(') {
561
581
  stack.push('sub');
562
- buffer += '$(';
582
+ put('$(', -1);
563
583
  i += 1;
564
584
  continue;
565
585
  }
@@ -569,21 +589,21 @@ function splitSegments(command) {
569
589
  if (char === '"')
570
590
  stack.pop();
571
591
  else if (char === '\\' && next !== undefined) {
572
- buffer += char + next;
592
+ put(char + next, -1);
573
593
  i += 1;
574
594
  continue;
575
595
  }
576
- buffer += char;
596
+ put(char, -1);
577
597
  continue;
578
598
  }
579
599
  if (char === '"') {
580
600
  stack.push('double');
581
- buffer += char;
601
+ put(char, -1);
582
602
  continue;
583
603
  }
584
604
  if (char === "'") {
585
605
  stack.push('single');
586
- buffer += char;
606
+ put(char, -1);
587
607
  continue;
588
608
  }
589
609
  // Inside a substitution or a subshell of one: its own parentheses nest, its `|`/`;`/`&&`
@@ -593,41 +613,53 @@ function splitSegments(command) {
593
613
  stack.push('sub');
594
614
  else if (char === ')')
595
615
  stack.pop();
596
- buffer += char;
616
+ put(char, -1);
597
617
  continue;
598
618
  }
599
619
  if (char === '\\' && next !== undefined) {
600
- buffer += char + next;
620
+ // An escaped character is a literal (`\>` writes no file).
621
+ put(char + next, -1);
601
622
  i += 1;
602
623
  continue;
603
624
  }
625
+ // Grouping belongs to the shell, not to a word: a statement written inside `( … )` or
626
+ // `{ … }` writes through that group, so the depth travels with every character a caller
627
+ // reading a redirect may ask about.
628
+ if (char === '(' || char === '{') {
629
+ put(char);
630
+ groupDepth += 1;
631
+ continue;
632
+ }
633
+ if (char === ')' || char === '}') {
634
+ groupDepth = Math.max(0, groupDepth - 1);
635
+ put(char);
636
+ continue;
637
+ }
604
638
  if ((char === '&' && next === '&') || (char === '|' && next === '|')) {
605
- parts.push({ text: buffer, start: bufferStart, after: char + next });
606
- buffer = '';
639
+ end(char + next);
607
640
  i += 1;
608
641
  bufferStart = i + 1;
609
642
  continue;
610
643
  }
611
644
  if (char === ';' || char === '|' || char === '\n') {
612
- parts.push({ text: buffer, start: bufferStart, after: char });
613
- buffer = '';
645
+ end(char);
614
646
  bufferStart = i + 1;
615
647
  continue;
616
648
  }
617
649
  if (char === '&' && source[i - 1] !== '>' && next !== '>') {
618
- parts.push({ text: buffer, start: bufferStart, after: '&' });
619
- buffer = '';
650
+ end('&');
620
651
  bufferStart = i + 1;
621
652
  continue;
622
653
  }
623
- buffer += char;
654
+ put(char);
624
655
  }
625
- parts.push({ text: buffer, start: bufferStart, after: '' });
656
+ end('');
626
657
  const out = [];
627
658
  // The separator of the last part, kept across the empty ones so a statement's own separator
628
659
  // survives the blank lines around it (`a &&\n b` and a heredoc's blanked body alike).
629
660
  let lastAfter = null;
630
661
  for (const part of parts) {
662
+ const trimStart = part.text.length - part.text.trimStart().length;
631
663
  const text = part.text.trim();
632
664
  if (text === '') {
633
665
  lastAfter = part.after;
@@ -635,14 +667,63 @@ function splitSegments(command) {
635
667
  }
636
668
  out.push({
637
669
  text,
638
- start: part.start + (part.text.length - part.text.trimStart().length),
670
+ start: part.start + trimStart,
639
671
  after: part.after,
640
672
  before: lastAfter ?? '',
673
+ depths: part.depths.slice(trimStart),
641
674
  });
642
675
  lastAfter = part.after;
643
676
  }
644
677
  return out;
645
678
  }
679
+ /** A redirect's target word: a quoted one keeps its spaces (`tee 'out file.txt'`). */
680
+ const REDIRECT_TARGET_WORD = String.raw `(?:'[^']*'|"[^"]*"|[^\s;&|]+)`;
681
+ /**
682
+ * A `>`, `>>` or `&>` redirect written at a statement's own level, with the word it writes to.
683
+ * The lookbehind is what keeps `2>&1` out: the `>` of a descriptor redirect follows a digit.
684
+ */
685
+ const REDIRECT_AND_TARGET = new RegExp(String.raw `(?<![0-9&>])(?:&>|1?>>?)\s*(${REDIRECT_TARGET_WORD})`, 'g');
686
+ /** A word with one layer of MATCHING shell quotes removed (`'out file.txt'` → `out file.txt`). */
687
+ function unquotedWord(word) {
688
+ return word.replace(/^(['"])(.*)\1$/, '$2');
689
+ }
690
+ /**
691
+ * The file a statement sends its own output to, when it redirects it at the command's own level.
692
+ *
693
+ * Three shapes are deliberately not one: a redirect inside a `( … )` / `{ … }` group (it writes
694
+ * through that group, not through the command's own shell), a statement that feeds a heredoc's
695
+ * file (`cat > cfg.ts <<'EOF'` writes a config), and `/dev/null` or a descriptor (`2>&1`).
696
+ *
697
+ * @param text - The statement's text, as the tokenizer split it.
698
+ * @param depths - The depth each character of `text` was written at: the open-group count, 0 at
699
+ * the command's own level, -1 inside a quote or a command substitution.
700
+ * @param program - The word in command position (`tee` writes through its own file argument).
701
+ * @param args - The statement's words after the program.
702
+ * @returns The target with one layer of quotes removed, or null.
703
+ */
704
+ function redirectTargetOf(text, depths, program, args) {
705
+ // A heredoc's own file is a config the command WRITES, never where the run's output goes
706
+ // (X1 flash-m44).
707
+ if (/(?:^|\s)<<(?![<])-?/.test(text))
708
+ return null;
709
+ // `| tee file` is a statement of its own: the pipe is a statement separator here.
710
+ if (program === 'tee') {
711
+ const target = args.find((arg) => !arg.startsWith('-'));
712
+ return target === undefined ? null : unquotedWord(target);
713
+ }
714
+ let last = null;
715
+ for (const match of text.matchAll(REDIRECT_AND_TARGET)) {
716
+ const word = unquotedWord(match[1] ?? '');
717
+ // Only the command's own level counts: not a group's redirect (depth > 0), and not one
718
+ // written inside a quote or a command substitution (depth -1).
719
+ if ((depths[match.index ?? 0] ?? 0) !== 0)
720
+ continue;
721
+ if (word === '/dev/null' || word.startsWith('&'))
722
+ continue;
723
+ last = word;
724
+ }
725
+ return last;
726
+ }
646
727
  /**
647
728
  * One segment's words: quotes kept whole, grouping parentheses dropped, and a `(` written
648
729
  * against the first word taken off it (`(npx playwright test)`).
@@ -912,6 +993,9 @@ function locatedSegmentsOf(command, depth) {
912
993
  assignmentOffset: part.start + leading.end,
913
994
  programOffset: words[start.at] === undefined ? null : part.start + (wordOffset(part.text, start.at) ?? 0),
914
995
  };
996
+ const program = words[start.at] === undefined ? '' : programName(words[start.at] ?? '');
997
+ const args = words.slice(start.at + 1);
998
+ const redirectTarget = redirectTargetOf(part.text, part.depths, program, args);
915
999
  if (start.nested !== null) {
916
1000
  const inner = locatedSegmentsOf(start.nested, depth + 1);
917
1001
  for (let i = 0; i < inner.length; i += 1) {
@@ -927,6 +1011,12 @@ function locatedSegmentsOf(command, depth) {
927
1011
  end: null,
928
1012
  assignmentOffset: null,
929
1013
  programOffset: null,
1014
+ // `sh -c '…' &` backgrounds the command inside the string too.
1015
+ terminator: part.after === '&' ? '&' : entry.terminator,
1016
+ // A redirect written OUTSIDE the string (`sh -c '…' > run.log`) carries the nested
1017
+ // command's output, so the first nested statement holds it — unless that statement
1018
+ // redirects on its own, which takes the output away from the outer file.
1019
+ redirectTarget: i === 0 && entry.redirectTarget === null ? redirectTarget : entry.redirectTarget,
930
1020
  separator: i === 0 ? part.before : entry.separator,
931
1021
  });
932
1022
  }
@@ -943,11 +1033,11 @@ function locatedSegmentsOf(command, depth) {
943
1033
  ...placed,
944
1034
  programOffset: null,
945
1035
  separator: part.before,
1036
+ terminator: part.after,
1037
+ redirectTarget,
946
1038
  });
947
1039
  continue;
948
1040
  }
949
- const program = programName(word);
950
- const args = words.slice(start.at + 1);
951
1041
  out.push({
952
1042
  segment: {
953
1043
  program,
@@ -958,6 +1048,8 @@ function locatedSegmentsOf(command, depth) {
958
1048
  assignments: leading.words,
959
1049
  ...placed,
960
1050
  separator: part.before,
1051
+ terminator: part.after,
1052
+ redirectTarget,
961
1053
  });
962
1054
  }
963
1055
  return out;
@@ -984,6 +1076,87 @@ export function commandTimeoutSeconds(command) {
984
1076
  }
985
1077
  return bound;
986
1078
  }
1079
+ /**
1080
+ * The command with the `timeout <N>` wrapper lifted off the statements `which` selects, and the
1081
+ * largest bound that was lifted.
1082
+ *
1083
+ * The platform moves a run to the background precisely because its own bound cannot fit this
1084
+ * tool's ceiling, and a background task has no ceiling — its wait is bounded by `wait_for_task`
1085
+ * and the turn's deadline. The `timeout N` an executor wrote to fit the ceiling therefore keeps
1086
+ * killing the run at N in the background, where nothing needed it: X1 xr-molecule-flash-m143
1087
+ * (+24:45) wrote `timeout 280` for a 31-test walk, the tool backgrounded it, and the prefix stopped
1088
+ * the walk with no tally — then the cut sentence told the executor to "run it in the background",
1089
+ * which is where it already was.
1090
+ *
1091
+ * The wrapper is found by the same parse every other command reader uses ({@link commandStart}):
1092
+ * `timeout` in an argument bounds nothing, and the wrapper's own flags (`-k`/`--kill-after`,
1093
+ * `-s`/`--signal`) are stepped over, so `timeout -k 5 280 …` is `280`.
1094
+ *
1095
+ * @param command - The shell command.
1096
+ * @param which - The statements to lift the wrapper from (a suite run, for the background path).
1097
+ * @returns The rewritten command, and the largest lifted bound (null when nothing was lifted).
1098
+ */
1099
+ export function withoutTimeoutWrapper(command, which) {
1100
+ if (typeof command !== 'string' || !command.trim())
1101
+ return { command, seconds: null };
1102
+ /** The spans to remove, as offsets in the command — applied back to front. */
1103
+ const removals = [];
1104
+ let seconds = null;
1105
+ for (const located of locatedSegmentsOf(command, 0)) {
1106
+ const segment = located.segment;
1107
+ if (segment === null || segment.timeoutSeconds === null)
1108
+ continue;
1109
+ // A statement inside a nested `sh -c '…'` has no offsets here: its positions are positions in
1110
+ // that string, so nothing may be spliced at them.
1111
+ if (located.start === null || located.programOffset === null)
1112
+ continue;
1113
+ if (!which(segment))
1114
+ continue;
1115
+ // Every word in front of the program: the assignments and the wrappers. The duration belongs
1116
+ // to the wrapper, so the walk has to see the words, not just the parse's total bound.
1117
+ const prefix = command.slice(located.start, located.programOffset);
1118
+ const words = [];
1119
+ const at = new RegExp(WORD.source, 'g');
1120
+ for (let match = at.exec(prefix); match !== null; match = at.exec(prefix)) {
1121
+ words.push({
1122
+ text: match[0],
1123
+ start: located.start + match.index,
1124
+ end: located.start + match.index + match[0].length,
1125
+ });
1126
+ }
1127
+ for (let i = 0; i < words.length; i += 1) {
1128
+ const word = words[i];
1129
+ if (word === undefined || programName(word.text) !== 'timeout')
1130
+ continue;
1131
+ // The wrapper's own flags, then the duration — the same walk {@link commandStart} makes.
1132
+ const withValue = WRAPPER_VALUE_FLAGS.timeout ?? [];
1133
+ let next = i + 1;
1134
+ while (next < words.length && (words[next]?.text ?? '').startsWith('-')) {
1135
+ const flag = unquoted(words[next]?.text ?? '');
1136
+ next += !flag.includes('=') && withValue.includes(flag) ? 2 : 1;
1137
+ }
1138
+ const duration = words[next];
1139
+ const bound = duration === undefined ? null : durationSeconds(unquoted(duration.text));
1140
+ if (duration === undefined || bound === null)
1141
+ continue;
1142
+ seconds = seconds === null ? bound : Math.max(seconds, bound);
1143
+ // The whitespace between the duration and the program goes too, so the command does not
1144
+ // carry a double space where the wrapper stood.
1145
+ let end = duration.end;
1146
+ while (end < command.length && /\s/.test(command[end] ?? ''))
1147
+ end += 1;
1148
+ removals.push({ start: word.start, end });
1149
+ i = next;
1150
+ }
1151
+ }
1152
+ if (removals.length === 0)
1153
+ return { command, seconds: null };
1154
+ let out = command;
1155
+ for (const removal of removals.sort((a, b) => b.start - a.start)) {
1156
+ out = out.slice(0, removal.start) + out.slice(removal.end);
1157
+ }
1158
+ return { command: out, seconds };
1159
+ }
987
1160
  /**
988
1161
  * Whether any segment of the command runs a program whose command-position text matches
989
1162
  * `pattern` — the one question a runner-kind table asks.
package/dist/cut-run.d.ts CHANGED
@@ -134,6 +134,14 @@ export interface CutRun {
134
134
  seconds: number;
135
135
  /** Whether this tool's budget stopped it or the `timeout` the command carried. */
136
136
  stoppedBy: CutBy;
137
+ /**
138
+ * Whether this was a detached background task, read with no budget of this tool's own
139
+ * ({@link commandCutRun}'s `budgetSeconds` is 0). A background run is bounded by what its own
140
+ * command carries — so a `command-timeout` cut there is that prefix killing the walk, and the
141
+ * executor must be told to drop the prefix, never to start the run in the background it is
142
+ * already in (X1 xr-molecule-flash-m143 +24:45).
143
+ */
144
+ background: boolean;
137
145
  /**
138
146
  * The exit code the run's own process reported, or null when the code in the result belongs to
139
147
  * the pipeline's filter (`… | tail`) rather than to the run — the case that read as success.
@@ -1 +1 @@
1
- {"version":3,"file":"cut-run.d.ts","sourceRoot":"","sources":["../src/cut-run.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AAKH;;;GAGG;AACH,eAAO,MAAM,aAAa,kBAAkB,CAAA;AAE5C,kFAAkF;AAClF,MAAM,MAAM,eAAe,GAAG,KAAK,GAAG,OAAO,aAAa,CAAA;AAE1D;;;;;;;;;;GAUG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,OAAO,GAAG,eAAe,CAGhE;AAiBD;;;;;;;;GAQG;AACH,MAAM,WAAW,YAAY;IAC3B;;;OAGG;IACH,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB;;;;;OAKG;IACH,SAAS,EAAE,MAAM,CAAA;IACjB,wEAAwE;IACxE,QAAQ,EAAE,OAAO,CAAA;CAClB;AAkDD;;;;;;;;;;GAUG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,YAAY,GAAG,IAAI,CA4B9D;AAED;;;;;;;;GAQG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAEhD;AAKD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,gBAAgB,CAC9B,QAAQ,EAAE,MAAM,GAAG,IAAI,EACvB,MAAM,EAAE,MAAM,EACd,KAAK,EAAE,OAAO,GACb,MAAM,GAAG,IAAI,CAIf;AAED,0BAA0B;AAC1B,MAAM,MAAM,KAAK;AACf,sEAAsE;AACpE,aAAa;AACf,oDAAoD;GAClD,iBAAiB;AACnB;;;;GAIG;GACD,cAAc,CAAA;AAElB,wDAAwD;AACxD,MAAM,WAAW,MAAM;IACrB,iDAAiD;IACjD,OAAO,EAAE,MAAM,CAAA;IACf,kFAAkF;IAClF,SAAS,EAAE,KAAK,CAAA;IAChB;;;OAGG;IACH,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;IACvB;;;OAGG;IACH,GAAG,EAAE,MAAM,GAAG,IAAI,CAAA;IAClB;;;OAGG;IACH,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;IACpB,qCAAqC;IACrC,IAAI,EAAE,OAAO,GAAG,OAAO,CAAA;IACvB,yEAAyE;IACzE,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;CACrB;AAsDD;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,wBAAgB,aAAa,CAC3B,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,OAAO,EACf,SAAS,EAAE,MAAM,EACjB,aAAa,SAAI,EACjB,SAAS,CAAC,EAAE,MAAM,GACjB,MAAM,GAAG,IAAI,CAgFf;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,WAAW,CAAC,MAAM,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CAgB1D;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAuBlD"}
1
+ {"version":3,"file":"cut-run.d.ts","sourceRoot":"","sources":["../src/cut-run.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AAKH;;;GAGG;AACH,eAAO,MAAM,aAAa,kBAAkB,CAAA;AAE5C,kFAAkF;AAClF,MAAM,MAAM,eAAe,GAAG,KAAK,GAAG,OAAO,aAAa,CAAA;AAE1D;;;;;;;;;;GAUG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,OAAO,GAAG,eAAe,CAGhE;AAiBD;;;;;;;;GAQG;AACH,MAAM,WAAW,YAAY;IAC3B;;;OAGG;IACH,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB;;;;;OAKG;IACH,SAAS,EAAE,MAAM,CAAA;IACjB,wEAAwE;IACxE,QAAQ,EAAE,OAAO,CAAA;CAClB;AAkDD;;;;;;;;;;GAUG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,YAAY,GAAG,IAAI,CA4B9D;AAED;;;;;;;;GAQG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAEhD;AAKD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,gBAAgB,CAC9B,QAAQ,EAAE,MAAM,GAAG,IAAI,EACvB,MAAM,EAAE,MAAM,EACd,KAAK,EAAE,OAAO,GACb,MAAM,GAAG,IAAI,CAIf;AAED,0BAA0B;AAC1B,MAAM,MAAM,KAAK;AACf,sEAAsE;AACpE,aAAa;AACf,oDAAoD;GAClD,iBAAiB;AACnB;;;;GAIG;GACD,cAAc,CAAA;AAElB,wDAAwD;AACxD,MAAM,WAAW,MAAM;IACrB,iDAAiD;IACjD,OAAO,EAAE,MAAM,CAAA;IACf,kFAAkF;IAClF,SAAS,EAAE,KAAK,CAAA;IAChB;;;;;;OAMG;IACH,UAAU,EAAE,OAAO,CAAA;IACnB;;;OAGG;IACH,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;IACvB;;;OAGG;IACH,GAAG,EAAE,MAAM,GAAG,IAAI,CAAA;IAClB;;;OAGG;IACH,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;IACpB,qCAAqC;IACrC,IAAI,EAAE,OAAO,GAAG,OAAO,CAAA;IACvB,yEAAyE;IACzE,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;CACrB;AAsDD;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,wBAAgB,aAAa,CAC3B,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,OAAO,EACf,SAAS,EAAE,MAAM,EACjB,aAAa,SAAI,EACjB,SAAS,CAAC,EAAE,MAAM,GACjB,MAAM,GAAG,IAAI,CAuFf;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,WAAW,CAAC,MAAM,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CAiB1D;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAiClD"}
package/dist/cut-run.js CHANGED
@@ -296,6 +296,9 @@ export function commandCutRun(command, result, elapsedMs, budgetSeconds = 0, raw
296
296
  // tool budget at all (a reader re-reading a background run) the command's own bound is the only
297
297
  // one there is.
298
298
  const ownBoundStops = budgetSeconds === 0 || (declared !== null && declared < budgetSeconds);
299
+ // No budget of this tool's in force is what a background reader is: the task was detached, and
300
+ // nothing bounded it but what its own command carries.
301
+ const background = budgetSeconds === 0;
299
302
  // `timeout`'s own 124 — the code the backend's wrapper returns when the TOOL's budget fires,
300
303
  // whatever the pipeline ended with (`backends/sandbox.ts`). Which of the two bounds fired is the
301
304
  // clock's to say: a run killed at the tool's budget reports at least the budget, and one killed
@@ -308,6 +311,7 @@ export function commandCutRun(command, result, elapsedMs, budgetSeconds = 0, raw
308
311
  return {
309
312
  seconds: ownFired ? declared : budgetSeconds,
310
313
  stoppedBy: ownFired ? 'command-timeout' : 'tool-budget',
314
+ background,
311
315
  exitCode,
312
316
  ...progress,
313
317
  doing,
@@ -317,6 +321,7 @@ export function commandCutRun(command, result, elapsedMs, budgetSeconds = 0, raw
317
321
  return {
318
322
  seconds: declared,
319
323
  stoppedBy: 'command-timeout',
324
+ background,
320
325
  // A code the filter produced is not the run's: `… | tail` handed back 0 forever (m93).
321
326
  exitCode: withheld || exitCode === 0 ? null : exitCode,
322
327
  ...progress,
@@ -329,6 +334,7 @@ export function commandCutRun(command, result, elapsedMs, budgetSeconds = 0, raw
329
334
  return {
330
335
  seconds: budgetSeconds,
331
336
  stoppedBy: 'tool-budget',
337
+ background,
332
338
  exitCode: exitCode === 0 ? null : exitCode,
333
339
  ...progress,
334
340
  doing,
@@ -344,6 +350,7 @@ export function commandCutRun(command, result, elapsedMs, budgetSeconds = 0, raw
344
350
  return {
345
351
  seconds: usedBudget ? budgetSeconds : Math.max(1, Math.round(elapsedMs / 1000)),
346
352
  stoppedBy: usedBudget ? 'tool-budget' : 'process-stop',
353
+ background,
347
354
  exitCode: withheld || exitCode === 0 ? null : exitCode,
348
355
  ...progress,
349
356
  doing,
@@ -377,6 +384,7 @@ export function cutRunFacts(result) {
377
384
  return {
378
385
  seconds: c.seconds,
379
386
  stoppedBy: c.stoppedBy,
387
+ background: c.background === true,
380
388
  exitCode: typeof c.exitCode === 'number' ? c.exitCode : null,
381
389
  ran: typeof c.ran === 'number' ? c.ran : null,
382
390
  total: typeof c.total === 'number' ? c.total : null,
@@ -401,9 +409,19 @@ export function cutRunSentence(run) {
401
409
  const by = run.stoppedBy === 'tool-budget'
402
410
  ? "this tool's limit"
403
411
  : run.stoppedBy === 'command-timeout'
404
- ? `its own \`timeout ${run.seconds}\``
412
+ ? run.background
413
+ ? `the command's own \`timeout ${run.seconds}\`, which killed it at ${run.seconds} s`
414
+ : `its own \`timeout ${run.seconds}\``
405
415
  : 'a process kill outside this tool (no `timeout` of its own and no limit of this tool had fired)';
406
- const background = 'run it in the background (run_in_background + wait_for_task)';
416
+ // What the reader must do next. A run that was ALREADY a background task has nothing to gain
417
+ // from being sent to the background again — when its own `timeout` is what stopped it, that
418
+ // prefix is the thing to drop (X1 xr-molecule-flash-m143 +24:45: three cut sentences in one run
419
+ // told a background walk to "run it in the background", which is where it already was).
420
+ const background = run.background
421
+ ? run.stoppedBy === 'command-timeout'
422
+ ? `drop the \`timeout\` prefix and send it again — in the background a run is bounded by \`wait_for_task\` and the turn's deadline, not by this tool's ceiling, so the bound you wrote is what killed it`
423
+ : 'send it again when you are ready'
424
+ : 'run it in the background (run_in_background + wait_for_task)';
407
425
  const missing = run.ran !== null && run.total !== null ? run.total - run.ran : null;
408
426
  const lead = missing !== null
409
427
  ? `Ran ${run.ran} of ${run.total} ${run.noun} in ${run.seconds} s and was stopped by ${by} — the ${missing} it never reached ${missing === 1 ? 'has' : 'have'} not run; ${background}`
package/dist/index.d.ts CHANGED
@@ -136,6 +136,7 @@ export * from './command-position.js';
136
136
  export * from './cut-run.js';
137
137
  export * from './failure-kind.js';
138
138
  export * from './input-normalizer.js';
139
+ export * from './line-diff.js';
139
140
  export * from './no-test-run.js';
140
141
  export * from './pipeline-exit-note.js';
141
142
  export * from './schemas.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,mBAAmB,CAAA;AACjC,cAAc,uBAAuB,CAAA;AACrC,cAAc,kBAAkB,CAAA;AAChC,cAAc,yBAAyB,CAAA;AACvC,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"}
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,mBAAmB,CAAA;AACjC,cAAc,uBAAuB,CAAA;AACrC,cAAc,gBAAgB,CAAA;AAC9B,cAAc,kBAAkB,CAAA;AAChC,cAAc,yBAAyB,CAAA;AACvC,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
@@ -136,6 +136,7 @@ export * from './command-position.js';
136
136
  export * from './cut-run.js';
137
137
  export * from './failure-kind.js';
138
138
  export * from './input-normalizer.js';
139
+ export * from './line-diff.js';
139
140
  export * from './no-test-run.js';
140
141
  export * from './pipeline-exit-note.js';
141
142
  export * from './schemas.js';
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Real added/removed LINE counts for the `diff` a file-writing tool reports.
3
+ *
4
+ * The count is a diff, not an arithmetic delta: replacing one line with another
5
+ * leaves the line count unchanged, so `new.length - old.length` is 0 for the
6
+ * edit that changed the file — a result that reads to the model as "your write
7
+ * did nothing" (X0 xr-molecule-flash-m141 +09:56: `write_file` rewrote
8
+ * `ArticlePreview.tsx`'s favorite label and reported
9
+ * `"linesAdded":0,"linesRemoved":0`). The lines an edit actually changed are
10
+ * found with the same longest-common-subsequence line diff the IDE's tool cards
11
+ * use (`@molecule/app-ide-react`'s `diffLineCount`); it is mirrored here because
12
+ * the API and App halves of the workspace cannot import each other (AGENTS.md
13
+ * Rule 11, enforced by ESLint). Keep the two in step if either changes.
14
+ *
15
+ * @module
16
+ */
17
+ /**
18
+ * Count truly added/removed lines between two line arrays using LCS.
19
+ *
20
+ * Bounded by construction, so a whole-file edit cannot allocate a quadratic
21
+ * table here: the longest common prefix and suffix are trimmed first (exact —
22
+ * those lines belong to every optimal diff, and on the usual small edit they
23
+ * shrink the table to the edited span), and what is left is capped (see
24
+ * {@link lcsLength}).
25
+ *
26
+ * @param oldLines - The original lines array.
27
+ * @param newLines - The modified lines array.
28
+ * @returns An object with the number of added and removed lines.
29
+ */
30
+ export declare function diffLineCount(oldLines: readonly string[], newLines: readonly string[]): {
31
+ added: number;
32
+ removed: number;
33
+ };
34
+ //# sourceMappingURL=line-diff.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"line-diff.d.ts","sourceRoot":"","sources":["../src/line-diff.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAmGH;;;;;;;;;;;;GAYG;AACH,wBAAgB,aAAa,CAC3B,QAAQ,EAAE,SAAS,MAAM,EAAE,EAC3B,QAAQ,EAAE,SAAS,MAAM,EAAE,GAC1B;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAuBpC"}
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Real added/removed LINE counts for the `diff` a file-writing tool reports.
3
+ *
4
+ * The count is a diff, not an arithmetic delta: replacing one line with another
5
+ * leaves the line count unchanged, so `new.length - old.length` is 0 for the
6
+ * edit that changed the file — a result that reads to the model as "your write
7
+ * did nothing" (X0 xr-molecule-flash-m141 +09:56: `write_file` rewrote
8
+ * `ArticlePreview.tsx`'s favorite label and reported
9
+ * `"linesAdded":0,"linesRemoved":0`). The lines an edit actually changed are
10
+ * found with the same longest-common-subsequence line diff the IDE's tool cards
11
+ * use (`@molecule/app-ide-react`'s `diffLineCount`); it is mirrored here because
12
+ * the API and App halves of the workspace cannot import each other (AGENTS.md
13
+ * Rule 11, enforced by ESLint). Keep the two in step if either changes.
14
+ *
15
+ * @module
16
+ */
17
+ /**
18
+ * Largest LCS table the exact diff may build, in cells (old × new). A whole-file
19
+ * edit can be thousands of lines per side, i.e. tens of millions of cells —
20
+ * gigabytes of heap for a summary that only needs two numbers. Above this cap
21
+ * the answer comes from {@link orderedCommonLines}, a provable, order-respecting
22
+ * common subsequence in O(n log n); below it the result is exact. 1 << 22 cells
23
+ * is a few milliseconds, and the two-row table keeps memory at O(min(old, new)).
24
+ */
25
+ const MAX_DIFF_CELLS = 1 << 22;
26
+ /**
27
+ * Longest common subsequence length of two line arrays, in bounded time and
28
+ * memory: two rows of the recurrence instead of the whole table, and — once the
29
+ * table would exceed {@link MAX_DIFF_CELLS} — the order-respecting lower bound
30
+ * from {@link orderedCommonLines}.
31
+ *
32
+ * @param a - The first line array.
33
+ * @param b - The second line array.
34
+ * @returns The common subsequence length (a lower bound for the capped inputs).
35
+ */
36
+ function lcsLength(a, b) {
37
+ if (a.length === 0 || b.length === 0)
38
+ return 0;
39
+ if (a.length * b.length > MAX_DIFF_CELLS)
40
+ return orderedCommonLines(a, b);
41
+ // The recurrence needs only the previous row, and a row is `cols` wide — so
42
+ // the SHORTER array belongs on the `cols` axis, which bounds every row by
43
+ // min(a, b) and is what the memory claim above rests on. The recurrence is
44
+ // symmetric, so the length it returns is the same either way.
45
+ let rows = a;
46
+ let cols = b;
47
+ if (cols.length > rows.length) {
48
+ rows = b;
49
+ cols = a;
50
+ }
51
+ let previous = new Array(cols.length + 1).fill(0);
52
+ let current = new Array(cols.length + 1).fill(0);
53
+ for (let i = 1; i <= rows.length; i++) {
54
+ const line = rows[i - 1];
55
+ for (let j = 1; j <= cols.length; j++) {
56
+ if (line === cols[j - 1]) {
57
+ current[j] = previous[j - 1] + 1;
58
+ }
59
+ else {
60
+ const above = previous[j];
61
+ const left = current[j - 1];
62
+ current[j] = above >= left ? above : left;
63
+ }
64
+ }
65
+ const swap = previous;
66
+ previous = current;
67
+ current = swap;
68
+ }
69
+ return previous[cols.length];
70
+ }
71
+ /**
72
+ * How many lines the two arrays share IN ORDER, for inputs too large for the
73
+ * exact table: the longest run of lines that appear in the same relative order
74
+ * on both sides, found among the lines that occur exactly once in `a` (their
75
+ * position is unambiguous) by reducing their `b` positions to a longest
76
+ * strictly increasing run — the standard patience LIS, O(n log n).
77
+ *
78
+ * Order is the whole point. Counting the lines as a multiset (order thrown
79
+ * away) is only an UPPER bound on the LCS, so it under-reports changes: a
80
+ * 3000-line file whose two halves were swapped shares every line, and the
81
+ * multiset count would report the replacement as `+0 −0` — a result claiming a
82
+ * real edit changed nothing. Every line counted here is a real in-order match,
83
+ * so the answer is a LOWER bound on the LCS and the change count it feeds can
84
+ * only ever be over-stated, never denied.
85
+ *
86
+ * @param a - The first line array.
87
+ * @param b - The second line array.
88
+ * @returns A common subsequence length.
89
+ */
90
+ function orderedCommonLines(a, b) {
91
+ const occurrences = new Map();
92
+ for (const line of a)
93
+ occurrences.set(line, (occurrences.get(line) ?? 0) + 1);
94
+ const position = new Map();
95
+ a.forEach((line, index) => {
96
+ if (occurrences.get(line) === 1)
97
+ position.set(line, index);
98
+ });
99
+ // Patience: `tails[i]` is the smallest `b`-position that ends an increasing
100
+ // run of length `i + 1`.
101
+ const tails = [];
102
+ for (const line of b) {
103
+ const at = position.get(line);
104
+ if (at === undefined)
105
+ continue;
106
+ let lo = 0;
107
+ let hi = tails.length;
108
+ while (lo < hi) {
109
+ const mid = (lo + hi) >> 1;
110
+ if (tails[mid] < at)
111
+ lo = mid + 1;
112
+ else
113
+ hi = mid;
114
+ }
115
+ tails[lo] = at;
116
+ }
117
+ return tails.length;
118
+ }
119
+ /**
120
+ * Count truly added/removed lines between two line arrays using LCS.
121
+ *
122
+ * Bounded by construction, so a whole-file edit cannot allocate a quadratic
123
+ * table here: the longest common prefix and suffix are trimmed first (exact —
124
+ * those lines belong to every optimal diff, and on the usual small edit they
125
+ * shrink the table to the edited span), and what is left is capped (see
126
+ * {@link lcsLength}).
127
+ *
128
+ * @param oldLines - The original lines array.
129
+ * @param newLines - The modified lines array.
130
+ * @returns An object with the number of added and removed lines.
131
+ */
132
+ export function diffLineCount(oldLines, newLines) {
133
+ const oldLength = oldLines.length;
134
+ const newLength = newLines.length;
135
+ const shared = Math.min(oldLength, newLength);
136
+ // Longest common prefix / suffix. `suffix` is bounded by `shared - prefix`, so
137
+ // the two trimmed runs can never overlap each other.
138
+ let prefix = 0;
139
+ while (prefix < shared && oldLines[prefix] === newLines[prefix])
140
+ prefix++;
141
+ let suffix = 0;
142
+ while (suffix < shared - prefix &&
143
+ oldLines[oldLength - 1 - suffix] === newLines[newLength - 1 - suffix]) {
144
+ suffix++;
145
+ }
146
+ const common = prefix +
147
+ suffix +
148
+ lcsLength(oldLines.slice(prefix, oldLength - suffix), newLines.slice(prefix, newLength - suffix));
149
+ return { added: newLength - common, removed: oldLength - common };
150
+ }
@@ -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;AAiJ9C;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAsB,eAAe,CAAC,OAAO,EAAE,gBAAgB,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAahG;AAkOD,OAAO,KAAK,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA;AAmEnE;;;GAGG;AACH,eAAO,MAAM,iBAAiB,QAEA,CAAA;AAE9B;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,wBAAwB,QAI6C,CAAA;AAmBlF;;;;;;;;;;;GAWG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAUhE;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,qBAAqB;IACpC,iFAAiF;IACjF,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAA;IACzB,8FAA8F;IAC9F,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAA;CAC5B;AAED;;;;;;;GAOG;AACH,MAAM,MAAM,qBAAqB,GAAG,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,qBAAqB,CAAC,CAAA;AAoBpF;;;;;;;GAOG;AACH,wBAAgB,0BAA0B,CACxC,GAAG,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC;IAAE,MAAM,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,GAAG,SAAS,CAAC,GACxE,qBAAqB,CAkBvB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AACH,wBAAsB,4BAA4B,CAChD,IAAI,EAAE,MAAM,EACZ,KAAK,CAAC,EAAE,qBAAqB,GAC5B,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAiCxB;AA8ED;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,gBAAgB,EAAE,MAAM,CAAC,EAAE,eAAe,GAAG,MAAM,EAAE,CAk6ExF"}
1
+ {"version":3,"file":"tools.d.ts","sourceRoot":"","sources":["../src/tools.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAIH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAA;AAkJ9C;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAsB,eAAe,CAAC,OAAO,EAAE,gBAAgB,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAahG;AAkOD,OAAO,KAAK,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA;AAqEnE;;;GAGG;AACH,eAAO,MAAM,iBAAiB,QAEA,CAAA;AAE9B;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,wBAAwB,QAI6C,CAAA;AAmBlF;;;;;;;;;;;GAWG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAUhE;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,qBAAqB;IACpC,iFAAiF;IACjF,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAA;IACzB,8FAA8F;IAC9F,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAA;CAC5B;AAED;;;;;;;GAOG;AACH,MAAM,MAAM,qBAAqB,GAAG,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,qBAAqB,CAAC,CAAA;AAoBpF;;;;;;;GAOG;AACH,wBAAgB,0BAA0B,CACxC,GAAG,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC;IAAE,MAAM,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,GAAG,SAAS,CAAC,GACxE,qBAAqB,CAkBvB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AACH,wBAAsB,4BAA4B,CAChD,IAAI,EAAE,MAAM,EACZ,KAAK,CAAC,EAAE,qBAAqB,GAC5B,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAiCxB;AA8ED;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,gBAAgB,EAAE,MAAM,CAAC,EAAE,eAAe,GAAG,MAAM,EAAE,CAk9ExF"}