@oh-my-pi/pi-coding-agent 17.0.9 → 17.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (285) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +1 -1
  3. package/dist/cli.js +4659 -4261
  4. package/dist/types/async/job-manager.d.ts +37 -1
  5. package/dist/types/cli/args.d.ts +2 -0
  6. package/dist/types/cli/bench-cli.d.ts +41 -0
  7. package/dist/types/commands/bench.d.ts +15 -3
  8. package/dist/types/commands/launch.d.ts +4 -0
  9. package/dist/types/config/model-registry.d.ts +3 -1
  10. package/dist/types/config/models-config-schema.d.ts +18 -6
  11. package/dist/types/config/models-config.d.ts +14 -5
  12. package/dist/types/config/provider-globals.d.ts +2 -2
  13. package/dist/types/config/service-tier.d.ts +5 -1
  14. package/dist/types/config/settings-schema.d.ts +316 -47
  15. package/dist/types/config/settings.d.ts +7 -0
  16. package/dist/types/dap/client.d.ts +20 -0
  17. package/dist/types/exec/bash-executor.d.ts +38 -0
  18. package/dist/types/exec/direnv.d.ts +33 -0
  19. package/dist/types/export/html/args.d.ts +15 -0
  20. package/dist/types/export/html/index.d.ts +11 -26
  21. package/dist/types/export/html/web-palette.d.ts +55 -116
  22. package/dist/types/extensibility/extensions/loader.d.ts +3 -0
  23. package/dist/types/extensibility/extensions/types.d.ts +17 -2
  24. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +13 -0
  25. package/dist/types/live/controller.d.ts +40 -0
  26. package/dist/types/live/protocol.d.ts +85 -0
  27. package/dist/types/live/protocol.test.d.ts +1 -0
  28. package/dist/types/live/transport.d.ts +48 -0
  29. package/dist/types/live/visualizer.d.ts +33 -0
  30. package/dist/types/lsp/client.d.ts +16 -1
  31. package/dist/types/mcp/config-writer.test.d.ts +1 -0
  32. package/dist/types/mcp/oauth-discovery.d.ts +2 -0
  33. package/dist/types/mcp/smithery-auth.d.ts +1 -1
  34. package/dist/types/mcp/smithery-auth.test.d.ts +1 -0
  35. package/dist/types/memory-backend/tool-names.d.ts +2 -0
  36. package/dist/types/modes/components/login-dialog.d.ts +2 -2
  37. package/dist/types/modes/components/plan-review-overlay.d.ts +26 -0
  38. package/dist/types/modes/components/settings-defs.d.ts +7 -1
  39. package/dist/types/modes/components/status-line/component.jj-cache.test.d.ts +1 -0
  40. package/dist/types/modes/components/tool-execution.d.ts +9 -1
  41. package/dist/types/modes/controllers/command-controller.d.ts +1 -1
  42. package/dist/types/modes/controllers/event-controller.d.ts +6 -1
  43. package/dist/types/modes/controllers/live-command-controller.d.ts +14 -0
  44. package/dist/types/modes/interactive-mode.d.ts +5 -0
  45. package/dist/types/modes/theme/theme.d.ts +1 -1
  46. package/dist/types/modes/types.d.ts +3 -1
  47. package/dist/types/modes/utils/context-usage.d.ts +12 -2
  48. package/dist/types/plan-mode/plan-files.d.ts +10 -0
  49. package/dist/types/registry/agent-lifecycle.d.ts +21 -9
  50. package/dist/types/registry/agent-registry.d.ts +11 -4
  51. package/dist/types/sdk.d.ts +15 -1
  52. package/dist/types/secrets/index.d.ts +15 -2
  53. package/dist/types/secrets/obfuscator.d.ts +113 -15
  54. package/dist/types/session/acp-permission-gate.d.ts +19 -0
  55. package/dist/types/session/agent-session-events.d.ts +84 -0
  56. package/dist/types/session/agent-session-types.d.ts +314 -0
  57. package/dist/types/session/agent-session.d.ts +118 -755
  58. package/dist/types/session/async-job-delivery.d.ts +28 -0
  59. package/dist/types/session/bash-runner.d.ts +75 -0
  60. package/dist/types/session/blob-store.d.ts +11 -1
  61. package/dist/types/session/blob-store.test.d.ts +1 -0
  62. package/dist/types/session/checkpoint-entries.d.ts +28 -0
  63. package/dist/types/session/eval-runner.d.ts +49 -0
  64. package/dist/types/session/irc-bridge.d.ts +46 -0
  65. package/dist/types/session/messages.d.ts +22 -0
  66. package/dist/types/session/model-controls.d.ts +180 -0
  67. package/dist/types/session/prewalk.d.ts +46 -0
  68. package/dist/types/session/queued-messages.d.ts +22 -0
  69. package/dist/types/session/redis-session-storage.d.ts +1 -0
  70. package/dist/types/session/retry-fallback-chains.d.ts +74 -0
  71. package/dist/types/session/role-models.d.ts +15 -0
  72. package/dist/types/session/session-advisors.d.ts +228 -0
  73. package/dist/types/session/session-entries.d.ts +8 -0
  74. package/dist/types/session/session-handoff.d.ts +70 -0
  75. package/dist/types/session/session-history-format.d.ts +6 -1
  76. package/dist/types/session/session-maintenance.d.ts +246 -0
  77. package/dist/types/session/session-manager.d.ts +41 -0
  78. package/dist/types/session/session-memory.d.ts +56 -0
  79. package/dist/types/session/session-provider-boundary.d.ts +62 -0
  80. package/dist/types/session/session-stats.d.ts +48 -0
  81. package/dist/types/session/session-tools.d.ts +143 -0
  82. package/dist/types/session/session-workspace.d.ts +32 -0
  83. package/dist/types/session/stream-guards.d.ts +44 -0
  84. package/dist/types/session/todo-tracker.d.ts +56 -0
  85. package/dist/types/session/ttsr-coordinator.d.ts +51 -0
  86. package/dist/types/session/turn-recovery.d.ts +225 -0
  87. package/dist/types/system-prompt.d.ts +2 -0
  88. package/dist/types/task/executor.d.ts +4 -1
  89. package/dist/types/task/output-manager.d.ts +3 -1
  90. package/dist/types/tools/approval.d.ts +1 -0
  91. package/dist/types/tools/browser/launch.d.ts +5 -0
  92. package/dist/types/tools/image-gen.d.ts +4 -4
  93. package/dist/types/tools/image-providers.d.ts +38 -0
  94. package/dist/types/tools/index.d.ts +5 -0
  95. package/dist/types/tools/path-utils.d.ts +8 -0
  96. package/dist/types/tools/todo.d.ts +10 -4
  97. package/dist/types/tools/xdev.d.ts +5 -1
  98. package/dist/types/tts/streaming-player.d.ts +14 -0
  99. package/dist/types/tts/vocalizer.d.ts +5 -0
  100. package/dist/types/utils/jj.d.ts +29 -0
  101. package/dist/types/utils/lang-from-path.d.ts +1 -0
  102. package/dist/types/utils/title-generator.d.ts +30 -0
  103. package/dist/types/vibe/runtime.d.ts +36 -8
  104. package/dist/types/web/search/index.d.ts +1 -1
  105. package/dist/types/web/search/provider.d.ts +15 -5
  106. package/dist/types/web/search/types.d.ts +94 -0
  107. package/package.json +12 -12
  108. package/scripts/generate-share-viewer.ts +4 -6
  109. package/src/advisor/__tests__/advisor.test.ts +643 -0
  110. package/src/advisor/runtime.ts +144 -43
  111. package/src/async/job-manager.ts +90 -2
  112. package/src/cli/args.ts +2 -0
  113. package/src/cli/auth-gateway-cli.ts +18 -3
  114. package/src/cli/bench-cli.ts +395 -29
  115. package/src/cli/flag-tables.ts +3 -0
  116. package/src/cli/gc-cli.ts +2 -2
  117. package/src/cli/usage-cli.ts +10 -1
  118. package/src/collab/host.ts +3 -2
  119. package/src/commands/bench.ts +18 -4
  120. package/src/commands/launch.ts +4 -0
  121. package/src/config/model-registry.ts +10 -2
  122. package/src/config/models-config-schema.ts +15 -4
  123. package/src/config/provider-globals.ts +9 -9
  124. package/src/config/service-tier.ts +26 -1
  125. package/src/config/settings-schema.ts +209 -51
  126. package/src/config/settings.ts +81 -2
  127. package/src/dap/client.ts +37 -4
  128. package/src/edit/index.ts +49 -12
  129. package/src/eval/py/executor.ts +103 -33
  130. package/src/exec/bash-executor.ts +89 -6
  131. package/src/exec/direnv.ts +145 -0
  132. package/src/export/html/args.ts +20 -0
  133. package/src/export/html/index.ts +45 -47
  134. package/src/export/html/template.css +19 -1
  135. package/src/export/html/template.html +6 -0
  136. package/src/export/html/template.js +21 -0
  137. package/src/export/html/tool-views.generated.js +31 -31
  138. package/src/export/html/web-palette.ts +116 -132
  139. package/src/export/share.ts +249 -49
  140. package/src/extensibility/extensions/loader.ts +29 -1
  141. package/src/extensibility/extensions/runner.ts +6 -0
  142. package/src/extensibility/extensions/types.ts +33 -2
  143. package/src/extensibility/plugins/legacy-pi-compat.ts +19 -1
  144. package/src/hindsight/state.ts +64 -6
  145. package/src/internal-urls/local-protocol.ts +2 -1
  146. package/src/internal-urls/memory-protocol.ts +2 -2
  147. package/src/internal-urls/skill-protocol.ts +2 -2
  148. package/src/internal-urls/ssh-protocol.ts +2 -1
  149. package/src/internal-urls/vault-protocol.ts +2 -1
  150. package/src/live/audio-worklet.txt +59 -0
  151. package/src/live/browser-runtime.txt +221 -0
  152. package/src/live/controller.ts +464 -0
  153. package/src/live/prompts/agent-final-message.md +3 -0
  154. package/src/live/prompts/live-instructions.md +23 -0
  155. package/src/live/protocol.test.ts +140 -0
  156. package/src/live/protocol.ts +233 -0
  157. package/src/live/transport.ts +490 -0
  158. package/src/live/visualizer.ts +234 -0
  159. package/src/lsp/client.ts +29 -9
  160. package/src/lsp/index.ts +19 -4
  161. package/src/main.ts +8 -1
  162. package/src/mcp/config-writer.test.ts +43 -0
  163. package/src/mcp/config-writer.ts +109 -82
  164. package/src/mcp/oauth-discovery.ts +10 -2
  165. package/src/mcp/smithery-auth.test.ts +29 -0
  166. package/src/mcp/smithery-auth.ts +3 -2
  167. package/src/memories/index.ts +40 -20
  168. package/src/memory-backend/tool-names.ts +2 -0
  169. package/src/mnemopi/backend.ts +24 -7
  170. package/src/modes/acp/acp-agent.ts +56 -10
  171. package/src/modes/acp/acp-event-mapper.ts +8 -1
  172. package/src/modes/components/agent-hub.ts +1 -1
  173. package/src/modes/components/login-dialog.ts +14 -4
  174. package/src/modes/components/plan-review-overlay.ts +350 -35
  175. package/src/modes/components/settings-defs.ts +23 -1
  176. package/src/modes/components/settings-selector.ts +169 -1
  177. package/src/modes/components/status-line/component.jj-cache.test.ts +229 -0
  178. package/src/modes/components/status-line/component.ts +123 -2
  179. package/src/modes/components/tool-execution.ts +11 -2
  180. package/src/modes/controllers/command-controller.ts +56 -15
  181. package/src/modes/controllers/event-controller.ts +111 -16
  182. package/src/modes/controllers/extension-ui-controller.test.ts +1 -0
  183. package/src/modes/controllers/extension-ui-controller.ts +20 -4
  184. package/src/modes/controllers/live-command-controller.ts +178 -0
  185. package/src/modes/controllers/mcp-command-controller.ts +10 -1
  186. package/src/modes/controllers/omfg-controller.ts +44 -40
  187. package/src/modes/controllers/selector-controller.ts +18 -10
  188. package/src/modes/controllers/session-focus-controller.ts +6 -1
  189. package/src/modes/interactive-mode.ts +157 -38
  190. package/src/modes/runtime-init.ts +2 -0
  191. package/src/modes/setup-wizard/scenes/web-search.ts +17 -9
  192. package/src/modes/theme/theme.ts +1 -1
  193. package/src/modes/types.ts +3 -1
  194. package/src/modes/utils/context-usage.ts +23 -7
  195. package/src/modes/utils/ui-helpers.ts +10 -5
  196. package/src/plan-mode/plan-files.ts +40 -0
  197. package/src/prompts/bench/cache-prefix-chunk.md +1 -0
  198. package/src/prompts/bench/cache-prefix.md +3 -0
  199. package/src/prompts/bench/cache-suffix.md +1 -0
  200. package/src/prompts/system/custom-system-prompt.md +1 -1
  201. package/src/prompts/system/project-prompt.md +8 -1
  202. package/src/prompts/system/subagent-async-pending.md +6 -0
  203. package/src/prompts/system/system-prompt.md +1 -1
  204. package/src/prompts/system/workflow-notice.md +48 -40
  205. package/src/prompts/system/xdev-mount-notice.md +4 -0
  206. package/src/prompts/tools/todo.md +3 -1
  207. package/src/registry/agent-lifecycle.ts +116 -46
  208. package/src/registry/agent-registry.ts +35 -8
  209. package/src/registry/persisted-agents.ts +26 -2
  210. package/src/sdk.ts +210 -136
  211. package/src/secrets/index.ts +127 -4
  212. package/src/secrets/obfuscator.ts +2328 -105
  213. package/src/session/acp-permission-gate.ts +165 -0
  214. package/src/session/agent-session-error-log.test.ts +1 -1
  215. package/src/session/agent-session-events.ts +66 -0
  216. package/src/session/agent-session-types.ts +334 -0
  217. package/src/session/agent-session.ts +1892 -12226
  218. package/src/session/artifacts.ts +6 -5
  219. package/src/session/async-job-delivery.ts +74 -0
  220. package/src/session/bash-runner.ts +326 -0
  221. package/src/session/blob-store.test.ts +56 -0
  222. package/src/session/blob-store.ts +18 -2
  223. package/src/session/checkpoint-entries.ts +81 -0
  224. package/src/session/eval-runner.ts +212 -0
  225. package/src/session/indexed-session-storage.ts +9 -2
  226. package/src/session/irc-bridge.ts +203 -0
  227. package/src/session/messages.ts +263 -1
  228. package/src/session/model-controls.ts +714 -0
  229. package/src/session/prewalk.ts +252 -0
  230. package/src/session/queued-messages.ts +93 -0
  231. package/src/session/redis-session-storage.ts +51 -11
  232. package/src/session/retry-fallback-chains.ts +455 -0
  233. package/src/session/role-models.ts +85 -0
  234. package/src/session/session-advisors.ts +1679 -0
  235. package/src/session/session-context.ts +6 -3
  236. package/src/session/session-entries.ts +8 -0
  237. package/src/session/session-handoff.ts +308 -0
  238. package/src/session/session-history-format.ts +20 -9
  239. package/src/session/session-loader.ts +0 -46
  240. package/src/session/session-maintenance.ts +2983 -0
  241. package/src/session/session-manager.ts +390 -30
  242. package/src/session/session-memory.ts +222 -0
  243. package/src/session/session-provider-boundary.ts +307 -0
  244. package/src/session/session-stats.ts +293 -0
  245. package/src/session/session-tools.ts +943 -0
  246. package/src/session/session-workspace.ts +53 -0
  247. package/src/session/stream-guards.ts +417 -0
  248. package/src/session/todo-tracker.ts +380 -0
  249. package/src/session/ttsr-coordinator.ts +496 -0
  250. package/src/session/turn-recovery.ts +1629 -0
  251. package/src/slash-commands/builtin-registry.ts +92 -11
  252. package/src/slash-commands/helpers/usage-report.ts +22 -3
  253. package/src/system-prompt.ts +41 -17
  254. package/src/task/executor.ts +285 -70
  255. package/src/task/index.ts +98 -105
  256. package/src/task/output-manager.ts +25 -3
  257. package/src/task/persisted-revive.ts +4 -3
  258. package/src/task/structured-subagent.ts +1 -0
  259. package/src/tools/approval.ts +55 -10
  260. package/src/tools/bash-interactive.ts +26 -0
  261. package/src/tools/bash-skill-urls.ts +28 -2
  262. package/src/tools/bash.ts +339 -123
  263. package/src/tools/browser/launch.ts +9 -1
  264. package/src/tools/browser/tab-supervisor.ts +8 -5
  265. package/src/tools/browser.ts +94 -47
  266. package/src/tools/image-gen.ts +20 -28
  267. package/src/tools/image-providers.ts +50 -0
  268. package/src/tools/index.ts +9 -0
  269. package/src/tools/path-utils.ts +49 -2
  270. package/src/tools/read.ts +156 -88
  271. package/src/tools/renderers.ts +7 -1
  272. package/src/tools/todo.ts +101 -11
  273. package/src/tools/vibe.ts +1 -2
  274. package/src/tools/write.ts +51 -1
  275. package/src/tools/xdev.ts +67 -3
  276. package/src/tts/streaming-player.ts +62 -4
  277. package/src/tts/vocalizer.ts +18 -1
  278. package/src/utils/jj.ts +125 -4
  279. package/src/utils/lang-from-path.ts +7 -0
  280. package/src/utils/title-generator.ts +137 -1
  281. package/src/vibe/runtime.ts +852 -72
  282. package/src/web/search/index.ts +3 -6
  283. package/src/web/search/provider.ts +31 -16
  284. package/src/web/search/providers/xai.ts +7 -1
  285. package/src/web/search/types.ts +3 -0
