pi-browser-harness 0.10.2 → 0.11.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 (88) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +5 -9
  3. package/package.json +5 -2
  4. package/skills/pi-browser-harness/SKILL.md +1 -1
  5. package/src/cdp/commands.ts +256 -0
  6. package/src/cdp/console-buffer.ts +10 -85
  7. package/src/cdp/discovery.ts +28 -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 +150 -203
  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 +18 -68
  23. package/src/{cdp/daemon-transport.ts → daemon/transport.ts} +18 -51
  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 -32
  28. package/src/domains/console.ts +48 -2
  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 +22 -26
  33. package/src/domains/fill-engine.ts +80 -0
  34. package/src/domains/form-batch.ts +65 -0
  35. package/src/domains/form.ts +116 -105
  36. package/src/domains/history.ts +13 -9
  37. package/src/domains/isolated-tab.ts +26 -55
  38. package/src/domains/js.ts +29 -42
  39. package/src/domains/keyboard.ts +36 -27
  40. package/src/domains/navigate.ts +16 -23
  41. package/src/domains/network.ts +60 -10
  42. package/src/domains/page.ts +24 -3
  43. package/src/domains/readpage/read-page.ts +74 -19
  44. package/src/domains/readpage/readability.ts +0 -24
  45. package/src/domains/ref-resolve.ts +24 -58
  46. package/src/domains/render.ts +6 -27
  47. package/src/domains/screenshot-capture.ts +80 -0
  48. package/src/domains/screenshot.ts +9 -86
  49. package/src/domains/scroll.ts +10 -11
  50. package/src/domains/search/google-serp.ts +0 -29
  51. package/src/domains/search/web-search.ts +48 -20
  52. package/src/domains/setup.ts +1 -11
  53. package/src/domains/snapshot.ts +35 -276
  54. package/src/domains/tabs.ts +14 -7
  55. package/src/domains/target.ts +35 -0
  56. package/src/domains/viewport.ts +5 -3
  57. package/src/index.ts +8 -58
  58. package/src/profile/bind.ts +8 -57
  59. package/src/profile/browser-process.ts +75 -126
  60. package/src/profile/command.ts +50 -29
  61. package/src/profile/launch.ts +22 -40
  62. package/src/profile/list.ts +14 -54
  63. package/src/profile/paths.ts +2 -65
  64. package/src/profile/store.ts +35 -56
  65. package/src/registry.ts +30 -33
  66. package/src/schemas/parse.ts +23 -0
  67. package/src/setup.ts +0 -25
  68. package/src/state.ts +16 -16
  69. package/src/util/debug.ts +8 -0
  70. package/src/util/guards.ts +30 -0
  71. package/src/util/js-template.ts +1 -16
  72. package/src/util/mutex.ts +0 -5
  73. package/src/util/result.ts +0 -6
  74. package/src/util/sharp-shim.ts +12 -19
  75. package/src/util/tool.ts +17 -42
  76. package/src/util/truncate.ts +6 -0
  77. package/tsconfig.json +1 -16
  78. package/skills/deep-research/SKILL.md +0 -66
  79. package/src/cdp/event-queue.ts +0 -128
  80. package/src/cdp/transport.ts +0 -156
  81. package/src/cdp/window-binding.ts +0 -15
  82. package/src/deep-research.ts +0 -41
  83. package/src/domains/forms.ts +0 -355
  84. package/src/domains/readpage/capture.ts +0 -46
  85. package/src/domains/search/extract.ts +0 -44
  86. package/src/profile/picker.ts +0 -73
  87. package/src/util/keycodes.ts +0 -10
  88. package/src/util/time.ts +0 -29
