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 docs/compatible-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ Watching requires shell integration that reports the running command. Terminal n

## Adding an agent

An agent integration normally needs one registry entry, an exit fixture, and a row in the table above; the standalone app and VS Code share the recovery implementation.
Add a registry entry, exit fixture, and table row. Both hosts share recovery.

### Add the definition and fixture

Expand All @@ -56,9 +56,9 @@ An agent integration normally needs one registry entry, an exit fixture, and a r
}
```

2. Declare the executable names the agent actually installs and the resume option or subcommand it supports. The shared parser handles space/equals separators, terminal escapes, and command reconstruction. IDs must fit its alphanumeric, hyphen, and underscore grammar. If an agent cannot identify the exact conversation on exit, discuss its capture mechanism in an issue first. Do not substitute a “latest conversation” command.
2. Declare the installed executable names and resume option or subcommand. [Detection](#detection) owns parsing and ID rules. If the agent cannot identify the exact conversation on exit, discuss its capture mechanism in an issue first; never substitute a “latest conversation” command.
3. Add a sanitized exit excerpt to [the fixtures](../lib/src/lib/__fixtures__/coding-agents.ts), with the expected rebuilt command, agent version, and operating system. Replace personal paths, account information, and session IDs; preserve relevant wording and terminal escapes. Record real exit output rather than reconstructing a hint from documentation.
4. Add the agent and its command forms to the supported-agents table. Set `watchByDefault` only after checking that the agent becomes quiet when it needs attention. The registry tests require fixture coverage, and the website tests compare this table with the registry.
4. Add the agent and its command forms to the supported-agents table. Set `watchByDefault` only after checking that the agent becomes quiet when it needs attention. Registry tests pin fixture coverage; website tests pin the table.

### Verify the integration

Expand Down
66 changes: 18 additions & 48 deletions docs/specs/transport.md

Large diffs are not rendered by default.

18 changes: 6 additions & 12 deletions docs/specs/vscode.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,7 @@ Start on the side of the webview boundary involved, then follow imports:

## What's built

Two hosting modes: a `WebviewView` in the bottom panel (alongside Terminal, Problems, Output) and `WebviewPanel` editor tabs (`dormouse.open`, multiple instances). Both restore across "Developer: Reload Window". PTYs live in the extension host (`pty-manager.ts`), survive panel visibility toggling, and replay buffered output on **resume**. Scrollback is never persisted (`docs/specs/transport.md` → "Persistence policy"); `deactivate()` instead interrupts the live PTYs and records each pane's agent resume invocation for the next cold restore to auto-run (`docs/specs/layout.md` → "Agent resume on cold restore").

The webview is the shared `lib/` frontend, unmodified for this host (`docs/specs/layout.md`, `docs/specs/transport.md`). The only VS Code-specific pieces in `lib/`: `lib/src/lib/platform/vscode-adapter.ts` (the postMessage bridge), `lib/src/lib/vscode-message-token.ts`, `lib/src/lib/vscode-keybindings.ts`.
The shared frontend runs in the bottom-panel `WebviewView` and independent editor-tab `WebviewPanel`s. Their ownership, visibility, and restore contracts are in Webview hosting and Serialization and restore.

### Invariants (VS Code-specific)

Expand All @@ -40,7 +38,7 @@ The webview is the shared `lib/` frontend, unmodified for this host (`docs/specs

### Extension manifest

