| Age | Commit message (Collapse) | Author |
|
Removes module banners, section dividers, and comments restating the code.
Keeps one-line notes for the window.open/browserContextId constraint, the
seed-tab claim, the unverifiable-pin reuse rule, the SeedTargetProvider
rationale, the deliberate dialog retention, the discovery-time browser
quirks, and the guarded CDP decoder assertion.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|
interactiveDiff re-assigned refs by tree position after every fill, click and
select, so any element appearing or disappearing shifted the numbering and a ref
the agent was still holding silently resolved to a different element — while the
tools kept promising that refs survive re-renders. Refs are now carried over by
backendNodeId and only genuinely new elements draw a new number.
Report: docs/superpowers/reports/2026-07-26-bug-audit.md#f6
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|
The event loop dropped every event whose sessionId was not the active tab's, so
the per-tab console and network buffers could only ever hold data captured while
their tab was in the foreground. The filter now drops only sessions this instance
never attached, and the dialog, frameNavigated and loadEventFired arms resolve
their tab from the event's sessionId instead of assuming the active tab — without
that, a background tab's dialog would have been recorded against the foreground
one. Tabs are also registered before the domain-enable round trips, so events
arriving during attach are no longer dropped as unknown.
Report: docs/superpowers/reports/2026-07-26-bug-audit.md#f4
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|
Every Target.attachToTarget mints a new sessionId and CDP domain enablement is
per-session, but switchTo only enabled domains on a tab's first visit. Switching
away and back left the refreshed session with Page, Network, Runtime and Log
disabled, so that tab silently stopped collecting console and network records and
stopped reporting dialogs and load events for the rest of the session.
Report: docs/superpowers/reports/2026-07-26-bug-audit.md#f3
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|
Types the event-queue iterator's TReturn as undefined so the terminal
result needs no cast; declares the isError field the pi runtime reads by
duck-typing; binds socket to a local const in the daemon transport;
replaces the @ts-ignore on the optional sharp import with a non-literal
specifier so src/ still typechecks where sharp was not installed.
Adds src/util/guards.ts as the single home for narrowing over unknown —
isRecord plus scalar/array readers — and converts the two private
isObject copies in cdp/types.ts and daemon/protocol.ts along with every
inline `as Record<string, unknown>` in domains/js.ts and domains/render.ts.
Gives Accessibility.getFullAXTree a real result schema, closing the last
unvalidated CDP boundary: only nodeId is required, every object carries
additionalProperties, and each AX value stays Unknown, so the tree
decodes as a whole or fails as a whole rather than being filtered.
Guards the five tool-result `details` readers, the daemon attach
response, the persisted session state, the browser-namespace flag, and
the fs errno narrowing, each feeding an already-present failure path.
The daemon-transport socket binding is not a bug fix: send() runs
synchronously inside sendWithTimeout's promise executor, so socket cannot
be reassigned between the check and the write. It removes the assertion
and makes the write immune to a later refactor that adds an await.
Two assertions survive by design and are documented in the task report:
the never->unknown re-widening in util/tool.ts, which the type-erased
tool registry makes unavoidable, and the AsyncFunction constructor lookup
in domains/js.ts, which JavaScript exposes no typed binding for.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|
Ten sites cast or implicitly typed JSON.parse output; JSON.parse("null")
would have satisfied all of them. parseJson returns a Result and validates
against a typebox schema. js.ts keeps unknown — user script output has no
fixed shape. daemon/bridge.ts assigned JSON.parse straight to CdpRawMessage
with no guard at all; it now uses isCdpRawMessage like the transport does.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|
Malformed or unknown events are now dropped rather than hand-narrowed;
consumers get typed params. Unknown methods are ignored, since the
transport delivers every event Chrome emits.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|
ref-resolve.ts and snapshot.ts had the same box-model unwrap, both using
data.model! to work around an untyped response. Extracted to domains/box.ts;
the typed schema makes the assertions unnecessary. Both derived the centre
identically (quad corner + half the model width/height), so centreOf keeps
that derivation; the shared zero-size rejection moved into boxOf, and
snapshot keeps its own off-viewport (x/y < 0) filter.
evalInIsolatedTab now goes through evaluateJson with the tab's own sessionId
and a caller-supplied guard; both callers evaluate expressions ending in
JSON.stringify, so they pass isJsonText rather than a check that checks
nothing. Runtime.evaluate/callFunctionOn result casts in form.ts, forms.ts
and keyboard.ts are replaced by named guards over the in-page result shapes.
Runtime.callFunctionOn's params schema gained arguments/returnByValue/
awaitPromise/userGesture: four domain call sites legitimately pass them and
typebox's additionalProperties:true does not widen the static type, so the
params type was rejecting valid CDP fields. Params schemas are type-only
(only results are compiled), so this is a compile-time widening with no
runtime effect.
tsc is green again for both tsconfigs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|
|
|
Target.attachToTarget was decoded at 5 sites, Browser.getWindowForTarget
at 5, and Runtime.evaluate unwrapped at another. Each is now one typed
helper. Deletes the dead cdp/window-binding.ts.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|
collapse decode into one path
session-call-test.ts previously only exercised decodeResult (Task 3's
surface, and mostly duplicating commands-test.ts) — call, callOnTarget,
callBrowser, and the session's internal req helper had zero coverage.
Add a stub CdpTransport (no socket, no browser) and test: a valid payload
decodes to typed data, a garbage payload becomes invalid_response without
throwing, a transport-level error passes through with its original kind
(not relabeled), call/callOnTarget/callBrowser route the session id
correctly, and opts.timeoutMs reaches the transport only when provided.
Also collapse the four near-identical transport-then-decode bodies (req,
call, callOnTarget, callBrowser) into one: the three public methods now
delegate to req, which is the sole place unknown ends for every CDP call
the session makes, internal or public. Public signatures are unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|
call/callOnTarget/callBrowser now take a known method and return its
decoded result type. The session is where unknown ends; transport keeps
returning unknown. Removes the casts in session.ts and target-factory.ts,
including the session's own internal Target.*/Browser.* calls, which now
decode through the same path as the public call methods.
Also completes two params-schema gaps in commands.ts surfaced by this
migration: Target.createTarget was missing openerId and Runtime.evaluate
was missing userGesture, both real CDP fields src/cdp/ already sends.
tsc is red project-wide (6 errors, all in src/domains/) — the sanctioned
state between Tasks 4 and 7.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|
One typebox schema per CDP command is the source of both the TS types and
the runtime validator, so they cannot drift. Malformed responses become
invalid_response errors instead of TypeErrors several frames later.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|
* feat: pin the agent to a chosen browser profile (#15)
Harness tabs were created with a bare Target.createTarget, which places
them in Chrome's defaultBrowserContextId — and that value follows window
focus. Which profile the agent acted as therefore depended on which
browser window the user last clicked, so the same task could run as a
work account on one run and a personal account on the next.
/browser-profile lists the connected browser's profiles as "Name (email)"
and pins one to ~/.pi/agent/browser-harness.json, so the choice outlives
the session. The same picker appears on first browser_setup when nothing
is pinned; with no interactive UI, or on cancel, setup continues with a
note and the previous behavior.
Chrome offers no direct route to another profile: Target.createTarget
rejects a foreign browserContextId, and openerId does not inherit the
opener's context. The window is opened through the browser's own command
line (--profile-directory, handed to the running browser by Chromium's
ProcessSingleton) and identified by a unique file:// sentinel page, since
a bare about:blank#token argument is discarded. Further tabs come from
window.open evaluated with userGesture: true — the only CDP-reachable way
to place a tab in a non-default browser context — correlated by a unique
token so concurrent isolated tabs stay distinct. A failed handshake is
reported, never silently redirected to another profile.
All four tab-creation sites now funnel through cdp/target-factory, so
open_urls and the isolated tabs behind web_search / read_page cannot run
with a different profile's cookies than the visible tabs.
Also fixed along the way: snap Chromium is discovered, Windows uses
%LOCALAPPDATA%, browsers launched with an explicit --user-data-dir are
found, the running browser is chosen by ranking candidates against the
connected user-data-dir rather than taking the first process found, and
an installed-but-closed browser no longer reads as running.
CI runs the profile suites and a real-browser end-to-end test on ubuntu,
macOS, and Windows — profile discovery is the one part of the harness
whose behavior genuinely differs per OS.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix: single-flight harness window creation
Isolated tabs run concurrently (browser_read_page is registered
unserialized), so several could each find "no harness window" and create
one — two blank windows before this feature, two LAUNCHED profile windows
after it. ensureHarnessWindow now shares one in-flight creation per
client.
Sharing introduces a second hazard: freshlyCreated is a claim on the seed
tab, and if every waiter received it, they would all drive the same tab.
Only the caller that started the creation keeps the claim; the rest open
their own tab beside it.
Covered by test/manual/profile-concurrency-test.ts (five concurrent
isolated tabs, pinned and unpinned: distinct targets, one window, correct
profile), wired into CI on Linux only — the coordination is pure JS and
identical across platforms.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix: tilde expansion differs by purpose on Windows
CI on windows-latest caught this: join("~", "x") yields "~\x" there, and
expandTilde only handled "~/".
The two uses are not the same, so they no longer share a helper. The
agent dir stays byte-compatible with pi's own expandTildePath ("~/" only,
every platform) — being more lenient would put the pin file somewhere pi
never looks. $CHROME_USER_DATA_DIR is ours to interpret, so it accepts
"~\" as well, which is what a Windows user would type.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
|
- Add forms domain with a universal field setter that auto-detects element
type and fires framework-compatible input events (React native-setter trick)
- Add window-binding helper to capture and bind the real Chrome windowId as the
session's durable window identity on every new-window code path
- Wire supporting changes across CDP session/ownership, client, navigate,
isolated-tab, prompt, and state
- Add manual tests for forms and window binding
- Bump version 0.8.3 -> 0.9.0
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
|
discovery (#13)
* feat: support Brave + Chrome channels, fix stale DevToolsActivePort discovery
Broadens browser detection and hardens CDP endpoint discovery, based on the
investigation and approach in #4 (Brave Beta on macOS with a leftover Chrome
DevToolsActivePort file).
Browser detection (src/setup.ts, src/cdp/discovery.ts):
- checkChromeRunning() now recognizes Brave (macOS "brave browser", Linux
brave/brave-browser, Windows brave.exe), alongside the existing Chrome/
Chromium/Edge detection. Also excludes gpu/updater sub-processes that can
linger after a browser quits.
- profileDirs() gains Brave (Stable/Beta/Nightly/Dev) and Chrome Beta/Dev/
Canary/Chromium profile paths across macOS, Linux, and Windows.
Stale DevToolsActivePort handling (src/cdp/discovery.ts):
Previously discovery trusted the first readable DevToolsActivePort file. A
browser that has quit leaves its file behind, so if another browser later binds
the same port (e.g. 9222), discovery returned a WS URL carrying the dead
browser's UUID against the live browser's server, and the connection failed.
discoverWsUrl() now:
- collects all readable candidates instead of committing to the first;
- when candidates share a port, keeps the most-recently-written file (mtime);
- skips candidates whose port isn't live via a fast single-shot probe (no
30s waitForPort block on stale files);
- asks each live browser for its canonical webSocketDebuggerUrl via
/json/version, falling back to the file's WS path when that endpoint is
disabled (some browsers only expose it via --remote-debugging-port);
- falls back to probing well-known ports (9222, plus BU_CDP_PORTS) when no
profile file is readable — covering sandboxed harnesses (EPERM/EACCES) and
non-default install locations.
Setup guidance updated to mention brave://inspect / edge://inspect and the
--remote-debugging-port launch flag.
Verified: typecheck clean; queryLiveWsUrl / mtime-dedup / fallback-port logic
exercised against a mock /json/version server; checkChromeRunning() matches the
single main process against live macOS ps output, excluding all helpers.
Closes #4
Co-authored-by: Trevor Whitney <trevorjwhitney@gmail.com>
* fix: harden discovery against malformed port files + stale-then-fallback
Addresses two issues raised in review of the discovery rewrite:
- Validate the port parsed from DevToolsActivePort before building a
candidate. A truncated/corrupt file (non-numeric or out-of-range first
line) yielded NaN, and net.connect throws ERR_SOCKET_BAD_PORT
synchronously for NaN / <=0 / >=65536 — which escaped discoverWsUrl as an
unhandled rejection instead of surfacing as a Result error. Such entries
are now skipped.
- Fall back to well-known ports (9222 / BU_CDP_PORTS) after all discovered
candidates prove stale. Previously the fallback ran only when no port file
was readable, so a leftover file for a quit browser caused a ~30s
waitForPort block and failure without ever trying a live fallback port.
Ports already covered by a candidate are skipped to avoid re-probing.
---------
Co-authored-by: Trevor Whitney <trevorjwhitney@gmail.com>
|
|
|
|
Interaction tools could only target elements by raw viewport coordinates
(browser_click) or model-guessed CSS selectors (browser_fill/focus/
select_option/upload). Neither survives a re-render: on React/SPA forms,
every save or edit reflows the page, so captured @(x,y) coordinates go
stale and guessed selectors miss — leaving the agent to fall back to
hand-rolled execute_js DOM walks and unnoticed failed saves.
Add a stable ref system, mirroring Playwright MCP / browser-use:
- snapshot assigns each interactive element a ref (e1, e2, …), shown as
[eN] in the outline, and publishes a per-tab ref → backendNodeId map
- interaction tools accept an optional `ref` that re-resolves the element
at call time (via DOM.resolveNode / getBoxModel), so it stays valid
across re-renders; coordinates/selectors remain as fallbacks
- mutating tools append a compact diff of changed / newly-appeared (*) /
removed interactive elements, so silent failed saves surface immediately
- stale refs return an actionable "re-snapshot" error instead of a miss
Ref map is replaced per snapshot (bounded memory); fill keeps the native
_valueTracker write path so React controlled inputs still update.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
|
|
* 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
|
|
Replace singleton dialog, pageInfoDirty, networkBuffer, and consoleBuffer
with per-tab TabSession objects stored in a Map<string, TabSession>.
- Add TabSession type (sessionId, targetId, dialog, pageInfoDirty,
networkBuffer, consoleBuffer) and tabs/sessionIdToTargetId Maps
- Add ev.sessionId filter guard in consumeEvents to prevent cross-tab
event contamination
- Rework switchTo to create/reuse TabSession without clearing buffers,
updating sessionId on re-attach
- Route all CDP events (dialog, network, console) through resolveTab
to the correct TabSession
- takeDialog/drainPageInfoInvalidations/drainNetworkBuffer/
drainConsoleBuffer read from current TabSession
- Target.targetDestroyed prunes dead TabSession entries
- attachFirstPage creates initial TabSession and prunes dead entries
- Per-tab pageCaches Map in client.ts replaces pageCache singleton
- listTabs reconciliation prunes dead pageCaches entries
- console-buffer.ts clear() resets nextSeq to 1 (per-tab seq space)
- Multi-agent warning in prompt.ts Parallel Execution section
|
|
|
|
prompts
Adds two new tools and pivots the agent's default workflow away from
screenshots toward structured page data.
browser_snapshot — CDP accessibility tree (roles, names, states, hierarchy)
with click coordinates @(x,y) attached to every interactive element via
DOM.getBoxModel (parallel, 1.5s aggregate budget). Pass directly to
browser_click — no screenshot round-trip. Optional includeScreenshot:true
for visual confirmation. format:"json" returns a slim structure with full
box per node.
browser_network_requests — replaces the deprecated browser_get_network_log
placeholder. Filters by urlPattern (substring or /regex/), method, status,
resource type, recency. Optional response-body capture under a 5s aggregate
budget with 50KB per-body cap. Buffer is page-scoped (cleared on tab switch),
500 records/tab, with a bufferOverflowed flag on drain.
CDP plumbing: in-process aggregator in src/cdp/network-buffer.ts wired into
the existing single-consumer event loop. Accessibility domain enabled
alongside Page/DOM/Runtime/Network on every attach.
Ctrl+O expand/collapse on three tools' renderResult — browser_snapshot,
browser_network_requests, browser_execute_js. Compact-by-default views with
keyHint("app.tools.expand") that respects user keybinding remaps.
Tool prompts pivoted snapshot-first: browser_screenshot is documented as
LAST RESORT for visual verification only; browser_snapshot is the default
for understanding pages; browser_execute_js is the default for surgical
reads. browser_click and browser_open_urls guidance follows.
SKILL.md collapsed from ~342 to ~53 lines — keeping only what isn't already
in tool prompts: the decision tree, connection rules, browser_run_script
daemon bindings.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|
Track which page targets this harness session opened so the agent can never
attach to, switch to, or close a tab the user opened themselves. New tabs
are spawned in a dedicated Chrome window (newWindow:true on first open,
openerId chaining after) so harness activity stays visually separate.
- new OwnershipRegistry (src/cdp/ownership.ts) tracks owned targetIds and
the harness window seed; mutations notify a single listener that persists
to BrowserState (ownedTargetIds, harnessWindowTargetId)
- attachFirstPage prefers an already-owned live target; otherwise opens a
fresh harness-owned tab in its own window instead of grabbing whatever
page the user is on
- session subscribes Target.setDiscoverTargets and reaps targetDestroyed
events so the registry stays in sync with reality
- listTabs returns owned:boolean per tab and reconciles dead ids
- browser_list_tabs defaults to scope:"owned"; pass scope:"all" to inspect
the user's other tabs (read-only — they cannot be switched to or closed)
- browser_switch_tab refuses non-owned tabs, with a clear remediation hint
- browser_new_tab opens inside the harness window via openerId, falling
back to a fresh window when the seed has been closed
- new browser_close_tab tool — owned tabs only, supports unique hex prefix
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|
document IIFE-wrap heuristic, log shutdown failures, fix CHANGELOG file-size claim
Five tweaks from the whole-implementation review:
- session.ts: log unexpected event-consumer rejections instead of silent swallow
- client.ts: comment the return-substring IIFE-wrap false-positive case
- transport.ts: comment that ws.onclose post-open settle is no-op-by-design
- index.ts: log client.stop() failures during shutdown
- CHANGELOG.md: correct largest-file claim (transport.ts and client.ts are ~220 each, not "client.ts at ~180")
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|
page info shape
|
|
|
|
|
|
|
|
errors
|
|
|
|
|