@@ -0,0 +1,30 @@
1
+ export const isRecord = (v: unknown): v is Readonly<Record<string, unknown>> =>
2
+ typeof v === "object" && v !== null && !Array.isArray(v);
3
+
4
+ export const asRecord = (v: unknown): Readonly<Record<string, unknown>> | undefined =>
5
+ isRecord(v) ? v : undefined;
6
+
7
+ export const asString = (v: unknown): string | undefined => (typeof v === "string" ? v : undefined);
8
+
9
+ export const asNumber = (v: unknown): number | undefined => (typeof v === "number" ? v : undefined);
10
+
11
+ export const asBoolean = (v: unknown): boolean | undefined => (typeof v === "boolean" ? v : undefined);
12
+
13
+ export const asArrayOf = <T>(
14
+ v: unknown,
15
+ item: (x: unknown) => T | undefined,
16
+ ): ReadonlyArray<T> | undefined => {
17
+ if (!Array.isArray(v)) return undefined;
18
+ const out: T[] = [];
19
+ for (const x of v) {
20
+ const parsed = item(x);
21
+ if (parsed === undefined) return undefined;
22
+ out.push(parsed);
23
+ }
24
+ return out;
25
+ };
26
+
27
+ export const errnoCode = (e: unknown): string | undefined => {
28
+ if (!(e instanceof Error) || !("code" in e)) return undefined;
29
+ return asString(e.code);
30
+ };
@@ -1,19 +1,4 @@
1
- /**
2
- * Build a JavaScript source string with safely-interpolated values.
3
- * Every interpolated value is JSON.stringify'd, so strings, numbers,
4
- * objects, and special characters all become valid JS literals.
5
- *
6
- * This is the ONLY supported way to interpolate untrusted values into
7
- * evaluation source — never use raw template literals for JS that
8
- * crosses the CDP boundary. The static parts of the template (the
9
- * `strings` array) must remain hard-coded; only the `${...}` slots
10
- * are sanitized.
11
- *
12
- * Values must be JSON-serializable: BigInt and Symbol throw at runtime;
13
- * functions and undefined serialize to `undefined`/omitted, which yields
14
- * a ReferenceError when evaluated. Pass strings, numbers, booleans,
15
- * plain objects, or arrays.
16
- */
1
+ // The ONLY supported way to interpolate untrusted values into evaluation source — never use raw template literals for JS crossing the CDP boundary.
17
2
  export const safeJs = (
18
3
  strings: TemplateStringsArray,
19
4
  ...values: ReadonlyArray<unknown>
package/src/util/mutex.ts CHANGED
@@ -1,8 +1,3 @@
1
- /**
2
- * Lightweight async mutex (~10 lines). Acquire returns a release function.
3
- * When the mutex is free, acquire() resolves immediately. When held,
4
- * callers are queued and execute serially in FIFO order.
5
- */
6
1
  export type Mutex = {
7
2
  acquire(): Promise<() => void>;
8
3
  };
@@ -7,9 +7,3 @@ export const err = <E>(error: E): Result<never, E> => ({ success: false, error }
7
7
 
8
8
  export const map = <T, U, E>(r: Result<T, E>, f: (t: T) => U): Result<U, E> =>
9
9
  r.success ? ok(f(r.data)) : r;
10
-
11
- export const andThen = <T, U, E>(r: Result<T, E>, f: (t: T) => Result<U, E>): Result<U, E> =>
12
- r.success ? f(r.data) : r;
13
-
14
- export const mapErr = <T, E, F>(r: Result<T, E>, f: (e: E) => F): Result<T, F> =>
15
- r.success ? r : err(f(r.error));
@@ -1,10 +1,4 @@
1
- // Typed minimal shim for the optional `sharp` dependency. Sharp is a heavy
2
- // native module not installed in every workspace; we import dynamically and
3
- // distinguish "missing" (no install) from "error" (installed but threw).
4
- //
5
- // We declare only the surface we use. The dynamic import returns `unknown`
6
- // at the boundary; the cast inside loadSharp() is the documented escape
7
- // hatch — every other consumer sees the typed SharpFactory.
1
+ // `sharp` is an optional dependency not installed in every workspace, so the import must stay dynamic and unresolvable to tsc.
8
2
 
9
3
  export type SharpInstance = {
10
4
  metadata(): Promise<{ width?: number; height?: number }>;
@@ -20,26 +14,25 @@ export type SharpLoad =
20
14
  | { readonly kind: "missing" }
21
15
  | { readonly kind: "error"; readonly message: string };
22
16
 
17
+ const SHARP_SPECIFIER: string = "sharp";
18
+
19
+ const MISSING_MODULE_MARKERS: ReadonlyArray<string> = [
20
+ "Cannot find module",
21
+ "MODULE_NOT_FOUND",
22
+ "ERR_MODULE_NOT_FOUND",
23
+ ];
24
+
23
25
  export const loadSharp = async (): Promise<SharpLoad> => {
24
26
  let mod: unknown;
25
27
  try {
26
- // sharp is an optional dependency; it may not be installed.
27
- // @ts-ignore — optional peer dependency not required at compile time
28
- mod = await import("sharp");
28
+ mod = await import(SHARP_SPECIFIER);
29
29
  } catch (e) {
30
30
  const msg = e instanceof Error ? e.message : String(e);
31
- if (
32
- msg.includes("Cannot find module") ||
33
- msg.includes("MODULE_NOT_FOUND") ||
34
- msg.includes("ERR_MODULE_NOT_FOUND")
35
- ) {
36
- return { kind: "missing" };
37
- }
31
+ if (MISSING_MODULE_MARKERS.some((marker) => msg.includes(marker))) return { kind: "missing" };
38
32
  return { kind: "error", message: msg };
39
33
  }
40
34
  if (mod === null || mod === undefined) return { kind: "missing" };
41
- // The CommonJS sharp module's default export IS the factory. ESM-wrapped
42
- // sharp puts it on .default. Probe both.
35
+ // The CommonJS sharp module's default export IS the factory; ESM-wrapped sharp puts it on .default.
43
36
  const m = mod as { default?: unknown };
44
37
  const candidate: unknown = typeof m.default === "function" ? m.default : mod;
45
38
  if (typeof candidate !== "function") {
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
- };