@oh-my-pi/pi-coding-agent 17.2.1 → 17.2.3

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 (121) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/dist/{CHANGELOG-dj46zzrm.md → CHANGELOG-c5hpqt9r.md} +61 -0
  3. package/dist/cli.js +3856 -3850
  4. package/dist/types/config/keybindings.d.ts +6 -1
  5. package/dist/types/config/settings-schema.d.ts +16 -5
  6. package/dist/types/edit/hashline/block-resolver.d.ts +5 -5
  7. package/dist/types/edit/hashline/diff.d.ts +4 -4
  8. package/dist/types/eval/executor-base.d.ts +27 -0
  9. package/dist/types/eval/js/context-manager.d.ts +7 -0
  10. package/dist/types/eval/js/executor.d.ts +2 -0
  11. package/dist/types/extensibility/extensions/runner.d.ts +64 -5
  12. package/dist/types/extensibility/extensions/types.d.ts +15 -0
  13. package/dist/types/extensibility/extensions/wrapper.d.ts +1 -1
  14. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +1 -1
  15. package/dist/types/live/visualizer.d.ts +3 -1
  16. package/dist/types/modes/acp/acp-agent.d.ts +3 -3
  17. package/dist/types/modes/components/ask-dialog.d.ts +0 -1
  18. package/dist/types/modes/components/keybinding-hints.d.ts +1 -1
  19. package/dist/types/modes/controllers/mcp-command-controller.d.ts +13 -0
  20. package/dist/types/modes/interactive-mode.d.ts +1 -0
  21. package/dist/types/modes/theme/theme.d.ts +1 -1
  22. package/dist/types/modes/types.d.ts +2 -0
  23. package/dist/types/secrets/index.d.ts +22 -0
  24. package/dist/types/secrets/obfuscator.d.ts +1 -1
  25. package/dist/types/session/agent-session-types.d.ts +4 -0
  26. package/dist/types/session/agent-session.d.ts +9 -0
  27. package/dist/types/session/codex-auto-reset.d.ts +182 -64
  28. package/dist/types/session/session-storage.d.ts +5 -2
  29. package/dist/types/session/session-tools.d.ts +2 -1
  30. package/dist/types/session/turn-recovery.d.ts +7 -1
  31. package/dist/types/system-prompt.d.ts +8 -0
  32. package/dist/types/task/executor.d.ts +27 -0
  33. package/dist/types/task/persisted-revive.d.ts +7 -0
  34. package/dist/types/tools/bash-interceptor.d.ts +1 -1
  35. package/dist/types/tools/browser/attach.d.ts +9 -1
  36. package/dist/types/tools/browser/launch.d.ts +50 -1
  37. package/dist/types/tools/browser/registry.d.ts +7 -0
  38. package/dist/types/tools/browser/shared-daemon.d.ts +26 -0
  39. package/dist/types/tools/browser/tab-protocol.d.ts +8 -0
  40. package/dist/types/tools/browser/tab-supervisor.d.ts +1 -0
  41. package/dist/types/tools/browser/tab-worker.d.ts +3 -1
  42. package/dist/types/tools/shell-tokenize.d.ts +11 -0
  43. package/dist/types/utils/image-loading.d.ts +2 -0
  44. package/dist/types/web/search/providers/duckduckgo.d.ts +9 -0
  45. package/dist/types/web/search/types.d.ts +2 -2
  46. package/package.json +12 -12
  47. package/src/commit/agentic/index.ts +2 -1
  48. package/src/commit/pipeline.ts +3 -2
  49. package/src/config/keybindings.ts +6 -1
  50. package/src/config/settings-schema.ts +16 -3
  51. package/src/edit/hashline/block-resolver.ts +5 -5
  52. package/src/edit/hashline/diff.ts +22 -15
  53. package/src/edit/hashline/execute.ts +14 -13
  54. package/src/edit/renderer.ts +3 -3
  55. package/src/eval/executor-base.ts +39 -0
  56. package/src/eval/jl/executor.ts +13 -2
  57. package/src/eval/js/context-manager.ts +97 -14
  58. package/src/eval/js/executor.ts +3 -0
  59. package/src/eval/js/index.ts +1 -0
  60. package/src/eval/py/executor.ts +55 -15
  61. package/src/eval/rb/executor.ts +8 -1
  62. package/src/extensibility/extensions/runner.ts +121 -4
  63. package/src/extensibility/extensions/types.ts +15 -0
  64. package/src/extensibility/extensions/wrapper.ts +19 -2
  65. package/src/extensibility/legacy-pi-coding-agent-shim.ts +5 -5
  66. package/src/launch/broker.ts +59 -41
  67. package/src/live/visualizer.ts +8 -1
  68. package/src/lsp/index.ts +51 -11
  69. package/src/main.ts +3 -0
  70. package/src/modes/acp/acp-agent.ts +16 -5
  71. package/src/modes/components/ask-dialog.ts +65 -23
  72. package/src/modes/components/assistant-message.ts +5 -10
  73. package/src/modes/components/custom-editor.ts +1 -1
  74. package/src/modes/components/keybinding-hints.ts +4 -13
  75. package/src/modes/components/status-line/segments.ts +4 -5
  76. package/src/modes/components/tool-execution.ts +4 -5
  77. package/src/modes/controllers/input-controller.ts +18 -40
  78. package/src/modes/controllers/live-command-controller.ts +1 -0
  79. package/src/modes/controllers/mcp-command-controller.ts +28 -11
  80. package/src/modes/interactive-mode.ts +59 -2
  81. package/src/modes/theme/theme.ts +13 -6
  82. package/src/modes/types.ts +2 -0
  83. package/src/modes/utils/hotkeys-markdown.ts +1 -0
  84. package/src/prompts/system/plan-mode-active.md +5 -5
  85. package/src/sdk.ts +47 -16
  86. package/src/secrets/index.ts +83 -3
  87. package/src/secrets/obfuscator.ts +70 -15
  88. package/src/session/agent-session-types.ts +7 -1
  89. package/src/session/agent-session.ts +394 -113
  90. package/src/session/codex-auto-reset.ts +594 -123
  91. package/src/session/eval-runner.ts +2 -0
  92. package/src/session/messages.ts +1 -1
  93. package/src/session/session-maintenance.ts +50 -3
  94. package/src/session/session-manager.ts +4 -3
  95. package/src/session/session-stats.ts +14 -3
  96. package/src/session/session-storage.ts +47 -15
  97. package/src/session/session-tools.ts +30 -2
  98. package/src/session/turn-recovery.ts +15 -2
  99. package/src/slash-commands/builtin-registry.ts +21 -15
  100. package/src/slash-commands/helpers/reset-usage.ts +2 -0
  101. package/src/system-prompt.ts +13 -1
  102. package/src/task/executor.ts +168 -15
  103. package/src/task/persisted-revive.ts +26 -1
  104. package/src/telemetry-export.ts +8 -4
  105. package/src/tools/bash-interceptor.ts +85 -8
  106. package/src/tools/bash.ts +12 -6
  107. package/src/tools/browser/attach.ts +35 -10
  108. package/src/tools/browser/launch.ts +110 -16
  109. package/src/tools/browser/registry.ts +140 -31
  110. package/src/tools/browser/shared-daemon.ts +190 -0
  111. package/src/tools/browser/tab-protocol.ts +8 -0
  112. package/src/tools/browser/tab-supervisor.ts +31 -4
  113. package/src/tools/browser/tab-worker.ts +32 -2
  114. package/src/tools/browser.ts +1 -1
  115. package/src/tools/output-meta.ts +16 -1
  116. package/src/tools/shell-tokenize.ts +101 -0
  117. package/src/utils/image-loading.ts +8 -3
  118. package/src/utils/shell-snapshot.ts +31 -21
  119. package/src/web/search/providers/codex.ts +31 -6
  120. package/src/web/search/providers/duckduckgo.ts +183 -15
  121. package/src/web/search/types.ts +6 -1
