pi-browser-harness 0.10.2 → 0.12.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.
Files changed (91) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +21 -10
  3. package/package.json +6 -3
  4. package/skills/pi-browser-harness/SKILL.md +30 -1
  5. package/src/cdp/commands.ts +260 -0
  6. package/src/cdp/console-buffer.ts +10 -85
  7. package/src/cdp/discovery.ts +31 -71
  8. package/src/cdp/errors.ts +4 -0
  9. package/src/cdp/events.ts +170 -0
  10. package/src/cdp/network-buffer.ts +42 -109
  11. package/src/cdp/ownership.ts +0 -17
  12. package/src/cdp/record-store.ts +61 -0
  13. package/src/cdp/session.ts +205 -193
  14. package/src/cdp/target-factory.ts +10 -80
  15. package/src/cdp/types.ts +21 -12
  16. package/src/client.ts +39 -95
  17. package/src/daemon/bridge.ts +375 -265
  18. package/src/daemon/index.ts +0 -28
  19. package/src/daemon/protocol.ts +6 -68
  20. package/src/daemon/queue.ts +85 -0
  21. package/src/daemon/server.ts +24 -99
  22. package/src/daemon/spawn.ts +90 -82
  23. package/src/{cdp/daemon-transport.ts → daemon/transport.ts} +24 -52
  24. package/src/domains/ax-tree.ts +179 -0
  25. package/src/domains/box.ts +51 -0
  26. package/src/domains/cdp-call.ts +55 -0
  27. package/src/domains/click.ts +19 -37
  28. package/src/domains/console.ts +48 -4
  29. package/src/domains/dialog.ts +8 -5
  30. package/src/domains/drag.ts +8 -7
  31. package/src/domains/element-call.ts +117 -0
  32. package/src/domains/files.ts +75 -30
  33. package/src/domains/fill-engine.ts +80 -0
  34. package/src/domains/form-batch.ts +64 -0
  35. package/src/domains/form.ts +115 -112
  36. package/src/domains/headers.ts +89 -0
  37. package/src/domains/history.ts +16 -12
  38. package/src/domains/isolated-tab.ts +26 -55
  39. package/src/domains/js.ts +32 -51
  40. package/src/domains/keyboard.ts +38 -34
  41. package/src/domains/navigate.ts +16 -26
  42. package/src/domains/network.ts +61 -13
  43. package/src/domains/page.ts +25 -10
  44. package/src/domains/readpage/read-page.ts +74 -20
  45. package/src/domains/readpage/readability.ts +0 -24
  46. package/src/domains/ref-resolve.ts +24 -58
  47. package/src/domains/render.ts +6 -27
  48. package/src/domains/screenshot-capture.ts +80 -0
  49. package/src/domains/screenshot.ts +9 -90
  50. package/src/domains/scroll.ts +10 -12
  51. package/src/domains/search/google-serp.ts +0 -29
  52. package/src/domains/search/web-search.ts +48 -21
  53. package/src/domains/setup.ts +1 -13
  54. package/src/domains/snapshot.ts +36 -280
  55. package/src/domains/tabs.ts +15 -16
  56. package/src/domains/target.ts +35 -0
  57. package/src/domains/viewport.ts +5 -4
  58. package/src/index.ts +23 -69
  59. package/src/profile/bind.ts +8 -57
  60. package/src/profile/browser-process.ts +75 -126
  61. package/src/profile/command.ts +50 -29
  62. package/src/profile/launch.ts +22 -40
  63. package/src/profile/list.ts +14 -54
  64. package/src/profile/paths.ts +2 -65
  65. package/src/profile/store.ts +35 -56
  66. package/src/prompt.ts +22 -122
  67. package/src/registry.ts +33 -33
  68. package/src/schemas/parse.ts +23 -0
  69. package/src/setup.ts +0 -25
  70. package/src/state.ts +48 -18
  71. package/src/util/debug.ts +8 -0
  72. package/src/util/guards.ts +30 -0
  73. package/src/util/js-template.ts +1 -16
  74. package/src/util/mutex.ts +0 -5
  75. package/src/util/paths.ts +11 -1
  76. package/src/util/result.ts +0 -6
  77. package/src/util/sharp-shim.ts +12 -19
  78. package/src/util/tool.ts +17 -42
  79. package/src/util/truncate.ts +6 -0
  80. package/tsconfig.json +1 -16
  81. package/skills/deep-research/SKILL.md +0 -66
  82. package/src/cdp/event-queue.ts +0 -128
  83. package/src/cdp/transport.ts +0 -156
  84. package/src/cdp/window-binding.ts +0 -15
  85. package/src/deep-research.ts +0 -41
  86. package/src/domains/forms.ts +0 -355
  87. package/src/domains/readpage/capture.ts +0 -46
  88. package/src/domains/search/extract.ts +0 -44
  89. package/src/profile/picker.ts +0 -73
  90. package/src/util/keycodes.ts +0 -10
  91. package/src/util/time.ts +0 -29
