@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.
- package/CHANGELOG.md +14 -0
- package/dist/{CHANGELOG-xfpkakrn.md → CHANGELOG-c5hpqt9r.md} +14 -0
- package/dist/cli.js +2970 -2970
- package/dist/types/modes/components/ask-dialog.d.ts +0 -1
- package/dist/types/tools/browser/launch.d.ts +30 -0
- package/dist/types/tools/browser/registry.d.ts +6 -1
- package/dist/types/tools/browser/shared-daemon.d.ts +26 -0
- package/package.json +12 -12
- package/src/launch/broker.ts +59 -41
- package/src/modes/components/ask-dialog.ts +65 -23
- package/src/session/agent-session.ts +1 -1
- package/src/session/session-maintenance.ts +2 -2
- package/src/tools/bash.ts +11 -5
- package/src/tools/browser/launch.ts +54 -9
- package/src/tools/browser/registry.ts +123 -30
- package/src/tools/browser/shared-daemon.ts +190 -0
- package/src/tools/browser.ts +1 -1
- package/src/web/search/providers/codex.ts +31 -6
|
@@ -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
|
-
|
|
90
|
-
|
|
91
|
-
if (
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/tools/browser.ts
CHANGED
|
@@ -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
|
|
562
|
-
|
|
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
|
|
566
|
-
const
|
|
567
|
-
|
|
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
|
|