@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.
- package/CHANGELOG.md +61 -0
- package/dist/{CHANGELOG-dj46zzrm.md → CHANGELOG-c5hpqt9r.md} +61 -0
- package/dist/cli.js +3856 -3850
- package/dist/types/config/keybindings.d.ts +6 -1
- package/dist/types/config/settings-schema.d.ts +16 -5
- package/dist/types/edit/hashline/block-resolver.d.ts +5 -5
- package/dist/types/edit/hashline/diff.d.ts +4 -4
- package/dist/types/eval/executor-base.d.ts +27 -0
- package/dist/types/eval/js/context-manager.d.ts +7 -0
- package/dist/types/eval/js/executor.d.ts +2 -0
- package/dist/types/extensibility/extensions/runner.d.ts +64 -5
- package/dist/types/extensibility/extensions/types.d.ts +15 -0
- package/dist/types/extensibility/extensions/wrapper.d.ts +1 -1
- package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +1 -1
- package/dist/types/live/visualizer.d.ts +3 -1
- package/dist/types/modes/acp/acp-agent.d.ts +3 -3
- package/dist/types/modes/components/ask-dialog.d.ts +0 -1
- package/dist/types/modes/components/keybinding-hints.d.ts +1 -1
- package/dist/types/modes/controllers/mcp-command-controller.d.ts +13 -0
- package/dist/types/modes/interactive-mode.d.ts +1 -0
- package/dist/types/modes/theme/theme.d.ts +1 -1
- package/dist/types/modes/types.d.ts +2 -0
- package/dist/types/secrets/index.d.ts +22 -0
- package/dist/types/secrets/obfuscator.d.ts +1 -1
- package/dist/types/session/agent-session-types.d.ts +4 -0
- package/dist/types/session/agent-session.d.ts +9 -0
- package/dist/types/session/codex-auto-reset.d.ts +182 -64
- package/dist/types/session/session-storage.d.ts +5 -2
- package/dist/types/session/session-tools.d.ts +2 -1
- package/dist/types/session/turn-recovery.d.ts +7 -1
- package/dist/types/system-prompt.d.ts +8 -0
- package/dist/types/task/executor.d.ts +27 -0
- package/dist/types/task/persisted-revive.d.ts +7 -0
- package/dist/types/tools/bash-interceptor.d.ts +1 -1
- package/dist/types/tools/browser/attach.d.ts +9 -1
- package/dist/types/tools/browser/launch.d.ts +50 -1
- package/dist/types/tools/browser/registry.d.ts +7 -0
- package/dist/types/tools/browser/shared-daemon.d.ts +26 -0
- package/dist/types/tools/browser/tab-protocol.d.ts +8 -0
- package/dist/types/tools/browser/tab-supervisor.d.ts +1 -0
- package/dist/types/tools/browser/tab-worker.d.ts +3 -1
- package/dist/types/tools/shell-tokenize.d.ts +11 -0
- package/dist/types/utils/image-loading.d.ts +2 -0
- package/dist/types/web/search/providers/duckduckgo.d.ts +9 -0
- package/dist/types/web/search/types.d.ts +2 -2
- package/package.json +12 -12
- package/src/commit/agentic/index.ts +2 -1
- package/src/commit/pipeline.ts +3 -2
- package/src/config/keybindings.ts +6 -1
- package/src/config/settings-schema.ts +16 -3
- package/src/edit/hashline/block-resolver.ts +5 -5
- package/src/edit/hashline/diff.ts +22 -15
- package/src/edit/hashline/execute.ts +14 -13
- package/src/edit/renderer.ts +3 -3
- package/src/eval/executor-base.ts +39 -0
- package/src/eval/jl/executor.ts +13 -2
- package/src/eval/js/context-manager.ts +97 -14
- package/src/eval/js/executor.ts +3 -0
- package/src/eval/js/index.ts +1 -0
- package/src/eval/py/executor.ts +55 -15
- package/src/eval/rb/executor.ts +8 -1
- package/src/extensibility/extensions/runner.ts +121 -4
- package/src/extensibility/extensions/types.ts +15 -0
- package/src/extensibility/extensions/wrapper.ts +19 -2
- package/src/extensibility/legacy-pi-coding-agent-shim.ts +5 -5
- package/src/launch/broker.ts +59 -41
- package/src/live/visualizer.ts +8 -1
- package/src/lsp/index.ts +51 -11
- package/src/main.ts +3 -0
- package/src/modes/acp/acp-agent.ts +16 -5
- package/src/modes/components/ask-dialog.ts +65 -23
- package/src/modes/components/assistant-message.ts +5 -10
- package/src/modes/components/custom-editor.ts +1 -1
- package/src/modes/components/keybinding-hints.ts +4 -13
- package/src/modes/components/status-line/segments.ts +4 -5
- package/src/modes/components/tool-execution.ts +4 -5
- package/src/modes/controllers/input-controller.ts +18 -40
- package/src/modes/controllers/live-command-controller.ts +1 -0
- package/src/modes/controllers/mcp-command-controller.ts +28 -11
- package/src/modes/interactive-mode.ts +59 -2
- package/src/modes/theme/theme.ts +13 -6
- package/src/modes/types.ts +2 -0
- package/src/modes/utils/hotkeys-markdown.ts +1 -0
- package/src/prompts/system/plan-mode-active.md +5 -5
- package/src/sdk.ts +47 -16
- package/src/secrets/index.ts +83 -3
- package/src/secrets/obfuscator.ts +70 -15
- package/src/session/agent-session-types.ts +7 -1
- package/src/session/agent-session.ts +394 -113
- package/src/session/codex-auto-reset.ts +594 -123
- package/src/session/eval-runner.ts +2 -0
- package/src/session/messages.ts +1 -1
- package/src/session/session-maintenance.ts +50 -3
- package/src/session/session-manager.ts +4 -3
- package/src/session/session-stats.ts +14 -3
- package/src/session/session-storage.ts +47 -15
- package/src/session/session-tools.ts +30 -2
- package/src/session/turn-recovery.ts +15 -2
- package/src/slash-commands/builtin-registry.ts +21 -15
- package/src/slash-commands/helpers/reset-usage.ts +2 -0
- package/src/system-prompt.ts +13 -1
- package/src/task/executor.ts +168 -15
- package/src/task/persisted-revive.ts +26 -1
- package/src/telemetry-export.ts +8 -4
- package/src/tools/bash-interceptor.ts +85 -8
- package/src/tools/bash.ts +12 -6
- package/src/tools/browser/attach.ts +35 -10
- package/src/tools/browser/launch.ts +110 -16
- package/src/tools/browser/registry.ts +140 -31
- package/src/tools/browser/shared-daemon.ts +190 -0
- package/src/tools/browser/tab-protocol.ts +8 -0
- package/src/tools/browser/tab-supervisor.ts +31 -4
- package/src/tools/browser/tab-worker.ts +32 -2
- package/src/tools/browser.ts +1 -1
- package/src/tools/output-meta.ts +16 -1
- package/src/tools/shell-tokenize.ts +101 -0
- package/src/utils/image-loading.ts +8 -3
- package/src/utils/shell-snapshot.ts +31 -21
- package/src/web/search/providers/codex.ts +31 -6
- package/src/web/search/providers/duckduckgo.ts +183 -15
- 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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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=${
|
|
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
|
|
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
|
-
|
|
323
|
-
|
|
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 {
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
if (
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|