package/src/util/tool.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  import type { TSchema, Static } from "typebox";
2
2
  import type {
3
3
  AgentToolResult,
4
- AgentToolUpdateCallback,
5
4
  ExtensionAPI,
6
5
  ExtensionContext,
7
6
  Theme,
@@ -9,8 +8,7 @@ import type {
9
8
  ToolRenderResultOptions,
10
9
  } from "@mariozechner/pi-coding-agent";
11
10
  import type { Component } from "@mariozechner/pi-tui";
12
- import { access } from "node:fs/promises";
13
- import { DAEMON_SOCKET_PATH } from "../daemon/protocol";
11
+ import { daemonSocketMayExist } from "../daemon/spawn";
14
12
  import type { BrowserClient } from "../client";
15
13
  import type { Result } from "./result";
16
14
 
@@ -55,17 +53,11 @@ export type BrowserToolDefinition<S extends TSchema> = {
55
53
  readonly handler: ToolHandler<S>;
56
54
  readonly renderCall?: (args: Static<S>, theme: Theme) => Component;
57
55
  readonly renderResult?: (result: AgentToolResult<unknown>, expanded: boolean, theme: Theme) => Component;
58
- /** If true, the tool acquires the client's mutation mutex before executing.
59
- * This ensures that two serialized tools never run concurrently,
60
- * preventing race conditions on shared CDP session/page state. */
61
- readonly serialized?: boolean;
56
+ readonly concurrency: "serialized" | "parallel";
62
57
  readonly ensureAlive?: boolean;
63
58
  };
64
59
 
65
- // Type-erased entry for the registry's heterogeneous TOOLS array.
66
- // renderCall accepts `never` so any concrete args type satisfies it via
67
- // function-parameter contravariance (a handler that takes SpecificT is
68
- // assignable to one that takes never, because never is a subtype of everything).
60
+ // renderCall accepts `never` so any concrete args type satisfies it via function-parameter contravariance.
69
61
  export type AnyBrowserToolDefinition = Omit<BrowserToolDefinition<TSchema>, "renderCall" | "renderResult"> & {
70
62
  readonly renderCall?: (args: never, theme: Theme) => Component;
71
63
  readonly renderResult?: (result: AgentToolResult<unknown>, expanded: boolean, theme: Theme) => Component;
@@ -78,10 +70,12 @@ export const defineBrowserTool = <S extends TSchema>(
78
70
  type OkDetails = { readonly ok: true } & Readonly<Record<string, unknown>>;
79
71
  type ErrDetails = { readonly ok: false; readonly kind: ToolErrKind; readonly message: string } & Readonly<Record<string, unknown>>;
80
72
 
73
+ type ErrorFlagged<D> = AgentToolResult<D> & { readonly isError?: boolean };
74
+
81
75
  const toToolResult = (
82
76
  r: Result<ToolOk, ToolErr>,
83
77
  toolName: string,
84
- ): AgentToolResult<OkDetails | ErrDetails> => {
78
+ ): ErrorFlagged<OkDetails | ErrDetails> => {
85
79
  if (r.success) {
86
80
  const details: OkDetails = { ok: true, ...(r.data.details ?? {}) };
87
81
  return {
@@ -96,10 +90,7 @@ const toToolResult = (
96
90
  ...(r.error.details ?? {}),
97
91
  };
98
92
  return {
99
- // isError is an extension the pi-coding-agent runtime picks up from the result object;
100
- // AgentToolResult<T> itself doesn't declare it but the runtime reads it via duck-typing.
101
- // We cast through unknown to avoid widening the return type.
102
- ...(({ isError: true }) as unknown as object),
93
+ isError: true,
103
94
  content: [{ type: "text", text: `${toolName} failed (${r.error.kind}): ${r.error.message}` }],
104
95
  details,
105
96
  };
@@ -110,10 +101,9 @@ export const registerBrowserTool = (
110
101
  client: BrowserClient,
111
102
  def: AnyBrowserToolDefinition,
112
103
  ): void => {
113
- // AnyBrowserToolDefinition is type-erased; we recover the schema-typed surface
114
- // by casting def to the generic form. The cast is safe because defineBrowserTool
115
- // ensures the handler, parameters, and renderCall were all created with the same S.
116
104
  type S = TSchema;
105
+ const defRenderCall = def.renderCall;
106
+ const defRenderResult = def.renderResult;
117
107
  const td: ToolDefinition<S> = {
118
108
  name: def.name,
119
109
  label: def.label,
@@ -121,35 +111,26 @@ export const registerBrowserTool = (
121
111
  promptSnippet: def.promptSnippet,
122
112
  promptGuidelines: [...def.promptGuidelines],
123
113
  parameters: def.parameters,
124
- ...(def.renderCall
114
+ ...(defRenderCall
125
115
  ? {
126
116
  renderCall: (args: Static<S>, theme: Theme, _ctx: Parameters<NonNullable<ToolDefinition<S>["renderCall"]>>[2]) =>
127
- // Non-null assertion safe: this branch only runs when renderCall was provided.
128
- // Cast from never->Component to unknown->Component: safe because at runtime
129
- // args is always the concrete Static<S> value the tool was defined with.
130
- (def.renderCall as (args: unknown, theme: Theme) => Component)(args, theme),
117
+ // Sanctioned cast: at runtime args is always the concrete Static<S> value the tool was defined with.
118
+ (defRenderCall as (args: unknown, theme: Theme) => Component)(args, theme),
131
119
  }
132
120
  : {}),
133
- ...(def.renderResult
121
+ ...(defRenderResult
134
122
  ? {
135
123
  renderResult: (
136
124
  result: AgentToolResult<unknown>,
137
125
  options: ToolRenderResultOptions,
138
126
  theme: Theme,
139
127
  _ctx: Parameters<NonNullable<ToolDefinition<S>["renderResult"]>>[3],
140
- ) =>
141
- // Non-null assertion safe: this branch only runs when renderResult was provided.
142
- def.renderResult!(result, options.expanded ?? false, theme),
128
+ ) => defRenderResult(result, options.expanded ?? false, theme),
143
129
  }
144
130
  : {}),
145
131
  async execute(_toolCallId, args, signal, onUpdate, extensionCtx) {
146
132
  if (def.ensureAlive !== false) {
147
- // Check daemon socket exists before attempting browser control.
148
- // If missing, the user hasn't run /browser-setup yet. Return a clear
149
- // error so the agent knows to direct the user to initialize.
150
- try {
151
- await access(DAEMON_SOCKET_PATH);
152
- } catch {
133
+ if (!(await daemonSocketMayExist())) {
153
134
  return toToolResult(
154
135
  {
155
136
  success: false,
@@ -169,12 +150,8 @@ export const registerBrowserTool = (
169
150
  );
170
151
  }
171
152
  }
172
- // Serialized tools acquire the client's mutation mutex so that only one
173
- // mutation tool runs at a time. Observation tools skip this and run freely
174
- // in parallel. AbortSignal is checked after acquiring the lock so that a
175
- // cancelled task doesn't hold the mutex forever.
176
153
  let release: (() => void) | undefined;
177
- if (def.serialized) {
154
+ if (def.concurrency !== "parallel") {
178
155
  const acquire = await client.mutationMutex().acquire();
179
156
  release = acquire;
180
157
  if (signal?.aborted) {
@@ -191,13 +168,11 @@ export const registerBrowserTool = (
191
168
  signal,
192
169
  onUpdate: (u) => {
193
170
  if (onUpdate) {
194
- // AgentToolUpdateCallback expects AgentToolResult<TDetails>; we use unknown for TDetails.
195
171
  const update: AgentToolResult<OkDetails> = {
196
172
  content: [{ type: "text", text: u.text }],
197
173
  details: { ok: true, ...(u.details ?? {}) },
198
174
  };
199
- // Cast needed: onUpdate is AgentToolUpdateCallback<unknown> (generic TDetails).
200
- (onUpdate as AgentToolUpdateCallback<OkDetails>)(update);
175
+ onUpdate(update);
201
176
  }
202
177
  },
203
178
  extensionCtx,
@@ -33,3 +33,9 @@ export const applyTruncation = async (output: string, prefix: string): Promise<T
33
33
  const text = `${t.content}\n\n[Output truncated: ${t.outputLines} of ${t.totalLines} lines (${formatSize(t.outputBytes)} of ${formatSize(t.totalBytes)}). ${formatSize(omitted)} omitted. Full output: ${file}]`;
34
34
  return { text, fullOutputPath: file, wasTruncated: true };
35
35
  };
36
+
37
+ export const formatBytes = (n: number): string => {
38
+ if (n >= 1_048_576) return `${(n / 1_048_576).toFixed(1)} MB`;
39
+ if (n >= 1024) return `${(n / 1024).toFixed(1)} KB`;
40
+ return `${n} B`;
41
+ };
package/tsconfig.json CHANGED
@@ -1,21 +1,6 @@
1
1
  {
2
+ "extends": "./tsconfig.base.json",
2
3
  "compilerOptions": {
3
- "target": "ES2022",
4
- "module": "ESNext",
5
- "moduleResolution": "bundler",
6
- "strict": true,
7
- "noImplicitAny": true,
8
- "strictNullChecks": true,
9
- "noUnusedLocals": true,
10
- "noUnusedParameters": true,
11
- "noImplicitReturns": true,
12
- "noFallthroughCasesInSwitch": true,
13
- "noUncheckedIndexedAccess": true,
14
- "exactOptionalPropertyTypes": true,
15
- "noPropertyAccessFromIndexSignature": true,
16
- "forceConsistentCasingInFileNames": true,
17
- "esModuleInterop": true,
18
- "skipLibCheck": true,
19
4
  "outDir": "./dist",
20
5
  "rootDir": "./src",
21
6
  "declaration": true,
@@ -1,66 +0,0 @@
1
- ---
2
- name: deep-research
3
- description: Use when the user asks you to research a topic on the web, do deep research, investigate something across multiple sources, or produce a cited report / literature review from the live web. Decomposes the question, fans out isolated web-search-researcher subagents (one per sub-question) that scrape the real browser, then synthesizes a source-cited Markdown report. Trigger phrases include "research", "deep research", "look this up and cite sources", "investigate", "compare X and Y with sources".
4
- ---
5
-
6
- # Deep Research
7
-
8
- Turn a research question into a **cited Markdown report** by fanning out to isolated `web-search-researcher` subagents that use the real browser, then synthesizing their distilled findings. Every claim in the final report carries a source link.
9
-
10
- This runs on two browser tools — `browser_web_search` (ranked SERP, links only) and `browser_read_page` (URL → clean article text) — used *inside the subagents*, not by you directly. Both tools open their own isolated tabs, so **the fan-out never disturbs the user's tabs or current page.** You do not need `/browser-setup` to have been run by you; if the browser isn't connected the subagents' first tool call will report `not_connected` — relay that and ask the user to run `/browser-setup`.
11
-
12
- ## Workflow
13
-
14
- ### 1. Decompose
15
-
16
- Break the question into **3–6 focused sub-questions**, each independently answerable from the web. Cover the distinct facets — definitions, current state, competing options, trade-offs, recent changes — not slight rewordings of the same thing. Aim for coverage: if you synthesized perfect answers to all sub-questions, the original question should be fully answered.
17
-
18
- State the sub-questions to the user before dispatching (a short numbered list) so the plan is visible.
19
-
20
- ### 2. Fan out
21
-
22
- Dispatch **one `web-search-researcher` subagent per sub-question**, in parallel where the runtime allows. Each subagent runs in isolated context and returns a distilled, source-linked finding (claims + urls) — not raw pages. Give each subagent exactly one sub-question plus any constraints (recency, specific ecosystem, authoritative sources to prefer).
23
-
24
- Do not do the searching yourself — that is the subagents' job, and their isolation is what keeps your context clean enough to synthesize well.
25
-
26
- ### 3. Assess coverage — loop with a hard ceiling
27
-
28
- After each round of findings, self-assess: **is every sub-question answered, or did findings surface new gaps worth a follow-up search?**
29
-
30
- - If gaps remain and you are under the ceiling, dispatch a second round of researchers targeting only the gaps (rephrased or newly discovered sub-questions).
31
- - **Hard ceiling:** at most **2 rounds** and **8 researcher subagents total**. When you hit either limit, stop searching, synthesize with what you have, and list the unresolved gaps explicitly in the report. Never loop indefinitely chasing completeness.
32
-
33
- Handle subagent-reported tool failures:
34
- - A subagent reporting a `browser_web_search` `invalid_state` with `reason: "captcha"` means a bot wall. **Surface this to the user immediately** and do not spawn a tight retry loop — ask whether to continue, wait, or narrow scope.
35
- - `reason: "no_results"` is already handled inside the subagent (one rephrase); if it still comes back empty, treat that sub-question as a gap.
36
-
37
- ### 4. Synthesize a cited report
38
-
39
- Merge the distilled findings into a **Markdown report written to a file** (e.g. `research-<topic-slug>.md` in the working directory — tell the user the path). Structure:
40
-
41
- ```
42
- # {The research question}
43
-
44
- ## Summary
45
- {A few paragraphs answering the question, synthesized across sources.}
46
-
47
- ## {Sub-topic / sub-question 1}
48
- {Findings, in prose. Every claim carries a source link inline: … as shown in [Title](url).}
49
-
50
- ## {Sub-topic 2}
51
- ...
52
-
53
- ## Open questions / gaps
54
- {Anything the ceiling left unresolved, or where sources conflicted.}
55
-
56
- ## Sources
57
- {Deduplicated list of every URL cited, with titles.}
58
- ```
59
-
60
- Rules for the report:
61
- - **Every factual claim carries a source link** — inline `[title](url)`. No unsourced assertions.
62
- - **Attribute conflicts.** Where sources disagree, present both and cite each.
63
- - **Distinguish synthesis from quotation.** Don't fabricate precision the sources didn't provide.
64
- - **Be honest about gaps.** The gaps section is mandatory when the ceiling cut off coverage — an incomplete-but-honest report beats a complete-looking-but-padded one.
65
-
66
- After writing the file, give the user a short chat summary and the file path — don't paste the whole report into the conversation.
@@ -1,128 +0,0 @@
1
- /**
2
- * Shared transport primitives for CdpTransport implementations.
3
- *
4
- * Both the WebSocket transport (transport.ts, talking to Chrome directly) and
5
- * the daemon transport (daemon-transport.ts, talking to the daemon over a Unix
6
- * socket) need an identical single-consumer async event queue plus the same
7
- * pending-request bookkeeping. This module is the single source of truth for
8
- * that shared machinery.
9
- */
10
-
11
- import { type Result, err } from "../util/result";
12
- import { type CdpError, cdpError } from "./errors";
13
- import type { CdpEvent } from "./types";
14
-
15
- export type CdpResult = Result<unknown, CdpError>;
16
-
17
- /**
18
- * A single-consumer async queue of CDP events.
19
- *
20
- * `push` enqueues an event (dropped silently after `end`). `end` terminates the
21
- * stream — any pending and future iterator `next()` calls resolve `done:true`.
22
- * `iter` is the AsyncIterable consumed by one for-await loop; a second consumer
23
- * would silently steal events from the first.
24
- */
25
- export type EventQueue = {
26
- readonly push: (e: CdpEvent) => void;
27
- readonly end: () => void;
28
- readonly iter: AsyncIterable<CdpEvent>;
29
- };
30
-
31
- export const makeEventQueue = (): EventQueue => {
32
- const buf: CdpEvent[] = [];
33
- const waiters: Array<(v: IteratorResult<CdpEvent>) => void> = [];
34
- let ended = false;
35
- return {
36
- push(e) {
37
- if (ended) return;
38
- const w = waiters.shift();
39
- if (w) w({ value: e, done: false });
40
- else buf.push(e);
41
- },
42
- end() {
43
- ended = true;
44
- // value: undefined as unknown as CdpEvent is the correct iterator-protocol
45
- // pattern: when done=true the value field is conventionally undefined but
46
- // the TS type still requires CdpEvent.
47
- for (const w of waiters.splice(0)) w({ value: undefined as unknown as CdpEvent, done: true });
48
- },
49
- iter: {
50
- [Symbol.asyncIterator]() {
51
- return {
52
- next: (): Promise<IteratorResult<CdpEvent>> =>
53
- new Promise((resolve) => {
54
- const next = buf.shift();
55
- if (next) resolve({ value: next, done: false });
56
- else if (ended) resolve({ value: undefined as unknown as CdpEvent, done: true });
57
- else waiters.push(resolve);
58
- }),
59
- };
60
- },
61
- },
62
- };
63
- };
64
-
65
- /** An in-flight CDP request awaiting its response or timeout. */
66
- export type Pending = {
67
- readonly resolve: (v: Result<unknown, CdpError>) => void;
68
- readonly timer: ReturnType<typeof setTimeout>;
69
- readonly method: string;
70
- };
71
-
72
- /**
73
- * Reject every in-flight request with a transport_closed error and clear the
74
- * map. Called from each transport's cleanup path when the underlying connection
75
- * goes away.
76
- */
77
- export const rejectAllPending = (pending: Map<number, Pending>, reason: string): void => {
78
- for (const [, p] of pending) {
79
- clearTimeout(p.timer);
80
- p.resolve(err(cdpError("transport_closed", reason, p.method)));
81
- }
82
- pending.clear();
83
- };
84
-
85
- /**
86
- * Send a CDP request and return a promise that settles when the matching
87
- * response arrives, the request times out, or the send throws.
88
- *
89
- * The transport supplies a `send` callback that puts the already-serialized
90
- * payload onto its wire (WebSocket frame or Unix-socket line); everything else
91
- * — registering the pending entry, the timeout timer, and synchronous send
92
- * failure handling — is identical across transports and lives here.
93
- *
94
- * `timeoutLabel` is the transport's prefix for the timeout error message
95
- * (e.g. "CDP" or "Daemon").
96
- */
97
- export const sendWithTimeout = (
98
- pending: Map<number, Pending>,
99
- id: number,
100
- method: string,
101
- timeoutMs: number,
102
- timeoutLabel: string,
103
- send: () => void,
104
- ): Promise<CdpResult> =>
105
- new Promise((resolve) => {
106
- const timer = setTimeout(() => {
107
- pending.delete(id);
108
- resolve(err(cdpError("timeout", `${timeoutLabel} timeout after ${timeoutMs}ms: ${method}`, method)));
109
- }, timeoutMs);
110
- pending.set(id, { resolve, timer, method });
111
- try {
112
- send();
113
- } catch (e) {
114
- clearTimeout(timer);
115
- pending.delete(id);
116
- resolve(err(cdpError("transport_closed", e instanceof Error ? e.message : String(e), method)));
117
- }
118
- });
119
-
120
- /**
121
- * Register a close listener, returning an unsubscribe function. Identical
122
- * across transports, so it lives here.
123
- */
124
- export const makeOnClose = (closeListeners: Set<() => void>) =>
125
- (cb: () => void): (() => void) => {
126
- closeListeners.add(cb);
127
- return () => closeListeners.delete(cb);
128
- };
@@ -1,156 +0,0 @@
1
- import WebSocket from "ws";
2
- import { type Result, err, ok } from "../util/result";
3
- import { type CdpError, cdpError } from "./errors";
4
- import { isCdpRawMessage } from "./types";
5
- import type { CdpEvent, CdpRawMessage } from "./types";
6
- import { type Pending, makeEventQueue, makeOnClose, rejectAllPending, sendWithTimeout } from "./event-queue";
7
-
8
- const DEFAULT_TIMEOUT_MS = 15_000;
9
-
10
- export type CdpTransport = {
11
- connect(url: string, opts?: { timeoutMs?: number }): Promise<Result<void, CdpError>>;
12
- close(): Promise<void>;
13
- request(
14
- method: string,
15
- params: Record<string, unknown>,
16
- opts?: { sessionId?: string | null; timeoutMs?: number },
17
- ): Promise<Result<unknown, CdpError>>;
18
- // Returns the current event queue's iterator. After close()+reconnect, callers
19
- // must re-call events() — the old iterator will have received done:true.
20
- events(): AsyncIterable<CdpEvent>;
21
- state(): "open" | "closed" | "connecting";
22
- onClose(cb: () => void): () => void;
23
- };
24
-
25
- export const createCdpTransport = (): CdpTransport => {
26
- let ws: WebSocket | null = null;
27
- let nextId = 1;
28
- const pending = new Map<number, Pending>();
29
- const closeListeners = new Set<() => void>();
30
-
31
- let queue = makeEventQueue();
32
-
33
- const handleMessage = (raw: string): void => {
34
- let parsed: unknown;
35
- try {
36
- parsed = JSON.parse(raw);
37
- } catch {
38
- return;
39
- }
40
- if (!isCdpRawMessage(parsed)) return;
41
- const msg: CdpRawMessage = parsed;
42
- if (msg.id === undefined) {
43
- if (msg.method) {
44
- queue.push({
45
- method: msg.method,
46
- params: msg.params,
47
- ...(msg.sessionId !== undefined ? { sessionId: msg.sessionId } : {}),
48
- });
49
- }
50
- return;
51
- }
52
- const p = pending.get(msg.id);
53
- if (!p) return;
54
- pending.delete(msg.id);
55
- clearTimeout(p.timer);
56
- if (msg.error) {
57
- const kind = msg.error.message.includes("Session with given id not found")
58
- ? "session_not_found"
59
- : "remote_error";
60
- p.resolve(err(cdpError(kind, msg.error.message, p.method)));
61
- return;
62
- }
63
- p.resolve(ok(msg.result));
64
- };
65
-
66
- const cleanup = (reason: string): void => {
67
- rejectAllPending(pending, reason);
68
- queue.end();
69
- queue = makeEventQueue();
70
- for (const cb of closeListeners) cb();
71
- };
72
-
73
- return {
74
- connect(url, opts = {}): Promise<Result<void, CdpError>> {
75
- const timeoutMs = opts.timeoutMs ?? 10_000;
76
- return new Promise((resolve) => {
77
- let settled = false;
78
- const settle = (r: Result<void, CdpError>): void => {
79
- if (settled) return;
80
- settled = true;
81
- resolve(r);
82
- };
83
- try {
84
- ws = new WebSocket(url);
85
- } catch (e) {
86
- settle(err(cdpError("transport_closed", e instanceof Error ? e.message : String(e))));
87
- return;
88
- }
89
- const timer = setTimeout(() => {
90
- ws?.close();
91
- ws = null;
92
- settle(err(cdpError("timeout", `CDP WebSocket connection timed out after ${timeoutMs}ms`)));
93
- }, timeoutMs);
94
- ws.onopen = () => {
95
- clearTimeout(timer);
96
- settle(ok(undefined));
97
- };
98
- ws.onmessage = (ev: WebSocket.MessageEvent) => {
99
- const data = ev.data;
100
- handleMessage(typeof data === "string" ? data : data.toString());
101
- };
102
- ws.onerror = () => {
103
- clearTimeout(timer);
104
- settle(err(cdpError("transport_closed", "CDP WebSocket error during connection")));
105
- };
106
- ws.onclose = () => {
107
- clearTimeout(timer);
108
- ws = null;
109
- cleanup("WebSocket closed");
110
- // If onopen already settled the connect promise, this settle is a no-op
111
- // (guarded by `settled`). cleanup() above still runs unconditionally so
112
- // pending requests are rejected and the event queue is rotated.
113
- settle(err(cdpError("transport_closed", "CDP WebSocket closed")));
114
- };
115
- });
116
- },
117
- close(): Promise<void> {
118
- if (ws) {
119
- try { ws.close(1000, "Shutdown"); } catch { /* best effort */ }
120
- ws = null;
121
- }
122
- cleanup("close() called");
123
- return Promise.resolve();
124
- },
125
- request(method, params, opts = {}): Promise<Result<unknown, CdpError>> {
126
- const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
127
- const sessionId = opts.sessionId ?? null;
128
- if (!ws || ws.readyState !== WebSocket.OPEN) {
129
- return Promise.resolve(err(cdpError("transport_closed", "Browser not connected. Is Chrome running?", method)));
130
- }
131
- // Bind to a local const so the non-null is guaranteed even across microtasks
132
- // between the readyState check and the send() call.
133
- const sock = ws;
134
- const id = nextId++;
135
- const payload: Record<string, unknown> = { id, method, params };
136
- if (sessionId) payload["sessionId"] = sessionId;
137
- const json = JSON.stringify(payload);
138
- return sendWithTimeout(pending, id, method, timeoutMs, "CDP", () => sock.send(json));
139
- },
140
- events(): AsyncIterable<CdpEvent> {
141
- // Single-consumer: each connection's event stream may be iterated by
142
- // exactly one for-await loop. Calling events() multiple times returns
143
- // the same iterable; the second consumer will silently steal events
144
- // from the first. After a reconnect, the previous iterator is ended
145
- // and callers must re-call events() to receive new events.
146
- return queue.iter;
147
- },
148
- state(): "open" | "closed" | "connecting" {
149
- if (!ws) return "closed";
150
- if (ws.readyState === WebSocket.CONNECTING) return "connecting";
151
- if (ws.readyState === WebSocket.OPEN) return "open";
152
- return "closed";
153
- },
154
- onClose: makeOnClose(closeListeners),
155
- };
156
- };
@@ -1,15 +0,0 @@
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
- };
@@ -1,41 +0,0 @@
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
- }