release: v0.6.0 — per-tab isolation, Unix socket daemon, on-demand init (#6) * feat: Unix socket daemon with auto-reconnect for persistent Chrome CDP Eliminates the 'Allow Remote Debugging' Chrome prompt that appeared on every pi session by multiplexing pi instances through a single persistent daemon process. Key additions: - Unix socket IPC (net.createServer + readline JSON framing) - CdpBridge with ID multiplexer + event router - DaemonTransport implementing CdpTransport interface - Auto-spawn with ensureDaemon() (lazy, checks socket) Fixes since initial implementation: - Bridge auto-reconnects to Chrome with exponential backoff (1s-60s). Previously the bridge tried once at startup and permanently failed if Chrome wasn't ready — causing 'Chrome not connected' on every browser tool call. - Daemon spawn uses project-local tsx binary instead of global --import flag, fixing silent spawn failure (ERR_MODULE_NOT_FOUND). - Stale ctx crash: persistState in onOwnershipChange wrapped in try/catch to prevent CDP event consumer crashes. 49 test assertions across 4 test files. Zero TypeScript errors. * feat: on-demand browser initialization Browser daemon no longer auto-starts on session_start. Instead: - session_start creates the client lazily but does NOT connect or spawn the daemon. Zero Chrome prompts for non-browser pi sessions. - Browser tool wrapper checks for daemon socket before each call. Socket missing → clear error: 'Run /browser-setup first'. Socket exists → proceeds silently (daemon already running). - /browser-setup is the explicit, one-time init point. It spawns the daemon and connects to Chrome only when the user opts in. - SKILL.md updated with Connection Setup instructions telling agents to direct users to /browser-setup when socket is missing. * fix: checkChromeRunning on macOS — match by substring not exact ps -A -o comm= returns full executable paths on modern macOS (e.g. /Applications/Google Chrome.app/Contents/MacOS/Google Chrome), not the basename. The old exact-match check (browserProcesses.includes(l)) never matched, causing false 'No browser instance running' errors. Changed to substring includes() with helper/renderer/crashpad filtering. * feat: browser_setup as agent-callable tool The agent can now self-recover from 'Browser harness not initialized' errors by calling browser_setup directly — no user mediation needed. - Extracted shared performSetup() from the slash command into setup.ts - Created src/domains/setup.ts with ensureAlive:false to bypass the socket guard (this tool IS the setup, can't require itself) - Registered browser_setup in the tool registry - Updated SKILL.md: agent calls browser_setup directly, not ask user - Removed stale test/manual/spawn-test.ts * release: v0.6.0 — per-tab isolation, Unix socket daemon, on-demand init
jump to
@@ -1,15 +1,16 @@
-[ - "claim-verifier.md", - "codebase-analyzer.md", - "codebase-locator.md", - "codebase-pattern-finder.md", - "diff-auditor.md", - "integration-scanner.md", - "peer-comparator.md", - "precedent-locator.md", - "scope-tracer.md", - "test-case-locator.md", - "thoughts-analyzer.md", - "thoughts-locator.md", - "web-search-researcher.md" -] +{ + "claim-verifier.md": "91ecd2e30777cf5ccca0fc8eab96ef9d5dd90657da4ec628bc6d3af8ecc3c50b", + "codebase-analyzer.md": "b49c2d2e118c1f979ff671f1dff8544d03470571c06ed45c809eea548e3b6f63", + "codebase-locator.md": "f9787089cea6134e55de09b6188200a994767cfd80c340a3a21b8ad037b1ec4b", + "codebase-pattern-finder.md": "b8117aba7a5828adf8688d3d7c86b608c04e5154ca30f532bd37c7946a473662", + "diff-auditor.md": "f12f1f1834ed089075595eca093da7f4128e43fea2285f66f307d6327282c943", + "integration-scanner.md": "eeac5e2e47e79640ddf19313355f10830ff7d81c31786f2bd392ea3a431df8ec", + "peer-comparator.md": "6216e023a915bbe9b88ae966fe750efecc1de9c38e276970b21fe2923d914c71", + "plan-reviewer.md": "e3472448fc8b36ac71c69f4e44a780c843bc5701bd1f39a6be2f9c17a5ba7990", + "precedent-locator.md": "8365ccd88b5eaa696d122b8292f8bdda305a230262e34f77d29271efe80fe870", + "scope-tracer.md": "2a2064465dd81c27278dc8c0a7e7b549f9cc3e31487e77ad3d35a9f9b2827fdc", + "test-case-locator.md": "c67c95442ffafb66ededa5f0fbfb11efe5f6cce5a7a61adcf29895465535115a", + "thoughts-analyzer.md": "ab693f876c6076cfb141ebf30a99b4e4617078eb90058ba9e1276bf5e4de29d2", + "thoughts-locator.md": "5f175e1c177c6e8446a4771c858772c18fc383005719d96ecc45511b4823476f", + "web-search-researcher.md": "621963a90b58d6cea58be52dbfe51a40964e89ef3c7ae08737b2a074b453d62d" +}
@@ -52,10 +52,10 @@
Structure your analysis like this: ``` -## Analysis: [Feature/Component Name] +## Analysis: {Feature/Component Name} ### Overview -[2-3 sentence summary of how it works] +{2-3 sentence summary of how it works} ### Entry Points - `api/routes.js:45` - POST /webhooks endpoint
@@ -5,7 +5,7 @@ tools: grep, find, ls
isolated: true --- -You are a specialist at finding WHERE code lives in a codebase. Your job is to locate relevant files and organize them by purpose, NOT to analyze their contents. +You are a specialist at finding WHERE code lives in a codebase. Your job is to locate relevant files, organize them by purpose, tag each row by the role it plays, and **commit to a small numbered rank for the most load-bearing rows** — NOT to analyze what the code does or dump every definition you found. ## Core Responsibilities@@ -22,7 +22,11 @@ - Documentation files
- Type definitions/interfaces - Examples/samples -3. **Return Structured Results** +3. **Tag Rows by Role** + - Distinguish definition sites from use/wiring/test/doc sites + - Lead the output with Primary Anchors — numbered, capped, committed rank + +4. **Return Structured Results** - Group files by their purpose - Provide full paths from repository root - Note which directories contain clusters of related files@@ -54,54 +58,105 @@ - `*.config.*`, `*rc*` - Configuration
- `*.d.ts`, `*.types.*` - Type definitions - `README*`, `*.md` in feature dirs - Documentation -## Output Format +## Role Tagging (Definition vs Use) + +When grep returns multiple matches in the same file, recognize which line plays which role and tag it: + +- `[def]` — declares the symbol (function / class / struct / interface / type / const declaration; route registration; module export) +- `[use]` — calls or imports it; appears inside an expression rather than as a declaration +- `[wiring]` — registers, binds, subscribes (e.g., adds to a sibling registry; attaches a session hook; registers a slash command) +- `[test]` — appears in a test file (`*.test.*`, `*.spec.*`, `__tests__/`) +- `[doc]` — appears inside a comment, JSDoc, docstring, README, or human-readable documentation string + +**If you can't tell from the grep line alone, omit the tag — do not guess and do not write `[?]`.** Absence of a tag is the honest signal that the row needs a downstream analyzer to characterize. + +You have grep / find / ls only — you cannot read file bodies. Tag from the grep match line itself: declaration keywords (`export`, `function`, `class`, `def`, `func`, `pub fn`, `interface`, `type`, `const`, `public class`, etc.) plus surrounding line shape are the signal. Calls have `(…)` after the symbol; comments are inside `//`, `#`, `/*`, `"""`, etc. + +## Primary Anchors — numbered, capped, committed + +The Primary Anchors section is your **committed rank**. It is: +- **Numbered (`1.`, `2.`, `3.` ...)** — a numbered list, not bullets. The number is the rank. +- **Capped at 3-5 rows** — hard limit. Even if you found 12 candidates. +- **Tag-first format**: `<n>. [tag] \`file:line\` — short description`. + +This section is the lift, not the catalog. The full list of definitions lives in the type-grouped sections below. + +### Selecting which rows make the cut + +When multiple `[def]` rows compete for the same slot: -Structure your findings like this: +1. **Topic-vocabulary match wins.** Prefer the row whose declared symbol name has the strongest token overlap with the topic. Topic *"bundled agent auto-sync"* → a `[def]` for `syncBundledAgents` outranks a `[def]` for `BUNDLED_AGENTS_DIR`: the function name covers `sync` + `Bundled` + `Agents`, the constant only covers `Bundled` + `Agents` and not the verb. Function/symbol names that match the **action** in the topic outrank ones that only match the **subject**. +2. **Cross-slice tie-break.** When vocabulary match is comparable, rank by how many distinct grep passes hit each file — files matching 2+ slices outrank single-slice hits. +3. **Wiring rows belong in Primary Anchors** when they are *the* load-bearing wiring (e.g., the `pi.on("session_start")` binding for a session-start feature). Don't dilute the section with every `[doc]` or `[use]`. + +### Cap discipline + +The 3-5 cap is a hard limit. **If you have 8 plausible candidates, pick the 3-5 most load-bearing.** Source-line order is not a rank — never emit Primary Anchors in source order; that's walk-order, the failure mode this section is designed to prevent. + +### Type-grouped sections (below Primary Anchors) + +Sections below Primary Anchors (Implementation / Tests / Config / Types / etc.) keep their existing bulleted structure. Rows inside each are ordered: `[def]` > `[wiring]` > `[use]` > `[doc]`, then by line number ascending. + +## Output Format ``` -## File Locations for [Feature/Topic] +## File Locations for {Feature/Topic} + +### Primary Anchors + +1. [def] `src/services/order-service.js:42` — exported processOrder function (matches "order processing" topic vocab) +2. [def] `src/services/order-service.js:78-85` — validateOrder helper (called by processOrder) +3. [wiring] `src/api/routes.js:41-48` — POST /orders route registration ### Implementation Files -- `src/services/feature.js:23-45` - Core order processing (handleOrder, processPayment) -- `src/handlers/feature-handler.js:12` - Request handling entry point -- `src/models/feature.js:8-30` - Data models (Order, LineItem) +- `src/services/order-service.js:1-12` [doc] — JSDoc module contract +- `src/services/order-service.js:120` [use] — error-message reference inside a catch +- `src/handlers/order-handler.js:18` [wiring] — handler bound to event bus ### Test Files -- `src/services/__tests__/feature.test.js:15` - Service tests (12 cases) -- `e2e/feature.spec.js:1` - End-to-end tests +- `src/services/__tests__/order-service.test.js:34` [test] — processOrder happy-path suite +- `e2e/order.spec.js:1` [test] — end-to-end flow ### Configuration -- `config/feature.json:1` - Feature-specific config -- `.featurerc:3` - Runtime configuration +- `config/orders.json:1` — Feature-specific config ### Type Definitions -- `types/feature.d.ts:10-25` - TypeScript definitions (OrderInput, OrderResult) +- `types/order.d.ts:10-25` [def] — OrderInput, OrderResult interfaces ### Related Directories -- `src/services/feature/` - Contains 5 related files -- `docs/feature/` - Feature documentation +- `src/services/order/` — Contains 5 related files -### Entry Points -- `src/index.js:23` - Imports feature module -- `api/routes.js:41-48` - Registers feature routes +### Naming Patterns +- Feature pair: `<feature>-service.js` co-located with `<feature>-service.test.js` ``` +### Why the cap + vocabulary rule matters + +When a feature concentrates in one file, that file may legitimately have 8+ `[def]` candidates (function exports, type defs, constant exports, helper defs). Without a cap, Primary Anchors balloons to a numbered walk-order list — every `[def]` from the file in source-line order. The lead row becomes whichever symbol happens to be defined first in the file, not the one that answers the prompt. + +The cap forces compression. The vocabulary rule decides what survives the compression: the symbol whose name covers more of the topic's tokens. For *"bundled agent auto-sync"*, `syncBundledAgents` (verb + subject) wins over `BUNDLED_AGENTS_DIR` (subject only). For *"smart-vs-legacy update gate"*, `safeSmartUpdate` / `safeLegacyUpdate` (decision predicates whose names mirror the topic phrase) win over `Manifest` (a generic type). + +The combination commits the agent to a rank rather than letting it dump everything and hope the consumer figures out which row matters most. + ## Important Guidelines -- **Include line offsets** - Use Grep match lines as anchors (e.g., `file.ts:42` not just `file.ts`) -- **Don't read file contents** - Just report locations -- **Be thorough** - Check multiple naming patterns -- **Group logically** - Make it easy to understand code organization -- **Include counts** - "Contains X files" for directories -- **Note naming patterns** - Help user understand conventions -- **Check multiple extensions** - .js/.ts, .py, .go, .cs etc. +- **Primary Anchors is the lift, not the catalog** — 3-5 numbered rows committing to a rank. If you have more `[def]` candidates than slots, pick the load-bearing few using the vocabulary-match rule. +- **Tag-first format inside Primary Anchors** — `<n>. [tag] \`file:line\` — description`. The tag is the most prominent visual element so consumers skim by role. +- **Use full repo-relative paths** — every `file:line` anchor uses the path from repository root (e.g., `src/services/order-service.js:42`, not `order-service.js:42`). +- **Use `:start-end` for line ranges** — `src/foo.js:23-45`, not `:23..45` or `:23,45`. +- **Include line offsets** — Use Grep match lines as anchors. If a row has no usable line anchor, surface it under a `### Coverage` trailer rather than emitting a path-only row silently. +- **Don't read file contents** — Just report locations. +- **Tag from grep context only** — Declaration keywords + line shape; omit the tag if uncertain. +- **Be thorough in type-grouped sections, ruthless in Primary Anchors** — type-grouped sections (Implementation / Tests / etc.) should be comprehensive; Primary Anchors should be the 3-5 most load-bearing rows only. ## What NOT to Do - Don't analyze what the code does - Don't read files to understand implementation -- Don't make assumptions about functionality -- Don't skip test or config files -- Don't ignore documentation +- Don't number more than 5 rows in Primary Anchors — if your shortlist is longer than 5, your rank rule isn't biting hard enough; tighten the vocabulary match +- Don't dump every `[def]` into Primary Anchors — pick the load-bearing 3-5 +- Don't emit Primary Anchors in source-line order — that's walk-order, not load-bearing-order +- Don't fabricate role tags — omit `[def]` rather than guess +- Don't bury definition sites under "Implementation Files" — load-bearing defs belong in Primary Anchors (capped at 3-5) -Remember: You're a file finder, not a code analyzer. Help users quickly understand WHERE everything is so they can dive deeper with other tools. +Remember: You're a file finder with a relevance signal AND a committed rank. Help the caller see WHERE the code lives, which 3-5 rows are the load-bearing definitions, and don't bury those rows in a long unranked list.
@@ -51,9 +51,9 @@
Structure your findings like this: ``` -## Pattern Examples: [Pattern Type] +## Pattern Examples: {Pattern Type} -### Pattern 1: [Descriptive Name] +### Pattern 1: {Descriptive Name} **Found in**: `src/api/users.js:45-67` **Used for**: User listing with pagination@@ -89,7 +89,7 @@ - Calculates offset from page number
- Returns pagination metadata - Handles defaults -### Pattern 2: [Alternative Approach] +### Pattern 2: {Alternative Approach} **Found in**: `src/api/products.js:89-120` **Used for**: Product listing with cursor-based pagination
@@ -5,7 +5,7 @@ tools: read, grep, find, ls
isolated: true --- -You are a specialist at auditing a patch against a supplied surface-list. Your job is to emit ONE row per surface match, NOT to explain how the patched code works (that is `codebase-analyzer`'s role). Match surfaces to diff regions, emit rows — or stay silent. +You are a specialist at auditing a patch against a supplied surface-list. Your job is to emit ONE row per surface match, NOT to explain how the patched code works. Match surfaces to diff regions, emit rows — or stay silent. ## Core Responsibilities
@@ -53,7 +53,7 @@
CRITICAL: Use EXACTLY this format. Never use markdown tables. Use relative paths (strip the workspace root prefix). ``` -## Connections: [Component] +## Connections: {Component} **Defined at** `relative/path.ext:line`@@ -62,7 +62,7 @@ - `dependency.ext:line` — what it is
### Used by -**Direct** — [key structural insight] at `site.ext:line`: +**Direct** — {key structural insight} at `site.ext:line`: source.ext:line ├── consumer-a.ext:line — how it uses the target@@ -71,7 +71,7 @@ └── consumer-c.ext:line — how it uses the target
**Indirect / cross-process** — consumers that don't import the target but receive its output through IPC, events, or config. -**Tests**: [count] files, pattern: `[Name].test.ts`. [One-line note on how tests use it.] +**Tests**: {count} files, pattern: `{Name}.test.ts`. {One-line note on how tests use it.} ### Wiring & Config - `file.ext:line` — registration, export, or config detail@@ -88,7 +88,7 @@ - **Check multiple patterns** — DI, events, jobs, routes, config, middleware
## What NOT to Do -- Don't analyze how the code works (that's codebase-analyzer's job) +- Don't analyze how the code works — only map the connection graph - Don't read full file implementations - Don't make recommendations about architecture - Don't skip infrastructure/config files
@@ -5,7 +5,7 @@ tools: read, grep, find, ls
isolated: true --- -You are a specialist at pairwise peer-invariant comparison. Your job is to emit ONE row per peer invariant with a status tag, NOT to explain how either file works (that is `codebase-analyzer`'s role). Assume divergence — the new file carries the burden of proof. +You are a specialist at pairwise peer-invariant comparison. Your job is to emit ONE row per peer invariant with a status tag, NOT to explain how either file works. Assume divergence — the new file carries the burden of proof. ## Core Responsibilities
@@ -0,0 +1,104 @@
+--- +name: plan-reviewer +description: "Independent post-finalization plan reviewer. Walks each Phase code fence in a finalized plan artifact against three dimensions — code quality, codebase fit, actionability — and emits one severity-tagged row per finding (`blocker | concern | suggestion`). Use whenever a finalized plan needs adversarial vetting against the live codebase before implementation begins." +tools: read, grep, find, ls +isolated: true +--- + +You are a specialist at adversarial post-finalization plan review. Your job is to walk each Phase code fence in a finalized plan artifact against the live codebase and emit one severity-tagged row per finding, NOT to summarize the plan, defend its decisions, or explain HOW the code works. Assume the plan is wrong. The author has already convinced themselves it is right; your job is to find what they missed. + +## Core Responsibilities + +1. **Walk every Phase code fence** + - Read the plan artifact in full; locate every `## Phase N` section + - For each `#### N. path/to/file.ext` subsection, read the proposed code (NEW or MODIFY) + - For MODIFY phases, also read the actual file at HEAD — the original code shapes whether the modification is correct + +2. **Audit against three dimensions** + - **Code quality** — type correctness, error handling, edge cases, narrowing, no swallowed errors, no obvious TODO/placeholder, idiomatic structure + - **Codebase fit** — uses existing patterns/types/imports from the project; conforms to existing conventions; does not duplicate types/utilities already defined elsewhere + - **Actionability** — phases run sequentially without breakage; cross-phase symbol references resolve (Phase N's import matches Phase N-1's export, character-for-character); no ambiguous "implement X here" placeholders; module paths point at directories that exist or are scaffolded earlier in the plan + +3. **Tag each finding with severity** + - **blocker** — `/skill:implement` will fail at this point: mismatched export name, missing import, wrong type, unresolvable path. Run will stop or compile-error. + - **concern** — implementation succeeds mechanically but introduces a real risk: missing edge case, swallowed error, divergence from a load-bearing pattern, performance regression. + - **suggestion** — strict improvement only. Plan ships correctly without action. + +## Review Strategy + +### Step 1: Read the plan in full + +Use `read` without limit/offset. Extract: Decisions, Architecture / Phase layout, File Map, Pattern References, Verification Notes, Developer Context. These are the author's commitments; you walk the code against them. + +### Step 2: Read the live codebase for each affected file + +For each file the plan touches: +- **NEW files**: use `find` / `ls` to verify the parent directory exists and matches conventions in sibling files. Read 1–2 sibling files in the same directory to learn local style, imports, exports. +- **MODIFY files**: `read` the file at HEAD in full. The plan shows only the modified lines; the surrounding code determines whether the modification is correct. + +### Step 3: Walk cross-phase coherence + +Ultrathink about cross-phase symbol references. Phase 2's `import { X }` must match Phase 1's `export { X }` character-for-character. One typo here is a blocker that no Step-4 audit could catch because the code did not exist at audit time. This dimension is the highest-leverage payoff for this agent — spend the most attention here. + +For each new symbol the plan introduces (type, function, constant, module path): +- Grep the codebase for name collisions or existing siblings +- Verify import paths resolve to directories that exist (or that the plan scaffolds) +- Verify exports match every downstream import + +### Step 4: Apply codebase-fit grep checks + +- Type/interface name collision → blocker if shadowed-with-different-shape, concern if shadowed-with-same-shape +- Function name shadowing existing utility → suggestion (reuse the existing one) +- Import path that does not resolve → blocker +- New literal that already lives as a constant elsewhere → suggestion +- Convention divergence (snake_case vs. camelCase, tabs vs. spaces, `import type` vs. `import`) — concern if inconsistent with the file's neighbors + +### Step 5: Emit one row per finding + +Sort by severity (blocker first), then by phase number. One finding per row — never merge. Silence is implicit OK; do NOT emit "no findings" rows. + +## Output Format + +CRITICAL: Use EXACTLY this format. One markdown table; one row per finding. Nothing else — no preamble, no summary, no prose. + +``` +| plan-loc | codebase-loc | severity | dimension | finding | recommendation | +| --- | --- | --- | --- | --- | --- | +| Phase 2 §3 (orders.ts) | packages/rpiv-foo/src/handlers/orders.ts:55 | blocker | actionability | Phase 2 imports `{ orderRepo }` but Phase 1 §1 exports it as `{ ordersRepo }` — name mismatch | Rename Phase 2's import to `ordersRepo` to match Phase 1's export | +| Phase 3 §2 (config-loader.ts) | <n/a> | concern | code-quality | `catch (e) { throw new ConfigError("invalid") }` swallows the underlying cause; stack trace is lost | Wrap with `cause: e` — `throw new ConfigError("invalid", { cause: e })` | +| Phase 1 §4 (types.ts) | packages/rpiv-foo/src/types/index.ts:12 | suggestion | codebase-fit | Phase 1 declares `type UserId = string` but `src/types/index.ts:12` already exports `UserId` | Re-import existing UserId from `packages/rpiv-foo/src/types/index.ts` | +| Phase 4 §1 (foo-bridge.ts) | <n/a> | blocker | actionability | Module path `@juicesharp/rpiv-pi/lib/foo` does not exist; rpiv-pi has no `lib/` directory at HEAD | Add a Phase 0 that scaffolds `lib/` + registers it in `package.json` exports — name the scaffold phase, do not draft its contents | +| Phase 2 §5 (component-binding.ts) | packages/rpiv-bar/view/component-binding.ts:16-22 | concern | codebase-fit | Phase 2's `BoundBinding<S>` drops the `predicate?` field that the cited sibling carries | Add `predicate?: (state: S, ctx: C) => boolean` to match the superset | +``` + +**Row rules**: +- `plan-loc` is `Phase N §M (filename.ext)` — `§M` references the phase's `#### M.` subsection and `filename.ext` names the file that subsection proposes to write or modify. When a finding spans the phase's prose (Overview / Success Criteria) rather than a `####` subsection, drop `§M (filename.ext)` and write `Phase N`. +- `codebase-loc` is `path/to/file.ext:line` for findings that reference live code, or literal `<n/a>` for plan-internal findings (cross-phase mismatches, code-quality issues with no codebase counterpart). +- `severity ∈ { blocker, concern, suggestion }` — exactly one per row. +- `dimension ∈ { code-quality, codebase-fit, actionability }` — exactly one per row. +- `finding` is one sentence, names the concrete mechanism, cites the verbatim quote inline when relevant. +- `recommendation` is one sentence — the smallest concrete action that resolves the finding. No "consider…" hedging. If the finding requires a structural plan change (e.g. a new phase), name the change explicitly and stop — do not draft the new phase's content. + +**Severity semantics (decision rules)**: +- Run `/skill:implement` mentally against the cited phase: does it succeed? If no → `blocker`. If yes but with a real bug surface → `concern`. If yes and no bug surface but still improvable → `suggestion`. + +## Important Guidelines + +- **Default to silence** — emit a row only when the finding is concrete and grounded. Vibes like "this could be clearer" are not findings. +- **Every row cites a `file:line`** — write `<n/a>` explicitly when there is no codebase counterpart, so a reader can tell suppression from omission. +- **Cross-phase blockers are the highest-leverage finding class** — they are exactly what an in-context audit during plan authoring cannot catch because the concrete code did not exist at that point. Spend disproportionate attention here. +- **Read MODIFY files in full at HEAD** — never review a MODIFY phase without reading the current state of the file. The surrounding code shapes whether the modification is correct. +- **One finding per row** — five issues in one phase produce five rows. +- **Output starts at the first table line and ends at the last row** — no preamble, no summary, no closing prose. + +## What NOT to Do + +- Don't summarize the plan — the table is the whole output. +- Don't praise the plan — clean phases produce no rows; that is the praise. +- Don't propose architectural alternatives — that is `design`/`blueprint`'s role. Findings live within the plan's chosen architecture, not against it. +- Don't hedge — emit a row with severity, or do not emit. No "could be a concern depending on …". +- Don't merge findings across phases or across files. +- Don't tag `blocker` without a concrete path the implementer can follow to the failure. Speculative blockers are `concern`. +- Don't analyze HOW the proposed code works — review checks whether it WILL work, not how. + +Remember: You are an adversarial post-finalization reviewer. The author already believes the plan is correct; your job is to find what they missed. Rows in (the finalized phases), rows out (severity-tagged findings) — every blocker grounded in a concrete cross-phase mismatch or live-codebase fact.
@@ -20,12 +20,12 @@ - Still search thoughts/ for lessons (Step 4 — Grep/Glob-based, works without git)
- Return this format: ``` -## Precedents for [planned change] +## Precedents for {planned change} **No git history available** — not a git repository. ### Lessons from Documentation -[Findings from thoughts/, or "No relevant documents found"] +{Findings from thoughts/, or "No relevant documents found"} ### Composite Lessons - No git-based lessons available@@ -87,9 +87,9 @@
CRITICAL: Use EXACTLY this format. Be concise — commit hashes and dates are the evidence, not prose. ``` -## Precedents for [planned change] +## Precedents for {planned change} -### Precedent: [what was added/changed] +### Precedent: {what was added/changed} **Commit(s)**: `hash` — "message" (YYYY-MM-DD) **Blast radius**: N files across M layers layer/ — what changed@@ -100,12 +100,12 @@
**Lessons from docs**: - thoughts/path/to/doc.md — key lesson extracted -**Takeaway**: [one sentence — what to watch out for] +**Takeaway**: {one sentence — what to watch out for} ### Composite Lessons -- [lesson 1 — most recurring pattern first] -- [lesson 2] -- [lesson 3] +- {lesson 1 — most recurring pattern first} +- {lesson 2} +- {lesson 3} ``` ## Important Guidelines@@ -121,7 +121,7 @@
## What NOT to Do - Don't run destructive git commands (no reset, checkout, rebase, push) -- Don't analyze code implementation (that's codebase-analyzer's job) +- Don't analyze code implementation — only mine git history and docs for precedents and lessons - Don't dump raw diff output — summarize the blast radius - Don't fetch or pull from remotes - Don't speculate about lessons — only report what's evidenced by commits or documents
@@ -5,7 +5,7 @@ tools: read, grep, find, ls
isolated: true --- -You are a specialist at tracing the scope of a research investigation. Your job is to bound the file landscape to the slices worth investigating and emit a Discovery Summary + 5-10 dense numbered questions that trace that scope, NOT to locate paths (`codebase-locator`), trace one component (`codebase-analyzer`), or answer the questions (the `research` skill). +You are a specialist at tracing the scope of a research investigation. Your job is to bound the file landscape to the slices worth investigating and emit a Discovery Summary + 5-10 dense numbered questions that trace that scope, NOT to enumerate every path, trace one component end-to-end, or answer the questions yourself. ## Core Responsibilities@@ -60,7 +60,12 @@
### Step 4: Read key files for depth Compile every file reference from Step 3 into a single list. Rank by: -1. Files referenced by 2+ slices (cross-cutting, highest priority) +0. Definition sites for the anchor terms — files where the named symbol / + function / type / command is *defined*, not used. Resolve definitions + first; consumers follow. (Highest priority — analyzer agents read in + citation order, and the canonical definition anchors every downstream + trace.) +1. Files referenced by 2+ slices (cross-cutting) 2. Entry points and main implementation files 3. Type/interface files (often short, high value) 4. Config / wiring / registration files@@ -71,6 +76,10 @@ ### Step 5: Synthesize 5-10 dense questions
Using combined knowledge from Steps 1-4, write 5-10 dense paragraphs: +- **First citation = canonical definition.** The FIRST `file:line` reference + in each paragraph must be where the symbol the paragraph traces is + *defined*, not where it is consumed. Analyzer agents read in citation + order; leading with the definition anchors the entire downstream trace. - **3-6 sentences each**, naming specific files/functions/types at each step of the trace - **Self-contained** — an agent receiving only this paragraph has enough context to begin work - **Trace-quality** — names a complete path, not a generic theme@@ -92,7 +101,7 @@ ```
# Research Questions: how does the plugin system load and initialize extensions ## Discovery Summary -Swept the plugin loader and lifecycle anchors across `src/plugins/`. Key files for depth: `src/plugins/registry.ts` (scan + manifest validation), `src/plugins/loader.ts` (instantiation factory), `src/plugins/lifecycle.ts` (hook contract), `src/plugins/types.ts` (PluginManifest interface), `tests/plugins/registry.test.ts` (existing coverage shape). Two thoughts/ docs surfaced: `thoughts/shared/research/2026-03-12_plugin-architecture.md` (prior architectural decisions) and `thoughts/shared/plans/2026-04-01_plugin-lifecycle-extension.md` (recent lifecycle hook addition). The shape is a synchronous scan + lazy instantiate + lifecycle-hook chain pattern; no async loaders or hot-reload paths found. +Swept the plugin loader and lifecycle anchors across `src/plugins/`. Key files for depth: `src/plugins/types.ts:8-30` (definition — PluginManifest interface), `src/plugins/registry.ts:23` (entry — scan + manifest validation), `src/plugins/loader.ts:45` (factory — instantiation), `src/plugins/lifecycle.ts:12-44` (contract — hook ordering), `tests/plugins/registry.test.ts` (coverage). Two thoughts/ docs surfaced: `thoughts/shared/research/2026-03-12_plugin-architecture.md` (prior architectural decisions) and `thoughts/shared/plans/2026-04-01_plugin-lifecycle-extension.md` (recent lifecycle hook addition). The shape is a synchronous scan + lazy instantiate + lifecycle-hook chain pattern; no async loaders or hot-reload paths found. ## Questions@@ -105,7 +114,7 @@ ```
## What NOT to Do -- **Don't answer the questions** — that's the `research` skill's job; you trace the scope, the questions stay open +- **Don't answer the questions** — you trace the scope, the questions stay open for downstream consumers - **Don't make recommendations** — no "we should…", no architectural advice; that's `design` / `blueprint` territory - **Don't read more than 10 files in Step 4** — context budget is real; rank ruthlessly - **Don't synthesize generic titles** — every question must cite >=3 specific files / functions / types; vague themes are too thin@@ -113,4 +122,4 @@ - **Don't include thoughts/ docs as numbered questions** — surface them in the Discovery Summary; numbered questions are about live code paths
- **Don't write any file** — the artifact body lives in your final assistant message; the calling skill parses it in-memory - **Don't dispatch other agents** — `Agent` is not in the allowlist by design; the anchor sweep is sequential within this agent's own toolkit -Remember: You're a scope-tracer for an entire investigation. Read deeply, sweep anchor terms, return a Discovery Summary + 5-10 dense numbered questions inline — `research` answers them, not you. +Remember: You're a scope-tracer for an entire investigation. Read deeply, sweep anchor terms, return a Discovery Summary + 5-10 dense numbered questions inline — leave the questions open for downstream consumers to answer.
@@ -112,7 +112,7 @@ - **Handle incomplete frontmatter** — Some test cases may be missing fields; report what's available
## What NOT to Do -- Don't read file contents beyond frontmatter fields — that's codebase-analyzer's job +- Don't read file contents beyond frontmatter fields — catalog metadata only - Don't generate or suggest new test cases - Don't evaluate test case quality or completeness - Don't modify or reorganize existing test case files
@@ -58,43 +58,43 @@
Structure your analysis like this: ``` -## Analysis of: [Document Path] +## Analysis of: {Document Path} ### Document Context -- **Date**: [From frontmatter `date:` field] -- **Type**: [Research / Solution Analysis / Design / Plan / Review / Handoff] -- **Purpose**: [From frontmatter `topic:` field + document content] -- **Status**: [From frontmatter `status:` field — complete/ready/resolved/superseded] -- **Upstream**: [From `questions_source:`, `research_source:` or `design_source:` if present] +- **Date**: {From frontmatter `date:` field} +- **Type**: {Research / Solution Analysis / Design / Plan / Review / Handoff} +- **Purpose**: {From frontmatter `topic:` field + document content} +- **Status**: {From frontmatter `status:` field — complete/ready/resolved/superseded} +- **Upstream**: {From `parent:` if present} ### Key Decisions -1. **[Decision Topic]**: [Specific decision made] - - Rationale: [Why this decision] - - Impact: [What this enables/prevents] +1. **{Decision Topic}**: {Specific decision made} + - Rationale: {Why this decision} + - Impact: {What this enables/prevents} -2. **[Another Decision]**: [Specific decision] - - Trade-off: [What was chosen over what] +2. **{Another Decision}**: {Specific decision} + - Trade-off: {What was chosen over what} ### Critical Constraints -- **[Constraint Type]**: [Specific limitation and why] -- **[Another Constraint]**: [Limitation and impact] +- **{Constraint Type}**: {Specific limitation and why} +- **{Another Constraint}**: {Limitation and impact} ### Technical Specifications -- [Specific config/value/approach decided] -- [API design or interface decision] -- [Performance requirement or limit] +- {Specific config/value/approach decided} +- {API design or interface decision} +- {Performance requirement or limit} ### Actionable Insights -- [Something that should guide current implementation] -- [Pattern or approach to follow/avoid] -- [Gotcha or edge case to remember] +- {Something that should guide current implementation} +- {Pattern or approach to follow/avoid} +- {Gotcha or edge case to remember} ### Still Open/Unclear -- [Questions that weren't resolved] -- [Decisions that were deferred] +- {Questions that weren't resolved} +- {Decisions that were deferred} ### Relevance Assessment -[1-2 sentences on whether this information is still applicable and why] +{1-2 sentences on whether this information is still applicable and why} ``` ## Quality Filters
@@ -62,7 +62,7 @@
Structure your findings like this: ``` -## Thought Documents about [Topic] +## Thought Documents about {Topic} ### Tickets - `thoughts/shared/tickets/eng_1235.md` - Rate limit configuration design@@ -76,11 +76,11 @@ - `thoughts/shared/solutions/2026-01-16_14-30-00_rate-limiting-strategies.md` - Comparison of Redis vs in-memory vs distributed approaches
### Design Artifacts - `thoughts/shared/designs/2026-01-17_09-00-00_rate-limiter-design.md` - Architectural design for sliding window rate limiter - - research_source: `thoughts/shared/research/2026-01-15_10-45-00_rate-limiting-approaches.md` + - parent: `thoughts/shared/research/2026-01-15_10-45-00_rate-limiting-approaches.md` ### Implementation Plans - `thoughts/shared/plans/2026-01-18_11-20-00_rate-limiter-implementation.md` - Phased plan for rate limits - - design_source: `thoughts/shared/designs/2026-01-17_09-00-00_rate-limiter-design.md` + - parent: `thoughts/shared/designs/2026-01-17_09-00-00_rate-limiter-design.md` ### Code Reviews - `thoughts/shared/reviews/2026-01-25_16-00-00_rate-limiter-review.md` - Review of rate limiting implementation@@ -113,11 +113,11 @@
3. **Look for patterns**: - Ticket files often named `eng_XXXX.md` - Skill-generated files use `YYYY-MM-DD_HH-MM-SS_topic.md` (research, solutions, designs, plans, handoffs, reviews) - - Documents have YAML frontmatter with searchable `topic:`, `tags:`, `status:`, `questions_source:`, `research_source:`, `design_source:` fields + - Documents have YAML frontmatter with searchable `topic:`, `tags:`, `status:`, `parent:` fields 4. **Follow artifact chains**: - Research Questions → Research → Solutions → Designs → Plans → Reviews → Handoffs - - Check `questions_source:`, `research_source:` and `design_source:` in frontmatter to find related documents + - Check `parent:` in frontmatter to find related documents - When you find one artifact, look for upstream/downstream artifacts on the same topic ## Important Guidelines
@@ -37,7 +37,7 @@
## Search Strategies ### For API/Library Documentation: -- Search for official docs first: "[library name] official documentation [specific feature]" +- Search for official docs first: "{library name} official documentation {specific feature}" - Look for changelog or release notes for version-specific information - Find code examples in official repositories or trusted tutorials@@ -65,26 +65,26 @@ Structure your findings as:
``` ## Summary -[Brief overview of key findings] +{Brief overview of key findings} ## Detailed Findings -### [Topic/Source 1] -**Source**: [Name with link] -**Relevance**: [Why this source is authoritative/useful] +### {Topic/Source 1} +**Source**: {Name with link} +**Relevance**: {Why this source is authoritative/useful} **Key Information**: - Direct quote or finding (with link to specific section if possible) - Another relevant point -### [Topic/Source 2] -[Continue pattern...] +### {Topic/Source 2} +{Continue pattern...} ## Additional Resources -- [Relevant link 1] - Brief description -- [Relevant link 2] - Brief description +- {Relevant link 1} - Brief description +- {Relevant link 2} - Brief description ## Gaps or Limitations -[Note any information that couldn't be found or requires further investigation] +{Note any information that couldn't be found or requires further investigation} ``` ## Quality Guidelines
@@ -2,7 +2,18 @@ # Changelog
All notable changes to pi-browser-harness will be documented in this file. -## 0.5.0 — 2026-05-07 +## 0.6.0 — 2026-06-21 + +### Added + +- **Per-tab isolation for multi-agent safety.** Each agent's browser session now operates in a dedicated Chrome window with tab ownership tracking. Tabs opened via `browser_open_urls` and `browser_new_tab` are automatically registered in the ownership registry and tagged with a 🟢 prefix in the document title for user visibility. `browser_list_tabs` scoped to `"owned"` (default) only shows the current session's tabs. +- **Unix socket daemon with auto-reconnect.** The browser daemon now binds to a Unix-domain socket (`pi-browser.sock`) in the harness temp directory instead of a TCP port. The transport layer reconnects automatically when Chrome restarts — no manual `/browser-reload-daemon` needed. +- **On-demand browser initialization.** Chrome is no longer launched eagerly at harness startup. The harness attaches lazily on the first browser tool call, reducing resource usage when the agent isn't using the browser. +- **`browser_setup` as agent-callable tool.** The setup tool can now be called programmatically by agents, not just via slash command. Idempotent — safe to call when already connected. + +### Fixed + +- **Chrome detection on macOS** now matches by process-name substring instead of exact binary path comparison, fixing false negatives when Chrome is running from different installation paths (e.g. `/Applications/Google Chrome.app/` vs user-local copies). ### Added
@@ -1,12 +1,12 @@
{ "name": "pi-browser-harness", - "version": "0.3.1", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-browser-harness", - "version": "0.3.1", + "version": "0.5.0", "license": "MIT", "dependencies": { "ws": "^8.20.0"@@ -15,6 +15,7 @@ "devDependencies": {
"@mariozechner/pi-coding-agent": "*", "@mariozechner/pi-tui": "*", "@types/ws": "^8.18.1", + "tsx": "^4.22.4", "typebox": "*", "typescript": "^5.0.0" },@@ -876,6 +877,448 @@ "dependencies": {
"tslib": "^2.4.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@google/genai": { "version": "1.50.1", "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.50.1.tgz",@@ -2798,6 +3241,48 @@ "dependencies": {
"once": "^1.4.0" } }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",@@ -2994,6 +3479,21 @@ "fetch-blob": "^3.1.2"
}, "engines": { "node": ">=12.20.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, "node_modules/gaxios": {@@ -4051,6 +4551,25 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "devOptional": true, "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } }, "node_modules/typebox": { "version": "1.1.34",
@@ -1,6 +1,6 @@
{ "name": "pi-browser-harness", - "version": "0.5.0", + "version": "0.6.0", "description": "Browser control extension for pi — navigate, click, type, screenshot, and extract data from real Chrome via CDP", "keywords": [ "pi-package",@@ -52,6 +52,7 @@ "devDependencies": {
"@mariozechner/pi-coding-agent": "*", "@mariozechner/pi-tui": "*", "@types/ws": "^8.18.1", + "tsx": "^4.22.4", "typebox": "*", "typescript": "^5.0.0" },
@@ -1,6 +1,6 @@
--- name: pi-browser-harness -description: Direct browser control via CDP. Use when the user wants to automate, scrape, test, or interact with web pages. Connects to the user's already-running Chrome. Default to browser_snapshot for understanding pages and browser_execute_js for surgical reads — browser_screenshot is for visual verification only. +description: Direct browser control via CDP. Use when the user wants to automate, scrape, test, or interact with web pages. Connects to the user's already-running Chrome. Default to browser_snapshot for understanding pages and browser_execute_js for surgical reads — browser_screenshot is for visual verification only. --- # pi-browser-harness@@ -29,6 +29,22 @@ → browser_screenshot (LAST RESORT — pixels only)
``` Pass `@(x,y)` from `browser_snapshot` straight to `browser_click`. No screenshot round-trip. + +## Connection Setup + +Browser control is **on-demand** — the daemon does NOT start automatically. +If you try a browser tool and get a `not_connected` error, tell the user to +run `/browser-setup` first. This opens the daemon and connects to Chrome. +Once initialized, all subsequent sessions reuse the same connection silently. + +**Before calling any browser tool**, the runtime checks for the daemon socket +at `/tmp/pi-browser-daemon.sock`. If the socket is missing (user hasn't run +`/browser-setup`), you get: `"Browser harness not initialized. Run /browser-setup first"`. + +**Do not ask the user.** Call `browser_setup` directly — it spawns the daemon, +connects to Chrome, and opens a test tab. The user sees a single "Allow Remote +Debugging" prompt the first time. After that, all sessions reuse the same connection. +`browser_setup` is idempotent — safe to call even when already connected. ## Connection
@@ -0,0 +1,277 @@
+/** + * DaemonTransport — CdpTransport backed by a Unix socket to the browser daemon. + * + * Satisfies the exact CdpTransport interface so BrowserClient, CdpSession, and + * all domain tools work without changes. Internally uses newline-delimited JSON + * over a Unix domain socket instead of a WebSocket to Chrome. + * + * References: + * - CdpTransport interface: src/cdp/transport.ts + * - net.createConnection IPC: https://nodejs.org/docs/latest-v22.x/api/net.html#netcreateconnection + * - readline.createInterface: https://nodejs.org/docs/latest-v22.x/api/readline.html#readlinecreateinterfaceoptions + */ + +import { createConnection, type Socket } from "node:net"; +import { createInterface } from "node:readline"; +import { type Result, err, ok } from "../util/result"; +import { type CdpError, cdpError } from "../cdp/errors"; +import type { CdpTransport } from "../cdp/transport"; +import type { CdpEvent } from "../cdp/types"; +import { + DAEMON_SOCKET_PATH, + type WireRequest, + type WireControl, + deserialize, + serialize, +} from "../daemon/protocol"; + +// ── Types ────────────────────────────────────────────────────────────────────── + +type EventQueue = { + readonly push: (e: CdpEvent) => void; + readonly end: () => void; + readonly iter: AsyncIterable<CdpEvent>; +}; + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +const makeEventQueue = (): EventQueue => { + const buf: CdpEvent[] = []; + const waiters: Array<(v: IteratorResult<CdpEvent>) => void> = []; + let ended = false; + return { + push(e) { + if (ended) return; + const w = waiters.shift(); + if (w) w({ value: e, done: false }); + else buf.push(e); + }, + end() { + ended = true; + for (const w of waiters.splice(0)) w({ value: undefined as unknown as CdpEvent, done: true }); + }, + iter: { + [Symbol.asyncIterator]() { + return { + next: (): Promise<IteratorResult<CdpEvent>> => + new Promise((resolve) => { + const next = buf.shift(); + if (next) resolve({ value: next, done: false }); + else if (ended) resolve({ value: undefined as unknown as CdpEvent, done: true }); + else waiters.push(resolve); + }), + }; + }, + }, + }; +}; + +// ── Implementation ───────────────────────────────────────────────────────────── + +export const createDaemonTransport = (clientId: string): CdpTransport => { + let socket: Socket | null = null; + let rl: ReturnType<typeof createInterface> | null = null; + let queue = makeEventQueue(); + const closeListeners = new Set<() => void>(); + const pending = new Map<number, { + resolve: (v: Result<unknown, CdpError>) => void; + timer: ReturnType<typeof setTimeout>; + method: string; + }>(); + let registered = false; + let nextRequestId = 1; + + const cleanup = (reason: string): void => { + for (const [, p] of pending) { + clearTimeout(p.timer); + p.resolve(err(cdpError("transport_closed", reason, p.method))); + } + pending.clear(); + queue.end(); + queue = makeEventQueue(); + registered = false; + + if (rl) { rl.close(); rl = null; } + if (socket) { try { socket.destroy(); } catch {} socket = null; } + + for (const cb of closeListeners) cb(); + }; + + // ── Connect ──────────────────────────────────────────────────────────── + + const connect = (_url: string, opts?: { timeoutMs?: number }): Promise<Result<void, CdpError>> => { + // url is ignored — we always connect to the daemon socket + const timeoutMs = opts?.timeoutMs ?? 10_000; + + if (socket && !socket.destroyed && registered) { + return Promise.resolve(ok(undefined)); + } + + return new Promise((resolve) => { + let settled = false; + const settle = (r: Result<void, CdpError>): void => { + if (settled) return; + settled = true; + resolve(r); + }; + + try { + socket = createConnection(DAEMON_SOCKET_PATH); + } catch (e) { + settle(err(cdpError("transport_closed", e instanceof Error ? e.message : String(e)))); + return; + } + + const connectTimer = setTimeout(() => { + cleanup("Connection timeout"); + settle(err(cdpError("timeout", `Daemon connection timed out after ${timeoutMs}ms`))); + }, timeoutMs); + + socket.on("connect", () => { + clearTimeout(connectTimer); + rl = createInterface({ input: socket!, crlfDelay: Infinity }); + + rl.on("line", (line: string) => { + const msg = deserialize(line); + if (!msg) return; + + if (msg.type === "control" && msg.action === "registered" && msg.clientId === clientId) { + registered = true; + settle(ok(undefined)); + return; + } + + if (msg.type === "control" && msg.action === "shutdown") { + cleanup(`Daemon shutting down: ${msg.reason ?? "unknown"}`); + return; + } + + if (!registered) return; + + if (msg.type === "response") { + const p = pending.get(msg.id); + if (!p) return; + pending.delete(msg.id); + clearTimeout(p.timer); + + if (msg.error) { + p.resolve(err(cdpError( + msg.error.message.includes("Session with given id not found") + ? "session_not_found" + : "remote_error", + msg.error.message, + p.method, + ))); + } else { + p.resolve(ok(msg.result)); + } + return; + } + + if (msg.type === "event") { + queue.push({ + method: msg.method, + params: msg.params, + ...(msg.sessionId !== undefined ? { sessionId: msg.sessionId } : {}), + }); + return; + } + }); + + socket!.on("error", () => { + // Errors are surfaced via 'close' + }); + + socket!.on("close", () => { + clearTimeout(connectTimer); + cleanup("Daemon socket closed"); + // If connect hasn't settled yet, settle with error + settle(err(cdpError("transport_closed", "Daemon socket closed before registration"))); + }); + + // Send registration + const regMsg: WireControl = { type: "control", action: "register", clientId }; + socket!.write(serialize(regMsg) + "\n"); + }); + + socket.on("error", (e: NodeJS.ErrnoException) => { + clearTimeout(connectTimer); + const msg = e.code === "ENOENT" + ? "Daemon not running — socket not found" + : e.message; + settle(err(cdpError("transport_closed", msg))); + }); + }); + }; + + // ── Close ────────────────────────────────────────────────────────────── + + const close = async (): Promise<void> => { + if (registered && socket && !socket.destroyed) { + const dereg: WireControl = { type: "control", action: "deregister", clientId }; + try { socket.write(serialize(dereg) + "\n"); } catch {} + } + cleanup("close() called"); + }; + + // ── Request ──────────────────────────────────────────────────────────── + + const request = ( + method: string, + params: Record<string, unknown>, + opts?: { sessionId?: string | null; timeoutMs?: number }, + ): Promise<Result<unknown, CdpError>> => { + const timeoutMs = opts?.timeoutMs ?? 15_000; + const sessionId = opts?.sessionId ?? null; + + if (!socket || socket.destroyed || !registered) { + return Promise.resolve(err(cdpError("transport_closed", "Daemon not connected", method))); + } + + const id = nextRequestId++; + const req: WireRequest = { + type: "request", + id, + method, + params, + ...(sessionId !== null && sessionId !== undefined ? { sessionId } : {}), + }; + + return new Promise((resolve) => { + const timer = setTimeout(() => { + pending.delete(id); + resolve(err(cdpError("timeout", `Daemon timeout after ${timeoutMs}ms: ${method}`, method))); + }, timeoutMs); + + pending.set(id, { resolve, timer, method }); + + try { + socket!.write(serialize(req) + "\n"); + } catch (e) { + clearTimeout(timer); + pending.delete(id); + resolve(err(cdpError("transport_closed", e instanceof Error ? e.message : String(e), method))); + } + }); + }; + + // ── Events ───────────────────────────────────────────────────────────── + + const events = (): AsyncIterable<CdpEvent> => queue.iter; + + // ── State ────────────────────────────────────────────────────────────── + + const state = (): "open" | "closed" | "connecting" => { + if (!socket) return "closed"; + if (!registered) return "connecting"; + if (socket.destroyed) return "closed"; + return "open"; + }; + + const onClose = (cb: () => void): (() => void) => { + closeListeners.add(cb); + return () => closeListeners.delete(cb); + }; + + return { connect, close, request, events, state, onClose }; +};
@@ -11,6 +11,7 @@ import type { DaemonStatus, DialogInfo, PageInfo, TabInfo } from "./cdp/types";
export type BrowserClientOptions = { readonly namespace: string; + readonly transport?: CdpTransport; readonly remote?: { readonly cdpUrl: string; readonly browserId: string }; readonly initialOwnership?: { readonly ownedTargetIds?: ReadonlyArray<string>;@@ -28,6 +29,9 @@ ensureAlive(): Promise<Result<void, CdpError>>;
status(): DaemonStatus; start(): Promise<Result<void, CdpError>>; stop(): Promise<void>; + /** Detach from the current page target (removes the "Chrome is being controlled" banner) + * while keeping the transport connection alive. Call on session shutdown. */ + detach(): Promise<void>; evaluateJs(expression: string, sessionId?: string): Promise<Result<unknown, CdpError>>; pageInfo(): Promise<Result<PageInfo | { readonly dialog: DialogInfo }, CdpError>>; takeDialog(): DialogInfo | null;@@ -77,7 +81,7 @@ });
}; export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient => { - const transport = createCdpTransport(); + const transport = opts.transport ?? createCdpTransport(); const ownershipInit: { ownedTargetIds?: ReadonlyArray<string>; harnessWindowTargetId?: string } = {}; if (opts.initialOwnership?.ownedTargetIds !== undefined) { ownershipInit.ownedTargetIds = opts.initialOwnership.ownedTargetIds;@@ -298,9 +302,19 @@ namespace: opts.namespace,
...(remote?.browserId !== undefined ? { remoteBrowserId: remote.browserId } : {}), }); + const detach = async (): Promise<void> => { + const cur = session.current(); + if (!cur) return; + // Target.detachFromTarget removes the "Chrome is being controlled" banner + // and releases the page session. The transport (WebSocket/Unix socket) + // stays alive for reuse by the next session. + // Reference: https://chromedevtools.github.io/devtools-protocol/tot/Target/#method-detachFromTarget + await transport.request("Target.detachFromTarget", { sessionId: cur.sessionId }, { sessionId: null }); + }; + return { namespace: opts.namespace, - ensureAlive, status, start, stop, + ensureAlive, status, start, stop, detach, evaluateJs, pageInfo, takeDialog: () => session.takeDialog(), listTabs, switchTab, newTab, closeTab,
@@ -0,0 +1,411 @@
+/** + * CdpBridge — persistent WebSocket to Chrome, ID multiplexing, event routing. + * + * A single long-lived process connection to Chrome that proxies CDP traffic + * for multiple pi clients connected via the IpcServer. One Allow dialog ever. + * + * References: + * - CDP message format: https://github.com/aslushnikov/getting-started-with-cdp#protocol-fundamentals + * - CDP sessions + IDs: https://github.com/aslushnikov/getting-started-with-cdp#targets--sessions + * - Target domain: https://chromedevtools.github.io/devtools-protocol/tot/Target/ + */ + +import WebSocket from "ws"; +import { discoverWsUrl } from "../cdp/discovery"; +import type { CdpRawMessage } from "../cdp/types"; +import type { WireRequest, WireResponse, WireEvent } from "./protocol"; +import { CDP_CONNECT_TIMEOUT_MS, CDP_COMMAND_TIMEOUT_MS } from "./protocol"; + +// ── Types ────────────────────────────────────────────────────────────────────── + +export type SendToClient = (clientId: string, msg: WireResponse | WireEvent) => void; + +export type EventHandler = (event: WireEvent, targetClientIds: string[]) => void; + +export type CloseHandler = () => void; + +export type CdpBridge = { + /** Start the bridge. Resolves when the daemon IPC + Chrome discovery loop + * are initialized (not when Chrome is connected — that may take retries). */ + start(): Promise<void>; + stop(): Promise<void>; + handleRequest(req: WireRequest, clientId: string, send: SendToClient): void; + /** True when the Chrome WebSocket is open and ready. */ + isAlive(): boolean; + recordSession(clientId: string, sessionId: string): void; + releaseSession(sessionId: string): void; + getSessionOwner(sessionId: string): string | undefined; + removeClient(clientId: string): void; + onEvent(handler: EventHandler): void; + onClose(handler: CloseHandler): void; +}; + +// ── ID Multiplexer ──────────────────────────────────────────────────────────── + +interface PendingEntry { + clientId: string; + localId: number; +} + +class IdMultiplexer { + private nextId = 1; + private localToRemote = new Map<string, Map<number, number>>(); + private pending = new Map<number, PendingEntry>(); + + allocate(clientId: string, localId: number): number { + const daemonId = this.nextId++; + let m = this.localToRemote.get(clientId); + if (!m) { m = new Map(); this.localToRemote.set(clientId, m); } + m.set(localId, daemonId); + this.pending.set(daemonId, { clientId, localId }); + return daemonId; + } + + resolve(daemonId: number): PendingEntry | null { + const e = this.pending.get(daemonId); + if (!e) return null; + this.pending.delete(daemonId); + const m = this.localToRemote.get(e.clientId); + if (m) m.delete(e.localId); + return e; + } + + clearClient(clientId: string): void { + const m = this.localToRemote.get(clientId); + if (m) { for (const [, did] of m) this.pending.delete(did); } + this.localToRemote.delete(clientId); + } +} + +// ── Event Router ─────────────────────────────────────────────────────────────── + +class EventRouter { + private owners = new Map<string, string>(); // sessionId → clientId + private clientSessions = new Map<string, Set<string>>(); // clientId → sessions + + record(clientId: string, sessionId: string): void { + const prev = this.owners.get(sessionId); + if (prev && prev !== clientId) { + this.clientSessions.get(prev)?.delete(sessionId); + } + this.owners.set(sessionId, clientId); + let s = this.clientSessions.get(clientId); + if (!s) { s = new Set(); this.clientSessions.set(clientId, s); } + s.add(sessionId); + } + + release(sessionId: string): void { + const owner = this.owners.get(sessionId); + if (owner) this.clientSessions.get(owner)?.delete(sessionId); + this.owners.delete(sessionId); + } + + getOwner(sessionId: string): string | undefined { + return this.owners.get(sessionId); + } + + removeClient(clientId: string): void { + const sessions = this.clientSessions.get(clientId); + if (sessions) { for (const sid of sessions) this.owners.delete(sid); } + this.clientSessions.delete(clientId); + } + + /** + * Route a CDP event to client(s). + * Session-scoped → only the owning client. + * Browser-level (no sessionId) → all connected clients. + */ + route(event: WireEvent): string[] { + if (event.sessionId) { + const owner = this.owners.get(event.sessionId); + return owner ? [owner] : []; + } + return [...this.clientSessions.keys()]; + } + + allClients(): string[] { + return [...this.clientSessions.keys()]; + } +} + +// ── Implementation ───────────────────────────────────────────────────────────── + +export const createCdpBridge = (): CdpBridge => { + let ws: WebSocket | null = null; + let wsUrl: string | null = null; + const mux = new IdMultiplexer(); + const router = new EventRouter(); + let eventHandler: EventHandler | null = null; + let closeHandler: CloseHandler | null = null; + + // daemonId → { send, timer, isAttach } + const callbacks = new Map<number, { + send: SendToClient; + clientId: string; + localId: number; + timer: ReturnType<typeof setTimeout>; + isAttach: boolean; + }>(); + + // ── Handle raw CDP messages from Chrome ───────────────────────────────── + + const onChromeMessage = (raw: string): void => { + let msg: CdpRawMessage; + try { msg = JSON.parse(raw); } catch { return; } + + // ---- Response (has id) ---- + if (msg.id !== undefined) { + const cb = callbacks.get(msg.id); + if (!cb) { + // Stale response — client already timed out or disconnected. + // Still clean up the multiplexer mapping. + mux.resolve(msg.id); + return; + } + clearTimeout(cb.timer); + callbacks.delete(msg.id); + + const localId = cb.localId; + + if (msg.error) { + cb.send(cb.clientId, { + type: "response", + id: localId, + error: { code: msg.error.code ?? -1, message: msg.error.message }, + }); + } else { + // Track session ownership for attach responses + if (cb.isAttach && msg.result) { + const result = msg.result as { sessionId?: string }; + if (result.sessionId) { + router.record(cb.clientId, result.sessionId); + } + } + cb.send(cb.clientId, { type: "response", id: localId, result: msg.result }); + } + + mux.resolve(msg.id); + return; + } + + // ---- Event (no id, has method) ---- + if (!msg.method) return; + + // Inspector.detached — release session ownership + if (msg.method === "Inspector.detached" && msg.sessionId) { + router.release(msg.sessionId); + } + + const wireEvent: WireEvent = { + type: "event", + method: msg.method, + ...(msg.params !== undefined ? { params: msg.params } : {}), + ...(msg.sessionId !== undefined ? { sessionId: msg.sessionId } : {}), + }; + + // Route through the event handler for the IpcServer to broadcast + if (eventHandler) { + const targets = router.route(wireEvent); + if (targets.length > 0) { + eventHandler(wireEvent, targets); + } + } + }; + + // ── Connect to Chrome ─────────────────────────────────────────────────── + + // ── Auto-reconnecting Chrome connection ─────────────────────────────── + + let reconnectTimer: ReturnType<typeof setTimeout> | null = null; + let reconnectAttempt = 0; + let stopped = false; + + /** + * Try to discover Chrome and open a WebSocket. On failure, schedule + * an exponential backoff retry (capped at 60s). When Chrome finally + * becomes available, the bridge connects automatically — no manual + * daemon restart needed. + */ + const tryConnect = (): void => { + if (stopped) return; + if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } + + const attempt = async (): Promise<void> => { + // Already connected — nothing to do. + if (ws && ws.readyState === WebSocket.OPEN) return; + + // Resolve URL (discover if not cached, or clear cache on retry) + let url = wsUrl; + if (!url || reconnectAttempt > 0) { + const d = await discoverWsUrl(); + if (!d.success) { scheduleRetry(); return; } + url = d.data; + wsUrl = url; + } + + const settledPromise = new Promise<void>((settle) => { + let settled = false; + const settleOnce = (): void => { + if (settled) return; + settled = true; + settle(); + }; + + let sock: WebSocket; + try { + sock = new WebSocket(url!, { perMessageDeflate: false }); + } catch { + scheduleRetry(); + return; + } + + const timer = setTimeout(() => { + sock.close(); + settleOnce(); + }, CDP_CONNECT_TIMEOUT_MS); + + sock.on("open", () => { + clearTimeout(timer); + ws = sock; + reconnectAttempt = 0; + // Enable target discovery so we see page events + ws.send(JSON.stringify({ id: 0, method: "Target.setDiscoverTargets", params: { discover: true } })); + console.log("[pi-browser-daemon] Connected to Chrome ✓"); + settleOnce(); + }); + + sock.on("message", (data: WebSocket.Data) => { + onChromeMessage(typeof data === "string" ? data : data.toString()); + }); + + sock.on("error", () => { + clearTimeout(timer); + settleOnce(); + }); + + sock.on("close", () => { + clearTimeout(timer); + ws = null; + // Reject all pending callbacks + for (const [daemonId, cb] of callbacks) { + clearTimeout(cb.timer); + cb.send(cb.clientId, { + type: "response", + id: cb.localId, + error: { code: -32000, message: "Chrome disconnected" }, + }); + callbacks.delete(daemonId); + mux.resolve(daemonId); + } + closeHandler?.(); + // Auto-reconnect on unexpected close + scheduleRetry(); + }); + }); + + await settledPromise; + // If connection didn't succeed, schedule a retry + if (!ws || ws.readyState !== WebSocket.OPEN) { + scheduleRetry(); + } + }; + + attempt().catch(() => scheduleRetry()); + }; + + /** Exponential backoff: 1s, 2s, 4s, 8s, 16s, 32s, 60s (capped). */ + const scheduleRetry = (): void => { + if (stopped) return; + const delay = Math.min(1000 * Math.pow(2, Math.min(reconnectAttempt, 6)), 60_000); + reconnectAttempt++; + wsUrl = null; // force re-discovery on next attempt + reconnectTimer = setTimeout(tryConnect, delay); + }; + + const start = async (): Promise<void> => { + stopped = false; + // Don't await — the retry loop runs in the background. + // The daemon is "started" when the IPC server is listening, not when + // Chrome is connected. + tryConnect(); + }; + + const stop = async (): Promise<void> => { + stopped = true; + if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } + for (const [, cb] of callbacks) clearTimeout(cb.timer); + callbacks.clear(); + if (ws) { try { ws.close(1000, "Shutdown"); } catch {} ws = null; } + wsUrl = null; + }; + + const handleRequest = ( + req: WireRequest, + clientId: string, + send: SendToClient, + ): void => { + if (!ws || ws.readyState !== WebSocket.OPEN) { + send(clientId, { + type: "response", + id: req.id, + error: { code: -32000, message: "Chrome not connected" }, + }); + return; + } + + const daemonId = mux.allocate(clientId, req.id); + + // Store callback for the response + const timer = setTimeout(() => { + callbacks.delete(daemonId); + mux.resolve(daemonId); + send(clientId, { + type: "response", + id: req.id, + error: { code: -32000, message: `Timeout after ${CDP_COMMAND_TIMEOUT_MS}ms: ${req.method}` }, + }); + }, CDP_COMMAND_TIMEOUT_MS); + + const isAttach = req.method === "Target.attachToTarget"; + const isDetach = req.method === "Target.detachFromTarget"; + + if (isDetach && req.sessionId) { + router.release(req.sessionId); + } + + callbacks.set(daemonId, { send, clientId, localId: req.id, timer, isAttach }); + + const payload: Record<string, unknown> = { + id: daemonId, + method: req.method, + params: req.params ?? {}, + }; + if (req.sessionId) payload["sessionId"] = req.sessionId; + + try { + ws.send(JSON.stringify(payload)); + } catch (e) { + clearTimeout(timer); + callbacks.delete(daemonId); + mux.resolve(daemonId); + send(clientId, { + type: "response", + id: req.id, + error: { code: -32000, message: e instanceof Error ? e.message : String(e) }, + }); + } + }; + + return { + start, + stop, + handleRequest, + isAlive: () => ws !== null && ws.readyState === WebSocket.OPEN, + recordSession: (cid, sid) => router.record(cid, sid), + releaseSession: (sid) => router.release(sid), + getSessionOwner: (sid) => router.getOwner(sid), + removeClient: (cid) => { mux.clearClient(cid); router.removeClient(cid); }, + onEvent: (h) => { eventHandler = h; }, + onClose: (h) => { closeHandler = h; }, + }; +};
@@ -0,0 +1,114 @@
+/** + * Browser daemon entry point — standalone long-lived process. + * + * Owns a single persistent WebSocket to Chrome. Exposes a Unix domain socket + * for pi instances to connect through. One Allow dialog ever (per Chrome + * lifetime), zero repeated prompts. + * + * Run: npx tsx src/daemon/index.ts + */ + +import { createIpcServer } from "./server"; +import { createCdpBridge, type SendToClient } from "./bridge"; +import { DAEMON_IDLE_TIMEOUT_MS } from "./protocol"; + +async function main() { + console.log("[pi-browser-daemon] Starting..."); + + const ipcServer = createIpcServer(); + const cdpBridge = createCdpBridge(); + + // ── Wire IpcServer → CdpBridge ───────────────────────────────────────── + + // When a pi client sends a CDP request, proxy it through the bridge to Chrome. + ipcServer.onMessage((msg, client) => { + if (msg.type !== "request") return; + + const send: SendToClient = (cid, resp) => { + ipcServer.send(cid, resp); + }; + + cdpBridge.handleRequest(msg, client.id, send); + }); + + // When Chrome sends an event, route it to the correct pi client(s). + cdpBridge.onEvent((event, targetClientIds) => { + for (const cid of targetClientIds) { + ipcServer.send(cid, event); + } + }); + + // When a client disconnects, clean up its CDP state. + ipcServer.onDisconnect((client) => { + cdpBridge.removeClient(client.id); + }); + + // When Chrome disconnects, notify all clients. + cdpBridge.onClose(() => { + console.log("[pi-browser-daemon] Chrome disconnected"); + ipcServer.broadcast({ + type: "control", + action: "shutdown", + reason: "chrome_disconnected", + }); + }); + + // ── Idle timeout ─────────────────────────────────────────────────────── + + // The daemon exits after DAEMON_IDLE_TIMEOUT_MS with zero connected clients. + // This prevents zombie processes when pi sessions end and never reconnect. + // The timeout resets whenever a client connects. + let idleTimer: ReturnType<typeof setTimeout> | null = null; + + const resetIdleTimer = (): void => { + if (idleTimer) clearTimeout(idleTimer); + if (ipcServer.clientCount() === 0) { + idleTimer = setTimeout(() => { + console.log("[pi-browser-daemon] Idle timeout — no clients for " + + `${DAEMON_IDLE_TIMEOUT_MS / 60000} minutes. Shutting down.`); + shutdown(); + }, DAEMON_IDLE_TIMEOUT_MS); + } + }; + + const cancelIdleTimer = (): void => { + if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; } + }; + + ipcServer.onConnect(() => { cancelIdleTimer(); }); + ipcServer.onDisconnect(() => { resetIdleTimer(); }); + + // ── Start ────────────────────────────────────────────────────────────── + + try { + await ipcServer.start(); + console.log("[pi-browser-daemon] IPC server listening"); + } catch (e) { + console.error("[pi-browser-daemon] Failed to start IPC server:", e); + process.exit(1); + } + + // Connect to Chrome (auto-retries with backoff in the background). + // The daemon is ready immediately — Chrome may connect later. + await cdpBridge.start(); + + // Start the idle timer (no clients yet at startup). + resetIdleTimer(); + + // ── Graceful shutdown ────────────────────────────────────────────────── + + const shutdown = async () => { + console.log("[pi-browser-daemon] Shutting down..."); + await cdpBridge.stop(); + await ipcServer.stop(); + process.exit(0); + }; + + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); +} + +main().catch((e) => { + console.error("[pi-browser-daemon] Fatal:", e); + process.exit(1); +});
@@ -0,0 +1,162 @@
+/** + * Unix socket daemon protocol — wire message types, type guards, and constants. + * + * The daemon speaks JSON-line protocol over a Unix domain socket. + * One JSON object per newline. Four message types: request, response, event, control. + * + * References: + * - CDP message format: https://github.com/aslushnikov/getting-started-with-cdp#protocol-fundamentals + * - net.createServer IPC: https://nodejs.org/docs/latest-v22.x/api/net.html#serverlistenpath-backlog-callback + * - readline line-delimited: https://nodejs.org/docs/latest-v22.x/api/readline.html#event-line + */ + +// ── Message types ────────────────────────────────────────────────────────────── + +/** A CDP command sent from a pi client to the daemon. */ +export type WireRequest = { + readonly type: "request"; + /** Client-local CDP id. The daemon remaps this to a globally-unique id for Chrome. */ + readonly id: number; + readonly method: string; + readonly params?: Record<string, unknown>; + /** Optional CDP session id. Omitted for browser-level commands. */ + readonly sessionId?: string; +}; + +/** A CDP response (result or error) sent from the daemon back to the pi client. */ +export type WireResponse = { + readonly type: "response"; + /** Matches the id from the corresponding WireRequest. */ + readonly id: number; + readonly result?: unknown; + readonly error?: { + readonly code: number; + readonly message: string; + readonly data?: string; + }; +}; + +/** A CDP event forwarded from Chrome through the daemon to pi client(s). */ +export type WireEvent = { + readonly type: "event"; + readonly method: string; + readonly params?: Record<string, unknown>; + /** Session-scoped events carry this; browser-level events omit it. */ + readonly sessionId?: string; +}; + +/** Daemon lifecycle control messages. Bidirectional. */ +export type WireControl = { + readonly type: "control"; + readonly action: "register" | "registered" | "deregister" | "shutdown"; + /** pi client namespace (required for register/registered/deregister). */ + readonly clientId?: string; + /** Human-readable reason for shutdown. */ + readonly reason?: string; +}; + +/** All messages that may appear on the Unix socket. */ +export type WireMessage = WireRequest | WireResponse | WireEvent | WireControl; + +// ── Type guard ───────────────────────────────────────────────────────────────── + +const isObject = (v: unknown): v is Readonly<Record<string, unknown>> => + typeof v === "object" && v !== null && !Array.isArray(v); + +/** + * Validates that an unknown parsed-JSON value is a well-formed WireMessage. + * Does NOT validate the full CDP semantics — only structural shape. + */ +export const isWireMessage = (v: unknown): v is WireMessage => { + if (!isObject(v)) return false; + const t = v["type"]; + if (typeof t !== "string") return false; + + switch (t) { + case "request": { + if (typeof v["id"] !== "number") return false; + if (typeof v["method"] !== "string") return false; + const sid = v["sessionId"]; + if (sid !== undefined && typeof sid !== "string") return false; + return true; + } + case "response": { + if (typeof v["id"] !== "number") return false; + const errVal = v["error"]; + if (errVal !== undefined) { + if (!isObject(errVal)) return false; + if (typeof errVal["message"] !== "string") return false; + if (typeof errVal["code"] !== "number") return false; + } + return true; + } + case "event": { + if (typeof v["method"] !== "string") return false; + const sid = v["sessionId"]; + if (sid !== undefined && typeof sid !== "string") return false; + return true; + } + case "control": { + const action = v["action"]; + if (typeof action !== "string") return false; + if (!["register", "registered", "deregister", "shutdown"].includes(action)) return false; + const cid = v["clientId"]; + if ( + (action === "register" || action === "registered" || action === "deregister") && + typeof cid !== "string" + ) { + return false; + } + return true; + } + default: + return false; + } +}; + +// ── Wire message constructors ────────────────────────────────────────────────── + +/** Serialize a WireMessage to a JSON line (no trailing newline — caller adds \\n). */ +export const serialize = (msg: WireMessage): string => JSON.stringify(msg); + +/** Deserialize a single JSON-line string to a WireMessage. Returns null on parse failure. */ +export const deserialize = (line: string): WireMessage | null => { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + return null; + } + if (!isWireMessage(parsed)) return null; + return parsed; +}; + +// ── Constants ────────────────────────────────────────────────────────────────── + +/** Filesystem path for the daemon's Unix domain socket. */ +export const DAEMON_SOCKET_PATH = "/tmp/pi-browser-daemon.sock"; + +/** + * How long the daemon stays alive with zero connected clients before exiting + * (milliseconds). After this period, the daemon cleans up and shuts down. + */ +export const DAEMON_IDLE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes + +/** + * Hard cap on concurrent pi client connections. Extra connections are rejected + * immediately to prevent resource exhaustion. + */ +export const DAEMON_MAX_CLIENTS = 16; + +/** + * If true, the daemon unlinks (deletes) any stale socket file from a previous + * daemon crash before calling server.listen(). Without this, a crashed daemon + * leaves a socket inode that blocks the next listen() call with EADDRINUSE. + */ +export const DAEMON_STALE_SOCKET_CLEANUP = true; + +/** CDP WebSocket connection timeout (ms). */ +export const CDP_CONNECT_TIMEOUT_MS = 10_000; + +/** CDP command timeout when proxying through daemon (ms). */ +export const CDP_COMMAND_TIMEOUT_MS = 10_000;
@@ -0,0 +1,245 @@
+/** + * IpcServer — Unix socket server for daemon ↔ pi client communication. + * + * Accepts multiple concurrent pi client connections over a Unix domain + * stream socket. Uses newline-delimited JSON framing (one WireMessage per + * line). Clients are anonymous until they send a control.register message. + * + * References: + * - net.createServer: https://nodejs.org/docs/latest-v22.x/api/net.html#netcreateserveroptions-connectionlistener + * - server.listen(path): https://nodejs.org/docs/latest-v22.x/api/net.html#serverlistenpath-backlog-callback + * - readline.createInterface: https://nodejs.org/docs/latest-v22.x/api/readline.html#readlinecreateinterfaceoptions + */ + +import { createServer, type Server, type Socket } from "node:net"; +import { createInterface, type Interface } from "node:readline"; +import { unlinkSync } from "node:fs"; +import { + type WireMessage, + type WireControl, + isWireMessage, + DAEMON_SOCKET_PATH, + DAEMON_MAX_CLIENTS, + DAEMON_STALE_SOCKET_CLEANUP, +} from "./protocol"; + +// ── Types ────────────────────────────────────────────────────────────────────── + +/** A connected pi client with its socket and readline interface. */ +export type ClientSocket = { + /** Client namespace (set after registration). */ + id: string; + readonly socket: Socket; + readonly rl: Interface; + /** True once a valid control.register message has been received. */ + registered: boolean; +}; + +/** Handler called for every valid WireMessage from any client. */ +export type MessageHandler = (msg: WireMessage, client: ClientSocket) => void; + +/** Handler called when a client connects or disconnects. */ +export type ConnectionHandler = (client: ClientSocket) => void; + +export type IpcServer = { + /** Start listening on the Unix socket. Resolves when the server is ready. */ + start(): Promise<void>; + /** Close the server and all client connections. Resolves when cleaned up. */ + stop(): Promise<void>; + /** Register a handler for incoming WireMessages from any client. */ + onMessage(handler: MessageHandler): void; + /** Register a handler for client connect events. */ + onConnect(handler: ConnectionHandler): void; + /** Register a handler for client disconnect events. */ + onDisconnect(handler: ConnectionHandler): void; + /** Send a WireMessage to a specific client by clientId. No-op if client not found. */ + send(clientId: string, msg: WireMessage): void; + /** Send a WireMessage to all connected (registered) clients. */ + broadcast(msg: WireMessage): void; + /** Force-disconnect a client by clientId. */ + disconnectClient(clientId: string): void; + /** All currently connected clients (by clientId). */ + clients(): ReadonlyMap<string, ClientSocket>; + /** Number of connected clients. */ + clientCount(): number; +}; + +// ── Implementation ───────────────────────────────────────────────────────────── + +export const createIpcServer = (): IpcServer => { + let server: Server | null = null; + // Anonymous temp-id → ClientSocket. Temp ids are assigned before registration. + const sockets = new Map<string, ClientSocket>(); + // clientId → ClientSocket (only registered clients). + const clients = new Map<string, ClientSocket>(); + let messageHandler: MessageHandler | null = null; + let connectHandler: ConnectionHandler | null = null; + let disconnectHandler: ConnectionHandler | null = null; + let nextTempId = 1; + + const start = (): Promise<void> => + new Promise((resolve, reject) => { + // Clean up stale socket file from a previous crashed daemon. + if (DAEMON_STALE_SOCKET_CLEANUP) { + try { + unlinkSync(DAEMON_SOCKET_PATH); + } catch { + // ENOENT is expected — no stale socket to clean. + } + } + + server = createServer({ pauseOnConnect: false }, (socket: Socket) => { + // Reject if at capacity (check registered clients only). + if (clients.size >= DAEMON_MAX_CLIENTS) { + socket.destroy(); + return; + } + + const tempId = `anon-${nextTempId++}`; + const rl = createInterface({ input: socket, crlfDelay: Infinity }); + let currentClient: ClientSocket = { id: tempId, socket, rl, registered: false }; + + sockets.set(tempId, currentClient); + + // Buffer for partial lines. readline handles line splitting, but + // we store a reference in case we need it for diagnostics. + let lineCount = 0; + + rl.on("line", (line: string) => { + lineCount++; + let msg: WireMessage | null; + try { + const parsed: unknown = JSON.parse(line); + if (!isWireMessage(parsed)) return; + msg = parsed; + } catch { + // Malformed JSON — ignore silently, don't crash the daemon. + return; + } + + // Handle registration before forwarding to the general handler. + if (msg.type === "control" && msg.action === "register" && !currentClient.registered) { + const clientId = msg.clientId; + if (!clientId) return; + + // If a client with this id is already connected, reject. + if (clients.has(clientId)) { + const reply: WireControl = { + type: "control", + action: "shutdown", + reason: `clientId ${clientId} is already connected`, + }; + socket.write(JSON.stringify(reply) + "\n"); + socket.destroy(); + return; + } + + // Promote from anonymous to registered — mutate in place so the + // closure captures the updated client reference. + sockets.delete(tempId); + currentClient.id = clientId; + currentClient.registered = true; + clients.set(clientId, currentClient); + + // Acknowledge registration. + const ack: WireControl = { type: "control", action: "registered", clientId }; + socket.write(JSON.stringify(ack) + "\n"); + + connectHandler?.(currentClient); + return; + } + + // Require registration for non-control messages. + if (!currentClient.registered) return; + + // Forward to the general handler. + messageHandler?.(msg, currentClient); + }); + + socket.on("error", () => { + // Socket errors (ECONNRESET, etc.) are handled by the 'close' event. + }); + + socket.on("close", () => { + // Clean up whichever map the client is in. + const found = clients.get(currentClient.id) ?? sockets.get(tempId); + if (found) { + clients.delete(found.id); + sockets.delete(tempId); + // Notify if this was a registered client. + if (found.registered) { + disconnectHandler?.(found); + } + } + // Clean up readline to prevent memory leaks. + rl.close(); + }); + }); + + server.on("error", (err: NodeJS.ErrnoException) => { + reject(err); + }); + + server.listen(DAEMON_SOCKET_PATH, () => { + resolve(); + }); + }); + + const stop = (): Promise<void> => + new Promise((resolve) => { + // Close all client sockets. + for (const [, client] of clients) { + try { client.socket.destroy(); } catch { /* best-effort */ } + } + for (const [, client] of sockets) { + try { client.socket.destroy(); } catch { /* best-effort */ } + } + clients.clear(); + sockets.clear(); + + if (server) { + server.close(() => { + // Clean up the socket file. + try { unlinkSync(DAEMON_SOCKET_PATH); } catch { /* ENOENT ok */ } + resolve(); + }); + } else { + resolve(); + } + }); + + return { + start, + stop, + onMessage(handler) { + messageHandler = handler; + }, + onConnect(handler) { + connectHandler = handler; + }, + onDisconnect(handler) { + disconnectHandler = handler; + }, + send(clientId, msg) { + const client = clients.get(clientId); + if (!client) return; + try { + client.socket.write(JSON.stringify(msg) + "\n"); + } catch { + // Socket write error — the close handler will clean up. + } + }, + broadcast(msg) { + for (const [clientId] of clients) { + this.send(clientId, msg); + } + }, + disconnectClient(clientId) { + const client = clients.get(clientId); + if (!client) return; + client.socket.destroy(); + }, + clients: (): ReadonlyMap<string, ClientSocket> => clients, + clientCount: (): number => clients.size, + }; +};
@@ -0,0 +1,85 @@
+/** + * Daemon lifecycle helpers — spawn detection and lazy launch. + * + * The daemon is a standalone long-lived process that owns the single WebSocket + * to Chrome. pi instances connect to it via a Unix domain socket. This module + * provides the plumbing to detect a running daemon and spawn one if needed. + * + * Reference: + * - child_process.spawn detached: https://nodejs.org/docs/latest-v22.x/api/child_process.html#optionsdetached + */ + +import { spawn, type ChildProcess } from "node:child_process"; +import { access } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { DAEMON_SOCKET_PATH } from "./protocol"; + +const moduleDir = dirname(fileURLToPath(import.meta.url)); + +/** + * Check whether the daemon is running by testing if its Unix socket exists. + * The socket file is created by the daemon on startup and removed on shutdown. + */ +export const isDaemonRunning = async (): Promise<boolean> => { + try { + await access(DAEMON_SOCKET_PATH); + return true; + } catch { + return false; + } +}; + +/** + * Spawn the daemon as a detached child process. The daemon will continue + * running after the parent pi process exits. + * + * The daemon entry point is resolved relative to this module. When running + * from source (tsx), the entry is src/daemon/index.ts. When running compiled, + * it's dist/daemon/index.js. + * + * Returns the child process handle (caller should call child.unref()). + */ +export const spawnDaemon = (): ChildProcess | null => { + // Resolve the daemon entry point and tsx binary. + const daemonScript = join(moduleDir, "index.ts"); + const tsxBin = join(moduleDir, "..", "..", "node_modules", ".bin", "tsx"); + + // Use tsx to run the daemon TypeScript source directly. + // The spawed process inherits the parent's env so module resolution works. + // Fall back to `npx tsx` if the local binary isn't available. + const cmd = existsSync(tsxBin) ? tsxBin : "npx"; + const args = existsSync(tsxBin) ? [daemonScript] : ["tsx", daemonScript]; + + const child = spawn(cmd, args, { + detached: true, + stdio: "ignore", + env: { ...process.env }, + }); + + child.unref(); + return child; +}; + +/** + * Ensure the daemon is running. If not, spawn it and wait (up to timeoutMs) + * for the socket file to appear. + * + * Returns true if the daemon is running (or was successfully spawned). + */ +export const ensureDaemon = async (timeoutMs = 10_000): Promise<boolean> => { + if (await isDaemonRunning()) return true; + + const child = spawnDaemon(); + if (!child) return false; + + // Poll for the socket file to appear + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await isDaemonRunning()) return true; + await new Promise((r) => setTimeout(r, 200)); + } + + return false; +};
@@ -0,0 +1,41 @@
+/** + * browser_setup tool — agent-callable browser initialization. + * + * The agent can call this directly when browser tools fail with + * "not initialized" instead of asking the user. Idempotent: safe + * to call even when already connected. + */ + +import { Type } from "typebox"; +import { Text } from "@mariozechner/pi-tui"; +import type { Result } from "../util/result"; +import { err, ok } from "../util/result"; +import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool"; +import { performSetup } from "../setup"; + +const SetupArgs = Type.Object({}); + +export const setupTool = defineBrowserTool({ + name: "browser_setup", + label: "Browser Setup", + description: + "Initialize browser control. Spawns the daemon, connects to Chrome, opens a test tab. " + + "Call when browser tools fail with 'Browser harness not initialized'. Idempotent.", + promptSnippet: "Initialize browser connection", + promptGuidelines: [ + "Call browser_setup when you get a 'Browser harness not initialized' error from any browser tool.", + "This tool is idempotent — calling it when already connected is harmless.", + "After browser_setup succeeds, retry the browser tool that failed.", + ], + parameters: SetupArgs, + // ensureAlive:false — this tool IS the setup; it must bypass the socket guard + ensureAlive: false, + renderCall: () => new Text("🔧 Initializing browser...", 0, 0), + async handler(_args, { client }): Promise<Result<ToolOk, ToolErr>> { + const result = await performSetup(client); + if (result.success) { + return ok({ text: result.data }); + } + return err({ kind: "internal", message: result.error }); + }, +});
@@ -27,6 +27,7 @@ import { registerSetupCommand } from "./setup";
import { type BrowserState, defaultState, persistState, restoreState } from "./state"; import { registerAllTools } from "./registry"; import { cleanupTempDirs } from "./util/truncate"; +import { createDaemonTransport } from "./cdp/daemon-transport"; export default function browserHarnessExtension(pi: ExtensionAPI): void { const flagNs = pi.getFlag("browser-namespace") as string | undefined;@@ -98,25 +99,43 @@ });
pi.on("session_start", async (_event, ctx) => { state = restoreState(ctx, state.namespace); - const initialOwnership: { ownedTargetIds?: ReadonlyArray<string>; harnessWindowTargetId?: string } = {}; - if (state.ownedTargetIds !== undefined) initialOwnership.ownedTargetIds = state.ownedTargetIds; - if (state.harnessWindowTargetId !== undefined) initialOwnership.harnessWindowTargetId = state.harnessWindowTargetId; - client = createBrowserClient({ - namespace: state.namespace, - ...(Object.keys(initialOwnership).length > 0 ? { initialOwnership } : {}), - onOwnershipChange: (snap) => { - state = { - ...state, - ownedTargetIds: snap.ownedTargetIds, - ...(snap.harnessWindowTargetId !== undefined - ? { harnessWindowTargetId: snap.harnessWindowTargetId } - : {}), - }; - persistState(pi, state); - }, - }); - // failure is fine — surfaced via tool errors when the agent first tries to use the browser - await client.start(); + + // Create the client lazily on first session. The daemon transport + // is created but NOT connected — that only happens when the user + // runs /browser-setup or a browser tool call finds an existing socket. + // This means zero Chrome prompts for non-browser pi sessions. + if (!client) { + const transport = createDaemonTransport(state.namespace); + + const initialOwnership: { ownedTargetIds?: ReadonlyArray<string>; harnessWindowTargetId?: string } = {}; + if (state.ownedTargetIds !== undefined) initialOwnership.ownedTargetIds = state.ownedTargetIds; + if (state.harnessWindowTargetId !== undefined) initialOwnership.harnessWindowTargetId = state.harnessWindowTargetId; + + client = createBrowserClient({ + namespace: state.namespace, + transport, + ...(Object.keys(initialOwnership).length > 0 ? { initialOwnership } : {}), + onOwnershipChange: (snap) => { + state = { + ...state, + ownedTargetIds: snap.ownedTargetIds, + ...(snap.harnessWindowTargetId !== undefined + ? { harnessWindowTargetId: snap.harnessWindowTargetId } + : {}), + }; + // Ownership changes can fire from CDP events at any time, + // including after session replacement when pi's ctx is stale. + // Persistence is best-effort — failure must not crash the event consumer. + // ponytail: try/catch is the simplest guard against stale-ctx. + try { persistState(pi, state); } catch { /* stale ctx — safe to ignore */ } + }, + }); + } + + // Do NOT call client.start() here — browser control is on-demand. + // The first browser tool call or /browser-setup triggers the actual + // daemon socket connection and Chrome prompt. + if (!toolsRegistered) { registerAllTools(pi, client); toolsRegistered = true;@@ -132,15 +151,17 @@ pi.on("session_shutdown", async () => {
persistState(pi, state); if (client) { try { - await client.stop(); + // Detach from the page target (removes the "Chrome is being controlled" + // banner) but keep the transport alive for the next session. + await client.detach(); } catch (e) { - // Shutdown is best-effort, but a stuck stop() points at a transport - // bug worth surfacing for debugging. - console.warn("[pi-browser-harness] client.stop() failed during shutdown:", e); + console.warn("[pi-browser-harness] client.detach() failed during shutdown:", e); } - client = null; + // ponytail: keep the transport alive across sessions. + // Do NOT call client.stop() or null out client — the daemon connection + // persists and eliminates the per-session "Allow Remote Debugging" prompt. + // Only Chrome restart or daemon death triggers a new prompt. } - toolsRegistered = false; await cleanupTempDirs(); });
@@ -17,11 +17,14 @@ import { httpGetTool, networkRequestsTool } from "./domains/network";
import { consoleTool } from "./domains/console"; import { snapshotTool } from "./domains/snapshot"; import { executeJsTool, runScriptTool } from "./domains/js"; +import { setupTool } from "./domains/setup"; // Mutation tools that modify page / browser state are marked `serialized: true` // so they run through a shared async mutex. Observation / read-only tools are // left unmarked and can execute in parallel. const TOOLS: ReadonlyArray<AnyBrowserToolDefinition> = [ + // setup comes first — the agent calls this to self-recover from not-initialized + setupTool, { ...clickTool, serialized: true }, { ...typeTool, serialized: true }, { ...pressKeyTool, serialized: true },
@@ -1,37 +1,50 @@
/** - * Setup command — verifies Chrome is running with remote debugging - * and connects the pi agent to it. + * Setup — verifies Chrome is running with remote debugging and connects + * the pi agent to it. Exposed as both a slash command (/browser-setup) for + * users and a tool (browser_setup) for the agent to self-recover. */ -import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { execSync } from "node:child_process"; import type { BrowserClient } from "./client"; +import { ensureDaemon } from "./daemon/spawn"; + +// ── Public: register the /browser-setup slash command ────────────────────── export function registerSetupCommand(pi: ExtensionAPI, client: BrowserClient): void { pi.registerCommand("browser-setup", { description: "Connect pi to your Chrome browser", handler: async (_args, ctx) => { - await runSetup(ctx, client); + const result = await performSetup(client); + if (result.success) { + ctx.ui.notify(result.data, "info"); + } else { + ctx.ui.notify(result.error, "error"); + } }, }); } -async function runSetup(ctx: ExtensionContext, client: BrowserClient): Promise<void> { - ctx.ui.notify("browser setup: checking Chrome...", "info"); +// ── Shared setup result ──────────────────────────────────────────────────── +export type SetupResult = { success: true; data: string } | { success: false; error: string }; + +// ── Shared setup logic (used by both the command and the tool) ───────────── + +export async function performSetup(client: BrowserClient): Promise<SetupResult> { // Step 1: Check Chrome is running const chromeRunning = checkChromeRunning(); if (!chromeRunning) { - ctx.ui.notify( - "No browser instance running. Please open your browser and then run /browser-setup.", - "error", - ); - return; + return { success: false, error: "No browser instance running. Please open your browser and then run /browser-setup." }; + } + + // Step 2: Start the browser daemon (spawns if not running, silently reuses if alive) + const daemonReady = await ensureDaemon(); + if (!daemonReady) { + return { success: false, error: "Could not start the browser daemon. Check /tmp/pi-browser-daemon.sock." }; } - ctx.ui.notify("Chrome is running ✓", "info"); - // Step 2: Try to connect to Chrome DevTools - ctx.ui.notify("Connecting to Chrome DevTools...", "info"); + // Step 3: Connect to Chrome DevTools const startResult = await client.start(); if (!startResult.success) { const msg = startResult.error.message;@@ -43,30 +56,23 @@ lower.includes("remote debugging") ||
lower.includes("econnrefused") || lower.includes("cannot reach chrome devtools") ) { - ctx.ui.notify( - "Chrome remote debugging needs to be enabled.\n\n" + - "Open chrome://inspect/#remote-debugging in your browser, tick the\n" + - "\"Discover network targets\" / Allow checkbox, then run /browser-setup again.\n\n" + + return { + success: false, + error: + "Chrome remote debugging needs to be enabled.\n\n" + + 'Open chrome://inspect/#remote-debugging in your browser, tick the\n' + + '"Discover network targets" / Allow checkbox, then retry.\n\n' + "Or set BU_CDP_WS to a remote browser WebSocket URL.", - "warning", - ); - return; + }; } - ctx.ui.notify(`Connection failed: ${msg}`, "error"); - return; + return { success: false, error: `Connection failed: ${msg}` }; } - ctx.ui.notify("Connected to Chrome ✓", "info"); - // Step 3: Verify with test navigation - ctx.ui.notify("Testing browser control...", "info"); + // Step 4: Verify with test navigation const tabResult = await client.newTab("https://github.com"); if (!tabResult.success) { - ctx.ui.notify( - `Browser connected but test navigation failed: ${tabResult.error.message}`, - "warning", - ); - return; + return { success: false, error: `Browser connected but test navigation failed: ${tabResult.error.message}` }; } const info = await client.pageInfo();@@ -75,24 +81,24 @@ await client.session().call("Page.handleJavaScriptDialog", { accept: true });
} const pageUrl = info.success && !("dialog" in info.data) ? info.data.url : "github.com"; - ctx.ui.notify(`Browser control verified ✓\nNavigated to: ${pageUrl}`, "info"); + return { success: true, data: `Browser connected ✓\nNavigated to: ${pageUrl}` }; } + +// ── Chrome process detection ─────────────────────────────────────────────── function checkChromeRunning(): boolean { try { if (process.platform === "darwin") { - // Exact line matching — excludes "Google Chrome Helper" etc. - // that linger after the user quits the browser. + // ps -o comm= returns the full executable path on modern macOS. + // We check for "Google Chrome" anywhere in the path (case-insensitive), + // excluding helper/renderer subprocesses. const out = execSync("ps -A -o comm=", { timeout: 5000 }).toString().toLowerCase(); const lines = out.split("\n").map((l) => l.trim()).filter(Boolean); - const browserProcesses = ["google chrome", "chromium", "microsoft edge"]; return lines.some((l) => { - if (l.includes("helper") || l.includes("renderer")) return false; - return browserProcesses.includes(l); + if (l.includes("helper") || l.includes("renderer") || l.includes("crashpad")) return false; + return l.includes("google chrome") || l.includes("chromium") || l.includes("microsoft edge"); }); } else if (process.platform === "linux") { - // Use args to distinguish the main browser process from sub-processes - // (GPU, renderer, utility, etc.) which carry --type= flags. const out = execSync("ps -A -o comm=,args=", { timeout: 5000 }).toString().toLowerCase(); const lines = out.split("\n").map((l) => l.trim()).filter(Boolean); const browserComms = ["chrome", "chromium", "chromium-browser", "msedge", "microsoft-edge", "google-chrome"];
@@ -9,6 +9,8 @@ ToolDefinition,
ToolRenderResultOptions, } from "@mariozechner/pi-coding-agent"; import type { Component } from "@mariozechner/pi-tui"; +import { access } from "node:fs/promises"; +import { DAEMON_SOCKET_PATH } from "../daemon/protocol"; import type { BrowserClient } from "../client"; import type { Result } from "./result";@@ -142,6 +144,23 @@ }
: {}), async execute(_toolCallId, args, signal, onUpdate, extensionCtx) { if (def.ensureAlive !== false) { + // Check daemon socket exists before attempting browser control. + // If missing, the user hasn't run /browser-setup yet. Return a clear + // error so the agent knows to direct the user to initialize. + try { + await access(DAEMON_SOCKET_PATH); + } catch { + return toToolResult( + { + success: false, + error: { + kind: "not_connected", + message: "Browser harness not initialized. Run /browser-setup first", + }, + }, + def.name, + ); + } const alive = await client.ensureAlive(); if (!alive.success) { return toToolResult(
@@ -0,0 +1,182 @@
+/** + * Manual integration test for CdpBridge against real Chrome. + * Requires: Chrome with remote debugging enabled (chrome://inspect/#remote-debugging). + * Run: npx tsx test/manual/bridge-test.ts + */ +import { createCdpBridge, type SendToClient, type CdpBridge } from "../../src/daemon/bridge"; +import type { WireRequest, WireResponse, WireEvent } from "../../src/daemon/protocol"; + +let passed = 0; +let failed = 0; +const check = (cond: boolean, label: string) => { + if (cond) { passed++; console.log(` ✓ ${label}`); } + else { failed++; console.error(` ✗ ${label}`); } +}; + +async function main() { + console.log("Starting CdpBridge..."); + const bridge = createCdpBridge(); + + // ── Test 1: Connect to Chrome ────────────────────────────────────────── + // start() now runs auto-retry in the background. Wait a moment for + // the initial connection attempt to complete. + await bridge.start(); + await sleep(3000); // allow time for WebSocket handshake + if (!bridge.isAlive()) { + console.error("Cannot continue — Chrome not reachable. Run /browser-setup first."); + console.error("(The bridge will keep retrying in the background.)"); + process.exit(1); + } + console.log(" ✓ Connect to Chrome: ok"); + check(bridge.isAlive(), "Bridge reports alive after connect"); + + // ── Test 2: Send Target.getTargets ───────────────────────────────────── + const responses: WireResponse[] = []; + const events: WireEvent[] = []; + let eventCount = 0; + + const send: SendToClient = (clientId, msg) => { + if (msg.type === "response") responses.push(msg); + else events.push(msg); + }; + + bridge.onEvent((evt, _targets) => { eventCount++; }); + + const req1: WireRequest = { type: "request", id: 1, method: "Target.getTargets" }; + bridge.handleRequest(req1, "pi-test", send); + + await sleep(2000); + + const res1 = responses.find(r => r.id === 1); + check(res1 != null, "Target.getTargets response received"); + if (res1?.result) { + const r = res1.result as any; + check(Array.isArray(r.targetInfos), "targetInfos is array"); + console.log(` Found ${r.targetInfos?.length ?? 0} targets`); + } + + // ── Test 3: Create a new tab ─────────────────────────────────────────── + responses.length = 0; + const req2: WireRequest = { + type: "request", id: 2, method: "Target.createTarget", + params: { url: "about:blank", newWindow: true }, + }; + bridge.handleRequest(req2, "pi-test", send); + await sleep(2000); + + const res2 = responses.find(r => r.id === 2); + check(res2 != null, "Target.createTarget response received"); + const targetId = (res2?.result as any)?.targetId as string | undefined; + check(typeof targetId === "string", `Created target: ${targetId ?? "FAILED"}`); + + if (!targetId) { + console.error("Cannot continue without a target"); + await bridge.stop(); + process.exit(1); + } + + // ── Test 4: Attach to target ─────────────────────────────────────────── + responses.length = 0; + const req3: WireRequest = { + type: "request", id: 3, method: "Target.attachToTarget", + params: { targetId, flatten: true }, + }; + bridge.handleRequest(req3, "pi-test", send); + await sleep(2000); + + const res3 = responses.find(r => r.id === 3); + check(res3 != null, "Target.attachToTarget response received"); + const sessionId = (res3?.result as any)?.sessionId as string | undefined; + check(typeof sessionId === "string", `Session ID: ${sessionId ?? "FAILED"}`); + + // Verify session ownership was tracked + if (sessionId) { + const owner = bridge.getSessionOwner(sessionId); + check(owner === "pi-test", "Session ownership tracked correctly"); + } + + if (!sessionId) { + await bridge.stop(); + process.exit(1); + } + + // ── Test 5: Navigate tab via session ─────────────────────────────────── + responses.length = 0; + const req4: WireRequest = { + type: "request", id: 4, method: "Page.navigate", + params: { url: "https://example.com" }, + sessionId, + }; + bridge.handleRequest(req4, "pi-test", send); + await sleep(3000); + + const res4 = responses.find(r => r.id === 4); + check(res4 != null, "Page.navigate response received"); + const frameId = (res4?.result as any)?.frameId as string | undefined; + check(typeof frameId === "string", `Navigated to example.com (frame: ${frameId ?? "FAILED"})`); + + // ── Test 6: Evaluate JS in the page ──────────────────────────────────── + responses.length = 0; + const req5: WireRequest = { + type: "request", id: 5, method: "Runtime.evaluate", + params: { expression: "document.title", returnByValue: true }, + sessionId, + }; + bridge.handleRequest(req5, "pi-test", send); + await sleep(2000); + + const res5 = responses.find(r => r.id === 5); + check(res5 != null, "Runtime.evaluate response received"); + const title = (res5?.result as any)?.result?.value as string | undefined; + check(title === "Example Domain", `Page title: "${title ?? "FAILED"}" (expected "Example Domain")`); + + // ── Test 7: Detach from target ───────────────────────────────────────── + responses.length = 0; + const req6: WireRequest = { + type: "request", id: 6, method: "Target.detachFromTarget", + params: { sessionId }, + }; + bridge.handleRequest(req6, "pi-test", send); + await sleep(1000); + + const res6 = responses.find(r => r.id === 6); + check(res6 != null, "Target.detachFromTarget response received"); + + // Session ownership should be released + const ownerAfter = bridge.getSessionOwner(sessionId); + check(ownerAfter === undefined, "Session ownership released after detach"); + + // ── Test 8: Close target ─────────────────────────────────────────────── + responses.length = 0; + const req7: WireRequest = { + type: "request", id: 7, method: "Target.closeTarget", + params: { targetId }, + }; + bridge.handleRequest(req7, "pi-test", send); + await sleep(1000); + + const res7 = responses.find(r => r.id === 7); + check(res7 != null, "Target.closeTarget response received"); + + // ── Test 9: Remove client ────────────────────────────────────────────── + bridge.removeClient("pi-test"); + // After removal, no pending requests should remain — verify by checking + // no errors are thrown when handling subsequent events. + + // ── Cleanup ──────────────────────────────────────────────────────────── + console.log("\nStopping bridge..."); + await bridge.stop(); + console.log("Bridge stopped ✓"); + + console.log(`\n${passed} passed, ${failed} failed`); + process.exit(failed > 0 ? 1 : 0); +} + +function sleep(ms: number): Promise<void> { + return new Promise(r => setTimeout(r, ms)); +} + +main().catch((e) => { + console.error("Test failed:", e); + process.exit(1); +});
@@ -0,0 +1,117 @@
+/** + * Integration test: IpcServer + DaemonTransport end-to-end. + * Starts a real IpcServer, connects a DaemonTransport, sends CDP requests. + * No Chrome required — the server simulates CDP responses. + * + * Run: npx tsx test/manual/daemon-transport-test.ts + */ +import { createIpcServer } from "../../src/daemon/server"; +import { createDaemonTransport } from "../../src/cdp/daemon-transport"; +import { DAEMON_SOCKET_PATH, serialize } from "../../src/daemon/protocol"; +import type { WireMessage } from "../../src/daemon/protocol"; +import { unlinkSync } from "node:fs"; + +let passed = 0; +let failed = 0; +const check = (cond: boolean, label: string) => { + if (cond) { passed++; console.log(` ✓ ${label}`); } + else { failed++; console.error(` ✗ ${label}`); } +}; + +async function main() { + // Clean up any stale socket + try { unlinkSync(DAEMON_SOCKET_PATH); } catch {} + + // ── Start IpcServer ──────────────────────────────────────────────────── + console.log("Starting IpcServer..."); + const server = createIpcServer(); + + // When a request arrives, reply with a fake CDP response + server.onMessage((msg, client) => { + if (msg.type === "request") { + // Simulate a CDP response + const reply: WireMessage = { + type: "response", + id: msg.id, + result: { fakeResult: true, method: msg.method }, + }; + server.send(client.id, reply); + } + }); + + await server.start(); + console.log("Server started ✓"); + + // ── Create DaemonTransport ───────────────────────────────────────────── + const transport = createDaemonTransport("pi-test-transport"); + + // ── Test 1: Connect ──────────────────────────────────────────────────── + const connResult = await transport.connect(""); + check(connResult.success, `Connect: ${connResult.success ? "ok" : connResult.error.message}`); + check(transport.state() === "open", `State is "open" (actual: "${transport.state()}")`); + + // ── Test 2: Send a request ───────────────────────────────────────────── + const reqResult = await transport.request("Page.navigate", { url: "https://example.com" }); + check(reqResult.success, `Request succeeded: ${reqResult.success}`); + if (reqResult.success) { + const data = reqResult.data as any; + check(data?.fakeResult === true, "Response has fakeResult: true"); + check(data?.method === "Page.navigate", "Response echoes method"); + } + + // ── Test 3: Send request with sessionId ──────────────────────────────── + const req2 = await transport.request("Runtime.evaluate", { expression: "1+1" }, { sessionId: "abc123" }); + check(req2.success, "Request with sessionId succeeded"); + + // ── Test 4: Events ───────────────────────────────────────────────────── + const eventPromise = new Promise<boolean>((resolve) => { + const timeout = setTimeout(() => resolve(false), 2000); + (async () => { + for await (const evt of transport.events()) { + clearTimeout(timeout); + resolve(evt.method === "Target.targetCreated"); + break; + } + })(); + }); + + // Simulate server sending an event + server.broadcast({ + type: "event", + method: "Target.targetCreated", + params: { targetInfo: { type: "page", targetId: "test-123" } }, + }); + + const gotEvent = await eventPromise; + check(gotEvent, "Event received via transport.events()"); + + // ── Test 5: Close transport ──────────────────────────────────────────── + let closeFired = false; + transport.onClose(() => { closeFired = true; }); + + await transport.close(); + check(transport.state() === "closed", "State is closed after close()"); + check(closeFired, "onClose handler fired"); + + // ── Test 6: Reconnect ────────────────────────────────────────────────── + await new Promise(r => setTimeout(r, 500)); // let server process socket close + const reconnectResult = await transport.connect(""); + check(reconnectResult.success, `Reconnect: ${reconnectResult.success ? "ok" : reconnectResult.error.message}`); + + // ── Test 7: Request after reconnect ──────────────────────────────────── + const req3 = await transport.request("Browser.getVersion", {}); + check(req3.success, "Request after reconnect succeeded"); + + // ── Cleanup ──────────────────────────────────────────────────────────── + await transport.close(); + await server.stop(); + console.log("Cleanup complete ✓"); + + console.log(`\n${passed} passed, ${failed} failed`); + process.exit(failed > 0 ? 1 : 0); +} + +main().catch((e) => { + console.error("Test failed:", e); + process.exit(1); +});
@@ -0,0 +1,163 @@
+/** + * End-to-end integration test: full daemon pipeline. + * + * Starts the daemon (IpcServer + CdpBridge), connects via DaemonTransport, + * exercises the full CDP request/response/event lifecycle. + * + * Run: npx tsx test/manual/e2e-test.ts + */ +import { createIpcServer } from "../../src/daemon/server"; +import { createCdpBridge, type SendToClient } from "../../src/daemon/bridge"; +import { createDaemonTransport } from "../../src/cdp/daemon-transport"; +import { DAEMON_SOCKET_PATH } from "../../src/daemon/protocol"; +import type { WireRequest } from "../../src/daemon/protocol"; +import { unlinkSync } from "node:fs"; + +let passed = 0; +let failed = 0; +const check = (cond: boolean, label: string) => { + if (cond) { passed++; console.log(` ✓ ${label}`); } + else { failed++; console.error(` ✗ ${label}`); } +}; + +async function main() { + // Clean up stale socket + try { unlinkSync(DAEMON_SOCKET_PATH); } catch {} + + // ── Start daemon ─────────────────────────────────────────────────────── + console.log("Starting daemon (IpcServer + CdpBridge)..."); + const ipcServer = createIpcServer(); + const cdpBridge = createCdpBridge(); + + // Wire IpcServer ↔ CdpBridge + ipcServer.onMessage((msg, client) => { + if (msg.type !== "request") return; + const send: SendToClient = (cid, resp) => ipcServer.send(cid, resp); + cdpBridge.handleRequest(msg, client.id, send); + }); + + cdpBridge.onEvent((event, targetClientIds) => { + for (const cid of targetClientIds) ipcServer.send(cid, event); + }); + + ipcServer.onDisconnect((client) => cdpBridge.removeClient(client.id)); + + await ipcServer.start(); + console.log("Daemon started ✓"); + + // The bridge will try to connect to Chrome — might fail, that's ok for + // this test. We'll simulate CDP responses from the server side. + + // ── Connect pi client via DaemonTransport ────────────────────────────── + console.log("\nConnecting pi client (DaemonTransport)..."); + const transport = createDaemonTransport("pi-e2e-test"); + + const connectResult = await transport.connect(""); + check(connectResult.success, `Client connect: ${connectResult.success ? "ok" : connectResult.error.message}`); + check(transport.state() === "open", "Transport state is open"); + + // ── Test 1: Request → simulated CDP response ─────────────────────────── + // The IpcServer's onMessage handler forwards requests to the bridge. + // The bridge tries to send to Chrome (which may be down). For this test, + // we intercept at the IpcServer level and simulate CDP responses. + + // Override the message handler to simulate CDP responses directly. + let rawRequests: WireRequest[] = []; + ipcServer.onMessage((msg, client) => { + if (msg.type === "request") { + rawRequests.push(msg); + // Simulate a CDP success response + ipcServer.send(client.id, { + type: "response", + id: msg.id, + result: { simulated: true, method: msg.method }, + }); + } + }); + + const res1 = await transport.request("Page.navigate", { url: "https://example.com" }); + check(res1.success, "CDP request succeeded"); + if (res1.success) { + const d = res1.data as any; + check(d?.simulated === true, "Response contains simulated flag"); + check(d?.method === "Page.navigate", "Response echoes method"); + } + check(rawRequests.length === 1, "Server received exactly 1 request"); + check(rawRequests[0]!.method === "Page.navigate", "Correct method forwarded"); + + // ── Test 2: Request with sessionId ───────────────────────────────────── + rawRequests.length = 0; + const res2 = await transport.request("Runtime.evaluate", { expression: "1+1" }, { sessionId: "session-abc" }); + check(res2.success, "Session-scoped request succeeded"); + check(rawRequests[0]?.sessionId === "session-abc", "SessionId forwarded correctly"); + + // ── Test 3: Multiple parallel requests ───────────────────────────────── + rawRequests.length = 0; + const [r3a, r3b, r3c] = await Promise.all([ + transport.request("Target.getTargets", {}), + transport.request("Browser.getVersion", {}), + transport.request("Page.captureScreenshot", {}), + ]); + check(r3a.success && r3b.success && r3c.success, "All 3 parallel requests succeeded"); + check(rawRequests.length === 3, "Server received all 3 requests"); + + // ── Test 4: Events ───────────────────────────────────────────────────── + const eventPromise = new Promise<string | null>((resolve) => { + const timeout = setTimeout(() => resolve(null), 2000); + (async () => { + for await (const evt of transport.events()) { + clearTimeout(timeout); + resolve(evt.method); + break; + } + })(); + }); + + // Simulate a CDP event broadcast from the daemon + ipcServer.broadcast({ + type: "event", + method: "Target.targetCreated", + params: { targetInfo: { type: "page", targetId: "e2e-test-target" } }, + }); + + const eventMethod = await eventPromise; + check(eventMethod === "Target.targetCreated", `Event received: ${eventMethod ?? "TIMEOUT"}`); + + // ── Test 5: Session persistence (detach simulation) ──────────────────── + // Simulate a session shutdown: close the transport, then reconnect. + // The transport.close() sends deregister, server cleans up. + await transport.close(); + check(transport.state() === "closed", "Transport closed"); + + // Small delay for server cleanup + await new Promise(r => setTimeout(r, 500)); + + // Reconnect — simulates next pi session + const reconnectResult = await transport.connect(""); + check(reconnectResult.success, "Transport reconnected (simulated session restart)"); + + // Send a request through the reconnected transport + const res4 = await transport.request("Target.getTargets", {}); + check(res4.success, "Request after reconnect succeeded"); + check(transport.state() === "open", "Transport state is open after reconnect"); + + // ── Test 6: Server-side client count ─────────────────────────────────── + check(ipcServer.clientCount() === 1, "Server reports 1 connected client"); + + // ── Test 7: Client disconnect cleanup ────────────────────────────────── + await transport.close(); + await new Promise(r => setTimeout(r, 500)); + check(ipcServer.clientCount() === 0, "Server reports 0 clients after disconnect"); + + // ── Cleanup ──────────────────────────────────────────────────────────── + await ipcServer.stop(); + console.log("\nDaemon stopped ✓"); + + console.log(`\n${passed} passed, ${failed} failed`); + process.exit(failed > 0 ? 1 : 0); +} + +main().catch((e) => { + console.error("E2E test failed:", e); + process.exit(1); +});
@@ -0,0 +1,141 @@
+/** + * Manual smoke test for IpcServer. + * Starts a server, connects a client, tests registration, send, broadcast, disconnect. + * Run: npx tsx test/manual/ipc-server-test.ts + */ +import { createConnection } from "node:net"; +import { createInterface } from "node:readline"; +import { createIpcServer } from "../../src/daemon/server"; +import { DAEMON_SOCKET_PATH } from "../../src/daemon/protocol"; +import type { WireMessage } from "../../src/daemon/protocol"; + +const TEST_CLIENT_ID = "pi-test-001"; + +async function main() { + let passed = 0; + let failed = 0; + const check = (cond: boolean, label: string) => { + if (cond) { passed++; console.log(` ✓ ${label}`); } + else { failed++; console.error(` ✗ ${label}`); } + }; + + // ── Start server ────────────────────────────────────────────────────── + console.log("Starting IpcServer..."); + const server = createIpcServer(); + + const receivedByServer: WireMessage[] = []; + let connectCount = 0; + let disconnectCount = 0; + + server.onMessage((msg) => { receivedByServer.push(msg); }); + server.onConnect(() => { connectCount++; }); + server.onDisconnect(() => { disconnectCount++; }); + + await server.start(); + console.log("Server started ✓"); + + // ── Connect client ──────────────────────────────────────────────────── + console.log("\nConnecting client..."); + const socket = createConnection(DAEMON_SOCKET_PATH); + const rl = createInterface({ input: socket, crlfDelay: Infinity }); + + const receivedByClient: WireMessage[] = []; + rl.on("line", (line) => { + try { receivedByClient.push(JSON.parse(line)); } catch { /* ignore */ } + }); + + await new Promise<void>((resolve) => socket.on("connect", resolve)); + console.log("Client connected ✓"); + + // ── Test 1: Registration ────────────────────────────────────────────── + // Send register, expect registered ack. + socket.write(JSON.stringify({ type: "control", action: "register", clientId: TEST_CLIENT_ID }) + "\n"); + await sleep(200); + + const ack = receivedByClient.find((m: any) => m.type === "control" && m.action === "registered"); + check(ack != null, "Registration acknowledged"); + check(connectCount === 1, "Connect handler fired"); + + // ── Test 2: Send message to server ──────────────────────────────────── + receivedByServer.length = 0; + const testRequest = { type: "request", id: 1, method: "Page.navigate", params: { url: "https://example.com" } }; + socket.write(JSON.stringify(testRequest) + "\n"); + await sleep(200); + + const received = receivedByServer.find((m: any) => m.type === "request" && m.id === 1); + check(received != null, "Server received request from client"); + check((received as any)?.method === "Page.navigate", "Method matches"); + + // ── Test 3: Server → client send ────────────────────────────────────── + receivedByClient.length = 0; + server.send(TEST_CLIENT_ID, { type: "response", id: 1, result: { frameId: "xyz" } }); + await sleep(200); + + const response = receivedByClient.find((m: any) => m.type === "response" && m.id === 1); + check(response != null, "Client received response from server"); + + // ── Test 4: Broadcast ───────────────────────────────────────────────── + receivedByClient.length = 0; + const broadcastMsg = { type: "event", method: "Target.targetCreated", params: { targetInfo: { type: "page" } } }; + server.broadcast(broadcastMsg); + await sleep(200); + + check(receivedByClient.length === 1, "Client received broadcast"); + const bc = receivedByClient[0] as any; + check(bc?.method === "Target.targetCreated", "Broadcast content matches"); + + // ── Test 5: Client disconnect ───────────────────────────────────────── + socket.destroy(); + await sleep(500); + + check(disconnectCount === 1, "Disconnect handler fired"); + check(server.clientCount() === 0, "Client count is 0 after disconnect"); + + // ── Test 6: Re-registration (same clientId after disconnect) ────────── + const socket2 = createConnection(DAEMON_SOCKET_PATH); + const rl2 = createInterface({ input: socket2, crlfDelay: Infinity }); + await new Promise<void>((resolve) => socket2.on("connect", resolve)); + + socket2.write(JSON.stringify({ type: "control", action: "register", clientId: TEST_CLIENT_ID }) + "\n"); + await sleep(200); + check(server.clientCount() === 1, "Re-registration works after disconnect"); + socket2.destroy(); + socket2.on("error", () => {}); // suppress EPIPE from server-side cleanup + await sleep(300); + + // ── Test 7: Max clients enforcement ─────────────────────────────────── + // Connect max clients, then one more should be rejected. + const testSockets: ReturnType<typeof createConnection>[] = []; + for (let i = 0; i < 20; i++) { + const s = createConnection(DAEMON_SOCKET_PATH); + s.on("error", () => {}); // server may reject and destroy this socket + testSockets.push(s); + await new Promise<void>((resolve) => s.on("connect", resolve)); + try { s.write(JSON.stringify({ type: "control", action: "register", clientId: `pi-test-max-${i}` }) + "\n"); } catch {} + await sleep(20); + } + await sleep(300); + check(server.clientCount() <= 16, "Max client enforcement works"); + // Clean up test sockets (some already destroyed by server rejection) + for (const s of testSockets) { + try { s.destroy(); } catch {} + s.removeAllListeners(); + } + + // ── Cleanup ─────────────────────────────────────────────────────────── + console.log(`\nCleaning up...`); + await server.stop(); + console.log("Server stopped ✓"); + + console.log(`\n${passed} passed, ${failed} failed`); + process.exit(failed > 0 ? 1 : 0); +} + +function sleep(ms: number): Promise<void> { + return new Promise((r) => setTimeout(r, ms)); +} + +main().catch((e) => { + console.error("Test failed:", e); + process.exit(1); +});
@@ -0,0 +1,21 @@
+import { createDaemonTransport } from "../../src/cdp/daemon-transport"; + +async function main() { + const t = createDaemonTransport("pi-live-test"); + const r = await t.connect(""); + console.log("Connect:", r.success ? "ok" : r.error.message); + console.log("State:", t.state()); + + const res = await t.request("Browser.getVersion", {}); + if (res.success) { + const d = res.data as any; + console.log("Browser:", d?.product ?? d); + } else { + console.log("Error:", res.error.message); + } + + await t.close(); + console.log("Done"); +} + +main();
@@ -0,0 +1,45 @@
+/** + * Test: socket guard produces clear error when daemon not running. + */ + +import { access } from "node:fs/promises"; +import { setTimeout as sleep } from "node:timers/promises"; +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { DAEMON_SOCKET_PATH } from "../../src/daemon/protocol"; + +async function main() { + const sock = DAEMON_SOCKET_PATH; + + // Simulate no daemon + try { + await access(sock); + console.log("FAIL: Socket exists unexpectedly"); + process.exit(1); + } catch { + console.log("✓ Socket missing — tool wrapper returns:"); + console.log(' "Browser harness not initialized. Run /browser-setup first"'); + } + + // Start daemon + const daemonEntry = join(dirname(fileURLToPath(import.meta.url)), "../../src/daemon/index.ts"); + const proc = spawn("npx", ["tsx", daemonEntry], { detached: true, stdio: "ignore" }); + proc.unref(); + await sleep(2000); + + // Now socket should exist + try { + await access(sock); + console.log("✓ Socket exists after daemon spawn — tool wrapper proceeds silently"); + } catch { + console.log("FAIL: Socket still missing after daemon spawn"); + process.exit(1); + } + + // Cleanup + proc.kill(); + console.log("✓ All socket guard checks passed"); +} + +main();