@oh-my-pi/pi-coding-agent 16.3.10 → 16.3.12

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 (85) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/dist/cli.js +3368 -3277
  3. package/dist/types/advisor/runtime.d.ts +11 -0
  4. package/dist/types/config/model-registry.d.ts +4 -0
  5. package/dist/types/config/settings-schema.d.ts +6 -0
  6. package/dist/types/config/settings.d.ts +2 -0
  7. package/dist/types/discovery/helpers.d.ts +9 -0
  8. package/dist/types/exec/bash-executor.d.ts +1 -0
  9. package/dist/types/extensibility/shared-events.d.ts +2 -2
  10. package/dist/types/internal-urls/__tests__/agent-protocol-nested.test.d.ts +1 -0
  11. package/dist/types/internal-urls/registry-helpers.d.ts +7 -5
  12. package/dist/types/modes/components/model-selector.d.ts +2 -1
  13. package/dist/types/modes/github-ref-autocomplete.d.ts +35 -0
  14. package/dist/types/modes/utils/context-usage.d.ts +0 -12
  15. package/dist/types/modes/workflow.d.ts +5 -1
  16. package/dist/types/session/agent-session.d.ts +5 -0
  17. package/dist/types/system-prompt.d.ts +1 -1
  18. package/dist/types/tools/bash-interactive.d.ts +1 -1
  19. package/dist/types/tools/bash-skill-urls.d.ts +1 -0
  20. package/dist/types/tools/bash.d.ts +2 -1
  21. package/dist/types/tools/browser/launch.d.ts +1 -0
  22. package/dist/types/tools/grep.d.ts +2 -0
  23. package/dist/types/tools/index.d.ts +4 -0
  24. package/dist/types/tools/path-utils.d.ts +24 -0
  25. package/dist/types/tools/read.d.ts +2 -0
  26. package/dist/types/utils/local-date.d.ts +2 -0
  27. package/package.json +12 -12
  28. package/src/advisor/__tests__/advisor.test.ts +145 -0
  29. package/src/advisor/runtime.ts +19 -0
  30. package/src/config/api-key-resolver.ts +7 -2
  31. package/src/config/model-discovery.ts +18 -2
  32. package/src/config/model-registry.ts +9 -0
  33. package/src/config/settings-schema.ts +11 -1
  34. package/src/config/settings.ts +11 -0
  35. package/src/discovery/builtin.ts +2 -1
  36. package/src/discovery/claude-plugins.ts +167 -46
  37. package/src/discovery/helpers.ts +16 -1
  38. package/src/edit/renderer.ts +20 -6
  39. package/src/eval/js/worker-core.ts +163 -6
  40. package/src/exec/bash-executor.ts +14 -9
  41. package/src/extensibility/plugins/legacy-pi-compat.ts +6 -2
  42. package/src/extensibility/plugins/marketplace/fetcher.ts +15 -14
  43. package/src/extensibility/shared-events.ts +2 -2
  44. package/src/internal-urls/__tests__/agent-protocol-nested.test.ts +68 -0
  45. package/src/internal-urls/docs-index.generated.txt +1 -1
  46. package/src/internal-urls/registry-helpers.ts +9 -6
  47. package/src/modes/components/model-selector.ts +30 -6
  48. package/src/modes/components/settings-defs.ts +1 -1
  49. package/src/modes/components/status-line/component.ts +14 -2
  50. package/src/modes/controllers/command-controller.ts +13 -23
  51. package/src/modes/controllers/event-controller.ts +12 -12
  52. package/src/modes/controllers/extension-ui-controller.ts +6 -35
  53. package/src/modes/controllers/input-controller.ts +18 -2
  54. package/src/modes/controllers/mcp-command-controller.ts +10 -9
  55. package/src/modes/controllers/selector-controller.ts +16 -5
  56. package/src/modes/github-ref-autocomplete.ts +75 -0
  57. package/src/modes/interactive-mode.ts +54 -10
  58. package/src/modes/prompt-action-autocomplete.ts +35 -0
  59. package/src/modes/utils/context-usage.ts +58 -5
  60. package/src/modes/utils/hotkeys-markdown.ts +2 -1
  61. package/src/modes/utils/ui-helpers.ts +2 -2
  62. package/src/modes/workflow.ts +14 -8
  63. package/src/prompts/system/plan-mode-active.md +5 -2
  64. package/src/prompts/system/system-prompt.md +1 -2
  65. package/src/prompts/system/title-system.md +10 -10
  66. package/src/prompts/system/workflow-notice.md +69 -50
  67. package/src/prompts/tools/bash.md +18 -7
  68. package/src/prompts/tools/grep.md +1 -1
  69. package/src/prompts/tools/read.md +4 -3
  70. package/src/sdk.ts +11 -0
  71. package/src/session/agent-session.ts +128 -14
  72. package/src/system-prompt.test.ts +34 -1
  73. package/src/system-prompt.ts +27 -10
  74. package/src/tools/bash-interactive.ts +1 -1
  75. package/src/tools/bash-skill-urls.ts +39 -7
  76. package/src/tools/bash.ts +69 -39
  77. package/src/tools/browser/launch.ts +31 -4
  78. package/src/tools/grep.ts +53 -14
  79. package/src/tools/index.ts +11 -0
  80. package/src/tools/path-utils.ts +46 -1
  81. package/src/tools/read.ts +108 -50
  82. package/src/utils/local-date.ts +7 -0
  83. package/src/utils/open.ts +36 -10
  84. package/src/utils/title-generator.ts +40 -65
  85. package/src/prompts/system/title-system-marker.md +0 -17
