@oh-my-pi/pi-coding-agent 15.0.0 → 15.0.2

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 (165) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/examples/extensions/plan-mode.ts +0 -1
  3. package/package.json +10 -10
  4. package/scripts/build-binary.ts +5 -0
  5. package/src/autoresearch/helpers.ts +17 -0
  6. package/src/autoresearch/tools/log-experiment.ts +9 -17
  7. package/src/autoresearch/tools/run-experiment.ts +2 -17
  8. package/src/capability/skill.ts +7 -0
  9. package/src/cli/list-models.ts +1 -1
  10. package/src/cli/shell-cli.ts +3 -13
  11. package/src/cli/update-cli.ts +1 -1
  12. package/src/cli.ts +10 -29
  13. package/src/commands/commit.ts +10 -0
  14. package/src/commit/agentic/tools/propose-changelog.ts +8 -1
  15. package/src/commit/analysis/conventional.ts +8 -66
  16. package/src/commit/map-reduce/reduce-phase.ts +6 -65
  17. package/src/commit/pipeline.ts +2 -2
  18. package/src/commit/shared-llm.ts +89 -0
  19. package/src/config/config-file.ts +210 -0
  20. package/src/config/model-equivalence.ts +8 -11
  21. package/src/config/model-registry.ts +44 -3
  22. package/src/config/model-resolver.ts +1 -4
  23. package/src/config/settings-schema.ts +82 -1
  24. package/src/config/settings.ts +1 -1
  25. package/src/config.ts +3 -219
  26. package/src/discovery/claude-plugins.ts +19 -7
  27. package/src/edit/renderer.ts +7 -1
  28. package/src/eval/js/executor.ts +3 -0
  29. package/src/eval/js/shared/rewrite-imports.ts +2 -2
  30. package/src/eval/py/executor.ts +5 -0
  31. package/src/eval/py/runner.py +42 -11
  32. package/src/eval/py/runtime.ts +1 -0
  33. package/src/exa/factory.ts +2 -2
  34. package/src/exa/mcp-client.ts +74 -1
  35. package/src/exec/bash-executor.ts +5 -1
  36. package/src/export/html/template.generated.ts +1 -1
  37. package/src/export/html/template.js +0 -11
  38. package/src/extensibility/extensions/get-commands-handler.ts +77 -0
  39. package/src/extensibility/extensions/runner.ts +1 -1
  40. package/src/extensibility/extensions/types.ts +89 -223
  41. package/src/extensibility/hooks/types.ts +89 -314
  42. package/src/extensibility/plugins/legacy-pi-compat.ts +48 -31
  43. package/src/extensibility/shared-events.ts +343 -0
  44. package/src/extensibility/skills.ts +9 -0
  45. package/src/goals/index.ts +3 -0
  46. package/src/goals/runtime.ts +500 -0
  47. package/src/goals/state.ts +37 -0
  48. package/src/goals/tools/goal-tool.ts +237 -0
  49. package/src/hashline/anchors.ts +2 -2
  50. package/src/hashline/input.ts +2 -1
  51. package/src/hashline/parser.ts +27 -3
  52. package/src/hindsight/mental-models.ts +1 -1
  53. package/src/internal-urls/agent-protocol.ts +1 -20
  54. package/src/internal-urls/artifact-protocol.ts +1 -19
  55. package/src/internal-urls/docs-index.generated.ts +11 -12
  56. package/src/internal-urls/registry-helpers.ts +25 -0
  57. package/src/internal-urls/router.ts +8 -0
  58. package/src/internal-urls/types.ts +21 -0
  59. package/src/lsp/config.ts +15 -6
  60. package/src/lsp/defaults.json +6 -2
  61. package/src/main.ts +11 -2
  62. package/src/mcp/oauth-flow.ts +20 -0
  63. package/src/modes/acp/acp-agent.ts +327 -95
  64. package/src/modes/components/assistant-message.ts +14 -8
  65. package/src/modes/components/bash-execution.ts +24 -63
  66. package/src/modes/components/custom-message.ts +14 -40
  67. package/src/modes/components/eval-execution.ts +27 -57
  68. package/src/modes/components/execution-shared.ts +102 -0
  69. package/src/modes/components/hook-message.ts +17 -49
  70. package/src/modes/components/mcp-add-wizard.ts +26 -5
  71. package/src/modes/components/message-frame.ts +88 -0
  72. package/src/modes/components/model-selector.ts +1 -1
  73. package/src/modes/components/session-observer-overlay.ts +6 -2
  74. package/src/modes/components/session-selector.ts +1 -1
  75. package/src/modes/components/status-line/segments.ts +93 -8
  76. package/src/modes/components/status-line/types.ts +4 -0
  77. package/src/modes/components/status-line.ts +28 -10
  78. package/src/modes/components/tool-execution.ts +7 -8
  79. package/src/modes/controllers/command-controller-shared.ts +108 -0
  80. package/src/modes/controllers/command-controller.ts +13 -4
  81. package/src/modes/controllers/event-controller.ts +36 -7
  82. package/src/modes/controllers/extension-ui-controller.ts +3 -2
  83. package/src/modes/controllers/input-controller.ts +13 -0
  84. package/src/modes/controllers/mcp-command-controller.ts +56 -61
  85. package/src/modes/controllers/ssh-command-controller.ts +18 -57
  86. package/src/modes/interactive-mode.ts +624 -52
  87. package/src/modes/print-mode.ts +16 -86
  88. package/src/modes/rpc/host-uris.ts +235 -0
  89. package/src/modes/rpc/rpc-mode.ts +41 -88
  90. package/src/modes/rpc/rpc-types.ts +57 -0
  91. package/src/modes/runtime-init.ts +116 -0
  92. package/src/modes/theme/defaults/dark-poimandres.json +3 -0
  93. package/src/modes/theme/defaults/light-poimandres.json +3 -0
  94. package/src/modes/theme/theme.ts +24 -6
  95. package/src/modes/types.ts +14 -3
  96. package/src/modes/utils/context-usage.ts +13 -13
  97. package/src/modes/utils/ui-helpers.ts +10 -3
  98. package/src/plan-mode/approved-plan.ts +35 -1
  99. package/src/prompts/goals/goal-budget-limit.md +16 -0
  100. package/src/prompts/goals/goal-continuation.md +28 -0
  101. package/src/prompts/goals/goal-mode-active.md +23 -0
  102. package/src/prompts/system/plan-mode-active.md +5 -5
  103. package/src/prompts/system/plan-mode-tool-decision-reminder.md +1 -1
  104. package/src/prompts/tools/bash.md +6 -0
  105. package/src/prompts/tools/github.md +4 -4
  106. package/src/prompts/tools/goal.md +13 -0
  107. package/src/prompts/tools/hashline.md +101 -117
  108. package/src/prompts/tools/read.md +55 -36
  109. package/src/prompts/tools/resolve.md +6 -5
  110. package/src/sdk.ts +12 -5
  111. package/src/session/agent-session.ts +428 -106
  112. package/src/session/blob-store.ts +36 -3
  113. package/src/session/messages.ts +67 -2
  114. package/src/session/session-manager.ts +131 -12
  115. package/src/session/session-storage.ts +33 -15
  116. package/src/session/streaming-output.ts +309 -13
  117. package/src/slash-commands/builtin-registry.ts +18 -0
  118. package/src/ssh/ssh-executor.ts +5 -0
  119. package/src/system-prompt.ts +4 -2
  120. package/src/task/discovery.ts +5 -2
  121. package/src/task/executor.ts +19 -8
  122. package/src/task/index.ts +3 -0
  123. package/src/task/render.ts +21 -15
  124. package/src/task/types.ts +4 -0
  125. package/src/tools/ast-edit.ts +21 -120
  126. package/src/tools/ast-grep.ts +21 -119
  127. package/src/tools/bash-command-fixup.ts +47 -0
  128. package/src/tools/bash-interactive.ts +9 -1
  129. package/src/tools/bash.ts +66 -19
  130. package/src/tools/browser/attach.ts +3 -3
  131. package/src/tools/browser/launch.ts +81 -18
  132. package/src/tools/browser/registry.ts +1 -5
  133. package/src/tools/browser/render.ts +2 -2
  134. package/src/tools/browser/tab-supervisor.ts +51 -14
  135. package/src/tools/conflict-detect.ts +15 -4
  136. package/src/tools/eval.ts +12 -2
  137. package/src/tools/find.ts +20 -38
  138. package/src/tools/gh.ts +44 -10
  139. package/src/tools/index.ts +22 -11
  140. package/src/tools/inspect-image.ts +3 -10
  141. package/src/tools/job.ts +16 -7
  142. package/src/tools/output-meta.ts +202 -37
  143. package/src/tools/path-utils.ts +125 -2
  144. package/src/tools/read.ts +548 -237
  145. package/src/tools/render-utils.ts +92 -0
  146. package/src/tools/renderers.ts +2 -0
  147. package/src/tools/resolve.ts +72 -44
  148. package/src/tools/search.ts +120 -186
  149. package/src/tools/ssh.ts +3 -2
  150. package/src/tools/write.ts +64 -9
  151. package/src/utils/file-mentions.ts +1 -1
  152. package/src/utils/image-loading.ts +7 -3
  153. package/src/utils/image-resize.ts +32 -43
  154. package/src/vim/parser.ts +0 -17
  155. package/src/vim/render.ts +1 -1
  156. package/src/vim/types.ts +1 -1
  157. package/src/web/search/providers/anthropic.ts +5 -0
  158. package/src/web/search/providers/exa.ts +3 -0
  159. package/src/web/search/providers/gemini.ts +40 -95
  160. package/src/web/search/providers/jina.ts +5 -2
  161. package/src/web/search/providers/zai.ts +5 -2
  162. package/src/prompts/tools/exit-plan-mode.md +0 -6
  163. package/src/tools/exit-plan-mode.ts +0 -97
  164. package/src/utils/fuzzy.ts +0 -108
  165. package/src/utils/image-convert.ts +0 -27
