@oh-my-pi/pi-coding-agent 17.2.2 → 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.
@@ -1,5 +1,5 @@
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";
@@ -8,11 +8,13 @@ import type { CmuxKind } from "./cmux/rpc";
8
8
  import { CmuxSocketClient } from "./cmux/socket-client";
9
9
  import {
10
10
  BROWSER_PROTOCOL_TIMEOUT_MS,
11
+ DEFAULT_VIEWPORT,
11
12
  launchHeadlessBrowser,
12
13
  loadPuppeteer,
13
14
  removeUserDataDir,
14
15
  type UserAgentOverride,
15
16
  } from "./launch";
17
+ import { ensureSharedBrowser } from "./shared-daemon";
16
18
 
17
19
  export type PuppeteerBrowserKind =
18
20
  | { kind: "headless"; headless: boolean }
@@ -41,8 +43,10 @@ export interface PuppeteerBrowserHandle extends BrowserHandleCommon {
41
43
  browser: Browser;
42
44
  cdpUrl?: string;
43
45
  pid?: number;
44
- /** OMP-owned temp Chromium profile directory removed on dispose (headless launches). */
46
+ /** OMP-owned temp Chromium profile directory removed on dispose (process-local headless launches). */
45
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 };
46
50
  subprocess?: Subprocess;
47
51
  stealth: { browserSession: CDPSession | null; override: UserAgentOverride | null };
48
52
  }
@@ -63,6 +67,8 @@ export interface ReleaseBrowserOptions {
63
67
  }
64
68
 
65
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>>();
66
72
 
67
73
  function browserKey(kind: BrowserKind): string {
68
74
  switch (kind.kind) {
@@ -86,37 +92,51 @@ export interface AcquireBrowserOptions {
86
92
 
87
93
  export async function acquireBrowser(kind: BrowserKind, opts: AcquireBrowserOptions): Promise<BrowserHandle> {
88
94
  const key = browserKey(kind);
89
- const existing = browsers.get(key);
90
- if (existing) {
91
- if ("client" in existing) return existing;
92
- if (existing.browser.connected) return existing;
93
- browsers.delete(key);
94
- await disposeBrowserHandle(existing, { kill: false });
95
- }
96
- // Short-circuit before launching: the tool wrapper's `untilAborted` only
97
- // rejects its outer promise on abort; without this check `openBrowserHandle`
98
- // would still fire and its result would land in `browsers` below.
99
- 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");
100
108
 
101
- const handle = await openBrowserHandle(kind, opts);
102
- // The launch may resolve AFTER the caller has already aborted (the outer
103
- // `untilAborted` rejects immediately on abort but does not cancel the
104
- // inner promise, and `launchHeadlessBrowser` does not accept a signal).
105
- // Without this branch the completed handle sits in `browsers` at
106
- // refCount:0 forever — no tab ever takes a hold, `releaseBrowser` never
107
- // fires, and `releaseAllTabs` walks `tabs`, not `browsers`, so the
108
- // orphaned Chromium/app process / puppeteer handle survives to process
109
- // exit. (Issue #3963.)
110
- if (opts.signal?.aborted) {
111
- await disposeBrowserHandle(handle, { kill: kind.kind === "spawned" }).catch(err => {
112
- logger.debug("Failed to dispose orphan browser after abort", {
113
- 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
+ });
114
134
  });
115
- });
116
- throw new ToolAbortError("Browser open aborted");
135
+ throw new ToolAbortError("Browser open aborted");
136
+ }
137
+ browsers.set(key, handle);
138
+ return handle;
117
139
  }
118
- browsers.set(key, handle);
119
- return handle;
120
140
  }
121
141
 
122
142
  export function normalizeConnectedCdpUrl(rawCdpUrl: string): string {
@@ -142,6 +162,14 @@ async function openBrowserHandle(kind: BrowserKind, opts: AcquireBrowserOptions)
142
162
  };
143
163
  }
