Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .claude/skills/debug-standalone-agent-browser/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: debug-standalone-agent-browser
description: Use when debugging Dormouse standalone behavior through the browser-based agent-browser harness instead of Tauri. Covers launching a fresh `pnpm dev:standalone:ab` run, observing sidecar and in-browser logs together, driving the UI with `agent-browser`, clearing stale nested browser sessions, and timing agent-browser screencast/tab behavior.
description: Use when debugging Dormouse standalone behavior through the browser-based agent-browser harness instead of Tauri. Covers launching a fresh `pnpm innerdogfood` run, observing sidecar and in-browser logs together, driving the UI with `agent-browser`, clearing stale nested browser sessions, and timing agent-browser screencast/tab behavior.
---

# Debug Standalone With Agent Browser
Expand All @@ -15,7 +15,7 @@ Run from the repo root:
DORMOUSE_BROWSER_DEV_AB_SESSION=dormouse-debug-$(date +%s) \
DORMOUSE_BROWSER_DEV_VITE_PORT=1550 \
DORMOUSE_BROWSER_DEV_HOST_PORT=1552 \
pnpm dev:standalone:ab
pnpm innerdogfood
```

The harness:
Expand Down Expand Up @@ -59,7 +59,7 @@ Stop any running harness with Ctrl-C (or `pkill -f dev-agent-browser.mjs`) befor

## Driving Dormouse

Use the outer harness session printed by `dev:standalone:ab`:
Use the outer harness session printed by `innerdogfood`:

```sh
agent-browser --session <outer-session> snapshot -i
Expand Down
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ pnpm install # install deps
pnpm build # build lib, vscode extension, Pocket, and website
```

**Inside Dormouse, run `innerdogfood`** — `dor ensure -- pnpm innerdogfood`, then
`dor ab --key innerdogfood open http://localhost:1420`

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this leave two live webviews on one sidecar? openAgentBrowser() runs unconditionally at the end of standalone/scripts/dev-agent-browser.mjs and does agent-browser --session <browserSession> open http://localhost:${vitePort} — with the defaults browserSession = 'dormouse-dev-standalone' and vitePort = 1420. So by the time the reader runs dor ab --key innerdogfood open http://localhost:1420, the harness has already opened that exact URL in its own session, and this line opens it again under dormouse.1.innerdogfood.

Both pages then hold an SSE stream on the same bridge, and the bridge has no per-webview routing: sseClients is a plain Set and broadcast() fans every sidecar event to all of it. That is the situation docs/specs/transport.md -> "PTY ownership" is about — "A PTY routed to one webview must not be stolen by another router; new routers attaching to a host must respect existing ownership" — and the dev bridge is the one host that doesn't enforce it.

The repo's own debug-standalone-agent-browser skill goes the other way: "Use the outer harness session printed by innerdogfood", i.e. drive the session the harness already opened rather than starting a second one. dor ab --session dormouse-dev-standalone <cmd> is the flag for that — per dor ab --help, "Attach to a raw agent-browser session by its literal name", and "dor opens (or reuses) the browser surface bound to the session" — which would put the harness's existing page in a Dormouse pane without a second webview.

If you exercised the two-browser form while validating the Codex link click and it behaves, say so and I'll drop it — I can't run the harness from CI. But as written, AGENTS.md and the skill now prescribe different things for the same task.

(`docs/specs/transport.md` → "Standalone browser-dev harness").

**Open every PR as a draft.** Chromatic bills per snapshot and skips drafts, so
marking a PR ready for review is what spends them.

Expand Down
2 changes: 1 addition & 1 deletion docs/specs/mouse-and-clipboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ Selection is available whenever the terminal handles the mouse (§3.5, §6.1).

### 3.1 Initiating a Selection

- A click-and-drag in the terminal content area begins a selection; a ~4px movement threshold separates a plain click (which only shifts pane focus) from a drag.
- **Must begin selection after a click-and-drag crosses ~4px**; plain clicks shift pane focus or activate hyperlinks. **Must capture mouse presses on xterm’s screen immediately** (rationale). Pinned by `lib/src/lib/terminal-mouse-router.test.ts`.
- On touch or pen, a primary pointer tap-and-drag takes the same path; non-primary touch pointers are ignored.
- The selection draws as a single perimeter outline tracing the union of selected cells (§7 owns rendering). Color is `--color-focus-ring` (`docs/specs/theme.md`), with a hardcoded cornflower-blue final fallback in `SelectionOverlay.tsx`.
- **A drag whose button comes up outside the webview iframe must still finalize**, by captured `pointerup` or the window-`mousemove` backstop (rationale).
Expand Down
2 changes: 2 additions & 0 deletions docs/specs/mouse-and-clipboard.rationale.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