package/src/tools/bash.ts CHANGED
@@ -9,7 +9,7 @@ import { type BashResult, executeBash } from "../exec/bash-executor";
9
9
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
10
10
  import { InternalUrlRouter } from "../internal-urls";
11
11
  import { truncateToVisualLines } from "../modes/components/visual-truncate";
12
- import type { Theme } from "../modes/theme/theme";
12
+ import { highlightCode, type Theme } from "../modes/theme/theme";
13
13
  import bashDescription from "../prompts/tools/bash.md" with { type: "text" };
14
14
  import type { ClientBridgeTerminalExitStatus, ClientBridgeTerminalOutput } from "../session/client-bridge";
15
15
  import { DEFAULT_MAX_BYTES, streamTailUpdates, TailBuffer } from "../session/streaming-output";
@@ -17,10 +17,11 @@ import { renderStatusLine } from "../tui";
17
17
  import { CachedOutputBlock } from "../tui/output-block";
18
18
  import { getSixelLineMask } from "../utils/sixel";
19
19
  import type { ToolSession } from ".";
20
+ import { applyBashFixups, formatBashFixupNotice } from "./bash-command-fixup";
20
21
  import { type BashInteractiveResult, runInteractiveBashPty } from "./bash-interactive";
21
22
  import { checkBashInterception } from "./bash-interceptor";
22
23
  import { expandInternalUrls, type InternalUrlExpansionOptions } from "./bash-skill-urls";