@@ -24,6 +24,7 @@ import projectPromptTemplate from "./prompts/system/project-prompt.md" with { ty
24
24
  import systemPromptTemplate from "./prompts/system/system-prompt.md" with { type: "text" };
25
25
  import { shortenPath } from "./tools/render-utils";
26
26
  import { type ActiveRepoContext, resolveActiveRepoContext } from "./utils/active-repo-context";
27
+ import { formatLocalCalendarDate } from "./utils/local-date";
27
28
  import { normalizePromptPath } from "./utils/prompt-path";
28
29
  import { AGENTS_MD_LIMIT, buildWorkspaceTree, type WorkspaceTree } from "./workspace-tree";
29
30
 
@@ -249,6 +250,21 @@ async function getCachedGpu(): Promise<string | undefined> {
249
250
  await logger.time("getCachedGpu:saveGpuCache", saveGpuCache, { gpu });
250
251
  return gpu ?? undefined;
251
252
  }
253
+
254
+ async function getCpuModel(): Promise<string | undefined> {
255
+ if (process.platform !== "linux") return undefined;
256
+ try {
257
+ const cpuInfo = await Bun.file("/proc/cpuinfo").text();
258
+ const match = /^model name\s*:\s*(.+)$/m.exec(cpuInfo);
259
+ return match?.[1]?.trim() || undefined;
260
+ } catch (error) {
261
+ if (!isEnoent(error)) {
262
+ logger.debug("Could not read Linux CPU model", { error: String(error) });
263
+ }
264
+ return undefined;
265
+ }
266
+ }
267
+
252
268
  /**
253
269
  * Kernel identity for the workstation block. Prefers the uname build string
254
270
  * from `os.version()`, but Bun on macOS 15+ (Darwin 24/25) returns the literal
@@ -263,13 +279,10 @@ function getKernelIdentity(): string {
263
279
  return `${os.type()} ${os.release()}`.trim();
264
280
  }
265
281
 
266
- function getEnvironmentInfo(gpu: string | undefined): Array<{ label: string; value: string }> {
267
- let cpuModel: string | undefined;
268
- try {
269
- cpuModel = os.cpus()[0]?.model;
270
- } catch {
271
- cpuModel = undefined;
272
- }
282
+ function getEnvironmentInfo(
283
+ cpuModel: string | undefined,
284
+ gpu: string | undefined,
285
+ ): Array<{ label: string; value: string }> {
273
286
  const entries: Array<{ label: string; value: string | undefined }> = [
274
287
  { label: "OS", value: `${os.platform()} ${os.release()}` },
275
288
  { label: "Distro", value: os.type() },
@@ -388,7 +401,7 @@ export async function loadSystemPromptFiles(options: LoadContextFilesOptions = {
388
401
  return userLevel?.content ?? null;
389
402
  }
390
403
 
391
- export const DEFAULT_SYSTEM_PROMPT_TOOL_NAMES = ["read", "bash", "eval", "edit", "write"] as const;
404
+ export const DEFAULT_SYSTEM_PROMPT_TOOL_NAMES = ["read", "bash", "edit", "write"] as const;
392
405
 
393
406
  export interface SystemPromptToolMetadata {
394
407
  label: string;
@@ -549,6 +562,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
549
562
  agentsMdFiles: [],
550
563
  } satisfies WorkspaceTree,
551
564
  activeRepoContext: null as ActiveRepoContext | null,
565
+ cpuModel: undefined as string | undefined,
552
566
  gpu: undefined as string | undefined,
553
567
  };
554
568
 
@@ -619,6 +633,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
619
633
  providedActiveRepoContext !== undefined
620
634
  ? Promise.resolve(providedActiveRepoContext)
621
635
  : logger.time("resolveActiveRepoContext", () => resolveActiveRepoContext(resolvedCwd));
636
+ const cpuModelPromise = logger.time("getCpuModel", getCpuModel);
622
637
  const gpuPromise = logger.time("getCachedGpu", getCachedGpu);
623
638
 
624
639
  const [
@@ -629,6 +644,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
629
644
  skills,
630
645
  workspaceTree,
631
646
  activeRepoContext,
647
+ cpuModel,
632
648
  gpu,
633
649
  ] = await Promise.all([
634
650
  withDeadline(
@@ -652,6 +668,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
652
668
  withDeadline("loadSkills", skillsPromise, prepDefaults.skills),
653
669
  withDeadline("buildWorkspaceTree", workspaceTreePromise, prepDefaults.workspaceTree),
654
670
  withDeadline("resolveActiveRepoContext", activeRepoContextPromise, prepDefaults.activeRepoContext),
671
+ withDeadline("getCpuModel", cpuModelPromise, prepDefaults.cpuModel),
655
672
  withDeadline("getCachedGpu", gpuPromise, prepDefaults.gpu),
656
673
  ]);
657
674
  clearTimeout(deadlineTimer);
@@ -677,7 +694,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
677
694
  }
678
695
  }
679
696
 
680
- const date = new Date().toISOString().slice(0, 10);
697
+ const date = formatLocalCalendarDate();
681
698
  const dateTime = date;
682
699
  const promptCwd = shortenPath(normalizePromptPath(resolvedCwd));
683
700
  const activeRepoContextPrompt = renderActiveRepoContextPrompt(activeRepoContext);
@@ -732,7 +749,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
732
749
  ];
733
750
  const injectedAlwaysApplyRules = dedupeAlwaysApplyRules(alwaysApplyRules, promptSources);
734
751
 
735
- const environment = getEnvironmentInfo(gpu);
752
+ const environment = getEnvironmentInfo(cpuModel, gpu);
736
753
  const data = {
737
754
  systemPromptCustomization: effectiveSystemPromptCustomization,
738
755
  customPrompt: resolvedCustomPrompt,
@@ -300,7 +300,7 @@ export async function runInteractiveBashPty(
300
300
  options: {
301
301
  command: string;
302
302
  cwd: string;
303
- timeoutMs: number;
303
+ timeoutMs?: number;
304
304
  signal?: AbortSignal;
305
305
  env?: Record<string, string>;
306
306
  artifactPath?: string;
@@ -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 without a leading colon (e.g. "50-100", "50+10", "50-100,200-300"); keeps `path` literal when filenames contain colons',
84
+ ),
80
85
  "case?": type("boolean").describe("case-sensitive search"),
81
86
  "gitignore?": type("boolean").describe("respect gitignore"),
82
87
  "skip?": type("number")
@@ -119,6 +124,7 @@ 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[]];
123
129
  }
124
130
 
@@ -147,9 +153,38 @@ function isReadSelectorGrammar(sel: string): boolean {
147
153
  return lower === "raw" || lower === "conflicts" || parseLineRanges(sel) !== null;
148
154
  }
149
155
 
150
- function parsePathSpecs(rawEntries: readonly string[]): GrepPathSpec[] {
156
+ async function parsePathSpecs(
157
+ rawEntries: readonly string[],
158
+ cwd: string,
159
+ explicitSelector?: string,
160
+ ): Promise<GrepPathSpec[]> {
161
+ const explicitRanges =
162
+ explicitSelector === undefined || explicitSelector.length === 0 ? undefined : parseLineRanges(explicitSelector);
163
+ if (explicitSelector !== undefined && !explicitRanges) {
164
+ throw new ToolError(
165
+ `selector "${explicitSelector}" is invalid — use line ranges like "50-100", "50+10", or "50-100,200-300" without a leading colon`,
166
+ );
167
+ }
151
168
  const specs: GrepPathSpec[] = [];
152
169
  for (const entry of rawEntries) {
170
+ if (explicitRanges) {
171
+ // Separate selector parameter makes `path` deterministic: first try the
172
+ // exact local filesystem path (with read-path normalization), then let
173
+ // archive/internal/URL resolution handle non-literal structured paths.
174
+ const rawPathHasScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(entry);
175
+ const probe = rawPathHasScheme ? "missing" : await probeLiteralPathExists(entry, cwd);
176
+ // `"unknown"` covers EACCES/IO where we cannot confirm existence — treat
177
+ // it as a literal so a real file such as `test:1-2` under an unreadable
178
+ // parent is never silently reinterpreted as `test` + selector.
179
+ const literalMatch = probe !== "missing";
180
+ specs.push({
181
+ original: entry,
182
+ clean: literalMatch && !rawPathHasScheme ? resolveReadPath(entry, cwd) : entry,
183
+ literalFilesystemMatch: literalMatch,
184
+ ranges: explicitRanges,
185
+ });
186
+ continue;
187
+ }
153
188
  // Internal URLs (`artifact://`, `skill://`, …) use the URL-aware splitter,
154
189
  // which peels selector-shaped tails only for selector-capable schemes and
155
190
  // leaves opaque ones (`mcp://`) intact. Unlike filesystem paths, their
@@ -168,10 +203,14 @@ function parsePathSpecs(rawEntries: readonly string[]): GrepPathSpec[] {
168
203
  specs.push({ original: entry, clean: internalSplit.path, ranges: selectorLineRanges(internalSplit.sel) });
169
204
  continue;
170
205
  }
171
- const split = splitPathAndSel(entry);
172
- let clean = entry;
206
+ // Prefer a literal filesystem match when one exists — a real file named
207
+ // `test:1-2` outranks the `:1-2` selector interpretation (issue #4618).
208
+ const strictSplit = splitPathAndSel(entry);
209
+ const split = await splitPathAndSelPreferringLiteral(entry, cwd);
210
+ const literalFilesystemMatch = strictSplit.sel !== undefined && split.sel === undefined;
211
+ let clean = literalFilesystemMatch ? resolveReadPath(entry, cwd) : entry;
173
212
  let ranges: [LineRange, ...LineRange[]] | undefined;
174
- if (split.sel) {
213
+ if (!literalFilesystemMatch && split.sel) {
175
214
  const parsed = parseLineRanges(split.sel);
176
215
  if (!parsed) {
177
216
  throw new ToolError(
@@ -184,7 +223,7 @@ function parsePathSpecs(rawEntries: readonly string[]): GrepPathSpec[] {
184
223
  clean = split.path;
185
224
  ranges = parsed;
186
225
  }
187
- specs.push({ original: entry, clean, ranges });
226
+ specs.push({ original: entry, clean, literalFilesystemMatch, ranges });
188
227
  }
189
228
  return specs;
190
229
  }
@@ -220,7 +259,7 @@ function matchAbsolutePath(matchPath: string, searchPath: string): string {
220
259
  * cleanup hook the caller MUST invoke in a `finally`.
221
260
  */
222
261
  async function resolveArchiveSearchPaths(
223
- paths: string[],
262
+ pathSpecs: readonly GrepPathSpec[],
224
263
  cwd: string,
225
264
  ): Promise<{
226
265
  resolvedPaths: string[];
@@ -229,17 +268,18 @@ async function resolveArchiveSearchPaths(
229
268
  unreadable: string[];
230
269
  cleanup: () => Promise<void>;
231
270
  }> {
232
- const resolvedPaths = paths.slice();
271
+ const resolvedPaths = pathSpecs.map(spec => spec.clean);
233
272
  const displayMap = new Map<string, string>();
234
273
  const displaySet = new Set<string>();
235
274
  const unreadable: string[] = [];
236
275
  let tempDir: string | undefined;
237
276
  const archiveCache = new Map<string, ArchiveReader>();
238
277
 
239
- for (let idx = 0; idx < paths.length; idx++) {
240
- const entry = paths[idx];
278
+ for (let idx = 0; idx < pathSpecs.length; idx++) {
279
+ const spec = pathSpecs[idx];
280
+ if (!spec || spec.literalFilesystemMatch) continue;
281
+ const entry = spec.clean;
241
282
  const candidates = parseArchivePathCandidates(entry);
242
- // Longest archive prefix first; we want the one whose member portion is non-empty.
243
283
  const member = candidates.find(c => c.subPath !== "" && c.archivePath !== entry);
244
284
  if (!member) continue;
245
285
 
@@ -879,7 +919,7 @@ export class GrepTool implements AgentTool<typeof searchSchema, GrepToolDetails>
879
919
  _onUpdate?: AgentToolUpdateCallback<GrepToolDetails>,
880
920
  _toolContext?: AgentToolContext,
881
921
  ): Promise<AgentToolResult<GrepToolDetails>> {
882
- const { pattern, path: rawPath, case: caseSensitive, gitignore, skip } = params;
922
+ const { pattern, path: rawPath, selector, case: caseSensitive, gitignore, skip } = params;
883
923
 
884
924
  return untilAborted(signal, async () => {
885
925
  // Preserve the pattern verbatim — leading/trailing whitespace is
@@ -897,8 +937,7 @@ export class GrepTool implements AgentTool<typeof searchSchema, GrepToolDetails>
897
937
  const scopedPaths = toPathList(rawPath);
898
938
  const effectivePaths = scopedPaths.length > 0 ? scopedPaths : ["."];
899
939
  const rawEntries = await expandDelimitedPathEntries(effectivePaths, this.session.cwd);
900
- const pathSpecs = parsePathSpecs(rawEntries);
901
- const paths = pathSpecs.map(spec => spec.clean);
940
+ const pathSpecs = await parsePathSpecs(rawEntries, this.session.cwd, selector);
902
941
  const materializedExternalPaths = new Map<string, string>();
903
942
  const materializeExternalUrlForSearch = async (rawPath: string) => {
904
943
  const target = parseReadUrlTarget(rawPath);
@@ -917,7 +956,7 @@ export class GrepTool implements AgentTool<typeof searchSchema, GrepToolDetails>
917
956
  displaySet: archiveDisplaySet,
918
957
  unreadable: archiveUnreadable,
919
958
  cleanup: cleanupArchiveScratch,
920
- } = await resolveArchiveSearchPaths(paths, this.session.cwd);
959
+ } = await resolveArchiveSearchPaths(pathSpecs, this.session.cwd);
921
960
  try {
922
961
  const internalResolution = await resolveInternalSearchInputs({
923
962
  pathSpecs,