## 3.1 Initiating a Selection

**Why mouse capture targets xterm’s screen.** Reproduced with Codex CLI 0.153.4 in the Chromium innerdogfood harness (2026-09): Codex emitted valid OSC 8 links and xterm recognized their targets. Capturing on Dormouse's wrapper at pointerdown retargeted mouseup outside xterm's screen and cleared its hovered link, preventing activation. Capturing on xterm’s screen keeps the release on the link-handler path. Deferring capture until the drag threshold restored clicks too, but lost a selection whose first coalesced movement left the iframe; early screen capture preserves both behaviors. An innerdogfood iframe probe starting 2 px inside the top edge and moving directly to −30 px delivered the move and release to `.xterm-screen` and finalized the selection (Chromium, 2026-09).

**How a mouse-up outside the iframe still reaches us.** Capture is taken on mouse-down, and Chromium delivers the captured `pointerup` across the frame boundary even when the button comes up over host chrome. Engines that do not honor cross-frame capture deliver nothing, so a window `mousemove` reporting `buttons === 0` stands in for the missed mouse-up: a pointer still holding the button reports `buttons === 1`, so the heal cannot fire mid-drag, but it does need the pointer to re-enter the frame. Against double-finalizing, the captured-pointerup path defers to a macrotask and stands down if the compatibility mouseup for an *inside* release arrives first.

