pi-browser-harness 0.8.3 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +50 -0
- package/README.md +242 -181
- package/package.json +1 -1
- package/skills/deep-research/SKILL.md +66 -0
- package/skills/pi-browser-harness/SKILL.md +23 -0
- package/src/cdp/discovery.ts +183 -37
- package/src/cdp/ownership.ts +19 -1
- package/src/cdp/session.ts +74 -0
- package/src/cdp/target-factory.ts +223 -0
- package/src/cdp/window-binding.ts +15 -0
- package/src/client.ts +114 -27
- package/src/daemon/protocol.ts +12 -2
- package/src/daemon/spawn.ts +46 -13
- package/src/deep-research.ts +41 -0
- package/src/domains/forms.ts +355 -0
- package/src/domains/isolated-tab.ts +120 -0
- package/src/domains/navigate.ts +21 -13
- package/src/domains/readpage/capture.ts +46 -0
- package/src/domains/readpage/read-page.ts +119 -0
- package/src/domains/readpage/readability.ts +89 -0
- package/src/domains/render.ts +64 -0
- package/src/domains/search/extract.ts +44 -0
- package/src/domains/search/google-serp.ts +114 -0
- package/src/domains/search/web-search.ts +116 -0
- package/src/domains/setup.ts +4 -2
- package/src/index.ts +33 -7
- package/src/profile/bind.ts +138 -0
- package/src/profile/browser-process.ts +371 -0
- package/src/profile/command.ts +168 -0
- package/src/profile/launch.ts +90 -0
- package/src/profile/list.ts +171 -0
- package/src/profile/paths.ts +166 -0
- package/src/profile/picker.ts +73 -0
- package/src/profile/store.ts +105 -0
- package/src/prompt.ts +92 -116
- package/src/registry.ts +6 -0
- package/src/setup.ts +28 -44
- package/src/state.ts +1 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single place harness tabs and windows are created.
|
|
3
|
+
*
|
|
4
|
+
* Every tab the harness opens must land in the pinned profile, so all creation
|
|
5
|
+
* paths (client.newTab, browser_open_urls, isolated tabs for web_search /
|
|
6
|
+
* read_page) funnel through here. A missed call site would silently act in
|
|
7
|
+
* another profile — with that profile's cookies and logins.
|
|
8
|
+
*
|
|
9
|
+
* Two mechanisms, chosen by whether a profile is pinned:
|
|
10
|
+
*
|
|
11
|
+
* unpinned — `Target.createTarget`, exactly as before: `newWindow: true` for
|
|
12
|
+
* a fresh dedicated window, `openerId` for siblings inside it.
|
|
13
|
+
*
|
|
14
|
+
* pinned — the window comes from a command-line launch into the profile
|
|
15
|
+
* (see profile/launch.ts), and further tabs come from
|
|
16
|
+
* `window.open(..., '_blank')` evaluated in an owned page with
|
|
17
|
+
* `userGesture: true`. That is the only CDP-reachable way to place
|
|
18
|
+
* a tab in a non-default browser context: createTarget rejects a
|
|
19
|
+
* foreign browserContextId outright, and `openerId` does NOT
|
|
20
|
+
* inherit the opener's context (verified — it lands in the
|
|
21
|
+
* focus-derived default context, in a different window).
|
|
22
|
+
* window.open keeps both the context and the window, recursively.
|
|
23
|
+
*
|
|
24
|
+
* Correlation: window.open returns no target id, so each spawn carries a unique
|
|
25
|
+
* `about:blank#pi-<uuid>` token and the new target is found by that token.
|
|
26
|
+
* Set-difference polling would be wrong here — isolated tabs run concurrently
|
|
27
|
+
* (browser_read_page is registered unserialized).
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { randomUUID } from "node:crypto";
|
|
31
|
+
import type { BrowserClient } from "../client";
|
|
32
|
+
import { type Result, err, ok } from "../util/result";
|
|
33
|
+
import { type CdpError, cdpError } from "./errors";
|
|
34
|
+
|
|
35
|
+
/** How long to wait for a window.open-spawned target to appear. */
|
|
36
|
+
const SPAWN_DEADLINE_MS = 8_000;
|
|
37
|
+
const SPAWN_POLL_MS = 100;
|
|
38
|
+
|
|
39
|
+
export type HarnessWindow = {
|
|
40
|
+
/** Target id of the window's seed tab. */
|
|
41
|
+
readonly targetId: string;
|
|
42
|
+
/**
|
|
43
|
+
* True when this call created the window. The seed tab is then a blank tab
|
|
44
|
+
* the caller may use directly, instead of opening a second one.
|
|
45
|
+
*/
|
|
46
|
+
readonly freshlyCreated: boolean;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
type PageTarget = {
|
|
50
|
+
readonly targetId: string;
|
|
51
|
+
readonly type: string;
|
|
52
|
+
readonly url: string;
|
|
53
|
+
readonly browserContextId?: string;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const listPageTargets = async (client: BrowserClient): Promise<Result<ReadonlyArray<PageTarget>, CdpError>> => {
|
|
57
|
+
const r = await client.session().callBrowser("Target.getTargets");
|
|
58
|
+
if (!r.success) return r;
|
|
59
|
+
const data = r.data as { targetInfos: ReadonlyArray<PageTarget> };
|
|
60
|
+
return ok(data.targetInfos.filter((t) => t.type === "page"));
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** A CDP session id for driving `targetId`, reusing the active one when possible. */
|
|
64
|
+
const sessionForTarget = async (client: BrowserClient, targetId: string): Promise<Result<string, CdpError>> => {
|
|
65
|
+
const current = client.current();
|
|
66
|
+
if (current?.targetId === targetId) return ok(current.sessionId);
|
|
67
|
+
const attached = await client.session().callBrowser("Target.attachToTarget", { targetId, flatten: true });
|
|
68
|
+
if (!attached.success) return attached;
|
|
69
|
+
return ok((attached.data as { sessionId: string }).sessionId);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Spawn a tab from `openerTargetId` via window.open, so it inherits the
|
|
74
|
+
* opener's profile and window, and return its target id.
|
|
75
|
+
*/
|
|
76
|
+
const spawnTabViaOpener = async (
|
|
77
|
+
client: BrowserClient,
|
|
78
|
+
openerTargetId: string,
|
|
79
|
+
): Promise<Result<string, CdpError>> => {
|
|
80
|
+
const session = await sessionForTarget(client, openerTargetId);
|
|
81
|
+
if (!session.success) return session;
|
|
82
|
+
|
|
83
|
+
const token = `pi-${randomUUID()}`;
|
|
84
|
+
// userGesture: true is what gets this past the popup blocker.
|
|
85
|
+
const opened = await client.session().callOnTarget(
|
|
86
|
+
"Runtime.evaluate",
|
|
87
|
+
{
|
|
88
|
+
expression: `!!window.open('about:blank#${token}', '_blank')`,
|
|
89
|
+
returnByValue: true,
|
|
90
|
+
userGesture: true,
|
|
91
|
+
},
|
|
92
|
+
session.data,
|
|
93
|
+
);
|
|
94
|
+
if (!opened.success) return opened;
|
|
95
|
+
const result = opened.data as { result?: { value?: unknown } };
|
|
96
|
+
if (result.result?.value !== true) {
|
|
97
|
+
return err(cdpError(
|
|
98
|
+
"invalid_response",
|
|
99
|
+
"the browser blocked window.open in the pinned profile's window — the page may have navigated away; retry, or run /browser-profile to re-seed",
|
|
100
|
+
"Runtime.evaluate",
|
|
101
|
+
));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const deadline = Date.now() + SPAWN_DEADLINE_MS;
|
|
105
|
+
while (Date.now() < deadline) {
|
|
106
|
+
const targets = await listPageTargets(client);
|
|
107
|
+
if (targets.success) {
|
|
108
|
+
const spawned = targets.data.find((t) => t.url.includes(token));
|
|
109
|
+
if (spawned) return ok(spawned.targetId);
|
|
110
|
+
}
|
|
111
|
+
await new Promise((r) => setTimeout(r, SPAWN_POLL_MS));
|
|
112
|
+
}
|
|
113
|
+
return err(cdpError("timeout", `the tab opened in the pinned profile did not report back within ${SPAWN_DEADLINE_MS}ms`));
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* In-flight window creation, per client. Isolated tabs run concurrently
|
|
118
|
+
* (browser_read_page is registered unserialized), so two of them starting at
|
|
119
|
+
* once would otherwise each find "no harness window" and create one — two blank
|
|
120
|
+
* windows before, and two launched profile windows now. Callers share the first
|
|
121
|
+
* call's result instead.
|
|
122
|
+
*/
|
|
123
|
+
const inFlight = new WeakMap<BrowserClient, Promise<Result<HarnessWindow, CdpError>>>();
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The harness window for this session, creating it when absent.
|
|
127
|
+
*
|
|
128
|
+
* Reuses a live owned tab when one exists — including after the user closed the
|
|
129
|
+
* original seed — so reconnects don't scatter windows.
|
|
130
|
+
*/
|
|
131
|
+
export const ensureHarnessWindow = async (client: BrowserClient): Promise<Result<HarnessWindow, CdpError>> => {
|
|
132
|
+
const pending = inFlight.get(client);
|
|
133
|
+
if (pending) {
|
|
134
|
+
const shared = await pending;
|
|
135
|
+
// `freshlyCreated` is a claim on the seed tab, and only the caller that
|
|
136
|
+
// started the creation may hold it. Everyone waiting on the same window
|
|
137
|
+
// must open a tab of their own beside it, or two callers would drive the
|
|
138
|
+
// very same tab.
|
|
139
|
+
return shared.success ? ok({ targetId: shared.data.targetId, freshlyCreated: false }) : shared;
|
|
140
|
+
}
|
|
141
|
+
const attempt = ensureHarnessWindowUncoordinated(client).finally(() => inFlight.delete(client));
|
|
142
|
+
inFlight.set(client, attempt);
|
|
143
|
+
return attempt;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const ensureHarnessWindowUncoordinated = async (
|
|
147
|
+
client: BrowserClient,
|
|
148
|
+
): Promise<Result<HarnessWindow, CdpError>> => {
|
|
149
|
+
const targets = await listPageTargets(client);
|
|
150
|
+
if (!targets.success) return targets;
|
|
151
|
+
const live = new Set(targets.data.map((t) => t.targetId));
|
|
152
|
+
|
|
153
|
+
const ownership = client.ownership();
|
|
154
|
+
const pinnedCtx = client.profileContextId();
|
|
155
|
+
// A tab may only be reused when we can prove which profile it is in. With a
|
|
156
|
+
// pin set but no context resolved yet — a fresh connection, or a browser that
|
|
157
|
+
// restarted — the recorded tabs cannot be verified, so we seed a new window
|
|
158
|
+
// rather than risk adopting one from the wrong profile. (Sessions close their
|
|
159
|
+
// owned tabs on shutdown, so this is the normal, no-cost case.)
|
|
160
|
+
const canReuse = client.profilePin() === null || pinnedCtx !== undefined;
|
|
161
|
+
const inPinnedProfile = (targetId: string): boolean => {
|
|
162
|
+
if (!pinnedCtx) return true;
|
|
163
|
+
const info = targets.data.find((t) => t.targetId === targetId);
|
|
164
|
+
return info?.browserContextId === pinnedCtx;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
if (canReuse) {
|
|
168
|
+
const harnessWindow = ownership.harnessWindow();
|
|
169
|
+
if (harnessWindow && live.has(harnessWindow) && inPinnedProfile(harnessWindow)) {
|
|
170
|
+
return ok({ targetId: harnessWindow, freshlyCreated: false });
|
|
171
|
+
}
|
|
172
|
+
const survivor = ownership.list().find((id) => live.has(id) && inPinnedProfile(id));
|
|
173
|
+
if (survivor) {
|
|
174
|
+
ownership.setHarnessWindow(survivor);
|
|
175
|
+
return ok({ targetId: survivor, freshlyCreated: false });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const seeded = client.profilePin()
|
|
180
|
+
? await client.seedPinnedProfileWindow()
|
|
181
|
+
: await createDedicatedWindow(client);
|
|
182
|
+
if (!seeded.success) return seeded;
|
|
183
|
+
return ok({ targetId: seeded.data, freshlyCreated: true });
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
/** Unpinned path: a fresh dedicated window, exactly as the harness always did. */
|
|
187
|
+
const createDedicatedWindow = async (client: BrowserClient): Promise<Result<string, CdpError>> => {
|
|
188
|
+
const created = await client.session().callBrowser("Target.createTarget", { url: "about:blank", newWindow: true });
|
|
189
|
+
if (!created.success) return created;
|
|
190
|
+
const { targetId } = created.data as { targetId: string };
|
|
191
|
+
const ownership = client.ownership();
|
|
192
|
+
ownership.setHarnessWindow(targetId);
|
|
193
|
+
ownership.add(targetId);
|
|
194
|
+
const win = await client.session().callBrowser("Browser.getWindowForTarget", { targetId });
|
|
195
|
+
if (win.success) {
|
|
196
|
+
const windowId = (win.data as { windowId?: number }).windowId;
|
|
197
|
+
if (typeof windowId === "number") ownership.setHarnessWindowId(windowId);
|
|
198
|
+
}
|
|
199
|
+
return ok(targetId);
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Open a harness-owned tab beside `openerTargetId`, in the same window and —
|
|
204
|
+
* when a profile is pinned — the same profile. The tab is registered as owned.
|
|
205
|
+
*/
|
|
206
|
+
export const openHarnessTab = async (
|
|
207
|
+
client: BrowserClient,
|
|
208
|
+
openerTargetId: string,
|
|
209
|
+
): Promise<Result<string, CdpError>> => {
|
|
210
|
+
const spawned = client.profileContextId()
|
|
211
|
+
? await spawnTabViaOpener(client, openerTargetId)
|
|
212
|
+
: await (async (): Promise<Result<string, CdpError>> => {
|
|
213
|
+
const created = await client.session().callBrowser("Target.createTarget", {
|
|
214
|
+
url: "about:blank",
|
|
215
|
+
openerId: openerTargetId,
|
|
216
|
+
});
|
|
217
|
+
if (!created.success) return created;
|
|
218
|
+
return ok((created.data as { targetId: string }).targetId);
|
|
219
|
+
})();
|
|
220
|
+
if (!spawned.success) return spawned;
|
|
221
|
+
client.ownership().add(spawned.data);
|
|
222
|
+
return ok(spawned.data);
|
|
223
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { BrowserClient } from "../client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Capture the real Chrome windowId of a freshly-created harness window and bind
|
|
5
|
+
* it as the session's durable window identity. Best-effort: a failed CDP call
|
|
6
|
+
* leaves the previous binding untouched. Call this on every code path that
|
|
7
|
+
* spawns a NEW dedicated window (newWindow:true), so the windowId never points
|
|
8
|
+
* at a window that has since been replaced.
|
|
9
|
+
*/
|
|
10
|
+
export const bindHarnessWindowId = async (client: BrowserClient, targetId: string): Promise<void> => {
|
|
11
|
+
const r = await client.session().callBrowser("Browser.getWindowForTarget", { targetId });
|
|
12
|
+
if (!r.success) return;
|
|
13
|
+
const wid = (r.data as { windowId?: number }).windowId;
|
|
14
|
+
if (typeof wid === "number") client.ownership().setHarnessWindowId(wid);
|
|
15
|
+
};
|
package/src/client.ts
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { type Result, err, ok } from "./util/result";
|
|
2
2
|
import { safeJs } from "./util/js-template";
|
|
3
3
|
import { type Mutex, createMutex } from "./util/mutex";
|
|
4
|
-
import {
|
|
4
|
+
import { discoverEndpoint } from "./cdp/discovery";
|
|
5
5
|
import { type CdpError, cdpError } from "./cdp/errors";
|
|
6
6
|
import type { CdpTransport } from "./cdp/transport";
|
|
7
7
|
import { createCdpTransport } from "./cdp/transport";
|
|
8
8
|
import { type CdpSession, createCdpSession } from "./cdp/session";
|
|
9
9
|
import { type OwnershipRegistry, createOwnershipRegistry } from "./cdp/ownership";
|
|
10
|
+
import { ensureHarnessWindow, openHarnessTab } from "./cdp/target-factory";
|
|
11
|
+
import { seedProfileWindow } from "./profile/bind";
|
|
12
|
+
import type { ProfilePin } from "./profile/store";
|
|
10
13
|
import type { DaemonStatus, DialogInfo, PageInfo, TabInfo } from "./cdp/types";
|
|
11
14
|
|
|
12
15
|
export type BrowserClientOptions = {
|
|
@@ -16,10 +19,14 @@ export type BrowserClientOptions = {
|
|
|
16
19
|
readonly initialOwnership?: {
|
|
17
20
|
readonly ownedTargetIds?: ReadonlyArray<string>;
|
|
18
21
|
readonly harnessWindowTargetId?: string;
|
|
22
|
+
readonly harnessWindowId?: number;
|
|
19
23
|
};
|
|
24
|
+
/** Profile to pin this session to, loaded from the durable pin store. */
|
|
25
|
+
readonly profilePin?: ProfilePin | null;
|
|
20
26
|
readonly onOwnershipChange?: (snapshot: {
|
|
21
27
|
readonly ownedTargetIds: ReadonlyArray<string>;
|
|
22
28
|
readonly harnessWindowTargetId: string | undefined;
|
|
29
|
+
readonly harnessWindowId: number | undefined;
|
|
23
30
|
}) => void;
|
|
24
31
|
};
|
|
25
32
|
|
|
@@ -32,6 +39,9 @@ export type BrowserClient = {
|
|
|
32
39
|
/** Detach from the current page target (removes the "Chrome is being controlled" banner)
|
|
33
40
|
* while keeping the transport connection alive. Call on session shutdown. */
|
|
34
41
|
detach(): Promise<void>;
|
|
42
|
+
/** Close every tab this session owns and clear the window binding. Best-effort —
|
|
43
|
+
* used on session shutdown so no stale harness tabs survive. */
|
|
44
|
+
closeOwnedTabs(): Promise<void>;
|
|
35
45
|
evaluateJs(expression: string, sessionId?: string): Promise<Result<unknown, CdpError>>;
|
|
36
46
|
pageInfo(): Promise<Result<PageInfo | { readonly dialog: DialogInfo }, CdpError>>;
|
|
37
47
|
takeDialog(): DialogInfo | null;
|
|
@@ -44,6 +54,19 @@ export type BrowserClient = {
|
|
|
44
54
|
current(): { readonly sessionId: string; readonly targetId: string } | null;
|
|
45
55
|
session(): CdpSession;
|
|
46
56
|
transport(): CdpTransport;
|
|
57
|
+
/** User-data-dir of the connected browser, when discovery could determine it.
|
|
58
|
+
* Undefined for BU_CDP_WS / bare-port connections, where profile selection
|
|
59
|
+
* is unavailable. */
|
|
60
|
+
userDataDir(): string | undefined;
|
|
61
|
+
/** The profile this session is pinned to, or null when unpinned. */
|
|
62
|
+
profilePin(): ProfilePin | null;
|
|
63
|
+
setProfilePin(pin: ProfilePin | null): void;
|
|
64
|
+
/** browserContextId of the pinned profile, once a window has identified it.
|
|
65
|
+
* Session-scoped: Chrome re-mints context ids on every browser run. */
|
|
66
|
+
profileContextId(): string | undefined;
|
|
67
|
+
setProfileContextId(contextId: string | undefined): void;
|
|
68
|
+
/** Open and adopt a window inside the pinned profile. Returns its seed tab. */
|
|
69
|
+
seedPinnedProfileWindow(): Promise<Result<string, CdpError>>;
|
|
47
70
|
/** Returns the shared async mutex that serialized browser tools must acquire
|
|
48
71
|
* before performing mutations. Observation tools should not use this. */
|
|
49
72
|
mutationMutex(): Mutex;
|
|
@@ -82,13 +105,16 @@ const parsePageInfoPayload = (v: unknown): Result<PageInfo, CdpError> => {
|
|
|
82
105
|
|
|
83
106
|
export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient => {
|
|
84
107
|
const transport = opts.transport ?? createCdpTransport();
|
|
85
|
-
const ownershipInit: { ownedTargetIds?: ReadonlyArray<string>; harnessWindowTargetId?: string } = {};
|
|
108
|
+
const ownershipInit: { ownedTargetIds?: ReadonlyArray<string>; harnessWindowTargetId?: string; harnessWindowId?: number } = {};
|
|
86
109
|
if (opts.initialOwnership?.ownedTargetIds !== undefined) {
|
|
87
110
|
ownershipInit.ownedTargetIds = opts.initialOwnership.ownedTargetIds;
|
|
88
111
|
}
|
|
89
112
|
if (opts.initialOwnership?.harnessWindowTargetId !== undefined) {
|
|
90
113
|
ownershipInit.harnessWindowTargetId = opts.initialOwnership.harnessWindowTargetId;
|
|
91
114
|
}
|
|
115
|
+
if (opts.initialOwnership?.harnessWindowId !== undefined) {
|
|
116
|
+
ownershipInit.harnessWindowId = opts.initialOwnership.harnessWindowId;
|
|
117
|
+
}
|
|
92
118
|
const ownership = createOwnershipRegistry(ownershipInit);
|
|
93
119
|
if (opts.onOwnershipChange) {
|
|
94
120
|
const cb = opts.onOwnershipChange;
|
|
@@ -96,14 +122,37 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
|
|
|
96
122
|
cb({
|
|
97
123
|
ownedTargetIds: ownership.list(),
|
|
98
124
|
harnessWindowTargetId: ownership.harnessWindow(),
|
|
125
|
+
harnessWindowId: ownership.harnessWindowId(),
|
|
99
126
|
});
|
|
100
127
|
});
|
|
101
128
|
}
|
|
102
|
-
|
|
129
|
+
// The seed provider routes attachFirstPage's fallback through the target
|
|
130
|
+
// factory, so a pinned profile is honoured on every path that can create the
|
|
131
|
+
// harness window.
|
|
132
|
+
const session = createCdpSession(transport, ownership, async () => {
|
|
133
|
+
const window = await ensureHarnessWindow(api);
|
|
134
|
+
return window.success ? ok(window.data.targetId) : window;
|
|
135
|
+
});
|
|
103
136
|
const mutationMutex = createMutex();
|
|
104
137
|
let lastHealth = 0;
|
|
105
138
|
let pageCaches = new Map<string, { readonly info: PageInfo; readonly at: number }>();
|
|
106
139
|
let remote: BrowserClientOptions["remote"] | null = opts.remote ?? null;
|
|
140
|
+
let userDataDir: string | undefined;
|
|
141
|
+
let profilePin: ProfilePin | null = opts.profilePin ?? null;
|
|
142
|
+
let profileContextId: string | undefined;
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* With a profile pinned, the harness window must exist BEFORE anything
|
|
146
|
+
* attaches: session.attachFirstPage() would otherwise fall back to a bare
|
|
147
|
+
* Target.createTarget, which lands in the focus-derived default profile.
|
|
148
|
+
* Seeding first means attachFirstPage always finds an owned tab to attach to.
|
|
149
|
+
*/
|
|
150
|
+
const seedIfPinned = async (): Promise<Result<void, CdpError>> => {
|
|
151
|
+
if (!profilePin) return ok(undefined);
|
|
152
|
+
const window = await ensureHarnessWindow(api);
|
|
153
|
+
if (!window.success) return window;
|
|
154
|
+
return ok(undefined);
|
|
155
|
+
};
|
|
107
156
|
|
|
108
157
|
const start = async (): Promise<Result<void, CdpError>> => {
|
|
109
158
|
if (transport.state() === "open" && session.current()) return ok(undefined);
|
|
@@ -114,15 +163,23 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
|
|
|
114
163
|
} else if (envUrl) {
|
|
115
164
|
wsUrl = envUrl;
|
|
116
165
|
} else {
|
|
117
|
-
const discovered = await
|
|
166
|
+
const discovered = await discoverEndpoint();
|
|
118
167
|
if (!discovered.success) return discovered;
|
|
119
|
-
wsUrl = discovered.data;
|
|
168
|
+
wsUrl = discovered.data.wsUrl;
|
|
169
|
+
userDataDir = discovered.data.userDataDir;
|
|
120
170
|
}
|
|
121
171
|
const connected = await transport.connect(wsUrl, { timeoutMs: 10_000 });
|
|
122
172
|
if (!connected.success) return connected;
|
|
123
173
|
if (!remote) {
|
|
124
174
|
remote = { cdpUrl: wsUrl, browserId: wsUrl.split("/").pop() ?? "unknown" };
|
|
125
175
|
}
|
|
176
|
+
// A fresh browser run invalidates any context id resolved earlier.
|
|
177
|
+
profileContextId = undefined;
|
|
178
|
+
const seeded = await seedIfPinned();
|
|
179
|
+
if (!seeded.success) {
|
|
180
|
+
await transport.close();
|
|
181
|
+
return seeded;
|
|
182
|
+
}
|
|
126
183
|
const attached = await session.attachFirstPage();
|
|
127
184
|
if (!attached.success) {
|
|
128
185
|
await transport.close();
|
|
@@ -157,6 +214,10 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
|
|
|
157
214
|
expression: "1", returnByValue: true,
|
|
158
215
|
}, { timeoutMs: 2_000 });
|
|
159
216
|
if (!jsProbe.success && jsProbe.error.kind === "session_not_found") {
|
|
217
|
+
// Re-seed first when pinned: if the user closed every harness tab,
|
|
218
|
+
// attachFirstPage would mint a window in the focus-derived profile.
|
|
219
|
+
const seeded = await seedIfPinned();
|
|
220
|
+
if (!seeded.success) return seeded;
|
|
160
221
|
const reattached = await session.attachFirstPage();
|
|
161
222
|
if (reattached.success) {
|
|
162
223
|
pageCaches.clear();
|
|
@@ -257,37 +318,33 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
|
|
|
257
318
|
};
|
|
258
319
|
|
|
259
320
|
const newTab = async (url?: string): Promise<Result<string, CdpError>> => {
|
|
260
|
-
//
|
|
261
|
-
// target list also gives listTabs's reconciliation a chance to run.
|
|
321
|
+
// Reconcile ownership against live targets before deciding where to open.
|
|
262
322
|
const tabsResult = await listTabs(true);
|
|
263
323
|
if (!tabsResult.success) return tabsResult;
|
|
264
|
-
const live = new Set(tabsResult.data.map((t) => t.targetId));
|
|
265
|
-
const hw = ownership.harnessWindow();
|
|
266
324
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
325
|
+
// ensureHarnessWindow reuses the seed tab, falls back to any surviving
|
|
326
|
+
// owned tab, and only then creates a window — which, when a profile is
|
|
327
|
+
// pinned, means launching one inside that profile.
|
|
328
|
+
const window = await ensureHarnessWindow(api);
|
|
329
|
+
if (!window.success) return window;
|
|
330
|
+
// A freshly created window IS the new tab; opening another would leave a
|
|
331
|
+
// stray blank tab behind.
|
|
332
|
+
let tabId: string;
|
|
333
|
+
if (window.data.freshlyCreated) {
|
|
334
|
+
tabId = window.data.targetId;
|
|
272
335
|
} else {
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
336
|
+
const opened = await openHarnessTab(api, window.data.targetId);
|
|
337
|
+
if (!opened.success) return opened;
|
|
338
|
+
tabId = opened.data;
|
|
276
339
|
}
|
|
277
|
-
const created = await session.callBrowser("Target.createTarget", params);
|
|
278
|
-
if (!created.success) return created;
|
|
279
|
-
const c = created.data as { targetId: string };
|
|
280
|
-
|
|
281
|
-
if (params["newWindow"]) ownership.setHarnessWindow(c.targetId);
|
|
282
|
-
ownership.add(c.targetId);
|
|
283
340
|
|
|
284
|
-
const switched = await switchTab(
|
|
341
|
+
const switched = await switchTab(tabId);
|
|
285
342
|
if (!switched.success) return switched;
|
|
286
343
|
if (url && url !== "about:blank") {
|
|
287
344
|
const nav = await session.call("Page.navigate", { url });
|
|
288
345
|
if (!nav.success) return nav;
|
|
289
346
|
}
|
|
290
|
-
return ok(
|
|
347
|
+
return ok(tabId);
|
|
291
348
|
};
|
|
292
349
|
|
|
293
350
|
const closeTab = async (targetId: string): Promise<Result<void, CdpError>> => {
|
|
@@ -297,6 +354,18 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
|
|
|
297
354
|
return ok(undefined);
|
|
298
355
|
};
|
|
299
356
|
|
|
357
|
+
const closeOwnedTabs = async (): Promise<void> => {
|
|
358
|
+
// Snapshot first — remove() mutates the list, and targetDestroyed events
|
|
359
|
+
// may also prune concurrently. Failures are swallowed: a tab the user
|
|
360
|
+
// already closed, or a dead transport, must not block shutdown.
|
|
361
|
+
for (const id of ownership.list()) {
|
|
362
|
+
await session.callBrowser("Target.closeTarget", { targetId: id }).catch(() => {});
|
|
363
|
+
ownership.remove(id);
|
|
364
|
+
}
|
|
365
|
+
ownership.setHarnessWindow(undefined);
|
|
366
|
+
ownership.setHarnessWindowId(undefined);
|
|
367
|
+
};
|
|
368
|
+
|
|
300
369
|
const status = (): DaemonStatus => ({
|
|
301
370
|
alive: transport.state() === "open" && session.current() !== null,
|
|
302
371
|
sessionId: session.current()?.sessionId ?? null,
|
|
@@ -314,9 +383,9 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
|
|
|
314
383
|
await transport.request("Target.detachFromTarget", { sessionId: cur.sessionId }, { sessionId: null });
|
|
315
384
|
};
|
|
316
385
|
|
|
317
|
-
|
|
386
|
+
const api: BrowserClient = {
|
|
318
387
|
namespace: opts.namespace,
|
|
319
|
-
ensureAlive, status, start, stop, detach,
|
|
388
|
+
ensureAlive, status, start, stop, detach, closeOwnedTabs,
|
|
320
389
|
evaluateJs, pageInfo,
|
|
321
390
|
takeDialog: () => session.takeDialog(),
|
|
322
391
|
listTabs, switchTab, newTab, closeTab,
|
|
@@ -325,6 +394,24 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
|
|
|
325
394
|
current: () => session.current(),
|
|
326
395
|
session: () => session,
|
|
327
396
|
transport: () => transport,
|
|
397
|
+
userDataDir: () => userDataDir,
|
|
398
|
+
profilePin: () => profilePin,
|
|
399
|
+
setProfilePin: (pin) => {
|
|
400
|
+
profilePin = pin;
|
|
401
|
+
// The old profile's context no longer describes where tabs should go.
|
|
402
|
+
profileContextId = undefined;
|
|
403
|
+
},
|
|
404
|
+
profileContextId: () => profileContextId,
|
|
405
|
+
setProfileContextId: (contextId) => {
|
|
406
|
+
profileContextId = contextId;
|
|
407
|
+
},
|
|
408
|
+
seedPinnedProfileWindow: async () => {
|
|
409
|
+
if (!profilePin) {
|
|
410
|
+
return err(cdpError("discovery_failed", "no browser profile is selected — run /browser-profile"));
|
|
411
|
+
}
|
|
412
|
+
return seedProfileWindow(api, profilePin);
|
|
413
|
+
},
|
|
328
414
|
mutationMutex: () => mutationMutex,
|
|
329
415
|
};
|
|
416
|
+
return api;
|
|
330
417
|
};
|
package/src/daemon/protocol.ts
CHANGED
|
@@ -133,8 +133,18 @@ export const deserialize = (line: string): WireMessage | null => {
|
|
|
133
133
|
|
|
134
134
|
// ── Constants ──────────────────────────────────────────────────────────────────
|
|
135
135
|
|
|
136
|
-
/**
|
|
137
|
-
|
|
136
|
+
/**
|
|
137
|
+
* IPC endpoint for the daemon.
|
|
138
|
+
*
|
|
139
|
+
* On POSIX platforms this is a Unix domain socket path. On Windows, Node's IPC
|
|
140
|
+
* requires a named pipe of the form `\\.\pipe\<name>` — a Unix path like
|
|
141
|
+
* `/tmp/…` is not a valid `net` listen/connect target there and causes setup
|
|
142
|
+
* to fail. See https://nodejs.org/api/net.html#ipc-support.
|
|
143
|
+
*/
|
|
144
|
+
export const DAEMON_SOCKET_PATH =
|
|
145
|
+
process.platform === "win32"
|
|
146
|
+
? "\\\\.\\pipe\\pi-browser-daemon"
|
|
147
|
+
: "/tmp/pi-browser-daemon.sock";
|
|
138
148
|
|
|
139
149
|
/**
|
|
140
150
|
* How long the daemon stays alive with zero connected clients before exiting
|
package/src/daemon/spawn.ts
CHANGED
|
@@ -19,6 +19,19 @@ import { DAEMON_SOCKET_PATH, serialize, type WireControl } from "./protocol";
|
|
|
19
19
|
|
|
20
20
|
const moduleDir = dirname(fileURLToPath(import.meta.url));
|
|
21
21
|
|
|
22
|
+
const isWindows = process.platform === "win32";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Remove a stale POSIX socket file. On Windows the daemon endpoint is a named
|
|
26
|
+
* pipe, which is not a filesystem entry and is torn down automatically when the
|
|
27
|
+
* owning process exits — so there is nothing to unlink, and calling unlink on a
|
|
28
|
+
* pipe path throws. This is a no-op on Windows.
|
|
29
|
+
*/
|
|
30
|
+
const cleanupStaleSocket = (): void => {
|
|
31
|
+
if (isWindows) return;
|
|
32
|
+
unlink(DAEMON_SOCKET_PATH).catch(() => {});
|
|
33
|
+
};
|
|
34
|
+
|
|
22
35
|
/**
|
|
23
36
|
* Check whether the daemon is running by probing its Unix socket.
|
|
24
37
|
* A file-existence check is unreliable — a crashed daemon leaves a stale socket.
|
|
@@ -26,11 +39,15 @@ const moduleDir = dirname(fileURLToPath(import.meta.url));
|
|
|
26
39
|
* On failure, clean up the stale socket so ensureDaemon can spawn a fresh one.
|
|
27
40
|
*/
|
|
28
41
|
export const isDaemonRunning = async (timeoutMs = 2_000): Promise<boolean> => {
|
|
29
|
-
//
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
42
|
+
// On POSIX, a missing socket file means the daemon definitely isn't running,
|
|
43
|
+
// so we can short-circuit. Windows named pipes are not filesystem entries —
|
|
44
|
+
// access() always fails there — so skip the pre-check and probe directly.
|
|
45
|
+
if (!isWindows) {
|
|
46
|
+
try {
|
|
47
|
+
await access(DAEMON_SOCKET_PATH);
|
|
48
|
+
} catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
34
51
|
}
|
|
35
52
|
|
|
36
53
|
return new Promise((resolve) => {
|
|
@@ -45,7 +62,7 @@ export const isDaemonRunning = async (timeoutMs = 2_000): Promise<boolean> => {
|
|
|
45
62
|
const timer = setTimeout(() => {
|
|
46
63
|
sock.destroy();
|
|
47
64
|
// ponytail: stale socket cleanup; ENOENT = already gone, fine
|
|
48
|
-
|
|
65
|
+
cleanupStaleSocket();
|
|
49
66
|
done(false);
|
|
50
67
|
}, timeoutMs);
|
|
51
68
|
|
|
@@ -70,7 +87,7 @@ export const isDaemonRunning = async (timeoutMs = 2_000): Promise<boolean> => {
|
|
|
70
87
|
clearTimeout(timer);
|
|
71
88
|
sock.destroy();
|
|
72
89
|
// ponytail: connection refused = daemon dead, clean up stale socket
|
|
73
|
-
|
|
90
|
+
cleanupStaleSocket();
|
|
74
91
|
done(false);
|
|
75
92
|
});
|
|
76
93
|
|
|
@@ -96,18 +113,34 @@ export const isDaemonRunning = async (timeoutMs = 2_000): Promise<boolean> => {
|
|
|
96
113
|
export const spawnDaemon = (): ChildProcess | null => {
|
|
97
114
|
// Resolve the daemon entry point and tsx binary.
|
|
98
115
|
const daemonScript = join(moduleDir, "index.ts");
|
|
99
|
-
const tsxBin = join(moduleDir, "..", "..", "node_modules", ".bin", "tsx");
|
|
100
116
|
|
|
101
|
-
//
|
|
102
|
-
//
|
|
117
|
+
// On Windows, npm installs binaries as `.cmd` shims, not bare executables,
|
|
118
|
+
// and the npx fallback is `npx.cmd`. A `.cmd` file can only be launched
|
|
119
|
+
// through a shell (spawning it directly yields EINVAL), so set shell: true
|
|
120
|
+
// whenever we invoke one.
|
|
121
|
+
const tsxBinBase = join(moduleDir, "..", "..", "node_modules", ".bin", "tsx");
|
|
122
|
+
const tsxBin = isWindows ? `${tsxBinBase}.cmd` : tsxBinBase;
|
|
123
|
+
const hasLocalTsx = existsSync(tsxBin);
|
|
124
|
+
|
|
125
|
+
// Use the local tsx binary to run the daemon TypeScript source directly.
|
|
126
|
+
// The spawned process inherits the parent's env so module resolution works.
|
|
103
127
|
// Fall back to `npx tsx` if the local binary isn't available.
|
|
104
|
-
const cmd =
|
|
105
|
-
const args =
|
|
128
|
+
const cmd = hasLocalTsx ? tsxBin : isWindows ? "npx.cmd" : "npx";
|
|
129
|
+
const args = hasLocalTsx ? [daemonScript] : ["tsx", daemonScript];
|
|
130
|
+
|
|
131
|
+
// With shell: true, Node passes the command line to the shell verbatim
|
|
132
|
+
// without quoting, so any path containing spaces (e.g. C:\Users\Some Name)
|
|
133
|
+
// would be split into multiple tokens. Wrap each token in double quotes on
|
|
134
|
+
// Windows to keep space-containing paths intact.
|
|
135
|
+
const quote = (s: string): string => (isWindows ? `"${s}"` : s);
|
|
106
136
|
|
|
107
|
-
const child = spawn(cmd, args, {
|
|
137
|
+
const child = spawn(quote(cmd), args.map(quote), {
|
|
108
138
|
detached: true,
|
|
109
139
|
stdio: "ignore",
|
|
110
140
|
env: { ...process.env },
|
|
141
|
+
// `.cmd` shims (local tsx or npx.cmd on Windows) must run via a shell.
|
|
142
|
+
shell: isWindows,
|
|
143
|
+
windowsVerbatimArguments: isWindows,
|
|
111
144
|
});
|
|
112
145
|
|
|
113
146
|
child.unref();
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deep research — the /deep-research slash command. An explicit entry point
|
|
3
|
+
* (alongside the auto-triggering deep-research skill) that kicks off a research
|
|
4
|
+
* run for a given question.
|
|
5
|
+
*
|
|
6
|
+
* The command is thin: it validates a non-empty question and injects a user
|
|
7
|
+
* message that starts the deep-research flow. The actual orchestration
|
|
8
|
+
* (decompose → fan out web-search-researcher subagents → synthesize a cited
|
|
9
|
+
* report) lives in skills/deep-research/SKILL.md, which the injected prompt
|
|
10
|
+
* triggers. pi.sendUserMessage always starts a turn.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
14
|
+
|
|
15
|
+
// ── Shared core: build the prompt that starts a deep-research run ───────────
|
|
16
|
+
|
|
17
|
+
export function buildDeepResearchPrompt(question: string): string {
|
|
18
|
+
return (
|
|
19
|
+
`Do deep research on the following question and produce a cited Markdown report. ` +
|
|
20
|
+
`Use the deep-research skill: decompose it into sub-questions, fan out web-search-researcher ` +
|
|
21
|
+
`subagents to research each against the live web, then synthesize a source-cited report file.\n\n` +
|
|
22
|
+
`Question: ${question}`
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ── Public: register the /deep-research slash command ──────────────────────
|
|
27
|
+
|
|
28
|
+
export function registerDeepResearchCommand(pi: ExtensionAPI): void {
|
|
29
|
+
pi.registerCommand("deep-research", {
|
|
30
|
+
description: "Research a question on the web and write a cited report",
|
|
31
|
+
handler: async (args, ctx) => {
|
|
32
|
+
const question = args.trim();
|
|
33
|
+
if (question.length === 0) {
|
|
34
|
+
ctx.ui.notify('Usage: /deep-research <question>. Example: /deep-research "compare CDP vs WebDriver"', "warning");
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
ctx.ui.notify(`Starting deep research: ${question}`, "info");
|
|
38
|
+
pi.sendUserMessage(buildDeepResearchPrompt(question));
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
}
|