1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
|
# Architecture
How `pi-browser-harness` is put together, what each layer may depend on, and the rules a new
tool has to follow. Read the "Conventions that look like mistakes" section before you clean
anything up — several deliberate choices in this codebase look like typos.
The codebase carries almost no comments by policy. That makes this file the only place the
non-obvious conventions live.
---
## 1. Layers
Dependencies point downward only.
```
index.ts extension entry: flags, slash commands, session lifecycle hooks
|
registry.ts flat tool catalog (ALL_TOOLS, 40 tools): imports only, zero per-tool config
|
util/tool.ts tool runtime: liveness guard -> concurrency lock -> handler
-> Result-to-AgentToolResult conversion
|
domains/* one file per capability. Pure tool definitions.
The only layer expected to grow.
|
client.ts BrowserClient facade: session, ownership, mutation mutex,
profile pin
|
cdp/session.ts typed call/callOnTarget/callBrowser, event routing,
console + network buffers
|
cdp/daemon-transport.ts -> daemon/* out-of-process daemon owning the Chrome socket
|
Chrome (CDP over WebSocket)
```
Side stacks:
| Directory | Role | Who may import it |
|---|---|---|
| `profile/*` | decides *which* Chrome and which profile to drive | `index.ts`, `setup.ts`, `client.ts` — never a domain |
| `schemas/*` | shared typebox arg fragments (`Coords`, `MouseButton`) and `parseJson` | anything |
| `util/*` | leaves — they import nothing from this repo except each other (`result`, `guards`, `mutex`, `time`, `keycodes`, `truncate`, `paths`, `js-template`, `sharp-shim`) | anything |
`src/util/tool.ts` sits in `util/` for locality but is not a leaf — it is the tool runtime layer
in the diagram above and imports `client` and `daemon/spawn`.
### The import rule for domains
A `domains/*` file may import from `schemas/`, `util/`, other `domains/`, the `BrowserClient`
type, and the *typed, session-level* parts of `cdp/` (`cdp/commands` types, `cdp/attach`,
`cdp/window`, `cdp/evaluate`, `cdp/target-factory`, the buffers, the `CdpSession` type).
**It must never import from `daemon/`, `cdp/transport`, or `profile/`.** A domain that reaches
the raw transport has escaped the validated boundary in section 2; a domain that reaches
`profile/` has coupled a tool to Chrome-launch policy.
This currently holds across all of `src/domains/`. It is **not** machine-enforced —
`scripts/check-boundaries.ts` checks casts, not imports. Verify it by eye in review.
---
## 2. The CDP boundary
`src/cdp/commands.ts` (34 commands) and `src/cdp/events.ts` (12 events) are the single source
of truth for **both** the TypeScript types and the runtime validation. One typebox schema per
entry generates both, so they cannot drift.
```ts
const cmd = <P extends TSchema, R extends TSchema>(params: P, result: R) => ({
params,
result,
validate: Compile(result),
});
export const COMMANDS = {
"Target.attachToTarget": cmd(
Type.Object({ targetId: Type.String(), flatten: Type.Optional(Type.Boolean()) }),
Type.Object({ sessionId: Type.String() }),
),
// ...
};
export type CdpMethod = keyof typeof COMMANDS;
export type ParamsOf<M extends CdpMethod> = Static<(typeof COMMANDS)[M]["params"]>;
export type ResultOf<M extends CdpMethod> = Static<(typeof COMMANDS)[M]["result"]>;
```
`decodeResult` validates every response before resolving; `decodeEvent` does the same for
subscribed events. A malformed payload becomes `err({ kind: "invalid_response", ... })` instead
of a `TypeError` three frames later.
### Adding a command
1. Add one `cmd(params, result)` entry to `COMMANDS`, keyed by the CDP method name.
2. Call it. `session.call(method, params)` and `cdpCall(client, method, params)` are already
typed against the table. **No cast anywhere.**
### `additionalProperties: true` — the part that bites
`additionalProperties: true` makes the **runtime validator** permissive. It does **not** widen
the derived `Static<>` **type**. The consequence differs by direction, and getting it backwards
produces two different bugs:
| Schema | Under-specify | Over-specify |
|---|---|---|
| `params` | **compile error at the call site** — the field is not in `ParamsOf<M>`, so you cannot pass it | harmless |
| `result` / event params | field is simply unavailable to read | **silently dropped event or failed decode** when a future Chrome changes that field |
So:
- **Enumerate every field a call site passes.** Four of the six compile errors this refactor
had to fix were exactly this: `Runtime.callFunctionOn`'s params schema omitted `arguments`,
`returnByValue`, and `awaitPromise`, which the code genuinely sends.
- **On results and events, enumerate only what a consumer actually reads.** `Page.frameNavigated`
and `Page.loadEventFired` are declared as fully-open objects with no enumerated fields
(`ev(Anything)`) and never call `decodeEvent` at all — the session only needs to know they
fired.
### `cdpCall` vs `session.call`
`cdpCall(client, method, params)` (`src/domains/cdp-call.ts`) is `session().call` plus the
`CdpError -> ToolErr` mapping, retiring the `if (!r.success) return err({ kind: "cdp_error" })`
couplet after every call. Domains use it by default.
`params` is **required**, unlike `session.call`. A no-argument CDP method needs an explicit `{}`:
```ts
const r = await cdpCall(client, "Page.reload", {});
```
`src/domains/snapshot.ts` **deliberately mixes both styles.** Its three `session.call` sites —
two in `liveValue`, one in the optional-screenshot block — swallow the failure on purpose:
`liveValue` returns `undefined` so the caller falls back to the accessibility-tree value, and a
failed screenshot just omits the path. Neither should surface as a tool error. This is
intentional, not drift — do not convert them to `cdpCall`.
---
## 3. Keeping the type safety
### A green `tsc` proves nothing about unchecked casts
`as { … }` between structurally compatible object types compiles **silently**. At one point this
codebase held 33 such casts and produced only 6 compile errors when they were removed. Strict
flags do not see them.
Likewise, **`JSON.parse` returns `any`, so an annotated assignment is an invisible cast.**
`let msg: CdpRawMessage = JSON.parse(raw)` typechecked cleanly in `src/daemon/bridge.ts` while
being completely unguarded — a `null` frame from Chrome would have thrown inside a `ws` handler
with no `try`/`catch` above it and killed the daemon. That is why `src/schemas/parse.ts` exists:
`parseJson(raw, validator)` parses to `unknown`, validates, and returns a `Result`. Every parse
boundary goes through it or an equivalent guard.
### The boundary scanner
`npm run boundaries` runs `scripts/check-boundaries.ts` over every `.ts` file under `src/` and
fails the build on five line-level patterns:
| Rule | Matches |
|---|---|
| `as-any` | `as any` |
| `ts-ignore` | `@ts-ignore`, `@ts-expect-error`, `@ts-nocheck` |
| `non-null-assertion` | `x!.`, `x!(`, `x!;` … |
| `inline-object-cast` | `as {` |
| `double-cast` | `as unknown as` |
Exemptions live in an **external allowlist**, `scripts/boundary-allowlist.ts`, as
`{ path, rule, count, reason }` entries. The `count` is a budget, not a flag: a *second*
violation of the same rule in an already-exempt file still fails the build.
The allowlist is deliberately external rather than a comment marker in the source, because the
zero-comments policy would eventually delete an in-source marker and turn CI red for an
unrecognisable reason. `unusedExemptions` also reports an entry whose code is gone, so the list
cannot rot.
**Limitations, stated rather than implied:** the scan is **line-based**, so a cast split across
two lines evades every rule. And it only walks `src/` — `test/` and `scripts/` are unscanned.
### The five surviving assertions in `src/`
All five are boundary assertions where a runtime check has already established the shape and
TypeScript cannot express the correlation.
| Location | Why |
|---|---|
| `decodeResult` in `src/cdp/commands.ts` | `validate.Check(raw)` proves `raw` matches `COMMANDS[method].result`; TS cannot correlate a generic key to its mapped value |
| `decodeEvent` in `src/cdp/events.ts` | same, for `EVENTS[name].params` |
| `loadSharp` in `src/util/sharp-shim.ts` (probe for `.default`) | the module comes from a dynamic import of an optional dependency, so its shape is unknown at compile time; the cast only claims an optional `.default`, and every value read off it is re-checked with `typeof` |
| `loadSharp` in `src/util/sharp-shim.ts` (`candidate` to `SharpFactory`) | `typeof candidate === "function"` was checked immediately above |
| `registerBrowserTool` in `src/util/tool.ts` (`renderCall` re-widening) | `AnyBrowserToolDefinition` is type-erased so the registry can hold a heterogeneous array; `defineBrowserTool` guarantees handler, parameters, and `renderCall` were built from the same `S` |
Only the `sharp-shim.ts` `.default` probe needs an allowlist entry, because it is the one written
as `as { … }`. The other four are `as SomeNamedType`, which no scanner rule matches. **Do not add
allowlist entries for them** — they are not required, and `unusedExemptions` would flag them.
---
## 4. The tool authoring contract
Nine rules. Every `domains/*` file follows them.
| # | Rule | Exemplar |
|---|---|---|
| 1 | **One domain per file**, exporting only `<name>Tool` consts plus helpers it owns. Past roughly 250 lines, split by capability. | `src/domains/history.ts` |
| 2 | **Args schema at the top of the file**, named `<Name>Args`, built with typebox, with a `description` on every field. Those strings are the agent's only documentation of the tool. | `src/domains/keyboard.ts` (`TypeArgs`, `PressKeyArgs`) |
| 3 | **`defineBrowserTool({ … })` holds all metadata, including `concurrency`.** A tool's concurrency class must not live in a different file from the tool. | every domain file |
| 4 | **Fixed handler signature** `(args, ctx) => Promise<Result<ToolOk, ToolErr>>`. Never throw, never `console.log`, never touch `process`. Errors are values. | `src/util/tool.ts` (`ToolHandler`) |
| 5 | **Reach Chrome only via `ctx.client`.** No domain imports the transport. | see section 1 |
| 6 | **Element targeting goes through the shared `resolveTarget(client, args)`** in `src/domains/target.ts` — never a hand-rolled ref-vs-x/y branch. | `src/domains/click.ts` |
| 7 | **CDP calls go through `cdpCall(client, method, params)`.** | `src/domains/history.ts` |
| 8 | **Naming:** tool name `browser_<verb>`, `label` title-case, `promptSnippet` one line, `promptGuidelines` at most six agent-facing bullets. | pinned by `test/domains/registry-test.ts` |
| 9 | **Every tool has a test** covering its schema and its error paths, written against a fake client so no browser is needed. | `test/domains/form-errors-test.ts` |
Standing exceptions to rule 1: `src/domains/snapshot.ts` (477) and `src/domains/form.ts` (409)
are over the 250-line guidance. Both are single coherent capabilities; splitting them is
tracked work, not licence to add a third.
Rule 6 currently binds only `src/domains/click.ts`, because it is the sole tool taking
*either* a ref *or* coordinates. `drag.ts` takes explicit coordinates only and `scroll.ts`
defaults to the viewport centre; neither resolves refs. Any *new* ref-or-coords tool must use
`resolveTarget`.
Rules 8 and 9 are partly machine-checked. `test/domains/registry-test.ts` asserts every tool
declares a concurrency class, that names are unique, that every name is `browser_`-prefixed, and
that the set of `serialized` tools is **exactly** a pinned list — so adding a serialized tool
means adding its name to `SERIALIZED` in that file.
---
## 5. How to add a tool
`src/domains/history.ts` (80 lines, three tools) is the whole pattern in one file. A minimal
tool, abridged from `reloadTool` in that file — the real one also branches on an open JS dialog:
```ts
import { Type } from "typebox";
import { type Result, err, ok } from "../util/result";
import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
import { cdpCall } from "./cdp-call";
export const reloadTool = defineBrowserTool({
name: "browser_reload",
label: "Browser Reload",
description: "Reload the current page.",
promptSnippet: "Reload the page",
promptGuidelines: ["Use to refresh the page, e.g. after server-side changes."],
parameters: Type.Object({}),
concurrency: "serialized",
async handler(_a, { client }): Promise<Result<ToolOk, ToolErr>> {
const r = await cdpCall(client, "Page.reload", {});
if (!r.success) return r;
const info = await client.pageInfo();
if (!info.success) return err({ kind: "cdp_error", message: info.error.message });
return ok({ text: `Reloaded: ${info.data.title}`, details: { page: info.data } });
},
});
```
Note `if (!r.success) return r;` — a failed `cdpCall` is already a `Result<_, ToolErr>`, so it
returns unchanged. No re-wrapping.
Steps:
1. Create `src/domains/foo.ts`. Declare `FooArgs` at the top with a `description` on every field.
2. If the CDP method you need is not in `COMMANDS`, add a table entry first (section 2).
3. Call `defineBrowserTool` with an explicit `concurrency`. Pick `serialized` if the tool mutates
page or session state; `parallel` only for pure reads.
4. Add the export to `ALL_TOOLS` in `src/registry.ts`.
5. If it is `serialized`, add its name to `SERIALIZED` in `test/domains/registry-test.ts`.
6. Write `test/domains/foo-test.ts`.
### Testing a handler
Use **`createFakeClient()`** from `test/domains/fake-client.ts`. It builds a real `CdpSession`
over a **stub transport** — canned per-method results, recorded calls, and an injectable event
stream:
```ts
const ctxFor = (fake: FakeClient): HandlerContext => ({
client: fake.client,
signal: undefined,
onUpdate: () => {},
extensionCtx: undefined as never,
});
const fake = await createFakeClient({
refs: [{ ref: "e1", backendId: 101 }],
canned: {
"DOM.resolveNode": ok({ object: { type: "object", objectId: "obj-1" } }),
"Runtime.callFunctionOn": ok({
result: { type: "undefined" },
exceptionDetails: { text: "Uncaught TypeError: el.dispatchEvent is not a function" },
}),
},
});
const r = await fillTool.handler({ ref: "e1", value: "x" }, ctxFor(fake));
assert.equal(r.success, false);
if (r.success) return;
assert.equal(r.error.kind, "cdp_error");
assert.match(r.error.message, /dispatchEvent is not a function/);
```
`canned` maps a CDP method to a `Result` (or a queue of them, consumed in order); `refs` seeds the
session's ref map so a `ref`-taking tool resolves without a snapshot; `evaluate` stubs
`client.evaluateJs`. `fake.calls` / `fake.callsTo(method)` are the recorded requests, and
`fake.emit(event)` injects a CDP event. `test/domains/form-errors-test.ts` is the reference usage.
**Do not mock the `BrowserClient` instead.** A mocked client bypasses the real session, the real
decoders, and the real error mapping, which produces a test that cannot fail. Two tasks on this
refactor shipped exactly that before `createFakeClient` existed.
It also exports `createStubTransport` (for session-level tests) and `axNode` (for
accessibility-tree fixtures).
---
## 6. Conventions that look like mistakes
Each of these has been, or would be, "fixed" by someone who did not know why it was written
that way.
**`src/util/tool.ts` gates the lock on `if (def.concurrency !== "parallel")`, not
`=== "serialized"`.** Deliberate fail-closed default: an unrecognised or missing concurrency
value **over-serialises** rather than racing on shared CDP session and page state. A test in
`test/domains/registry-test.ts` ("a definition with no concurrency class takes the mutation
mutex") pins it.
**`src/util/sharp-shim.ts` routes its dynamic import through
`const SHARP_SPECIFIER: string = "sharp"`** so TypeScript cannot resolve the specifier
statically. `sharp` is an **optional** dependency. **Anyone who "simplifies" this back to a
static `import("sharp")` breaks the build wherever sharp is absent.** This is the single most
likely thing in the codebase to be broken by a well-meaning cleanup.
**`npm test` passes `--conditions=import`.** A test can import a tool definition *only* because
of it: `@mariozechner/pi-coding-agent` publishes no `"require"` condition, and this repo is not
`"type": "module"`, so tsx would otherwise compile `src/` to CJS and the specifier would be
unresolvable. It also *matches* production — pi loads the extension with jiti and aliases that
package to `dist/index.js` by absolute path, which is what the `import` condition selects.
**The test glob is `test/{profile,deep-research,domains,cdp,util}/**/*-test.ts`.** A test placed
in any other directory — `test/schemas/`, `test/daemon/` — **silently never runs.** `test/manual/`
is excluded on purpose: those need live Chrome.
**`Box` (`src/domains/box.ts`) has six fields — `x, y, width, height, cx, cy` — and all six are
consumed.** `cx`/`cy` are the click point. `width`/`height` reach the model through
`browser_snapshot` with `format: "json"`, which serialises each node's `box` wholesale.
Narrowing the type would silently change tool output.
---
## 7. The failure mode that cost the most rework
**Adopting a type guard where a loose truthiness check lived can invert a safety default.**
`if (guard(x)) { if (!x.ok) fail }` is **not** equivalent to `if (!x.ok) fail`. With an unchecked
cast, a malformed payload left the field `undefined`, so `!field` was **true** and the failure
path fired. Fold the guard into the boolean gate and a rejected shape now **skips the check
entirely** — the code proceeds in exactly the case where it knows least.
The structural tell, verified across six sites:
- **SAFE** — the guard produces a **value** that inherits a pre-existing failure path:
```ts
const v = guard(x) ? x : undefined;
if (v === undefined || !v.ok) return err(…);
```
- **UNSAFE** — the guard is **ANDed into a boolean gate**:
```ts
if (ok && guard(x)) { … }
```
`typeTool` in `src/domains/keyboard.ts` has both halves within six lines. The inner line is the
safe form — `isFocusProbe(focused.data) ? focused.data : { ok: false, tag: null }` turns a bad
shape into a **value** meaning "not focused", which the existing `if (!f.ok)` then rejects. The
outer `if (focused.success)` is the same mistake in its other guise: a gate, so when the focus
probe itself fails the entire check is skipped and the tool types anyway. That is a known open bug
(F9 in the bug audit), left in place here as the clearest illustration in the tree.
Rule of thumb: **a check must default to the failure path when its input cannot be understood.**
If adding a guard moved a case from "fails loudly" to "not checked", the guard is in the wrong
place.
---
## 8. Running the checks
```bash
npm run check # typecheck + boundaries + test
```
- `npm run typecheck` — `tsc --noEmit` over `src/`, then `tsc -p tsconfig.test.json` over
`src/`, `test/`, and `scripts/`. Flags live in `tsconfig.base.json`; the notable ones beyond
`strict` are `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`,
`noPropertyAccessFromIndexSignature`, and `verbatimModuleSyntax`.
- `npm run boundaries` — the cast scanner (section 3).
- `npm test` — `node:test` over the glob above.
`npm run check` must be green before a PR.
|