docs/ARCHITECTURE.md (view raw)
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 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 |
# 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. Tool files hold tools; shared logic
lives in helper-only modules beside them (cdp-call, ax-tree,
element-call, box, ref-resolve, fill-engine, isolated-tab,
screenshot-capture, target). The only layer expected to grow.
|
domains/cdp-call.ts the ONLY door from a tool to the browser: cdpCall,
cdpCallOnTarget, cdpCallBrowser, evalJs, cdpErrToToolErr
|
client.ts BrowserClient facade: session, ownership, mutation mutex,
profile pin
|
cdp/session.ts typed call/callOnTarget/callBrowser, event routing,
console + network buffers
|
daemon/transport.ts -> daemon/* out-of-process daemon owning the Chrome socket
(CdpTransport is declared in cdp/types.ts)
|
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`, `truncate`, `paths`, `js-template`, `sharp-shim`, `debug`) | 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/target-factory`,
the buffers, and `cdp/session` for the `CdpSession` type and `evaluateJson`).
**It must never import from `daemon/` 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.
The import rule itself is **not** machine-enforced — verify it by eye in review. What *is*
enforced is the narrower rule that matters most: two boundary-scanner rules, `raw-cdp-call` and
`raw-evaluate`, are scoped to `src/domains/` and fail the build when any file but `cdp-call.ts`
reaches `session.call*` or `evaluateJs` directly. The scope matters — `client.ts` and `cdp/` are
the layer that legitimately owns the session, so the rules deliberately do not apply there.
The rules match the two call shapes that exist today (`client.session().call…` and a `CdpSession`
held in a local named `session`). They are a guardrail, not a proof: renaming that local would
slip past them.
---
## 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.
### How a tool reaches the browser
`src/domains/cdp-call.ts` is the only door, and the scanner enforces it (section 1):
| Helper | Use for |
|---|---|
| `cdpCall(client, method, params, opts?)` | the current tab |
| `cdpCallOnTarget(client, method, params, sessionId, opts?)` | a specific attached session |
| `cdpCallBrowser(client, method, params?, opts?)` | browser-level, no session |
| `evalJs(client, expression, sessionId?)` | evaluating JS, returns `unknown` |
| `cdpErrToToolErr(e, method)` | mapping a `CdpError` you got from elsewhere |
Each wraps the session call and maps `CdpError -> ToolErr`, so the
`if (!r.success) return err({ kind: "cdp_error", ... })` couplet is gone — a failed call is
returned as-is (`if (!r.success) return r;`).
`params` is **required** on `cdpCall`, unlike `session.call`. A no-argument CDP method needs an
explicit `{}`:
```ts
const r = await cdpCall(client, "Page.reload", {});
```
**A tool that wants to swallow a failure still goes through these helpers** — it just ignores the
error instead of returning it. `liveValue` in `src/domains/ax-tree.ts` returns `undefined` so the
caller falls back to the accessibility-tree value, and `snapshot`'s optional-screenshot block
simply omits the path. Both call `cdpCall` and discard the error. Reaching past the helper to
suppress an error is never the reason to do it.
One honest gap: `session.attach()` and `session.windowId()` are session methods that do perform a
CDP call, and `domains/navigate.ts` calls `attach` directly. The scanner's rules match `call*` and
`evaluateJs`, not these two.
---
## 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.** Anything a second file needs goes in a helper-only module beside it (`ax-tree.ts`, `element-call.ts`, `screenshot-capture.ts`, …) — never exported from a tool file. 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 `src/domains/cdp-call.ts`** — `cdpCall`, `cdpCallOnTarget`, `cdpCallBrowser`, `evalJs`. Machine-enforced by the `raw-cdp-call` and `raw-evaluate` scanner rules. | `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` |
Rule 1's two standing exceptions are largely settled: `snapshot.ts` went from 477 lines to 256
once the tree model moved to `ax-tree.ts`, and `form.ts` from 409 to 283 once the element-call
machinery moved to `element-call.ts`. Both sit at the guidance rather than far past it. Neither
is 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,domains,cdp,daemon,util}/**/*-test.ts`.** A test placed
in any other directory — `test/schemas/`, say — **silently never runs**, so adding a directory
means adding it here too. `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.
|