@oh-my-pi/pi-coding-agent 16.3.11 → 16.3.13

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.
Files changed (113) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/dist/cli.js +3176 -3087
  3. package/dist/types/advisor/runtime.d.ts +11 -0
  4. package/dist/types/config/keybindings.d.ts +9 -4
  5. package/dist/types/config/model-registry.d.ts +4 -0
  6. package/dist/types/config/settings-schema.d.ts +6 -0
  7. package/dist/types/config/settings.d.ts +3 -1
  8. package/dist/types/discovery/helpers.d.ts +9 -0
  9. package/dist/types/exec/bash-executor.d.ts +1 -0
  10. package/dist/types/extensibility/extensions/types.d.ts +11 -2
  11. package/dist/types/extensibility/shared-events.d.ts +2 -2
  12. package/dist/types/internal-urls/__tests__/agent-protocol-nested.test.d.ts +1 -0
  13. package/dist/types/internal-urls/registry-helpers.d.ts +7 -5
  14. package/dist/types/mnemopi/state.d.ts +7 -3
  15. package/dist/types/modes/acp/acp-event-mapper.d.ts +1 -0
  16. package/dist/types/modes/components/model-selector.d.ts +2 -1
  17. package/dist/types/modes/components/read-tool-group.d.ts +1 -0
  18. package/dist/types/modes/github-ref-autocomplete.d.ts +35 -0
  19. package/dist/types/modes/interactive-mode.d.ts +3 -1
  20. package/dist/types/modes/rpc/rpc-client.d.ts +11 -5
  21. package/dist/types/modes/rpc/rpc-mode.d.ts +1 -1
  22. package/dist/types/modes/types.d.ts +3 -1
  23. package/dist/types/modes/utils/context-usage.d.ts +0 -12
  24. package/dist/types/modes/workflow.d.ts +5 -1
  25. package/dist/types/session/agent-session.d.ts +8 -4
  26. package/dist/types/system-prompt.d.ts +1 -1
  27. package/dist/types/tools/bash-interactive.d.ts +1 -1
  28. package/dist/types/tools/bash-skill-urls.d.ts +1 -0
  29. package/dist/types/tools/bash.d.ts +2 -1
  30. package/dist/types/tools/browser/launch.d.ts +1 -0
  31. package/dist/types/tools/grep.d.ts +2 -0
  32. package/dist/types/tools/index.d.ts +4 -0
  33. package/dist/types/tools/path-utils.d.ts +24 -0
  34. package/dist/types/tools/read.d.ts +3 -0
  35. package/dist/types/tools/renderers.d.ts +12 -5
  36. package/dist/types/tools/ssh.d.ts +4 -1
  37. package/dist/types/tools/write.d.ts +1 -0
  38. package/dist/types/utils/local-date.d.ts +2 -0
  39. package/package.json +12 -12
  40. package/src/advisor/__tests__/advisor.test.ts +145 -0
  41. package/src/advisor/runtime.ts +19 -0
  42. package/src/config/api-key-resolver.ts +7 -2
  43. package/src/config/keybindings.ts +62 -10
  44. package/src/config/model-registry.ts +94 -20
  45. package/src/config/settings-schema.ts +11 -1
  46. package/src/config/settings.ts +59 -21
  47. package/src/discovery/builtin.ts +2 -1
  48. package/src/discovery/claude-plugins.ts +167 -46
  49. package/src/discovery/helpers.ts +16 -1
  50. package/src/edit/renderer.ts +20 -6
  51. package/src/eval/js/worker-core.ts +163 -6
  52. package/src/exec/bash-executor.ts +14 -9
  53. package/src/extensibility/extensions/runner.ts +1 -0
  54. package/src/extensibility/extensions/types.ts +13 -2
  55. package/src/extensibility/plugins/legacy-pi-compat.ts +6 -2
  56. package/src/extensibility/plugins/marketplace/fetcher.ts +15 -14
  57. package/src/extensibility/shared-events.ts +2 -2
  58. package/src/internal-urls/__tests__/agent-protocol-nested.test.ts +68 -0
  59. package/src/internal-urls/docs-index.generated.txt +1 -1
  60. package/src/internal-urls/registry-helpers.ts +9 -6
  61. package/src/mnemopi/state.ts +19 -5
  62. package/src/modes/acp/acp-agent.ts +69 -8
  63. package/src/modes/acp/acp-event-mapper.ts +1 -1
  64. package/src/modes/components/model-selector.ts +30 -6
  65. package/src/modes/components/read-tool-group.ts +5 -1
  66. package/src/modes/components/settings-defs.ts +1 -1
  67. package/src/modes/components/status-line/component.ts +14 -2
  68. package/src/modes/components/tool-execution.ts +28 -24
  69. package/src/modes/controllers/command-controller.ts +13 -23
  70. package/src/modes/controllers/event-controller.ts +12 -12
  71. package/src/modes/controllers/extension-ui-controller.test.ts +16 -0
  72. package/src/modes/controllers/extension-ui-controller.ts +7 -35
  73. package/src/modes/controllers/input-controller.ts +23 -57
  74. package/src/modes/controllers/mcp-command-controller.ts +10 -9
  75. package/src/modes/controllers/selector-controller.ts +16 -5
  76. package/src/modes/github-ref-autocomplete.ts +75 -0
  77. package/src/modes/interactive-mode.ts +97 -12
  78. package/src/modes/prompt-action-autocomplete.ts +35 -0
  79. package/src/modes/rpc/rpc-client.ts +42 -13
  80. package/src/modes/rpc/rpc-mode.ts +21 -19
  81. package/src/modes/types.ts +3 -0
  82. package/src/modes/utils/context-usage.ts +58 -5
  83. package/src/modes/utils/hotkeys-markdown.ts +2 -1
  84. package/src/modes/utils/ui-helpers.ts +2 -2
  85. package/src/modes/workflow.ts +14 -8
  86. package/src/prompts/agents/plan.md +0 -1
  87. package/src/prompts/agents/reviewer.md +0 -1
  88. package/src/prompts/system/plan-mode-active.md +5 -2
  89. package/src/prompts/system/system-prompt.md +1 -2
  90. package/src/prompts/system/workflow-notice.md +69 -50
  91. package/src/prompts/tools/bash.md +18 -7
  92. package/src/prompts/tools/grep.md +2 -1
  93. package/src/prompts/tools/memory-edit.md +2 -0
  94. package/src/prompts/tools/read.md +4 -3
  95. package/src/sdk.ts +11 -0
  96. package/src/session/agent-session.ts +136 -19
  97. package/src/system-prompt.ts +3 -2
  98. package/src/tools/bash-interactive.ts +1 -1
  99. package/src/tools/bash-skill-urls.ts +39 -7
  100. package/src/tools/bash.ts +69 -39
  101. package/src/tools/browser/launch.ts +31 -4
  102. package/src/tools/grep.ts +105 -21
  103. package/src/tools/image-gen.ts +1 -1
  104. package/src/tools/index.ts +11 -0
  105. package/src/tools/memory-edit.ts +3 -1
  106. package/src/tools/path-utils.ts +46 -1
  107. package/src/tools/read.ts +135 -57
  108. package/src/tools/renderers.ts +13 -5
  109. package/src/tools/ssh.ts +10 -3
  110. package/src/tools/tts.ts +1 -1
  111. package/src/tools/write.ts +26 -0
  112. package/src/utils/local-date.ts +7 -0
  113. package/src/utils/open.ts +36 -10
