From c69061852b381d0714de3ee4a03613222027b61b Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 9 Jul 2026 13:03:56 -0700 Subject: [PATCH 1/9] Author the dor agent skill; text output is the primary agent interface dor-skill.md is a portable agent skill (Claude Code, Codex, etc.) teaching an agent running inside a Dormouse terminal to drive it via dor. It recasts the spec's Agent Workflows as a targeting ontology (hold the handle / address by identity key / rediscover) plus copy-pasteable recipes, omitting the staged wait-on-sub-agent flow. Writing it surfaced that --json was over-prescribed for an LLM consumer: text output already carries the same refs in fewer tokens, and for `dor read` JSON is strictly worse (escaped screen text). Both the skill and the dor-cli spec now document text as the primary agent interface, with --json reserved for scripts and pipelines. Co-Authored-By: Claude Fable 5 --- docs/specs/dor-cli.md | 26 ++--- dor-skill.md | 241 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 255 insertions(+), 12 deletions(-) create mode 100644 dor-skill.md diff --git a/docs/specs/dor-cli.md b/docs/specs/dor-cli.md index 128878e3..0cb000e4 100644 --- a/docs/specs/dor-cli.md +++ b/docs/specs/dor-cli.md @@ -229,8 +229,8 @@ Invariants: swaps do not change the ref. Killing a Surface retires its ref; a later target that names it fails instead of silently retargeting. - Surface targets also accept `title:`, primarily for human - recovery; automation should prefer refs/ids from command responses or - `dor list --json`. Action commands (`read`, `send`, `kill`) resolve against + recovery; automation should prefer refs from command responses or + `dor list`. Action commands (`read`, `send`, `kill`) resolve against listed Surfaces, including minimized ones. For `split` and `ensure --surface`, the reference target also resolves against the listed Surfaces so minimized peers participate in ambiguity checks; when the resolved reference is @@ -289,7 +289,9 @@ output, JSON responses, default `ensure` titles, and the launched command alike. Every first-party command except the `dor agent-browser` / `dor ab` passthrough accepts `--json` and emits a stable object with the same handles as its text -output. Single-Surface responses always include both `surface_id` (the stable +output. Text output is the primary interface for agents as well as humans — it +carries the same refs; `--json` exists for scripts and pipelines that consume +output mechanically. Single-Surface responses always include both `surface_id` (the stable id) and `surface_ref` (the Workspace-stable short ref). `dor ab` forwards arguments to the user's `agent-browser` CLI, so any JSON mode there belongs to that delegated command surface rather than to `dor`. @@ -362,7 +364,7 @@ from `command-detail`. A handful of end-to-end agent scenarios are the CLI's product-level acceptance tests: each one checks that the commands *compose* into a real automation, not just that they work in isolation — orchestration, Surface targeting, browser -handoff, cleanup, and JSON output holding together across a whole task. They all +handoff, cleanup, and output holding together across a whole task. They all reduce to one shape — **discover the target Surface with `dor list` (filtered), then act on it with a handle-taking command** — which is why targeting lives in `dor list` while `read` / `send` / `kill` stay handle-taking instead of each @@ -378,14 +380,14 @@ their session is held externally by `agent-browser`. | Workflow | How the shipped CLI does it | | --- | --- | -| Share a dev server | `dor ensure -- npm dev` reuses the command already live in the same resolved cwd (`--restart` re-runs it in place, preserving layout and minimized/visible state). `dor list --command "npm dev" --cwd . --ports --json` returns the Surface with its ports, and the agent opens `dor ab open http://localhost:`. Passing the terminal handle straight to the browser command (`dor ab open surface:N`) is the one unshipped ergonomic — see [Future](#future). | +| Share a dev server | `dor ensure -- npm dev` reuses the command already live in the same resolved cwd (`--restart` re-runs it in place, preserving layout and minimized/visible state). `dor list --command "npm dev" --cwd . --ports` returns the Surface with its ports, and the agent opens `dor ab open http://localhost:`. Passing the terminal handle straight to the browser command (`dor ab open surface:N`) is the one unshipped ergonomic — see [Future](#future). | | Launch a sub-agent | `dor split -- codex` returns `surface:N`; drive it with `dor send surface:N --text "/review" --key enter` (or `--sequence` for arbitrary ordering), then read it back with `dor read surface:N`. | -| Wait on a sub-agent | `dor split -- otheragent` returns `surface:5`; the caller watches `dor list --json` for that Surface's `ringing` flag and calls `dor read surface:5` once the peer rings the Dormouse bell to signal it is done. Blocking on the bell directly with `dor await surface:5` (which prints the screen the moment it rings) is staged — see [Future](#future). | +| Wait on a sub-agent | `dor split -- otheragent` returns `surface:5`; the caller watches `dor list` for that Surface's `[ringing]` tag and calls `dor read surface:5` once the peer rings the Dormouse bell to signal it is done. Blocking on the bell directly with `dor await surface:5` (which prints the screen the moment it rings) is staged — see [Future](#future). | | Client / server browser testing | `dor ab --key client open ` and `dor ab --key server open ` create or reuse two independent browser Surfaces. | -| Multi-worktree, same command | Two worktrees each run `dor ensure -- npm dev`; the resolved cwd keeps them distinct, and `dor list --command "npm dev" --cwd --json` selects the intended one. | -| Long-running background job | `dor ensure --minimize -- npm test -- --watch` keeps a watcher out of the layout; `dor list --command "npm test -- --watch" --json` rediscovers the minimized Surface after churn, and `read` / `send` / `kill` target it by ref. | -| Port-owner handoff | `dor list --port 5173 --json` returns the terminal that owns the socket (browser Surfaces never match `--port`), then `dor ab --key client open http://localhost:5173` binds the browser side. | -| Safe cleanup | `dor list --command "npm dev" --cwd . --json`, then `dor kill --confirm-if-read `. The ref comes from a recent listing or command response; `title:` also targets one but can drift. | +| Multi-worktree, same command | Two worktrees each run `dor ensure -- npm dev`; the resolved cwd keeps them distinct, and `dor list --command "npm dev" --cwd ` selects the intended one. | +| Long-running background job | `dor ensure --minimize -- npm test -- --watch` keeps a watcher out of the layout; `dor list --command "npm test -- --watch"` rediscovers the minimized Surface after churn, and `read` / `send` / `kill` target it by ref. | +| Port-owner handoff | `dor list --port 5173` returns the terminal that owns the socket (browser Surfaces never match `--port`), then `dor ab --key client open http://localhost:5173` binds the browser side. | +| Safe cleanup | `dor list --command "npm dev" --cwd .`, then `dor kill --confirm-if-read `. The ref comes from a recent listing or command response; `title:` also targets one but can drift. | ## Future @@ -394,7 +396,7 @@ their session is held externally by `agent-browser`. `dor iframe ` accept an explicit terminal Surface handle (`surface:N`, `surface:`, `surface:self`, or `surface:focused`) wherever they currently accept an absolute URL, collapsing the two-step - `dor list --ports --json` → `dor ab open http://localhost:` dance into + `dor list --ports` → `dor ab open http://localhost:` dance into one command. Resolution calls the same host port scan as `dor list --ports`. V1 groups listening records by port, so one dev server bound on `localhost`, a LAN address, and an overlay-network address is still one candidate; Dormouse @@ -409,7 +411,7 @@ their session is held externally by `agent-browser`. launches a peer with `dor split -- otheragent`, then `dor await surface:5` parks until that peer signals completion by ringing (the `BEL` / `OSC 9` / `9;4` / `99` / `777` events that already drive the `ringing` flag in `dor list`), so the - caller stops polling `dor list --json` in a loop. A Surface already ringing when + caller stops polling `dor list` in a loop. A Surface already ringing when `await` is called returns immediately. Resolving is exactly a human attending the ringing Session (`docs/specs/alert.md` → Clearing And TODO): it clears the active ring and sets `todo = true`, so the bell goes quiet and the Surface now carries a diff --git a/dor-skill.md b/dor-skill.md new file mode 100644 index 00000000..28aeaa22 --- /dev/null +++ b/dor-skill.md @@ -0,0 +1,241 @@ +--- +name: dormouse +description: Control the Dormouse terminal you are running inside via the `dor` CLI — spawn terminals in visible panes, run dev servers and background jobs where the user can see them, read and type into other terminals, open browser surfaces, and clean up safely. Use whenever the DORMOUSE_SURFACE_ID environment variable is set and you need to run a long-lived process, share a server or URL with the user, test in a browser, launch a sub-agent, or coordinate multiple terminals. +--- + +# Driving Dormouse with `dor` + +You are running inside Dormouse, a multitasking terminal. Every terminal it +launches has the `dor` CLI on `PATH`. `dor` lets you create, inspect, type +into, and kill other terminal panes, and open browser panes — so instead of +backgrounding a process inside your own shell (where its output is invisible +and it dies with you), you run it in its own **surface** that the user can see +and that outlives your session. + +**Applies when:** `DORMOUSE_SURFACE_ID` is set in your environment. If it is +not set, you are not in a Dormouse terminal; do not use `dor`. + +## Targeting: three ways to name a surface + +Action commands (`read`, `send`, `kill`) take a surface handle — there is +deliberately no `dor kill "npm dev"`. You name the surface you want one of +three ways: + +1. **Hold the handle.** Commands that create surfaces (`split`, `ensure`, + `iframe`) print the new ref (`created surface:3`). Capture it and act on + it directly — refs stay valid across any layout churn. +2. **Address by identity key.** Surfaces with a natural identity skip handle + bookkeeping: `dor ensure -- ` uses its exact command + cwd as an + implicit key (match-or-create in one idempotent call), and browser + surfaces are addressed by an explicit key (`dor ab --key `). +3. **Rediscover.** When you hold nothing — a fresh session, or a process the + user started by hand — `dor list` (filtered) turns a description + (`--command`, `--cwd`, `--port`) into a handle. + +Text output is designed for you to read: it is terse and carries the same +refs. Reach for `--json` (every command except `dor ab` supports it) only +when a shell script or pipeline using `jq` consumes the output. + +## Surface handles + +- `surface:N` — short ref, e.g. `surface:3`. Stable for the surface's whole + life: reordering, minimizing, zooming, and focus changes never change it, and + numbers are never reused after a kill. A ref for a killed surface fails + loudly instead of silently retargeting, so refs from earlier in your session + stay safe to use. +- A stable surface id (or `surface:`) — from `--json` output. +- `surface:self` — the terminal you are running in. +- `surface:focused` — whatever the user currently has focused. +- `title:` — exists for human recovery; avoid it in automation + (titles drift). Prefer refs from command responses or `dor list`. + +Bare numbers and `pane:N` are not valid handles. + +## Command reference + +Run `dor --help` for full details on any command. + +### `dor list` — find surfaces + +```sh +dor list # everything in the workspace +dor list --command "npm run dev" --cwd . # exact command + cwd match +dor list --port 5173 # which terminal owns port 5173 +dor list --kind terminal --view minimized # filters AND together +dor list --ports # add each terminal's listening ports +``` + +Lists every surface in the current workspace — terminals and browser surfaces, +including minimized ones. `--command` matches the exact command the shell +reports it is running (`npm run dev` ≠ `npm run dev --host`). `--cwd` resolves +relative to your `PWD`. `--port`/`--ports` trigger an opt-in port scan and +`--port` only ever matches the terminal that owns the socket, never a browser +showing that URL. Text rows mark the user's focus with `*` and your own +terminal with `(you)`; `--json` adds stable ids and a host/identity block. + +### `dor split` — create a terminal + +```sh +dor split -- npm test # runs in background; focus stays with you +dor split --minimize -- ./watch.sh +dor split # bare split: focuses the new empty shell +``` + +Direction flags `--left|--right|--up|--down` (default `--auto`). +`--surface ` picks which surface to split from. The response includes the +new surface's ref — save it. + +### `dor ensure` — idempotent "make sure this is running" + +```sh +dor ensure -- npm run dev # reuse if live, else create +dor ensure --restart -- npm run dev # interrupt + re-run in place +dor ensure --minimize --cwd ../worktree-b -- npm run dev +``` + +Matches on exact command + resolved cwd against commands that are *currently +live* (via shell integration), so it also adopts a server the user started by +hand. It never changes focus, and never collapses the same command running in +two directories. `--restart` preserves the surface's place in the layout and +its minimized/visible state. Requires a shell with OSC 633 integration in the +target (Dormouse-launched shells have it; cmd.exe does not). + +Prefer `ensure` over `split` for anything with a natural identity ("the dev +server for this directory") — it is your dedupe key across re-runs and layout +churn. + +### `dor send` — type into a terminal + +```sh +dor send surface:3 --text "npm test" --key enter # the canonical run-a-command +dor send surface:3 --key ctrl-c # interrupt +cat answers.txt | dor send surface:3 --stdin +dor send surface:3 --sequence '[{"text":"y"},{"key":"enter"},{"key":"tab"}]' +``` + +Exactly one input mode per call: `--text`/`--key` (only in that order, text +first), `--stdin`, or `--sequence` for anything more complex. Special keys go +through `--key` (`enter`, `escape`, `tab`, `backspace`, `delete`, arrows, +`ctrl-a`..`ctrl-z`) so they are never confused with literal text. `--text` +interprets `\n` `\r` `\t` `\\` unless `--raw`. + +### `dor read` — read a terminal's screen + +```sh +dor read surface:3 # visible screen, printed directly +dor read surface:3 --scrollback --lines 200 +``` + +### `dor kill` — kill a surface (confirmation required) + +```sh +dor kill surface:3 --confirm-if-read "npm test" # preferred: verify then kill +dor kill surface:3 --confirm-dangerously # only when already validated +``` + +`--confirm-if-read ` kills only if the surface's visible screen contains +the text (≥4 non-whitespace chars) — use it as a cheap guard that you are +killing what you think you are. + +### `dor ab` / `dor agent-browser` — agent-drivable browser pane + +Forwards everything to your installed `agent-browser` CLI (not bundled — +`npm i -g agent-browser`) and binds the session to a Dormouse browser surface +so the user watches what you drive. + +```sh +dor ab open http://localhost:5173 # key "default" +dor ab --key server open http://localhost:3000 +dor ab click @e3 # further args are agent-browser's own +dor ab --key server reload +``` + +`--key ` is a workspace-scoped browser identity: one key = one session = +one surface, reused across commands. Use distinct keys when you need +independent browsers at once. `dor ab` has no `--json` of its own; any JSON +flags belong to `agent-browser`. + +### `dor iframe` — high-fidelity URL pane for the user + +```sh +dor iframe http://localhost:6006 # absolute http(s) URL required +``` + +For *showing* a page to the human at full fidelity (no automation hooks). +Provisional: some sites refuse framing. To *drive* a page, use `dor ab`. + +## Recipes + +**Run a dev server and show it to the user.** Ensure it, find its port, open a +browser on it: + +```sh +dor ensure -- npm run dev +dor list --command "npm run dev" --cwd . --ports # read the port +dor ab open http://localhost: +``` + +**Launch and drive a sub-agent** (another CLI agent in a sibling pane): + +```sh +dor split -- codex # prints "created surface:N" +dor send surface:N --text "/review" --key enter +dor read surface:N # poll its screen for progress/results +``` + +**Client/server browser testing.** Two keys, two independent browsers: + +```sh +dor ab --key server open http://localhost:3000/admin +dor ab --key client open http://localhost:5173 +``` + +**Same command in multiple worktrees.** cwd keeps them distinct: + +```sh +dor ensure --cwd ~/wt/feature-a -- npm run dev +dor ensure --cwd ~/wt/feature-b -- npm run dev +dor list --command "npm run dev" --cwd ~/wt/feature-a # picks one +``` + +**Long-running background job, out of the way.** Minimize it; rediscover it +later by command instead of remembering the ref: + +```sh +dor ensure --minimize -- npm test -- --watch +dor list --command "npm test -- --watch" +dor read surface:N --lines 50 +``` + +**Who owns this port?** + +```sh +dor list --port 5173 # the terminal, never a browser surface +``` + +**Safe cleanup.** List, verify, kill: + +```sh +dor list --command "npm run dev" --cwd . +dor kill surface:N --confirm-if-read "npm run dev" +``` + +## Rules and pitfalls + +- **Never pre-quote command tails.** Everything after `--` is forwarded as a + raw argv array; Dormouse quotes it correctly for whatever shell the target + surface runs (POSIX, cmd, PowerShell). Pass `-- npm test -- --watch`, not + `-- "npm test -- --watch"`. +- **Focus etiquette.** `dor ensure` and `dor split -- ` never steal + focus from the user; bare `dor split` intentionally does (it hands them an + empty shell to type into). Don't bare-split as part of automation. +- **Take refs from responses.** Capture the ref that `split`/`ensure`/`iframe` + print rather than re-listing and guessing. +- **`--command` is exact.** Match the command string you launched with, + including its flags. +- **Prefer `--confirm-if-read` over `--confirm-dangerously`** unless you have + just read the surface yourself. +- **Scope:** `dor` sees the current workspace only. Terminals ring bells and + carry todo flags (`[ringing]`/`[todo]` in `dor list`); browser surfaces are + the only ones with explicit keys, because their sessions live in + `agent-browser`. From 71f5e0a080c9404fa36ac9cccc66e2abedb5b598 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 9 Jul 2026 13:38:59 -0700 Subject: [PATCH 2/9] Stage the dor skill distribution plan: dor skill + --install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The skill body loses its environment-detection paragraph — if `dor skill` ran, dor is by definition available. Detection moves to the bootstrap stub: docs/specs/dor-cli.md now stages `dor skill` (print the skill, inlined into the bundle for version lockstep) and `dor skill --install` (write a marker-delimited stub block into the project's AGENTS.md / CLAUDE.md, whose whole content is "if DORMOUSE_SURFACE_ID is set, run dor skill and follow it"). The committed stub is inert without Dormouse and travels with the repo, so one teammate installing it covers every agent and every clone. Co-Authored-By: Claude Fable 5 --- docs/specs/dor-cli.md | 40 ++++++++++++++++++++++++++++++++++++++++ dor-skill.md | 3 --- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/docs/specs/dor-cli.md b/docs/specs/dor-cli.md index 0cb000e4..412175a4 100644 --- a/docs/specs/dor-cli.md +++ b/docs/specs/dor-cli.md @@ -391,6 +391,46 @@ their session is held externally by `agent-browser`. ## Future +- **`dor skill` — agent-skill distribution** — ship the agent + skill (`dor-skill.md`, authored at the repo root) to every coding agent + running inside a Dormouse terminal. Distribution splits into content and + bootstrap so each can be exactly as stable as it needs to be: + - **Content ships with the CLI.** `dor skill` prints the skill markdown. + The text is inlined into the compiled entrypoint at build time (esbuild + text import; the file moves into the `dor` package — e.g. a top-level + `skill.md` there — so the build can import it), keeping the staged package + launchers + bundle and version-locking the text to the CLI it documents. + The printed text is snapshot-tested alongside the command's help. The + skill body deliberately contains no environment detection: if `dor skill` + ran, `dor` is by definition available — detection belongs entirely to the + stub. + - **Bootstrap is a stub that cannot drift.** `dor skill --install` writes + the stub as a managed block in the *project's* agent instruction file — + `AGENTS.md`, the convention read by Codex, Pi, OpenCode, and most other + harnesses, or `CLAUDE.md` where that is what the project has — whose + entire content is the detection rule: *if `DORMOUSE_SURFACE_ID` is set in + the environment, run `dor skill` and follow it; otherwise this terminal is + not Dormouse-hosted and `dor` does not apply.* The stub carries no facts + about the CLI, so it can never version-skew; the instructions always come + from the running Dormouse. The env-var guard also makes the committed + block inert for collaborators who don't run Dormouse — committing it is + safe, and is the point: the stub travels with the repo, so one teammate + installing it covers every agent and every clone, instead of living in one + user's home config. The block is delimited by marker comments and + rewritten in place on every run — `dor skill --install` is idempotent, + creates the file when the project has none, never touches text outside its + markers, and prints what it wrote. + - Skill-ecosystem publication (plugin marketplaces, npm) distributes the + stub, never a copy of the content. + - To workshop before implementing: the file-selection rule (prefer an + existing `AGENTS.md`; fall back to `CLAUDE.md` when it exists and does not + already import `AGENTS.md`; create `AGENTS.md` when neither exists?); + whether the stub says anything to readers without Dormouse (a one-line + pointer is honest advertising, more is spam); whether a user-level + `--global` variant is also worth offering; whether the canonical skill + file keeps its YAML frontmatter now that the stub is an instructions block + (nothing would index the frontmatter); and the exact stub wording. + - **Browser open target resolution** — the one unshipped Agent-Workflow ergonomic (the Share-a-dev-server shortcut above): `dor ab open ` and `dor iframe ` accept an explicit terminal Surface handle (`surface:N`, diff --git a/dor-skill.md b/dor-skill.md index 28aeaa22..2258a250 100644 --- a/dor-skill.md +++ b/dor-skill.md @@ -12,9 +12,6 @@ backgrounding a process inside your own shell (where its output is invisible and it dies with you), you run it in its own **surface** that the user can see and that outlives your session. -**Applies when:** `DORMOUSE_SURFACE_ID` is set in your environment. If it is -not set, you are not in a Dormouse terminal; do not use `dor`. - ## Targeting: three ways to name a surface Action commands (`read`, `send`, `kill`) take a surface handle — there is From 73123491c3e6a8eb10bac3da87f8ce7dda9f9ea6 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 9 Jul 2026 13:59:48 -0700 Subject: [PATCH 3/9] Implement dor skill: print the agent skill, install its bootstrap stub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The canonical skill markdown moves from the repo root into the dor package (dor/skill.md, frontmatter dropped) and is inlined into the CLI at build time by scripts/generate-dor-skill.mjs, mirroring the generated-version prebuild pattern — so `dor skill` always prints text version-locked to the CLI that ships it. `dor skill --install` writes a marker-delimited bootstrap stub into the project's agent instructions file. The stub's whole content is the detection rule (if DORMOUSE_SURFACE_ID is set, run `dor skill` and follow it), so a committed stub carries no CLI facts and never goes stale. Selection: rewrite an existing block in place; else append to AGENTS.md; else CLAUDE.md unless it imports @AGENTS.md; else create AGENTS.md. Malformed blocks fail loudly rather than being guessed at. Co-Authored-By: Claude Fable 5 --- .gitignore | 1 + dor/package.json | 2 +- dor-skill.md => dor/skill.md | 5 - dor/src/cli.ts | 3 + dor/src/commands/skill.ts | 128 ++++++++++++++++++ dor/src/node-runtime.d.ts | 2 + dor/test/cli-output.test.mjs | 113 +++++++++++++++- dor/test/snapshots/help/dor.md | 2 + dor/test/snapshots/help/skill.md | 32 +++++ dor/test/snapshots/skill-install-created.snap | 5 + scripts/generate-dor-skill.mjs | 16 +++ 11 files changed, 302 insertions(+), 7 deletions(-) rename dor-skill.md => dor/skill.md (94%) create mode 100644 dor/src/commands/skill.ts create mode 100644 dor/test/snapshots/help/skill.md create mode 100644 dor/test/snapshots/skill-install-created.snap create mode 100644 scripts/generate-dor-skill.mjs diff --git a/.gitignore b/.gitignore index 2dabb1f2..fb00e6c5 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ node_modules/ # Build output dist/ dor/src/generated-version.ts +dor/src/generated-skill.ts lib/dist/ lib/dist-pocket/ *.tsbuildinfo diff --git a/dor/package.json b/dor/package.json index b16a7561..2f584b80 100644 --- a/dor/package.json +++ b/dor/package.json @@ -12,7 +12,7 @@ "dist" ], "scripts": { - "prebuild": "pnpm --filter dor-lib-common build && node ../scripts/generate-dor-version.mjs", + "prebuild": "pnpm --filter dor-lib-common build && node ../scripts/generate-dor-version.mjs && node ../scripts/generate-dor-skill.mjs", "build": "tsc -p tsconfig.json && esbuild src/dor.ts --bundle --format=esm --platform=node --outfile=dist/dor.js --banner:js=\"import { createRequire } from 'module'; const require = createRequire(import.meta.url);\"", "test": "pnpm run build && node --test test/*.test.mjs" }, diff --git a/dor-skill.md b/dor/skill.md similarity index 94% rename from dor-skill.md rename to dor/skill.md index 2258a250..6bc9c77f 100644 --- a/dor-skill.md +++ b/dor/skill.md @@ -1,8 +1,3 @@ ---- -name: dormouse -description: Control the Dormouse terminal you are running inside via the `dor` CLI — spawn terminals in visible panes, run dev servers and background jobs where the user can see them, read and type into other terminals, open browser surfaces, and clean up safely. Use whenever the DORMOUSE_SURFACE_ID environment variable is set and you need to run a long-lived process, share a server or URL with the user, test in a browser, launch a sub-agent, or coordinate multiple terminals. ---- - # Driving Dormouse with `dor` You are running inside Dormouse, a multitasking terminal. Every terminal it diff --git a/dor/src/cli.ts b/dor/src/cli.ts index c529fe43..d5a0c048 100644 --- a/dor/src/cli.ts +++ b/dor/src/cli.ts @@ -13,6 +13,7 @@ import { killCommand } from './commands/kill.js'; import { listCommand } from './commands/list.js'; import { readCommand } from './commands/read.js'; import { sendCommand } from './commands/send.js'; +import { skillCommand } from './commands/skill.js'; import { splitCommand } from './commands/split.js'; import { versionCommand } from './commands/version.js'; import { errorMessage, fail } from './commands/shared.js'; @@ -67,6 +68,7 @@ const COMMANDS = [ splitCommand, ensureCommand, versionCommand, + skillCommand, sendCommand, readCommand, killCommand, @@ -79,6 +81,7 @@ const ROUTES = { split: splitCommand.command, ensure: ensureCommand.command, version: versionCommand.command, + skill: skillCommand.command, send: sendCommand.command, read: readCommand.command, kill: killCommand.command, diff --git a/dor/src/commands/skill.ts b/dor/src/commands/skill.ts new file mode 100644 index 00000000..6718333d --- /dev/null +++ b/dor/src/commands/skill.ts @@ -0,0 +1,128 @@ +/** Print the bundled Dormouse agent skill, or install its bootstrap stub. */ + +import { buildCommand } from '@stricli/core'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { resolve as resolvePath } from 'node:path'; +import { DOR_SKILL_MARKDOWN as skillMarkdown } from '../generated-skill.js'; +import type { Command, DorCommandContext } from './types.js'; +import { callerWorkingDirectory, renderJson, writeStdout } from './shared.js'; + +interface SkillFlags { + readonly install?: boolean; + readonly json?: boolean; +} + +// The stub is deliberately fact-free: its whole content is the detection rule, so +// a committed copy carries no `dor` specifics that could drift out of date — the +// agent runs `dor skill` for the real, always-current instructions. +const BOOTSTRAP_STUB = ` +If the \`DORMOUSE_SURFACE_ID\` environment variable is set, this terminal is +hosted by [Dormouse](https://dormouse.sh) and its \`dor\` CLI is on \`PATH\`: run +\`dor skill\` and follow what it prints to make full use of the terminal +(background processes in visible panes, browser surfaces, sub-agents). If the +variable is not set, ignore this section — \`dor\` is not available. +`; + +// Prefix of the opening marker line — enough to detect an existing block. +const BEGIN_MARKER = '`. +const END_MARKER = 'dor-skill:end -->'; + +// Instruction files an agent reads, in the order we adopt/prefer them. +const CANDIDATES = ['AGENTS.md', 'CLAUDE.md'] as const; + +export const skillCommand: Command = { + name: 'skill', + command: buildCommand({ + docs: { + brief: 'Print the Dormouse agent skill, or install its bootstrap stub.', + fullDescription: `Prints the Dormouse agent skill — instructions that teach a coding agent to drive Dormouse through the dor CLI: run background processes in visible panes, open browser surfaces, and launch and coordinate sub-agents. The text ships inside the CLI, so it always matches the Dormouse that staged it. + +--install instead writes a marker-delimited bootstrap stub into the project's agent instructions file. The stub's whole content is the detection rule — if DORMOUSE_SURFACE_ID is set, run \`dor skill\` and follow it — so a committed stub carries no CLI facts of its own and never goes stale. + +If AGENTS.md or CLAUDE.md already contains the block, it is rewritten in place. Otherwise the stub goes to AGENTS.md when it exists, else to CLAUDE.md when it exists and does not already import AGENTS.md (via \`@AGENTS.md\`), else to a newly created AGENTS.md. Everything outside the markers is left untouched, so re-running is idempotent. + +Text output: + created AGENTS.md + updated CLAUDE.md + +JSON output: + { + "status": "created", + "file": "AGENTS.md" + }`, + }, + parameters: { + flags: { + install: { kind: 'boolean', brief: 'Install the bootstrap stub into the project\'s agent instructions file.', optional: true, withNegated: false }, + json: { kind: 'boolean', brief: 'Print JSON output.', optional: true, withNegated: false }, + }, + }, + func: runSkillCommand, + }), +}; + +function runSkillCommand(this: DorCommandContext, flags: SkillFlags): void | Error { + if (flags.install === true) return installStub(this, flags.json === true); + writeStdout(this, flags.json === true ? renderJson({ markdown: skillMarkdown }) : skillMarkdown); + return undefined; +} + +function installStub(context: DorCommandContext, json: boolean): void | Error { + const projectDir = callerWorkingDirectory(undefined, context.options.env); + + // First adopt an existing block wherever it already lives, rewriting it in + // place so hand edits outside the markers survive. + for (const name of CANDIDATES) { + const path = resolvePath(projectDir, name); + if (!existsSync(path)) continue; + const content = readFileSync(path, 'utf8'); + if (!content.includes(BEGIN_MARKER)) continue; + const rewritten = rewriteStub(content, name); + if (rewritten instanceof Error) return rewritten; + writeFileSync(path, rewritten); + return renderInstall(context, 'updated', name, json); + } + + // No block yet: append to AGENTS.md when it exists, else CLAUDE.md unless it + // already imports AGENTS.md, else create AGENTS.md. + const agentsPath = resolvePath(projectDir, 'AGENTS.md'); + if (existsSync(agentsPath)) { + writeFileSync(agentsPath, appendStub(readFileSync(agentsPath, 'utf8'))); + return renderInstall(context, 'updated', 'AGENTS.md', json); + } + + const claudePath = resolvePath(projectDir, 'CLAUDE.md'); + if (existsSync(claudePath)) { + const content = readFileSync(claudePath, 'utf8'); + if (!content.includes('@AGENTS.md')) { + writeFileSync(claudePath, appendStub(content)); + return renderInstall(context, 'updated', 'CLAUDE.md', json); + } + } + + writeFileSync(agentsPath, `${BOOTSTRAP_STUB}\n`); + return renderInstall(context, 'created', 'AGENTS.md', json); +} + +// Replace the whole marked region with the current stub; a begin without a +// well-ordered end means someone mangled the block, so refuse rather than guess. +function rewriteStub(content: string, name: string): string | Error { + const begin = content.indexOf(BEGIN_MARKER); + const end = content.indexOf(END_MARKER); + if (end === -1 || end < begin) { + return new Error(`${name} has a malformed dor-skill block`); + } + return content.slice(0, begin) + BOOTSTRAP_STUB + content.slice(end + END_MARKER.length); +} + +function appendStub(content: string): string { + if (content.trim() === '') return `${BOOTSTRAP_STUB}\n`; + return `${content.trimEnd()}\n\n${BOOTSTRAP_STUB}\n`; +} + +// Report the bare file name only — never the absolute path — so output stays +// machine-independent. +function renderInstall(context: DorCommandContext, status: 'created' | 'updated', file: string, json: boolean): void { + writeStdout(context, json ? renderJson({ status, file }) : `${status} ${file}\n`); +} diff --git a/dor/src/node-runtime.d.ts b/dor/src/node-runtime.d.ts index cb62bef9..14e25ff7 100644 --- a/dor/src/node-runtime.d.ts +++ b/dor/src/node-runtime.d.ts @@ -14,6 +14,8 @@ declare module 'node:net' { declare module 'node:fs' { export function existsSync(path: string): boolean; + export function readFileSync(path: string, encoding: 'utf8'): string; + export function writeFileSync(path: string, data: string): void; } declare module 'node:path' { diff --git a/dor/test/cli-output.test.mjs b/dor/test/cli-output.test.mjs index c40d980e..2e1e1fb6 100644 --- a/dor/test/cli-output.test.mjs +++ b/dor/test/cli-output.test.mjs @@ -1,6 +1,8 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; import { delimiter, dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { runCli } from '../dist/cli.js'; @@ -11,6 +13,20 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const snapshotsDir = join(__dirname, 'snapshots'); const updateSnapshots = process.env.UPDATE_SNAPSHOTS === '1'; +// The markdown the prebuild codegen inlines into the CLI; `dor skill` must print it verbatim. +const skillMarkdown = await readFile(join(__dirname, '..', 'skill.md'), 'utf8'); + +// Run against a throwaway project directory, passed to the CLI as its PWD so +// `dor skill --install` resolves the target files there. Cleaned up after. +async function withProjectDir(run) { + const dir = await mkdtemp(join(tmpdir(), 'dor-skill-')); + try { + return await run(dir); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + const fixtureSurfaces = [ { id: '11111111-1111-4111-8111-111111111111', @@ -396,6 +412,101 @@ test('--version and -v alias the version command', async () => { } }); +test('skill prints the bundled markdown verbatim', async () => { + const result = await runCli(['skill']); + assert.equal(result.exitCode, 0); + assert.equal(result.stderr, ''); + assert.equal(result.stdout, skillMarkdown); +}); + +test('skill --json wraps the markdown', async () => { + const result = await runCli(['skill', '--json']); + assert.equal(result.exitCode, 0); + assert.equal(result.stderr, ''); + assert.equal(JSON.parse(result.stdout).markdown, skillMarkdown); +}); + +test('skill --install creates AGENTS.md when neither file exists', async () => { + await withProjectDir(async (dir) => { + const result = await runCli(['skill', '--install'], { env: { PWD: dir } }); + await snapshot('skill-install-created', result); + const content = await readFile(join(dir, 'AGENTS.md'), 'utf8'); + assert.ok(content.startsWith('\n')); + assert.match(content, /DORMOUSE_SURFACE_ID/); + }); +}); + +test('skill --install appends to an existing AGENTS.md and stays idempotent', async () => { + await withProjectDir(async (dir) => { + const agentsPath = join(dir, 'AGENTS.md'); + await writeFile(agentsPath, '# My project\n\nSome existing guidance.\n'); + const first = await runCli(['skill', '--install'], { env: { PWD: dir } }); + assert.equal(first.exitCode, 0); + assert.equal(first.stderr, ''); + assert.equal(first.stdout, 'updated AGENTS.md\n'); + const afterFirst = await readFile(agentsPath, 'utf8'); + assert.ok(afterFirst.startsWith('# My project\n\nSome existing guidance.\n\n\n')); + + // Re-running rewrites the block in place, leaving the file byte-identical. + const second = await runCli(['skill', '--install'], { env: { PWD: dir } }); + assert.equal(second.stdout, 'updated AGENTS.md\n'); + assert.equal(await readFile(agentsPath, 'utf8'), afterFirst); + }); +}); + +test('skill --install writes to CLAUDE.md when it is the only instructions file', async () => { + await withProjectDir(async (dir) => { + const claudePath = join(dir, 'CLAUDE.md'); + await writeFile(claudePath, '# Claude guidance\n'); + const result = await runCli(['skill', '--install'], { env: { PWD: dir } }); + assert.equal(result.stdout, 'updated CLAUDE.md\n'); + assert.match(await readFile(claudePath, 'utf8'), /\nold body\n'; + const after = '\n\n## Footer\n'; + await writeFile(agentsPath, before + staleBlock + after); + const result = await runCli(['skill', '--install'], { env: { PWD: dir } }); + assert.equal(result.stdout, 'updated AGENTS.md\n'); + const content = await readFile(agentsPath, 'utf8'); + assert.ok(content.startsWith(before)); + assert.ok(content.endsWith(after)); + assert.doesNotMatch(content, /old body/); + assert.match(content, /DORMOUSE_SURFACE_ID/); + assert.equal(content.match(//g).length, 1); + }); +}); + +test('skill --install --json reports status and file', async () => { + await withProjectDir(async (dir) => { + const result = await runCli(['skill', '--install', '--json'], { env: { PWD: dir } }); + assert.equal(result.exitCode, 0); + assert.equal(result.stderr, ''); + assert.deepEqual(JSON.parse(result.stdout), { status: 'created', file: 'AGENTS.md' }); + }); +}); + test('send text output', async () => { await snapshot( 'send-text', diff --git a/dor/test/snapshots/help/dor.md b/dor/test/snapshots/help/dor.md index 8afc2f49..04f7ca85 100644 --- a/dor/test/snapshots/help/dor.md +++ b/dor/test/snapshots/help/dor.md @@ -7,6 +7,7 @@ USAGE dor split [--left|--right|--up|--down|--auto] [--json] [--minimize] [--surface id|ref] [-- ...] dor ensure [--json] [--minimize] [--restart] [--surface id|ref] [--cwd path] -- ... dor version [--json] + dor skill [--install] [--json] dor send ([--text value] [--key value] | --stdin | --sequence json) [--json] [--raw] dor read [--json] [--lines count] [--scrollback] dor kill [--confirm-if-read text|--confirm-dangerously] [--json] @@ -25,6 +26,7 @@ COMMANDS split Create a new terminal surface by splitting an existing surface. ensure Ensure one surface is running a command. version Print the dor CLI version. + skill Print the Dormouse agent skill, or install its bootstrap stub. send Send text or key input to a terminal surface. read Read terminal text from a surface. kill Kill a surface. diff --git a/dor/test/snapshots/help/skill.md b/dor/test/snapshots/help/skill.md new file mode 100644 index 00000000..3eafba77 --- /dev/null +++ b/dor/test/snapshots/help/skill.md @@ -0,0 +1,32 @@ +# dor skill + +Invocation: `dor skill --help` + +```text +USAGE + dor skill [--install] [--json] + dor skill --help + +Prints the Dormouse agent skill — instructions that teach a coding agent to drive Dormouse through the dor CLI: run background processes in visible panes, open browser surfaces, and launch and coordinate sub-agents. The text ships inside the CLI, so it always matches the Dormouse that staged it. + +--install instead writes a marker-delimited bootstrap stub into the project's agent instructions file. The stub's whole content is the detection rule — if DORMOUSE_SURFACE_ID is set, run `dor skill` and follow it — so a committed stub carries no CLI facts of its own and never goes stale. + +If AGENTS.md or CLAUDE.md already contains the block, it is rewritten in place. Otherwise the stub goes to AGENTS.md when it exists, else to CLAUDE.md when it exists and does not already import AGENTS.md (via `@AGENTS.md`), else to a newly created AGENTS.md. Everything outside the markers is left untouched, so re-running is idempotent. + +Text output: + created AGENTS.md + updated CLAUDE.md + +JSON output: + { + "status": "created", + "file": "AGENTS.md" + } + +FLAGS + [--install] Install the bootstrap stub into the project's agent instructions file. + [--json] Print JSON output. + -h --help Print help information and exit + -- All subsequent inputs should be interpreted as arguments + +``` diff --git a/dor/test/snapshots/skill-install-created.snap b/dor/test/snapshots/skill-install-created.snap new file mode 100644 index 00000000..b9a6e434 --- /dev/null +++ b/dor/test/snapshots/skill-install-created.snap @@ -0,0 +1,5 @@ +exitCode: 0 +stdout: +created AGENTS.md + +stderr: diff --git a/scripts/generate-dor-skill.mjs b/scripts/generate-dor-skill.mjs new file mode 100644 index 00000000..2480a39d --- /dev/null +++ b/scripts/generate-dor-skill.mjs @@ -0,0 +1,16 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const scriptPath = fileURLToPath(import.meta.url); +const repoRoot = resolve(dirname(scriptPath), '..'); + +const markdown = await readFile(resolve(repoRoot, 'dor/skill.md'), 'utf8'); +const outPath = resolve(repoRoot, 'dor/src/generated-skill.ts'); + +await mkdir(dirname(outPath), { recursive: true }); +await writeFile( + outPath, + `// Generated by scripts/generate-dor-skill.mjs. Do not edit.\n` + + `export const DOR_SKILL_MARKDOWN = ${JSON.stringify(markdown)};\n`, +); From 9b2f4ed5bd3bc125cd2bd889b7acbede65fdb637 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 9 Jul 2026 14:02:00 -0700 Subject: [PATCH 4/9] Promote the agent-skill distribution design above the fold docs/specs/dor-cli.md gains an Agent Skill section describing the shipped architecture (content inlined via prebuild codegen, the fact-free bootstrap stub, the AGENTS.md/CLAUDE.md selection rule) and lists dor skill among the implemented commands. The Future section keeps only the unbuilt remainder: stub-only ecosystem publication and a deferred --global variant. Co-Authored-By: Claude Fable 5 --- AGENTS.md | 2 +- docs/specs/dor-cli.md | 84 +++++++++++++++++++++++-------------------- 2 files changed, 46 insertions(+), 40 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9f64d673..981f569f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,7 +41,7 @@ Each spec's own `Files` / `Code Map` section is the exhaustive file→spec mappi - **`docs/specs/transport.md`** — Adapter-agnostic webview ↔ host protocol: PTY lifecycle + buffering, the reconnection sequence, message contracts, persisted-session types and migrations, and universal adapter invariants. Touch points: `pty-manager.ts` / `pty-host.js` / `pty-core.js`, `message-router.ts` / `message-types.ts`, the platform adapters, `reconnect.ts` / `session-save.ts` / `session-restore.ts` / `session-types.ts`, anything crossing the webview/host boundary. - **`docs/specs/mouse-and-clipboard.md`** — Terminal-owned text selection, copy (Raw / Rewrapped), paste tiers + bracketed paste, smart URL/path extension, mouse-reporting override UI, and the state matrix for which layer owns mouse events. Touch points: the mouse/selection/clipboard modules in `lib/src/lib/`, `SelectionOverlay.tsx` / `SelectionPopup.tsx`, `standalone/sidecar/clipboard-ops.js`. - **`docs/specs/theme.md`** — Theme system: two-layer CSS variable strategy (`--vscode-*` → `--color-*`), the consumed-token resolver and registry defaults, terminal color contract, theme data + OpenVSX installer, Storybook simulation, and the theme debugger. Touch points: `lib/src/lib/themes/`, `lib/src/theme.css`, `ThemePicker.tsx`, `lib/scripts/bundle-themes.mjs`, theme restore in the hosts. -- **`docs/specs/dor-cli.md`** — The `dor` CLI staged onto every Dormouse terminal's `PATH`: bundling + env contract, the `spawnAndCapture` rules for external binaries (`dor-lib-common`), host control-socket plumbing, the Surface handle model, and the implemented command set. Touch points: `dor/`, `dor-lib-common/`, control-request routing in each host, the `Wall.tsx` control handler. +- **`docs/specs/dor-cli.md`** — The `dor` CLI staged onto every Dormouse terminal's `PATH`: bundling + env contract, the `spawnAndCapture` rules for external binaries (`dor-lib-common`), host control-socket plumbing, the Surface handle model, the implemented command set, and the bundled agent skill (`dor skill` / `--install`). Touch points: `dor/`, `dor-lib-common/`, control-request routing in each host, the `Wall.tsx` control handler. - **`docs/specs/dor-browser.md`** — The unified browser surface: one `BrowserPanel` with swappable `renderMode` (`ab-screencast` / `ab-popout` / `iframe`), canonical params + surface lifetimes, browser chrome, the Display modal + render swaps, the agent-browser connection/input/pop-out stack, and the iframe proxy + shim + CSP boundaries. Builds on the handle model in dor-cli.md. Touch points: `BrowserPanel.tsx` / `AgentBrowserPanel.tsx` / `IframePanel.tsx` / `SurfacePaneHeader.tsx`, the `agent-browser-*` modules in `lib/src/components/wall/`, `lib/src/host/`, the `dor ab` / `dor iframe` commands. - **`docs/specs/vscode.md`** — VS Code host layer: WebviewView + WebviewPanel hosting, webview ↔ Workspace mapping and union-status reflection onto native chrome, the persistence flow and its ordering rules, theme integration + OSC color-query answering, CSP, and the build/dogfood pipeline. The transport protocol it speaks lives in transport.md. Touch points: `vscode-ext/src/`, the theme resolver/observer in `terminal-theme.ts`, VS Code commands and context keys. - **`docs/specs/standalone.md`** — Standalone (Tauri) host layer: the Rust ↔ Node-sidecar bridge (invoke commands, JSON-lines stdio), the webview boot sequence, the AppBar (shell dropdown, theme picker, window controls), localStorage persistence at the adapter boundary, sidecar shutdown ordering + parent-PID watchdog, file drop, logging, and the build/dev workflow (including the vite-only `beforeDevCommand` caveat). The transport protocol it speaks lives in transport.md. Touch points: `standalone/src-tauri/src/lib.rs`, `standalone/src/` (main, AppBar, tauri-adapter), `standalone/sidecar/main.js`. diff --git a/docs/specs/dor-cli.md b/docs/specs/dor-cli.md index 412175a4..eb58f2ae 100644 --- a/docs/specs/dor-cli.md +++ b/docs/specs/dor-cli.md @@ -19,6 +19,7 @@ Source of truth: | POSIX / Windows launchers | `dor/bin/dor`, `dor/bin/dor.cmd` | | Snapshot tests for CLI output and help text | `dor/test/cli-output.test.mjs`, `dor/test/cli-help.test.mjs`, `dor/test/snapshots/` | | Shared staging script | `scripts/stage-dor-cli.mjs` | +| Agent skill markdown and its inlining codegen | `dor/skill.md`, `scripts/generate-dor-skill.mjs` | | Standalone staging/runtime env | `standalone/package.json`, `standalone/src-tauri/src/lib.rs`, `standalone/sidecar/pty-core.js`, `standalone/sidecar/main.js` | | VS Code staging/runtime env | `vscode-ext/package.json`, `vscode-ext/src/pty-manager.ts`, `vscode-ext/src/pty-host.js` | | Control request routing into the webview | `standalone/src/tauri-adapter.ts`, `vscode-ext/src/message-router.ts`, `lib/src/lib/platform/vscode-adapter.ts` | @@ -323,6 +324,9 @@ from `command-detail`. (`lib/src/components/wall/use-dor-control.ts`). - `dor ensure` [impl](../../dor/src/commands/ensure.ts) [docs](../../dor/test/snapshots/help/ensure.md) - `dor version` [impl](../../dor/src/commands/version.ts) [docs](../../dor/test/snapshots/help/version.md) +- `dor skill` — prints the bundled agent skill, or installs its bootstrap stub + with `--install`; see [Agent Skill](#agent-skill). + [impl](../../dor/src/commands/skill.ts) [docs](../../dor/test/snapshots/help/skill.md) - `dor send` [impl](../../dor/src/commands/send.ts) [docs](../../dor/test/snapshots/help/send.md) - `dor read` [impl](../../dor/src/commands/read.ts) [docs](../../dor/test/snapshots/help/read.md) - `dor kill` [impl](../../dor/src/commands/kill.ts) [docs](../../dor/test/snapshots/help/kill.md) @@ -389,47 +393,49 @@ their session is held externally by `agent-browser`. | Port-owner handoff | `dor list --port 5173` returns the terminal that owns the socket (browser Surfaces never match `--port`), then `dor ab --key client open http://localhost:5173` binds the browser side. | | Safe cleanup | `dor list --command "npm dev" --cwd .`, then `dor kill --confirm-if-read `. The ref comes from a recent listing or command response; `title:` also targets one but can drift. | +## Agent Skill + +`dor/skill.md` is the agent skill: instructions that teach a coding agent +running inside a Dormouse terminal to drive it through `dor` — the Agent +Workflows above, recast as a targeting model plus recipes. Distribution splits +into content and bootstrap so each is exactly as stable as it needs to be: + +- **Content ships with the CLI.** `scripts/generate-dor-skill.mjs` (prebuild, + like the version metadata) inlines the markdown into the bundle as the + gitignored `generated-skill.ts`, so `dor skill` prints text version-locked + to the CLI that staged it and the staged package stays launchers + bundle. + The skill body contains no environment detection: if `dor skill` ran, `dor` + is by definition available — detection lives only in the stub. +- **Bootstrap is a stub that cannot drift.** `dor skill --install` writes a + marker-delimited block (``) into + the project's agent instructions file, resolved against the invoking + shell's PWD like `dor ensure --cwd`. The block's entire content is the + detection rule — *if `DORMOUSE_SURFACE_ID` is set, run `dor skill` and + follow it; otherwise ignore this section* — plus a one-line Dormouse + pointer, so a committed stub carries no CLI facts and never version-skews, + and the env guard keeps it inert for collaborators who don't run Dormouse. + Committing it is the point: the stub travels with the repo (`AGENTS.md` is + the convention read by Codex, Pi, OpenCode, and most other harnesses), so + one teammate installing it covers every agent and every clone. +- **File selection.** An existing block in `AGENTS.md` or `CLAUDE.md` + (checked in that order) is rewritten in place; everything outside the + markers is untouched, so re-running is idempotent. Otherwise: append to + `AGENTS.md` when it exists; else to `CLAUDE.md` when it exists and does not + already import `@AGENTS.md`; else create `AGENTS.md`. A begin marker + without a well-ordered end marker fails (`malformed dor-skill block`) + rather than guessing. Output reports the bare file name only + (`created AGENTS.md` / `updated CLAUDE.md`), never an absolute path. + +Source of truth: `dor/src/commands/skill.ts`, `scripts/generate-dor-skill.mjs`, +`dor/skill.md`; `dor skill` output is asserted byte-identical to `dor/skill.md` +in `dor/test/cli-output.test.mjs`. + ## Future -- **`dor skill` — agent-skill distribution** — ship the agent - skill (`dor-skill.md`, authored at the repo root) to every coding agent - running inside a Dormouse terminal. Distribution splits into content and - bootstrap so each can be exactly as stable as it needs to be: - - **Content ships with the CLI.** `dor skill` prints the skill markdown. - The text is inlined into the compiled entrypoint at build time (esbuild - text import; the file moves into the `dor` package — e.g. a top-level - `skill.md` there — so the build can import it), keeping the staged package - launchers + bundle and version-locking the text to the CLI it documents. - The printed text is snapshot-tested alongside the command's help. The - skill body deliberately contains no environment detection: if `dor skill` - ran, `dor` is by definition available — detection belongs entirely to the - stub. - - **Bootstrap is a stub that cannot drift.** `dor skill --install` writes - the stub as a managed block in the *project's* agent instruction file — - `AGENTS.md`, the convention read by Codex, Pi, OpenCode, and most other - harnesses, or `CLAUDE.md` where that is what the project has — whose - entire content is the detection rule: *if `DORMOUSE_SURFACE_ID` is set in - the environment, run `dor skill` and follow it; otherwise this terminal is - not Dormouse-hosted and `dor` does not apply.* The stub carries no facts - about the CLI, so it can never version-skew; the instructions always come - from the running Dormouse. The env-var guard also makes the committed - block inert for collaborators who don't run Dormouse — committing it is - safe, and is the point: the stub travels with the repo, so one teammate - installing it covers every agent and every clone, instead of living in one - user's home config. The block is delimited by marker comments and - rewritten in place on every run — `dor skill --install` is idempotent, - creates the file when the project has none, never touches text outside its - markers, and prints what it wrote. - - Skill-ecosystem publication (plugin marketplaces, npm) distributes the - stub, never a copy of the content. - - To workshop before implementing: the file-selection rule (prefer an - existing `AGENTS.md`; fall back to `CLAUDE.md` when it exists and does not - already import `AGENTS.md`; create `AGENTS.md` when neither exists?); - whether the stub says anything to readers without Dormouse (a one-line - pointer is honest advertising, more is spam); whether a user-level - `--global` variant is also worth offering; whether the canonical skill - file keeps its YAML frontmatter now that the stub is an instructions block - (nothing would index the frontmatter); and the exact stub wording. +- **`dor skill` follow-ons** — skill-ecosystem publication (plugin + marketplaces, npm) distributes the bootstrap stub, never a copy of the + content. A user-level `--global` install variant waits until a story needs + it. - **Browser open target resolution** — the one unshipped Agent-Workflow ergonomic (the Share-a-dev-server shortcut above): `dor ab open ` and From 4a7e6018dd2e8fb9e5fb14091f1465b13688c0a5 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 9 Jul 2026 14:07:17 -0700 Subject: [PATCH 5/9] /simplify: read instruction files once in dor skill --install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit installStub read and stat'd AGENTS.md/CLAUDE.md twice per run (once scanning for an existing block, again choosing the append target) with the two append branches near-duplicated; both passes now share one up-front read and a single write path. The tests' temp-dir helper generalizes to withTempDir so the pre-existing agent-browser PATH test reuses it instead of its inline mkdtemp/finally/rm copy. Skipped (reviewed, judged fine as-is): the generate-dor-skill.mjs / generate-dor-version.mjs scaffolding overlap — two 16-line single-purpose prebuild scripts don't warrant a shared emit helper yet. Co-Authored-By: Claude Fable 5 --- dor/src/commands/skill.ts | 38 +++++++++++++++--------------------- dor/test/cli-output.test.mjs | 32 +++++++++++++----------------- 2 files changed, 30 insertions(+), 40 deletions(-) diff --git a/dor/src/commands/skill.ts b/dor/src/commands/skill.ts index 6718333d..52e8ae45 100644 --- a/dor/src/commands/skill.ts +++ b/dor/src/commands/skill.ts @@ -71,13 +71,17 @@ function runSkillCommand(this: DorCommandContext, flags: SkillFlags): void | Err function installStub(context: DorCommandContext, json: boolean): void | Error { const projectDir = callerWorkingDirectory(undefined, context.options.env); + // Read both instruction files once up front; the adopt pass and the append + // pass below share the contents. `content: null` means the file is absent. + const candidates = CANDIDATES.map((name) => { + const path = resolvePath(projectDir, name); + return { name, path, content: existsSync(path) ? readFileSync(path, 'utf8') : null }; + }); + // First adopt an existing block wherever it already lives, rewriting it in // place so hand edits outside the markers survive. - for (const name of CANDIDATES) { - const path = resolvePath(projectDir, name); - if (!existsSync(path)) continue; - const content = readFileSync(path, 'utf8'); - if (!content.includes(BEGIN_MARKER)) continue; + for (const { name, path, content } of candidates) { + if (content === null || !content.includes(BEGIN_MARKER)) continue; const rewritten = rewriteStub(content, name); if (rewritten instanceof Error) return rewritten; writeFileSync(path, rewritten); @@ -86,23 +90,13 @@ function installStub(context: DorCommandContext, json: boolean): void | Error { // No block yet: append to AGENTS.md when it exists, else CLAUDE.md unless it // already imports AGENTS.md, else create AGENTS.md. - const agentsPath = resolvePath(projectDir, 'AGENTS.md'); - if (existsSync(agentsPath)) { - writeFileSync(agentsPath, appendStub(readFileSync(agentsPath, 'utf8'))); - return renderInstall(context, 'updated', 'AGENTS.md', json); - } - - const claudePath = resolvePath(projectDir, 'CLAUDE.md'); - if (existsSync(claudePath)) { - const content = readFileSync(claudePath, 'utf8'); - if (!content.includes('@AGENTS.md')) { - writeFileSync(claudePath, appendStub(content)); - return renderInstall(context, 'updated', 'CLAUDE.md', json); - } - } - - writeFileSync(agentsPath, `${BOOTSTRAP_STUB}\n`); - return renderInstall(context, 'created', 'AGENTS.md', json); + const [agents, claude] = candidates; + const target = + agents.content === null && claude.content !== null && !claude.content.includes('@AGENTS.md') + ? claude + : agents; + writeFileSync(target.path, appendStub(target.content ?? '')); + return renderInstall(context, target.content === null ? 'created' : 'updated', target.name, json); } // Replace the whole marked region with the current stub; a begin without a diff --git a/dor/test/cli-output.test.mjs b/dor/test/cli-output.test.mjs index 2e1e1fb6..cfc15963 100644 --- a/dor/test/cli-output.test.mjs +++ b/dor/test/cli-output.test.mjs @@ -16,10 +16,11 @@ const updateSnapshots = process.env.UPDATE_SNAPSHOTS === '1'; // The markdown the prebuild codegen inlines into the CLI; `dor skill` must print it verbatim. const skillMarkdown = await readFile(join(__dirname, '..', 'skill.md'), 'utf8'); -// Run against a throwaway project directory, passed to the CLI as its PWD so -// `dor skill --install` resolves the target files there. Cleaned up after. -async function withProjectDir(run) { - const dir = await mkdtemp(join(tmpdir(), 'dor-skill-')); +// Run against a throwaway directory, cleaned up after. The skill --install +// tests pass it to the CLI as its PWD; the agent-browser PATH-resolution test +// drops a fake binary in it. +async function withTempDir(prefix, run) { + const dir = await mkdtemp(join(tmpdir(), prefix)); try { return await run(dir); } finally { @@ -427,7 +428,7 @@ test('skill --json wraps the markdown', async () => { }); test('skill --install creates AGENTS.md when neither file exists', async () => { - await withProjectDir(async (dir) => { + await withTempDir('dor-skill-', async (dir) => { const result = await runCli(['skill', '--install'], { env: { PWD: dir } }); await snapshot('skill-install-created', result); const content = await readFile(join(dir, 'AGENTS.md'), 'utf8'); @@ -438,7 +439,7 @@ test('skill --install creates AGENTS.md when neither file exists', async () => { }); test('skill --install appends to an existing AGENTS.md and stays idempotent', async () => { - await withProjectDir(async (dir) => { + await withTempDir('dor-skill-', async (dir) => { const agentsPath = join(dir, 'AGENTS.md'); await writeFile(agentsPath, '# My project\n\nSome existing guidance.\n'); const first = await runCli(['skill', '--install'], { env: { PWD: dir } }); @@ -457,7 +458,7 @@ test('skill --install appends to an existing AGENTS.md and stays idempotent', as }); test('skill --install writes to CLAUDE.md when it is the only instructions file', async () => { - await withProjectDir(async (dir) => { + await withTempDir('dor-skill-', async (dir) => { const claudePath = join(dir, 'CLAUDE.md'); await writeFile(claudePath, '# Claude guidance\n'); const result = await runCli(['skill', '--install'], { env: { PWD: dir } }); @@ -468,7 +469,7 @@ test('skill --install writes to CLAUDE.md when it is the only instructions file' }); test('skill --install creates AGENTS.md when CLAUDE.md imports it', async () => { - await withProjectDir(async (dir) => { + await withTempDir('dor-skill-', async (dir) => { const claudePath = join(dir, 'CLAUDE.md'); const claudeBody = '# Claude guidance\n\n@AGENTS.md\n'; await writeFile(claudePath, claudeBody); @@ -480,7 +481,7 @@ test('skill --install creates AGENTS.md when CLAUDE.md imports it', async () => }); test('skill --install rewrites the block in place, preserving surrounding text', async () => { - await withProjectDir(async (dir) => { + await withTempDir('dor-skill-', async (dir) => { const agentsPath = join(dir, 'AGENTS.md'); const before = '# Top matter\n\n'; const staleBlock = '\nold body\n'; @@ -499,7 +500,7 @@ test('skill --install rewrites the block in place, preserving surrounding text', }); test('skill --install --json reports status and file', async () => { - await withProjectDir(async (dir) => { + await withTempDir('dor-skill-', async (dir) => { const result = await runCli(['skill', '--install', '--json'], { env: { PWD: dir } }); assert.equal(result.exitCode, 0); assert.equal(result.stderr, ''); @@ -806,16 +807,13 @@ test('agent-browser respects DORMOUSE_AGENT_BROWSER_BIN and forwards it as binar }); test('agent-browser resolves the binary on PATH to an absolute binaryPath', async () => { - const { mkdtemp, writeFile: write, rm } = await import('node:fs/promises'); - const { tmpdir } = await import('node:os'); - const dir = await mkdtemp(join(tmpdir(), 'dor-ab-')); - try { + await withTempDir('dor-ab-', async (dir) => { // On Windows a bare name isn't executable and resolveBinaryPath walks // PATHEXT (.cmd/.exe/.bat), so the on-disk shim must carry one of those // extensions — mirroring how agent-browser actually installs there. const ext = process.platform === 'win32' ? '.cmd' : ''; const binPath = join(dir, `agent-browser${ext}`); - await write(binPath, '#!/bin/sh\n', { mode: 0o755 }); + await writeFile(binPath, '#!/bin/sh\n', { mode: 0o755 }); const ab = fakeAgentBrowser(); const client = fixtureClient(); await runCli(['ab', 'snapshot'], { @@ -826,9 +824,7 @@ test('agent-browser resolves the binary on PATH to an absolute binaryPath', asyn env: { PATH: ['/nonexistent', dir].join(delimiter) }, }); assert.equal(client.requests[0].request.binaryPath, binPath); - } finally { - await rm(dir, { recursive: true, force: true }); - } + }); }); // The caller terminal (surface:2) plus the host identity `dor list --json` folds From ebc07dcc8dc35b2fb0359ceb34a38bd4f549dbc2 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 9 Jul 2026 15:39:49 -0700 Subject: [PATCH 6/9] Reuse errorMessage() helper across dor command catch blocks read/send/iframe/kill (and agent-browser) inlined `error instanceof Error ? error.message : String(error)` where ensure/split/list already call shared.ts's errorMessage(). Route them all through the helper. Addresses a review nit on #239; committed here on #240, which is where the working branch sits. Co-Authored-By: Claude Opus 4.8 (1M context) --- dor/src/commands/agent-browser.ts | 6 +++--- dor/src/commands/iframe.ts | 3 ++- dor/src/commands/kill.ts | 3 ++- dor/src/commands/read.ts | 3 ++- dor/src/commands/send.ts | 5 +++-- 5 files changed, 12 insertions(+), 8 deletions(-) diff --git a/dor/src/commands/agent-browser.ts b/dor/src/commands/agent-browser.ts index adb7c5f7..a2503f87 100644 --- a/dor/src/commands/agent-browser.ts +++ b/dor/src/commands/agent-browser.ts @@ -37,7 +37,7 @@ import type { DorCommandContext, ParseResult, } from './types.js'; -import { fail, requireControlClient, stringParser } from './shared.js'; +import { errorMessage, fail, requireControlClient, stringParser } from './shared.js'; const INSTALL_HINT = 'npm i -g agent-browser'; const INSTALL_DOCS = 'https://agent-browser.dev'; @@ -213,7 +213,7 @@ export async function runAgentBrowserCli(args: string[], options: CliOptions): P if (isMissingBinaryError(error)) { return fail(missingBinaryMessage(binary)); } - return fail(error instanceof Error ? error.message : String(error)); + return fail(errorMessage(error)); } let stderrSuffix = ''; @@ -234,7 +234,7 @@ export async function runAgentBrowserCli(args: string[], options: CliOptions): P ...(binaryPath ? { binaryPath } : {}), }); } catch (error) { - stderrSuffix = `Warning: could not open the Dormouse browser surface: ${error instanceof Error ? error.message : String(error)}\n`; + stderrSuffix = `Warning: could not open the Dormouse browser surface: ${errorMessage(error)}\n`; } } } diff --git a/dor/src/commands/iframe.ts b/dor/src/commands/iframe.ts index 169395fb..c9fbccc6 100644 --- a/dor/src/commands/iframe.ts +++ b/dor/src/commands/iframe.ts @@ -7,6 +7,7 @@ import type { IframeSurfaceResponse, } from './types.js'; import { + errorMessage, renderJson, requireControlClient, stringParser, @@ -77,7 +78,7 @@ async function runIframeCommand(this: DorCommandContext, flags: IframeFlags, url writeStdout(this, renderIframeResponse(response, flags.json === true)); return undefined; } catch (error) { - return new Error(error instanceof Error ? error.message : String(error)); + return new Error(errorMessage(error)); } } diff --git a/dor/src/commands/kill.ts b/dor/src/commands/kill.ts index f8601384..45885611 100644 --- a/dor/src/commands/kill.ts +++ b/dor/src/commands/kill.ts @@ -9,6 +9,7 @@ import type { ParseResult, } from './types.js'; import { + errorMessage, renderJson, requireControlClient, stringParser, @@ -84,7 +85,7 @@ async function runKillCommand(this: DorCommandContext, flags: KillFlags, surface writeStdout(this, renderKillResponse(response, flags.json === true)); return undefined; } catch (error) { - return new Error(error instanceof Error ? error.message : String(error)); + return new Error(errorMessage(error)); } } diff --git a/dor/src/commands/read.ts b/dor/src/commands/read.ts index aea7fe0a..d647ad79 100644 --- a/dor/src/commands/read.ts +++ b/dor/src/commands/read.ts @@ -7,6 +7,7 @@ import type { ReadSurfaceResponse, } from './types.js'; import { + errorMessage, renderJson, requireControlClient, stringParser, @@ -76,7 +77,7 @@ async function runReadCommand(this: DorCommandContext, flags: ReadFlags, surface writeStdout(this, renderReadResponse(response, flags.json === true)); return undefined; } catch (error) { - return new Error(error instanceof Error ? error.message : String(error)); + return new Error(errorMessage(error)); } } diff --git a/dor/src/commands/send.ts b/dor/src/commands/send.ts index 2535ee22..269b99c0 100644 --- a/dor/src/commands/send.ts +++ b/dor/src/commands/send.ts @@ -8,6 +8,7 @@ import type { SendSurfaceResponse, } from './types.js'; import { + errorMessage, renderJson, requireControlClient, stringParser, @@ -154,7 +155,7 @@ async function runSendCommand(this: DorCommandContext, flags: SendFlags, surface writeStdout(this, renderSendResponse(response, flags.json === true)); return undefined; } catch (error) { - return new Error(error instanceof Error ? error.message : String(error)); + return new Error(errorMessage(error)); } } @@ -191,7 +192,7 @@ function parseSendSequence(input: string): ParseResult { try { parsed = JSON.parse(input); } catch (error) { - return { ok: false, message: `invalid --sequence JSON: ${error instanceof Error ? error.message : String(error)}` }; + return { ok: false, message: `invalid --sequence JSON: ${errorMessage(error)}` }; } if (!Array.isArray(parsed)) { From 6179a6c443639e451675e840f480426ec21a31e8 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 9 Jul 2026 16:30:11 -0700 Subject: [PATCH 7/9] Make the bootstrap stub loud: mandate dor ensure and dor ab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pointer-only stub proved too soft — agents skipped `dor skill` and fell back to native background subprocesses and built-in browser tools. The stub now leads with an imperative "run `dor skill` first" and carries the two load-bearing behavioral overrides inline as NEVER/ALWAYS rules: never background a long-running process (use `dor ensure`), never use a native browser tool (use `dor ab`). These must land before an agent would think to run `dor skill`, and both are foundational command names unlikely to drift, so the stub stays effectively stale-proof. skill.md leads with the same two rules; the spec and help text reflect the shift from "fact-free" to "two loud directives". Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/specs/dor-cli.md | 26 +++++++++++++++--------- dor/skill.md | 14 +++++++++++++ dor/src/commands/skill.ts | 34 ++++++++++++++++++++++++-------- dor/test/snapshots/help/skill.md | 2 +- 4 files changed, 58 insertions(+), 18 deletions(-) diff --git a/docs/specs/dor-cli.md b/docs/specs/dor-cli.md index eb58f2ae..cf30f5f2 100644 --- a/docs/specs/dor-cli.md +++ b/docs/specs/dor-cli.md @@ -406,17 +406,25 @@ into content and bootstrap so each is exactly as stable as it needs to be: to the CLI that staged it and the staged package stays launchers + bundle. The skill body contains no environment detection: if `dor skill` ran, `dor` is by definition available — detection lives only in the stub. -- **Bootstrap is a stub that cannot drift.** `dor skill --install` writes a - marker-delimited block (``) into - the project's agent instructions file, resolved against the invoking - shell's PWD like `dor ensure --cwd`. The block's entire content is the +- **Bootstrap is a loud stub that barely drifts.** `dor skill --install` + writes a marker-delimited block (``) into the project's agent instructions file, resolved + against the invoking shell's PWD like `dor ensure --cwd`. Its core is the detection rule — *if `DORMOUSE_SURFACE_ID` is set, run `dor skill` and - follow it; otherwise ignore this section* — plus a one-line Dormouse - pointer, so a committed stub carries no CLI facts and never version-skews, - and the env guard keeps it inert for collaborators who don't run Dormouse. - Committing it is the point: the stub travels with the repo (`AGENTS.md` is + follow it; otherwise ignore this section*. A pointer-only stub proved too + soft in practice — agents skipped `dor skill` and fell back to native + subprocesses and browser tools — so the block also carries two loud, + mandatory directives: never background a long-running process (use + `dor ensure`), never use a native browser tool (use `dor ab`). These are + the two behaviors that must be redirected *before* an agent would think to + run `dor skill`, and both are foundational command names — the least likely + `dor` facts to drift — so the stub stays effectively stale-proof. The env + guard keeps it inert for collaborators who don't run Dormouse, and + committing it is the point: the stub travels with the repo (`AGENTS.md` is the convention read by Codex, Pi, OpenCode, and most other harnesses), so - one teammate installing it covers every agent and every clone. + one teammate installing it covers every agent and every clone. `dor/skill.md` + leads with the same two rules so an agent that does run `dor skill` meets + them again up front. - **File selection.** An existing block in `AGENTS.md` or `CLAUDE.md` (checked in that order) is rewritten in place; everything outside the markers is untouched, so re-running is idempotent. Otherwise: append to diff --git a/dor/skill.md b/dor/skill.md index 6bc9c77f..7641651a 100644 --- a/dor/skill.md +++ b/dor/skill.md @@ -7,6 +7,20 @@ backgrounding a process inside your own shell (where its output is invisible and it dies with you), you run it in its own **surface** that the user can see and that outlives your session. +## Two hard rules + +These override your usual defaults. They matter more than anything else here: + +1. **NEVER start a long-running process — a dev server, a `--watch`, any daemon + — as a background subprocess.** It would be invisible to the user and die + with your shell. ALWAYS run it with `dor ensure -- `, which puts it + in a visible pane that outlives you and gets reused instead of duplicated. +2. **NEVER use a built-in, native, or bundled browser tool to open, view, or + drive a web page.** ALWAYS use `dor ab` (agent-browser), so the page renders + in a Dormouse pane the user can watch and you can drive. + +The rest of this guide is how to do everything well. + ## Targeting: three ways to name a surface Action commands (`read`, `send`, `kill`) take a surface handle — there is diff --git a/dor/src/commands/skill.ts b/dor/src/commands/skill.ts index 52e8ae45..3828cb49 100644 --- a/dor/src/commands/skill.ts +++ b/dor/src/commands/skill.ts @@ -12,15 +12,33 @@ interface SkillFlags { readonly json?: boolean; } -// The stub is deliberately fact-free: its whole content is the detection rule, so -// a committed copy carries no `dor` specifics that could drift out of date — the -// agent runs `dor skill` for the real, always-current instructions. +// The stub stays near fact-free — the real, always-current instructions live in +// `dor skill` — but a purely-pointer stub proved too soft: agents skipped it and +// fell back to native subprocesses and browser tools. So it carries exactly two +// load-bearing directives (use `dor ensure`, use `dor ab`), stated loudly, because +// those two behaviors must be redirected *before* an agent would think to run +// `dor skill`. Both are foundational command names, the least likely `dor` facts +// to ever drift. const BOOTSTRAP_STUB = ` +## Running inside Dormouse + If the \`DORMOUSE_SURFACE_ID\` environment variable is set, this terminal is -hosted by [Dormouse](https://dormouse.sh) and its \`dor\` CLI is on \`PATH\`: run -\`dor skill\` and follow what it prints to make full use of the terminal -(background processes in visible panes, browser surfaces, sub-agents). If the -variable is not set, ignore this section — \`dor\` is not available. +hosted by [Dormouse](https://dormouse.sh) and the \`dor\` CLI is on your \`PATH\`. +**Run \`dor skill\` first and do what it says** — it teaches you to use the +terminal fully (visible panes, browser surfaces, sub-agents). Two rules are +mandatory whether or not you have read it: + +- **NEVER start a long-running process — a dev server, a \`--watch\`, any daemon + — as a background subprocess.** It would be invisible to the user and die with + your shell. ALWAYS run it with \`dor ensure -- \` (e.g. + \`dor ensure -- npm run dev\`), which puts it in a visible pane that outlives + you and is reused instead of duplicated on re-runs. +- **NEVER use a built-in, native, or bundled browser tool to open, view, or + drive a web page.** ALWAYS use \`dor ab\` (agent-browser) — \`dor ab open + \`, \`dor ab click @e3\`, and so on — so the page renders in a Dormouse + pane the user can watch. + +If \`DORMOUSE_SURFACE_ID\` is not set, ignore this section — \`dor\` is not here. `; // Prefix of the opening marker line — enough to detect an existing block. @@ -38,7 +56,7 @@ export const skillCommand: Command = { brief: 'Print the Dormouse agent skill, or install its bootstrap stub.', fullDescription: `Prints the Dormouse agent skill — instructions that teach a coding agent to drive Dormouse through the dor CLI: run background processes in visible panes, open browser surfaces, and launch and coordinate sub-agents. The text ships inside the CLI, so it always matches the Dormouse that staged it. ---install instead writes a marker-delimited bootstrap stub into the project's agent instructions file. The stub's whole content is the detection rule — if DORMOUSE_SURFACE_ID is set, run \`dor skill\` and follow it — so a committed stub carries no CLI facts of its own and never goes stale. +--install instead writes a marker-delimited bootstrap stub into the project's agent instructions file. The stub is the detection rule — if DORMOUSE_SURFACE_ID is set, run \`dor skill\` and follow it — plus two loud, mandatory directives (use \`dor ensure\` for long-running processes, \`dor ab\` for browsers) that must land before an agent would think to run \`dor skill\`. It stays otherwise fact-free, so a committed stub does not go stale. If AGENTS.md or CLAUDE.md already contains the block, it is rewritten in place. Otherwise the stub goes to AGENTS.md when it exists, else to CLAUDE.md when it exists and does not already import AGENTS.md (via \`@AGENTS.md\`), else to a newly created AGENTS.md. Everything outside the markers is left untouched, so re-running is idempotent. diff --git a/dor/test/snapshots/help/skill.md b/dor/test/snapshots/help/skill.md index 3eafba77..744e451a 100644 --- a/dor/test/snapshots/help/skill.md +++ b/dor/test/snapshots/help/skill.md @@ -9,7 +9,7 @@ USAGE Prints the Dormouse agent skill — instructions that teach a coding agent to drive Dormouse through the dor CLI: run background processes in visible panes, open browser surfaces, and launch and coordinate sub-agents. The text ships inside the CLI, so it always matches the Dormouse that staged it. ---install instead writes a marker-delimited bootstrap stub into the project's agent instructions file. The stub's whole content is the detection rule — if DORMOUSE_SURFACE_ID is set, run `dor skill` and follow it — so a committed stub carries no CLI facts of its own and never goes stale. +--install instead writes a marker-delimited bootstrap stub into the project's agent instructions file. The stub is the detection rule — if DORMOUSE_SURFACE_ID is set, run `dor skill` and follow it — plus two loud, mandatory directives (use `dor ensure` for long-running processes, `dor ab` for browsers) that must land before an agent would think to run `dor skill`. It stays otherwise fact-free, so a committed stub does not go stale. If AGENTS.md or CLAUDE.md already contains the block, it is rewritten in place. Otherwise the stub goes to AGENTS.md when it exists, else to CLAUDE.md when it exists and does not already import AGENTS.md (via `@AGENTS.md`), else to a newly created AGENTS.md. Everything outside the markers is left untouched, so re-running is idempotent. From 1b170b949065564cc6c10cc1ea1c18856ace61b7 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 9 Jul 2026 17:12:48 -0700 Subject: [PATCH 8/9] Distinguish `dor split --` from bare `dor split`: empty tail is focus-neutral MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bare `dor split` moves keyboard focus to the new pane (for a human who wants to type there), which steals an agent's — and the user's — keystrokes unpredictably. Previously `dor split --` collapsed to the same thing, so there was no focus-safe way to open a blank pane. stricli discards the `--` escape sequence during parsing, leaving bare `dor split` and `dor split --` indistinguishable in the parsed positionals. cli.ts now captures `--` presence pre-parse (DorCommandContext.hasArgumentEscape) and split sends it as the request's focusNeutral. The host reads focusNeutral OR command-present, preserving the invariant that an initial command never steals focus. Net: focus turns solely on `--` — bare `dor split` focuses, everything with `--` (command or empty blank-terminal tail) does not. The skill now tells agents to never run a bare `dor split` and to use `dor split --` for an empty pane. Spec, help text, and both CLI + host tests updated. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/specs/dor-cli.md | 16 +++++++++++----- dor/skill.md | 16 ++++++++++++---- dor/src/cli.ts | 6 ++++++ dor/src/commands/split.ts | 7 ++++++- dor/src/commands/types.ts | 10 ++++++++++ dor/test/cli-output.test.mjs | 15 +++++++++++++++ dor/test/snapshots/help/split.md | 4 +++- lib/src/components/Wall.test.tsx | 15 +++++++++++++++ lib/src/components/wall/use-dor-control.ts | 11 ++++++++--- 9 files changed, 86 insertions(+), 14 deletions(-) diff --git a/docs/specs/dor-cli.md b/docs/specs/dor-cli.md index cf30f5f2..3b225a0b 100644 --- a/docs/specs/dor-cli.md +++ b/docs/specs/dor-cli.md @@ -317,11 +317,17 @@ EOF). Command help patches can target the `command-usage` section separately from `command-detail`. - `dor split` [impl](../../dor/src/commands/split.ts) [docs](../../dor/test/snapshots/help/split.md). - Bare `dor split` focuses the new surface (in passthrough mode focus follows the - selection, so the user types straight into it); `dor split -- ` runs - the command in the background and leaves focus on the caller, like `dor ensure`. - Both are wired through `createSplitSurface`'s `focusNeutral` flag - (`lib/src/components/wall/use-dor-control.ts`). + Focus turns solely on the presence of the `--` escape sequence, not on whether + a command follows it. A truly bare `dor split` (no `--`) focuses the new + surface (in passthrough mode focus follows the selection, so the user types + straight into it). Any `--` tail leaves focus on the caller, like `dor ensure`: + `dor split -- ` runs the command in the background, and a bare + `dor split --` opens a blank terminal without stealing focus. stricli discards + `--` while parsing, so the CLI captures its presence pre-parse + (`DorCommandContext.hasArgumentEscape`, set in `dor/src/cli.ts`) and sends it as + the request's `focusNeutral`; the host feeds that into `createSplitSurface`'s + `focusNeutral`, OR-ed with command presence so an initial command is always + focus-neutral (`lib/src/components/wall/use-dor-control.ts`). - `dor ensure` [impl](../../dor/src/commands/ensure.ts) [docs](../../dor/test/snapshots/help/ensure.md) - `dor version` [impl](../../dor/src/commands/version.ts) [docs](../../dor/test/snapshots/help/version.md) - `dor skill` — prints the bundled agent skill, or installs its bootstrap stub diff --git a/dor/skill.md b/dor/skill.md index 7641651a..40d4459c 100644 --- a/dor/skill.md +++ b/dor/skill.md @@ -84,13 +84,20 @@ terminal with `(you)`; `--json` adds stable ids and a host/identity block. ```sh dor split -- npm test # runs in background; focus stays with you dor split --minimize -- ./watch.sh -dor split # bare split: focuses the new empty shell +dor split -- # blank terminal, focus stays with you ``` Direction flags `--left|--right|--up|--down` (default `--auto`). `--surface ` picks which surface to split from. The response includes the new surface's ref — save it. +**Never run a bare `dor split` (no `--`).** It moves the user's keyboard focus +to the new pane, so their next keystrokes land there unpredictably. Bare +`dor split` exists for a human at the keyboard who wants to start typing in the +new pane. When you want an empty terminal, always write `dor split --` — same +blank pane, but focus stays put. The rule is simple: every `dor split` you run +has a `--`. + ### `dor ensure` — idempotent "make sure this is running" ```sh @@ -232,9 +239,10 @@ dor kill surface:N --confirm-if-read "npm run dev" raw argv array; Dormouse quotes it correctly for whatever shell the target surface runs (POSIX, cmd, PowerShell). Pass `-- npm test -- --watch`, not `-- "npm test -- --watch"`. -- **Focus etiquette.** `dor ensure` and `dor split -- ` never steal - focus from the user; bare `dor split` intentionally does (it hands them an - empty shell to type into). Don't bare-split as part of automation. +- **Focus etiquette.** `dor ensure` and anything with a `--` tail (`dor split + -- `, or a bare-terminal `dor split --`) never steal focus. Only a + bare `dor split` with no `--` does — never run that in automation; use + `dor split --` for an empty pane instead. - **Take refs from responses.** Capture the ref that `split`/`ensure`/`iframe` print rather than re-listing and guessing. - **`--command` is exact.** Match the command string you launched with, diff --git a/dor/src/cli.ts b/dor/src/cli.ts index d5a0c048..927122b4 100644 --- a/dor/src/cli.ts +++ b/dor/src/cli.ts @@ -156,12 +156,18 @@ export async function runCli(rawArgv: string[], options: CliOptions = {}): Promi if (!check.ok) return fail(check.message); } + // stricli discards the `--` escape sequence during parsing, so capture its + // presence here (pre-parse) for commands that must distinguish an empty + // command tail from none — e.g. `dor split --` vs bare `dor split`. + const hasArgumentEscape = args.includes('--'); + const capture = createCaptureProcess(options.env); await runStricli(APPLICATION, commandName ? [commandName, ...args] : [], { process: capture.process, forCommand: (): DorCommandContext => ({ process: capture.process, options, + hasArgumentEscape, }), }); diff --git a/dor/src/commands/split.ts b/dor/src/commands/split.ts index ea2e8174..681281e1 100644 --- a/dor/src/commands/split.ts +++ b/dor/src/commands/split.ts @@ -77,7 +77,9 @@ export const splitCommand: Command = { brief: 'Create a new terminal surface by splitting an existing surface.', fullDescription: `If no direction is provided, --auto is used. --auto chooses right when the target surface is wide, down when it is narrow, and right when the target is minimized. -Use -- followed by a command to run an initial command in the new terminal surface. Bare split focuses the new surface so you can start typing in it; split with an initial command runs it in the background and leaves focus on the calling surface. +Use -- followed by a command to run an initial command in the new terminal surface. + +Focus depends only on whether you pass --. A bare "dor split" (no --) moves focus to the new surface so a human can start typing in it — avoid it in automation, since it steals the user's keystrokes. Anything with -- leaves focus on the caller: "dor split -- " runs the command in the background, and a bare "dor split --" opens a blank terminal without stealing focus. --minimize creates the surface and immediately sends it to the minimized area. @@ -137,6 +139,9 @@ async function runSplitCommand(this: DorCommandContext, flags: SplitFlags, ...co direction: direction.value, minimized: flags.minimize === true, surface: flags.surface, + // A `--` tail — with or without a command — leaves focus on the caller. + // Only a truly bare `dor split` (no `--`) moves focus to the new surface. + focusNeutral: this.hasArgumentEscape, }); writeStdout(this, renderSplitResponse(response, flags.json === true)); return undefined; diff --git a/dor/src/commands/types.ts b/dor/src/commands/types.ts index 1c998649..cba00a86 100644 --- a/dor/src/commands/types.ts +++ b/dor/src/commands/types.ts @@ -76,6 +76,11 @@ export interface SplitSurfaceRequest { direction: SplitDirection; minimized: boolean; surface?: string; + /** Leave focus on the caller instead of moving it to the new surface. True + * whenever the invocation delimited a command tail with `--` — both + * `dor split -- ` and a bare `dor split --` (an empty blank-terminal + * tail). Only a truly bare `dor split` (no `--`) is focus-stealing. */ + focusNeutral: boolean; } export interface SplitSurfaceResponse { @@ -222,6 +227,11 @@ export interface CliResult { export interface DorCommandContext extends CommandContext { readonly options: CliOptions; + /** Whether the raw argv carried the `--` argument-escape sequence. stricli + * consumes `--` and leaves no trace in the parsed positionals, so this is the + * only way a command can tell `dor split --` (empty tail) from a bare + * `dor split`. Computed once in `cli.ts` from the pre-parse argv. */ + readonly hasArgumentEscape: boolean; } export interface Command { diff --git a/dor/test/cli-output.test.mjs b/dor/test/cli-output.test.mjs index cfc15963..8e4d8df0 100644 --- a/dor/test/cli-output.test.mjs +++ b/dor/test/cli-output.test.mjs @@ -300,10 +300,25 @@ test('split sends command argv to the host', async () => { direction: 'auto', minimized: false, surface: undefined, + focusNeutral: true, }, }]); }); +test('bare split is focus-stealing; an empty -- tail is not', async () => { + const bare = fixtureClient(); + await runCli(['split'], { client: bare }); + assert.equal(bare.requests[0].request.focusNeutral, false); + assert.equal(bare.requests[0].request.command, undefined); + + // `dor split --` opens a blank terminal without stealing focus: no command to + // run, but the `--` marks it focus-neutral, distinct from a bare `dor split`. + const emptyTail = fixtureClient(); + await runCli(['split', '--'], { client: emptyTail }); + assert.equal(emptyTail.requests[0].request.focusNeutral, true); + assert.equal(emptyTail.requests[0].request.command, undefined); +}); + test('ensure text output', async () => { await snapshot( 'ensure-text', diff --git a/dor/test/snapshots/help/split.md b/dor/test/snapshots/help/split.md index e9af3c15..2b18879f 100644 --- a/dor/test/snapshots/help/split.md +++ b/dor/test/snapshots/help/split.md @@ -9,7 +9,9 @@ USAGE If no direction is provided, --auto is used. --auto chooses right when the target surface is wide, down when it is narrow, and right when the target is minimized. -Use -- followed by a command to run an initial command in the new terminal surface. Bare split focuses the new surface so you can start typing in it; split with an initial command runs it in the background and leaves focus on the calling surface. +Use -- followed by a command to run an initial command in the new terminal surface. + +Focus depends only on whether you pass --. A bare "dor split" (no --) moves focus to the new surface so a human can start typing in it — avoid it in automation, since it steals the user's keystrokes. Anything with -- leaves focus on the caller: "dor split -- " runs the command in the background, and a bare "dor split --" opens a blank terminal without stealing focus. --minimize creates the surface and immediately sends it to the minimized area. diff --git a/lib/src/components/Wall.test.tsx b/lib/src/components/Wall.test.tsx index e26ee693..3b45c358 100644 --- a/lib/src/components/Wall.test.tsx +++ b/lib/src/components/Wall.test.tsx @@ -598,6 +598,21 @@ describe('Wall on the Lath engine', () => { expect(focusOf(newId)).toBe('false'); }); + it('dor split -- (empty tail) opens a blank surface without stealing focus', async () => { + await act(async () => { + root.render(); + }); + await flush(); + expect(focusOf('pane-a')).toBe('true'); + + // No command, but focusNeutral marks the `--` tail: a blank terminal that + // does not grab the user's keystrokes (unlike a bare `dor split`). + const newId = await dispatchSplit({ direction: 'right', focusNeutral: true }); + + expect(focusOf('pane-a')).toBe('true'); + expect(focusOf(newId)).toBe('false'); + }); + it('seeds multiple initial panes with the aspect-aware layout (geometry is measured before the seed)', async () => { // jsdom has no layout, so stub the container measurement wide. The seed reads the // store's geometry via `autoEdge`; if that geometry lags behind the measurement diff --git a/lib/src/components/wall/use-dor-control.ts b/lib/src/components/wall/use-dor-control.ts index a7ef6782..d0788259 100644 --- a/lib/src/components/wall/use-dor-control.ts +++ b/lib/src/components/wall/use-dor-control.ts @@ -29,6 +29,7 @@ type DorControlParams = { confirmation?: unknown; cwd?: unknown; direction?: unknown; + focusNeutral?: unknown; input?: unknown; inputCount?: unknown; key?: unknown; @@ -467,9 +468,13 @@ export function useDorControl({ minimized: booleanParam(params.minimized), reference: resolved.target, // Bare `dor split` hands the user a terminal to work in, so focus moves - // to the new surface. `dor split -- ` launches the command in - // the background and leaves focus on the caller (like `dor ensure`). - focusNeutral: command !== undefined, + // to the new surface. Any `--` tail leaves focus on the caller: the CLI + // sets focusNeutral whenever `--` is present, covering both + // `dor split -- ` (background command) and a bare + // `dor split --` (blank terminal). The `command !== undefined` fallback + // keeps the invariant that an initial command never steals focus even + // if a caller omits the flag. + focusNeutral: booleanParam(params.focusNeutral) || command !== undefined, }); if (!result.ok) { detail.respond({ ok: false, error: result.message }); From 08c10e8f40859b5cb2e94345b6b4251502a13f01 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 9 Jul 2026 17:33:52 -0700 Subject: [PATCH 9/9] /simplify: consolidate dor split focus decision in the CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The split-focus change left the decision split across two layers — the CLI sent focusNeutral from the `--` escape, and the host re-derived it with `|| command !== undefined`. Two sources of truth for one required field, and the "focus turns solely on --" prose didn't match. Fold command-presence into the CLI's focusNeutral (`hasArgumentEscape || command !== undefined`) so the wire field is the single source of truth; the host now just honors it. Also tightened the repetitive skill paragraph and the duplicated comments. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/specs/dor-cli.md | 16 ++++++++-------- dor/skill.md | 10 ++++------ dor/src/commands/split.ts | 7 ++++--- dor/src/commands/types.ts | 8 ++++---- dor/test/cli-output.test.mjs | 6 ++++++ lib/src/components/Wall.test.tsx | 4 +++- lib/src/components/wall/use-dor-control.ts | 12 ++++-------- 7 files changed, 33 insertions(+), 30 deletions(-) diff --git a/docs/specs/dor-cli.md b/docs/specs/dor-cli.md index 3b225a0b..0f67ee67 100644 --- a/docs/specs/dor-cli.md +++ b/docs/specs/dor-cli.md @@ -317,17 +317,17 @@ EOF). Command help patches can target the `command-usage` section separately from `command-detail`. - `dor split` [impl](../../dor/src/commands/split.ts) [docs](../../dor/test/snapshots/help/split.md). - Focus turns solely on the presence of the `--` escape sequence, not on whether - a command follows it. A truly bare `dor split` (no `--`) focuses the new - surface (in passthrough mode focus follows the selection, so the user types - straight into it). Any `--` tail leaves focus on the caller, like `dor ensure`: + Only a bare `dor split` (no `--`, no command) focuses the new surface (in + passthrough mode focus follows the selection, so the user types straight into + it). Everything else leaves focus on the caller, like `dor ensure`: `dor split -- ` runs the command in the background, and a bare `dor split --` opens a blank terminal without stealing focus. stricli discards `--` while parsing, so the CLI captures its presence pre-parse - (`DorCommandContext.hasArgumentEscape`, set in `dor/src/cli.ts`) and sends it as - the request's `focusNeutral`; the host feeds that into `createSplitSurface`'s - `focusNeutral`, OR-ed with command presence so an initial command is always - focus-neutral (`lib/src/components/wall/use-dor-control.ts`). + (`DorCommandContext.hasArgumentEscape`, set in `dor/src/cli.ts`) and folds it + with command-presence into the request's `focusNeutral` — the single source of + truth for the decision. The host honors that field directly + (`createSplitSurface`'s `focusNeutral` in + `lib/src/components/wall/use-dor-control.ts`). - `dor ensure` [impl](../../dor/src/commands/ensure.ts) [docs](../../dor/test/snapshots/help/ensure.md) - `dor version` [impl](../../dor/src/commands/version.ts) [docs](../../dor/test/snapshots/help/version.md) - `dor skill` — prints the bundled agent skill, or installs its bootstrap stub diff --git a/dor/skill.md b/dor/skill.md index 40d4459c..2376e46e 100644 --- a/dor/skill.md +++ b/dor/skill.md @@ -91,12 +91,10 @@ Direction flags `--left|--right|--up|--down` (default `--auto`). `--surface ` picks which surface to split from. The response includes the new surface's ref — save it. -**Never run a bare `dor split` (no `--`).** It moves the user's keyboard focus -to the new pane, so their next keystrokes land there unpredictably. Bare -`dor split` exists for a human at the keyboard who wants to start typing in the -new pane. When you want an empty terminal, always write `dor split --` — same -blank pane, but focus stays put. The rule is simple: every `dor split` you run -has a `--`. +**Never run a bare `dor split` (no `--`)** — it moves the user's keyboard focus +to the new pane, hijacking their keystrokes. For an empty terminal, write +`dor split --`: same blank pane, focus stays put. Every `dor split` you run has +a `--`. ### `dor ensure` — idempotent "make sure this is running" diff --git a/dor/src/commands/split.ts b/dor/src/commands/split.ts index 681281e1..4ca27ef7 100644 --- a/dor/src/commands/split.ts +++ b/dor/src/commands/split.ts @@ -139,9 +139,10 @@ async function runSplitCommand(this: DorCommandContext, flags: SplitFlags, ...co direction: direction.value, minimized: flags.minimize === true, surface: flags.surface, - // A `--` tail — with or without a command — leaves focus on the caller. - // Only a truly bare `dor split` (no `--`) moves focus to the new surface. - focusNeutral: this.hasArgumentEscape, + // Only a bare `dor split` (no `--`, no command) steals focus; a `--` tail + // and an initial command alike leave it on the caller. The CLI owns the + // whole decision so the host can honor the field as sent. + focusNeutral: this.hasArgumentEscape || command !== undefined, }); writeStdout(this, renderSplitResponse(response, flags.json === true)); return undefined; diff --git a/dor/src/commands/types.ts b/dor/src/commands/types.ts index cba00a86..ef8ace20 100644 --- a/dor/src/commands/types.ts +++ b/dor/src/commands/types.ts @@ -76,10 +76,10 @@ export interface SplitSurfaceRequest { direction: SplitDirection; minimized: boolean; surface?: string; - /** Leave focus on the caller instead of moving it to the new surface. True - * whenever the invocation delimited a command tail with `--` — both - * `dor split -- ` and a bare `dor split --` (an empty blank-terminal - * tail). Only a truly bare `dor split` (no `--`) is focus-stealing. */ + /** Leave focus on the caller instead of moving it to the new surface. The CLI + * sets it for every split except a bare `dor split` (no `--`, no command): a + * `--` tail (`dor split -- ` or an empty `dor split --`) and an + * initial command both leave focus put. The host honors it as sent. */ focusNeutral: boolean; } diff --git a/dor/test/cli-output.test.mjs b/dor/test/cli-output.test.mjs index 8e4d8df0..9c52328a 100644 --- a/dor/test/cli-output.test.mjs +++ b/dor/test/cli-output.test.mjs @@ -317,6 +317,12 @@ test('bare split is focus-stealing; an empty -- tail is not', async () => { await runCli(['split', '--'], { client: emptyTail }); assert.equal(emptyTail.requests[0].request.focusNeutral, true); assert.equal(emptyTail.requests[0].request.command, undefined); + + // The CLI owns the invariant that an initial command is never focus-stealing, + // so it stays focus-neutral even without a `--`. + const withCommand = fixtureClient(); + await runCli(['split', 'echo', 'hi'], { client: withCommand }); + assert.equal(withCommand.requests[0].request.focusNeutral, true); }); test('ensure text output', async () => { diff --git a/lib/src/components/Wall.test.tsx b/lib/src/components/Wall.test.tsx index 3b45c358..4ab89eb4 100644 --- a/lib/src/components/Wall.test.tsx +++ b/lib/src/components/Wall.test.tsx @@ -590,7 +590,9 @@ describe('Wall on the Lath engine', () => { await flush(); expect(focusOf('pane-a')).toBe('true'); - const newId = await dispatchSplit({ direction: 'right', command: ['echo', 'hi'] }); + // The CLI marks a `-- ` split focus-neutral (it always sends + // focusNeutral when `--` or a command is present). + const newId = await dispatchSplit({ direction: 'right', command: ['echo', 'hi'], focusNeutral: true }); // The initial command runs in the background: the caller keeps focus and the // new surface is not focused. diff --git a/lib/src/components/wall/use-dor-control.ts b/lib/src/components/wall/use-dor-control.ts index d0788259..a3ff886e 100644 --- a/lib/src/components/wall/use-dor-control.ts +++ b/lib/src/components/wall/use-dor-control.ts @@ -467,14 +467,10 @@ export function useDorControl({ direction, minimized: booleanParam(params.minimized), reference: resolved.target, - // Bare `dor split` hands the user a terminal to work in, so focus moves - // to the new surface. Any `--` tail leaves focus on the caller: the CLI - // sets focusNeutral whenever `--` is present, covering both - // `dor split -- ` (background command) and a bare - // `dor split --` (blank terminal). The `command !== undefined` fallback - // keeps the invariant that an initial command never steals focus even - // if a caller omits the flag. - focusNeutral: booleanParam(params.focusNeutral) || command !== undefined, + // The CLI computes the focus intent — a bare `dor split` steals focus; + // a `--` tail or an initial command does not — and sends it as + // focusNeutral. Honor it. + focusNeutral: booleanParam(params.focusNeutral), }); if (!result.ok) { detail.respond({ ok: false, error: result.message });