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
24 changes: 8 additions & 16 deletions .claude/skills/debug-standalone-agent-browser/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,6 @@ Use this skill when you need to run Dormouse standalone in a normal browser so y
Run from the repo root:

```sh
DORMOUSE_BROWSER_DEV_AB_SESSION=dormouse-debug-$(date +%s) \
DORMOUSE_BROWSER_DEV_VITE_PORT=1550 \
DORMOUSE_BROWSER_DEV_HOST_PORT=1552 \
pnpm innerdogfood
```

Expand All @@ -27,35 +24,29 @@ The harness:
- opens the app in `agent-browser`
- mirrors browser console logs as `[browser log] ...` in the harness terminal

Use unique `DORMOUSE_BROWSER_DEV_AB_SESSION`, Vite port, and host port for repeat runs to avoid stale outer-browser state and port collisions.
Port and session overrides are documented in `docs/specs/transport.md` → "Standalone browser-dev harness".

## Freshness

Before a measurement, clear any stale nested agent-browser session used by Dormouse surfaces:
Close only the browser session your test owns when you need a fresh page:

```sh
agent-browser --session dormouse.1.default close --all
agent-browser --session <outer-session> close
agent-browser --session <outer-session> open "http://localhost:<vite-port>/"
```

This matters because `dor ab open ...` uses a nested agent-browser session such as `dormouse.1.default`. If it has old tabs, the first stream snapshot can be polluted with stale URLs.
Never use `close --all` or a global process-name kill: other worktrees may have live harnesses. If testing nested browser surfaces, give their `dor ab --key` a test-specific name and close only that session afterward.

**`close --all` is global, not per-session.** Despite the `--session` flag, it closes *every* agent-browser session — including the outer harness session the app runs in. That is actually the cleanest way to get a fresh blank Dormouse, but you must then re-open the outer session yourself:
If the first `open` lands on `about:blank`, issue it again and poll until the URL sticks and the xterm input exists:

```sh
agent-browser --session dormouse.1.default close --all # clears nested AND outer
agent-browser --session <outer-session> open "http://localhost:<vite-port>/"
```

The first `open` after a `close --all` frequently lands on `about:blank` instead of navigating (the stray-about:blank race). **Issue `open` a second time** and poll until the URL sticks and the xterm input exists:

```sh
agent-browser --session <outer-session> open "http://localhost:<vite-port>/" # often needed twice
agent-browser --session <outer-session> eval '(()=>(!!document.querySelector("textarea.xterm-helper-textarea")&&location.href.indexOf("<vite-port>")>-1)?"ready":"no")()'
```

Browser console mirroring (`[browser log] ...`) keeps working after a manual re-open, so you don't lose log visibility.

Stop any running harness with Ctrl-C (or `pkill -f dev-agent-browser.mjs`) before starting another one. Do not leave background dev servers running after a timing run.
Parallel worktrees are isolated automatically (`docs/specs/transport.md` → "Standalone browser-dev harness"). Inside Dormouse, use `dor ensure -- pnpm innerdogfood`; the harness opens its browser pane. Stop only your own harness with Ctrl-C after a timing run.

## Driving Dormouse

Expand Down Expand Up @@ -190,6 +181,7 @@ After changing the harness, run:

```sh
node --check standalone/scripts/dev-agent-browser.mjs
pnpm --filter dormouse-standalone test
pnpm --filter dormouse-standalone build
```

Expand Down
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ 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`
**Inside Dormouse, run `innerdogfood`** — `dor ensure -- pnpm innerdogfood`.
The harness opens its browser pane and prints its URL and browser command
(`docs/specs/transport.md` → "Standalone browser-dev harness").

**Open every PR as a draft.** Chromatic bills per snapshot and skips drafts, so
Expand Down
7 changes: 6 additions & 1 deletion docs/specs/transport.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ Optional booleans:

`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.

- **Must bind OS-assigned ports for Vite and the HTTP bridge by default.**
- **Must derive the default browser key from the canonical worktree path**, stable across restarts. **Must open through `dor ab` when `DORMOUSE_SURFACE_ID` is set**, otherwise through `agent-browser`; print the actual app URL, session, and command to drive it. Inside Dormouse, `dor ensure -- pnpm innerdogfood` starts and opens the harness.
- **May pin ports with `DORMOUSE_BROWSER_DEV_VITE_PORT` / `DORMOUSE_BROWSER_DEV_HOST_PORT` and the session with `DORMOUSE_BROWSER_DEV_AB_SESSION`.** An occupied pinned port fails startup; `0` requests an OS-assigned port. Explicit overrides are the caller's isolation responsibility.
- **Must await Vite's own listener before opening the browser and use the actual ports for bridge authentication and CORS.** **Must close the bridge and Vite and terminate owned sidecar and browser-launch children on startup failure or shutdown**, escalating to SIGKILL after three seconds. Pinned by `standalone/scripts/dev-agent-browser.test.mjs`.

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").