144
164
  if (kind.kind === "headless") {
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
+ }
145
173
  const { browser, userDataDir } = await launchHeadlessBrowser({
146
174
  headless: kind.headless,
147
175
  viewport: opts.viewport,
@@ -257,6 +285,21 @@ async function disposeBrowserHandle(handle: BrowserHandle, opts: ReleaseBrowserO
257
285
  return;
258
286
  }
259
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
+ }
260
303
  if (handle.browser.connected) {
261
304
  // Puppeteer's `browser.close()` resolves only once the Chromium
262
305
  // process fully exits. A wedged Chromium (a known Windows failure
@@ -297,6 +340,56 @@ async function disposeBrowserHandle(handle: BrowserHandle, opts: ReleaseBrowserO
297
340
  if (opts.kill && handle.pid !== undefined) await gracefulKillTreeOnce(handle.pid);
298
341
  }
299
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
+
300
393
  /** Test-only accessor for the module-global browsers map. */
301
394
  export function getBrowsersMapForTest(): ReadonlyMap<string, BrowserHandle> {
302
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
+ }
@@ -422,7 +422,7 @@ function describeBrowser(handle: BrowserHandle): string {
422
422
  }
423
423
  switch (handle.kind.kind) {
424
424
  case "headless":
425
- return `headless browser (${handle.kind.headless ? "hidden" : "visible"})`;
425
+ return `headless browser (${handle.kind.headless ? "hidden" : "visible"}${handle.sharedDaemon ? ", shared" : ""})`;
426
426
  case "spawned":
427
427
  return `spawned ${handle.kind.path} (pid ${handle.pid ?? "?"})`;
428
428
  case "connected":
@@ -407,6 +407,30 @@ function buildCodexHeaders(
407
407
  return headers;
408
408
  }
409
409
 
410
+ /**
411
+ * Extracts a backend error `{code, message}` from a Codex SSE event, tolerating
412
+ * the envelope shapes the ChatGPT Codex backend emits: top-level `{code,message}`,
413
+ * a nested `error` object, and a `response.error` object (as in `response.failed`).
414
+ * Without this the nested shapes collapse to `Codex error (): Unknown error`,
415
+ * discarding the backend diagnostic — e.g. a regional/model-snapshot rejection (#7200).
416
+ */
417
+ function extractCodexSseError(rawEvent: Record<string, unknown>): { code: string; message: string } {
418
+ const candidates: unknown[] = [
419
+ rawEvent,
420
+ rawEvent.error,
421
+ (rawEvent.response as { error?: unknown } | undefined)?.error,
422
+ ];
423
+ let code = "";
424
+ let message = "";
425
+ for (const candidate of candidates) {
426
+ if (!candidate || typeof candidate !== "object") continue;
427
+ const record = candidate as Record<string, unknown>;
428
+ if (!code && typeof record.code === "string" && record.code) code = record.code;
429
+ if (!message && typeof record.message === "string" && record.message) message = record.message;
430
+ }
431
+ return { code, message };
432
+ }
433
+
410
434
  /**
411
435
  * Calls the Codex Responses API with web search tool enabled.
412
436
  * The caller provides the exact model id to send; retry / fallback policy
@@ -558,13 +582,14 @@ async function callCodexSearch(
558
582
  }
559
583
  }
560
584
  } else if (eventType === "error") {
561
- const code = (rawEvent as { code?: string }).code ?? "";
562
- const message = (rawEvent as { message?: string }).message ?? "Unknown error";
563
- throw new SearchProviderError("codex", `Codex error (${code}): ${message}`, 500);
585
+ const { code, message } = extractCodexSseError(rawEvent);
586
+ throw new SearchProviderError("codex", `Codex error (${code}): ${message || "Unknown error"}`, 500);
564
587
  } else if (eventType === "response.failed") {
565
- const resp = (rawEvent as { response?: { error?: { message?: string } } }).response;
566
- const errorMessage = resp?.error?.message ?? "Request failed";
567
- throw new SearchProviderError("codex", `Codex request failed: ${errorMessage}`, 500);
588
+ const { code, message } = extractCodexSseError(rawEvent);
589
+ const detail = code
590
+ ? `Codex request failed (${code}): ${message || "Request failed"}`
591
+ : `Codex request failed: ${message || "Request failed"}`;
592
+ throw new SearchProviderError("codex", detail, 500);
568
593
  }
569
594
  }
570
595