23
- import { formatStyledTruncationWarning, type OutputMeta } from "./output-meta";
24
+ import { formatStyledTruncationWarning, type OutputMeta, stripOutputNotice } from "./output-meta";
24
25
  import { resolveToCwd } from "./path-utils";
25
26
  import { formatToolWorkingDirectory, replaceTabs } from "./render-utils";
26
27
  import { ToolAbortError, ToolError } from "./tool-errors";
@@ -245,6 +246,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
245
246
  readonly #asyncEnabled: boolean;
246
247
  readonly #autoBackgroundEnabled: boolean;
247
248
  readonly #autoBackgroundThresholdMs: number;
249
+ #bashFixupNoticeEmitted = false;
248
250
 
249
251
  constructor(private readonly session: ToolSession) {
250
252
  this.#asyncEnabled = this.session.settings.get("async.enabled");
@@ -291,7 +293,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
291
293
  #buildCompletedResult(
292
294
  result: BashResult | BashInteractiveResult,
293
295
  timeoutSec: number,
294
- options: { requestedTimeoutSec?: number; notices?: string[]; terminalId?: string } = {},
296
+ options: { requestedTimeoutSec?: number; notices?: readonly string[]; terminalId?: string } = {},
295
297
  ): AgentToolResult<BashToolDetails> {
296
298
  const outputLines = [this.#formatResultOutput(result)];
297
299
  const notices = options.notices?.filter(Boolean) ?? [];
@@ -314,7 +316,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
314
316
  label: string,
315
317
  previewText: string,
316
318
  timeoutSec: number,
317
- options: { requestedTimeoutSec?: number; notices?: string[] } = {},
319
+ options: { requestedTimeoutSec?: number; notices?: readonly string[] } = {},
318
320
  ): AgentToolResult<BashToolDetails> {
319
321
  const details: BashToolDetails = {
320
322
  timeoutSeconds: timeoutSec,
@@ -352,7 +354,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
352
354
  timeoutMs: number;
353
355
  timeoutSec: number;
354
356
  requestedTimeoutSec?: number;
355
- timeoutClampNotice?: string;
357
+ notices?: readonly string[];
356
358
 
357
359
  resolvedEnv?: Record<string, string>;
358
360
  onUpdate?: AgentToolUpdateCallback<BashToolDetails>;
@@ -392,7 +394,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
392
394
  });
393
395
  const finalResult = this.#buildCompletedResult(result, options.timeoutSec, {
394
396
  requestedTimeoutSec: options.requestedTimeoutSec,
395
- notices: [options.timeoutClampNotice].filter((notice): notice is string => Boolean(notice)),
397
+ notices: options.notices ?? [],
396
398
  });
397
399
  const finalText = this.#extractTextResult(finalResult);
398
400
  latestText = finalText;
@@ -483,9 +485,23 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
483
485
  let command = rawCommand;
484
486
  const env = normalizeBashEnv(rawEnv);
485
487
 
488
+ // Apply conservative bash fixups (strip trailing `| head|tail` and redundant
489
+ // `2>&1`). The helper is single-line only and refuses anything that could
490
+ // change semantics.
491
+ let bashFixups: string[] = [];
492
+ if (this.session.settings.get("bash.stripTrailingHeadTail")) {
493
+ const fixup = applyBashFixups(command);
494
+ if (fixup.stripped.length > 0) {
495
+ command = fixup.command;
496
+ bashFixups = fixup.stripped;
497
+ }
498
+ }
499
+
486
500
  // Extract leading `cd <path> && ...` into cwd when the model ignores the cwd parameter.
501
+ // Constrained to a single line so a `&&` that sits on a later line of a multiline
502
+ // script can't pull the entire script into the "cwd" capture.
487
503
  if (!cwd) {
488
- const cdMatch = command.match(/^cd\s+((?:[^&\\]|\\.)+?)\s*&&\s*/);
504
+ const cdMatch = command.match(/^cd[ \t]+((?:[^&\\\n\r]|\\.)+?)[ \t]*&&[ \t]*/);
489
505
  if (cdMatch) {
490
506
  cwd = cdMatch[1].trim().replace(/^["']|["']$/g, "");
491
507
  command = command.slice(cdMatch[0].length);
@@ -556,7 +572,14 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
556
572
  const requestedTimeoutSec = rawTimeout;
557
573
  const timeoutSec = clampTimeout("bash", requestedTimeoutSec);
558
574
  const timeoutMs = timeoutSec * 1000;
575
+ const pendingNotices: string[] = [];
559
576
  const timeoutClampNotice = formatTimeoutClampNotice(requestedTimeoutSec, timeoutSec);
577
+ if (timeoutClampNotice) pendingNotices.push(timeoutClampNotice);
578
+ const bashFixupNotice = this.#bashFixupNoticeEmitted ? undefined : formatBashFixupNotice(bashFixups);
579
+ if (bashFixupNotice) {
580
+ pendingNotices.push(bashFixupNotice);
581
+ this.#bashFixupNoticeEmitted = true;
582
+ }
560
583
 
561
584
  if (asyncRequested) {
562
585
  if (!AsyncJobManager.instance()) {
@@ -568,7 +591,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
568
591
  timeoutMs,
569
592
  timeoutSec,
570
593
  requestedTimeoutSec,
571
- timeoutClampNotice,
594
+ notices: pendingNotices,
572
595
 
573
596
  resolvedEnv,
574
597
  onUpdate,
@@ -576,7 +599,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
576
599
  });
577
600
  return this.#buildBackgroundStartResult(job.jobId, job.label, "", timeoutSec, {
578
601
  requestedTimeoutSec,
579
- notices: [timeoutClampNotice].filter((notice): notice is string => Boolean(notice)),
602
+ notices: pendingNotices,
580
603
  });
581
604
  }
582
605
 
@@ -590,7 +613,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
590
613
  timeoutMs,
591
614
  timeoutSec,
592
615
  requestedTimeoutSec,
593
- timeoutClampNotice,
616
+ notices: pendingNotices,
594
617
 
595
618
  resolvedEnv,
596
619
  onUpdate,
@@ -599,7 +622,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
599
622
  if (startBackgrounded) {
600
623
  return this.#buildBackgroundStartResult(job.jobId, job.label, "", timeoutSec, {
601
624
  requestedTimeoutSec,
602
- notices: [timeoutClampNotice].filter((notice): notice is string => Boolean(notice)),
625
+ notices: pendingNotices,
603
626
  });
