opencode-dejavu 2.7.0 → 2.27.0

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/src/patterns.ts CHANGED
@@ -47,6 +47,26 @@ export function scrubSecrets(text: string): string {
47
47
  return s
48
48
  }
49
49
 
50
+ /**
51
+ * Terminal control characters (PowerShell VT-colored errors, bells, NULs)
52
+ * carry no signal — persisted they corrupt snippets/corrections with raw
53
+ * escape sequences (`ESC[31;1m...`) and fragmented identities when the
54
+ * coloring varies between runs. ANSI sequences first (they end in a letter,
55
+ * which bare C0 stripping would strand), then all C0 except LF/CR/TAB —
56
+ * those three carry structure (multi-line commands, indentation).
57
+ */
58
+ export function stripControl(text: string): string {
59
+ return text
60
+ .replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/g, "")
61
+ .replace(/\u001b./g, "")
62
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, "")
63
+ }
64
+
65
+ /** The persistence boundary: control-char strip + secret scrub, in one call. */
66
+ export function sanitizeForStore(text: string): string {
67
+ return scrubSecrets(stripControl(text))
68
+ }
69
+
50
70
  // --- Normalization -----------------------------------------------------------
51
71
 
52
72
  /**
@@ -56,12 +76,28 @@ export function scrubSecrets(text: string): string {
56
76
  * payload instead: same code = same key, different code = different key.
57
77
  * Secrets are scrubbed before hashing so they neither persist nor fragment.
58
78
  */
79
+ /**
80
+ * PowerShell shapes included: call operator + QUOTED exe path
81
+ * (`& "C:\...\python.exe" -c ...`) and bare interpreter alike. The quoted
82
+ * path needs the quote in the prefix class and an optional closing quote,
83
+ * otherwise `-c` never lines up and the payload escapes fingerprinting.
84
+ * Leading env assignments (`PYTHONPATH=x python -c ...`) are allowed in the
85
+ * anchor and stay in the prefix — without them the one-liner escaped
86
+ * fingerprinting entirely. Flag alternatives run LONGEST FIRST: regex
87
+ * alternatives are ordered, and `-c` matching inside `-command` swallowed
88
+ * `ommand` into the payload — fragmenting keys across spellings of the same
89
+ * call. `py` is the Windows Python launcher (`py -3 -c ...`).
90
+ */
59
91
  const INTERPRETER_ONELINER =