**The harness must keep logging the Burrow state directory in a form the pairing walkthrough parses**, which is how the walkthrough records that path before enrollment; pinned by `lib/src/lib/mirrored-constants.test.ts`.
Expand All @@ -43,7 +48,7 @@ The bridge is a transport shim over the same sidecar protocol, not a second PTY

The harness **may omit** native-only desktop chrome (window controls, update checks) but **must preserve** every `PlatformAdapter` contract the app uses — PTY, control-request, clipboard, iframe-proxy, Burrow, agent-browser. It **must mirror** standalone's Session-persistence answer ("The governing rule"): the same `PERSIST_SESSION = false` gate as `TauriAdapter`, `persistsSession: false`, and any pre-gate `localStorage` blob deleted on `init()` (rationale). **Tauri APIs must not be required at static module-evaluation time** when `VITE_DORMOUSE_BROWSER_DEV_HOST` is set — a normal browser loads the page, not the Tauri WebView.

Source of truth: `standalone/scripts/dev-agent-browser.mjs`, `standalone/scripts/dev-host-guard.mjs`, `standalone/src/browser-sidecar-host.ts`, `standalone/src/browser-sidecar-adapter.ts`; `stepBurrow` in `scripts/pairing-walkthrough/steps.mjs`.
Source of truth: `standalone/scripts/dev-agent-browser.mjs`, `standalone/scripts/dev-host-guard.mjs`, `standalone/src/browser-sidecar-host.ts`, `standalone/src/browser-sidecar-adapter.ts`; `stepBurrow` in `scripts/pairing-walkthrough/steps.mjs`; `sessionForKey` in `dor-lib-common/src/agent-browser.ts`.

