pi-browser-harness 0.8.1 → 0.9.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 +28 -0
- package/README.md +28 -7
- package/package.json +1 -1
- package/skills/deep-research/SKILL.md +66 -0
- package/skills/pi-browser-harness/SKILL.md +8 -0
- package/src/cdp/discovery.ts +171 -9
- package/src/cdp/ownership.ts +19 -1
- package/src/cdp/session.ts +55 -0
- package/src/cdp/window-binding.ts +15 -0
- package/src/client.ts +52 -9
- package/src/daemon/bridge.ts +13 -3
- package/src/daemon/protocol.ts +12 -2
- package/src/daemon/spawn.ts +101 -16
- package/src/deep-research.ts +41 -0
- package/src/domains/forms.ts +355 -0
- package/src/domains/isolated-tab.ts +121 -0
- package/src/domains/navigate.ts +25 -10
- 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/index.ts +17 -7
- package/src/prompt.ts +92 -116
- package/src/registry.ts +6 -0
- package/src/setup.ts +26 -10
- package/src/state.ts +1 -0
package/src/client.ts
CHANGED
|
@@ -16,10 +16,12 @@ export type BrowserClientOptions = {
|
|
|
16
16
|
readonly initialOwnership?: {
|
|
17
17
|
readonly ownedTargetIds?: ReadonlyArray<string>;
|
|
18
18
|
readonly harnessWindowTargetId?: string;
|
|
19
|
+
readonly harnessWindowId?: number;
|
|
19
20
|
};
|
|
20
21
|
readonly onOwnershipChange?: (snapshot: {
|
|
21
22
|
readonly ownedTargetIds: ReadonlyArray<string>;
|
|
22
23
|
readonly harnessWindowTargetId: string | undefined;
|
|
24
|
+
readonly harnessWindowId: number | undefined;
|
|
23
25
|
}) => void;
|
|
24
26
|
};
|
|
25
27
|
|
|
@@ -32,6 +34,9 @@ export type BrowserClient = {
|
|
|
32
34
|
/** Detach from the current page target (removes the "Chrome is being controlled" banner)
|
|
33
35
|
* while keeping the transport connection alive. Call on session shutdown. */
|
|
34
36
|
detach(): Promise<void>;
|
|
37
|
+
/** Close every tab this session owns and clear the window binding. Best-effort —
|
|
38
|
+
* used on session shutdown so no stale harness tabs survive. */
|
|
39
|
+
closeOwnedTabs(): Promise<void>;
|
|
35
40
|
evaluateJs(expression: string, sessionId?: string): Promise<Result<unknown, CdpError>>;
|
|
36
41
|
pageInfo(): Promise<Result<PageInfo | { readonly dialog: DialogInfo }, CdpError>>;
|
|
37
42
|
takeDialog(): DialogInfo | null;
|
|
@@ -82,13 +87,16 @@ const parsePageInfoPayload = (v: unknown): Result<PageInfo, CdpError> => {
|
|
|
82
87
|
|
|
83
88
|
export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient => {
|
|
84
89
|
const transport = opts.transport ?? createCdpTransport();
|
|
85
|
-
const ownershipInit: { ownedTargetIds?: ReadonlyArray<string>; harnessWindowTargetId?: string } = {};
|
|
90
|
+
const ownershipInit: { ownedTargetIds?: ReadonlyArray<string>; harnessWindowTargetId?: string; harnessWindowId?: number } = {};
|
|
86
91
|
if (opts.initialOwnership?.ownedTargetIds !== undefined) {
|
|
87
92
|
ownershipInit.ownedTargetIds = opts.initialOwnership.ownedTargetIds;
|
|
88
93
|
}
|
|
89
94
|
if (opts.initialOwnership?.harnessWindowTargetId !== undefined) {
|
|
90
95
|
ownershipInit.harnessWindowTargetId = opts.initialOwnership.harnessWindowTargetId;
|
|
91
96
|
}
|
|
97
|
+
if (opts.initialOwnership?.harnessWindowId !== undefined) {
|
|
98
|
+
ownershipInit.harnessWindowId = opts.initialOwnership.harnessWindowId;
|
|
99
|
+
}
|
|
92
100
|
const ownership = createOwnershipRegistry(ownershipInit);
|
|
93
101
|
if (opts.onOwnershipChange) {
|
|
94
102
|
const cb = opts.onOwnershipChange;
|
|
@@ -96,6 +104,7 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
|
|
|
96
104
|
cb({
|
|
97
105
|
ownedTargetIds: ownership.list(),
|
|
98
106
|
harnessWindowTargetId: ownership.harnessWindow(),
|
|
107
|
+
harnessWindowId: ownership.harnessWindowId(),
|
|
99
108
|
});
|
|
100
109
|
});
|
|
101
110
|
}
|
|
@@ -256,6 +265,15 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
|
|
|
256
265
|
return ok(undefined);
|
|
257
266
|
};
|
|
258
267
|
|
|
268
|
+
// Ask Chrome for the real windowId hosting a target. Undefined on failure —
|
|
269
|
+
// callers treat the window binding as best-effort.
|
|
270
|
+
const getWindowId = async (targetId: string): Promise<number | undefined> => {
|
|
271
|
+
const r = await session.callBrowser("Browser.getWindowForTarget", { targetId });
|
|
272
|
+
if (!r.success) return undefined;
|
|
273
|
+
const wid = (r.data as { windowId?: number }).windowId;
|
|
274
|
+
return typeof wid === "number" ? wid : undefined;
|
|
275
|
+
};
|
|
276
|
+
|
|
259
277
|
const newTab = async (url?: string): Promise<Result<string, CdpError>> => {
|
|
260
278
|
// Verify the recorded harness window still exists. Querying the live
|
|
261
279
|
// target list also gives listTabs's reconciliation a chance to run.
|
|
@@ -263,22 +281,35 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
|
|
|
263
281
|
if (!tabsResult.success) return tabsResult;
|
|
264
282
|
const live = new Set(tabsResult.data.map((t) => t.targetId));
|
|
265
283
|
const hw = ownership.harnessWindow();
|
|
284
|
+
// Pick an opener that keeps us inside the harness window. Prefer the seed
|
|
285
|
+
// tab; if the user closed it but other owned tabs survive, reuse one of
|
|
286
|
+
// those instead of spawning a second window (avoids window scatter).
|
|
287
|
+
const opener = hw && live.has(hw)
|
|
288
|
+
? hw
|
|
289
|
+
: ownership.list().find((id) => live.has(id));
|
|
266
290
|
|
|
267
291
|
const params: Record<string, unknown> = { url: "about:blank" };
|
|
268
|
-
if (
|
|
269
|
-
// Open as a child of
|
|
270
|
-
//
|
|
271
|
-
params["openerId"] =
|
|
292
|
+
if (opener) {
|
|
293
|
+
// Open as a child of an owned tab — Chrome places it in the same window,
|
|
294
|
+
// giving us visual grouping for free and keeping the session single-window.
|
|
295
|
+
params["openerId"] = opener;
|
|
272
296
|
} else {
|
|
273
|
-
//
|
|
274
|
-
// Spawn a new dedicated window.
|
|
297
|
+
// Fresh session, or the whole harness window was closed by the user.
|
|
298
|
+
// Spawn a new dedicated window and rebind to it.
|
|
275
299
|
params["newWindow"] = true;
|
|
276
300
|
}
|
|
277
301
|
const created = await session.callBrowser("Target.createTarget", params);
|
|
278
302
|
if (!created.success) return created;
|
|
279
303
|
const c = created.data as { targetId: string };
|
|
280
304
|
|
|
281
|
-
if (params["newWindow"])
|
|
305
|
+
if (params["newWindow"]) {
|
|
306
|
+
ownership.setHarnessWindow(c.targetId);
|
|
307
|
+
const wid = await getWindowId(c.targetId);
|
|
308
|
+
if (wid !== undefined) ownership.setHarnessWindowId(wid);
|
|
309
|
+
} else if (opener && opener !== hw) {
|
|
310
|
+
// Re-seeded onto a surviving owned tab; keep the seed pointer live.
|
|
311
|
+
ownership.setHarnessWindow(opener);
|
|
312
|
+
}
|
|
282
313
|
ownership.add(c.targetId);
|
|
283
314
|
|
|
284
315
|
const switched = await switchTab(c.targetId);
|
|
@@ -297,6 +328,18 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
|
|
|
297
328
|
return ok(undefined);
|
|
298
329
|
};
|
|
299
330
|
|
|
331
|
+
const closeOwnedTabs = async (): Promise<void> => {
|
|
332
|
+
// Snapshot first — remove() mutates the list, and targetDestroyed events
|
|
333
|
+
// may also prune concurrently. Failures are swallowed: a tab the user
|
|
334
|
+
// already closed, or a dead transport, must not block shutdown.
|
|
335
|
+
for (const id of ownership.list()) {
|
|
336
|
+
await session.callBrowser("Target.closeTarget", { targetId: id }).catch(() => {});
|
|
337
|
+
ownership.remove(id);
|
|
338
|
+
}
|
|
339
|
+
ownership.setHarnessWindow(undefined);
|
|
340
|
+
ownership.setHarnessWindowId(undefined);
|
|
341
|
+
};
|
|
342
|
+
|
|
300
343
|
const status = (): DaemonStatus => ({
|
|
301
344
|
alive: transport.state() === "open" && session.current() !== null,
|
|
302
345
|
sessionId: session.current()?.sessionId ?? null,
|
|
@@ -316,7 +359,7 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
|
|
|
316
359
|
|
|
317
360
|
return {
|
|
318
361
|
namespace: opts.namespace,
|
|
319
|
-
ensureAlive, status, start, stop, detach,
|
|
362
|
+
ensureAlive, status, start, stop, detach, closeOwnedTabs,
|
|
320
363
|
evaluateJs, pageInfo,
|
|
321
364
|
takeDialog: () => session.takeDialog(),
|
|
322
365
|
listTabs, switchTab, newTab, closeTab,
|
package/src/daemon/bridge.ts
CHANGED
|
@@ -29,7 +29,7 @@ export type CdpBridge = {
|
|
|
29
29
|
* are initialized (not when Chrome is connected — that may take retries). */
|
|
30
30
|
start(): Promise<void>;
|
|
31
31
|
stop(): Promise<void>;
|
|
32
|
-
handleRequest(req: WireRequest, clientId: string, send: SendToClient): void
|
|
32
|
+
handleRequest(req: WireRequest, clientId: string, send: SendToClient): Promise<void>;
|
|
33
33
|
/** True when the Chrome WebSocket is open and ready. */
|
|
34
34
|
isAlive(): boolean;
|
|
35
35
|
recordSession(clientId: string, sessionId: string): void;
|
|
@@ -339,11 +339,21 @@ export const createCdpBridge = (): CdpBridge => {
|
|
|
339
339
|
wsUrl = null;
|
|
340
340
|
};
|
|
341
341
|
|
|
342
|
-
const handleRequest = (
|
|
342
|
+
const handleRequest = async (
|
|
343
343
|
req: WireRequest,
|
|
344
344
|
clientId: string,
|
|
345
345
|
send: SendToClient,
|
|
346
|
-
): void => {
|
|
346
|
+
): Promise<void> => {
|
|
347
|
+
// ponytail: wait for Chrome to connect (user may still be clicking "Allow").
|
|
348
|
+
// Bridge retries in background; this polls so the client doesn't fail first try.
|
|
349
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
350
|
+
const deadline = Date.now() + 15_000;
|
|
351
|
+
while (Date.now() < deadline) {
|
|
352
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
353
|
+
if (ws && ws.readyState === WebSocket.OPEN) break;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
347
357
|
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
348
358
|
send(clientId, {
|
|
349
359
|
type: "response",
|
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
|
@@ -10,25 +10,94 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { spawn, type ChildProcess } from "node:child_process";
|
|
13
|
-
import { access } from "node:fs/promises";
|
|
13
|
+
import { access, unlink } from "node:fs/promises";
|
|
14
14
|
import { existsSync } from "node:fs";
|
|
15
15
|
import { fileURLToPath } from "node:url";
|
|
16
16
|
import { dirname, join } from "node:path";
|
|
17
|
-
import {
|
|
17
|
+
import { createConnection } from "node:net";
|
|
18
|
+
import { DAEMON_SOCKET_PATH, serialize, type WireControl } from "./protocol";
|
|
18
19
|
|
|
19
20
|
const moduleDir = dirname(fileURLToPath(import.meta.url));
|
|
20
21
|
|
|
22
|
+
const isWindows = process.platform === "win32";
|
|
23
|
+
|
|
21
24
|
/**
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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.
|
|
24
29
|
*/
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
30
|
+
const cleanupStaleSocket = (): void => {
|
|
31
|
+
if (isWindows) return;
|
|
32
|
+
unlink(DAEMON_SOCKET_PATH).catch(() => {});
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Check whether the daemon is running by probing its Unix socket.
|
|
37
|
+
* A file-existence check is unreliable — a crashed daemon leaves a stale socket.
|
|
38
|
+
* We connect, send a register, wait for the registered ack, then disconnect.
|
|
39
|
+
* On failure, clean up the stale socket so ensureDaemon can spawn a fresh one.
|
|
40
|
+
*/
|
|
41
|
+
export const isDaemonRunning = async (timeoutMs = 2_000): Promise<boolean> => {
|
|
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
|
+
}
|
|
31
51
|
}
|
|
52
|
+
|
|
53
|
+
return new Promise((resolve) => {
|
|
54
|
+
let settled = false;
|
|
55
|
+
const done = (alive: boolean): void => {
|
|
56
|
+
if (settled) return;
|
|
57
|
+
settled = true;
|
|
58
|
+
resolve(alive);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const sock = createConnection(DAEMON_SOCKET_PATH);
|
|
62
|
+
const timer = setTimeout(() => {
|
|
63
|
+
sock.destroy();
|
|
64
|
+
// ponytail: stale socket cleanup; ENOENT = already gone, fine
|
|
65
|
+
cleanupStaleSocket();
|
|
66
|
+
done(false);
|
|
67
|
+
}, timeoutMs);
|
|
68
|
+
|
|
69
|
+
sock.on("connect", () => {
|
|
70
|
+
const regMsg: WireControl = { type: "control", action: "register", clientId: "_liveness_probe" };
|
|
71
|
+
sock.write(serialize(regMsg) + "\n");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
sock.on("data", (chunk: Buffer) => {
|
|
75
|
+
const text = chunk.toString();
|
|
76
|
+
if (text.includes('"registered"')) {
|
|
77
|
+
clearTimeout(timer);
|
|
78
|
+
// Send deregister so the probe doesn't hog a slot
|
|
79
|
+
const deregMsg: WireControl = { type: "control", action: "deregister", clientId: "_liveness_probe" };
|
|
80
|
+
try { sock.write(serialize(deregMsg) + "\n"); } catch {}
|
|
81
|
+
sock.destroy();
|
|
82
|
+
done(true);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
sock.on("error", () => {
|
|
87
|
+
clearTimeout(timer);
|
|
88
|
+
sock.destroy();
|
|
89
|
+
// ponytail: connection refused = daemon dead, clean up stale socket
|
|
90
|
+
cleanupStaleSocket();
|
|
91
|
+
done(false);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// close without 'registered' ack = daemon alive but rejected us (e.g. max clients);
|
|
95
|
+
// don't unlink — the daemon is still using that socket.
|
|
96
|
+
sock.on("close", () => {
|
|
97
|
+
clearTimeout(timer);
|
|
98
|
+
done(false);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
32
101
|
};
|
|
33
102
|
|
|
34
103
|
/**
|
|
@@ -44,18 +113,34 @@ export const isDaemonRunning = async (): Promise<boolean> => {
|
|
|
44
113
|
export const spawnDaemon = (): ChildProcess | null => {
|
|
45
114
|
// Resolve the daemon entry point and tsx binary.
|
|
46
115
|
const daemonScript = join(moduleDir, "index.ts");
|
|
47
|
-
const tsxBin = join(moduleDir, "..", "..", "node_modules", ".bin", "tsx");
|
|
48
116
|
|
|
49
|
-
//
|
|
50
|
-
//
|
|
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.
|
|
51
127
|
// Fall back to `npx tsx` if the local binary isn't available.
|
|
52
|
-
const cmd =
|
|
53
|
-
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);
|
|
54
136
|
|
|
55
|
-
const child = spawn(cmd, args, {
|
|
137
|
+
const child = spawn(quote(cmd), args.map(quote), {
|
|
56
138
|
detached: true,
|
|
57
139
|
stdio: "ignore",
|
|
58
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,
|
|
59
144
|
});
|
|
60
145
|
|
|
61
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
|
+
}
|