@@ -140,6 +140,30 @@ function unquoteToken(token: string): string {
140
140
  return token;
141
141
  }
142
142
 
143
+ function isInsideShellQuote(command: string, index: number): boolean {
144
+ let quote: "'" | '"' | undefined;
145
+ for (let i = 0; i < index; i++) {
146
+ const char = command[i];
147
+ if (char === "\\" && quote !== "'") {
148
+ i++;
149
+ continue;
150
+ }
151
+ if (char === "'" && quote !== '"') {
152
+ quote = quote === "'" ? undefined : "'";
153
+ continue;
154
+ }
155
+ if (char === '"' && quote !== "'") {
156
+ quote = quote === '"' ? undefined : '"';
157
+ }
158
+ }
159
+ return quote !== undefined;
160
+ }
161
+
162
+ function isEmbeddedInQuotedText(command: string, token: string, index: number): boolean {
163
+ if (token.startsWith("'") || token.startsWith('"')) return false;
164
+ return isInsideShellQuote(command, index);
165
+ }
166
+
143
167
  /** Shell-escape a path using single quotes. */
144
168
  function shellEscape(p: string): string {
145
169
  return `'${p.replace(/'/g, "'\\''")}'`;
@@ -216,6 +240,7 @@ export function expandSkillUrls(command: string, skills: readonly Skill[]): stri
216
240
 
217
241
  /**
218
242
  * Expand supported internal URLs in a bash command string to shell-escaped absolute paths.
243
+ * Unresolvable URLs and literal mentions inside larger quoted text are left unchanged.
219
244
  * Supported schemes: skill://, agent://, artifact://, memory://, rule://, local://
220
245
  */
221
246
  export async function expandInternalUrls(command: string, options: InternalUrlExpansionOptions): Promise<string> {
@@ -231,15 +256,22 @@ export async function expandInternalUrls(command: string, options: InternalUrlEx
231
256
  const index = match.index;
232
257
  if (index === undefined) continue;
233
258
 
259
+ if (isEmbeddedInQuotedText(command, token, index)) continue;
260
+
234
261
  const rawUrl = unquoteToken(token);
235
262
  const url = normalizeLocalScheme(rawUrl);
236
- const resolvedPath = await resolveInternalUrlToPath(
237
- url,
238
- options.skills,
239
- options.internalRouter,
240
- options.localOptions,
241
- options.ensureLocalParentDirs,
242
- );
263
+ let resolvedPath: string;
264
+ try {
265
+ resolvedPath = await resolveInternalUrlToPath(
266
+ url,
267
+ options.skills,
268
+ options.internalRouter,
269
+ options.localOptions,
270
+ options.ensureLocalParentDirs,
271
+ );
272
+ } catch {
273
+ continue;
274
+ }
243
275
  const replacement = options.noEscape ? resolvedPath : shellEscape(resolvedPath);
244
276
  expanded = `${expanded.slice(0, index)}${replacement}${expanded.slice(index + token.length)}`;
245
277
  }
package/src/tools/bash.ts CHANGED
@@ -27,6 +27,7 @@ import { type BashInteractiveResult, runInteractiveBashPty } from "./bash-intera
27
27
  import { checkBashInterception } from "./bash-interceptor";
28
28
  import { canUseInteractiveBashPty } from "./bash-pty-selection";
29
29
  import { expandInternalUrls, type InternalUrlExpansionOptions } from "./bash-skill-urls";
30
+ import { resolveEvalBackends } from "./eval-backends";
30
31
  import { invalidateGithubCacheForBashCommand } from "./gh-cache-invalidation";
31
32
  import {
32
33
  formatStyledTruncationWarning,
@@ -131,7 +132,7 @@ async function saveBashOriginalArtifact(session: ToolSession, originalText: stri
131
132
  }
132
133
  }
133
134
 
134
- const BASH_TIMEOUT_DESCRIPTION = `timeout in seconds; clamped to ${TOOL_TIMEOUTS.bash.min}-${TOOL_TIMEOUTS.bash.max}`;
135
+ const BASH_TIMEOUT_DESCRIPTION = `timeout in seconds; 0 disables the command deadline; nonzero values are clamped to ${TOOL_TIMEOUTS.bash.min}-${TOOL_TIMEOUTS.bash.max}`;
135
136
 
136
137
  const bashSchemaBase = type({
137
138
  command: type("string").describe("command to execute"),
@@ -166,6 +167,7 @@ export interface BashToolDetails {
166
167
  meta?: OutputMeta;
167
168
  timeoutSeconds?: number;
168
169
  requestedTimeoutSeconds?: number;
170
+ timeoutDisabled?: boolean;
169
171
  wallTimeMs?: number;
170
172
  /** Exit code of a command that ran to completion but failed (non-zero). */
171
173
  exitCode?: number;
@@ -375,7 +377,24 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
375
377
  };
376
378
  readonly label = "Bash";
377
379
  readonly loadMode = "essential";
378
- readonly description: string;
380
+ get description(): string {
381
+ const evalBackends = resolveEvalBackends(this.session);
382
+ const isToolActive = (name: string, fallback: boolean): boolean => this.session.isToolActive?.(name) ?? fallback;
383
+ return prompt.render(bashDescription, {
384
+ asyncEnabled: this.#asyncEnabled,
385
+ autoBackgroundEnabled: this.#autoBackgroundEnabled,
386
+ autoBackgroundThresholdSeconds: Math.max(0, Math.floor(this.#autoBackgroundThresholdMs / 1000)),
387
+ hasAstGrep: isToolActive("ast_grep", this.session.settings.get("astGrep.enabled")),
388
+ hasAstEdit: isToolActive("ast_edit", this.session.settings.get("astEdit.enabled")),
389
+ hasGrep: isToolActive("grep", this.session.settings.get("grep.enabled")),
390
+ hasGlob: isToolActive("glob", this.session.settings.get("glob.enabled")),
391
+ hasRead: isToolActive("read", true),
392
+ hasEval: isToolActive(
393
+ "eval",
394
+ evalBackends.python || evalBackends.js || evalBackends.ruby || evalBackends.julia,
395
+ ),
396
+ });
397
+ }
379
398
  readonly parameters: BashToolSchema;
380
399
  // Non-pty calls run alongside each other (the executor isolates overlapping
381
400
  // runs on the same shell session); pty takes over the terminal UI and must
@@ -397,15 +416,6 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
397
416
  ),
398
417
  );
399
418
  this.parameters = this.#asyncEnabled ? bashSchemaWithAsync : bashSchemaBase;
400
- this.description = prompt.render(bashDescription, {
401
- asyncEnabled: this.#asyncEnabled,
402
- autoBackgroundEnabled: this.#autoBackgroundEnabled,
403
- autoBackgroundThresholdSeconds: Math.max(0, Math.floor(this.#autoBackgroundThresholdMs / 1000)),
404
- hasAstGrep: this.session.settings.get("astGrep.enabled"),
405
- hasAstEdit: this.session.settings.get("astEdit.enabled"),
406
- hasGrep: this.session.settings.get("grep.enabled"),
407
- hasGlob: this.session.settings.get("glob.enabled"),
408
- });
409
419
  }
410
420
 
411
421
  #formatResultOutput(result: BashResult | BashInteractiveResult): string {
@@ -421,7 +431,11 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
421
431
  * completed command that failed; #buildCompletedResult surfaces it as an
422
432
  * error *result* (carrying execution details) rather than a throw.
423
433
  */
424
- #throwIfUnfinished(result: BashResult | BashInteractiveResult, timeoutSec: number, outputText: string): void {
434
+ #throwIfUnfinished(
435
+ result: BashResult | BashInteractiveResult,
436
+ timeoutSec: number | undefined,
437
+ outputText: string,
438
+ ): void {
425
439
  if (result.cancelled) {
426
440
  // executeBash output already carries a `[Command cancelled]` notice from
427
441
  // the sink; PTY/bridge interactive output does not, so annotate it here.
@@ -431,11 +445,9 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
431
445
  }
432
446
  if (isInteractiveResult(result) && result.timedOut) {
433
447
  const out = normalizeResultOutput(result);
434
- throw new ToolError(
435
- out
436
- ? `${out}\n\n[Command timed out after ${timeoutSec} seconds]`
437
- : `Command timed out after ${timeoutSec} seconds`,
438
- );
448
+ const message =
449
+ timeoutSec === undefined ? "Command timed out" : `Command timed out after ${timeoutSec} seconds`;
450
+ throw new ToolError(out ? `${out}\n\n[${message}]` : message);
439
451
  }
440
452
  if (result.exitCode === undefined) {
441
453
  throw new ToolError(`${outputText}\n\nCommand failed: missing exit status`);
@@ -444,7 +456,7 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
444
456
 
445
457
  async #buildCompletedResult(
446
458
  result: BashResult | BashInteractiveResult,
447
- timeoutSec: number,
459
+ timeoutSec: number | undefined,
448
460
  options: {
449
461
  requestedTimeoutSec?: number;
450
462
  notices?: readonly string[];
@@ -472,7 +484,12 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
472
484
  // Aborts / timeouts / missing-status still propagate as thrown errors.
473
485
  this.#throwIfUnfinished(result, timeoutSec, outputText);
474
486
 
475
- const details: BashToolDetails = { timeoutSeconds: timeoutSec };
487
+ const details: BashToolDetails = {};
488
+ if (timeoutSec === undefined) {
489
+ details.timeoutDisabled = true;
490
+ } else {
491
+ details.timeoutSeconds = timeoutSec;
492
+ }
476
493
  if (options.requestedTimeoutSec !== undefined && options.requestedTimeoutSec !== timeoutSec) {
477
494
  details.requestedTimeoutSeconds = options.requestedTimeoutSec;
478
495
  }
@@ -503,13 +520,17 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
503
520
  jobId: string,
504
521
  label: string,
505
522
  previewText: string,
506
- timeoutSec: number,
523
+ timeoutSec: number | undefined,
507
524
  options: { requestedTimeoutSec?: number; notices?: readonly string[] } = {},
508
525
  ): AgentToolResult<BashToolDetails> {
509
526
  const details: BashToolDetails = {
510
- timeoutSeconds: timeoutSec,
511
527
  async: { state: "running", jobId, type: "bash" },
512
528
  };
529
+ if (timeoutSec === undefined) {
530
+ details.timeoutDisabled = true;
531
+ } else {
532
+ details.timeoutSeconds = timeoutSec;
533
+ }
513
534
  if (options.requestedTimeoutSec !== undefined && options.requestedTimeoutSec !== timeoutSec) {
514
535
  details.requestedTimeoutSeconds = options.requestedTimeoutSec;
515
536
  }
@@ -539,8 +560,8 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
539
560
  #startManagedBashJob(options: {
540
561
  command: string;
541
562
  commandCwd: string;
542
- timeoutMs: number;
543
- timeoutSec: number;
563
+ timeoutMs: number | undefined;
564
+ timeoutSec: number | undefined;
544
565
  requestedTimeoutSec?: number;
545
566
  notices?: readonly string[];
546
567
 
@@ -569,7 +590,7 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
569
590
  const result = await executeBash(options.command, {
570
591
  cwd: options.commandCwd,
571
592
  sessionKey: `${this.session.getSessionId?.() ?? ""}:async:${jobId}`,
572
- timeout: options.timeoutMs,
593
+ timeout: options.timeoutMs ?? 0,
573
594
  signal: runSignal,
574
595
  env: options.resolvedEnv,
575
596
  artifactPath,
@@ -661,8 +682,9 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
661
682
  }
662
683
  }
663
684
 
664
- #resolveAutoBackgroundWaitMs(timeoutMs: number): number {
685
+ #resolveAutoBackgroundWaitMs(timeoutMs: number | undefined): number {
665
686
  if (this.#autoBackgroundThresholdMs <= 0) return 0;
687
+ if (timeoutMs === undefined) return this.#autoBackgroundThresholdMs;
666
688
  const timeoutBufferMs = 1_000;
667
689
  return Math.max(0, Math.min(this.#autoBackgroundThresholdMs, timeoutMs - timeoutBufferMs));
668
690
  }
@@ -765,13 +787,17 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
765
787
  throw new ToolError(`Working directory is not a directory: ${commandCwd}`);
766
788
  }
767
789
 
768
- // Clamp to reasonable range: 1s - 3600s (1 hour)
790
+ // A timeout of 0 is an explicit long-running-command contract: the user
791
+ // must still cancel the call or job, but OMP does not impose a deadline.
769
792
  const requestedTimeoutSec = rawTimeout;
770
- const timeoutSec = clampTimeout("bash", requestedTimeoutSec);
771
- const timeoutMs = timeoutSec * 1000;
793
+ const timeoutDisabled = requestedTimeoutSec === 0;
794
+ const timeoutSec = timeoutDisabled ? undefined : clampTimeout("bash", requestedTimeoutSec);
795
+ const timeoutMs = timeoutSec === undefined ? undefined : timeoutSec * 1000;
772
796
  const pendingNotices: string[] = [];
773
- const timeoutClampNotice = formatTimeoutClampNotice(requestedTimeoutSec, timeoutSec);
774
- if (timeoutClampNotice) pendingNotices.push(timeoutClampNotice);
797
+ if (timeoutSec !== undefined) {
798
+ const timeoutClampNotice = formatTimeoutClampNotice(requestedTimeoutSec, timeoutSec);
799
+ if (timeoutClampNotice) pendingNotices.push(timeoutClampNotice);
800
+ }
775
801
 
776
802
  if (asyncRequested) {
777
803
  if (!this.session.asyncJobManager) {
@@ -909,14 +935,16 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
909
935
  throw new ToolAbortError("Command aborted");
910
936
  }
911
937
 
912
- const timeoutPromise = Bun.sleep(timeoutMs).then(() => ({ kind: "timeout" as const }));
938
+ const timeoutPromise = timeoutMs
939
+ ? Bun.sleep(timeoutMs).then(() => ({ kind: "timeout" as const }))
940
+ : undefined;
913
941
  // Poll until the process exits, times out, or the caller aborts.
914
942
  for (;;) {
915
943
  const racers: Array<Promise<BridgeRaceResult>> = [
916
944
  exitPromise.then(s => ({ kind: "exit" as const, status: s })),
917
- timeoutPromise,
918
945
  Bun.sleep(250).then(() => ({ kind: "poll" as const })),
919
946
  ];
947
+ if (timeoutPromise) racers.push(timeoutPromise);
920
948
  if (signal) {
921
949
  racers.push(abortedP.then(() => ({ kind: "aborted" as const })));
922
950
  }
@@ -1053,7 +1081,7 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
1053
1081
  : await executeBash(command, {
1054
1082
  cwd: commandCwd,
1055
1083
  sessionKey: this.session.getSessionId?.() ?? undefined,
1056
- timeout: timeoutMs,
1084
+ timeout: timeoutMs ?? 0,
1057
1085
  signal,
1058
1086
  env: resolvedEnv,
1059
1087
  artifactPath,
@@ -1074,11 +1102,9 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
1074
1102
  }
1075
1103
  if (isInteractiveResult(result) && result.timedOut) {
1076
1104
  const out = normalizeResultOutput(result);
1077
- throw new ToolError(
1078
- out
1079
- ? `${out}\n\n[Command timed out after ${timeoutSec} seconds]`
1080
- : `Command timed out after ${timeoutSec} seconds`,
1081
- );
1105
+ const message =
1106
+ timeoutSec === undefined ? "Command timed out" : `Command timed out after ${timeoutSec} seconds`;
1107
+ throw new ToolError(out ? `${out}\n\n[${message}]` : message);
1082
1108
  }
1083
1109
  return this.#buildCompletedResult(result, timeoutSec, {
1084
1110
  requestedTimeoutSec,
@@ -1286,13 +1312,17 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
1286
1312
  const showingFullOutput = expanded && renderContext?.isFullOutput === true;
1287
1313
 
1288
1314
  // Build truncation warning
1289
- const timeoutSeconds = details?.timeoutSeconds ?? renderContext?.timeout;
1315
+ const timeoutDisabled = details?.timeoutDisabled === true || renderContext?.timeout === 0;
1316
+ const timeoutSeconds = timeoutDisabled ? undefined : (details?.timeoutSeconds ?? renderContext?.timeout);
1290
1317
  const requestedTimeoutSeconds = details?.requestedTimeoutSeconds;
1291
1318
  const wallTimeMs = details?.wallTimeMs;
1292
1319
  const statsParts: string[] = [];
1293
1320
  if (wallTimeMs !== undefined) {
1294
1321
  statsParts.push(`Wall: ${formatWallTimeSeconds(wallTimeMs)}s`);
1295
1322
  }
1323
+ if (timeoutDisabled) {
1324
+ statsParts.push("Timeout: disabled");
1325
+ }
1296
1326
  if (typeof timeoutSeconds === "number") {
1297
1327
  statsParts.push(
1298
1328
  requestedTimeoutSeconds !== undefined && requestedTimeoutSeconds !== timeoutSeconds
@@ -29,15 +29,20 @@ export const DEFAULT_VIEWPORT = { width: 1365, height: 768, deviceScaleFactor: 1
29
29
  * connection dropped, etc.).
30
30
  */
31
31
  export const BROWSER_PROTOCOL_TIMEOUT_MS = 60_000;
32
+ const ENABLE_AUTOMATION_FLAG = "--enable-automation";
32
33
  // Automation-tell launch flags that puppeteer-core adds by default. We suppress
33
34
  // them via `ignoreDefaultArgs` (the supported escape hatch) to mirror xxxx's
34
- // chromiumSwitches patch. `--enable-automation` is the loudest: it sets
35
+ // chromiumSwitches patch. `--enable-automation` is the loudest: it normally sets
35
36
  // navigator.webdriver=true and shows the "controlled by automated software" infobar.
37
+ // Edge is the launch-stability exception: it can exit before CDP opens when this
38
+ // default flag is stripped, so Edge keeps Puppeteer's flag while our explicit
39
+ // `--disable-blink-features=AutomationControlled` launch arg still handles
40
+ // navigator.webdriver.
36
41
  // `ignoreDefaultArgs` does exact-string matching, so each entry must be a flag that
37
42
  // puppeteer emits verbatim. The default `--disable-features=...` string can't be
38
43
  // matched this way; it is neutralized in the puppeteer-core patch (ChromeLauncher).
39
44
  const STEALTH_IGNORE_DEFAULT_ARGS = [
40
- "--enable-automation",
45
+ ENABLE_AUTOMATION_FLAG,
41
46
  "--disable-extensions",
42
47
  "--disable-default-apps",
43
48
  "--disable-component-extensions-with-background-pages",
@@ -47,6 +52,23 @@ const STEALTH_IGNORE_DEFAULT_ARGS = [
47
52
  "--disable-ipc-flooding-protection",
48
53
  "--metrics-recording-only",
49
54
  ];
55
+
56
+ function isMicrosoftEdgeExecutable(executablePath: string | undefined): boolean {
57
+ if (!executablePath) return false;
58
+ const normalizedPath = executablePath.replaceAll("\\", "/").toLowerCase();
59
+ const executableName = normalizedPath.slice(normalizedPath.lastIndexOf("/") + 1);
60
+ return (
61
+ executableName === "msedge.exe" ||
62
+ executableName === "microsoft edge" ||
63
+ executableName.startsWith("microsoft-edge")
64
+ );
65
+ }
66
+
67
+ function stealthIgnoreDefaultArgs(executablePath: string | undefined): string[] {
68
+ if (!isMicrosoftEdgeExecutable(executablePath)) return [...STEALTH_IGNORE_DEFAULT_ARGS];
69
+ return STEALTH_IGNORE_DEFAULT_ARGS.filter(arg => arg !== ENABLE_AUTOMATION_FLAG);
70
+ }
71
+
50
72
  const STEALTH_ACCEPT_LANGUAGE = "en-US,en";
51
73
 
52
74
  const USER_AGENT_TARGET_TIMEOUT_MS = 5_000;
@@ -282,12 +304,13 @@ export async function launchHeadlessBrowser(opts: LaunchHeadlessOptions): Promis
282
304
  if (ignoreCert === "true" || ignoreCert === "1" || ignoreCert === "yes" || ignoreCert === "on") {
283
305
  launchArgs.push("--ignore-certificate-errors");
284
306
  }
307
+ const executablePath = await ensureChromiumExecutable();
285
308
  return await puppeteer.launch({
286
309
  headless: opts.headless,
287
310
  defaultViewport: opts.headless ? initialViewport : null,
288
- executablePath: await ensureChromiumExecutable(),
311
+ executablePath,
289
312
  args: launchArgs,
290
- ignoreDefaultArgs: [...STEALTH_IGNORE_DEFAULT_ARGS],
313
+ ignoreDefaultArgs: stealthIgnoreDefaultArgs(executablePath),
291
314
  protocolTimeout: BROWSER_PROTOCOL_TIMEOUT_MS,
292
315
  });
293
316
  }
@@ -737,6 +760,10 @@ export async function applyStealthPatches(
737
760
  await injectStealthScripts(page);
738
761
  }
739
762
 
763
+ export function stealthIgnoreDefaultArgsForTest(executablePath: string | undefined): string[] {
764
+ return stealthIgnoreDefaultArgs(executablePath);
765
+ }
766
+
740
767
  export function targetSupportsUserAgentOverrideForTest(target: Target): boolean {
741
768
  return targetSupportsUserAgentOverride(target);
742
769
  }
package/src/tools/grep.ts CHANGED
@@ -48,12 +48,14 @@ import {
48
48
  type LineRange,
49
49
  parseLineRanges,
50
50
  pathTargetsSsh,
51
+ probeLiteralPathExists,
51
52
  type ResolvedSearchTarget,
52
53
  resolveReadPath,
53
54
  resolveToolSearchScope,
54
55
  selectorLineRanges,
55
56
  splitInternalUrlSel,
56
57
  splitPathAndSel,
58
+ splitPathAndSelPreferringLiteral,
57
59
  toPathList,
58
60
  } from "./path-utils";
59
61
  import {
@@ -77,6 +79,9 @@ const searchSchema = type({
77
79
  "path?": searchPathEntry.describe(
78
80
  'file, directory, glob, internal URL, or "<file>:<lines>" selector to search; pass several as a semicolon-delimited list ("src; tests"). Omitted -> searches the workspace root (".")',
79
81
  ),
82
+ "selector?": type("string").describe(
83
+ 'line selector applied to every searched file (e.g. "50-100", "50+10", "50-100,200-300"); never a path like "/"',
84
+ ),
80
85
  "case?": type("boolean").describe("case-sensitive search"),
81
86
  "gitignore?": type("boolean").describe("respect gitignore"),
82
87
  "skip?": type("number")
@@ -119,7 +124,9 @@ const SEARCH_GREP_TIMEOUT_MS = 30_000;
119
124
  interface GrepPathSpec {
120
125
  original: string;
121
126
  clean: string;
127
+ literalFilesystemMatch?: boolean;
122
128
  ranges?: [LineRange, ...LineRange[]];
129
+ rangeSource?: "explicit" | "path";
123
130
  }
124
131
 
125
132
  /**
@@ -147,9 +154,39 @@ function isReadSelectorGrammar(sel: string): boolean {
147
154
  return lower === "raw" || lower === "conflicts" || parseLineRanges(sel) !== null;
148
155
  }
149
156
 
150
- function parsePathSpecs(rawEntries: readonly string[]): GrepPathSpec[] {
157
+ async function parsePathSpecs(
158
+ rawEntries: readonly string[],
159
+ cwd: string,
160
+ explicitSelector?: string,
161
+ ): Promise<GrepPathSpec[]> {
162
+ const normalizedSelector = explicitSelector?.trim() || undefined;
163
+ const explicitRanges = normalizedSelector === undefined ? undefined : parseLineRanges(normalizedSelector);
164
+ if (normalizedSelector !== undefined && !explicitRanges) {
165
+ throw new ToolError(
166
+ `selector "${normalizedSelector}" is invalid — use line ranges like "50-100", "50+10", or "50-100,200-300" without a leading colon`,
167
+ );
168
+ }
151
169
  const specs: GrepPathSpec[] = [];
152
170
  for (const entry of rawEntries) {
171
+ if (explicitRanges) {
172
+ // Separate selector parameter makes `path` deterministic: first try the
173
+ // exact local filesystem path (with read-path normalization), then let
174
+ // archive/internal/URL resolution handle non-literal structured paths.
175
+ const rawPathHasScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(entry);
176
+ const probe = rawPathHasScheme ? "missing" : await probeLiteralPathExists(entry, cwd);
177
+ // `"unknown"` covers EACCES/IO where we cannot confirm existence — treat
178
+ // it as a literal so a real file such as `test:1-2` under an unreadable
179
+ // parent is never silently reinterpreted as `test` + selector.
180
+ const literalMatch = probe !== "missing";
181
+ specs.push({
182
+ original: entry,
183
+ clean: literalMatch && !rawPathHasScheme ? resolveReadPath(entry, cwd) : entry,
184
+ literalFilesystemMatch: literalMatch,
185
+ ranges: explicitRanges,
186
+ rangeSource: "explicit",
187
+ });
188
+ continue;
189
+ }
153
190
  // Internal URLs (`artifact://`, `skill://`, …) use the URL-aware splitter,
154
191
  // which peels selector-shaped tails only for selector-capable schemes and
155
192
  // leaves opaque ones (`mcp://`) intact. Unlike filesystem paths, their
@@ -168,10 +205,15 @@ function parsePathSpecs(rawEntries: readonly string[]): GrepPathSpec[] {
168
205
  specs.push({ original: entry, clean: internalSplit.path, ranges: selectorLineRanges(internalSplit.sel) });
169
206
  continue;
170
207
  }
171
- const split = splitPathAndSel(entry);
172
- let clean = entry;
208
+ // Prefer a literal filesystem match when one exists — a real file named
209
+ // `test:1-2` outranks the `:1-2` selector interpretation (issue #4618).
210
+ const strictSplit = splitPathAndSel(entry);
211
+ const split = await splitPathAndSelPreferringLiteral(entry, cwd);
212
+ const literalFilesystemMatch = strictSplit.sel !== undefined && split.sel === undefined;
213
+ let clean = literalFilesystemMatch ? resolveReadPath(entry, cwd) : entry;
173
214
  let ranges: [LineRange, ...LineRange[]] | undefined;
174
- if (split.sel) {
215
+ let rangeSource: "path" | undefined;
216
+ if (!literalFilesystemMatch && split.sel) {
175
217
  const parsed = parseLineRanges(split.sel);
176
218
  if (!parsed) {
177
219
  throw new ToolError(
@@ -183,8 +225,15 @@ function parsePathSpecs(rawEntries: readonly string[]): GrepPathSpec[] {
183
225
  }
184
226
  clean = split.path;
185
227
  ranges = parsed;
228
+ rangeSource = "path";
186
229
  }
187
- specs.push({ original: entry, clean, ranges });
230
+ specs.push({
231
+ original: entry,
232
+ clean,
233
+ literalFilesystemMatch,
234
+ ranges,
235
+ rangeSource: ranges ? rangeSource : undefined,
236
+ });
188
237
  }
189
238
  return specs;
190
239
  }
@@ -220,7 +269,7 @@ function matchAbsolutePath(matchPath: string, searchPath: string): string {
220
269
  * cleanup hook the caller MUST invoke in a `finally`.
221
270
  */
222
271
  async function resolveArchiveSearchPaths(
223
- paths: string[],
272
+ pathSpecs: readonly GrepPathSpec[],
224
273
  cwd: string,
225
274
  ): Promise<{
226
275
  resolvedPaths: string[];
@@ -229,17 +278,18 @@ async function resolveArchiveSearchPaths(
229
278
  unreadable: string[];
230
279
  cleanup: () => Promise<void>;
231
280
  }> {
232
- const resolvedPaths = paths.slice();
281
+ const resolvedPaths = pathSpecs.map(spec => spec.clean);
233
282
  const displayMap = new Map<string, string>();
234
283
  const displaySet = new Set<string>();
235
284
  const unreadable: string[] = [];
236
285
  let tempDir: string | undefined;
237
286
  const archiveCache = new Map<string, ArchiveReader>();
238
287
 
239
- for (let idx = 0; idx < paths.length; idx++) {
240
- const entry = paths[idx];
288
+ for (let idx = 0; idx < pathSpecs.length; idx++) {
289
+ const spec = pathSpecs[idx];
290
+ if (!spec || spec.literalFilesystemMatch) continue;
291
+ const entry = spec.clean;
241
292
  const candidates = parseArchivePathCandidates(entry);
242
- // Longest archive prefix first; we want the one whose member portion is non-empty.
243
293
  const member = candidates.find(c => c.subPath !== "" && c.archivePath !== entry);
244
294
  if (!member) continue;
245
295
 
@@ -360,6 +410,27 @@ function lineAllowed(lineNumber: number, ranges: readonly LineRange[] | undefine
360
410
  return !ranges || isLineInRanges(lineNumber, ranges);
361
411
  }
362
412
 
413
+ /**
414
+ * Per-file native fetch budget that guarantees the JS range filter can still
415
+ * surface `perFileKeep` in-range hits. Matches arrive one entry per matched
416
+ * line in line order, so a bounded range's hits all sit within the first
417
+ * `endLine` entries, and an open-ended range starting at S is preceded by at
418
+ * most S-1 out-of-range entries — S-1+perFileKeep entries cover the kept
419
+ * window or exhaust the file. Clamped to the native file-size ceiling (a
420
+ * ≤4 MiB file cannot have more matched lines than bytes), which also keeps
421
+ * the scaled global budget inside the native layer's u32 bounds.
422
+ */
423
+ function lineRangeFetchCap(pathSpecs: readonly GrepPathSpec[], perFileKeep: number): number {
424
+ let cap = 0;
425
+ for (const spec of pathSpecs) {
426
+ if (!spec.ranges) continue;
427
+ for (const range of spec.ranges) {
428
+ cap = Math.max(cap, range.endLine ?? range.startLine - 1 + perFileKeep);
429
+ }
430
+ }
431
+ return Math.min(cap, NATIVE_GREP_MAX_FILE_BYTES);
432
+ }
433
+
363
434
  /** Binary search for the index of the line containing byte `offset`. */
364
435
  function findLineIndex(starts: readonly number[], offset: number): number {
365
436
  if (starts.length === 0) return -1;
@@ -879,7 +950,7 @@ export class GrepTool implements AgentTool<typeof searchSchema, GrepToolDetails>
879
950
  _onUpdate?: AgentToolUpdateCallback<GrepToolDetails>,
880
951
  _toolContext?: AgentToolContext,
881
952
  ): Promise<AgentToolResult<GrepToolDetails>> {
882
- const { pattern, path: rawPath, case: caseSensitive, gitignore, skip } = params;
953
+ const { pattern, path: rawPath, selector, case: caseSensitive, gitignore, skip } = params;
883
954
 
884
955
  return untilAborted(signal, async () => {
885
956
  // Preserve the pattern verbatim — leading/trailing whitespace is
@@ -897,8 +968,7 @@ export class GrepTool implements AgentTool<typeof searchSchema, GrepToolDetails>
897
968
  const scopedPaths = toPathList(rawPath);
898
969
  const effectivePaths = scopedPaths.length > 0 ? scopedPaths : ["."];
899
970
  const rawEntries = await expandDelimitedPathEntries(effectivePaths, this.session.cwd);
900
- const pathSpecs = parsePathSpecs(rawEntries);
901
- const paths = pathSpecs.map(spec => spec.clean);
971
+ const pathSpecs = await parsePathSpecs(rawEntries, this.session.cwd, selector);
902
972
  const materializedExternalPaths = new Map<string, string>();
903
973
  const materializeExternalUrlForSearch = async (rawPath: string) => {
904
974
  const target = parseReadUrlTarget(rawPath);
@@ -917,7 +987,7 @@ export class GrepTool implements AgentTool<typeof searchSchema, GrepToolDetails>
917
987
  displaySet: archiveDisplaySet,
918
988
  unreadable: archiveUnreadable,
919
989
  cleanup: cleanupArchiveScratch,
920
- } = await resolveArchiveSearchPaths(paths, this.session.cwd);
990
+ } = await resolveArchiveSearchPaths(pathSpecs, this.session.cwd);
921
991
  try {
922
992
  const internalResolution = await resolveInternalSearchInputs({
923
993
  pathSpecs,
@@ -932,6 +1002,7 @@ export class GrepTool implements AgentTool<typeof searchSchema, GrepToolDetails>
932
1002
  const searchablePaths = internalResolution.paths;
933
1003
  const { virtualResources, virtualPathSet, virtualInputIndexes } = internalResolution;
934
1004
  const rangesByAbsPath = new Map<string, LineRange[]>();
1005
+ const globalRanges = pathSpecs.find(spec => spec.rangeSource === "explicit")?.ranges;
935
1006
 
936
1007
  if (
937
1008
  archiveUnreadable.length > 0 &&
@@ -991,6 +1062,7 @@ export class GrepTool implements AgentTool<typeof searchSchema, GrepToolDetails>
991
1062
  for (let idx = 0; idx < pathSpecs.length; idx++) {
992
1063
  const spec = pathSpecs[idx];
993
1064
  if (!spec.ranges) continue;
1065
+ if (spec.rangeSource === "explicit") continue;
994
1066
  if (virtualInputIndexes.has(idx)) continue;
995
1067
  const resolved = internalResolution.resolvedPathsByInput[idx];
996
1068
  if (!resolved) continue;
@@ -1056,6 +1128,18 @@ export class GrepTool implements AgentTool<typeof searchSchema, GrepToolDetails>
1056
1128
  Boolean(multiTargets) ||
1057
1129
  (virtualResources.length > 0 && (virtualResources.length > 1 || searchablePaths.length > 0));
1058
1130
  const perFileMatchCap = isMultiScope ? MULTI_FILE_PER_FILE_MATCHES : SINGLE_FILE_MATCHES;
1131
+ // Range filtering happens in JS after the native fetch, so out-of-range
1132
+ // matches consume fetch budget. Widen the per-file budget just enough
1133
+ // that filtering can still yield `perFileMatchCap` in-range hits, and
1134
+ // scale the global safety ceiling by the same amplification so ranged
1135
+ // searches keep the baseline file coverage while staying finite.
1136
+ const hasLineRangeFilters = pathSpecs.some(spec => spec.ranges);
1137
+ const nativeMaxCountPerFile = hasLineRangeFilters
1138
+ ? Math.max(perFileMatchCap + 1, lineRangeFetchCap(pathSpecs, perFileMatchCap + 1))
1139
+ : perFileMatchCap + 1;
1140
+ const nativeMaxCount = hasLineRangeFilters
1141
+ ? Math.ceil(INTERNAL_TOTAL_CAP / (perFileMatchCap + 1)) * nativeMaxCountPerFile
1142
+ : INTERNAL_TOTAL_CAP;
1059
1143
 
1060
1144
  // Run grep
1061
1145
  let result: GrepResult = {
@@ -1090,12 +1174,12 @@ export class GrepTool implements AgentTool<typeof searchSchema, GrepToolDetails>
1090
1174
  multiline: effectiveMultiline,
1091
1175
  hidden: true,
1092
1176
  gitignore: useGitignore,
1093
- maxCount: INTERNAL_TOTAL_CAP,
1177
+ maxCount: nativeMaxCount,
1094
1178
  contextBefore: normalizedContextBefore,
1095
1179
  contextAfter: normalizedContextAfter,
1096
1180
  maxColumns: DEFAULT_MAX_COLUMN,
1097
1181
  mode: effectiveOutputMode,
1098
- maxCountPerFile: perFileMatchCap + 1,
1182
+ maxCountPerFile: nativeMaxCountPerFile,
1099
1183
  signal,
1100
1184
  timeoutMs: SEARCH_GREP_TIMEOUT_MS,
1101
1185
  },
@@ -1137,12 +1221,12 @@ export class GrepTool implements AgentTool<typeof searchSchema, GrepToolDetails>
1137
1221
  multiline: effectiveMultiline,
1138
1222
  hidden: true,
1139
1223
  gitignore: useGitignore,
1140
- maxCount: INTERNAL_TOTAL_CAP,
1224
+ maxCount: nativeMaxCount,
1141
1225
  contextBefore: normalizedContextBefore,
1142
1226
  contextAfter: normalizedContextAfter,
1143
1227
  maxColumns: DEFAULT_MAX_COLUMN,
1144
1228
  mode: effectiveOutputMode,
1145
- maxCountPerFile: perFileMatchCap + 1,
1229
+ maxCountPerFile: nativeMaxCountPerFile,
1146
1230
  signal,
1147
1231
  timeoutMs: SEARCH_GREP_TIMEOUT_MS,
1148
1232
  },
@@ -1183,12 +1267,12 @@ export class GrepTool implements AgentTool<typeof searchSchema, GrepToolDetails>
1183
1267
  }
1184
1268
  throw err;
1185
1269
  }
1186
- result = mergeGrepResults(result, virtualResult, INTERNAL_TOTAL_CAP);
1187
- if (rangesByAbsPath.size > 0) {
1270
+ result = mergeGrepResults(result, virtualResult, nativeMaxCount);
1271
+ if (rangesByAbsPath.size > 0 || globalRanges) {
1188
1272
  const filteredMatches: GrepMatch[] = [];
1189
1273
  for (const match of result.matches) {
1190
1274
  const abs = matchAbsolutePath(match.path, searchPath);
1191
- const ranges = rangesByAbsPath.get(abs);
1275
+ const ranges = rangesByAbsPath.get(abs) ?? globalRanges;
1192
1276
  if (!ranges) {
1193
1277
  // Path has no line-range constraint (e.g. a peer entry without `:N-M`).
1194
1278
  filteredMatches.push(match);