package/src/tools/bash.ts CHANGED
@@ -10,13 +10,17 @@ import type { Component } from "@oh-my-pi/pi-tui";
10
10
  import { ImageProtocol, TERMINAL } from "@oh-my-pi/pi-tui";
11
11
  import { getProjectDir, isEnoent, logger, prompt } from "@oh-my-pi/pi-utils";
12
12
  import { type } from "arktype";
13
- import { type BashResult, executeBash } from "../exec/bash-executor";
13
+ import { applyDirenvPreflight, type BashResult, executeBash } from "../exec/bash-executor";
14
14
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
15
15
  import { InternalUrlRouter } from "../internal-urls";
16
16
  import { truncateToVisualLines } from "../modes/components/visual-truncate";
17
17
  import { highlightCode, type Theme } from "../modes/theme/theme";
18
18
  import bashDescription from "../prompts/tools/bash.md" with { type: "text" };
19
- import type { ClientBridgeTerminalExitStatus, ClientBridgeTerminalOutput } from "../session/client-bridge";
19
+ import type {
20
+ ClientBridgeTerminalExitStatus,
21
+ ClientBridgeTerminalHandle,
22
+ ClientBridgeTerminalOutput,
23
+ } from "../session/client-bridge";
20
24
  import { DEFAULT_MAX_BYTES, enforceInlineByteCap, streamTailUpdates, TailBuffer } from "../session/streaming-output";