@@ -1,7 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
- import { $which, getPuppeteerDir, logger } from "@oh-my-pi/pi-utils";
4
+ import { $which, getPuppeteerDir, logger, removeWithRetries } from "@oh-my-pi/pi-utils";
5
5
  import type * as BrowsersNs from "@puppeteer/browsers";
6
6
  import type { Browser, CDPSession, Page, default as Puppeteer, Target } from "puppeteer-core";
7
7
  import stealthTamperingScript from "../puppeteer/00_stealth_tampering.txt" with { type: "text" };
@@ -285,19 +285,28 @@ export interface LaunchHeadlessOptions {
285
285
  ignoreDefaultArgs?: readonly string[];
286
286
  }
287
287
 
288
- export async function launchHeadlessBrowser(opts: LaunchHeadlessOptions): Promise<Browser> {
289
- const vp = opts.viewport ?? DEFAULT_VIEWPORT;
290
- const initialViewport = {
291
- width: vp.width,
292
- height: vp.height,
293
- deviceScaleFactor: vp.deviceScaleFactor ?? DEFAULT_VIEWPORT.deviceScaleFactor,
294
- };
295
- const puppeteer = await loadPuppeteer();
288
+ /** Result of a headless Chromium launch. */
289
+ export interface LaunchHeadlessResult {
290
+ browser: Browser;
291
+ /**
292
+ * OMP-owned temporary Chromium profile directory to remove after the browser
293
+ * process tree exits, or `undefined` when the caller supplied its own
294
+ * `--user-data-dir` (which OMP must not delete).
295
+ */
296
+ userDataDir?: string;
297
+ }
298
+
299
+ /**
300
+ * Base Chromium argv shared by process-local puppeteer launches and the
301
+ * broker-owned shared browser: sandbox/stealth flags, window size, and
302
+ * PUPPETEER_PROXY* env-derived proxy flags.
303
+ */
304
+ export function buildHeadlessLaunchArgs(viewport: { width: number; height: number }): string[] {
296
305
  const launchArgs = [
297
306
  "--no-sandbox",
298
307
  "--disable-setuid-sandbox",
299
308
  "--disable-blink-features=AutomationControlled",
300
- `--window-size=${initialViewport.width},${initialViewport.height}`,
309
+ `--window-size=${viewport.width},${viewport.height}`,
301
310
  ];
302
311
  const proxy = process.env.PUPPETEER_PROXY;
303
312
  if (proxy) {
@@ -313,18 +322,103 @@ export async function launchHeadlessBrowser(opts: LaunchHeadlessOptions): Promis
313
322
  if (ignoreCert === "true" || ignoreCert === "1" || ignoreCert === "yes" || ignoreCert === "on") {
314
323
  launchArgs.push("--ignore-certificate-errors");
315
324
  }
325
+ return launchArgs;
326
+ }
327
+
328
+ export async function launchHeadlessBrowser(opts: LaunchHeadlessOptions): Promise<LaunchHeadlessResult> {
329
+ const vp = opts.viewport ?? DEFAULT_VIEWPORT;
330
+ const initialViewport = {
331
+ width: vp.width,
332
+ height: vp.height,
333
+ deviceScaleFactor: vp.deviceScaleFactor ?? DEFAULT_VIEWPORT.deviceScaleFactor,
334
+ };
335
+ const puppeteer = await loadPuppeteer();
336
+ const launchArgs = buildHeadlessLaunchArgs(initialViewport);
316
337
  for (const arg of opts.args ?? []) {
317
338
  if (!launchArgs.includes(arg)) launchArgs.push(arg);
318
339
  }
340
+ // Own the Chromium profile directory instead of letting puppeteer-core create
341
+ // (and delete) a temporary one. Passing `--user-data-dir` makes puppeteer
342
+ // treat the profile as non-temporary, so `ChromeLauncher.cleanUserDataDir`
343
+ // becomes a no-op and can no longer reject its eager process-exit hook with an
344
+ // unhandled EBUSY when Chromium still holds the profile lock on Windows
345
+ // (issue #7058). `removeUserDataDir` cleans it up on our terms instead.
346
+ let userDataDir: string | undefined;
347
+ if (!launchArgs.some(arg => arg.startsWith("--user-data-dir"))) {
348
+ userDataDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "omp-chrome-profile-"));
349
+ launchArgs.push(`--user-data-dir=${userDataDir}`);
350
+ }
351
+ try {
352
+ const executablePath = await ensureChromiumExecutable();
353
+ const browser = await puppeteer.launch({
354
+ headless: opts.headless,
355
+ defaultViewport: opts.headless ? initialViewport : null,
356
+ executablePath,
357
+ args: launchArgs,
358
+ ignoreDefaultArgs: [
359
+ ...new Set([...stealthIgnoreDefaultArgs(executablePath), ...(opts.ignoreDefaultArgs ?? [])]),
360
+ ],
361
+ protocolTimeout: BROWSER_PROTOCOL_TIMEOUT_MS,
362
+ });
363
+ return { browser, userDataDir };
364
+ } catch (error) {
365
+ if (userDataDir) await removeUserDataDir(userDataDir);
366
+ throw error;
367
+ }
368
+ }
369
+
370
+ /** Fully resolved executable and argv for a broker-spawned shared Chromium. */
371
+ export interface SharedBrowserLaunchSpec {
372
+ executablePath: string;
373
+ args: string[];
374
+ }
375
+
376
+ /**
377
+ * Resolve the executable and complete argv for a shared Chromium the daemon
378
+ * broker spawns directly (no puppeteer inside the broker). Mirrors
379
+ * `launchHeadlessBrowser` flag assembly — puppeteer's default args minus the
380
+ * stealth-suppressed set — plus `--remote-debugging-port=0` so every client
381
+ * attaches over CDP. Returns null when no Chromium executable resolves;
382
+ * callers fall back to a process-local launch.
383
+ */
384
+ export async function resolveSharedBrowserLaunchSpec(opts: {
385
+ headless: boolean;
386
+ userDataDir: string;
387
+ viewport?: { width: number; height: number };
388
+ }): Promise<SharedBrowserLaunchSpec | null> {
319
389
  const executablePath = await ensureChromiumExecutable();
320
- return await puppeteer.launch({
390
+ if (!executablePath) return null;
391
+ const puppeteer = await loadPuppeteer();
392
+ const vp = opts.viewport ?? DEFAULT_VIEWPORT;
393
+ const ignored = new Set(stealthIgnoreDefaultArgs(executablePath));
394
+ const defaults = await puppeteer.defaultArgs({
321
395
  headless: opts.headless,
322
- defaultViewport: opts.headless ? initialViewport : null,
323
- executablePath,
324
- args: launchArgs,
325
- ignoreDefaultArgs: [...new Set([...stealthIgnoreDefaultArgs(executablePath), ...(opts.ignoreDefaultArgs ?? [])])],
326
- protocolTimeout: BROWSER_PROTOCOL_TIMEOUT_MS,
396
+ args: buildHeadlessLaunchArgs(vp),
397
+ userDataDir: opts.userDataDir,
327
398
  });
399
+ return {
400
+ executablePath,
401
+ args: [...defaults.filter(arg => !ignored.has(arg)), "--remote-debugging-port=0"],
402
+ };
403
+ }
404
+
405
+ /**
406
+ * Remove an OMP-owned headless Chromium profile directory, tolerating the brief
407
+ * window on Windows in which Chromium (or an orphaned browser subprocess) still
408
+ * holds the profile lock. The shared temp remover centralizes retry handling
409
+ * for EBUSY/EPERM/ENOTEMPTY; if the directory is still busy afterwards we warn
410
+ * and leave it for a later cleanup pass rather than throwing — a shutdown cleanup
411
+ * failure must never crash the process (issue #7058).
412
+ */
413
+ export async function removeUserDataDir(dir: string): Promise<void> {
414
+ try {
415
+ await removeWithRetries(dir);
416
+ } catch (error) {
417
+ logger.warn("Left Chromium profile directory in place after cleanup failure", {
418
+ dir,
419
+ error: error instanceof Error ? error.message : String(error),
420
+ });
421
+ }
328
422
  }
329
423
 
330
424
  export async function applyViewport(
@@ -1,12 +1,20 @@
1
1
  import * as path from "node:path";
2
- import { logger, withTimeout } from "@oh-my-pi/pi-utils";
2
+ import { isCompiledBinary, logger, withTimeout, workerHostEntry } from "@oh-my-pi/pi-utils";
3
3
  import type { Subprocess } from "bun";
4
4
  import type { Browser, CDPSession } from "puppeteer-core";
5
5
  import { ToolAbortError, ToolError } from "../tool-errors";
6
6
  import { findFreeCdpPort, findReusableCdp, gracefulKillTreeOnce, killExistingByPath, waitForCdp } from "./attach";
7
7
  import type { CmuxKind } from "./cmux/rpc";
8
8
  import { CmuxSocketClient } from "./cmux/socket-client";
9
- import { BROWSER_PROTOCOL_TIMEOUT_MS, launchHeadlessBrowser, loadPuppeteer, type UserAgentOverride } from "./launch";
9
+ import {
10
+ BROWSER_PROTOCOL_TIMEOUT_MS,
11
+ DEFAULT_VIEWPORT,
12
+ launchHeadlessBrowser,
13
+ loadPuppeteer,
14
+ removeUserDataDir,
15
+ type UserAgentOverride,
16
+ } from "./launch";
17
+ import { ensureSharedBrowser } from "./shared-daemon";
10
18
 
11
19
  export type PuppeteerBrowserKind =
12
20
  | { kind: "headless"; headless: boolean }
@@ -35,6 +43,10 @@ export interface PuppeteerBrowserHandle extends BrowserHandleCommon {
35
43
  browser: Browser;
36
44
  cdpUrl?: string;
37
45
  pid?: number;
46
+ /** OMP-owned temp Chromium profile directory removed on dispose (process-local headless launches). */
47
+ userDataDir?: string;
48
+ /** Broker daemon backing this handle; dispose disconnects instead of closing, kill routes to the broker. */
49
+ sharedDaemon?: { name: string; projectDir: string };
38
50
  subprocess?: Subprocess;
39
51
  stealth: { browserSession: CDPSession | null; override: UserAgentOverride | null };
40
52
  }
@@ -55,6 +67,8 @@ export interface ReleaseBrowserOptions {
55
67
  }
56
68
 
57
69
  const browsers = new Map<string, BrowserHandle>();
70
+ /** In-flight opens by browser key, so concurrent acquisitions share one launch instead of storming Chromium. */
71
+ const pendingOpens = new Map<string, Promise<BrowserHandle>>();
58
72
 
59
73
  function browserKey(kind: BrowserKind): string {
60
74
  switch (kind.kind) {
@@ -78,37 +92,51 @@ export interface AcquireBrowserOptions {
78
92
 
79
93
  export async function acquireBrowser(kind: BrowserKind, opts: AcquireBrowserOptions): Promise<BrowserHandle> {
80
94
  const key = browserKey(kind);
81
- const existing = browsers.get(key);
82
- if (existing) {
83
- if ("client" in existing) return existing;
84
- if (existing.browser.connected) return existing;
85
- browsers.delete(key);
86
- await disposeBrowserHandle(existing, { kill: false });
87
- }
88
- // Short-circuit before launching: the tool wrapper's `untilAborted` only
89
- // rejects its outer promise on abort; without this check `openBrowserHandle`
90
- // would still fire and its result would land in `browsers` below.
91
- if (opts.signal?.aborted) throw new ToolAbortError("Browser open aborted");
95
+ for (;;) {
96
+ const existing = browsers.get(key);
97
+ if (existing) {
98
+ if ("client" in existing) return existing;
99
+ if (existing.browser.connected) return existing;
100
+ browsers.delete(key);
101
+ await disposeBrowserHandle(existing, { kill: false });
102
+ continue;
103
+ }
104
+ // Short-circuit before launching: the tool wrapper's `untilAborted` only
105
+ // rejects its outer promise on abort; without this check `openBrowserHandle`
106
+ // would still fire and its result would land in `browsers` below.
107
+ if (opts.signal?.aborted) throw new ToolAbortError("Browser open aborted");
92
108
 
93
- const handle = await openBrowserHandle(kind, opts);
94
- // The launch may resolve AFTER the caller has already aborted (the outer
95
- // `untilAborted` rejects immediately on abort but does not cancel the
96
- // inner promise, and `launchHeadlessBrowser` does not accept a signal).
97
- // Without this branch the completed handle sits in `browsers` at
98
- // refCount:0 forever — no tab ever takes a hold, `releaseBrowser` never
99
- // fires, and `releaseAllTabs` walks `tabs`, not `browsers`, so the
100
- // orphaned Chromium/app process / puppeteer handle survives to process
101
- // exit. (Issue #3963.)
102
- if (opts.signal?.aborted) {
103
- await disposeBrowserHandle(handle, { kill: kind.kind === "spawned" }).catch(err => {
104
- logger.debug("Failed to dispose orphan browser after abort", {
105
- error: err instanceof Error ? err.message : String(err),
109
+ // Single-flight per key: a concurrent caller already opening this browser
110
+ // wins; everyone else waits and re-reads the registry. Without this, N
111
+ // simultaneous opens each launch a Chromium and the last write wins,
112
+ // leaking the rest as unreferenced process trees.
113
+ const pending = pendingOpens.get(key);
114
+ if (pending) {
115
+ await pending.catch(() => undefined);
116
+ continue;
117
+ }
118
+ const open = openBrowserHandle(kind, opts).finally(() => pendingOpens.delete(key));
119
+ pendingOpens.set(key, open);
120
+ const handle = await open;
121
+ // The launch may resolve AFTER the caller has already aborted (the outer
122
+ // `untilAborted` rejects immediately on abort but does not cancel the
123
+ // inner promise, and `launchHeadlessBrowser` does not accept a signal).
124
+ // Without this branch the completed handle sits in `browsers` at
125
+ // refCount:0 forever — no tab ever takes a hold, `releaseBrowser` never
126
+ // fires, and `releaseAllTabs` walks `tabs`, not `browsers`, so the
127
+ // orphaned Chromium/app process / puppeteer handle survives to process
128
+ // exit. (Issue #3963.)
129
+ if (opts.signal?.aborted) {
130
+ await disposeBrowserHandle(handle, { kill: kind.kind === "spawned" }).catch(err => {
131
+ logger.debug("Failed to dispose orphan browser after abort", {
132
+ error: err instanceof Error ? err.message : String(err),
133
+ });
106
134
  });
107
- });
108
- throw new ToolAbortError("Browser open aborted");
135
+ throw new ToolAbortError("Browser open aborted");
136
+ }
137
+ browsers.set(key, handle);
138
+ return handle;
109
139
  }
110
- browsers.set(key, handle);
111
- return handle;
112
140
  }
113
141
 
114
142
  export function normalizeConnectedCdpUrl(rawCdpUrl: string): string {
@@ -134,11 +162,23 @@ async function openBrowserHandle(kind: BrowserKind, opts: AcquireBrowserOptions)
134
162
  };
135
163
  }
136
164
  if (kind.kind === "headless") {
137
- const browser = await launchHeadlessBrowser({ headless: kind.headless, viewport: opts.viewport });
165
+ // Every real omp process (session, subagent, worker anything with a CLI
166
+ // worker host) MUST go through the project-shared broker-owned Chromium:
167
+ // per-process launches are what produced launch storms and orphaned
168
+ // process trees. The process-local launch survives only for hosts that
169
+ // cannot spawn the broker (bun test, SDK embedding without a CLI entry).
170
+ if (isCompiledBinary() || workerHostEntry() !== null) {
171
+ return await openSharedHeadlessHandle(kind, opts);
172
+ }
173
+ const { browser, userDataDir } = await launchHeadlessBrowser({
174
+ headless: kind.headless,
175
+ viewport: opts.viewport,
176
+ });
138
177
  return {
139
178
  key: browserKey(kind),
140
179
  kind,
141
180
  browser,
181
+ userDataDir,
142
182
  refCount: 0,
143
183
  stealth: { browserSession: null, override: null },
144
184
  };
@@ -245,6 +285,21 @@ async function disposeBrowserHandle(handle: BrowserHandle, opts: ReleaseBrowserO
245
285
  return;
246
286
  }
247
287
  if (handle.kind.kind === "headless") {
288
+ if (handle.sharedDaemon) {
289
+ // The broker owns the Chromium; this process only drops its CDP
290
+ // connection. `kill` is scoped to spawned-app browsers — stopping the
291
+ // shared daemon here would tear down every other session's tabs. The
292
+ // daemon dies with the last omp client in the project (broker idle
293
+ // teardown), or via an explicit hub stop.
294
+ if (handle.browser.connected) {
295
+ try {
296
+ handle.browser.disconnect();
297
+ } catch (err) {
298
+ logger.debug("Failed to disconnect from shared browser", { error: (err as Error).message });
299
+ }
300
+ }
301
+ return;
302
+ }
248
303
  if (handle.browser.connected) {
249
304
  // Puppeteer's `browser.close()` resolves only once the Chromium
250
305
  // process fully exits. A wedged Chromium (a known Windows failure
@@ -259,6 +314,10 @@ async function disposeBrowserHandle(handle: BrowserHandle, opts: ReleaseBrowserO
259
314
  if (proc?.pid !== undefined) await gracefulKillTreeOnce(proc.pid).catch(() => undefined);
260
315
  }
261
316
  }
317
+ // OMP owns the profile directory (puppeteer's temp cleanup is disabled by
318
+ // our explicit --user-data-dir), so remove it now the process tree has
319
+ // exited. Tolerant of the Windows lock-held window (issue #7058).
320
+ if (handle.userDataDir) await removeUserDataDir(handle.userDataDir);
262
321
  return;
263
322
  }
264
323
  if (handle.kind.kind === "connected") {
@@ -281,6 +340,56 @@ async function disposeBrowserHandle(handle: BrowserHandle, opts: ReleaseBrowserO
281
340
  if (opts.kill && handle.pid !== undefined) await gracefulKillTreeOnce(handle.pid);
282
341
  }
283
342
 
343
+ /**
344
+ * Attach to the project-shared broker-owned Chromium. Failures surface as
345
+ * `ToolError` — a CLI-host process never silently falls back to a private
346
+ * Chromium, so a broken broker cannot quietly recreate per-process launch
347
+ * storms.
348
+ */
349
+ async function openSharedHeadlessHandle(
350
+ kind: Extract<PuppeteerBrowserKind, { kind: "headless" }>,
351
+ opts: AcquireBrowserOptions,
352
+ ): Promise<PuppeteerBrowserHandle> {
353
+ const vp = opts.viewport ?? DEFAULT_VIEWPORT;
354
+ try {
355
+ const shared = await ensureSharedBrowser({
356
+ projectDir: opts.cwd,
357
+ headless: kind.headless,
358
+ viewport: vp,
359
+ signal: opts.signal,
360
+ });
361
+ if (!shared) {
362
+ throw new ToolError(
363
+ "Shared browser daemon unavailable (broker start or Chromium launch failed); check `hub ps` for omp.browser.* daemons and ~/.omp/logs for details",
364
+ );
365
+ }
366
+ const puppeteer = await loadPuppeteer();
367
+ const browser = await puppeteer.connect({
368
+ browserWSEndpoint: shared.wsEndpoint,
369
+ defaultViewport: kind.headless
370
+ ? {
371
+ width: vp.width,
372
+ height: vp.height,
373
+ deviceScaleFactor: vp.deviceScaleFactor ?? DEFAULT_VIEWPORT.deviceScaleFactor,
374
+ }
375
+ : null,
376
+ protocolTimeout: BROWSER_PROTOCOL_TIMEOUT_MS,
377
+ });
378
+ return {
379
+ key: browserKey(kind),
380
+ kind,
381
+ browser,
382
+ sharedDaemon: { name: shared.daemonName, projectDir: shared.projectDir },
383
+ refCount: 0,
384
+ stealth: { browserSession: null, override: null },
385
+ };
386
+ } catch (err) {
387
+ if (err instanceof ToolAbortError || err instanceof ToolError) throw err;
388
+ if (opts.signal?.aborted) throw new ToolAbortError("Browser open aborted");
389
+ throw new ToolError(`Shared browser attach failed: ${err instanceof Error ? err.message : String(err)}`);
390
+ }
391
+ }
392
+
284
393
  /** Test-only accessor for the module-global browsers map. */
285
394
  export function getBrowsersMapForTest(): ReadonlyMap<string, BrowserHandle> {
286
395
  return browsers;
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Shared automation Chromium owned by the per-project daemon broker.
3
+ *
4
+ * Instead of every omp process launching (and sometimes orphaning) a private
5
+ * Chromium, the headless browser kind attaches to one broker-supervised Chrome
6
+ * per project directory — sessions and subagents each open their own tabs in
7
+ * it. The broker stops the daemon when the last omp client in the project
8
+ * exits, so Chrome can never outlive omp, and concurrent acquisitions across
9
+ * processes converge on a single launch instead of a launch storm.
10
+ */
11
+ import * as fs from "node:fs/promises";
12
+ import * as path from "node:path";
13
+ import { logger } from "@oh-my-pi/pi-utils";
14
+ import { type DaemonBrokerClient, daemonClientForProject } from "../../launch/client";
15
+ import { daemonRuntimeDir } from "../../launch/paths";
16
+ import type { DaemonSnapshot } from "../../launch/protocol";
17
+ import { throwIfAborted } from "../tool-errors";
18
+ import { resolveSharedBrowserLaunchSpec } from "./launch";
19
+
20
+ /** Chrome prints this on stderr once the CDP listener is up; the broker's ready probe captures the line. */
21
+ const READY_LOG_PATTERN = String.raw`DevTools listening on ws://\S+`;
22
+ const READY_TIMEOUT_MS = 30_000;
23
+ const STOP_TIMEOUT_MS = 5_000;
24
+ const PROBE_TIMEOUT_MS = 1_500;
25
+ /** describe→start rounds before giving up; bounds cross-process start races and wedged-Chrome replacement. */
26
+ const ENSURE_ATTEMPTS = 3;
27
+
28
+ /** Broker-owned browser endpoint one omp process can attach to. */
29
+ export interface SharedBrowserEndpoint {
30
+ wsEndpoint: string;
31
+ daemonName: string;
32
+ /** Canonical project directory owning the broker (used to address later stop requests). */
33
+ projectDir: string;
34
+ }
35
+
36
+ /** Stable broker daemon name for the shared automation browser. */
37
+ export function sharedBrowserDaemonName(headless: boolean): string {
38
+ return headless ? "omp.browser.headless" : "omp.browser.headed";
39
+ }
40
+
41
+ function wsEndpointOf(snapshot: DaemonSnapshot | undefined): string | undefined {
42
+ return snapshot?.readyMatch?.match(/ws:\/\/\S+/)?.[0];
43
+ }
44
+
45
+ /** CDP liveness probe: the ws endpoint host must answer /json/version. */
46
+ async function probeEndpoint(wsEndpoint: string): Promise<boolean> {
47
+ let host: string;
48
+ try {
49
+ host = new URL(wsEndpoint).host;
50
+ } catch {
51
+ return false;
52
+ }
53
+ try {
54
+ const res = await fetch(`http://${host}/json/version`, { signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) });
55
+ await res.body?.cancel();
56
+ return res.ok;
57
+ } catch {
58
+ return false;
59
+ }
60
+ }
61
+
62
+ /** Snapshot the daemon, treating "unknown daemon" as absent. */
63
+ async function describeQuietly(
64
+ client: DaemonBrokerClient,
65
+ name: string,
66
+ signal?: AbortSignal,
67
+ ): Promise<DaemonSnapshot | undefined> {
68
+ try {
69
+ const result = await client.request({ op: "describe", name }, signal);
70
+ return result.op === "describe" ? result.daemon : undefined;
71
+ } catch (error) {
72
+ throwIfAborted(signal);
73
+ logger.debug("Shared browser describe failed", {
74
+ name,
75
+ error: error instanceof Error ? error.message : String(error),
76
+ });
77
+ return undefined;
78
+ }
79
+ }
80
+
81
+ /** Block until the daemon reports ready; undefined on timeout or pre-ready exit. */
82
+ async function waitReady(
83
+ client: DaemonBrokerClient,
84
+ name: string,
85
+ signal?: AbortSignal,
86
+ ): Promise<DaemonSnapshot | undefined> {
87
+ try {
88
+ const result = await client.request({ op: "wait", name, for: "ready", timeoutMs: READY_TIMEOUT_MS }, signal);
89
+ if (result.op !== "wait" || result.timedOut) return undefined;
90
+ return result.daemon;
91
+ } catch (error) {
92
+ throwIfAborted(signal);
93
+ logger.debug("Shared browser ready wait failed", {
94
+ name,
95
+ error: error instanceof Error ? error.message : String(error),
96
+ });
97
+ return undefined;
98
+ }
99
+ }
100
+
101
+ /** Best-effort stop before replacing a wedged or endpoint-less daemon. */
102
+ async function stopQuietly(client: DaemonBrokerClient, name: string, signal?: AbortSignal): Promise<void> {
103
+ try {
104
+ await client.request({ op: "stop", name, timeoutMs: STOP_TIMEOUT_MS }, signal);
105
+ } catch (error) {
106
+ throwIfAborted(signal);
107
+ logger.debug("Shared browser stop failed", {
108
+ name,
109
+ error: error instanceof Error ? error.message : String(error),
110
+ });
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Ensure the project-shared automation Chromium is running and reachable,
116
+ * launching it under the daemon broker when needed. Idempotent across
117
+ * processes: losers of the start race adopt the winner's endpoint on the next
118
+ * describe round. Returns null when the shared path is unavailable (no
119
+ * resolvable Chromium, broker failure, or a daemon that never becomes
120
+ * reachable); callers fall back to a process-local launch.
121
+ */
122
+ export async function ensureSharedBrowser(opts: {
123
+ projectDir: string;
124
+ headless: boolean;
125
+ viewport?: { width: number; height: number };
126
+ signal?: AbortSignal;
127
+ }): Promise<SharedBrowserEndpoint | null> {
128
+ const client = await daemonClientForProject(opts.projectDir);
129
+ const name = sharedBrowserDaemonName(opts.headless);
130
+ // Stable profile under the broker's runtime dir: reused across launches, and
131
+ // never contended by pre-daemon Chromiums that used throwaway temp profiles.
132
+ const userDataDir = path.join(daemonRuntimeDir(client.projectDir), `${name}.profile`);
133
+ const launch = await resolveSharedBrowserLaunchSpec({
134
+ headless: opts.headless,
135
+ userDataDir,
136
+ viewport: opts.viewport,
137
+ });
138
+ if (!launch) return null;
139
+ await fs.mkdir(userDataDir, { recursive: true });
140
+ for (let attempt = 0; attempt < ENSURE_ATTEMPTS; attempt++) {
141
+ throwIfAborted(opts.signal);
142
+ const existing = await describeQuietly(client, name, opts.signal);
143
+ if (existing && existing.state !== "exited" && existing.state !== "failed") {
144
+ const settled = existing.readyAt !== undefined ? existing : await waitReady(client, name, opts.signal);
145
+ const wsEndpoint = wsEndpointOf(settled);
146
+ if (wsEndpoint && (await probeEndpoint(wsEndpoint))) {
147
+ return { wsEndpoint, daemonName: name, projectDir: client.projectDir };
148
+ }
149
+ // Live record but unreachable Chrome (wedged, or readiness never
150
+ // matched): replace it rather than handing out a dead endpoint.
151
+ await stopQuietly(client, name, opts.signal);
152
+ continue;
153
+ }
154
+ try {
155
+ const started = await client.request(
156
+ {
157
+ op: "start",
158
+ spec: {
159
+ name,
160
+ application: launch.executablePath,
161
+ args: launch.args,
162
+ env: {},
163
+ cwd: client.projectDir,
164
+ pty: false,
165
+ ready: { log: READY_LOG_PATTERN, timeoutMs: READY_TIMEOUT_MS },
166
+ restart: "no",
167
+ persist: false,
168
+ detached: false,
169
+ },
170
+ },
171
+ opts.signal,
172
+ );
173
+ if (started.op !== "start") continue;
174
+ const wsEndpoint = started.readyTimedOut ? undefined : wsEndpointOf(started.daemon);
175
+ if (wsEndpoint && (await probeEndpoint(wsEndpoint))) {
176
+ return { wsEndpoint, daemonName: name, projectDir: client.projectDir };
177
+ }
178
+ await stopQuietly(client, name, opts.signal);
179
+ } catch (error) {
180
+ throwIfAborted(opts.signal);
181
+ // Lost a cross-process start race ("already starting/ready"); the next
182
+ // describe round adopts the winner's endpoint.
183
+ logger.debug("Shared browser start contention", {
184
+ name,
185
+ error: error instanceof Error ? error.message : String(error),
186
+ });
187
+ }
188
+ }
189
+ return null;
190
+ }
@@ -59,12 +59,20 @@ export type WorkerInitPayload =
59
59
  safeDir: string;
60
60
  targetId: string;
61
61
  dialogs?: "accept" | "dismiss";
62
+ url?: string;
63
+ waitUntil?: "load" | "domcontentloaded" | "networkidle0" | "networkidle2";
64
+ timeoutMs: number;
62
65
  /**
63
66
  * Post-timeout recycle: before adopting the page, dismiss any open JS dialog and
64
67
  * stop a pending navigation so a blocked target cannot stall worker init (which
65
68
  * previously force-killed the tab). Never set for first-time Electron attach.
66
69
  */
67
70
  recover?: boolean;
71
+ /**
72
+ * Whether the worker may raise this tab before capturing a screenshot. Unset
73
+ * behaves as `true`; the supervisor clears it for browsers we did not launch.
74
+ */
75
+ activateForScreenshot?: boolean;
68
76
  };
69
77
 
70
78
  export type ToolReply = { ok: true; value: unknown } | { ok: false; error: RunErrorPayload };