60
- /(?:^|[|;&(\n]\s*)(?:\S+[\\/])?(python3?|node|bun|deno|perl|ruby|pwsh|powershell)(?:\.exe)?(?:\s+-\w+)*\s+(-c|-e|--eval|-command)\s*/i
92
+ /(?:^|[|;&(\n]\s*)(?:\w+=\S+\s+)*(?:["']?\S*[\\/])?(python3?|py|node|bun|deno|perl|ruby|pwsh|powershell)(?:\.exe)?["']?(?:\s+-\w+)*\s+(-command|-encodedcommand|--eval|-c|-e)\s*/i
61
93
 
62
94
  function hashInterpreterPayload(command: string): string {
63
95
  const match = INTERPRETER_ONELINER.exec(command)
64
96
  if (!match) return command
97
+ // PowerShell here-string payloads (`@"..."@` / `@'...'@`) — the wrapper
98
+ // markers are part of the payload and hash with it. Previously the `@`
99
+ // markers survived normalization and the quoted body collapsed to <str>,
100
+ // leaving raw code tokens leaking into signatures when quotes unbalanced.
65
101
  const payload = command.slice(match.index + match[0].length)
66
102
  if (payload.trim() === "") return command
67
103
  // Already fingerprinted (re-normalization) — keep the existing token so
@@ -75,7 +111,40 @@ function hashInterpreterPayload(command: string): string {
75
111
  // Trim before hashing: trailing whitespace (e.g. a stripped override marker)
76
112
  // is not part of the code's identity.
77
113
  const fingerprint = createHash("sha1").update(scrubSecrets(payload.trim())).digest("hex").slice(0, 8)
78
- return `${command.slice(0, match.index + match[0].length)}<code:${fingerprint}>`
114
+ // Long PowerShell flags converge to -c: `-command`/`-encodedcommand` are
115
+ // spellings of the same one-liner call — one identity, not three families.
116
+ const prefix = command.slice(0, match.index + match[0].length).replace(/-(?:command|encodedcommand)(\s*)$/i, "-c$1")
117
+ return `${prefix}<code:${fingerprint}>`
118
+ }
119
+
120
+ /**
121
+ * Windows wrapper verb: `cmd /c "real command"` — the payload IS the call.
122
+ * Unwrapped, the payload normalizes with its own identity and its real verb
123
+ * stays visible to the diagnostic policy; left wrapped, `/c` becomes `<path>`
124
+ * and the payload becomes `<str>`, so `cmd <path> <str>` matched every cmd
125
+ * invocation on the machine. Recursion terminates: the payload is strictly
126
+ * shorter than the wrapper command.
127
+ */
128
+ const CMD_WRAPPER = /^cmd(?:\.exe)?\s+(?:\/s\s+)?\/(c|k)\s+/i
129
+
130
+ /**
131
+ * Raw payload of a `cmd /c|/k` wrapper, one wrapper-quote layer removed —
132
+ * null when the command is not wrapped. Shared by normalization (unwrap),
133
+ * segment expansion (inner chains) and override-marker visibility.
134
+ */
135
+ export function cmdWrapperPayload(command: string): string | null {
136
+ const match = CMD_WRAPPER.exec(command)
137
+ if (!match) return null
138
+ let payload = command.slice(match[0].length).trim()
139
+ if ((payload.startsWith('"') && payload.endsWith('"') && payload.length >= 2) || (payload.startsWith("'") && payload.endsWith("'") && payload.length >= 2)) {
140
+ payload = payload.slice(1, -1).trim()
141
+ }
142
+ return payload === "" ? null : payload
143
+ }
144
+
145
+ function unwrapCmdWrapper(command: string): string {
146
+ const payload = cmdWrapperPayload(command)
147
+ return payload === null ? command : normalizeCommand(payload)
79
148
  }
80
149
 
81
150
  /**
@@ -84,10 +153,14 @@ function hashInterpreterPayload(command: string): string {
84
153
  * away so that "same failure, different instance" collapses into one pattern.
85
154
  */
86
155
  export function normalizeCommand(command: string): string {
156
+ // Terminal control characters (PowerShell VT colors) carry no identity and
157
+ // fragmented signatures when coloring varied between runs — strip first.
158
+ let s = stripControl(command)
87
159
  // CRLF/CR commands (Windows pastes, agent multi-line) normalize to LF —
88
160
  // otherwise the same command fragments across line-ending styles.
89
- let s = command.replace(/\r\n?/g, "\n")
161
+ s = s.replace(/\r\n?/g, "\n")
90
162
  s = s.replace(COMMENT_LINE, "$1").toLowerCase()
163
+ s = unwrapCmdWrapper(s)
91
164
  s = hashInterpreterPayload(s)
92
165
  // Quoted spans come out FIRST: they are data, and removing them before the
93
166
  // path rules keeps normalization idempotent — a <str> replacement inserts
@@ -124,7 +197,7 @@ const PARAM_RULES: [RegExp, string][] = [
124
197
  ]
125
198
 
126
199
  export function parameterizeError(text: string): string {
127
- let s = text.toLowerCase()
200
+ let s = stripControl(text).toLowerCase()
128
201
  for (const [rule, token] of PARAM_RULES) {
129
202
  s = s.replace(rule, token)
130
203
  }
@@ -142,14 +215,36 @@ export function parameterizeError(text: string): string {
142
215
  const DIAGNOSTIC_VERBS: RegExp[] = [
143
216
  /(^|[\s|;&:])(grep|rg|findstr|select-string)\b/i,
144
217
  /\bgit grep\b/i,
218
+ // Read-only git inspectors are diagnostics like `git grep`: their exit 1 is
219
+ // usually a downstream filter finding nothing (`git show … | Select-String`),
220
+ // not a mistake. Real git errors exit >= 2 and still count.
221
+ /\bgit\s+(show|log|status|ls-tree|ls-files|blame|diff)\b/i,
145
222
  /(^|[\s|;&:])diff\b/i,
146
223
  /\b(pytest|jest|vitest|mocha|cucumbertest)\b/i,
224
+ // npm/yarn/pnpm test / typecheck / lint scripts are iteration work — their
225
+ // exit 1 is "tests failed / types wrong / lint found issues", not an
226
+ // infrastructure error. The second rule covers flags between the package
227
+ // manager and the verb (e.g. `pnpm --filter <pkg> typecheck`).
228
+ /\b(npm|pnpm|yarn) (run )?(test|typecheck|lint)\b/i,
229
+ /\b(npm|pnpm|yarn)\b[^\n;|&]*\b(typecheck|lint)\b/i,
230
+ // `npm run check:*` / `verify:*` scripts are iteration work like test/lint —
231
+ // their exit 1 is "found issues", not an infrastructure error.
232
+ /\b(npm|pnpm|yarn|bun) (run )?(check|verify)\b/i,
147
233
  /\bplaywright test\b/i,
148
234
  /\bflutter (test|analyze)\b/i,
149
235
  /\bdart (analyze|format|fix)\b/i,
236
+ // Iteration runners: `dart run <script>`, `go run|build`, `cargo run|build`
237
+ // fail repeatedly WHILE the agent fixes the code — the failures are the
238
+ // work itself. Blocking them produced arms races (dozens of overrides in
239
+ // production data); they remind but never block, and their exit 1 is the
240
+ // intended "still broken" outcome of iteration.
241
+ /\bdart run\b/i,
242
+ /\bgo (run|build|test|vet)\b/i,
243
+ /\bcargo (run|build|test|clippy)\b/i,
150
244
  /\bgradlew\b[^\n;|&]*(test|compilejava|compiletestjava)/i,
151
245
  /\b(eslint|prettier --check)\b/i,
152
246
  /\btsc\b/i,
247
+ /\bmypy\b/i,
153
248
  /\bcurl\b/i,
154
249
  /\bls\b/i,
155
250
  ]
@@ -162,40 +257,323 @@ export function isDiagnosticSignature(signature: string): boolean {
162
257
  return isDiagnosticText(signature)
163
258
  }
164
259
 
165
- /** OpenCode normalizes non-zero exits to 1 in metadata, so discriminate by command shape. */
260
+ /** Pipeline formatters shape output but are never the failing producer WHEN
261
+ * they are a pipe tail: PowerShell cmdlets don't set `$LASTEXITCODE` (it stays
262
+ * with the producing native command), and unix head/tail/column/uniq tails exit
263
+ * 0 on piped input. So piping a diagnostic into one (`tsc | Select-Object -Last
264
+ * 5`, `vitest | head -5`) must not break the diagnostic's exit-1 immunity.
265
+ * Position matters: a formatter standing alone or as the TERMINAL producer of a
266
+ * sequence (`npm test && tail -5 missing.log`) IS the failing producer — its
267
+ * exit must still count. isIntendedNonzero only grants the transparency to
268
+ * segments splitChainTagged marks as pipe tails. */
269
+ const PIPE_FORMATTERS =
270
+ /^\s*(select-object|sort-object|format-table|format-list|format-wide|format-custom|out-string|out-host|out-null|tee-object|foreach-object|where-object|measure-object|group-object|convertto-json|convertfrom-json|head|tail|column|uniq|tee)\b/i
271
+ /** Navigation changes directory, never the outcome — `cd X && <diagnostic>`
272
+ * must not lose the diagnostic's exit-1 immunity to the `cd` segment. */
273
+ const NAVIGATION_VERBS = /^\s*(cd|set-location|pushd|popd)\b/i
274
+ /** Pure environment assignments (`$env:CI="true"`, `FOO=bar`) and sleeps only
275
+ * prepare the session — they are never the failing producer, so they are
276
+ * transparent to exit-1 immunity and chain attribution like navigation. */
277
+ const ENV_ASSIGNMENT_SEGMENT = /^\s*(?:\$env:)?[a-z_][a-z0-9_]*\s*=\s*(?:"[^"]*"|'[^']*'|\S+)\s*$/i
278
+ const INERT_VERBS = /^\s*start-sleep\b/i
279
+
280
+ /** Flatten subshell parens to `;` segment separators, but ONLY outside `{}`
281
+ * script blocks and quotes. `(deploy && grep)` must split (a diagnostic inside
282
+ * parens must not blanket-immunize a non-diagnostic verb), but method-call
283
+ * parens inside a PowerShell script block (`ForEach-Object { $_.trim() }`) are
284
+ * part of that segment and must NOT split it. */
285
+ function flattenSubshellParens(command: string): string {
286
+ let result = ""
287
+ let quote: string | null = null
288
+ let braceDepth = 0
289
+ for (let i = 0; i < command.length; i++) {
290
+ const ch = command.charAt(i)
291
+ if (quote !== null) {
292
+ result += ch
293
+ if (ch === quote) quote = null
294
+ continue
295
+ }
296
+ if (ch === '"' || ch === "'") {
297
+ quote = ch
298
+ result += ch
299
+ continue
300
+ }
301
+ if (ch === "{") {
302
+ braceDepth += 1
303
+ result += ch
304
+ continue
305
+ }
306
+ if (ch === "}") {
307
+ braceDepth = Math.max(0, braceDepth - 1)
308
+ result += ch
309
+ continue
310
+ }
311
+ if ((ch === "(" || ch === ")") && braceDepth === 0) {
312
+ result += ";"
313
+ continue
314
+ }
315
+ result += ch
316
+ }
317
+ return result
318
+ }
319
+
320
+ /** Like splitChain but tags each segment with whether it immediately follows a
321
+ * pipe (`|`). Formatter transparency is position-dependent (pipe tail only), so
322
+ * the immunity check needs this. `||` is a sequence (OR) separator, not a pipe:
323
+ * the segment after it is a producer, NOT a pipe tail. */
324
+ function splitChainTagged(command: string): Array<{ text: string; pipeTail: boolean }> {
325
+ const segments: Array<{ text: string; pipeTail: boolean }> = []
326
+ let current = ""
327
+ let quote: string | null = null
328
+ let depth = 0
329
+ let pipeTail = false
330
+ const flush = (): void => {
331
+ const trimmed = current.trim()
332
+ if (trimmed !== "") segments.push({ text: trimmed, pipeTail })
333
+ current = ""
334
+ }
335
+ let i = 0
336
+ while (i < command.length) {
337
+ const ch = command.charAt(i)
338
+ const next = command.charAt(i + 1)
339
+ if (quote !== null) {
340
+ current += ch
341
+ if (ch === quote) quote = null
342
+ i += 1
343
+ continue
344
+ }
345
+ if (ch === '"' || ch === "'") {
346
+ quote = ch
347
+ current += ch
348
+ i += 1
349
+ continue
350
+ }
351
+ if (ch === "(") {
352
+ depth += 1
353
+ current += ch
354
+ i += 1
355
+ continue
356
+ }
357
+ if (ch === ")") {
358
+ depth = Math.max(0, depth - 1)
359
+ current += ch
360
+ i += 1
361
+ continue
362
+ }
363
+ if (depth === 0) {
364
+ if (ch === ";" || ch === "\n" || ch === "\r") {
365
+ flush()
366
+ pipeTail = false
367
+ i += 1
368
+ continue
369
+ }
370
+ if (ch === "&" && next === "&") {
371
+ flush()
372
+ pipeTail = false
373
+ i += 2
374
+ continue
375
+ }
376
+ if (ch === "|") {
377
+ flush()
378
+ if (next === "|") {
379
+ // `||` is a sequence (OR) separator — next segment is a producer.
380
+ pipeTail = false
381
+ i += 2
382
+ } else if (next === "&") {
383
+ // `|&` is bash's pipe-stdout-and-stderr — still a pipe, next is a tail.
384
+ pipeTail = true
385
+ i += 2
386
+ } else {
387
+ pipeTail = true
388
+ i += 1
389
+ }
390
+ continue
391
+ }
392
+ }
393
+ current += ch
394
+ i += 1
395
+ }
396
+ flush()
397
+ return segments
398
+ }
399
+
400
+ /**
401
+ * OpenCode normalizes non-zero exits to 1 in metadata, so discriminate by
402
+ * command shape. Exit-1 immunity requires EVERY producer segment to be
403
+ * diagnostic: in `deploy --broken && grep done log.txt` the exit is deploy's
404
+ * failure — granting immunity because grep appears later would hide it.
405
+ * Two segment kinds are transparent because they cannot be the failing
406
+ * producer: navigation (`cd`) — but ONLY when the segment is pure navigation,
407
+ * so `cd <path> npx vitest run` (no separator) keeps the diagnostic instead of
408
+ * being dropped wholesale — and pipe formatters, but the latter ONLY as a pipe
409
+ * tail. A formatter standing alone or as the terminal producer of a sequence
410
+ * (`npm test && tail -5 missing.log`) IS the producer, so its failure still
411
+ * counts. A real non-diagnostic command still breaks immunity — `npm install |
412
+ * select-object` still counts (npm install is not a diagnostic).
413
+ * Subshell paren groups are flattened to segment separators (`;`), NOT spaces,
414
+ * and only OUTSIDE `{}` script blocks: `(deploy && grep)` splits so a
415
+ * diagnostic nested in parens can't blanket-immunize a non-diagnostic verb,
416
+ * while method-call parens inside a script block (`ForEach-Object { $_.trim()
417
+ * }`) stay part of their segment and don't split it.
418
+ */
166
419
  export function isIntendedNonzero(command: string, exitCode: number): boolean {
167
- return exitCode === 1 && isDiagnosticText(command)
420
+ if (exitCode !== 1) return false
421
+ let sawProducer = false
422
+ for (const { text, pipeTail } of splitChainTagged(flattenSubshellParens(command))) {
423
+ // Navigation is transparent only when it is PURE navigation. A segment that
424
+ // pairs a navigation verb with a diagnostic and no separator between them
425
+ // (`cd <path> npx vitest run ...`) must keep that diagnostic — dropping the
426
+ // whole segment as navigation would hide the command and break immunity.
427
+ if (NAVIGATION_VERBS.test(text) && !isDiagnosticText(text)) continue
428
+ // Session prep that cannot be the failing producer: a pure `$env:X=...` /
429
+ // `FOO=bar` assignment, and start-sleep.
430
+ if (ENV_ASSIGNMENT_SEGMENT.test(text)) continue
431
+ if (INERT_VERBS.test(text)) continue
432
+ if (pipeTail && PIPE_FORMATTERS.test(text)) continue
433
+ if (!isDiagnosticText(text)) return false
434
+ sawProducer = true
435
+ }
436
+ return sawProducer
437
+ }
438
+
439
+ /**
440
+ * Count of non-transparent producer segments in a command chain — the segments
441
+ * that could plausibly be the failing producer (everything except pure
442
+ * navigation, pure env assignments, inert verbs, and pipe-tail formatters).
443
+ * Chain attribution is only defensible when exactly ONE such producer exists;
444
+ * with several, the exit code does not say which one failed, so attributing
445
+ * the failure to any single known segment fabricates evidence (a diagnostic
446
+ * segment's gate inflated by a non-diagnostic producer's failure).
447
+ */
448
+ export function nonTransparentProducers(command: string): number {
449
+ let count = 0
450
+ for (const { text, pipeTail } of splitChainTagged(flattenSubshellParens(command))) {
451
+ if (NAVIGATION_VERBS.test(text) && !isDiagnosticText(text)) continue
452
+ if (ENV_ASSIGNMENT_SEGMENT.test(text)) continue
453
+ if (INERT_VERBS.test(text)) continue
454
+ if (pipeTail && PIPE_FORMATTERS.test(text)) continue
455
+ count += 1
456
+ }
457
+ return count
458
+ }
459
+
460
+ // --- Residual identity (over-generic shape guard) ----------------------------
461
+
462
+ /** Placeholder tokens carry no identity — except `<code:...>`: the
463
+ * fingerprint IS the identity of a one-liner payload. */
464
+ const PLACEHOLDER_TOKEN = /^<(?:str|path|n|hash|uuid|sha|md5|ip|url|email|date)>$/
465
+
466
+ /** Shell plumbing: redirections and here-string/call-operator debris. */
467
+ const OPERATOR_TOKENS = new Set(["&", "@", ">", "<", ">&", ">>", "2>&1", "2>"])
468
+
469
+ /** Tokens that pass code/module to an interpreter — structure, not identity.
470
+ * `-m`/`--module` included: the NEXT token is the program, exactly like -c —
471
+ * `python -m <str>` (quoted module) must not gain identity from the flag. */
472
+ const CODE_PASSING_FLAGS = new Set(["-c", "-e", "--eval", "-command", "-encodedcommand", "-m", "--module"])
473
+
474
+ /** Bare flag tokens (`-x`, `--foo`) are switches, not call identity — after a
475
+ * wrapper head, a flag-only remainder matches an entire command family
476
+ * (`cmd <path> <str> -f`), which must not enforce. */
477
+ const FLAG_TOKEN = /^--?[a-z]/i
478
+
479
+ /** Shell builtins that only position the session: a chain headed by one
480
+ * (`cd <path> && python <path>`) must not borrow identity from the builtin —
481
+ * the whole chain may be parameterized away. */
482
+ const NO_IDENTITY_HEADS = new Set(["cd", "pushd", "popd", "set-location", "exit"])
483
+
484
+ /** Pipe-stage cmdlets that only post-process output: a segment made of
485
+ * plumbing (`... | select-object -last <n>`) contributes no identity. */
486
+ const PLUMBING_HEADS = new Set([
487
+ "select-object",
488
+ "select-string",
489
+ "out-string",
490
+ "out-file",
491
+ "out-null",
492
+ "foreach-object",
493
+ "where-object",
494
+ "sort-object",
495
+ "measure-object",
496
+ "tee-object",
497
+ "write-host",
498
+ "write-output",
499
+ "more",
500
+ ])
501
+
502
+ /** Wrappers whose bare name is not a call identity: their ARGUMENTS are the
503
+ * call. If the arguments were all parameterized away, the signature matches
504
+ * an entire command family — enforcing it would punish unrelated calls. */
505
+ const WRAPPER_BASENAMES = new Set(["cmd", "py", "node", "python", "python3", "bun", "deno", "perl", "ruby", "pwsh", "powershell"])
506
+
507
+ function baseName(token: string): string {
508
+ const bare = token.replace(/^["']+|["']+$/g, "")
509
+ const parts = bare.split(/[\\/]/)
510
+ const last = parts[parts.length - 1] ?? bare
511
+ return last.toLowerCase().replace(/\.exe$/, "")
512
+ }
513
+
514
+ function segmentHasIdentity(segment: string): boolean {
515
+ const tokens = segment.split(/\s+/).filter((t) => t !== "")
516
+ let head = ""
517
+ let headIdx = -1
518
+ for (let i = 0; i < tokens.length; i++) {
519
+ const token = tokens[i] ?? ""
520
+ if (PLACEHOLDER_TOKEN.test(token) || OPERATOR_TOKENS.has(token) || CODE_PASSING_FLAGS.has(token)) continue
521
+ head = token
522
+ headIdx = i
523
+ break
524
+ }
525
+ if (head === "") return false
526
+ if (head.startsWith("<code:")) return true
527
+ if (PLUMBING_HEADS.has(head)) return false
528
+ if (NO_IDENTITY_HEADS.has(head)) return false
529
+ if (!WRAPPER_BASENAMES.has(baseName(head))) return true
530
+ // Wrapper/interpreter head: identity must come from a surviving argument
531
+ // (a literal path/script, or a <code:...> fingerprint). Flags are switches,
532
+ // not identity — a flag-only remainder is an over-generic command family.
533
+ for (let i = headIdx + 1; i < tokens.length; i++) {
534
+ const token = tokens[i] ?? ""
535
+ if (token.startsWith("<code:")) return true
536
+ if (PLACEHOLDER_TOKEN.test(token) || CODE_PASSING_FLAGS.has(token) || OPERATOR_TOKENS.has(token) || FLAG_TOKEN.test(token)) continue
537
+ return true
538
+ }
539
+ return false
168
540
  }
169
541
 
170
542
  /**
171
- * A code flag whose payload was entirely parameterized away (`-c <str>`)
172
- * carries no identity blocking that shape blocks the whole command family.
173
- * New one-liners get <code:...> fingerprints in normalizeCommand; this guard
174
- * keeps legacy pre-fingerprint gates (and lookalikes) from ever blocking.
543
+ * A signature keeps residual identity when at least one chain segment names
544
+ * a concrete call. Signatures whose substance was entirely parameterized
545
+ * `cmd <path> <str>`, `node <str> <n> >& <n>`, `& <str> -c @ <str> @`,
546
+ * chains starting with an unknown `<str>` head match whole command
547
+ * families: they may be measured (watching) but never enforced. This
548
+ * generalizes the legacy bare-one-liner guard: ANY future normalization gap
549
+ * degrades to watching instead of blocking arbitrary calls.
175
550
  */
176
- const GENERIC_ONELINER_SHAPE = /(^|\s)(-c|-e|--eval|-command)\s+(?:@\s+)?<str>(?:\s+@)?\s*$/i
551
+ export function hasResidualIdentity(signature: string): boolean {
552
+ const body = signature.startsWith("bash:") ? signature.slice("bash:".length) : signature
553
+ return body.split(/\s*(?:\|\||&&|[|;&])\s*|\n+/).some((segment) => segmentHasIdentity(segment))
554
+ }
177
555
 
178
556
  /**
179
557
  * Blocking policy: only bash commands that are NOT diagnostics may ever
180
558
  * become enforced gates. File probes and diagnostic queries are measured
181
559
  * (watching) but never interrupt the agent — the data showed blocking them
182
- * punishes normal work.
560
+ * punishes normal work. Signatures without residual identity never enforce
561
+ * at any tier — they are too broad to interrupt anything.
183
562
  */
184
563
  export function canBlock(tool: string, signature: string): boolean {
185
564
  if (tool !== "bash") return false
186
- if (isDiagnosticSignature(signature)) return false
187
- return !GENERIC_ONELINER_SHAPE.test(signature)
565
+ if (!hasResidualIdentity(signature)) return false
566
+ return !isDiagnosticSignature(signature)
188
567
  }
189
568
 
190
569
  /**
191
570
  * Remind-only policy: diagnostic bash commands still surface a REMINDER when
192
571
  * they recur (the old behavior gave them zero signal), but they NEVER block —
193
572
  * blocking a test/lint the agent is iterating on punishes normal work.
194
- * Generic one-liner shapes stay unenforced: they are too broad to remind on.
195
573
  */
196
574
  export function canRemind(tool: string, signature: string): boolean {
197
575
  if (tool !== "bash") return false
198
- if (GENERIC_ONELINER_SHAPE.test(signature)) return false
576
+ if (!hasResidualIdentity(signature)) return false
199
577
  return isDiagnosticSignature(signature)
200
578
  }
201
579
 
@@ -277,7 +655,8 @@ export function splitChain(command: string): string[] {
277
655
  }
278
656
  if (ch === "|") {
279
657
  flush()
280
- i += next === "|" ? 2 : 1
658
+ // `||` (OR) and `|&` (bash pipe stdout+stderr) are 2-char; bare `|` is 1.
659
+ i += next === "|" || next === "&" ? 2 : 1
281
660
  continue
282
661
  }
283
662
  }
@@ -288,10 +667,28 @@ export function splitChain(command: string): string[] {
288
667
  return segments
289
668
  }
290
669
 
291
- /** Per-segment signatures for a bash command (bypass protection for chains). */
670
+ /**
671
+ * Per-segment signatures for a bash command (bypass protection for chains).
672
+ * cmd wrappers expand recursively: quote-aware splitChain keeps
673
+ * `cmd /c "a && gated"` as ONE segment, so the inner chain must unfold here —
674
+ * a gate on the inner command must fire through the wrapper. Depth-bounded:
675
+ * nested wrappers are pathological.
676
+ */
292
677
  export function bashSegmentSignatures(command: string): string[] {
293
678
  const clean = command.replace(OVERRIDE_MARKER, "")
294
- return splitChain(clean).map((segment) => `bash:${normalizeCommand(segment)}`)
679
+ const signatures: string[] = []
680
+ const expand = (text: string, depth: number): void => {
681
+ for (const segment of splitChain(text)) {
682
+ const payload = depth < 3 ? cmdWrapperPayload(segment) : null
683
+ if (payload === null) {
684
+ signatures.push(`bash:${normalizeCommand(segment)}`)
685
+ } else {
686
+ expand(payload, depth + 1)
687
+ }
688
+ }
689
+ }
690
+ expand(clean, 0)
691
+ return signatures
295
692
  }
296
693
 
297
694
  /** Normalize a file path: keep basename + extension, drop directories. */
@@ -373,12 +770,25 @@ const CODE_FINGERPRINTS = /<code:[0-9a-f]+>/g
373
770
  * DISJOINT flag sets are different operations and must never fuzzy-merge
374
771
  * ("train --lr <n>" vs "train --epochs <n>"). A subset IS allowed — extra
375
772
  * switches on the same operation ("gradlew test --no-daemon") still belong to
376
- * the same gate, otherwise enforcement fragments across harmless variants. */
773
+ * the same gate, otherwise enforcement fragments across harmless variants.
774
+ * Cached: the flood path calls fuzzySimilar per gate under the gates lock and
775
+ * would otherwise re-split/sort the SAME incoming signature on every pair. */
776
+ const FLAG_TOKEN_CACHE_CAP = 512
777
+ const flagTokenCache = new Map<string, string[]>()
377
778
  function flagTokens(signature: string): string[] {
378
- return signature
779
+ const cached = flagTokenCache.get(signature)
780
+ if (cached !== undefined) return cached
781
+ const tokens = signature
379
782
  .split(/\s+/)
380
783
  .filter((token) => token.startsWith("-"))
381
784
  .sort()
785
+ if (flagTokenCache.size >= FLAG_TOKEN_CACHE_CAP) {
786
+ // Evict an arbitrary (oldest-inserted) entry to bound memory.
787
+ const oldest = flagTokenCache.keys().next().value
788
+ if (oldest !== undefined) flagTokenCache.delete(oldest)
789
+ }
790
+ flagTokenCache.set(signature, tokens)
791
+ return tokens
382
792
  }
383
793
 
384
794
  function flagSubset(a: string[], b: string[]): boolean {
@@ -404,6 +814,16 @@ export const FUZZY_MAX_LEN = 300
404
814
  */
405
815
  export function fuzzySimilar(a: string, b: string): boolean {
406
816
  if (a === b) return true
817
+ // Cheapest rejects FIRST: the length band is O(1) with zero allocation and
818
+ // zero false negatives — it must run before any regex/flag work, because
819
+ // the flood path calls this per gate under the gates lock.
820
+ const maxLen = Math.max(a.length, b.length)
821
+ if (maxLen === 0) return true
822
+ if (maxLen > FUZZY_MAX_LEN) return false
823
+ // Triangle inequality: distance >= |lenA - lenB|. If even that floor
824
+ // exceeds the ratio threshold, no Levenshtein result can pass — an O(1)
825
+ // pre-filter with zero false negatives that skips most DP computations.
826
+ if (Math.abs(a.length - b.length) / maxLen > 0.3) return false
407
827
  const codesA = a.match(CODE_FINGERPRINTS)
408
828
  const codesB = b.match(CODE_FINGERPRINTS)
409
829
  if (codesA !== null || codesB !== null) {
@@ -412,13 +832,6 @@ export function fuzzySimilar(a: string, b: string): boolean {
412
832
  const flagsA = flagTokens(a)
413
833
  const flagsB = flagTokens(b)
414
834
  if (!flagSubset(flagsA, flagsB) && !flagSubset(flagsB, flagsA)) return false
415
- const maxLen = Math.max(a.length, b.length)
416
- if (maxLen === 0) return true
417
- if (maxLen > FUZZY_MAX_LEN) return false
418
- // Triangle inequality: distance >= |lenA - lenB|. If even that floor
419
- // exceeds the ratio threshold, no Levenshtein result can pass — an O(1)
420
- // pre-filter with zero false negatives that skips most DP computations.
421
- if (Math.abs(a.length - b.length) / maxLen > 0.3) return false
422
835
  const distance = levenshtein(a, b)
423
836
  return distance >= 3 && distance / maxLen <= 0.3
424
837
  }
@@ -437,20 +850,97 @@ export interface FailureDetection {
437
850
  * event channel instead.
438
851
  */
439
852
  const FAILURE_SIGNATURES: RegExp[] = [
440
- /exit code:?\s*[1-9]\d*/i,
853
+ /exit (?:code|status):?\s*[1-9]\d*/i,
441
854
  /\berror TS\d+\b/,
442
855
  /\bENOENT\b|\bEACCES\b|\bEPERM\b/,
443
856
  /command not found/i,
444
- /is not recognized as an internal or external command/i,
857
+ // cmd AND PowerShell wordings of "unknown command" — pwsh phrasing was
858
+ // uncovered, so head/tail/wc gates stored the "Check the spelling" boilerplate
859
+ // tail instead of the cause line.
860
+ /is not recognized as (?:an internal or external command|the name of a cmdlet)/i,
445
861
  /\b(SyntaxError|TypeError|ReferenceError|AssertionError)\b/,
446
862
  /Tests:\s+\d+\s+failed/i,
447
- /\bFAIL\s+\S/,
863
+ // Runner summaries, language-agnostic. Count-bearing forms require a NON-ZERO
864
+ // count in EITHER order ("1 failed" / "Failed: 1" / "Failures: 1") so a pass
865
+ // tally ("0 failed", "Failed: 0") never reads as a failure. Covers pytest/
866
+ // playwright/vitest/jest ("N failed"), RSpec/Elixir/minitest ("N failure(s)"),
867
+ // dotnet/Maven/sbt/unittest ("Failed: N", "Failures: N", "failures=N").
868
+ /\b[1-9]\d*\s+fail(?:ed|ures?)\b/i,
869
+ /\bfail(?:ed|ures?)\s*[:=]\s*[1-9]\d*\b/i,
870
+ /no tests? (?:found|matched|run|were executed)/i,
871
+ // Generic error prefix (Playwright "Error: No tests found", Node, tracebacks).
872
+ /^error:/i,
873
+ // Compiler/tool error prefixes that carry a bracket before the colon:
874
+ // Rust "error[E0308]:" and Maven/SBT "[ERROR] ...".
875
+ /^\s*error\s*\[/i,
876
+ /^\s*\[ERROR\]/i,
877
+ // Go: "--- FAIL: TestName", "FAIL\tpkg", standalone "FAIL" (uppercase; pass is
878
+ // "ok\tpkg"). \b keeps it off "FAILED"/"FAILURE".
879
+ /\bFAIL\b/,
880
+ // Build-level status words that never appear in a pass summary: Maven
881
+ // "BUILD FAILURE", Gradle "BUILD FAILED" / "FAILURE: Build failed", sbt
882
+ // "*** 1 TEST FAILED ***", dotnet build "Build FAILED.".
883
+ /\bBUILD\s+(?:FAILURE|FAILED)\b/i,
884
+ /\bFAILURE\b/i,
885
+ /\bTESTS?\s+FAILED\b/i,
886
+ // TAP ("node --test") failure marker.
887
+ /^not ok\b/i,
448
888
  /thread '[^']*' panicked/,
889
+ /\bpanic:/i,
449
890
  /\bFATAL\b/,
450
891
  ]
451
892
 
893
+ /** Lines that read like a SUCCESS summary. Quoting one as a failure's "last
894
+ * error" teaches the agent to fix something that worked — the store held
895
+ * "17 passed (3.1m)" as evidence for a failing gate (MidasAI). Matched as a
896
+ * SUBSTRING so decorated summaries ("==== 10 passed ====", "ok\tpkg 0.3s",
897
+ * "BUILD SUCCESSFUL") are caught too — the pass shape need not start the line. */
898
+ const SUCCESS_SHAPED: RegExp[] = [
899
+ /\b\d+\s+passed\b/i,
900
+ /\b\d+\s+(?:tests?|specs?|examples?)\s+passed\b/i,
901
+ /\ball tests passed\b/i,
902
+ /\bBUILD SUCCESS(?:FUL)?\b/i,
903
+ /\btest result: ok\b/i,
904
+ /\bOK\s*\(\s*\d+\s+tests?/i,
905
+ /^ok\s+\S/i,
906
+ // dotnet: pass summaries lead with "Passed!" ("Failed!" = failure) or say
907
+ // "Build succeeded." / "Test Run Successful."
908
+ /^Passed!/i,
909
+ /\bBuild succeeded\b/i,
910
+ /\bTest Run Successful\b/i,
911
+ /\bno offenses detected\b/i,
912
+ /\b0 issues\b/i,
913
+ // gradle/node noise that is never failure evidence: task summary, config-cache
914
+ // note, node version banner (printed at the tail of a crash, it is not the cause).
915
+ /\b\d+\s+actionable tasks?\b/i,
916
+ /\bConfiguration cache entry\b/i,
917
+ /^Node\.js v\d+\./i,
918
+ ]
919
+
920
+ export function looksLikeSuccess(line: string): boolean {
921
+ // A line that reports failures is not a success even when it also tallies
922
+ // passes ("1 failed, 1780 passed", "Failed: 1"). Non-zero count in either
923
+ // order, mirroring the failure signatures.
924
+ if (/\b[1-9]\d*\s+fail(?:ed|ures?)\b/i.test(line)) return false
925
+ if (/\bfail(?:ed|ures?)\s*[:=]\s*[1-9]\d*\b/i.test(line)) return false
926
+ return SUCCESS_SHAPED.some((rule) => rule.test(line))
927
+ }
928
+
929
+ /** Leading decorations runners wrap summaries in ("====", "---", "[info]",
930
+ * "✔") — stripped before matching so a pattern needs not anticipate every
931
+ * tool's framing. Bounded so a real line is never eaten whole. */
932
+ const LEADING_DECORATION = /^[\s=\-─—_*#•»>]{0,40}/
933
+
934
+ export function looksLikeFailure(line: string): boolean {
935
+ if (line.trim() === "" || looksLikeSuccess(line)) return false
936
+ const bare = line.replace(LEADING_DECORATION, "")
937
+ return FAILURE_SIGNATURES.some((rule) => rule.test(line) || rule.test(bare))
938
+ }
939
+
452
940
  export function detectFailure(outputText: string): FailureDetection {
453
- for (const line of outputText.split("\n")) {
941
+ // PowerShell colors errors with VT sequences — strip before scanning, or
942
+ // the escapes persist into snippets/corrections shown to the agent.
943
+ for (const line of stripControl(outputText).split("\n")) {
454
944
  for (const signature of FAILURE_SIGNATURES) {
455
945
  if (signature.test(line)) {
456
946
  return { matched: true, snippet: line.trim().slice(0, 200) }
@@ -463,14 +953,29 @@ export function detectFailure(outputText: string): FailureDetection {
463
953
  /**
464
954
  * For exit-code failures whose output matched no signature, a bare
465
955
  * "exit code N" gives a human/agent nothing to write a correction from.
466
- * Bash output is command output (safe to surface), so keep the last non-empty
467
- * line — compilers/test runners print their summary at the end.
956
+ * Bash output is command output (safe to surface). Scan from the END for a
957
+ * failure-shaped line — compilers/test runners print their summary last, but a
958
+ * SUCCESS-shaped tail ("17 passed") is never failure evidence: chained commands
959
+ * and `Select-Object -Last N` pipelines put another shard's pass summary there.
960
+ * Prefer the last real error line, then the last non-success line, then the exit code.
468
961
  */
469
962
  export function failureSnippet(outputText: string, exitCode: number | null): string {
470
- const lines = outputText
963
+ const lines = stripControl(outputText)
471
964
  .split("\n")
472
965
  .map((l) => l.trim())
473
966
  .filter((l) => l !== "")
967
+ if (exitCode !== null && exitCode !== 0) {
968
+ for (let i = lines.length - 1; i >= 0; i--) {
969
+ const line = lines[i] ?? ""
970
+ if (looksLikeFailure(line)) return line.slice(0, 200)
971
+ }
972
+ // No failure-shaped line: the last non-success line beats a bare exit code.
973
+ for (let i = lines.length - 1; i >= 0; i--) {
974
+ const line = lines[i] ?? ""
975
+ if (!looksLikeSuccess(line)) return line.slice(0, 200)
976
+ }
977
+ return `exit code ${exitCode}`
978
+ }
474
979
  const tail = lines[lines.length - 1]
475
980
  if (tail !== undefined && tail !== "") return tail.slice(0, 200)
476
981
  return `exit code ${exitCode}`
@@ -482,6 +987,9 @@ export function failureSnippet(outputText: string, exitCode: number | null): str
482
987
  * Infrastructure noise, not agent mistakes: aborted/cancelled executions
483
988
  * (user hit stop, background task reaped) teach nothing and fragmented the
484
989
  * store with unactionable patterns. Aborted != failed.
990
+ * Boundary: SERVER-side unavailability (daemon down, transport errors, 5xx)
991
+ * is noise — the agent cannot learn from "the service was down". CLIENT-side
992
+ * mistakes (4xx, wrong path, syntax) stay teachable and are NOT matched here.
485
993
  */
486
994
  const NOISE_ERRORS: RegExp[] = [
487
995
  /tool execution aborted/i,
@@ -489,12 +997,132 @@ const NOISE_ERRORS: RegExp[] = [
489
997
  /\baborted by user\b/i,
490
998
  /\bcancelled by user\b/i,
491
999
  /\bcanceled by user\b/i,
1000
+ /no results found for your query/i, // grep_app empty search: the tool worked, nothing matched
1001
+ /user dismissed this question/i, // question tool: the user's choice, not a failure
1002
+ // Infrastructure unavailability: the service, not the command, failed.
1003
+ /lsp (?:server|daemon|process)[^.\n]*(?:unreachable|not running|disconnected|crashed|did not become reachable)/i,
1004
+ /\b(?:daemon|server)\b[^.\n]*(?:unreachable|did not become reachable)/i,
1005
+ /streamable ?http ?error/i, // MCP streamable-http transport failure
1006
+ /\bmcp error\b/i, // MCP transport/protocol errors
1007
+ /non.?2xx status code/i, // webfetch HTTP failure: the endpoint answered, the URL fetch didn't
1008
+ /\btransport error\b/i, // webfetch/gRPC transport failure: the connection itself never completed
1009
+ // Browser automation: the page/context/browser was already closed when the
1010
+ // action ran — a transient startup/state hiccup fixed by relaunching, not an
1011
+ // agent habit (and a non-bash tool error could only ever watch anyway).
1012
+ /target page, context or browser has been closed/i,
1013
+ // LSP too slow to answer a diagnostics request in the window — a latency
1014
+ // hiccup, not an agent mistake (and lsp_* tools only ever watch anyway).
1015
+ /timed out waiting for (?:fresh )?diagnostics/i,
492
1016
  ]
493
1017
 
494
1018
  export function isNoiseError(errorText: string): boolean {
495
1019
  return NOISE_ERRORS.some((rule) => rule.test(errorText))
496
1020
  }
497
1021
 
1022
+ // --- Long-running command guard ------------------------------------------------
1023
+
1024
+ /**
1025
+ * High-confidence dev-server / watcher starters. Running one in FOREGROUND bash
1026
+ * blocks the tool call until its timeout (~2 min) and strands an orphan process.
1027
+ * Deliberately conservative: only unambiguous starters are listed (ambiguous
1028
+ * `node <file>`, `go run`, `dotnet run` are NOT here — they may be one-shots).
1029
+ * This is a bounded, recognizable class, unlike open-ended error detection.
1030
+ */
1031
+ const SERVER_STARTERS: RegExp[] = [
1032
+ // `start` included: `npm start` is the canonical dev-server script (CRA et al).
1033
+ // `workspace <name>` covers monorepo `yarn workspace app dev`.
1034
+ /\b(npm|yarn|pnpm|bun)\s+(run\s+|workspace\s+\S+\s+)?(dev|serve|watch|start)\b/i,
1035
+ /\b(next|nuxt|astro)\s+dev\b/i,
1036
+ /\bng\s+serve\b/i, // Angular
1037
+ // `vite` as a command: not in a filename ("vite.config.ts"), not "vitest", not a
1038
+ // `build:` script target ("npm run build:vite"), and not followed by a bare
1039
+ // `build` (one-shot). `vite build --watch` is a watcher (separate rule).
1040
+ /(?<![:\w])vite\b(?![.\w])(?![^\n]*\bbuild\b)/i,
1041
+ /\bvite\s+build\b[^\n]*\bwatch\b/i,
1042
+ // Flask 2.3+ puts `--app X` between the binary and `run`.
1043
+ /\b(flask|streamlit)\b[^\n|;&]*\brun\b/i,
1044
+ // Require an arg (module:var or flag) so `pip install uvicorn gunicorn` and
1045
+ // `grep uvicorn` (mention/install) don't read as starting a server.
1046
+ /\b(uvicorn|gunicorn)\s+(?:--?\w[^\s]*|\S+:\S+)/i,
1047
+ /\bpython\d?(?:\.\d+)?\s+-m\s+http\.server\b/i,
1048
+ /\b(python\d?(?:\.\d+)?\s+)?manage\.py\s+runserver\b/i, // Django
1049
+ /\bdjango-admin\s+runserver\b/i,
1050
+ // Python scripts named like servers (Flask/FastAPI entrypoints).
1051
+ // Negative lookahead for `\s+cli\b`: `python …/server.py cli …` is a one-shot
1052
+ // CLI invocation (e.g. muffin-supervisor), not a foreground server start.
1053
+ /\bpython\d?(?:\.\d+)?\s+(?:\S*[\/\\])?(?:app|server|main|run|wsgi|asgi)\.py\b(?!\s+cli\b)/i,
1054
+ /\bphp\s+(-S|artisan\s+serve)\b/i, // built-in / Laravel
1055
+ /\bjupyter\s+(lab|notebook)\b/i,
1056
+ /\b(webpack-dev-server|webpack\s+serve)\b/i,
1057
+ /\b(http-server|live-server)\b/i,
1058
+ // Docker foreground services: `compose up` / `run` WITHOUT -d/--detach block.
1059
+ // `docker run` constrained to server-ish flags (-p/--publish/-it) to avoid
1060
+ // flagging one-shot containers (`docker run --rm alpine echo hi`).
1061
+ /\bdocker(?:-compose)?\s+compose\s+up\b(?![^\n]*\s(?:-d|--detach)\b)/i,
1062
+ /\bdocker\s+run\b(?![^\n]*\s(?:-d|--detach)\b)(?=[^\n]*\s(?:-p|--publish|-it)\b)/i,
1063
+ // Built-in runtime watchers (Node 18+ / Bun): unambiguous long-running.
1064
+ /\bnode\s+--watch\b/i,
1065
+ /\bbun\s+--watch\b/i,
1066
+ /\bmvn\b[^\n]*\bspring-boot:run\b/i,
1067
+ /\bgradlew?\b[^\n]*\bbootRun\b/i,
1068
+ /\bdotnet\s+watch\b/i, // `dotnet run` stays excluded (ambiguous one-shot vs server)
1069
+ /\brails\s+(s|server)\b/i,
1070
+ /\bhugo\s+server\b/i,
1071
+ /\bjekyll\s+serve\b/i,
1072
+ /\bmkdocs\s+serve\b/i,
1073
+ /\bmix\s+phx\.server\b/i, // Elixir/Phoenix
1074
+ /\biex\s+-S\s+mix\b/i,
1075
+ /\bnodemon\b/i,
1076
+ /\b(expo|react-native)\s+start\b/i,
1077
+ /\bollama\s+serve\b/i,
1078
+ ]
1079
+
1080
+ /** Markers that mean the process is already detached / backgrounded. */
1081
+ function isDetached(command: string): boolean {
1082
+ // Start-Process detaches UNLESS -Wait (blocks for exit) or -NoNewWindow
1083
+ // (runs in the caller's window, effectively foreground).
1084
+ if (/\bStart-Process\b/i.test(command)) return !/\s-(?:Wait|NoNewWindow)\b/i.test(command)
1085
+ // Self-detaching managers/sessions.
1086
+ if (/\b(Start-Job|pm2|forever|daemonize|systemd-run|setsid)\b/i.test(command)) return true
1087
+ if (/\btmux\s+(new-session|new)\b/i.test(command)) return true
1088
+ // `screen -dmS`/`-d -m` start detached; a bare `screen -S name` is foreground.
1089
+ if (/\bscreen\s+-(d|m)/i.test(command)) return true
1090
+ if (/\bstart\s+\/b\b/i.test(command)) return true // cmd.exe background
1091
+ // A standalone background `&` (not part of `&&`), anywhere — trailing,
1092
+ // mid-chain, or closing a subshell (`(cmd &)`). `&&` chains stay foreground.
1093
+ // BUT `& … wait` blocks until the background job finishes, and `nohup X`
1094
+ // without `&` still runs in the foreground — both are NOT detached.
1095
+ if (/(^|[^&])&([^&]|$)/.test(command.trim())) return !/\bwait\b/i.test(command)
1096
+ if (/\b(nohup|disown)\b/i.test(command)) return false // needs `&` to detach; handled above
1097
+ return false
1098
+ }
1099
+
1100
+ export function isLongRunningCommand(command: string): boolean {
1101
+ return SERVER_STARTERS.some((rule) => rule.test(command))
1102
+ }
1103
+
1104
+ /** Warn only for a foreground server start; a detached one is fine. */
1105
+ export function shouldWarnLongRunning(command: string): boolean {
1106
+ return isLongRunningCommand(command) && !isDetached(command)
1107
+ }
1108
+
1109
+ /**
1110
+ * Agent-written polling loops with no timeout guard. These are NOT servers —
1111
+ * they hang in a while/until/for loop waiting for a condition (usually a health
1112
+ * endpoint) that may never arrive, until the bash timeout kills them.
1113
+ */
1114
+ const WAIT_LOOP: RegExp[] = [
1115
+ /\b(?:while|until)\b[^\n]*\b(?:sleep|Start-Sleep)\s+\d/i,
1116
+ // Multi-line loops: infinite condition ($true/true) or a network/health probe,
1117
+ // with a sleep anywhere in the body (possibly on later lines).
1118
+ /\b(?:while|until)\b[^\n]*(?:\$true|\btrue\b|\bTest-Connection\b|\bInvoke-WebRequest\b|\bInvoke-RestMethod\b|\bcurl\b|\bwget\b)[\s\S]{0,500}?\b(?:sleep|Start-Sleep)\b/i,
1119
+ /\bfor\s*\([^\n]*\)\s*\{[\s\S]{0,400}?\b(?:sleep|Start-Sleep)\s+\d/i,
1120
+ ]
1121
+
1122
+ export function shouldWarnWaitLoop(command: string): boolean {
1123
+ return WAIT_LOOP.some((rule) => rule.test(command))
1124
+ }
1125
+
498
1126
  // --- Default corrections ------------------------------------------------------
499
1127
 
500
1128
  /**
@@ -519,7 +1147,23 @@ export function suggestCorrection(signature: string, snippet: string): string {
519
1147
  if (/\b(npm|yarn|pnpm|bun)\s+(install|ci)\b/i.test(signature)) {
520
1148
  return "Dependency install failed — inspect the resolver error; try the lockfile/legacy-peer-deps route the repo documents."
521
1149
  }
522
- if (snippet !== "" && snippet !== "exit code 1") {
1150
+ // Unix tools absent from PowerShell: a missing-command failure on one is a platform habit, teach the native form.
1151
+ if (/\b(head|tail|cat|wc|grep|sed|awk|cut|sort|uniq|tr|xargs|less)\b/i.test(signature) && /not recognized|command not found|Check the spelling of the name/i.test(snippet)) {
1152
+ return "Unix tool, not a PowerShell command — use the native equivalent: Select-Object -First/-Last for head/tail, Get-Content for cat, Select-String for grep, (Get-Content <file>).Count for wc -l."
1153
+ }
1154
+ // File-tool probes: not-found means a wrong path guess — locate the file
1155
+ // instead of retrying guessed path variants.
1156
+ if (/^(read|edit|write):/i.test(signature) && /ENOENT|no such file|not found/i.test(snippet)) {
1157
+ return "The file does not exist at that path — locate the real path with glob/grep before reading/editing; do not guess path variants."
1158
+ }
1159
+ // Missing command on this machine — install it or pick an available tool.
1160
+ if (/^bash:/i.test(signature) && /command not found|not recognized/i.test(snippet)) {
1161
+ return "The command is not installed on this machine — install it first, or use an alternative tool that is already available."
1162
+ }
1163
+ // A success-shaped snippet is never an error — quoting it ("Last error:
1164
+ // '17 passed'") teaches the agent to fix something that worked. Likewise a
1165
+ // bare exit code carries nothing to quote. Fall through to the generic text.
1166
+ if (snippet !== "" && !/^exit code \d+$/i.test(snippet) && !looksLikeSuccess(snippet)) {
523
1167
  return `Last error: "${snippet}" — address that specific error before retrying this exact call.`
524
1168
  }
525
1169
  return "This exact call keeps failing — inspect the last output line and change approach before retrying."