21
25
  import { renderStatusLine } from "../tui";
22
26
  import { CachedOutputBlock, markFramedBlockComponent, outputBlockContentWidth } from "../tui/output-block";
@@ -51,6 +55,8 @@ export const BASH_DEFAULT_PREVIEW_LINES = DEFAULT_TERMINAL_PREVIEW_LINES;
51
55
 
52
56
  const BASH_ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
53
57
  const DEFAULT_AUTO_BACKGROUND_THRESHOLD_MS = 60_000;
58
+ const BASH_APPROVAL_SHELL_CONTROL_RE = /[\n\r;&|<>`$()]/u;
59
+ const BASH_PATTERN_APPROVAL_VALUES = new Set(["allow", "deny", "prompt"]);
54
60
 
55
61
  /**
56
62
  * Shape a shell command line for an ACP-conformant `terminal/create` request.
@@ -127,6 +133,63 @@ export const CRITICAL_BASH_PATTERNS = [
127
133
  /\bnc\b[^|;]*\s-[a-zA-Z]*[ec][a-zA-Z]*\s/i, // `nc -e` / `nc -c`.
128
134
  ] as const;
129
135
 
136
+ type BashPatternApproval = "allow" | "deny" | "prompt";
137
+
138
+ interface BashApprovalPatternRule {
139
+ match: string;
140
+ approval: BashPatternApproval;
141
+ }
142
+
143
+ function normalizeBashApprovalPattern(value: string): string {
144
+ return value.trim().replace(/\s+/gu, " ");
145
+ }
146
+
147
+ function bashApprovalPatternToRegExp(pattern: string): RegExp {
148
+ const escaped = normalizeBashApprovalPattern(pattern)
149
+ .split("*")
150
+ .map(part => part.replace(/[\\^$+?.()|[\]{}]/gu, "\\$&"))
151
+ .join(".*");
152
+ return new RegExp(`^${escaped}$`, "u");
153
+ }
154
+
155
+ function normalizeBashPatternApproval(value: unknown): BashPatternApproval | undefined {
156
+ if (typeof value !== "string") return undefined;
157
+ const normalized = value.trim().toLowerCase();
158
+ return BASH_PATTERN_APPROVAL_VALUES.has(normalized) ? (normalized as BashPatternApproval) : undefined;
159
+ }
160
+
161
+ function getBashApprovalPatternRules(value: unknown): BashApprovalPatternRule[] {
162
+ if (!Array.isArray(value)) return [];
163
+ return value
164
+ .map(item => {
165
+ if (!item || typeof item !== "object" || Array.isArray(item)) return undefined;
166
+ const record = item as Record<string, unknown>;
167
+ if (typeof record.match !== "string") return undefined;
168
+ const match = normalizeBashApprovalPattern(record.match);
169
+ const approval = normalizeBashPatternApproval(record.approval);
170
+ return match.length > 0 && approval ? { match, approval } : undefined;
171
+ })
172
+ .filter((rule): rule is BashApprovalPatternRule => !!rule);
173
+ }
174
+
175
+ function commandMatchesBashApprovalPattern(command: string, pattern: string): boolean {
176
+ const normalizedCommand = normalizeBashApprovalPattern(command);
177
+ if (normalizedCommand.length === 0) return false;
178
+ return bashApprovalPatternToRegExp(pattern).test(normalizedCommand);
179
+ }
180
+
181
+ function findBashApprovalPatternRule(
182
+ command: string,
183
+ rules: readonly BashApprovalPatternRule[],
184
+ ): BashApprovalPatternRule | undefined {
185
+ return rules.find(rule => {
186
+ if (rule.approval === "allow" && BASH_APPROVAL_SHELL_CONTROL_RE.test(command)) {
187
+ return false;
188
+ }
189
+ return commandMatchesBashApprovalPattern(command, rule.match);
190
+ });
191
+ }
192
+
130
193
  async function saveBashOriginalArtifact(session: ToolSession, originalText: string): Promise<string | undefined> {
131
194
  try {
132
195
  const alloc = await session.allocateOutputArtifact?.("bash-original");
@@ -384,9 +447,29 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
384
447
  readonly approval = (args: unknown): ToolApprovalDecision => {
385
448
  const rawCommand = (args as Partial<BashToolInput>).command;
386
449
  const command = typeof rawCommand === "string" ? rawCommand : "";
450
+ const patternRules = getBashApprovalPatternRules(this.session.settings.get("bash.patterns"));
451
+ const patternRule = patternRules.find(rule => commandMatchesBashApprovalPattern(command, rule.match));
452
+ if (patternRule?.approval === "deny") {
453
+ return {
454
+ tier: "exec",
455
+ override: true,
456
+ policy: "deny",
457
+ reason: `Blocked by bash pattern: ${patternRule.match}`,
458
+ };
459
+ }
387
460
  if (command !== "" && CRITICAL_BASH_PATTERNS.some(pattern => pattern.test(command))) {
388
461
  return { tier: "exec", override: true, reason: "Critical pattern detected" };
389
462
  }
463
+ const safePatternRule = findBashApprovalPatternRule(command, patternRules);
464
+ if (safePatternRule?.approval === "allow") return { tier: "write", policy: "allow" };
465
+ if (safePatternRule?.approval === "prompt") {
466
+ return {
467
+ tier: "exec",
468
+ override: true,
469
+ policy: "prompt",
470
+ reason: `Prompt required by bash pattern: ${safePatternRule.match}`,
471
+ };
472
+ }
390
473
  return "exec";
391
474
  };
392
475
  readonly formatApprovalDetails = (args: unknown): string[] => {
@@ -703,28 +786,40 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
703
786
  job: ManagedBashJobHandle,
704
787
  thresholdMs: number,
705
788
  signal?: AbortSignal,
706
- ): Promise<ManagedBashJobCompletion | { kind: "running" } | { kind: "aborted" }> {
789
+ steeringSignal?: AbortSignal,
790
+ ): Promise<ManagedBashJobCompletion | { kind: "running" } | { kind: "steer" } | { kind: "aborted" }> {
707
791
  if (signal?.aborted) {
708
792
  return { kind: "aborted" };
709
793
  }
794
+ if (steeringSignal?.aborted) {
795
+ return { kind: "steer" };
796
+ }
710
797
 
711
- const waiters: Array<Promise<ManagedBashJobCompletion | { kind: "running" } | { kind: "aborted" }>> = [
712
- job.completion,
713
- Bun.sleep(thresholdMs).then(() => ({ kind: "running" as const })),
714
- ];
798
+ const waiters: Array<
799
+ Promise<ManagedBashJobCompletion | { kind: "running" } | { kind: "steer" } | { kind: "aborted" }>
800
+ > = [job.completion, Bun.sleep(thresholdMs).then(() => ({ kind: "running" as const }))];
715
801
 
716
- if (!signal) {
802
+ if (!signal && !steeringSignal) {
717
803
  return await Promise.race(waiters);
718
804
  }
719
805
 
720
806
  const { promise: abortedPromise, resolve: resolveAborted } = Promise.withResolvers<{ kind: "aborted" }>();
721
807
  const onAbort = () => resolveAborted({ kind: "aborted" });
722
- signal.addEventListener("abort", onAbort, { once: true });
723
- waiters.push(abortedPromise);
808
+ const { promise: steerPromise, resolve: resolveSteer } = Promise.withResolvers<{ kind: "steer" }>();
809
+ const onSteer = () => resolveSteer({ kind: "steer" });
810
+ if (signal) {
811
+ signal.addEventListener("abort", onAbort, { once: true });
812
+ waiters.push(abortedPromise);
813
+ }
814
+ if (steeringSignal) {
815
+ steeringSignal.addEventListener("abort", onSteer, { once: true });
816
+ waiters.push(steerPromise);
817
+ }
724
818
  try {
725
819
  return await Promise.race(waiters);
726
820
  } finally {
727
- signal.removeEventListener("abort", onAbort);
821
+ signal?.removeEventListener("abort", onAbort);
822
+ steeringSignal?.removeEventListener("abort", onSteer);
728
823
  }
729
824
  }
730
825
 
@@ -911,7 +1006,12 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
911
1006
  // foreground-wait cannot also be injected by the delivery loop. Lifted
912
1007
  // via resumeDeliveries() if we end up backgrounding after all.
913
1008
  autoBgManager.acknowledgeDeliveries([job.jobId]);
914
- const waitResult = await this.#waitForManagedBashJob(job, autoBackgroundWaitMs, signal);
1009
+ const waitResult = await this.#waitForManagedBashJob(
1010
+ job,
1011
+ autoBackgroundWaitMs,
1012
+ signal,
1013
+ ctx?.toolCall?.steeringSignal,
1014
+ );
915
1015
  if (waitResult.kind === "completed") {
916
1016
  return waitResult.result;
917
1017
  }
@@ -924,52 +1024,94 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
924
1024
  }
925
1025
  job.stopUpdates();
926
1026
  autoBgManager.resumeDeliveries([job.jobId]);
1027
+ // "steer": a queued user/peer message arrived mid-wait — background
1028
+ // the command (it keeps running) so the message injects promptly.
1029
+ const notices =
1030
+ waitResult.kind === "steer"
1031
+ ? [...pendingNotices, "Backgrounded early to handle an incoming message; the command keeps running."]
1032
+ : pendingNotices;
927
1033
  return this.#buildBackgroundStartResult(job.jobId, job.getLatestText(), timeoutSec, {
928
1034
  requestedTimeoutSec,
929
- notices: pendingNotices,
1035
+ notices,
930
1036
  });
931
1037
  }
932
1038
 
1039
+ // Fold direnv/devenv env into (command, env) ONCE for the two backends
1040
+ // that bypass `executeBash` — the ACP client terminal and the PTY. The
1041
+ // `executeBash` branch below is intentionally excluded: it runs its own
1042
+ // preflight internally, so routing the pre-applied command there too
1043
+ // would double-apply the unset prefix and re-merge the env. No
1044
+ // `commandPrefix` here: ACP applies the shell prefix via
1045
+ // `wrapShellLineForClientTerminal`, and the PTY path never wrapped one.
1046
+ // `callerTimeoutMs` clamps the direnv load to a positive command timeout
1047
+ // (the backend's own timeout is installed only after this await), matching
1048
+ // the executeBash branch so a cold `.envrc` can't outlast a short call.
1049
+ const backendPreflight =
1050
+ (clientBridge?.capabilities.terminal && clientBridge.createTerminal && !pty) ||
1051
+ canUseInteractiveBashPty(pty, ctx)
1052
+ ? await applyDirenvPreflight(command, commandCwd, {
1053
+ callerEnv: resolvedEnv,
1054
+ signal,
1055
+ timeoutMs: this.session.settings.get("bash.direnvLoadTimeoutMs"),
1056
+ callerTimeoutMs: timeoutMs,
1057
+ direnvSetting: this.session.settings.get("bash.direnv"),
1058
+ })
1059
+ : undefined;
1060
+
933
1061
  // Route through the client terminal when the client advertises the terminal capability.
934
1062
  // Skip when pty=true (PTY needs the local terminal UI).
935
1063
  if (clientBridge?.capabilities.terminal && clientBridge.createTerminal && !pty) {
936
- const bridgeWallTimeStart = performance.now();
937
- const shellSpawn = wrapShellLineForClientTerminal(command, this.session.settings.getShellConfig());
938
- const handle = await clientBridge.createTerminal({
939
- command: shellSpawn.command,
940
- args: shellSpawn.args,
941
- cwd: commandCwd,
942
- env: resolvedEnv
943
- ? Object.entries(resolvedEnv).map(([name, value]) => ({ name, value: value as string }))
944
- : undefined,
945
- outputByteLimit: DEFAULT_MAX_BYTES,
946
- });
947
-
948
- // Emit partial update so the editor can embed the live terminal card.
949
- onUpdate?.({ content: [], details: { terminalId: handle.terminalId } });
950
-
951
- const exitPromise = handle.waitForExit();
952
- let exitStatus!: ClientBridgeTerminalExitStatus;
953
-
954
- type BridgeRaceResult =
955
- | { kind: "exit"; status: ClientBridgeTerminalExitStatus }
956
- | { kind: "poll" }
957
- | { kind: "timeout" }
958
- | { kind: "aborted" };
1064
+ // Invariant (ACP terminal bridge): createTerminal has no signal in its
1065
+ // contract; allocation cannot be cancelled retroactively. Guard before
1066
+ // allocation. Shared timeout helper / pure AbortSignal fusion rejected:
1067
+ // we need explicit kill-before-read ordering and distinct abort vs
1068
+ // timeout result shapes. Per-route race retained for testability.
1069
+ if (signal?.aborted) {
1070
+ throw new ToolAbortError("Command aborted");
1071
+ }
959
1072
 
960
- // Set up abort listener before entering the poll loop. The listener
961
- // kicks off `handle.kill()` synchronously so a `session/cancel`
962
- // arriving mid-poll terminates the remote command immediately,
963
- // instead of waiting for the next `currentOutput()` to return.
1073
+ const bridgeWallTimeStart = performance.now();
1074
+ const killGraceMs = 1000;
1075
+ const outputSnapshotGraceMs = 2000;
1076
+ // Cancellable timeout: a bare Bun.sleep(timeoutMs) would leave a live,
1077
+ // ref'd timer for the full command timeout after fast completions —
1078
+ // accumulating timers and delaying process shutdown in SDK/headless use.
1079
+ // `timeoutMs` is optional (#4642): without one, no timer is armed and
1080
+ // the promise simply never resolves.
1081
+ const { promise: timeoutPromise, resolve: resolveTimeout } = Promise.withResolvers<{
1082
+ kind: "timeout";
1083
+ }>();
1084
+ const timeoutTimer = timeoutMs ? setTimeout(() => resolveTimeout({ kind: "timeout" }), timeoutMs) : undefined;
964
1085
  const { promise: abortedP, resolve: resolveAborted } = Promise.withResolvers<void>();
1086
+ let handle: ClientBridgeTerminalHandle | undefined;
965
1087
  let killStarted = false;
966
1088
  const fireKill = (): Promise<void> => {
967
1089
  if (killStarted) return Promise.resolve();
1090
+ const currentHandle = handle;
1091
+ if (!currentHandle) return Promise.resolve();
968
1092
  killStarted = true;
969
- return handle.kill().catch((error: unknown) => {
970
- logger.warn("ACP terminal kill failed", { terminalId: handle.terminalId, error });
1093
+ return currentHandle.kill().catch((error: unknown) => {
1094
+ logger.warn("ACP terminal kill failed", { terminalId: currentHandle.terminalId, error });
971
1095
  });
972
1096
  };
1097
+ const cleanupLateCreate = (createP: Promise<ClientBridgeTerminalHandle>): void => {
1098
+ void createP
1099
+ .then(async lateHandle => {
1100
+ try {
1101
+ await lateHandle.kill();
1102
+ } catch (error) {
1103
+ logger.warn("ACP terminal kill failed", { terminalId: lateHandle.terminalId, error });
1104
+ }
1105
+ try {
1106
+ await lateHandle.release();
1107
+ } catch (error) {
1108
+ logger.warn("ACP terminal release failed", { terminalId: lateHandle.terminalId, error });
1109
+ }
1110
+ })
1111
+ .catch((error: unknown) => {
1112
+ logger.warn("ACP terminal create failed after cancellation", { error });
1113
+ });
1114
+ };
973
1115
  const onAbortSignal = () => {
974
1116
  resolveAborted();
975
1117
  void fireKill();
@@ -977,90 +1119,150 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
977
1119
  signal?.addEventListener("abort", onAbortSignal, { once: true });
978
1120
 
979
1121
  try {
980
- try {
981
- if (signal?.aborted) {
982
- await fireKill();
983
- throw new ToolAbortError("Command aborted");
984
- }
1122
+ // direnv-transformed command (carries any `unset -v` prefix) + merged
1123
+ // env; falls back to the raw command/env when direnv is off/absent.
1124
+ const bridgeCommand = backendPreflight?.command ?? command;
1125
+ const bridgeEnv = backendPreflight?.env ?? resolvedEnv;
1126
+ const shellSpawn = wrapShellLineForClientTerminal(bridgeCommand, this.session.settings.getShellConfig());
1127
+ const createP = clientBridge.createTerminal({
1128
+ command: shellSpawn.command,
1129
+ args: shellSpawn.args,
1130
+ cwd: commandCwd,
1131
+ env: bridgeEnv
1132
+ ? Object.entries(bridgeEnv).map(([name, value]) => ({ name, value: value as string }))
1133
+ : undefined,
1134
+ outputByteLimit: DEFAULT_MAX_BYTES,
1135
+ });
1136
+ const createRaced = await Promise.race([
1137
+ createP.then(createdHandle => ({ kind: "created" as const, handle: createdHandle })),
1138
+ timeoutPromise,
1139
+ abortedP.then(() => ({ kind: "aborted" as const })),
1140
+ ]);
1141
+ if (createRaced.kind === "aborted" || signal?.aborted) {
1142
+ cleanupLateCreate(createP);
1143
+ throw new ToolAbortError("Command aborted");
1144
+ }
1145
+ if (createRaced.kind === "timeout") {
1146
+ cleanupLateCreate(createP);
1147
+ const timedOutResult: BashInteractiveResult = {
1148
+ output: "",
1149
+ exitCode: undefined,
1150
+ cancelled: false,
1151
+ timedOut: true,
1152
+ truncated: false,
1153
+ totalLines: 0,
1154
+ totalBytes: 0,
1155
+ outputLines: 0,
1156
+ outputBytes: 0,
1157
+ };
1158
+ this.#throwIfUnfinished(timedOutResult, timeoutSec, this.#formatResultOutput(timedOutResult));
1159
+ throw new ToolError("Command timed out");
1160
+ }
985
1161
 
986
- const timeoutPromise = timeoutMs
987
- ? Bun.sleep(timeoutMs).then(() => ({ kind: "timeout" as const }))
988
- : undefined;
989
- // Poll until the process exits, times out, or the caller aborts.
990
- for (;;) {
991
- const racers: Array<Promise<BridgeRaceResult>> = [
992
- exitPromise.then(s => ({ kind: "exit" as const, status: s })),
993
- Bun.sleep(250).then(() => ({ kind: "poll" as const })),
994
- ];
995
- if (timeoutPromise) racers.push(timeoutPromise);
996
- if (signal) {
997
- racers.push(abortedP.then(() => ({ kind: "aborted" as const })));
998
- }
999
- const raced = await Promise.race(racers);
1162
+ handle = createRaced.handle;
1163
+
1164
+ // Emit partial update so the editor can embed the live terminal card.
1165
+ onUpdate?.({ content: [], details: { terminalId: handle.terminalId } });
1166
+
1167
+ const exitPromise = handle.waitForExit();
1168
+ let exitStatus!: ClientBridgeTerminalExitStatus;
1169
+
1170
+ type BridgeRaceResult =
1171
+ | { kind: "exit"; status: ClientBridgeTerminalExitStatus }
1172
+ | { kind: "poll" }
1173
+ | { kind: "timeout" }
1174
+ | { kind: "aborted" };
1175
+
1176
+ const exitRacer = exitPromise.then(status => ({ kind: "exit" as const, status }));
1177
+ const abortRacer = abortedP.then(() => ({ kind: "aborted" as const }));
1178
+ const abortPollRacer = abortedP.then(() => undefined as ClientBridgeTerminalOutput | undefined);
1179
+ const timeoutPollRacer = timeoutPromise.then(() => undefined as ClientBridgeTerminalOutput | undefined);
1180
+ let lastPolledOutput: ClientBridgeTerminalOutput = { output: "", truncated: false };
1181
+
1182
+ // Poll until the process exits, times out, or the caller aborts.
1183
+ for (;;) {
1184
+ const racers: Array<Promise<BridgeRaceResult>> = [
1185
+ exitRacer,
1186
+ timeoutPromise,
1187
+ Bun.sleep(250).then(() => ({ kind: "poll" as const })),
1188
+ ];
1189
+ if (signal) {
1190
+ racers.push(abortRacer);
1191
+ }
1192
+ const raced = await Promise.race(racers);
1000
1193
 
1001
- if (raced.kind === "aborted" || signal?.aborted) {
1002
- await fireKill();
1003
- throw new ToolAbortError("Command aborted");
1004
- }
1194
+ if (raced.kind === "aborted" || signal?.aborted) {
1195
+ await Promise.race([fireKill(), Bun.sleep(killGraceMs)]);
1196
+ throw new ToolAbortError("Command aborted");
1197
+ }
1005
1198
 
1006
- if (raced.kind === "timeout") {
1007
- // Kill before reading final output so a slow `terminal/output`
1008
- // RPC cannot let a timed-out command keep running past the
1009
- // enforced timeout. The handle stays valid post-kill so the
1010
- // buffered output is still readable.
1011
- await fireKill();
1012
- let current = { output: "", truncated: false };
1013
- try {
1014
- current = await handle.currentOutput();
1015
- } catch (error) {
1016
- logger.warn("ACP terminal final output read failed", {
1017
- terminalId: handle.terminalId,
1018
- error,
1019
- });
1020
- }
1021
- const timedOutResult: BashInteractiveResult = {
1022
- output: current.output,
1023
- exitCode: undefined,
1024
- cancelled: false,
1025
- timedOut: true,
1026
- truncated: current.truncated,
1027
- totalLines: current.output.length > 0 ? current.output.split("\n").length : 0,
1028
- totalBytes: current.output.length,
1029
- outputLines: current.output.length > 0 ? current.output.split("\n").length : 0,
1030
- outputBytes: current.output.length,
1031
- };
1032
- this.#throwIfUnfinished(timedOutResult, timeoutSec, this.#formatResultOutput(timedOutResult));
1033
- throw new ToolError("Command timed out");
1199
+ if (raced.kind === "timeout") {
1200
+ // Kill before reading final output so a slow `terminal/output`
1201
+ // RPC cannot let a timed-out command keep running past the
1202
+ // enforced timeout. The handle stays valid post-kill so the
1203
+ // buffered output is still readable.
1204
+ await Promise.race([fireKill(), Bun.sleep(killGraceMs)]);
1205
+ let current = lastPolledOutput;
1206
+ try {
1207
+ current = await Promise.race([
1208
+ handle.currentOutput(),
1209
+ Bun.sleep(outputSnapshotGraceMs).then(() => lastPolledOutput),
1210
+ ]);
1211
+ } catch (error) {
1212
+ logger.warn("ACP terminal final output read failed", {
1213
+ terminalId: handle.terminalId,
1214
+ error,
1215
+ });
1034
1216
  }
1217
+ const timedOutResult: BashInteractiveResult = {
1218
+ output: current.output,
1219
+ exitCode: undefined,
1220
+ cancelled: false,
1221
+ timedOut: true,
1222
+ truncated: current.truncated,
1223
+ totalLines: current.output.length > 0 ? current.output.split("\n").length : 0,
1224
+ totalBytes: current.output.length,
1225
+ outputLines: current.output.length > 0 ? current.output.split("\n").length : 0,
1226
+ outputBytes: current.output.length,
1227
+ };
1228
+ this.#throwIfUnfinished(timedOutResult, timeoutSec, this.#formatResultOutput(timedOutResult));
1229
+ throw new ToolError("Command timed out");
1230
+ }
1035
1231
 
1036
- if (raced.kind === "exit") {
1037
- exitStatus = raced.status;
1038
- break;
1039
- }
1232
+ if (raced.kind === "exit") {
1233
+ exitStatus = raced.status;
1234
+ break;
1235
+ }
1040
1236
 
1041
- // Poll tick: push current output so agent-loop transcript stays consistent.
1042
- // Race the read against abort so a stuck `terminal/output` RPC does not
1043
- // delay cancellation.
1044
- const pollOutput = await Promise.race([
1045
- handle.currentOutput(),
1046
- abortedP.then(() => undefined as ClientBridgeTerminalOutput | undefined),
1047
- ]);
1048
- if (pollOutput === undefined) {
1049
- // Abort fired during the poll-tick read; let the next loop iteration
1050
- // observe `signal?.aborted` and exit via the abort branch.
1051
- continue;
1052
- }
1053
- onUpdate?.({
1054
- content: [{ type: "text", text: pollOutput.output }],
1055
- details: { terminalId: handle.terminalId },
1056
- });
1237
+ // Poll tick: push current output so agent-loop transcript stays consistent.
1238
+ // Race the read against abort/timeout so a stuck `terminal/output` RPC does
1239
+ // not delay cancellation or let the command outlive its deadline.
1240
+ const pollOutput = await Promise.race([handle.currentOutput(), abortPollRacer, timeoutPollRacer]);
1241
+ if (pollOutput === undefined) {
1242
+ // Abort or timeout fired during the poll-tick read; let the next loop
1243
+ // iteration exit via the matching abort/timeout branch.
1244
+ continue;
1057
1245
  }
1058
- } finally {
1059
- signal?.removeEventListener("abort", onAbortSignal);
1246
+ lastPolledOutput = pollOutput;
1247
+ onUpdate?.({
1248
+ content: [{ type: "text", text: pollOutput.output }],
1249
+ details: { terminalId: handle.terminalId },
1250
+ });
1060
1251
  }
1061
1252
 
1062
1253
  // Fetch final output; the terminal is released in the outer finally.
1063
- const finalOutput = await handle.currentOutput();
1254
+ let finalOutput = lastPolledOutput;
1255
+ try {
1256
+ finalOutput = await Promise.race([
1257
+ handle.currentOutput(),
1258
+ Bun.sleep(outputSnapshotGraceMs).then(() => lastPolledOutput),
1259
+ ]);
1260
+ } catch (error) {
1261
+ logger.warn("ACP terminal final output read failed", {
1262
+ terminalId: handle.terminalId,
1263
+ error,
1264
+ });
1265
+ }
1064
1266
 
1065
1267
  // Map exit status: null exitCode with a signal → treat as signal kill (137).
1066
1268
  const rawExitCode = exitStatus.exitCode;
@@ -1093,10 +1295,18 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
1093
1295
  wallTimeMs: performance.now() - bridgeWallTimeStart,
1094
1296
  });
1095
1297
  } finally {
1096
- try {
1097
- await handle.release();
1098
- } catch (error) {
1099
- logger.warn("ACP terminal release failed", { terminalId: handle.terminalId, error });
1298
+ clearTimeout(timeoutTimer);
1299
+ signal?.removeEventListener("abort", onAbortSignal);
1300
+ if (handle) {
1301
+ const releaseHandle = handle;
1302
+ // Bound release like kill/output: a hung `terminal/release` RPC must not
1303
+ // keep the tool pending after the result is already decided.
1304
+ await Promise.race([
1305
+ releaseHandle.release().catch((error: unknown) => {
1306
+ logger.warn("ACP terminal release failed", { terminalId: releaseHandle.terminalId, error });
1307
+ }),
1308
+ Bun.sleep(killGraceMs),
1309
+ ]);
1100
1310
  }
1101
1311
  }
1102
1312
  }
@@ -1114,15 +1324,21 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
1114
1324
  const wallTimeStart = performance.now();
1115
1325
  const result: BashResult | BashInteractiveResult = interactiveUi
1116
1326
  ? await runInteractiveBashPty(interactiveUi, {
1117
- command,
1327
+ // PTY bypasses executeBash, so feed it the direnv-transformed
1328
+ // command + merged env (backendPreflight is defined whenever this
1329
+ // branch runs, since both gate on canUseInteractiveBashPty).
1330
+ command: backendPreflight?.command ?? command,
1118
1331
  cwd: commandCwd,
1119
1332
  timeoutMs,
1120
1333
  signal,
1121
- env: resolvedEnv,
1334
+ env: backendPreflight?.env ?? resolvedEnv,
1122
1335
  artifactPath,
1123
1336
  artifactId,
1124
1337
  })
1125
- : await executeBash(command, {
1338
+ : // executeBash runs its OWN direnv preflight internally — pass the RAW
1339
+ // command + resolvedEnv here so the unset prefix / env merge is not
1340
+ // applied twice.
1341
+ await executeBash(command, {
1126
1342
  cwd: commandCwd,
1127
1343
  sessionKey: this.session.getSessionId?.() ?? undefined,
1128
1344
  timeout: timeoutMs ?? 0,
@@ -275,9 +275,14 @@ function resolveSystemChromium(): string | undefined {
275
275
  return undefined;
276
276
  }
277
277
 
278
+ /** Options shared by headless Chromium consumers. */
278
279
  export interface LaunchHeadlessOptions {
279
280
  headless: boolean;
280
281
  viewport?: { width: number; height: number; deviceScaleFactor?: number };
282
+ /** Additional Chromium arguments merged with the centralized launch defaults. */
283
+ args?: readonly string[];
284
+ /** Additional exact Puppeteer default arguments to suppress. */
285
+ ignoreDefaultArgs?: readonly string[];
281
286
  }
282
287
 
283
288
  export async function launchHeadlessBrowser(opts: LaunchHeadlessOptions): Promise<Browser> {
@@ -308,13 +313,16 @@ export async function launchHeadlessBrowser(opts: LaunchHeadlessOptions): Promis
308
313
  if (ignoreCert === "true" || ignoreCert === "1" || ignoreCert === "yes" || ignoreCert === "on") {
309
314
  launchArgs.push("--ignore-certificate-errors");
310
315
  }
316
+ for (const arg of opts.args ?? []) {
317
+ if (!launchArgs.includes(arg)) launchArgs.push(arg);
318
+ }
311
319
  const executablePath = await ensureChromiumExecutable();
312
320
  return await puppeteer.launch({
313
321
  headless: opts.headless,
314
322
  defaultViewport: opts.headless ? initialViewport : null,
315
323
  executablePath,
316
324
  args: launchArgs,
317
- ignoreDefaultArgs: stealthIgnoreDefaultArgs(executablePath),
325
+ ignoreDefaultArgs: [...new Set([...stealthIgnoreDefaultArgs(executablePath), ...(opts.ignoreDefaultArgs ?? [])])],
318
326
  protocolTimeout: BROWSER_PROTOCOL_TIMEOUT_MS,
319
327
  });
320
328
  }
@@ -286,13 +286,16 @@ async function acquireTabImpl(
286
286
  }
287
287
  }
288
288
 
289
- // If the caller aborted while we were spawning/initializing the worker,
290
- // tear the freshly-built worker down before publishing the tab so the
291
- // browser refCount (which `holdBrowser` below would take) never grows for
292
- // a tab nobody is waiting for.
289
+ // If the caller aborted while we were spawning/initializing the worker, tear
290
+ // the freshly-built worker down before publishing the tab so the browser
291
+ // refCount (which `holdBrowser` below would take) never grows for a tab
292
+ // nobody is waiting for. Mirror the error paths' `refCount === 0` release so
293
+ // a fresh browser held by nothing but this aborted open is not orphaned in
294
+ // the registry; a browser still leased/held elsewhere (refCount > 0) is left
295
+ // for its owner to release.
293
296
  if (opts.signal?.aborted) {
294
297
  await worker.terminate().catch(() => undefined);
295
- if (tempHold) await releaseBrowser(browser, { kill: false }).catch(() => undefined);
298
+ if (tempHold || browser.refCount === 0) await releaseBrowser(browser, { kill: false }).catch(() => undefined);
296
299
  throw new ToolAbortError("Browser tab open aborted");
297
300
  }
298
301