## 5.1 Detection
Expand Down
4 changes: 2 additions & 2 deletions docs/specs/standalone.md
Original file line number Diff line number Diff line change
Expand Up @@ -519,7 +519,7 @@ Source of truth: `init_log` / `read_update_log` in `standalone/src-tauri/src/lib

Source of truth: `standalone/package.json` (package scripts),
`standalone/src-tauri/tauri.conf.json` (`build`, `bundle.resources`), and the root
`package.json` for the `dev:standalone*` orchestration.
`package.json` for the `dev:standalone` and `innerdogfood` orchestration.

- `stage` = `stage:dor-cli` (build + stage the dor CLI, `docs/specs/dor-cli.md`)
plus `stage:sidecar-proxy` (`build-sidecar-proxy.mjs` bundles the
Expand All @@ -538,7 +538,7 @@ Source of truth: `standalone/package.json` (package scripts),
Frontend edits hot-reload, but changes to the sidecar, the staged dor CLI, or the
bundled `lib/src/host/` sources need a manual re-stage and app restart — the dev
loop does not watch them.
- `pnpm dev:standalone:ab` runs the sidecar + webview in a normal browser via the
- `pnpm innerdogfood` runs the sidecar + webview in a normal browser via the
browser-dev harness instead of the Tauri WebView (`docs/specs/transport.md`,
Standalone browser-dev harness).

Expand Down
2 changes: 1 addition & 1 deletion docs/specs/transport.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ Optional booleans:

### Standalone browser-dev harness

`pnpm dev:standalone:ab` starts the standalone sidecar directly, a localhost-only HTTP bridge, and Vite with `VITE_DORMOUSE_BROWSER_DEV_HOST`, then opens the app URL in an `agent-browser` session. The browser build uses `BrowserSidecarAdapter` instead of `TauriAdapter` whenever that env var is present.
`pnpm innerdogfood` starts the standalone sidecar directly, a localhost-only HTTP bridge, and Vite with `VITE_DORMOUSE_BROWSER_DEV_HOST`, then opens the app URL in an `agent-browser` session. The browser build uses `BrowserSidecarAdapter` instead of `TauriAdapter` whenever that env var is present.

The bridge is a transport shim over the same sidecar protocol, not a second PTY implementation: fire-and-forget commands `POST /__dormouse_dev_host/send`, request/response commands `POST /__dormouse_dev_host/invoke`, host→webview events as SSE on `GET /__dormouse_dev_host/events`, and browser console output mirrored to `POST /__dormouse_dev_host/console` so one terminal shows sidecar, Vite, and in-browser logs together. The Burrow rides it too, on the message names below ("Message protocol"), so the harness runs a real Burrow against a per-run temp state directory (`docs/specs/standalone.md` → "Burrow service").

Expand Down
2 changes: 1 addition & 1 deletion lib/src/lib/mirrored-constants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ describe('the pairing walkthrough mirrors the copy it clicks', () => {
// Not copy but a log line: the harness prints where the Burrow keeps its
// state and the walkthrough parses it back out. Rendered with a stand-in path
// rather than compared as text, so the two only have to agree on what the
// line looks like — the `[dev:standalone:ab]` prefix `log()` adds is free to
// line looks like — the `[innerdogfood]` prefix `log()` adds is free to
// change, and the capture group has to survive.
it('parses the state directory the harness actually logs', () => {
const harness = 'standalone/scripts/dev-agent-browser.mjs';
Expand Down
53 changes: 42 additions & 11 deletions lib/src/lib/terminal-mouse-router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ class ListenerHost {
class FakeElement extends ListenerHost {
setPointerCapture = vi.fn();
releasePointerCapture = vi.fn();
querySelector = vi.fn();

getBoundingClientRect(): Pick<DOMRect, 'left' | 'top'> {
return { left: 0, top: 0 };
Expand Down Expand Up @@ -86,6 +87,8 @@ const dims: TerminalOverlayDims = {

function createHarness(windowHost: ListenerHost) {
const element = new FakeElement();
const screen = new FakeElement();
element.querySelector.mockReturnValue(screen);
const terminal = {
cols: 80,
clearSelection: vi.fn(),
Expand All @@ -103,7 +106,7 @@ function createHarness(windowHost: ListenerHost) {
getOverlayDims: () => dims,
setSelectionBaseline: vi.fn(),
});
return { cleanup, element, terminal, windowHost };
return { cleanup, element, screen, terminal, windowHost };
}

let windowHost: ListenerHost;
Expand Down Expand Up @@ -349,43 +352,71 @@ describe('terminal-mouse-router: override suppression', () => {
return pointerEvent({ pointerType: 'mouse', ...overrides });
}

// Drives a left-button mouse press into an active selection drag (capture +
// pendingDrag created on press, drag begun once movement crosses threshold).
// Drives a left-button mouse press into an active selection drag (pendingDrag
// and screen capture created on press; drag begins once movement crosses threshold).
function startMouseDrag(element: FakeElement) {
element.emit('pointerdown', mousePointer({ clientX: 5, clientY: 5 }));
element.emit('mousedown', mouseEvent({ clientX: 5, clientY: 5 }));
windowHost.emit('mousemove', mouseEvent({ clientX: 25, clientY: 15 }));
}

it('captures the mouse pointer on a left-button press over terminal-owned content', () => {
const { cleanup, element } = createHarness(windowHost);
it('captures a plain click on xterm’s screen without suppressing its hyperlink events', () => {
const { cleanup, element, screen } = createHarness(windowHost);
const down = mouseEvent();
const up = mouseEvent();

element.emit('pointerdown', mousePointer({ clientX: 5, clientY: 5 }));
element.emit('pointerdown', mousePointer());
element.emit('mousedown', down);
windowHost.emit('mousemove', mouseEvent({ clientX: 6, buttons: 1 }));
windowHost.emit('pointerup', mousePointer({ clientX: 6 }));
windowHost.emit('mouseup', up);

expect(element.setPointerCapture).toHaveBeenCalledWith(1);
expect(element.setPointerCapture).not.toHaveBeenCalled();
expect(element.querySelector).toHaveBeenCalledWith('.xterm-screen');
expect(screen.setPointerCapture).toHaveBeenCalledWith(1);
expect(screen.releasePointerCapture).toHaveBeenCalledWith(1);
expect(down.stopPropagation).not.toHaveBeenCalled();
expect(up.stopPropagation).not.toHaveBeenCalled();
expect(getMouseSelectionState('t1').selection).toBeNull();
cleanup();
});

it('captures before the first move so a drag can begin outside the iframe', () => {
const { cleanup, element, screen } = createHarness(windowHost);

element.emit('pointerdown', mousePointer({ clientY: 2 }));
element.emit('mousedown', mouseEvent({ clientY: 2 }));
expect(screen.setPointerCapture).toHaveBeenCalledWith(1);
expect(getMouseSelectionState('t1').selection).toBeNull();

windowHost.emit('mousemove', mouseEvent({ clientY: -20, buttons: 1 }));
expect(getMouseSelectionState('t1').selection).toMatchObject({ dragging: true });
cleanup();
});

it('does not capture the mouse pointer for non-left buttons', () => {
const { cleanup, element } = createHarness(windowHost);
const { cleanup, element, screen } = createHarness(windowHost);

element.emit('pointerdown', mousePointer({ button: 2 }));
element.emit('pointerdown', mousePointer({ button: 2, clientX: 5, clientY: 5 }));
element.emit('mousedown', mouseEvent({ button: 2, clientX: 5, clientY: 5 }));
windowHost.emit('mousemove', mouseEvent({ button: 2, clientX: 25, clientY: 15 }));

expect(element.setPointerCapture).not.toHaveBeenCalled();
expect(screen.setPointerCapture).not.toHaveBeenCalled();
cleanup();
});

it('finalizes a mouse drag from a captured pointerup when the button is released outside the iframe', () => {
vi.useFakeTimers();
const { cleanup, element } = createHarness(windowHost);
const { cleanup, element, screen } = createHarness(windowHost);

startMouseDrag(element);
expect(getMouseSelectionState('t1').selection).toMatchObject({ dragging: true });

// Released outside the iframe: only the captured pointerup reaches us, never
// the compatibility mouseup. The deferred finalize ends the drag in place.
windowHost.emit('pointerup', mousePointer({ clientX: 500, clientY: 500 }));
expect(element.releasePointerCapture).toHaveBeenCalledWith(1);
expect(screen.releasePointerCapture).toHaveBeenCalledWith(1);
expect(getMouseSelectionState('t1').selection).toMatchObject({ dragging: true });

vi.runAllTimers();
Expand Down
28 changes: 11 additions & 17 deletions lib/src/lib/terminal-mouse-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@ export function attachTerminalMouseRouter({
return { row: absRow, col, startedInScrollback };
};

// xterm's linkifier listens on its screen, not our outer wrapper. Capture
// there so even a plain click's retargeted mouseup reaches the link handler.
// Take capture on pointerdown: the first move may already be outside the iframe.
const mouseCaptureElement = element.querySelector<HTMLElement>('.xterm-screen') ?? element;
const DRAG_THRESHOLD_PX_SQ = 16;
// Touch has no Alt key, so a double-tap-then-drag is how a block selection is
// started on touch. A second touch within this window and distance of the
Expand All @@ -82,7 +86,6 @@ export function attachTerminalMouseRouter({
button: number;
clientX: number;
clientY: number;
pointerId: number | null;
touchLike: boolean;
} | null = null;
let activePointerId: number | null = null;
Expand Down Expand Up @@ -112,7 +115,7 @@ export function attachTerminalMouseRouter({

const beginPendingDrag = (
ev: MouseEvent | PointerEvent,
opts: { pointerId: number | null; touchLike: boolean; block?: boolean },
opts: { touchLike: boolean; block?: boolean },
) => {
const { state, cell, terminalOwns } = terminalOwnsEvent(ev);
// Touch suppresses compatibility mousedown, so popup's mouse listener
Expand All @@ -136,7 +139,6 @@ export function attachTerminalMouseRouter({
button: ev.button,
clientX: ev.clientX,
clientY: ev.clientY,
pointerId: opts.pointerId,
touchLike: opts.touchLike,
};
return true;
Expand Down Expand Up @@ -216,26 +218,18 @@ export function attachTerminalMouseRouter({
consumePointerEvent(ev, true);
return;
}
beginPendingDrag(ev, { pointerId: null, touchLike: false });
beginPendingDrag(ev, { touchLike: false });
};

const onPointerDown = (ev: PointerEvent) => {
if (ev.pointerType === 'mouse') {
// Capture the mouse pointer for left-button presses on terminal-owned
// content so a selection drag released *outside* our iframe still reports
// back. Chromium delivers the captured pointerup across the frame boundary
// even when the button comes up over the host page, letting onWindowPointerUp
// finalize the drag immediately rather than waiting for the cursor to wander
// back in (the window-mousemove heal). Engines that don't honor cross-frame
// capture get no such pointerup and fall back to that heal.
if (ev.button !== 0) return;
const { terminalOwns } = terminalOwnsEvent(ev);
if (!terminalOwns) return;
if (!terminalOwnsEvent(ev).terminalOwns) return;
try {
element.setPointerCapture(ev.pointerId);
mouseCaptureElement.setPointerCapture(ev.pointerId);
mouseDragPointerId = ev.pointerId;
} catch {
// Best-effort continuity aid; the heal still covers us if capture is rejected.
// The window-mousemove heal still covers an outside release.
mouseDragPointerId = null;
}
return;
Expand All @@ -249,7 +243,7 @@ export function attachTerminalMouseRouter({
const doubleTap = lastTouchTap !== null
&& Date.now() - lastTouchTap.time <= DOUBLE_TAP_MS
&& dx * dx + dy * dy <= DOUBLE_TAP_DIST_PX_SQ;
const handled = beginPendingDrag(ev, { pointerId: ev.pointerId, touchLike: true, block: doubleTap });
const handled = beginPendingDrag(ev, { touchLike: true, block: doubleTap });
if (!handled) return;
activePointerId = ev.pointerId;
suppressSyntheticMouseUntil = Date.now() + 800;
Expand Down Expand Up @@ -306,7 +300,7 @@ export function attachTerminalMouseRouter({
mouseDragPointerId = null;
// Capture auto-releases on pointerup, but be explicit.
try {
element.releasePointerCapture(ev.pointerId);
mouseCaptureElement.releasePointerCapture(ev.pointerId);
} catch {
// already released
}
Expand Down
4 changes: 4 additions & 0 deletions lib/src/lib/terminal-registry.alert.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,10 @@ class MockElement {

addEventListener(): void {}
removeEventListener(): void {}
querySelector(): MockElement | null {
return null;
}

querySelectorAll(): MockElement[] {
return [];
}
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
"dev:canopy": "pnpm --filter canopy storybook",
"dev:lib": "pnpm --filter dormouse-lib dev",
"dev:standalone": "node scripts/free-dev-port.mjs && pnpm --filter dormouse-standalone tauri dev",
"dev:standalone:ab": "pnpm --filter dormouse-standalone dev:agent-browser",
"innerdogfood": "pnpm --filter dormouse-standalone innerdogfood",
"dev:relay": "pnpm --filter dormouse-lib build:pocket && pnpm --filter relay dev",
"dev:website": "pnpm --filter dormouse-website dev",
"build:vscode": "pnpm --filter dormouse-lib build && pnpm --filter dormouse build:frontend && pnpm --filter dormouse build",
Expand Down
4 changes: 2 additions & 2 deletions scripts/pairing-walkthrough/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ it.
| # | Step | What happens |
| --- | --- | --- |
| 1 | `relay` | `pnpm dev:relay` with an isolated `DORMOUSE_STATE_DIR`, then waits for `:3000` to answer. |
| 2 | `burrow` | `pnpm dev:standalone:ab` with `DORMOUSE_REMOTE_CONNECT_SRC` pointed at that Relay, then waits for the app's first terminal. → `01-burrow-booted.png` |
| 2 | `burrow` | `pnpm innerdogfood` with `DORMOUSE_REMOTE_CONNECT_SRC` pointed at that Relay, then waits for the app's first terminal. → `01-burrow-booted.png` |
| 3 | `settings` | Clicks the baseboard's Settings button and scrolls to Remote control. → `02-settings-open.png` |
| 4 | `enroll` | Types the Relay URL, the setup password and the machine name into the real form, submits, and waits for **Connected**. → `03-enroll-form.png`, `04-enrolled.png` |
| 5 | `qr` | Clicks **Set up a phone**, waits for the code, screenshots, crops to the QR, makes a camera-shaped Y4M, and decodes the crop to prove it is legible. → `qr-full.png`, `qr.png`, `qr.y4m`, `invitation-url.txt` |
Expand Down Expand Up @@ -125,7 +125,7 @@ at the end. **Nothing is written into the repo.**

```
relay.log the Relay's whole stdout/stderr
burrow.log the dev:standalone:ab harness, sidecar and Vite
burrow.log the innerdogfood harness, sidecar and Vite
01-burrow-booted.png … one screenshot per UI step
qr-full.png the Burrow webview at the moment the QR was measured
qr.png the QR alone, cropped with a little padding
Expand Down
2 changes: 1 addition & 1 deletion scripts/pairing-walkthrough/ab.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* (`scripts/pairing-walkthrough/README.md`).
*
* One instance is one `--session`, which is one isolated browser. The Burrow runs
* in the session the `dev:standalone:ab` harness opened; the Pocket browser is a
* in the session the `innerdogfood` harness opened; the Pocket browser is a
* second instance with its own session name, which is why this is a class rather
* than a module of free functions.
*/
Expand Down
2 changes: 1 addition & 1 deletion scripts/pairing-walkthrough/proc.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ const started = [];
* `logPath`, and hand back a handle whose `lines` the caller can poll.
*
* **Its own process group, always.** `pnpm dev:relay` and
* `pnpm dev:standalone:ab` each fan out into a tree (pnpm → node → vite →
* `pnpm innerdogfood` each fan out into a tree (pnpm → node → vite →
* esbuild), and killing only the pnpm shim orphans everything under it.
* `detached: true` plus a `process.kill(-pid)` at teardown takes the group.
*/
Expand Down
Loading