## PTY lifecycle

Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion scripts/spec-word-budgets.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
"docs/specs/terminal-state.md": 2350,
"docs/specs/theme.md": 2150,
"docs/specs/tiling-engine.md": 4500,
"docs/specs/transport.md": 4350,
"docs/specs/transport.md": 4450,
"docs/specs/tutorial.md": 1900,
"docs/specs/vscode.md": 7350,
"docs/specs/webgl-text.md": 1200,
Expand Down
2 changes: 2 additions & 0 deletions standalone/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"stage:dor-cli": "pnpm --filter dor build && node scripts/stage-dor-cli.mjs",
"stage:sidecar-proxy": "pnpm --filter remote-lib-common build && node scripts/build-sidecar-proxy.mjs",
"tauri": "pnpm run stage && node scripts/tauri.mjs",
"pretest": "pnpm --filter dor-lib-common build",
"test": "vitest run && node --test scripts/*.test.mjs"
},
"dependencies": {
Expand All @@ -37,6 +38,7 @@
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.2",
"cross-spawn": "^7.0.6",
"dor-lib-common": "workspace:*",
"esbuild": "^0.28.0",
"jsdom": "^29.1.1",
"tailwindcss": "^4.3.0",
Expand Down
160 changes: 94 additions & 66 deletions standalone/scripts/dev-agent-browser.mjs
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
#!/usr/bin/env node
import http from 'node:http';
import net from 'node:net';
import os from 'node:os';
import path from 'node:path';
import { readFile } from 'node:fs/promises';
import { randomBytes } from 'node:crypto';
import { readFile, realpath } from 'node:fs/promises';
import { createHash, randomBytes } from 'node:crypto';
import { createServer } from 'vite';
import { sessionForKey } from 'dor-lib-common/agent-browser';
import { fileURLToPath } from 'node:url';
// cross-spawn, not node:child_process: this script spawns `pnpm` and
// cross-spawn, not node:child_process: this script spawns `dor` and
// `agent-browser`, which are `.cmd` shims on Windows that a bare-name spawn
// can't resolve (ENOENT) and Node >=22 won't run directly (EINVAL). cross-spawn
// handles both and is a no-op on POSIX. See docs/specs/dor-cli.md.
Expand All @@ -23,9 +24,12 @@ const sidecarDir = path.join(standaloneDir, 'sidecar');
const sidecarScript = path.join(sidecarDir, 'main.js');
const dorBinDir = path.join(sidecarDir, 'dor-cli', 'bin');
const dorEntrypoint = path.join(sidecarDir, 'dor-cli', 'dist', 'dor.js');
const hostPort = Number(process.env.DORMOUSE_BROWSER_DEV_HOST_PORT || 1422);
const vitePort = Number(process.env.DORMOUSE_BROWSER_DEV_VITE_PORT || 1420);
const browserSession = process.env.DORMOUSE_BROWSER_DEV_AB_SESSION || 'dormouse-dev-standalone';
// Bind port 0 directly: probing and then releasing a free port races other runs.
let hostPort = Number(process.env.DORMOUSE_BROWSER_DEV_HOST_PORT || 0);
const requestedVitePort = Number(process.env.DORMOUSE_BROWSER_DEV_VITE_PORT || 0);
const worktreeKey = `innerdogfood-${createHash('sha256').update(await realpath(repoRoot)).digest('hex').slice(0, 16)}`;
const browserSession = process.env.DORMOUSE_BROWSER_DEV_AB_SESSION || sessionForKey(worktreeKey);
const insideDormouse = Boolean(process.env.DORMOUSE_SURFACE_ID);
// Only the token: the sidecar picks the control socket path itself (hardened
// per-user directory on POSIX, unguessable pipe name on Windows) and reports it
// on its own stderr as `[dor-control] listening on …`, which this harness
Expand All @@ -44,7 +48,7 @@ const controlToken = randomBytes(24).toString('hex');
// dev page gets it, via the URL baked into `VITE_DORMOUSE_BROWSER_DEV_HOST`.
// Overloading one token would hand the bridge to every spawned shell for free.
const bridgeToken = randomBytes(24).toString('hex');
const viteOrigin = `http://localhost:${vitePort}`;
let viteOrigin;
// The Burrow persists its enrollment + ACL here, under the harness's own
// temp dir so a dev run never touches the installed app's state.
const stateDir = path.join(os.tmpdir(), `dormouse-${process.pid}-browser-state`);
Expand All @@ -53,6 +57,8 @@ const pending = new Map();
const sseClients = new Set();
let sidecar;
let vite;
let hostServer;
let browser;
let shuttingDown = false;
let requestSeq = 0;

Expand Down Expand Up @@ -157,6 +163,10 @@ function cors(req, res) {

function startHostServer() {
const server = http.createServer(async (req, res) => {
if (!viteOrigin) {
res.writeHead(404).end('not found');
return;
}
cors(req, res);
if (req.method === 'OPTIONS') {
res.writeHead(204).end();
Expand Down Expand Up @@ -215,6 +225,7 @@ function startHostServer() {
server.once('error', reject);
server.listen(hostPort, '127.0.0.1', () => {
server.off('error', reject);
hostPort = server.address().port;
resolve(server);
});
});
Expand Down Expand Up @@ -259,84 +270,101 @@ function startSidecar() {
broadcast('sidecar', { event, data });
});
createInterface({ input: sidecar.stderr }).on('line', (line) => console.error(`[sidecar] ${line}`));
sidecar.on('error', (err) => {
console.error(err);
shutdown(1);
});
sidecar.on('exit', (code, signal) => {
log(`sidecar exited code=${code} signal=${signal}`);
for (const request of pending.values()) request.reject(new Error('sidecar exited'));
pending.clear();
shutdown();
shutdown(1);
});
}

function startVite() {
vite = spawn('pnpm', ['--filter', 'dormouse-standalone', 'dev'], {
cwd: repoRoot,
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
// The token rides in the URL, so the page needs nothing else plumbed to
// it and `BrowserSidecarHost` stays the single place that knows about it.
VITE_DORMOUSE_BROWSER_DEV_HOST: `http://127.0.0.1:${hostPort}/?t=${bridgeToken}`,
DORMOUSE_BROWSER_DEV_VITE_PORT: String(vitePort),
async function startVite() {
// Own Vite in this process: listen() reports the actual bound port and close()
// tears down its watchers too. A TCP readiness probe could find another run.
vite = await createServer({
root: standaloneDir,
// Inject only into the page, never process.env: the sidecar's PTYs must not
// inherit the HTTP bridge credential.
define: {
'import.meta.env.VITE_DORMOUSE_BROWSER_DEV_HOST': JSON.stringify(`http://127.0.0.1:${hostPort}/?t=${bridgeToken}`),
},
server: {
host: '127.0.0.1', port: requestedVitePort, strictPort: true,
// Share Vite's listener, including when TAURI_DEV_HOST is inherited.
hmr: { host: 'localhost', port: 0, protocol: 'ws' },
},
});
createInterface({ input: vite.stdout }).on('line', (line) => console.error(`[vite] ${line}`));
createInterface({ input: vite.stderr }).on('line', (line) => console.error(`[vite] ${line}`));
vite.on('exit', (code, signal) => {
log(`vite exited code=${code} signal=${signal}`);
shutdown();
});
}

async function waitForVite() {
const deadline = Date.now() + 30000;
while (Date.now() < deadline) {
try {
await new Promise((resolve, reject) => {
const socket = net.connect(vitePort, 'localhost', resolve);
socket.once('error', reject);
socket.once('connect', () => socket.end());
});
return;
} catch {
await new Promise((resolve) => setTimeout(resolve, 250));
}
}
throw new Error(`vite did not open port ${vitePort}`);
await vite.listen();
viteOrigin = `http://localhost:${vite.httpServer.address().port}`;
log(`app URL: ${viteOrigin}`);
}

async function openAgentBrowser() {
const args = ['--session', browserSession];
const binary = insideDormouse ? 'dor' : 'agent-browser';
const identity = insideDormouse && !process.env.DORMOUSE_BROWSER_DEV_AB_SESSION
? ['--key', worktreeKey]
: ['--session', browserSession];
const args = insideDormouse ? ['ab', ...identity] : identity;
const command = `${binary} ${args.join(' ')}`;
if (process.env.DORMOUSE_BROWSER_DEV_HEADED === '1') args.push('--headed');
args.push('open', `http://localhost:${vitePort}`);
const child = spawn('agent-browser', args, { cwd: repoRoot, stdio: ['ignore', 'pipe', 'pipe'] });
createInterface({ input: child.stdout }).on('line', (line) => console.error(`[agent-browser] ${line}`));
createInterface({ input: child.stderr }).on('line', (line) => console.error(`[agent-browser] ${line}`));
await new Promise((resolve) => child.on('exit', resolve));
args.push('open', viteOrigin);
browser = spawn(binary, args, { cwd: repoRoot, stdio: ['ignore', 'pipe', 'pipe'] });
createInterface({ input: browser.stdout }).on('line', (line) => console.error(`[${binary}] ${line}`));
createInterface({ input: browser.stderr }).on('line', (line) => console.error(`[${binary}] ${line}`));
await new Promise((resolve, reject) => {
browser.once('error', reject);
browser.once('exit', (code, signal) => code === 0
? resolve()
: reject(new Error(`${binary} exited code=${code} signal=${signal}`)));
});
log(`agent-browser session: ${browserSession}`);
log(`try: agent-browser --session ${browserSession} snapshot -i`);
log(`try: ${command} snapshot -i`);
}

async function shutdown() {
async function shutdown(code = 0) {
if (shuttingDown) return;
shuttingDown = true;
for (const client of sseClients) client.end();
sseClients.clear();
if (vite && !vite.killed) vite.kill('SIGTERM');
if (sidecar && !sidecar.killed) sidecar.kill('SIGTERM');
setTimeout(() => process.exit(0), 250).unref();
hostServer?.close();
hostServer?.closeAllConnections();
const children = new Set([browser, sidecar].filter(child =>
child?.pid && child.exitCode === null && child.signalCode === null));
const childrenClosed = [...children].map(child => new Promise(resolve => {
child.once('exit', () => {
children.delete(child);
resolve();
});
child.kill('SIGTERM');
}));
// Bound cleanup even when a child or open request stops responding.
const timeout = setTimeout(() => {
for (const child of children) child.kill('SIGKILL');
process.exit(code);
}, 3000);
await Promise.all([vite?.close(), ...childrenClosed]);
clearTimeout(timeout);
process.exit(code);
}

process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
process.on('SIGINT', () => shutdown());
process.on('SIGTERM', () => shutdown());

log(`starting browser dev host on http://127.0.0.1:${hostPort}`);
// Printed so poking the bridge by hand stays possible. Local stderr only: this
// harness never runs in CI, and the token dies with the process.
log(`bridge token: ${bridgeToken}`);
log(`try: curl -H 'content-type: application/json' -d '{"cmd":"pty_request_init"}' 'http://127.0.0.1:${hostPort}/__dormouse_dev_host/send?t=${bridgeToken}'`);
await startHostServer();
startSidecar();
startVite();
await waitForVite();
await openAgentBrowser();
log('running; Ctrl-C to stop');
try {
hostServer = await startHostServer();
log(`starting browser dev host on http://127.0.0.1:${hostPort}`);
// Local stderr only; this credential dies with the process.
log(`bridge token: ${bridgeToken}`);
log(`try: curl -H 'content-type: application/json' -d '{"cmd":"pty_request_init"}' 'http://127.0.0.1:${hostPort}/__dormouse_dev_host/send?t=${bridgeToken}'`);
await startVite();
startSidecar();
await openAgentBrowser();
log('running; Ctrl-C to stop');
} catch (err) {
console.error(err);
await shutdown(1);
}
Loading