604
627
  }
605
628
  const waitResult = await this.#waitForManagedBashJob(job, autoBackgroundWaitMs, signal);
@@ -619,7 +642,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
619
642
  job.setBackgrounded(true);
620
643
  return this.#buildBackgroundStartResult(job.jobId, job.label, job.getLatestText(), timeoutSec, {
621
644
  requestedTimeoutSec,
622
- notices: [timeoutClampNotice].filter((notice): notice is string => Boolean(notice)),
645
+ notices: pendingNotices,
623
646
  });
624
647
  }
625
648
 
@@ -720,7 +743,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
720
743
  };
721
744
  return this.#buildCompletedResult(timedOutResult, timeoutSec, {
722
745
  requestedTimeoutSec,
723
- notices: [timeoutClampNotice].filter((notice): notice is string => Boolean(notice)),
746
+ notices: pendingNotices,
724
747
  terminalId: handle.terminalId,
725
748
  });
726
749
  }
@@ -776,7 +799,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
776
799
 
777
800
  const bridgeNotices: string[] = [];
778
801
  if (finalOutput.truncated) bridgeNotices.push("(output truncated)");
779
- if (timeoutClampNotice) bridgeNotices.push(timeoutClampNotice);
802
+ for (const notice of pendingNotices) bridgeNotices.push(notice);
780
803
 
781
804
  return this.#buildCompletedResult(bridgeResult, timeoutSec, {
782
805
  requestedTimeoutSec,
@@ -831,7 +854,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
831
854
  }
832
855
  return this.#buildCompletedResult(result, timeoutSec, {
833
856
  requestedTimeoutSec,
834
- notices: [timeoutClampNotice].filter((notice): notice is string => Boolean(notice)),
857
+ notices: pendingNotices,
835
858
  });
836
859
  }
837
860
  }
@@ -892,6 +915,27 @@ export function formatBashCommand(args: BashRenderArgs): string {
892
915
  return displayWorkdir ? `${prompt} cd ${displayWorkdir} && ${renderedCommand}` : `${prompt} ${renderedCommand}`;
893
916
  }
894
917
 