**Must activate on the contributed view, restored editor panels, or an invoked contributed command.** Command activation is implicit on the supported VS Code versions ([activation events](https://code.visualstudio.com/api/references/activation-events#oncommand)). The manifest owns contributed commands, views, and title actions: ids, titles, icons, and ordering. The shipped commands are `dormouse.focus`, `dormouse.open`, `dormouse.debugTheme`, `dormouse.newTerminal`, and `dormouse.selectShell`.
**Must activate on the contributed view, restored editor panels, or an invoked contributed command.** Command activation is implicit on the supported VS Code versions ([activation events](https://code.visualstudio.com/api/references/activation-events#oncommand)). The manifest owns contributed commands, views, and title actions: ids, titles, icons, and ordering.

**No `configuration`, no `keybindings`, no context key**: settings live in the in-webview Settings dialog rather than `settings.json`, chords are handled inside the webview, and nothing is `when`-gated on Dormouse state. Context keys are [Future](#context-keys). Source of truth: `vscode-ext/package.json`.

Expand Down Expand Up @@ -183,7 +181,7 @@ frame-src http://127.0.0.1:* http://localhost:*

**That origin is a build-time constant, never a runtime value**: `vscode-ext/scripts/esbuild.mjs` bakes `DORMOUSE_RELAY_ORIGIN` into `dist/extension.js`. The default, the modes, and the build-time guards are `docs/specs/relay.md` → "Relay origin".

`unsafe-inline` for styles covers the theme CSS variables VS Code injects as inline styles on the body element. Scripts stay nonce-gated on a fresh per-render nonce of 24 CSPRNG bytes (`node:crypto` `randomBytes`) base64url-encoded to 32 characters — **never `Math.random()`**. Vite builds the webview HTML from the `lib` package; at runtime `webview-html.ts` rewrites asset URLs to webview URIs, injects the CSP meta tag, swaps Vite's nonce placeholder for the real one, and appends a nonce-gated inline script carrying the boot globals (message token, initial state, selected shell, recovery commands).
`unsafe-inline` for styles covers the theme CSS variables VS Code injects as inline styles on the body element. **Must mint a fresh per-render nonce from 24 CSPRNG bytes, never `Math.random()`**, and nonce-gate the boot globals. `getWebviewHtml` owns asset rewriting, nonce substitution, and boot serialization.

**`lib/index.html` keeps `<head>` and `</head>` bare**: both splices match a literal and **throw when it is absent**, an attribute otherwise yielding an unpoliced document. Pinned by "refuses a document whose splice marker was edited away".

Expand All @@ -204,7 +202,7 @@ Source of truth: `getWebviewHtml` in `vscode-ext/src/webview-html.ts`, `CSP_NONC

**The webview's `window` is a shared inbox, so `event.data.type` cannot decide trust** — it is attacker-chosen. The extension host posts there, and so can any framed surface (`dor iframe`, agent-browser; `docs/specs/dor-browser.md`) via `parent.postMessage`, which crosses origin and sandbox boundaries by design; the CSP governs what the document may *load*, never who may *message* it. A forgery is consequential: `dor:controlRequest` becomes a `dormouse:control-request` event `use-dor-control.ts` can turn into a `writePty`, and the `pty:*` family drives what the user sees (rationale). Host-originated messages are therefore authenticated by a **per-boot message token**:

- `getWebviewHtml` mints one token per webview document — 24 CSPRNG bytes, base64url, from the same `randomSecret()` as the CSP nonce — injects it as `globalThis.__DORMOUSE_MESSAGE_TOKEN__` in the same nonce-gated inline script that seeds the other `__DORMOUSE_*` globals, and returns it alongside the HTML.
- **Must mint a fresh message token per document from 24 CSPRNG bytes**, distinct from its CSP nonce, and inject it only through the nonce-gated boot script.
- **`serveWebview` is the only way to put a document on a webview**: it mints, assigns `webview.html`, and returns a `WebviewChannel` whose `post()` closes over that document's token. **Minting and serving are one step**, so a token cannot drift from its document; re-serving yields a new token and channel, and nothing keys a token by webview identity, so there is no cleanup.
- **Every host → webview send goes through a channel**, making a bypass a type error rather than a convention to remember; only the two serve sites (`setupPanel`, `resolveWebviewView`) still hold a raw webview, and `attachRouter` takes a `WebviewChannel`, not a `vscode.Webview`. `DormouseViewProvider.postMessage` forwards to its stored channel, **returning `false` before the view is served or after it disposes** — the VS Code API's own undelivered signal, already handled by the `dormouse:newTerminal` retry loop and `forwardDorControlRequest`'s rejection path.
- `VSCodeAdapter` captures the token **once, at construction**, and both of its `message` listeners — the main dispatcher and the per-request reply listener inside `requestResponse` — call `isHostMessage(event.data, token)` before reading anything else, `type` included.
Expand Down Expand Up @@ -339,6 +337,8 @@ Once an answer names a `ptyId`, the broker replaces that owner-local id with a s

Two UI events *are* addressed: **when a window completes the handshake the broker sends it the current `status` and `one-time` events** — each is emitted only when it changes and once as the service starts, so a window opened after the enrollment or the link would otherwise sit disarmed until reloaded.

**Must bound every peer frame in UTF-8 bytes before parsing**, complete frames and partial tails included. Oversized frames are discarded through their newline without losing adjacent valid frames.

**Socket bind errors reject startup** and are handled as an unavailable peer link; they never leave the listen promise pending or surface as an uncaught extension host error.

Source of truth: `vscode-ext/src/peer-link.ts` (sockets, arbitration, `HANDSHAKE_BUDGET_MS`, and the `routes` / `routePtyIds` routing table); `vscode-ext/src/peer-link-protocol.ts` (frame shapes, framing, handshake helpers, `PEER_REPLY_BUDGET_MS`), pinned by `vscode-ext/test/peer-link-protocol.test.ts`; `askBothTiers` in `vscode-ext/src/burrow.ts`; `brokerRequest` and the `peer:*` / `burrow:command` cases in `vscode-ext/src/message-router.ts`; `lib/src/remote/burrow/remote-api.ts`.
Expand All @@ -365,12 +365,6 @@ types without checking them, so `tsc` runs separately as `pnpm typecheck`, **wir
into the package's `test` script** so the root `pnpm test` covers it — that wiring
is what protects `deactivate()`, which has no `try`/`catch` (rationale).

The checked program spans two runtimes — `src/` is extension-host Node code but
imports webview modules from `../lib/src/` — so its config carries both DOM and
Node libs, looser than either alone, each side checked precisely by its own
project (`lib/tsconfig.app.json` for the webview). What it reliably catches is
vscode-ext's own code referring to something that no longer exists.

`pnpm dogfood:vscode` uninstalls the legacy `diffplug.mouseterm` extension before
packaging and installing the current Dormouse VSIX; the VS Code window must then
be reloaded. Day-to-day development uses it, since it runs against your real
Expand Down
2 changes: 2 additions & 0 deletions docs/specs/vscode.rationale.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,3 +110,5 @@ macOS host, hence copying only the declared platform packages.
**Why a self-host VSIX needs no update switch of its own.** VS Code treats a VSIX install as a pinned version and leaves it out of Marketplace auto-update (microsoft/vscode#219932, fixed by #219933 in the July 2024 iteration, 1.92; the diff covers the CLI's VSIX path, `code --install-extension`, which `pnpm dogfood:vscode` takes — checked 2026-09). An extension cannot opt itself out of Marketplace updates, and a distinct extension id would collide with the Marketplace build's command, view, and keybinding contributions when both are installed, and would strand the enrollment in another id's `SecretStorage`.

**Why the separate typecheck is wired into `test`.** A reference to a deleted function once reached a commit and surfaced only as a runtime throw during `deactivate()`, which — having no `try`/`catch` — skipped every teardown step behind it. `tsc` is the package's only automated check for that class of error.

**Why the typecheck config carries both DOM and Node libs.** The checked program spans two runtimes — `src/` is extension-host Node code but imports webview modules from `../lib/src/` — so `vscode-ext/tsconfig.json` is looser than either runtime alone; each side is checked precisely by its own project (`lib/tsconfig.app.json` for the webview). What it reliably catches is vscode-ext's own code referring to something that no longer exists.
4 changes: 2 additions & 2 deletions scripts/spec-word-budgets.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,9 @@
"docs/specs/terminal-state.md": 2400,
"docs/specs/theme.md": 2400,
"docs/specs/tiling-engine.md": 4450,
"docs/specs/transport.md": 5050,
"docs/specs/transport.md": 4800,
"docs/specs/tutorial.md": 2050,
"docs/specs/vscode.md": 7300,
"docs/specs/vscode.md": 7150,
"docs/specs/webgl-text.md": 1200,
"docs/specs/website-docs.md": 5000
}
37 changes: 25 additions & 12 deletions vscode-ext/src/peer-link-protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,9 @@ export function encodeFrame(frame: PeerLinkFrame | PeerLinkHandshake): string {
* killing the link.
*/
export class FrameDecoder {
/** The unterminated frame so far, and its UTF-8 size, counted per chunk. */
#buffer = '';
#bufferBytes = 0;
/**
* Set once one frame has outgrown the cap: everything up to the next newline
* belongs to that frame and is dropped, and normal accumulation resumes after
Expand All @@ -103,25 +105,29 @@ export class FrameDecoder {
#discarding = false;
readonly #maxFrameBytes: number;

/** Bounds a peer that never sends a newline; the default fits a screenful. */
/** Bounds each UTF-8 frame before JSON parsing; the default fits a screenful. */
constructor(maxFrameBytes = 4 * 1024 * 1024) {
this.#maxFrameBytes = maxFrameBytes;
}

push(chunk: string): unknown[] {
this.#buffer += chunk;
const frames: unknown[] = [];
for (;;) {
const newline = this.#buffer.indexOf('\n');
if (newline === -1) break;
const line = this.#buffer.slice(0, newline);
this.#buffer = this.#buffer.slice(newline + 1);
// Each piece is measured once, as it arrives — re-measuring the whole
// buffer on every chunk would be quadratic in a large frame.
const pieces = chunk.split('\n');
const tail = pieces.pop()!;
for (const piece of pieces) {
const line = this.#buffer + piece;
const bytes = this.#bufferBytes + Buffer.byteLength(piece, 'utf8');
this.#buffer = '';
this.#bufferBytes = 0;
if (this.#discarding) {
// That was the oversized frame's terminator; the bytes after it are a
// frame boundary again.
this.#discarding = false;
continue;
}
if (bytes > this.#maxFrameBytes) continue;
if (!line.trim()) continue;
try {
frames.push(JSON.parse(line));
Expand All @@ -130,11 +136,18 @@ export class FrameDecoder {
}
}
// Whatever is left is one unterminated frame. Past the cap it is a frame we
// can never read, so it goes — but the whole frames already taken out of
// the buffer above are real, and dropping them with it would lose traffic
// from a link that is otherwise healthy.
if (this.#buffer.length > this.#maxFrameBytes) this.#discarding = true;
if (this.#discarding) this.#buffer = '';
// can never read, so it goes — but the whole frames already taken out
// above are real, and dropping them with it would lose traffic from a link
// that is otherwise healthy.
if (!this.#discarding) {
this.#buffer += tail;
this.#bufferBytes += Buffer.byteLength(tail, 'utf8');
if (this.#bufferBytes > this.#maxFrameBytes) this.#discarding = true;
}
if (this.#discarding) {
this.#buffer = '';
this.#bufferBytes = 0;
}
return frames;
}
}
Expand Down
25 changes: 16 additions & 9 deletions vscode-ext/src/webview-view-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,20 @@ export class DormouseViewProvider implements vscode.WebviewViewProvider {
_token: vscode.CancellationToken,
): Promise<void> {
this.view = view;
// Registered before the shell-discovery await: a view disposed or replaced
// while it is pending is never served, and a stale view's disposal never
// releases its successor's router.
let disposed = false;
let ownedRouter: vscode.Disposable | undefined;
view.onDidDispose(() => {
disposed = true;
ownedRouter?.dispose();
if (this.view !== view) return;
log.info('[view] onDidDispose fired — releasing router (PTYs remain alive)');
this.routerDisposable = undefined;
this.channel = undefined;
this.view = undefined;
});
if (this.description !== undefined) view.description = this.description;

const mediaPath = path.join(this.context.extensionPath, 'media');
Expand All @@ -62,6 +76,7 @@ export class DormouseViewProvider implements vscode.WebviewViewProvider {
// is cached; this blocks only on a true cold start.
if (!this.selectedShell) {
const shells = await ptyManager.getAvailableShells();
if (disposed || this.view !== view) return;
const shell = resolveSelectedShell(this.context, shells);
this.selectedShell = shell ? { shell: shell.path, args: shell.args } : null;
if (shell) {
Expand All @@ -85,7 +100,7 @@ export class DormouseViewProvider implements vscode.WebviewViewProvider {
);

this.routerDisposable?.dispose();
this.routerDisposable = attachRouter(this.channel, {
this.routerDisposable = ownedRouter = attachRouter(this.channel, {
reconnect: true,
onSaveState: (state) => {
return saveSessionState(this.context, mergeAlertStates(state, getAlertStates()));
Expand All @@ -100,14 +115,6 @@ export class DormouseViewProvider implements vscode.WebviewViewProvider {
if (this.view) this.view.badge = workspaceBadge(union);
},
});

view.onDidDispose(() => {
log.info('[view] onDidDispose fired — releasing router (PTYs remain alive)');
this.routerDisposable?.dispose();
this.routerDisposable = undefined;
this.channel = undefined;
this.view = undefined;
});
}

focus(): void {
Expand Down
36 changes: 36 additions & 0 deletions vscode-ext/test/peer-link-protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,42 @@ import {
} from '../src/peer-link-protocol';

describe('FrameDecoder', () => {
it('caps complete UTF-8 frames before parsing and preserves surrounding frames', () => {
const good = { kind: 'notify' } as const;
for (const data of ['x'.repeat(100), '\u00e9'.repeat(20)]) {
const encoded = encodeFrame({ kind: 'data', ptyId: 'p', data });
expect(Buffer.byteLength(encoded.slice(0, -1), 'utf8')).toBeGreaterThan(64);
const decoder = new FrameDecoder(64);
expect(decoder.push(encodeFrame(good) + encoded + encodeFrame(good))).toEqual([good, good]);
}
});

it('discards an oversized multibyte partial frame through its newline', () => {
const decoder = new FrameDecoder(64);
const encoded = encodeFrame({ kind: 'data', ptyId: 'p', data: '\u00e9'.repeat(20) });
expect(decoder.push(encoded.slice(0, -1))).toEqual([]);
expect(decoder.push('\n' + encodeFrame({ kind: 'notify' }))).toEqual([{ kind: 'notify' }]);
});

it('accepts exactly the UTF-8 byte cap and rejects one byte more', () => {
const frame = { kind: 'data', ptyId: 'p', data: '\u00e9'.repeat(20) } as const;
const encoded = encodeFrame(frame);
const cap = Buffer.byteLength(encoded.slice(0, -1), 'utf8');
expect(new FrameDecoder(cap).push(encoded)).toEqual([frame]);
expect(new FrameDecoder(cap - 1).push(encoded)).toEqual([]);
});

it('counts UTF-8 bytes across a frame delivered one character at a time', () => {
const frame = { kind: 'data', ptyId: 'p', data: '\u00e9'.repeat(20) } as const;
const encoded = encodeFrame(frame);
const cap = Buffer.byteLength(encoded.slice(0, -1), 'utf8');
const trickle = (decoder: FrameDecoder) => [...encoded].flatMap((ch) => decoder.push(ch));
expect(trickle(new FrameDecoder(cap))).toEqual([frame]);
const over = new FrameDecoder(cap - 1);
expect(trickle(over)).toEqual([]);
expect(over.push(encodeFrame({ kind: 'notify' }))).toEqual([{ kind: 'notify' }]);
});

it('reads one frame per line', () => {
const decoder = new FrameDecoder();
const frames = decoder.push(
Expand Down
Loading
Loading