918
+ /**
919
+ * Returns the bash command formatted for the result body: the dim `$ cd … &&`
920
+ * prefix joined with syntax-highlighted command lines. The prefix is applied
921
+ * only to the first line so multi-line commands display cleanly — terminals
922
+ * reset SGR state at line boundaries, which made the previous single-string
923
+ * `theme.fg("dim", ...)` form render only the first line as dim.
924
+ */
925
+ export function formatBashCommandLines(args: BashRenderArgs, uiTheme: Theme): string[] {
926
+ const command = replaceTabs(args.command || "…");
927
+ const cwd = getProjectDir();
928
+ const displayWorkdir = formatToolWorkingDirectory(args.cwd, cwd);
929
+ const envAssignments = formatBashEnvAssignments(getBashEnvForDisplay(args));
930
+ const prefixParts = ["$"];
931
+ if (displayWorkdir) prefixParts.push(`cd ${displayWorkdir} &&`);
932
+ if (envAssignments) prefixParts.push(envAssignments);
933
+ const prefix = uiTheme.fg("dim", `${prefixParts.join(" ")} `);
934
+ const highlightedLines = highlightCode(command, "bash");
935
+ if (highlightedLines.length === 0) return [prefix.trimEnd()];
936
+ return highlightedLines.map((line, i) => (i === 0 ? `${prefix}${line}` : line));
937
+ }
938
+
895
939
  function toBashRenderArgs<TArgs>(args: TArgs | undefined, config: ShellRendererConfig<TArgs>): BashRenderArgs {
896
940
  return {
897
941
  command: config.resolveCommand?.(args),
@@ -922,7 +966,7 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
922
966
  args?: TArgs,
923
967
  ): Component {
924
968
  const renderArgs = toBashRenderArgs(args, config);
925
- const cmdText = args ? formatBashCommand(renderArgs) : undefined;
969
+ const cmdLines = args ? formatBashCommandLines(renderArgs, uiTheme) : undefined;
926
970
  const isError = result.isError === true;
927
971
  const icon = options.isPartial ? "pending" : isError ? "error" : "success";
928
972
  const title = config.resolveTitle(args, options);
@@ -937,8 +981,11 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
937
981
  const expanded = renderContext?.expanded ?? options.expanded;
938
982
  const previewLines = renderContext?.previewLines ?? BASH_DEFAULT_PREVIEW_LINES;
939
983
 
940
- // Get output from context (preferred) or fall back to result content
941
- const output = renderContext?.output ?? result.content?.find(c => c.type === "text")?.text ?? "";
984
+ // Get output from context (preferred) or fall back to result content.
985
+ // Strip the LLM-facing notice appended by wrappedExecute so we don't
986
+ // double-print it alongside the styled warning line below.
987
+ const rawOutput = renderContext?.output ?? result.content?.find(c => c.type === "text")?.text ?? "";
988
+ const output = stripOutputNotice(rawOutput, details?.meta);
942
989
  const displayOutput = output.trimEnd();
943
990
  const showingFullOutput = expanded && renderContext?.isFullOutput === true;
944
991
 
@@ -1000,7 +1047,7 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
1000
1047
  header,
1001
1048
  state: options.isPartial ? "pending" : isError ? "error" : "success",
1002
1049
  sections: [
1003
- { lines: cmdText ? [uiTheme.fg("dim", cmdText)] : [] },
1050
+ { lines: cmdLines ?? [] },
1004
1051
  { label: uiTheme.fg("toolTitle", "Output"), lines: outputLines },
1005
1052
  ],
1006
1053
  width,
@@ -3,7 +3,7 @@ import { Process, ProcessStatus } from "@oh-my-pi/pi-natives";
3
3
  import type { Browser, Page } from "puppeteer-core";
4
4
  import { ToolError, throwIfAborted } from "../tool-errors";
5
5
 
6
- export const ATTACH_TARGET_SKIP_PATTERN =
6
+ const ATTACH_TARGET_SKIP_PATTERN =
7
7
  /request[\s_-]?handler|devtools|background[\s_-]?(?:page|host)|service[\s_-]?worker/i;
8
8
 
9
9
  /**
@@ -62,7 +62,7 @@ export async function waitForCdp(cdpUrl: string, timeoutMs: number, signal?: Abo
62
62
  * accepts both `--flag=value` and `--flag value`). Returns null if absent or
63
63
  * malformed.
64
64
  */
65
- export function findCdpPortInArgs(args: string[]): number | null {
65
+ function findCdpPortInArgs(args: string[]): number | null {
66
66
  for (const arg of args) {
67
67
  const m = /^--remote-debugging-port=(\d+)$/.exec(arg);
68
68
  if (m) {
@@ -80,7 +80,7 @@ export function findCdpPortInArgs(args: string[]): number | null {
80
80
  }
81
81
 
82
82
  /** One-shot probe: returns true when `/json/version` answers 200 within the timeout. */
83
- export async function probeCdpAt(port: number, signal?: AbortSignal): Promise<boolean> {
83
+ async function probeCdpAt(port: number, signal?: AbortSignal): Promise<boolean> {
84
84
  const probeTimeout = AbortSignal.timeout(1500);
85
85
  const probeSignal = signal ? AbortSignal.any([signal, probeTimeout]) : probeTimeout;
86
86
  try {
@@ -3,7 +3,7 @@ import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import { $which, getPuppeteerDir, logger } from "@oh-my-pi/pi-utils";
5
5
  import * as browsers from "@puppeteer/browsers";
6
- import type { Browser, CDPSession, Page, default as Puppeteer } from "puppeteer-core";
6
+ import type { Browser, CDPSession, Page, default as Puppeteer, Target } from "puppeteer-core";
7
7
  import { PUPPETEER_REVISIONS } from "puppeteer-core/internal/revisions.js";
8
8
  import stealthTamperingScript from "../puppeteer/00_stealth_tampering.txt" with { type: "text" };
9
9
  import stealthActivityScript from "../puppeteer/01_stealth_activity.txt" with { type: "text" };
@@ -30,13 +30,15 @@ export const DEFAULT_VIEWPORT = { width: 1365, height: 768, deviceScaleFactor: 1
30
30
  * connection dropped, etc.).
31
31
  */
32
32
  export const BROWSER_PROTOCOL_TIMEOUT_MS = 60_000;
33
- export const STEALTH_IGNORE_DEFAULT_ARGS = [
33
+ const STEALTH_IGNORE_DEFAULT_ARGS = [
34
34
  "--disable-extensions",
35
35
  "--disable-default-apps",
36
36
  "--disable-component-extensions-with-background-pages",
37
37
  ];
38
- export const STEALTH_ACCEPT_LANGUAGE = "en-US,en";
38
+ const STEALTH_ACCEPT_LANGUAGE = "en-US,en";
39
39
 
40
+ const USER_AGENT_TARGET_TIMEOUT_MS = 5_000;
41
+ const USER_AGENT_TARGET_TYPES = new Set(["page", "webview", "background_page"]);
40
42
  const PUPPETEER_SOURCE_URL_SUFFIX = "//# sourceURL=__puppeteer_evaluation_script__";
41
43
 
42
44
  /**
@@ -82,7 +84,7 @@ export async function loadPuppeteerInWorker(safeDir: string): Promise<typeof Pup
82
84
  * The browser is cached under ~/.omp/puppeteer (getPuppeteerDir).
83
85
  */
84
86
  let chromiumExecutablePromise: Promise<string | undefined> | undefined;
85
- export async function ensureChromiumExecutable(): Promise<string | undefined> {
87
+ async function ensureChromiumExecutable(): Promise<string | undefined> {
86
88
  const sysChrome = resolveSystemChromium();
87
89
  if (sysChrome) return sysChrome;
88
90
  const envPath = process.env.PUPPETEER_EXECUTABLE_PATH;
@@ -138,7 +140,7 @@ export async function ensureChromiumExecutable(): Promise<string | undefined> {
138
140
  return chromiumExecutablePromise;
139
141
  }
140
142
 
141
- let _resolvedChromium: string | null | undefined; // undefined = unchecked; null = not found
143
+ let resolvedChromium: string | null | undefined; // undefined = unchecked; null = not found
142
144
 
143
145
  function isExecutableFile(p: string): boolean {
144
146
  try {
@@ -209,19 +211,19 @@ function systemChromiumCandidates(): string[] {
209
211
  return candidates;
210
212
  }
211
213
 
212
- export function resolveSystemChromium(): string | undefined {
213
- if (_resolvedChromium !== undefined) return _resolvedChromium ?? undefined;
214
+ function resolveSystemChromium(): string | undefined {
215
+ if (resolvedChromium !== undefined) return resolvedChromium ?? undefined;
214
216
  const seen = new Set<string>();
215
217
  for (const candidate of systemChromiumCandidates()) {
216
218
  if (!candidate || seen.has(candidate)) continue;
217
219
  seen.add(candidate);
218
220
  if (isExecutableFile(candidate)) {
219
- _resolvedChromium = candidate;
221
+ resolvedChromium = candidate;
220
222
  logger.debug("Using system Chrome/Chromium", { path: candidate });
221
223
  return candidate;
222
224
  }
223
225
  }
224
- _resolvedChromium = null;
226
+ resolvedChromium = null;
225
227
  return undefined;
226
228
  }
227
229
 
@@ -463,6 +465,7 @@ export interface UserAgentSession {
463
465
  async function configureUserAgentTargets(
464
466
  browser: Browser,
465
467
  state: { browserSession: CDPSession | null; override: UserAgentOverride },
468
+ targetTimeoutMs = USER_AGENT_TARGET_TIMEOUT_MS,
466
469
  ): Promise<void> {
467
470
  if (!state.browserSession) {
468
471
  state.browserSession = await browser.target().createCDPSession();
@@ -471,23 +474,72 @@ async function configureUserAgentTargets(
471
474
  waitForDebuggerOnStart: false,
472
475
  flatten: true,
473
476
  });
474
- state.browserSession.on("Target.attachedToTarget", async (event: { sessionId: string }) => {
475
- const connection = state.browserSession?.connection();
476
- const session = connection?.session(event.sessionId);
477
- if (!session) return;
478
- await sendUserAgentOverride(wrapSession(session), state.override);
479
- });
477
+ state.browserSession.on(
478
+ "Target.attachedToTarget",
479
+ async (event: { sessionId: string; targetInfo?: { type?: string } }) => {
480
+ if (!targetInfoSupportsUserAgentOverride(event.targetInfo)) return;
481
+ const connection = state.browserSession?.connection();
482
+ const session = connection?.session(event.sessionId);
483
+ if (!session) return;
484
+ await withSoftTimeout(
485
+ sendUserAgentOverride(wrapSession(session), state.override),
486
+ targetTimeoutMs,
487
+ "new target user-agent override",
488
+ );
489
+ },
490
+ );
480
491
  }
481
492
 
482
- const targets = browser.targets();
493
+ const targets = browser.targets().filter(targetSupportsUserAgentOverride);
483
494
  await Promise.all(
484
495
  targets.map(async target => {
485
- const session = await target.createCDPSession();
486
- await sendUserAgentOverride(wrapSession(session), state.override);
496
+ await withSoftTimeout(
497
+ applyTargetUserAgentOverride(target, state.override),
498
+ targetTimeoutMs,
499
+ "target user-agent override",
500
+ );
487
501
  }),
488
502
  );
489
503
  }
490
504
 
505
+ function targetSupportsUserAgentOverride(target: Target): boolean {
506
+ return targetInfoSupportsUserAgentOverride({ type: target.type() });
507
+ }
508
+
509
+ function targetInfoSupportsUserAgentOverride(targetInfo: { type?: string } | undefined): boolean {
510
+ return Boolean(targetInfo?.type && USER_AGENT_TARGET_TYPES.has(targetInfo.type));
511
+ }
512
+
513
+ async function applyTargetUserAgentOverride(target: Target, override: UserAgentOverride): Promise<void> {
514
+ const session = await target.createCDPSession();
515
+ try {
516
+ await sendUserAgentOverride(wrapSession(session), override);
517
+ } finally {
518
+ await session.detach().catch(() => undefined);
519
+ }
520
+ }
521
+
522
+ async function withSoftTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T | undefined> {
523
+ let timeout: NodeJS.Timeout | undefined;
524
+ const timeoutPromise = new Promise<undefined>(resolve => {
525
+ timeout = setTimeout(() => {
526
+ logger.debug(`Timed out applying ${label}`);
527
+ resolve(undefined);
528
+ }, timeoutMs);
529
+ });
530
+ try {
531
+ return await Promise.race([
532
+ promise.catch(error => {
533
+ logger.debug(`Failed to apply ${label}`, { error: error instanceof Error ? error.message : String(error) });
534
+ return undefined;
535
+ }),
536
+ timeoutPromise,
537
+ ]);
538
+ } finally {
539
+ if (timeout) clearTimeout(timeout);
540
+ }
541
+ }
542
+
491
543
  async function injectStealthScripts(page: Page): Promise<void> {
492
544
  const scripts = [
493
545
  stealthTamperingScript,
@@ -574,3 +626,14 @@ export async function applyStealthPatches(
574
626
  state.browserSession = targetState.browserSession;
575
627
  await injectStealthScripts(page);
576
628
  }
629
+
630
+ export function targetSupportsUserAgentOverrideForTest(target: Target): boolean {
631
+ return targetSupportsUserAgentOverride(target);
632
+ }
633
+ export async function configureUserAgentTargetsForTest(
634
+ browser: Browser,
635
+ state: { browserSession: CDPSession | null; override: UserAgentOverride },
636
+ targetTimeoutMs?: number,
637
+ ): Promise<void> {
638
+ await configureUserAgentTargets(browser, state, targetTimeoutMs);
639
+ }
@@ -26,10 +26,6 @@ export interface BrowserHandle {
26
26
 
27
27
  const browsers = new Map<string, BrowserHandle>();
28
28
 
29
- export function listBrowsers(): BrowserHandle[] {
30
- return [...browsers.values()];
31
- }
32
-
33
29
  function browserKey(kind: BrowserKind): string {
34
30
  switch (kind.kind) {
35
31
  case "headless":
@@ -166,7 +162,7 @@ export async function releaseBrowser(handle: BrowserHandle, opts: { kill: boolea
166
162
  }
167
163
  }
168
164
 
169
- export async function disposeBrowserHandle(handle: BrowserHandle, opts: { kill: boolean }): Promise<void> {
165
+ async function disposeBrowserHandle(handle: BrowserHandle, opts: { kill: boolean }): Promise<void> {
170
166
  if (handle.kind.kind === "headless") {
171
167
  if (handle.browser.connected) {
172
168
  try {
@@ -11,7 +11,7 @@ import type { RenderResultOptions } from "../../extensibility/custom-tools/types
11
11
  import type { Theme } from "../../modes/theme/theme";
12
12
  import { Hasher, renderCodeCell, renderStatusLine } from "../../tui";
13
13
  import type { BrowserToolDetails } from "../browser";
14
- import { formatStyledTruncationWarning } from "../output-meta";
14
+ import { formatStyledTruncationWarning, stripOutputNotice } from "../output-meta";
15
15
  import { replaceTabs, shortenPath } from "../render-utils";
16
16
 
17
17
  const BROWSER_DEFAULT_PREVIEW_LINES = 10;
@@ -195,7 +195,7 @@ export const browserToolRenderer = {
195
195
  const details = result.details;
196
196
  const action = details?.action ?? argsObj.action;
197
197
  const isError = result.isError === true;
198
- const output = extractTextOutput(result.content);
198
+ const output = stripOutputNotice(extractTextOutput(result.content), details?.meta);
199
199
 
200
200
  if (action === "run") {
201
201
  let component = renderRunCell(argsObj, details, options, output, isError, theme);
@@ -30,6 +30,7 @@ import type {
30
30
  interface WorkerHandle {
31
31
  send(msg: WorkerInbound, transferList?: Transferable[]): void;
32
32
  onMessage(handler: (msg: WorkerOutbound) => void): () => void;
33
+ onError(handler: (error: Error) => void): () => void;
33
34
  terminate(): Promise<void>;
34
35
  readonly mode: "worker" | "inline";
35
36
  }
@@ -89,10 +90,6 @@ export function getTab(name: string): TabSession | undefined {
89
90
  return tabs.get(name);
90
91
  }
91
92
 
92
- export function listTabs(): TabSession[] {
93
- return [...tabs.values()];
94
- }
95
-
96
93
  export async function acquireTab(
97
94
  name: string,
98
95
  browser: BrowserHandle,
@@ -124,23 +121,14 @@ export async function acquireTab(
124
121
 
125
122
  const initPayload = await buildInitPayload(browser, opts);
126
123
  const worker = await spawnTabWorker();
127
- const { promise, resolve, reject } = Promise.withResolvers<ReadyInfo>();
128
- const unlisten = worker.onMessage(msg => {
129
- if (msg.type === "ready") resolve(msg.info);
130
- else if (msg.type === "init-failed") reject(errorFromPayload(msg.error));
131
- else if (msg.type === "log") logWorkerMessage(msg);
132
- });
133
124
  let info: ReadyInfo;
134
125
  try {
135
- worker.send({ type: "init", payload: initPayload });
136
- info = await raceWithTimeout(promise, opts.timeoutMs + GRACE_MS, "Timed out initializing browser tab worker");
126
+ info = await initializeTabWorker(worker, initPayload, opts.timeoutMs + GRACE_MS);
137
127
  } catch (error) {
138
- unlisten();
139
128
  await worker.terminate().catch(() => undefined);
140
129
  if (browser.refCount === 0) await releaseBrowser(browser, { kill: false });
141
130
  throw error;
142
131
  }
143
- unlisten();
144
132
 
145
133
  holdBrowser(browser);
146
134
  const tab: TabSession = {
@@ -477,6 +465,17 @@ function wrapBunWorker(worker: Worker): WorkerHandle {
477
465
  worker.addEventListener("message", wrap);
478
466
  return () => worker.removeEventListener("message", wrap);
479
467
  },
468
+ onError(handler) {
469
+ const onError = (event: ErrorEvent): void => handler(errorFromWorkerEvent(event));
470
+ const onMessageError = (event: MessageEvent): void =>
471
+ handler(new ToolError(`Tab worker message error: ${String(event.data)}`));
472
+ worker.addEventListener("error", onError);
473
+ worker.addEventListener("messageerror", onMessageError);
474
+ return () => {
475
+ worker.removeEventListener("error", onError);
476
+ worker.removeEventListener("messageerror", onMessageError);
477
+ };
478
+ },
480
479
  async terminate() {
481
480
  worker.terminate();
482
481
  },
@@ -515,6 +514,44 @@ async function spawnInlineWorker(): Promise<WorkerHandle> {
515
514
  hostListeners.add(handler);
516
515
  return () => hostListeners.delete(handler);
517
516
  },
517
+ onError: () => () => {},
518
518
  async terminate() {},
519
519
  };
520
520
  }
521
+
522
+ async function initializeTabWorker(
523
+ worker: WorkerHandle,
524
+ payload: WorkerInitPayload,
525
+ timeoutMs: number,
526
+ ): Promise<ReadyInfo> {
527
+ const { promise, resolve, reject } = Promise.withResolvers<ReadyInfo>();
528
+ const unlisten = worker.onMessage(msg => {
529
+ if (msg.type === "ready") resolve(msg.info);
530
+ else if (msg.type === "init-failed") reject(errorFromPayload(msg.error));
531
+ else if (msg.type === "log") logWorkerMessage(msg);
532
+ });
533
+ const unlistenError = worker.onError(error => {
534
+ reject(new ToolError(`Tab worker failed during startup: ${error.message}`));
535
+ });
536
+ try {
537
+ worker.send({ type: "init", payload });
538
+ return await raceWithTimeout(promise, timeoutMs, "Timed out initializing browser tab worker");
539
+ } finally {
540
+ unlisten();
541
+ unlistenError();
542
+ }
543
+ }
544
+
545
+ export function initializeTabWorkerForTest(
546
+ worker: WorkerHandle,
547
+ payload: WorkerInitPayload,
548
+ timeoutMs: number,
549
+ ): Promise<ReadyInfo> {
550
+ return initializeTabWorker(worker, payload, timeoutMs);
551
+ }
552
+
553
+ function errorFromWorkerEvent(event: ErrorEvent): Error {
554
+ if (event.error instanceof Error) return event.error;
555
+ if (event.message) return new Error(event.message);
556
+ return new Error("Unknown tab worker error");
557
+ }
@@ -250,9 +250,19 @@ export interface ParsedConflictUri {
250
250
  /** `"*"` selects every currently-registered conflict (bulk write only). */
251
251
  id: number | "*";
252
252
  scope?: ConflictScope;
253
+ /**
254
+ * When `raw` was a malformed `<file-prefix>:conflict://…` path, the
255
+ * stripped prefix is preserved here so callers can surface a gentle
256
+ * "you don't need the file path" note. `undefined` for clean URIs.
257
+ */
258
+ recoveredPrefix?: string;
253
259
  }
254
260
 
255
- const CONFLICT_URI_RE = /^conflict:\/\/(.+)$/;
261
+ // Accept an optional `<prefix>:` before the scheme so paths like
262
+ // `path/to/file.ts:conflict://3` (where the agent mixed the `:conflicts`
263
+ // read selector with the `conflict://` scheme) still resolve. The prefix
264
+ // is greedy so the LAST `:conflict://` wins for multi-colon inputs.
265
+ const CONFLICT_URI_RE = /^(?:(.+):)?conflict:\/\/(.+)$/;
256
266
 
257
267
  /**
258
268
  * Parse a `conflict://<N>`, `conflict://<N>/<scope>`, or `conflict://*` URI.
@@ -269,7 +279,8 @@ const CONFLICT_URI_RE = /^conflict:\/\/(.+)$/;
269
279
  export function parseConflictUri(raw: string): ParsedConflictUri | null {
270
280
  const match = raw.match(CONFLICT_URI_RE);
271
281
  if (!match) return null;
272
- const tail = match[1];
282
+ const recoveredPrefix = match[1];
283
+ const tail = match[2];
273
284
  const slashIdx = tail.indexOf("/");
274
285
  const idPart = slashIdx === -1 ? tail : tail.slice(0, slashIdx);
275
286
  const scopePart = slashIdx === -1 ? undefined : tail.slice(slashIdx + 1);
@@ -280,7 +291,7 @@ export function parseConflictUri(raw: string): ParsedConflictUri | null {
280
291
  `Invalid conflict URI '${raw}': wildcard 'conflict://*' does not accept a scope segment. Drop '/${scopePart}' or use a numeric id.`,
281
292
  );
282
293
  }
283
- return { id: "*" };
294
+ return recoveredPrefix !== undefined ? { id: "*", recoveredPrefix } : { id: "*" };
284
295
  }
285
296
 
286
297
  if (!/^\d+$/.test(idPart)) {
@@ -303,7 +314,7 @@ export function parseConflictUri(raw: string): ParsedConflictUri | null {
303
314
  scope = scopePart as ConflictScope;
304
315
  }
305
316
 
306
- return { id, scope };
317
+ return recoveredPrefix !== undefined ? { id, scope, recoveredPrefix } : { id, scope };
307
318
  }
308
319
 
309
320
  /**
package/src/tools/eval.ts CHANGED
@@ -16,7 +16,12 @@ import evalDescription from "../prompts/tools/eval.md" with { type: "text" };
16
16
  import { DEFAULT_MAX_BYTES, OutputSink, type OutputSummary, TailBuffer } from "../session/streaming-output";
17
17
  import { getTreeBranch, getTreeContinuePrefix, renderCodeCell } from "../tui";
18
18
  import { resolveEvalBackends, type ToolSession } from ".";
19
- import { formatStyledTruncationWarning } from "./output-meta";
19
+ import {
20
+ formatStyledTruncationWarning,
21
+ resolveOutputMaxColumns,
22
+ resolveOutputSinkHeadBytes,
23
+ stripOutputNotice,
24
+ } from "./output-meta";
20
25
  import { formatTitle, replaceTabs, shortenPath, truncateToWidth, wrapBrackets } from "./render-utils";
21
26
  import { ToolAbortError, ToolError } from "./tool-errors";
22
27
  import { toolResult } from "./tool-result";
@@ -358,6 +363,8 @@ export class EvalTool implements AgentTool<typeof evalSchema> {
358
363
  outputSink = new OutputSink({
359
364
  artifactPath,
360
365
  artifactId,
366
+ headBytes: resolveOutputSinkHeadBytes(session.settings),
367
+ maxColumns: resolveOutputMaxColumns(session.settings),
361
368
  onChunk: chunk => {
362
369
  appendTail(chunk);
363
370
  pushUpdate();
@@ -920,8 +927,11 @@ export const evalToolRenderer = {
920
927
  ): Component {
921
928
  const details = result.details;
922
929
 
923
- const output =
930
+ const rawOutput =
924
931
  options.renderContext?.output ?? (result.content?.find(c => c.type === "text")?.text ?? "").trimEnd();
932
+ // Strip the LLM-facing notice (appended by wrappedExecute) before display;
933
+ // the styled `warningLine` below carries the same text in ⟨…⟩ form.
934
+ const output = stripOutputNotice(rawOutput, details?.meta).trimEnd();
925
935
 
926
936
  const jsonOutputs = details?.jsonOutputs ?? [];
927
937
  const jsonLines = jsonOutputs.flatMap((value, index) => {