diff --git a/docs/arch/arch.md b/docs/arch/arch.md index 22dccd49..da57ddbe 100644 --- a/docs/arch/arch.md +++ b/docs/arch/arch.md @@ -49,8 +49,9 @@ Runner is a local desktop app. A user configures a **crew** of CLI coding agents │ │ Runner session (one per slot × mission) │ │ │ │ │ │ │ │ ┌──────────┐ PTY ┌─────────────────────────────────────┐ │ │ -│ │ │ master │ ◄─────► │ child: claude-code / codex / shell │ │ │ -│ │ └──────────┘ │ env: RUNNER_CREW_ID, │ │ │ +│ │ │ master │ ◄─────► │ child: claude-code / codex / qoder │ │ │ +│ │ └──────────┘ │ / trae / shell │ │ │ +│ │ │ env: RUNNER_CREW_ID, │ │ │ │ │ │ RUNNER_MISSION_ID, │ │ │ │ │ │ RUNNER_HANDLE, │ │ │ │ │ │ RUNNER_EVENT_LOG, PATH=… │ │ │ @@ -79,7 +80,7 @@ Runner is a local desktop app. A user configures a **crew** of CLI coding agents *Runtime (the hot path — the row below MissionManager).* -- **SessionManager** — the per-session PTY runtime. Holds each PTY master, runs the blocking reader thread, keeps the scrollback ring, and serializes writes. Resume is a fresh spawn against the same session row; for claude-code/codex, `agent_session_key` lets the agent CLI continue its own conversation when supported. +- **SessionManager** — the per-session PTY runtime. Holds each PTY master, runs the blocking reader thread, keeps the scrollback ring, and serializes writes. Resume is a fresh spawn against the same session row; for claude-code/codex/qoder/trae, `agent_session_key` lets the agent CLI continue its own conversation when supported. - **EventBus** — tails the per-mission NDJSON file with `notify`, parses each new line, and republishes it as Tauri events the webview and the router can subscribe to. "Projections" are the in-memory rollups it computes on the fly — inbox, pending HITL cards, status map — all derived from the same event stream. **The Signal router** sits downstream of the EventBus. When a parsed line is a built-in signal type, the router runs a fixed handler. The "inject_stdin / human_question / status" arrow into SessionManager covers the three things a handler can do: write bytes into a specific session's PTY master (`inject_stdin` — launch prompt to lead on `mission_goal`, human choice on `human_response`, worker question on `ask_lead`), append a new event back to the NDJSON log so the UI renders a HITL card (`human_question`), or update the in-memory status map (`runner_status` events from the forwarder feed this). @@ -88,7 +89,7 @@ Runner is a local desktop app. A user configures a **crew** of CLI coding agents **Closing the loop.** Child invokes `runner` CLI → CLI appends a line to `events.ndjson` → `notify` wakes the EventBus → EventBus fans the line out to (a) the Signal router for handler dispatch and (b) the webview as a Tauri event. If the handler needs a wake-up, it calls back into SessionManager's writer to push bytes into a session's stdin — that's the upward arrow on the convergence point. The bus is the spine: all coordination flows through one append-only file, which is why it's debuggable with `tail -f | jq`. -**The webview** is downstream of everything. It renders each session's PTY output (subscribes to `session:{id}:out`) and the event feed + HITL cards + signal log (subscribes to `mission:{id}:event`). +**The webview** is downstream of everything. It renders each session's PTY output (subscribes to `session:{id}:out`) and the read-mostly event feed + HITL cards + signal log (subscribes to `mission:{id}:event`). The operator talks to a runner by typing directly into that runner's pane; the feed's only write control is a pending `ask_human` card's response buttons. **What's not in the picture.** The SQLite DB. That's deliberate — SQLite holds configuration and session-lifecycle metadata only (runners, crews, slots, mission rows, session rows with PID + runtime metadata). It is not on the runtime hot path. All live coordination state lives in the NDJSON file or in the router's in-memory map. @@ -112,7 +113,7 @@ Runner is a local desktop app. A user configures a **crew** of CLI coding agents | Auto-update | **`tauri-plugin-updater`** | Signed updates from the GitHub Releases manifest. Settings → About drives the check → download → restart ladder; a sidebar prompt card surfaces ready-to-install (impl 0025). | | MCP | **`rmcp` + Unix socket + `runner-mcp` bridge** | Runner.app owns stateful tool execution; external MCP clients spawn `runner-mcp`, which bridges stdio to the app's local Unix socket. | -**Platform targets.** macOS (Apple Silicon + x64) primary; Linux (x64) best-effort. Windows is deferred — `portable-pty` works there but no one is on the validation loop. +**Platform target.** macOS (Apple Silicon + x64), and only macOS. Linux and Windows are out of scope, so no cross-platform fallback paths are maintained and Unix-only mechanisms are used freely. ## 3. Domain model @@ -153,7 +154,7 @@ A mission is a container. Everything in the runtime column is either the contain ### 3.2 Runner — *one configured agent* -A reusable template: handle, display name, runtime (`claude-code | codex` today), command + args, working dir, system prompt (persona), env. **Top-level, not nested under a crew.** The same runner template can be used by many crews simultaneously, and can also be the subject of standalone direct-chat sessions. +A reusable template: handle, display name, runtime (`claude-code | codex | qoder | trae` today), command + args, working dir, system prompt (persona), env. **Top-level, not nested under a crew.** The same runner template can be used by many crews simultaneously, and can also be the subject of standalone direct-chat sessions. A runner has two identifying fields: @@ -213,19 +214,18 @@ A session owns: A session is the only object in the system that actually *executes* code — everything else is metadata, a coordination channel, or a projection over the event log. -### 3.6 Surface hierarchy — *Project → Window → Folder → Tab → Pane* +### 3.6 Surface hierarchy — *Project → Window → Tab → Pane* How sessions are displayed spans durable organization and ephemeral view state. The concepts must never be blurred in code, docs, or UI copy: -- **Project** — a global, cwd-bound container that groups both missions and direct-chat tabs in the sidebar. Starting work from a project copies its cwd into the new mission/session row and records nullable `project_id`; runtime cwd precedence stays unchanged after that point. Project identity and ordering are durable, while collapse and the active project that scopes new-chat creation are per-window view state. Deleting a project only unbinds its work through `ON DELETE SET NULL`; it never archives chats/missions or touches the directory on disk. +- **Project** — the only durable container in the sidebar: a global, cwd-bound group for missions and direct-chat tabs. Starting work from a project copies its cwd into the new mission/session row and records nullable `project_id`; runtime cwd precedence stays unchanged after that point. Project identity and ordering are durable, while collapse and the active project that scopes new-chat creation are per-window view state. Deleting a project archives its chats and missions but never touches the directory on disk. - **Window** — a real OS window (⌘N, `File → New Window`, impl 0018). The backend's per-window subject registry (`src-tauri/src/windows.rs`) tracks every visible direct-chat subject, focus recency for duplicate-session ownership, and explicit current focus for viewed-attention semantics; it knows no tab layout beyond the reported session subjects. -- **Folder** — a user-created, collapsible sidebar group containing tabs. Folder identity, name, and order are persisted in SQLite; collapse is per-window view state. Ungrouped tabs have no folder and render below every folder. -- **Tab** — one stable, ULID-keyed group of panes rendered as exactly one sidebar row. SQLite persists its folder membership, name, order, JSON layout, and nullable completion/viewed watermarks; the layout picker mutates the same row without resetting attention state. Every active direct-chat session belongs to exactly one tab, including single-pane chats. Per-window active-tab selection remains ephemeral. +- **Tab** — one stable, ULID-keyed group of panes rendered as exactly one sidebar row. SQLite persists its optional project parent, name, order, JSON layout, and nullable completion/viewed watermarks; the layout picker mutates the same row without resetting attention state. Every active direct-chat session belongs to exactly one tab, including single-pane chats. Per-window active-tab selection remains ephemeral. - **Pane** — one slot inside a tab, holding exactly one chat session (move-not-copy). Panes are filled from a pane's own New chat button or a sidebar pick into a focused empty pane; `⌘[` / `⌘]` cycle pane focus, `⌘W` closes the focused pane without stopping its session. -Sessions exist independently of the display tree: closing a pane or window never kills a PTY. Archiving a tab removes the tab row and archives its member sessions. Deleting a folder performs that archive for every member tab in one transaction; the `tabs.folder_id` foreign key is `ON DELETE RESTRICT`, so tabs never fall silently to the ungrouped level. +Sessions exist independently of the display tree: closing a pane or window never kills a PTY. Archiving a tab removes the tab row and archives its member sessions. -Projects and chat folders deliberately coexist. A project is the cwd-bound owner-facing grouping for missions and chats; a feature-38 folder remains chat-tab-only organization with its existing archive-on-delete lifecycle. Assigning a tab to a project does not rewrite `tabs.folder_id`, so removing the project assignment reveals the tab in its prior chat folder (or the ungrouped CHAT list). +Projects contain tab and mission leaves; root contains projects and unfiled leaves. Moving a leaf across a project boundary writes its `project_id` binding through so sidebar placement and runtime cwd ownership stay aligned. Disambiguation: the mission workspace's per-slot terminal switcher (feature 33's "terminal tabs") predates this hierarchy and is a different, mission-scoped UI element — not a Tab in the sense above. If the mission surface ever adopts the tab/pane model, that is feature 19's deferred scope. @@ -262,11 +262,12 @@ A signal carries an optional `payload` (JSON) for the router and UI. Human-reada ### 4.2 Message — *"here's what I think"* -Prose, addressed either to the mission (broadcast) or to a specific crewmate (direct). Runner-to-runner *and* runner-to-human (via the reserved virtual handle `human` — see §8.5). +Prose, addressed either to the mission (broadcast) or to a specific crewmate (direct). Two shapes: -- **Broadcast** — `runner msg post ""`. Goes to everyone's inbox. -- **Direct** — `runner msg post --to ""`. Goes to that slot's inbox only. `--to human` reaches the workspace operator (rendered in the event feed). + +- **Broadcast** — `runner msg post ""`. Goes to every other runner's inbox; a human-authored broadcast goes to every runner. +- **Direct** — `runner msg post --to ""`. Goes to that slot's inbox only. Messages are **flat by design** — one stream per mission, no message-thread scoping, and no separate fact primitive. Each runner consumes messages through their **inbox** (§4.3). Durable conclusions belong in project files, code, commits, or normal message prose instead of a second coordination object model. @@ -281,7 +282,7 @@ Every slot has an **inbox**: the subset of the mission's messages relevant to it ``` inbox(h) = all events in the mission where - kind = "message" AND (to = null OR to = h) + kind = "message" AND from != h AND (to = null OR to = h) ``` `runner msg read` returns the calling slot's inbox, sorted by ULID (chronological). `--since ` restricts to messages newer than a given ULID/timestamp so agents can poll without re-reading history. @@ -365,7 +366,7 @@ The whole system runs many of these side-by-side — one per slot per live missi ### 5.2 Why PTY (not pipes) -Claude Code and Codex are TUIs. They check `isatty()`; if false, they degrade (no colors, no spinner, sometimes outright refuse). Their output is a stream of escape sequences (`\x1b[2K`, alt-screen toggles) that only a terminal emulator can render. +Claude Code, Codex, Qoder, and TRAE CLI are TUIs. They check `isatty()`; if false, they degrade (no colors, no spinner, sometimes outright refuse). Their output is a stream of escape sequences (`\x1b[2K`, alt-screen toggles) that only a terminal emulator can render. A pseudo-terminal gives the child a real terminal on stdin/stdout/ stderr (full TUI mode) and hands us the master end as a byte stream that we forward to **xterm.js** in the webview. @@ -373,13 +374,17 @@ A pseudo-terminal gives the child a real terminal on stdin/stdout/ stderr (full `portable-pty` is the in-process PTY library. The session runtime is encapsulated behind a `SessionRuntime` trait so the storage layer (SessionManager) doesn't know whether the runtime is in-process PTY, a tmux multiplexer, or anything else; today only `PtyRuntime` is shipped. +Login-shell discovery is startup-safe and shared. Setup seeds `SessionManager` from the last successful `LoginShellEnv` snapshot in `_app_state.login_shell_env_lkg`, paints the app, then runs the configured shell probe on a background thread with a five-second deadline. A successful probe atomically swaps the environment used by future spawns, persists the new snapshot, and emits `runtime/changed`; timeout, spawn failure, empty capture, or a missing shell leaves the prior snapshot active and records the typed outcome for Settings → Agents. Refresh is explicit after launch and follows the same path. + +Built-in runtime commands are resolved in Rust against the same direct-chat PATH the child receives. Precedence is a valid backend-persisted runtime override, then the first regular executable with an executable bit found by walking the composed PATH, then the bare catalog command only while discovery is still in flight. Once discovery completes, a missing executable fails before PTY creation with a pointer to Settings → Agents. Resolution substitutes only a runner command that still equals the runtime catalog default; legacy custom commands remain byte-for-byte unchanged. Runtime-only sessions record the effective command, reuse a still-valid recorded absolute path on resume, and re-resolve when that file disappears. + ``` portable_pty::openpty(rows, cols) ├─ master handle → kept by SessionManager └─ slave handle → given to child via spawn_command() Child inherits (mission session): - PATH = $APPDATA/runner/bin: + PATH = :$APPDATA/runner/bin::: RUNNER_CREW_ID = RUNNER_MISSION_ID = RUNNER_HANDLE = @@ -398,7 +403,7 @@ Reader thread (blocking): on EOF: wait(child) → emit session:{id}:exit { code } → update sessions row ``` -System prompt content is delivered to the runtime via its native flag for the lead (`--append-system-prompt` for claude-code, the equivalent for each runtime) and via a positional-argv first-turn body for workers when the runtime accepts one. The runtime adapter in `router::runtime` owns the per-runtime mapping. +System prompt content is delivered through the runtime adapter in `router::runtime`. Claude-code, codex, qoder, and trae receive the composed first-turn body as a positional argument on fresh spawn; resumed conversations suppress it to avoid injecting a duplicate turn. ### 5.4 Frontend wiring and human takeover @@ -411,6 +416,8 @@ System prompt content is delivered to the runtime via its native flag for the le The xterm pane is a real terminal, not a log viewer. Special keys (arrows, Enter, Ctrl-C) pass through untouched. The agent on the other end can't tell whether the bytes came from the router, the human, or its normal terminal input — which is the point. +The mission feed is read-mostly. It renders coordination events and historical human-authored events, but it has no free-form composer; the operator selects a runner pane and types there. The remaining feed-side input is the choice control on a pending `human_question` card, which appends a correlated `human_response` for the router to inject back to the asker. External orchestrators can still post `human_said` through MCP when they need to relay an operator instruction programmatically. + ### 5.5 Sessions outlive the UI, not the app process Sessions live in the Rust backend and belong to the mission, not to any webview or tab. Closing the mission control window does *not* kill the sessions — the agents keep running, events keep flowing into the NDJSON file, and the router keeps handling live signals. Re-opening the window re-attaches: the frontend fetches each session's scrollback ring to rebuild xterm state, then subscribes to live output from wherever it was. @@ -431,7 +438,7 @@ The PTY master writer is shared between the human (via `send_input` command) and Bounded raw-byte ring per session in SessionManager. It survives tab switches, route changes, and late workspace attachment while the app process is alive. It does not survive app restart, and there is no on-disk scrollback overflow today. The ring sees raw bytes including alt-screen toggles — acceptable because the frontend replays through xterm.js which can absorb them. -Resume preserves the ring for claude-code (impls 0024 and 0032): it paints inline into the main screen, so kept scrollback + resume banner + tail repaint is what a physical terminal would show, and a later remount replay keeps the pre-resume conversation. Before the new child forks, Runner appends a synthetic seam chunk through the normal output ingest path to reset SGR, disable bracketed paste and mouse reporting, and start the banner on a fresh line. The ring stays bounded and process-local as before — old and new bytes share the same cap. Codex and other purge runtimes still drop prior bytes because their full-frame resume repaint would stack over retained content; Runner seeds the purged ring with an in-band full-reset chunk so mounted terminal grids and later replay both start clean. Either way `resume` stamps a seq watermark before appending the synthetic chunk, and the synthetic chunks contain no ready-mode enable, so retained or synthetic bytes cannot satisfy a consumer waiting on the new PTY. +Resume preserves the ring for claude-code and qoder (impls 0024, 0032, and 0034): they paint inline into the main screen, so kept scrollback + resume banner + tail repaint is what a physical terminal would show, and a later remount replay keeps the pre-resume conversation. Before the new child forks, Runner appends a synthetic seam chunk through the normal output ingest path to reset SGR, disable bracketed paste and mouse reporting, and start the banner on a fresh line. The ring stays bounded and process-local as before — old and new bytes share the same cap. Codex, trae, and other purge runtimes still drop prior bytes because their full-frame resume repaint would stack over retained content; Runner seeds the purged ring with an in-band full-reset chunk so mounted xterm grids and later replay both start clean. Claude-code, codex, qoder, and trae all clear stale scrollback on width changes before their SIGWINCH-driven repaint. Either way `resume` stamps a seq watermark before appending the synthetic chunk; the frontend's starting/resuming pills only honor TUI-ready escapes above it, and the synthetic chunks contain no ready-mode enable, so retained or synthetic bytes cannot clear an overlay waiting on the new PTY. Each session row persists the last applied PTY `cols` and `rows`. Spawn and resume resolve their initial size as explicit frontend dimensions, then the persisted dimensions, then 80×24 only for a session with no prior size. This resolution happens in SessionManager before the runtime forks, so relaunch resumes and other temporarily unmeasurable views cannot emit an initial 80-column segment before the frontend settles. @@ -457,7 +464,7 @@ Every spawned session receives a composed system prompt — different shape for The mission spawn path composes each runner's effective prompt from three layers, applied in this order: -1. **Layer 1 — platform preamble** (code-owned, not editable). For non-lead workers: a fixed block describing the `runner` CLI verbs (`msg read`, `msg post`, `signal ask_lead`), how to reply to the human (`runner msg post --to human "…"`), and the pull-based inbox convention. For the lead: the launch prompt composed at `mission_goal` time (§6.3), including the goal, the roster, and the allowed-signals list. +1. **Layer 1 — platform preamble** (code-owned, not editable). For non-lead workers: a fixed block describing the `runner` CLI verbs (`msg read`, `msg post`, `signal ask_lead`) and the pull-based inbox convention. For the lead: the launch prompt composed at `mission_goal` time (§6.3), including the goal, the roster, and the allowed-signals list. 2. **Layer 2 — crew team conventions** (data-owned, optional — `crews.system_prompt_addendum`). Spliced under a `== Team conventions ==` section between Layer 1 and Layer 3. Empty / NULL = no splice. Lets a crew share house rules without editing every runner template. 3. **Layer 3 — runner persona** (data-owned — `runners.system_prompt`). The role brief: who the runner is and what they do. Spliced under `== Your brief ==`. @@ -475,7 +482,7 @@ Example for a worker slot `reviewer` filled by a `reviewer` runner template: ``` You are a worker in a crew coordinated by the bundled `runner` CLI… -[Layer 1 preamble: verbs, inbox convention, replying to human] +[Layer 1 preamble: verbs and inbox convention] == Team conventions == ← Layer 2, if crew.system_prompt_addendum set … @@ -554,7 +561,7 @@ Stdin pushes are deliberately silent: the router writes bytes into the target PT | Signal type | Fixed handler | |---|---| | `mission_goal` | Compose the launch prompt and inject it to the lead's stdin. | -| `human_said` | Inject `payload.text` to `payload.target` if present, otherwise to the lead. | +| `human_said` | Inject MCP-provided `payload.text` to `payload.target` if present, otherwise to the lead. | | `ask_lead` | Inject the worker's `{ question, context }` to the lead. | | `ask_human` | Append a `human_question` event for the UI. | | `human_response` | Look up the matching `question_id` and inject the answer to the runner that emitted the original `ask_human`. | @@ -606,9 +613,11 @@ By convention (§3.3), workers do not escalate to the human directly: This is not a new protocol — it is `ask_lead` + `ask_human` + directed messages composed. The only schema additions are the `ask_lead` signal type and the optional `on_behalf_of` field on `human_question`. -### 8.4 The reserved `human` handle +### 8.4 Read-mostly mission feed + +The mission feed answers what is happening across the crew; the selected terminal pane is where the operator talks to a runner. Runners cannot address a virtual `human` message recipient: `runner msg post --to human` fails with guidance to answer in TUI output. -`human` is a reserved virtual recipient. Runners reply to the human via `runner msg post --to human ""` — the event appears in the workspace feed and is what humans read. This is how workers reply in-feed without needing a `human_said`-style inverse signal. +The feed keeps its render paths for historical `human_said`, `human_response`, and message events addressed to `human`, so pre-feature-51 logs replay unchanged. The router likewise keeps the `human_said` handler for MCP-originated operator instructions and the message-nudge skip for historical messages targeting `human`. The live feed's only input is a pending `human_question` card response, which preserves the `ask_human` → `human_question` → `human_response` → injection-to-asker path. ### 8.5 Who does delivery @@ -640,7 +649,7 @@ One binary, two real verbs (`signal`, `msg`) plus the deprecated `status` alias - **`signal [--payload ]`** — append a `kind: signal` event to the mission log. The router picks it up via §7.2's notify tailer and runs its fixed handler (§8.1). `--payload` is free-form JSON; the router interprets it per signal type. - **`msg post `** — broadcast: append a `kind: message` event with `to: null`. Lands in every slot's inbox. -- **`msg post --to `** — directed: append a `kind: message` event with `to: `. Lands in that slot's inbox only. `--to human` reaches the workspace operator (the reserved virtual recipient, §8.4). +- **`msg post --to `** — directed: append a `kind: message` event with `to: `. Lands in that slot's inbox only. The handle must be a slot in the mission roster. - **`msg read [--since ] [--from ]`** — the inbox-read projection (§4.3). Returns broadcasts plus directs addressed to me, sorted by ULID. `--since` filters by ULID cutoff for poll-without-rewind; `--from` filters by sender. - **`status busy|idle [--note ]`** — **deprecated.** Busy/idle is now inferred by the session forwarder from PTY-byte silence (§5.10). The verb is kept as a back-compat alias (the event is stamped `source: "agent"` so debug tooling can tell agent-reported events apart from forwarder-inferred ones) and prints a stderr deprecation notice. Bundled templates no longer call it; slated for removal in a future release. - **`help`** — long-form usage from `cli/src/help.rs`. Mirrors this section. @@ -674,7 +683,7 @@ runners ( id TEXT PRIMARY KEY, handle TEXT NOT NULL UNIQUE, -- globally unique slug; see §3.2 display_name TEXT NOT NULL, - runtime TEXT NOT NULL, -- first-class runtime key; claude-code | codex today + runtime TEXT NOT NULL, -- first-class runtime key; claude-code | codex | qoder | trae today command TEXT NOT NULL, args_json TEXT, working_dir TEXT, -- direct-chat working dir; missions override via mission.cwd @@ -751,7 +760,7 @@ sessions ( -- continue the prior conversation after a stop or app restart. agent_session_key TEXT, agent_runtime TEXT, -- runtime-only direct chat identity - agent_command TEXT, + agent_command TEXT, -- effective executable for runtime-only/pinned sessions archived_at TEXT, title TEXT, -- direct-chat title; null for mission sessions pinned_at TEXT diff --git a/docs/features/archive/05-runner-skills.md b/docs/features/05-runner-skills.md similarity index 89% rename from docs/features/archive/05-runner-skills.md rename to docs/features/05-runner-skills.md index c0382fe8..fff01058 100644 --- a/docs/features/archive/05-runner-skills.md +++ b/docs/features/05-runner-skills.md @@ -1,4 +1,4 @@ -# 05 — Cross-platform, agent-agnostic MCP & skills management +# 05 — Agent-agnostic MCP & skills management Tracking: [#73](https://github.com/yicheng47/runner/issues/73) @@ -6,7 +6,7 @@ Tracking: [#73](https://github.com/yicheng47/runner/issues/73) ## Motivation -Every coding agent ships its own way to configure MCP servers and skills — claude-code reads `~/.claude.json` + `~/.claude/skills/`, codex reads `~/.codex/config.toml`, and each agent's settings UI is specific to that agent and that machine. There's no single place to manage these, and nothing that works the same across agents or across platforms. +Every coding agent ships its own way to configure MCP servers and skills — claude-code reads `~/.claude.json` + `~/.claude/skills/`, codex reads `~/.codex/config.toml`, and each agent's settings UI is specific to that agent and that machine. There's no single place to manage these, and nothing that works the same across agents. Runner already coordinates multiple agents from one app, so it's the natural home for one central, agent-agnostic place to define and manage MCP servers and skills: define a server or a skill once, and let Runner apply it to whichever agent a runner is backed by. @@ -14,7 +14,7 @@ Runner already coordinates multiple agents from one app, so it's the natural hom - **Central catalog.** A dedicated management surface (settings-style, like Codex's "MCP servers" screen) to create / edit / delete reusable MCP servers and skills. One catalog, not buried inside per-runner edit forms. - **Agent-agnostic.** Definitions are stored in Runner's own neutral shape and materialized into whatever the target agent expects (claude-code JSON, codex TOML, skill directories). The user defines an MCP/skill once; Runner handles the per-agent translation. -- **Cross-platform.** Both the management surface and the apply mechanism must work on macOS, Linux, and Windows — no Unix-only assumptions. +- **macOS-only, so Unix mechanisms are fine.** Symlinks always work; the apply mechanism needs no portability ladder. (This lifts the constraint that partly motivated the 2026-07-15 rewrite below — the old symlink-overlay agent home was rejected as "Unix-only", which is no longer disqualifying. It stays rejected on the other grounds: the per-runner-only surface buried the catalog.) ## Reference analysis: skills-manager @@ -33,7 +33,7 @@ Runner already coordinates multiple agents from one app, so it's the natural hom ### What Runner borrows 1. **The adapter-registry shape.** A neutral per-agent record describing where skills/config live, how to detect the agent, and per-agent overrides — extended in Runner to also describe the MCP config file format (JSON at `~/.claude.json` vs TOML at `~/.codex/config.toml`) and merge strategy. -2. **Copy-capable apply with the Windows ladder.** Symlink where possible, junction on NTFS, copy as the universal fallback — plus their src/dst overlap guards. This directly resolves the cross-platform constraint that killed the old spec's symlink-overlay agent home. +2. **Copy-capable apply, plus their overlap guards.** Symlink by default with copy as a per-agent option, and their guards refusing syncs where source and destination overlap in either direction. Their Windows symlink→junction→copy ladder is moot here — on macOS symlinks always work — but copy stays worth offering for agents that follow symlinks badly. 3. **Scan-don't-trust.** The management surface should show what the agent actually sees (scan the real dirs/config), surface externally-added entries, and offer adoption — not maintain a parallel belief that drifts. 4. **Source metadata + content hash** on catalog entries, so "imported from git / local / hand-written" is recorded and update checks are possible later without redesign. 5. **`SKILL.md` + YAML frontmatter** as the on-disk skill format — it's the ecosystem convention; claude-code loads it natively. diff --git a/docs/features/archive/19-mission-split-view.md b/docs/features/19-mission-split-view.md similarity index 100% rename from docs/features/archive/19-mission-split-view.md rename to docs/features/19-mission-split-view.md diff --git a/docs/features/archive/21-import-native-sessions.md b/docs/features/21-import-native-sessions.md similarity index 100% rename from docs/features/archive/21-import-native-sessions.md rename to docs/features/21-import-native-sessions.md diff --git a/docs/features/archive/24-cronjobs.md b/docs/features/24-cronjobs.md similarity index 98% rename from docs/features/archive/24-cronjobs.md rename to docs/features/24-cronjobs.md index 0dd5dc41..ecd58862 100644 --- a/docs/features/archive/24-cronjobs.md +++ b/docs/features/24-cronjobs.md @@ -120,7 +120,7 @@ same workspace UI. The only new concept is the trigger. - **Remote/headless execution.** The scheduler runs in-process in Tauri; the app must be open for ticks to fire. A headless daemon - mode (launchd agent on macOS, systemd on Linux) is a follow-up. + mode (a launchd agent) is a follow-up. - **Cron expression editor UI.** v1 ships a text input with presets and a human-readable preview. A visual day/hour picker grid is a follow-up. @@ -141,9 +141,10 @@ same workspace UI. The only new concept is the trigger. 1. **In-process Tokio scheduler, not OS-level cron/launchd.** The app must be running to spawn PTYs (they're child processes of the Tauri backend). An OS-level trigger that launches the app on - schedule is attractive but adds platform-specific complexity - (launchd plist on macOS, Task Scheduler on Windows, systemd on - Linux) that doesn't justify itself in v1. The in-process + schedule is attractive but adds a whole install/uninstall + lifecycle (a launchd plist, its permissions, and the + app-not-running semantics) that doesn't justify itself in v1. + The in-process scheduler is ~100 lines of Rust and covers the "app is open all day" use case that cronjobs target. 2. **Skip on overlap, don't queue.** If a mission takes 2 hours and diff --git a/docs/features/archive/37-agent-runtime-executable-settings.md b/docs/features/37-agent-runtime-executable-settings.md similarity index 81% rename from docs/features/archive/37-agent-runtime-executable-settings.md rename to docs/features/37-agent-runtime-executable-settings.md index a64460ab..7f8d39e0 100644 --- a/docs/features/archive/37-agent-runtime-executable-settings.md +++ b/docs/features/37-agent-runtime-executable-settings.md @@ -13,7 +13,7 @@ Runtime-only direct chats have no configurable command, and the create/edit runn ### In scope -- **Agent runtimes settings pane.** Add an Agent runtimes pane under Settings → Integrations. Show one row/card for each first-class runtime returned by the backend registry, initially Claude Code and Codex. +- **Agents settings pane.** Add an Agents pane to the Integrations group in Settings, next to MCP. Show one row for each first-class runtime returned by the backend registry, initially Claude Code and Codex. - **Detected executable.** Resolve each runtime's catalog command against Runner's composed user `$PATH` and show the resulting absolute executable path, or a clear Not found / Detection failed state. - **Executable override.** Let the user enter or pick an absolute executable path per runtime. An empty override means automatic discovery. Validate that a non-empty path exists, is a regular file, and is executable before saving. - **Resolution precedence.** Use the explicit runtime override first, then the automatically detected executable, then the catalog command only when it can be resolved through the effective child `$PATH`. Do not silently report a configured runtime as available when none of those paths resolves. @@ -24,10 +24,11 @@ Runtime-only direct chats have no configurable command, and the create/edit runn - **Slow shell initialization.** Replace the current all-or-nothing two-second startup probe with a non-blocking or otherwise startup-safe discovery flow that accommodates realistic zsh/Oh My Zsh initialization. Preserve the last known good result on timeout and expose the timeout as a diagnosable state instead of silently dropping to launchd's stripped environment. - **Diagnostics.** Log the selected shell, discovery duration, success/failure reason, and resolved runtime executable paths without logging unrelated environment values. Surface enough status in Settings for a user to distinguish Not installed from Shell probe timed out. - **Backend persistence.** Store overrides in backend-owned app settings so all windows and all Rust spawn paths share the same value; do not make localStorage the source of truth for executable selection. -- **Design first.** Add the settings pane and its detected/override/error/refresh states to `design/runner-mvp-design.pen` before implementation. +- **Design first.** The pane and its states are designed in `design/runner-setting.pen`: frame `Settings — Agents` (node `Zes2l`) for the pane layout, and `Spec — Agent runtime row states` (node `cXdkp`) for the six row states (detected, override, not-found, checking, probe-timed-out, invalid-override). ### Out of scope +- User-defined custom runtimes (the registry-as-data extension in #279). Deliberately cut for now: built-ins only, so the pane stays simple. The backend registry shape should not preclude adding custom rows later. - Installing, upgrading, or authenticating Claude Code or Codex. - Accepting aliases or shell functions as runtime executables; Runner spawns a real process and requires an executable file. - General-purpose editing of the child process `$PATH`. @@ -39,7 +40,7 @@ Runtime-only direct chats have no configurable command, and the create/edit runn ### Phase 1 — UX design and settings contract -- Design Settings → Integrations → Agent runtimes in `design/runner-mvp-design.pen`, including detected, overridden, not-found, probing, timeout, validation-error, and refresh states. +- ~~Design the Agents pane~~ — done in `design/runner-setting.pen` (`Settings — Agents` + `Spec — Agent runtime row states`), covering detected, overridden, not-found, checking, probe-timed-out, invalid-override, and refresh states. - Define a backend runtime-settings shape keyed by stable runtime name with an optional executable override. - Define the effective-command precedence and legacy `runner.command` compatibility rules in tests before changing spawn behavior. @@ -74,21 +75,23 @@ Runtime-only direct chats have no configurable command, and the create/edit runn ## Verification -- [ ] Settings → Integrations → Agent runtimes shows Claude Code and Codex from the backend registry. +Unchecked items below require manual app verification. + +- [ ] Settings → Agents (Integrations group) shows Claude Code and Codex from the backend registry. - [ ] A standard executable on the captured login-shell `$PATH` is displayed as an absolute detected path. - [ ] A slow zsh/Oh My Zsh startup does not silently discard a previously valid `$PATH` after two seconds. -- [ ] Bash, zsh, and other explicitly supported shells use documented, tested startup semantics. +- [x] Bash, zsh, and other explicitly supported shells use documented, tested startup semantics. - [ ] Missing, invalid, or unsupported login shells produce a visible detection failure rather than a misleading Not installed state. - [ ] A user can refresh discovery after installing Codex without restarting Runner. -- [ ] A valid absolute override is persisted and used by runtime-only direct chats. -- [ ] The override is used by runner and mission spawns whose stored command is the runtime's catalog default. -- [ ] A runner with a custom non-default command continues using that command. -- [ ] Clearing an override returns the runtime to automatic discovery. +- [x] A valid absolute override is persisted and used by runtime-only direct chats. +- [x] The override is used by runner and mission spawns whose stored command is the runtime's catalog default. +- [x] A runner with a custom non-default command continues using that command. +- [x] Clearing an override returns the runtime to automatic discovery. - [ ] Nonexistent, non-file, and non-executable overrides are rejected with inline errors. -- [ ] Aliases and shell functions are not accepted as executable paths. -- [ ] A missing runtime fails before PTY spawn with actionable copy pointing to Agent runtimes settings. +- [x] Aliases and shell functions are not accepted as executable paths. +- [x] A missing runtime fails before PTY spawn with actionable copy pointing to Agents settings. - [ ] Discovery logs include shell, duration, outcome, and resolved executable without dumping the user's full environment. - [ ] Settings and effective command behavior remain consistent across multiple app windows. -- [ ] `pnpm exec tsc --noEmit` passes. -- [ ] `pnpm run lint` passes. -- [ ] Relevant Rust tests pass, followed by `cargo test --workspace` when implementation is complete. +- [x] `pnpm exec tsc --noEmit` passes. +- [x] `pnpm run lint` passes. +- [x] Relevant Rust tests pass, followed by `cargo test --workspace` when implementation is complete. diff --git a/docs/features/45-auto-resume-on-launch.md b/docs/features/45-auto-resume-on-launch.md new file mode 100644 index 00000000..ce799420 --- /dev/null +++ b/docs/features/45-auto-resume-on-launch.md @@ -0,0 +1,55 @@ +# 45 — Auto-resume running chats and missions on launch + +> Tracking issue: [#320](https://github.com/yicheng47/runner/issues/320) + +## Motivation + +Quitting the app kills every running agent (PTYs die with the process; `stop_running_sessions_on_quit` stops direct chats gracefully, startup demotes stale `running` rows). On next launch the user manually resumes each chat and mission they were working in — pure friction, since the app already knows how to resume everything: sessions persist `agent_session_key`, `session_resume` respawns into the prior conversation (impl 0024 keeps claude-code scrollback), and missions keep `status = running` across restarts (`mount_all_running_mission_routers` re-mounts their buses at startup — only their sessions are dead). + +The gap is memory plus initiative: nothing records *which* sessions were live at quit, and nothing acts on it at launch. + +## Scope + +### In scope + +- **Mark at quit.** `stop_running_sessions_on_quit` already enumerates running direct sessions; stamp them (`resume_on_launch` flag on the session row) before killing. The same pass marks running-slot sessions of running missions (they're demoted by startup cleanup today with no trace). A crash skips the stamp — see key decision 2. +- **Auto-resume at launch.** After the webview is ready, resume every marked session that is still resumable (`agent_session_key` present, not archived), clearing the flag as each is consumed. Missions need no extra start step: their status is still `running` and buses re-mount as today — resuming their marked slot sessions brings the workspace back to life. +- **Staggered spawns.** Resume sequentially with a short gap, not as one burst — N simultaneous PTY spawns + login-shell env snapshots is a stampede for no benefit. +- **Failure tolerance.** The existing resume-failure heuristic (fast death → `crashed` + warning toast, next launch starts fresh) already covers rejected `--resume` keys; auto-resume inherits it. A failed auto-resume must not block the rest of the queue. +- **Opt-in setting.** One toggle in Settings ("Resume running agents on launch", default off). Spawning agents unprompted at launch must be an explicit choice; there is no per-chat granularity in v1. + +### Out of scope + +- Restoring UI state beyond what already persists (sidebar tree, tab layouts, window geometry are all covered; the restored sessions simply light up their existing rows). +- Auto-resuming sessions the user stopped *manually* before quitting — stopped means stopped; only quit-time-running sessions are marked. +- Re-injecting prompts or auto-continuing agent work. Resume reopens the conversation; the agent stays idle until spoken to. +- Cross-device / sync anything. + +### Key decisions + +1. **Explicit flag, not timestamp inference.** Inferring "was running at quit" from `stopped_at` proximity to shutdown confuses deliberately-stopped chats with quit-killed ones. The quit hook knows exactly which rows it's killing; it should say so. +2. **Crash = no auto-resume.** The stamp lives in the graceful-quit path only. After a crash, sessions demote via startup cleanup as today and stay stopped — auto-respawning agents after a crash risks looping into whatever caused it. (If crash-restore is ever wanted, it's a separate, deliberate decision.) +3. **Resume, never fresh-spawn.** A marked session that lost its `agent_session_key` (or whose resume fails) stays stopped with the existing Resume affordance — auto-starting a *fresh* conversation the user didn't ask for is worse than doing nothing. +4. **Quit stamping is unconditional.** The backend does not read the frontend setting. Every graceful quit records the live set; the launch consumer decides whether to resume it. +5. **Toggle-off consumes without resuming.** A launch with auto-resume disabled clears every pending `resume_on_launch` stamp so turning the setting on later cannot resurrect work from an older quit. + +### Resolved product decisions + +- Stagger resume spawns by 300ms. +- Resume silently in v1; do not add a "Resuming N agents…" indicator. +- Put "Resume running agents on launch" in Settings → General under a new Startup section. It defaults off and persists in `localStorage` through the `src/lib/settings.ts` `STORAGE_*` pattern. The default flip also applies to existing users who never stored an explicit choice; there is no migration key. + +## Implementation phases + +1. **Schema + quit stamp** — `resume_on_launch` column (sessions), stamped in `stop_running_sessions_on_quit` for direct chats and running-mission slot sessions. +2. **Launch consumer** — post-ready sequential resume of marked resumable sessions via the existing `session_resume` path; flag cleared per session; Settings toggle gating the whole pass. +3. **Polish** — stagger tuning, resume indicator if decided in. + +## Verification + +- [ ] Turn the toggle on, quit with two running chats and a running mission → relaunch → all three come back live without interaction; scrollback intact for claude-code. +- [ ] A chat stopped manually before quit stays stopped after relaunch. +- [ ] Kill the app process (simulated crash) → nothing auto-resumes. +- [ ] A session with a rejected resume key surfaces the existing crash warning and doesn't block other resumes. +- [ ] Toggle absent or off → relaunch restores nothing and clears pending stamps; rows keep their normal Resume buttons; turning the toggle back on does not resume the old quit's set. +- [ ] `cargo fmt --check`, `cargo clippy --workspace`, `cargo test --workspace`, `pnpm exec tsc --noEmit`, `pnpm run lint` clean. diff --git a/docs/features/52-hook-based-session-status.md b/docs/features/52-hook-based-session-status.md new file mode 100644 index 00000000..db7d36ad --- /dev/null +++ b/docs/features/52-hook-based-session-status.md @@ -0,0 +1,41 @@ +# 52 — Hook-based session status + +> Tracking issue: [#347](https://github.com/yicheng47/runner/issues/347) + +## Motivation + +Session status today is a byte-flow heuristic: `IdleDetector` (`src-tauri/src/session/pty_runtime.rs:500`) flips Busy on any PTY byte and Idle after 750ms of silence. Both states are guesses, and the most valuable state for a human babysitting agents — *waiting on you* (permission prompt, question) — is not expressible at all: a permission prompt and an idle prompt are the same bytes-then-silence, so no threshold can separate them. TUI animation reads as Busy while the agent is actually blocked on the human; a quiet tool run can read as Idle mid-turn. + +Agent CLIs already know their own state and will say so: claude-code and codex both ship hook systems that fire on prompt submission, tool use, permission requests, and turn completion. Orca (source studied 2026-07-25) demonstrated the architecture: hooks as the authoritative status source, heuristics demoted to an explicitly tracked fallback. Runner adopts the signal but rejects Orca's installation model — Orca installs managed hook scripts into user CLI configs because its panes include remotes and user-launched shells; runner spawns every CLI itself and owns argv and env, so hooks can be scoped to the session with nothing installed. + +## Scope + +- A loopback HTTP receiver in the Tauri backend with a per-app bearer token; hook payloads POST to it, the handler fails open (a broken hook never blocks the agent), and receiver port + token ride the existing per-session env injection. +- Spawn-scoped hook injection, never config mutation: claude-code via `--settings ` (verified: merges additional settings on top of the user's) carrying a runner-generated hooks config; codex via its hooks system (verified live in codex-cli 0.145.0: `pre_tool_use`, `post_tool_use`, `permission_request`, `stop`; definitions in `$CODEX_HOME/hooks.json` gated by `features.hooks`), injected through a runner-managed `CODEX_HOME` mirror or `-c`/`--enable hooks` overrides — whichever spec-time verification supports. The user's `~/.claude` and `~/.codex` are never touched. +- Per-runtime normalizers mapping hook events to `working` / `waiting` / `done` (+ `interrupted`): claude-code `UserPromptSubmit`/`PreToolUse`/`PostToolUse` → working, `PermissionRequest` or AskUserQuestion `PreToolUse` → waiting, `Stop` → done; codex equivalents. +- The normalized model extends `RunnerStatus` (Busy/Idle) with Waiting and Done+interrupted, flows through the existing `session/status` event, and carries a confidence tier (`hook` vs `heuristic`). +- The byte-flow `IdleDetector` stays as the universal fallback tier: any runtime without a hook adapter (shells, future agent CLIs) gets Busy/Idle on day one with zero integration work, and a runtime whose hooks go quiet degrades to the heuristic via freshness decay instead of freezing on stale state. +- Consumers: `chatAttention` gains a needs-you tier above `working`/`unread`; sidebar rollups, mission router nudge gating (features 47/48), the blocked-inbox indicator (feature 50), and `mission_status` pending-asks can all read the richer states. + +## Out of scope + +- Mutating or managing the user's global CLI configs (Orca's managed-install model, relays, remote transports). +- Replacing the byte-flow detector. It is demoted, not deleted. +- OSC in-band status protocols and interrupt-keystroke inference (Orca's hardening layers) — candidate follow-ups, not v1. +- Per-CLI transcript parsing for status (JSONL reads stay enrichment-only if ever added). + +## Implementation phases + +1. **Receiver + model** — loopback server, token, normalized status states with confidence tiers, `session/status` wiring, freshness decay. +2. **claude-code adapter** — hooks config generated per spawn, delivered via `--settings`; normalizer + tests. +3. **codex adapter** — verify injection route (`CODEX_HOME` mirror vs `-c` overrides), normalizer + tests; qodercli inherits the claude-shaped adapter once #341 lands. +4. **Consumers** — needs-you attention tier in sidebar/chat surfaces; router gating reads `waiting`. + +## Verification + +- [ ] A claude-code permission prompt flips the session to `waiting` within a hook round-trip, with no byte-timing involvement; approving it returns `working`. +- [ ] `Stop` flips to `done`; Esc-interrupted turns carry `interrupted`. +- [ ] A shell session (no hooks) behaves exactly as today via the heuristic tier. +- [ ] Killing the receiver or breaking a hook script degrades status to the heuristic tier without blocking or slowing the agent. +- [ ] The user's `~/.claude/settings.json` and `~/.codex/config.toml` are byte-identical before and after a session using injected hooks. +- [ ] Sidebar shows the needs-you state for a hidden pane whose agent is waiting on approval. diff --git a/docs/features/53-session-fork.md b/docs/features/53-session-fork.md new file mode 100644 index 00000000..a477e211 --- /dev/null +++ b/docs/features/53-session-fork.md @@ -0,0 +1,42 @@ +# 53 — Session fork + +> Tracking issue: [#348](https://github.com/yicheng47/runner/issues/348) + +## Motivation + +There is no way to branch a conversation: take a chat that has accumulated valuable context and explore a different direction in parallel, without steering or destroying the original. Orca ships this as "Fork Agent Session" (source studied 2026-07-25): serialize the last 800 lines of xterm scrollback, strip ANSI and trim to a 36,000-char newest-first budget, wrap it in a fenced handoff prompt, create a `-fork` worktree, and launch a fresh agent with the handoff as an editable unsubmitted first-turn draft. No system prompt and no CLI session flags are involved. + +Runner can do strictly better for claude-code: we already persist `agent_session_key` (impl 0017 for codex; claude keys captured at spawn), so a native full-history fork via `claude --resume --fork-session` needs no lossy capture at all. Orca structurally cannot do this — it drops provider session IDs during hook normalization. + +## Scope + +- **Native fork tier (claude-code, preferred):** a "Fork chat" action spawns a new direct chat with the same runner and cwd, launched with `--resume --fork-session` — full conversation history, new session identity, original untouched. +- **Context fork tier (generic fallback):** for runtimes without a native fork path, build a bounded, ANSI-cleaned transcript server-side from the output ring (no xterm serialization needed), wrap it in a handoff preamble ("this is a fork of an existing session — acknowledge and wait"), and deliver it as an unsubmitted first-turn draft via the existing spawn-time prompt delivery plumbing (impls 0005/0007; bracketed paste without Enter). +- UX entry points: chat header kebab and sidebar chat context menu → new chat appears beside the original. +- Capture bounds for the fallback tier follow Orca's proven envelope as a starting point: newest-first retention with an explicit omission marker, fenced so transcript backticks cannot break the prompt. + +## Out of scope + +- Missions and mission slots (v1 is direct chats only). +- Worktree coupling. Runner chats are not worktree-bound; an optional different-cwd picker at fork time is a spec-level open question, not v1. +- Summarization or structured transcript parsing for the fallback tier — the handoff is a verbatim bounded transcript. +- Cross-runtime forks (fork a claude chat into a codex chat). + +## Open questions + +- Does codex resume-as-a-second-instance fork cleanly, or does it contend for the same session file? Decides whether codex gets the native tier or the context tier. +- Per-runtime draft-delivery behavior: paste-without-submit must land in the input box (not auto-execute) in each TUI. +- Fallback capture bounds: adopt Orca's 36k-char budget or size to runner's ring retention. + +## Implementation phases + +1. **Native claude-code fork** — chat row creation + fork spawn flags + UX entry points. +2. **Context fork from the ring** — transcript cleanup/bounding helpers, handoff preamble, draft delivery for non-native runtimes. +3. **codex native-fork investigation** — promote codex to the native tier if resume-fork proves safe; qodercli after #341. + +## Verification + +- [ ] Forking a claude-code chat produces a new chat whose agent recalls full pre-fork context; the original session continues independently with its own key. +- [ ] The forked chat's first turn is an editable draft, not an auto-submitted message. +- [ ] Context-fork fallback produces a cleaned, bounded transcript with an omission marker when history exceeds the budget, and the fenced block survives transcripts containing backticks. +- [ ] Forking never writes to or mutates the source session's ring, key, or process. diff --git a/docs/features/56-backend-list-pagination.md b/docs/features/56-backend-list-pagination.md new file mode 100644 index 00000000..3c56be2f --- /dev/null +++ b/docs/features/56-backend-list-pagination.md @@ -0,0 +1,40 @@ +# 56 — Backend pagination for Runners and Crews, honest bottom pager + +Tracking: [#377](https://github.com/yicheng47/runner/issues/377) + +Design: `design/runner-crew-list-search.pen` — the "56" row below the Crews frames: frame `csK4O` ("Runners — 56 · bottom pager, honest scroll" — pager stays bottom-pinned behind a 1px hairline aligned with the sidebar footer's divider, list region above scrolls with a visible scrollbar, a card cut at the fold reads as scrollable rather than hidden) plus deltas note `nmAx8`. The single-page state uses the same footer with page 1 selected and both arrows disabled; it has no separate frame. `cmp/Pager` itself is unchanged. + +## Motivation + +Two problems on the Runners/Crews list pages (direction set after an earlier scrolling-list draft — pagination stays, per product call): + +1. **The bottom band reads as a block hiding rows.** The card list is `min-h-0 flex-1 overflow-y-auto` with a hidden scrollbar, so it claims all remaining viewport height and clips the trailing card mid-border; the `Pager` is then pushed below it by `mt-auto` + `pt-3` on top of the page's `pb-8`. The clipped card sliver directly above a thick dark band with no separating edge makes it look like runners are hidden behind the pagination block. +2. **Pagination is client-side slicing.** `runner_list_with_activity` / the crews list return every row; `useListControls` filters and slices in the frontend. Fine at 6 runners, but it means search and paging semantics live in the wrong layer, and every list render pays for the full table. + +## Scope + +- **Backend pagination.** The list commands gain `page`, `page_size`, and `query` parameters and return `{ items, total_count, filtered_count }`, with `LIMIT`/`OFFSET` and the search filter applied in SQL. Search must move with pagination: filtering the current page client-side would silently drop matches on other pages. Runner search covers handle and display name only, so matches always correspond to visible identity text instead of hidden command/config fields. Crew search covers the fields its empty-state copy advertises: name, purpose, goal, system prompt, slot handle, runner handle, and runtime. +- **Honest bottom pager.** The pager stays bottom-pinned and centered exactly where it is today, including the single-page state so the footer and its aligned divider remain visible. A 1px `$border` hairline separates it from the list, so a card cut at the fold reads as a scrollable region above a fixed footer rather than content masked by a black block — the hairline aligns to the same y as the sidebar footer's divider (the Settings row's top border), so the two read as one continuous line. The list column scrolls with a **visible** auto-hiding scrollbar instead of the current hidden one. +- **Frontend plumbing.** `useListControls` becomes the IPC driver: debounced query, page state, clamp-on-shrink (deleting the last row of the last page steps back a page). Counter stays `N of M` from `filtered_count` / `total_count`. +- **Drop the `esc` keycap hint from the search field.** The badge is noise; Esc keeps its clear-the-query behavior, and the `×` clear button remains the visible affordance. +- Runners and Crews get identical pagination and list-layout treatment; their search fields follow the visible identity/context copy documented above. + +## To Be Decided + +- Viewport-adaptive `page_size` (fit as many whole cards as the window allows) vs. fixed 8: adaptive kills the short-window scroll case entirely but adds resize→refetch churn. Spec assumes fixed 8 until dogfooding says otherwise. +- Whether `runner_list` (bare, non-activity) keeps its unpaginated shape for internal callers — assumed yes; only the two list-page commands paginate. + +## Implementation Phases + +1. **Backend** — paginated query for runners-with-activity and crews (SQL `LIKE` filter + `LIMIT`/`OFFSET` + count queries in one command), unit tests for filter coverage, paging math, and the empty-page clamp. +2. **Frontend** — `useListControls` drives the IPC (debounce, page clamp); Runners/Crews render returned items; pager stays visible for non-empty lists and gains the hairline separator; visible auto-hiding scrollbar on the list column. +3. **Validation** — `cargo test`, `tsc`, lint, vitest; manual pass below. + +## Verification + +- [ ] Six runners, default window: all six visible or reachable with no half-clipped card; the page-1 pager and aligned hairline remain visible with both arrows disabled and no thick dead band above the window bottom. +- [ ] More than 8 runners: pager appears bottom-pinned behind its hairline; a card cut at the fold shows a visible scrollbar above the separator; page 2 shows the remainder; deleting the last row of page 2 steps back to page 1. +- [ ] Search narrows across *all* pages (a match on what was page 2 appears while page 1 is open), counter reads `filtered of total`. +- [ ] Short window: full page of cards scrolls with a visible scrollbar; nothing renders below the pager but page padding. +- [ ] The pager hairline sits at the same y as the sidebar footer's divider (one continuous line across the window); the search field shows no `esc` keycap and Esc still clears the query. +- [ ] Crews page behaves identically. diff --git a/docs/features/57-start-project-modal.md b/docs/features/57-start-project-modal.md new file mode 100644 index 00000000..dc7366a3 --- /dev/null +++ b/docs/features/57-start-project-modal.md @@ -0,0 +1,33 @@ +# Start Project modal + +Tracking issue: [#383](https://github.com/yicheng47/runner/issues/383). Status: planned. + +## Motivation + +The sidebar's "Add project" (+) jumps straight into a native directory picker and silently creates the project (`Sidebar.tsx` `addProject`): name is hardcoded to the directory basename, and the picker starts wherever macOS last left it. There is no chance to set the name at creation time (rename-after is the only path), the configured default working directory (`settings.defaultWorkingDir`) is ignored, and the flow is asymmetric with its sibling actions — starting a chat or a mission both open a modal (`StartChatModal`, `StartMissionModal`). Project creation should mirror them. + +## Scope + +A `StartProjectModal` component mirroring the sibling start modals (same `Modal` shell, buttons, field styling), opened by the sidebar's Add project (+) in place of the direct picker call. + +Fields: + +- **Directory** — text input plus a Browse button that opens the native picker (`openDialog({ directory: true, defaultPath })`). Prefilled with `readDefaultWorkingDir()` when the setting is non-empty, else empty. Browse starts from the current field value, falling back to the default working dir. +- **Name** — prefilled with the basename of the directory field and follows directory changes (pick a new folder → name updates to its basename) until the user edits the name manually; after a manual edit the name sticks. + +Create calls the existing `api.project.create(name, cwd)` and keeps today's post-create behavior: Projects section opens, the new project becomes active, the tree refreshes. Create is disabled while either field is empty. Enter submits, Esc closes — same keyboard contract as the sibling modals. + +## Non-Goals + +- Backend changes — `project_create` ships as-is; no directory-exists or duplicate checks beyond what it already does. +- Any change to project rename or set-cwd flows. + +## Implementation Notes + +- `src/components/StartProjectModal.tsx` — new component; `src/components/Sidebar.tsx` `addProject` becomes "open modal", post-create logic moves into the modal's `onCreated` callback. +- Naming collision: `StartProjectModal.test.tsx` already exists but tests project-*scoped* chat/mission modals. Rename that file (e.g. `projectScopedStartModals.test.tsx`) so the new component's test can take the canonical name. + +## Verification + +- Vitest: name follows directory until manually edited, then sticks; prefill from `readDefaultWorkingDir()`; create disabled on empty fields. +- Manual: + opens the modal with defaults populated; Browse starts at the default working dir; created project lands selected in an open Projects section. diff --git a/docs/features/58-runner-crew-detail-redesign.md b/docs/features/58-runner-crew-detail-redesign.md new file mode 100644 index 00000000..46fd1a6f --- /dev/null +++ b/docs/features/58-runner-crew-detail-redesign.md @@ -0,0 +1,40 @@ +# Runner & crew detail redesign + +Tracking issue: [#393](https://github.com/yicheng47/runner/issues/393). Status: planned. + +## Motivation + +Both detail pages are MVP drafts implemented straight from the historical MVP canvas (`design/runner-mvp-design.pen`, frames `ocAFJ` and `CUKjM`) and both bury their primary content under prompt prose. + +**Crew detail** (`src/pages/CrewEditor.tsx`, `/crews/:crewId`): the slot roster — who is actually in this crew — renders last, below the Purpose, Default goal, and Team conventions sections. With real prose in those sections the slots land below the fold; the page reads as a text editor with a roster appendix. Each `SlotRow` also packs handle, LEAD badge, runtime select, model-override chip, source-runner attribution, a line-clamped system-prompt preview, and the effective command line into one dense row, so the load-bearing facts (who, which engine, who leads) don't pop. + +**Runner detail** (`src/pages/RunnerDetail.tsx`, `/runners/:handle`): the full default system prompt renders as an unbounded `
` dump at the top of the two-thirds column. Any real prompt is hundreds of lines, pushing "Crews using this runner" and the working-dir info far below the fold. The "Chat now" card duplicates the header's Chat now button and exists mostly to display the working directory; identity (display name) floats as a lone paragraph under the breadcrumb.
+
+## Scope
+
+Presentation-only redesign of the two pages. Same data, same commands, no backend or API changes.
+
+Design first, in Pencil, per repo convention: new feature-scoped file (e.g. `design/58-runner-crew-detail.pen`) with one frame per page; review before any code. The MVP canvas stays untouched as the historical record.
+
+Direction to explore in the design pass (not binding until frames are approved):
+
+- **Crew detail**: slots become the hero, directly under the header/toolbar. Prose config (purpose, goal, conventions) demoted to a secondary presentation — collapsed sections, a side column, or a config tab — collapsed by default when content is long. Slot rows restructured so identity and role read at a glance (avatar/handle/LEAD first-class), with runtime/model overrides and command detail tucked behind hover or disclosure affordances. Keep drag-reorder, set-lead, override editing, and remove flows functionally intact.
+- **Runner detail**: identity block (handle, display name, runtime, avatar) plus activity as the hero. System prompt shown clamped with expand/collapse instead of a full dump. Consolidate the "Chat now" card into the header action + a Details row for working dir. "Crews using this runner" stays one glance away.
+
+## Non-Goals
+
+- New capabilities on either page (no new fields, no new slot operations).
+- Changes to `RunnerEditDrawer`, `AddSlotModal`, or `StartMissionModal` beyond what the new layouts require for visual consistency.
+- List pages (`Runners.tsx`, `Crews.tsx`) — separate surfaces, already covered by feature 56.
+
+## Implementation Phases
+
+1. **Design** — Pencil frames for both pages in a feature-scoped `.pen`; iterate with the user until approved.
+2. **Crew detail** — reorder sections (slots first), restructure `SlotRow` per the approved frame, keep reorder/lead/override/remove behaviors and their tests green.
+3. **Runner detail** — new layout: identity hero, clamped prompt block, card consolidation.
+
+## Verification
+
+- `pnpm exec tsc --noEmit` and `pnpm run lint` clean after each phase.
+- Existing Vitest suites (`CrewEditor.test.tsx`, `RunnerRuntimeModelReset.test.tsx`) still pass; extend where row restructuring moves behavior.
+- Manual pass with a worst-case fixture: a runner with a multi-hundred-line system prompt, a crew with 5+ slots and long purpose/goal/conventions prose — slots visible without scrolling on the crew page, prompt collapsed by default on the runner page.
diff --git a/docs/features/59-slot-model-effort-overrides.md b/docs/features/59-slot-model-effort-overrides.md
new file mode 100644
index 00000000..aaf22bc3
--- /dev/null
+++ b/docs/features/59-slot-model-effort-overrides.md
@@ -0,0 +1,34 @@
+# Per-slot model and effort overrides
+
+Tracking issue: [#397](https://github.com/yicheng47/runner/issues/397). Status: planned.
+
+## Motivation
+
+Slot-level agent customization is incomplete and asymmetric. A runner template pins `model` and `effort`; a direct chat can override both at start (impl 0043 companion work); but a crew slot can only override the runtime, and the model chip is gated on that: `SlotRuntimeSelect` must be set before `SlotModelOverrideEditor` even renders (`CrewEditor.tsx`), and the backend enforces the same gate — `resolve_runtime_override` (`src-tauri/src/session/manager/mod.rs:1214`) early-returns when `runtime_override` is empty, and the mission spawn hardcodes `None` for effort (`src-tauri/src/session/manager/spawn.rs:248`). Net effect: to give one slot a different model you must first "override" the runtime (possibly to the same engine), and no slot can ever run at a different effort than its template. The natural mental model — a slot describes the complete agent configuration for that seat, with the runner as the default — breaks exactly at model/effort.
+
+## Scope
+
+Make model and effort independently overridable per slot, with the runner template as the fallback at every level.
+
+- **Schema**: add `effort_override TEXT NULL` to `slots`; expose on `Slot`/`SlotWithRunner` (`src/lib/types.ts`), `slot_update`, and the `slot_update` MCP tool alongside `model_override`.
+- **Resolution**: rework `resolve_runtime_override` so model/effort overrides apply without a runtime override — effective config = runner template, then runtime override (which resets model/effort to the new engine's defaults, as today), then model/effort overrides on top. `pinned` semantics unchanged: only a set `runtime_override` pins the session's engine; model/effort-only overrides don't pin.
+- **Spawn**: mission spawn passes `slot.effort_override`; `model_effort_args` already emits per-runtime flags and ignores what a runtime doesn't support.
+- **UI** (`CrewEditor.tsx`): all three overrides — agent, model, effort — live in the slot's edit drawer (⋯ → Edit runner) so there is one editing surface; slot rows show a static effective-engine badge (accent when overridden) so the roster stays compact and glanceable. In the drawer, the Agent select offers "Runner default (engine)" plus each detected agent — an explicit pick pins the slot's engine; model uses `ModelField` with the effective runtime's catalog, and effort mirrors the chat-level override control (runner/runtime default plus the runtime's effort steps). Clearing the runtime override keeps model/effort overrides only if they are valid for the runner's own runtime; on engine change, model resets (today's behavior) and effort resets with it.
+
+## Non-Goals
+
+- Per-slot system-prompt, env, args, or permission-mode overrides — the runner template stays the home for persona and engine config.
+- Per-mission (as opposed to per-slot) agent overrides.
+- Changing direct-chat override behavior or the runner editor.
+
+## Implementation Phases
+
+1. **Backend** — migration, repo/commands/MCP plumbing for `effort_override`, resolver rework + spawn wiring, rust tests (model-only override without runtime override; effort flows to spawn args; engine-change reset semantics; pinning unchanged).
+2. **Frontend** — move per-slot model and effort controls into the edit drawer, keep slot rows compact, preserve reset-on-engine-change behavior, and add vitest coverage (extend `RunnerRuntimeModelReset.test.tsx` patterns).
+3. **Docs** — refresh feature 41's archived assumptions if referenced by arch docs.
+
+## Verification
+
+- `cargo test --workspace`: resolver and spawn-arg tests above.
+- Vitest: row selectors absent, drawer model/effort round-trip, override reset on runtime change.
+- Manual: crew with one runner template and two slots — slot A default, slot B model+effort overridden, no runtime override; mission spawn shows the flag difference in the PTY command lines; overriding runtime resets both; clearing runtime override restores template inheritance.
diff --git a/docs/features/60-fork-chat-to-pane-or-tab.md b/docs/features/60-fork-chat-to-pane-or-tab.md
new file mode 100644
index 00000000..fb85c761
--- /dev/null
+++ b/docs/features/60-fork-chat-to-pane-or-tab.md
@@ -0,0 +1,38 @@
+# Fork a chat into a split pane or a new tab
+
+Tracking issue: [#398](https://github.com/yicheng47/runner/issues/398). Status: planned. Supersedes the native tier of [53 — Session fork](./53-session-fork.md) (closed won't-do in [#348](https://github.com/yicheng47/runner/issues/348); its revisit trigger — actually reaching for a fork in real work — has now fired).
+
+## Motivation
+
+A chat that has accumulated valuable context can't be branched: exploring a second direction means steering the original or losing the context. #348 declined to build this because nobody had reached for it and because the generic transcript-capture tier was lossy per-runtime machinery; the close-out explicitly reserved the native tier as "small and worth building" once someone actually wanted a fork. That happened. What's new in this spec versus 53 is the destination: the fork should land where the comparison is useful — beside the original as a split pane in the current tab, or out of the way in a new tab.
+
+Orca reference (source re-checked at `~/repos/gui/orca`, `terminal-agent-session-fork.ts`): its "Fork Agent Session…" always creates a new worktree workspace and launches the forked agent in a new tab there, delivering an 800-line ANSI-stripped scrollback capture as an editable draft; a sibling "Copy context" puts the bare transcript on the clipboard. Two things carry over: fork never mutates the source pane, and the forked turn arrives as a draft, not an auto-submitted message. Two things don't: the lossy capture (Runner has persisted `agent_session_key`, so claude-code forks natively with full history) and the single fixed destination (Runner's pane-layout system supports an in-tab split).
+
+## Scope
+
+- **Native fork only.** Fork spawns a new direct chat with the same runner, cwd, model/effort as the source session, launched via the runtime's native fork flags (claude-code: `--resume  --fork-session`). Full history, new session identity, source session untouched. No transcript-capture fallback tier — #348 refused that machinery and this spec keeps refusing it.
+- **Two destinations, chosen at the entry point** ("Fork to split pane" / "Fork to new tab"):
+  - *Split pane*: the current tab's layout grows by one pane (same mechanism as the pane-level New chat affordance) and the forked session lands in it, focused, beside the source.
+  - *New tab*: a fresh single-pane tab in the same window (the ⌘T shape) holding the forked session; the source tab stays as-is.
+- **Entry points**: chat pane header kebab and the sidebar chat context menu. Both actions disabled with an explanatory tooltip when the session's runtime has no native fork path or no `agent_session_key` has been captured yet.
+- **Naming**: forked chat titles derive from the source (" (fork)"), editable like any chat.
+
+## Non-Goals
+
+- The lossy transcript/context tier for non-native runtimes, including Orca-style "Copy context" affordances.
+- Missions and mission slots — direct chats only.
+- Cross-runtime forks and fork-time cwd/worktree pickers (chats are not worktree-bound).
+
+## Implementation Phases
+
+1. **Backend fork spawn** — a fork variant of the direct-chat spawn: new session row cloning runner/cwd/model/effort from the source row, resume plan composed with the fork flag, fork-eligibility exposed on the session (runtime capability + key presence).
+2. **Destinations + entry points** — pane-kebab and sidebar context-menu actions; split-pane destination through the pane-layout grow path, new-tab destination through the tab-creation path; disabled-state tooltips.
+3. **codex investigation** — promote codex to native fork if a second `codex resume` instance forks cleanly without contending for the source session file (open question inherited from 53).
+
+## Verification
+
+- Forking a claude-code chat yields a new session whose agent recalls pre-fork context in full; the source session keeps its own key and continues independently.
+- Split destination: layout gains a pane beside the source with the fork focused; tab destination: new tab holds the fork, source tab unchanged.
+- Fork actions disabled (with tooltip) for a runtime without native fork and for a session with no captured key.
+- Forking never writes to the source session's ring, key, or process.
+- `cargo test --workspace` for spawn-plan composition; vitest for entry-point state; manual pass for both destinations.
diff --git a/docs/features/61-mission-worktree-isolation.md b/docs/features/61-mission-worktree-isolation.md
new file mode 100644
index 00000000..9cb5f7d1
--- /dev/null
+++ b/docs/features/61-mission-worktree-isolation.md
@@ -0,0 +1,38 @@
+# Opt-in worktree isolation per mission
+
+Tracking issue: [#403](https://github.com/yicheng47/runner/issues/403). Status: planned.
+
+## Motivation
+
+Every mission on a project runs in the same checkout. Two missions fanned out on one repo step on each other's edits, and any mission steps on the human's working tree — the operator can't safely keep editing while a crew works. The integration point for fixing this already exists: `mission.cwd` is the highest-priority working directory at spawn (`src-tauri/src/session/manager/spawn.rs:261` resolves mission cwd → runner `working_dir` → inherit), and the resume-stability comment beside it (`spawn.rs:265`) already demands that a mission's cwd never change across respawns — which is exactly what a worktree created once at mission start provides. Isolation is therefore a mission-start decorator: create the worktree, point `mission.cwd` at it, and spawn, resume, sessions, and the event log need zero changes. It also becomes the injection point for later per-runner skill materialization (spec 05's per-agent skill dirs want a session-scoped tree to write into).
+
+Worktree use is opt-in per mission. Most missions are one crew on one repo with no concurrent sibling; the default stays the project checkout so the solo flow is untouched.
+
+## Scope
+
+- **Schema**: migration `0021_mission_worktree.sql` adds `worktree_branch TEXT NULL` to `missions`; expose on `Mission` (`src-tauri/src/model.rs`, `src/lib/types.ts`). A set `worktree_branch` marks `cwd` as app-owned — the discriminator that lets cleanup act only on directories runner created, never on a user-typed cwd.
+- **Creation** (`mission_start`, `src-tauri/src/commands/mission.rs`): when the caller opts in and the resolved project cwd is a git repo, run `git worktree prune` (heals manually deleted trees), then `git worktree add <repo>/.worktrees/<short-id>-<slug> -b mission/<short-id>-<slug> HEAD`, append `.worktrees/` to `.git/info/exclude` if absent (not `.gitignore` — don't dirty the user's tree), and set `mission.cwd` to the worktree path before slots spawn. The short id comes from the mission id, killing title collisions. A failed `worktree add` fails mission start with the git stderr — no partial missions in an ambiguous cwd.
+- **Sharing**: all slots run in the one mission worktree. Crew coordination happens through signals in a shared tree; that stays the model.
+- **UI** (`src/components/StartMissionModal.tsx`): a "Run in isolated worktree" toggle, default off, disabled with a hint when the selected project cwd is not a git repo. The mission workspace header (`src/pages/MissionWorkspace.tsx`) shows a branch chip (`mission/<short-id>-<slug>`) so the operator always knows where the crew is writing.
+- **Cleanup**: `mission_archive` on a worktree mission offers removal via `git worktree remove` — which refuses a dirty tree by default, so unmerged agent work is never destroyed silently; a dirty tree archives with the worktree left in place and the UI says so. The branch always survives; merge-back is manual in v1 (the chip tells the human what to PR).
+- **MCP**: `mission_start` (`src-tauri/src/mcp/tools/mission.rs`) grows an optional `worktree: bool`, same semantics as the modal toggle.
+
+## Non-Goals
+
+- Per-slot worktrees — one tree per mission; adversarial parallel-attempt patterns can revisit later.
+- Auto-merge, PR creation, or any merge-back automation.
+- Base-ref picker — v1 branches from repo HEAD at mission start.
+- Worktree support for non-git projects or a per-project "always isolate" default (revisit once the toggle has usage).
+- Migrating existing missions; the column is null for all history.
+
+## Implementation Phases
+
+1. **Backend** — migration, `worktree_branch` plumbing through repo/model/commands, creation + prune in `mission_start`, archive-time removal path, MCP flag; rust tests (worktree created and cwd points into it; branch naming; non-git opt-in rejected; dirty-tree removal refused and archive still succeeds; prune heals a deleted tree).
+2. **Frontend** — start-modal toggle with git-repo gating, branch chip in the mission header, archive prompt copy for the dirty-tree case; vitest coverage alongside `projectScopedStartModals.test.tsx`.
+3. **Docs** — README index entry; note the app-owned-cwd discriminator in the arch docs if session docs reference cwd resolution.
+
+## Verification
+
+- `cargo test --workspace`: the backend tests above.
+- Vitest: toggle renders only for git projects, start payload carries the flag, branch chip renders from `worktree_branch`.
+- Manual: start two missions on the same project, one isolated — `git worktree list` shows the mission tree, sessions' PTYs land in `.worktrees/<slug>`, the other mission and the human's checkout stay untouched; archive the clean one (worktree removed, branch remains), dirty the other and archive (worktree left, UI states why); `--resume` a worktree mission's session after app restart and confirm it resumes against the same cwd.
diff --git a/docs/features/README.md b/docs/features/README.md
index 159ee7cf..51e9e469 100644
--- a/docs/features/README.md
+++ b/docs/features/README.md
@@ -8,18 +8,55 @@ implementation is the source of truth, but the spec stays around as the
 Tracking lives in GitHub Issues with the `feature` label. Each spec
 links to its tracking issue.
 
+This branch is parity-only (gpui rewrite — see [the program plan](../impls/gpui-rewrite/plan.md)): the specs below are `main`'s feature pipeline, synced 2026-08-18; nothing new ships here before cutover.
+
 ## Index
 
-Empty by design. The rewrite is parity-only (impl 0031's non-goals):
-no new feature surface until cutover. New specs land here after the
-Phase 6 rebrand.
+- [05 — Agent-agnostic MCP & skills management](./05-runner-skills.md) —
+  one central catalog of MCP servers and skills, stored in a neutral
+  shape and materialized per agent (claude-code JSON, codex TOML,
+  skill dirs); informed by the skills-manager reference analysis.
+- [37 — Agent runtime executable settings](./37-agent-runtime-executable-settings.md) — detect and display Claude Code/Codex executables from the user's login-shell environment, fix slow shell initialization failures, and provide explicit per-runtime path overrides.
+- [45 — Auto-resume on launch](./45-auto-resume-on-launch.md) — stamp quit-killed running chats and mission-slot sessions with `resume_on_launch`, then auto-resume them (staggered, resume-only, settings-gated) on next open; crash path never stamps.
+- [52 — Hook-based session status](./52-hook-based-session-status.md) — authoritative `working`/`waiting`/`done` status from agent CLI hooks injected per spawn (claude `--settings`, codex hooks.json — never the user's config), with the byte-flow IdleDetector demoted to a universal fallback tier; adds the needs-you attention state.
+- [60 — Fork a chat into a split pane or a new tab](./60-fork-chat-to-pane-or-tab.md) — native-only session fork (claude-code `--resume <key> --fork-session`) with a destination choice: split pane beside the source in the current tab, or a new tab; supersedes 53's native tier after #348's revisit trigger fired ([#398](https://github.com/yicheng47/runner/issues/398)).
+- [56 — Backend list pagination](./56-backend-list-pagination.md) — Runners/Crews pagination moves into SQL (`page`/`page_size`/`query` with LIMIT/OFFSET and the search filter server-side); the pager becomes one slim row flush under the cards, hidden at a single page, with no half-clipped card above it ([#377](https://github.com/yicheng47/runner/issues/377)).
+- [57 — Start Project modal](./57-start-project-modal.md) — replace the bare directory-picker "Add project" flow with a modal mirroring the chat/mission start modals: directory field prefilled from `settings.defaultWorkingDir`, name field defaulting to the directory basename and following it until manually edited ([#383](https://github.com/yicheng47/runner/issues/383)).
+- [58 — Runner & crew detail redesign](./58-runner-crew-detail-redesign.md) — Pencil-first redesign of both MVP-draft detail pages: crew detail puts the slot roster above the prose config sections instead of below them, runner detail clamps the system-prompt dump and consolidates redundant cards ([#393](https://github.com/yicheng47/runner/issues/393)).
+- [59 — Per-slot model and effort overrides](./59-slot-model-effort-overrides.md) — complete the slot-as-agent-config model: model and effort overridable per slot without requiring a runtime override first (new `effort_override` column, resolver rework, ungated chips in the crew editor) ([#397](https://github.com/yicheng47/runner/issues/397)).
+- [61 — Opt-in worktree isolation per mission](./61-mission-worktree-isolation.md) — mission-start decorator: opted-in missions get `git worktree add <repo>/.worktrees/… -b mission/<short-id>-<slug>` and `mission.cwd` points into it, so concurrent crews and the human's checkout never collide; default stays the project checkout, all slots share the tree, archive offers safe (`git worktree remove`, dirty-refusing) cleanup ([#403](https://github.com/yicheng47/runner/issues/403)).
 
 ## Archive
 
 Shipped specs live in [`archive/`](./archive/), in spec-number order.
 See the directory listing for what's there.
 
-Exception to the "shipped" rule: specs 05, 19, 21, 24, and 37 were
-archived **unshipped** at the 2026-07-19 repo split — open Tauri-line
-ideas that are frozen during the parity-only rewrite. Their tracking
-issues live in the `runner` repo; revive by moving the spec back here.
+## Dropped
+
+Considered and deliberately not built. Spec kept in-repo as the record.
+
+- [19 — Mission split view](./19-mission-split-view.md)
+  — closed as won't-do ([#255](https://github.com/yicheng47/runner/issues/255)):
+  crew missions coordinate turn-based, so side-by-side slot PTYs mostly
+  show one busy terminal next to an idle one; the feed + per-runner tabs
+  cover monitoring, and split view already exists for direct chats.
+- [21 — Import native agent sessions into a project](./21-import-native-sessions.md)
+  — closed as won't-do ([#176](https://github.com/yicheng47/runner/issues/176)):
+  the CLIs' own resume pickers (`claude --resume` / `codex resume` from a
+  pane in the project cwd) cover the core need, so the native-store import
+  machinery wasn't worth its maintenance surface.
+- [24 — Cronjobs](./24-cronjobs.md)
+  — closed as won't-do ([#193](https://github.com/yicheng47/runner/issues/193)):
+  a resident scheduler (overlap, catch-up, timeouts, wake correctness)
+  is always-on machinery inside an app whose identity is a cockpit you
+  open to work in, and `mission_start` over MCP/CLI already lets any
+  external scheduler fire missions on cron with zero app code. Revisit
+  only if the same mission goal keeps getting launched manually on a
+  rhythm.
+- [53 — Session fork](./53-session-fork.md)
+  — closed as won't-do ([#348](https://github.com/yicheng47/runner/issues/348)):
+  months of daily use produced zero fork reaches, and the generic
+  transcript-handoff tier was exactly the lossy per-runtime capture
+  machinery the simplicity budget keeps refusing. The revisit trigger
+  fired in 2026-08; the native tier returns, with destinations, as
+  spec 60 ([#398](https://github.com/yicheng47/runner/issues/398)).
diff --git a/docs/features/archive/43-sidebar-pinned-section.md b/docs/features/archive/43-sidebar-pinned-section.md
new file mode 100644
index 00000000..fb00df59
--- /dev/null
+++ b/docs/features/archive/43-sidebar-pinned-section.md
@@ -0,0 +1,46 @@
+# 43 — Sidebar pinned section
+
+> Tracking issue: [#317](https://github.com/yicheng47/runner/issues/317)
+
+## Motivation
+
+Pinned chat tabs and pinned missions need one predictable home. PINNED is a derived view over the node tree that collects the things a user wants to keep at hand without changing their project or root membership.
+
+This spec reflects the node-tree model shipped in #318 and supersedes the pre-remodel version of this document. `nodes.pinned_position` is non-NULL for pinned nodes and orders the global PINNED section; pinning remains an overlay on the node's existing `parent_id`.
+
+## Scope
+
+- Render a PINNED section at the top of the sidebar nav containing every pinned tab and mission, whether its original parent is a project or root, ordered by `pinned_position`.
+- Hide PINNED entirely when no nodes are pinned.
+- Remove pinned rows from their origin project or root scopes. Origin scopes render only unpinned rows in `position` order; the interim pinned-first ordering inside each container is retired.
+- Preserve each row's type-specific rendering, click target, context menu, pin action, and attention state.
+- Support drag-to-reorder inside PINNED through `node_reorder_pinned(ordered_ids)`. The payload must contain every currently pinned node exactly once, and the backend rewrites `pinned_position` to match it.
+- While a pinned row is dragged, other pinned rows act as reorder positions rather than project containers or parent-scope targets.
+- Keep pin and unpin on the context menu. Dragging into or out of PINNED does not pin, unpin, or reparent a node.
+- Keep `parent_id` unchanged while a node is pinned and treat `position` as dormant. Unpinning appends the node to the end of its original parent scope by rewriting `position` to the visible scope's maximum plus one.
+- Make every parent-scope reorder and complete-set validation operate on unpinned members only. This includes the full-root payload used by project reorder, so a pinned root leaf neither invalidates nor participates in a root project reorder.
+
+## Out of scope
+
+- Pinning or unpinning by dragging into or out of PINNED.
+- Changing `parent_id` or the meaning of parent-scoped `position` while pinning.
+- Merging mission and tab domain models.
+
+## Implementation
+
+1. Partition resolved pinned rows into a global PINNED view and omit them from project/root views.
+2. Add the repository operation, Tauri command, and frontend API for `node_reorder_pinned` with complete pinned-set validation.
+3. Reuse the sidebar reorder-position drag pattern for PINNED and submit the complete pinned order.
+4. Exclude pinned nodes from backend parent-scope validation and every frontend parent-scope `ordered_ids` construction.
+5. On unpin, recompute the node's parent-scoped `position` at the visible end rather than restoring its dormant value.
+
+## Verification
+
+- [ ] Pin a root chat tab, a project-nested chat tab, and a mission; all appear once in PINNED in `pinned_position` order and disappear from their origin scopes.
+- [ ] Drag pinned rows to reorder them; reload and confirm the order persists.
+- [ ] Pinned rows cannot be dropped into project/root scopes, and unpinned rows cannot be dropped into PINNED.
+- [ ] Pin a project-nested tab, add and reorder siblings, then unpin it; the tab reappears at the end of that project.
+- [ ] Pin a root tab, reorder projects, and confirm the project reorder succeeds without moving or unpinning the tab.
+- [ ] Unpin the final pinned row and confirm the PINNED section disappears.
+- [ ] Row click targets, context menus, working/unread attention, and status indicators behave unchanged in PINNED.
+- [ ] `cargo fmt`, `cargo clippy --workspace --all-targets`, `cargo test --workspace`, `pnpm test`, `pnpm exec tsc --noEmit`, and `pnpm run lint` pass.
diff --git a/docs/features/archive/44-sidebar-node-tree.md b/docs/features/archive/44-sidebar-node-tree.md
new file mode 100644
index 00000000..453dee94
--- /dev/null
+++ b/docs/features/archive/44-sidebar-node-tree.md
@@ -0,0 +1,88 @@
+# 44 — Sidebar node tree (unified nav model)
+
+> Tracking issue: [#318](https://github.com/yicheng47/runner/issues/318)
+
+## Motivation
+
+The sidebar's containment mechanisms accreted one migration at a time, and each new concept invented its own: 0009 hard-coded a one-level structure split across two tables (`folders` + `tabs` via `folder_id` + `position`), 0010 bolted attention watermarks onto tabs, 0011 added a second, incompatible containment mechanism (pointer membership via `sessions.project_id` / `missions.project_id`), and pinning is a third (flags: `sessions.pinned`, `missions.pinned_at`). The result: ordering exists only where 0009's structure happens to provide it (tabs within folders), and every wanted behavior — reorder inside a project group, missions in folders, pinned reordering, mission drag — needs its own bespoke addition.
+
+`folders` and `tabs` are the same idea — a positioned nav node — split into two tables. The fix is one **nodes** table: a single tree with one containment/ordering mechanism, where every sidebar row is a node.
+
+The model (from the spec-43 discussion):
+
+- **Nodes are navigation state; domain objects are content.** Folder and Tab are nav-native (no domain counterpart). Project and Mission stay domain objects; their nodes *reference* them. Sessions are never nodes — they're content behind a tab's layout slots or a mission's slots.
+- **Exactly two leaf types**: `tab` (composes direct sessions into panes) and `mission` (references a crew run that owns its own composition). A chat never appears bare — it's always wrapped in a (possibly auto-created single-slot) tab, as `ensure_active_sessions` already guarantees.
+- **Two container types**: `folder` (pure nav structure) and `project` (domain object rendered as a container).
+
+Prereq shipped: #315 (unified nav scroll surface, PR #316). Sequencing: this remodel lands **before** #317 — the PINNED section then ships as the `pinned_position` derived view from day one instead of being built on the old pin columns and migrated. Until #317 adds the section, pinned rows keep sorting first inside their containers, driven by `pinned_position`.
+
+## Scope
+
+### Target schema
+
+```sql
+CREATE TABLE nodes (
+    id                 TEXT PRIMARY KEY,
+    parent_id          TEXT REFERENCES nodes(id) ON DELETE RESTRICT,  -- NULL = root
+    position           INTEGER NOT NULL,          -- scoped to parent
+    type               TEXT NOT NULL,             -- 'folder' | 'project' | 'tab' | 'mission'
+    name               TEXT,                      -- folder/tab title (project/mission names live on their domain rows)
+    ref_id             TEXT,                      -- projects.id / missions.id for reference types
+    layout             TEXT,                      -- tab-only: pane layout JSON (as tabs.layout today)
+    pinned_position    INTEGER,                   -- non-NULL = pinned; value orders the PINNED section
+    last_completed_at  TEXT,                      -- tab-only attention watermarks (from 0010)
+    last_viewed_at     TEXT,
+    created_at         TEXT NOT NULL
+);
+```
+
+- `parent_id` + `position` is the **single** containment/ordering mechanism — folder ordering, project-internal ordering, mixed tab/mission ordering all fall out of it.
+- `pinned_position` as a nullable position (not reparenting into a PINNED container) gives pinned ordering *and* unpin-returns-to-origin without remembering origins. PINNED renders as a derived view over `pinned_position IS NOT NULL`.
+- Collapse state stays frontend, per 0012's decision.
+
+### Migration (cutover, one migration file)
+
+- `folders` rows → `folder` nodes at root, keeping `position`.
+- `tabs` rows → `tab` nodes: parent = the folder's node (or the owning project's node when every member session shares a `project_id`; root otherwise), carrying `layout` + watermarks.
+- `projects` rows → `project` nodes at root, in sidebar order.
+- Non-archived missions → `mission` nodes (parent = project node when `project_id` set, root otherwise).
+- Pin flags → `pinned_position` seeded from the current pinned-first sort order; `sessions.pinned` / `missions.pinned_at` retire from sidebar use.
+- Rename `folders` / `tabs` to `*_legacy` after the copy (dogfooding insurance against a migration bug); drop them in a later migration.
+
+### Code impact
+
+- Repo layer: `repo/node.rs` replaces `repo/folder.rs` + `repo/tab.rs`; `ensure_active_sessions` seeds tab nodes. Archive/restore needs no special handling — archiving already removes a chat from the structure (`remove_session`), and the same invariant-repair loop re-creates a node for a restored chat: parented under its project's node when `project_id` is set, appended at the parent's end (original position not remembered, matching today). Mission nodes follow symmetrically: created on `mission_start`, deleted on archive, re-created on unarchive.
+- Sidebar renders from one tree: sections become derived views (PINNED = pinned overlay; PROJECT = project nodes; recent list = remaining roots), completing the MISSION/CHAT section merge.
+- dnd collapses to one reparent/reposition operation for every drag: tab-into-folder, tab/mission-into-project, mission-into-PINNED, reorder anywhere.
+
+### Key decisions
+
+1. **Write-through on project boundaries.** The tree owns placement and order, but `sessions.project_id` / `missions.project_id` stay authoritative for domain membership (cwd binding, project scoping). Reparenting a tab or mission across a project boundary writes the pointer through. Considered and rejected: tree-only membership (breaks non-sidebar consumers of `project_id`) and pointer-derived children (re-creates today's no-ordering problem).
+2. **Type-specific columns, not a payload blob.** Nullable `layout`/watermarks on one table over a JSON payload or per-type side tables — SQLite-pragmatic, matches how `tabs` already works. (`type` is a Rust keyword — the repo struct field maps it as `node_type` via serde rename, like the column-name indirection `serde_rusqlite` already handles.)
+3. **Attention stays tab-scoped.** Watermarks move with the tab node; mission attention remains the live-activity roll-up. No unification of the two attention models.
+
+### Out of scope
+
+- Any change to the domain models (`sessions`, `missions`, `projects`, crews) beyond retiring the two pin columns from sidebar use.
+- Nesting policy beyond today's shapes (no folders-in-folders, no projects-in-folders); the schema allows a general tree but the app enforces current depth.
+- Mission-as-tab data unification (rejected in spec 43).
+
+### To be decided
+
+- Whether the recent/unfiled list is one interleaved run or grouped by kind.
+- Exact dnd affordances for mission rows in v1 of this rework.
+
+## Implementation phases
+
+1. **Schema + repo layer** — `nodes` table, migration cutover from `folders`/`tabs`, `repo/node.rs`, seeding hooks (`ensure_active_sessions`, mission start/archive).
+2. **Sidebar reads the tree** — sections as derived views over one query; MISSION/CHAT sections merge; pinned-first ordering driven by `pinned_position` (the PINNED section itself arrives with #317).
+3. **Unified drag** — single reparent/reposition op wired to dnd for all row types; project write-through on cross-boundary moves.
+
+## Verification
+
+- [ ] Migration preserves every folder, tab (with layout + watermarks), project grouping, and pin, in the same visual order as before.
+- [ ] Reorder works inside a folder, inside a project, in the recent list, and in PINNED — same interaction everywhere.
+- [ ] Moving a tab into/out of a project updates member sessions' `project_id`; new chats in that project still inherit the right cwd.
+- [ ] Unpinning returns a row to its tree position; pinned order survives restart.
+- [ ] Mission nodes appear on `mission_start`, nest under their project, and leave on archive.
+- [ ] `pnpm exec tsc --noEmit`, `pnpm run lint`, `cargo test --workspace` clean.
diff --git a/docs/features/archive/46-sidebar-project-reorder.md b/docs/features/archive/46-sidebar-project-reorder.md
new file mode 100644
index 00000000..9cd79f2c
--- /dev/null
+++ b/docs/features/archive/46-sidebar-project-reorder.md
@@ -0,0 +1,49 @@
+# 46 — Sidebar project reorder
+
+Tracking: [#324](https://github.com/yicheng47/runner/issues/324)
+
+## Motivation
+
+Project rows in the PROJECTS section render in `nodes.position` order, which today is frozen at creation order — there is no way to move a project up or down. As projects became the primary grouping surface (feature 40, node tree 44), the list grew past the point where creation order matches working order: active projects end up buried under dormant ones.
+
+The backend is already done. `repo::node::move_and_reorder` explicitly allows a `project` node to move within the root scope (`node.rs:361-362`), and the `node_move` command handles `NodeType::Project` in its post-move emit match. Only the frontend affordance is missing: project rows render as `ContainerDropRow` (a drop target for tabs and missions) but are never wrapped in `SortableNavRow`, so they cannot be dragged themselves.
+
+## Scope
+
+- Drag a project row up/down within the PROJECTS section to reorder it. Projects stay root-level — no nesting under folders or other projects (already enforced by `move_and_reorder`).
+- Reuse the existing sidebar dnd-kit machinery: wrap project rows in `SortableNavRow` (or a project-specific equivalent) inside the section's existing `DndContext`, with drop indicators consistent with tab/mission drag.
+- A project row keeps its current dual role: dragging the row itself reorders projects; dropping a tab/mission node onto it still reparents (the `ContainerDropRow` behavior). Disambiguate by the dragged node's type: while a `project` node is dragged, other project rows present as reorder positions, not containers.
+- Out of scope: reordering via context menu or keyboard, nesting projects, and any change to the CHATS & MISSIONS section's ordering behavior.
+
+### Design note — shared root scope
+
+There are no per-section root nodes: root is `parent_id = NULL`, and the two sections are type filters over one shared scope with one position-space. `node_move` validates `ordered_ids` as the complete root scope, so a project reorder must submit **all** root node ids — the project rows in their new order interleaved with the non-project root nodes in their existing relative order. Keep the shared root; introducing explicit section nodes is a migration with no user-visible gain.
+
+## Implementation Phases
+
+### Phase 1 — frontend drag wiring
+
+- Make project rows draggable inside the PROJECTS section: sortable wrapper with the node id, disabled while the project is being renamed.
+- Extend the drag handlers (`handleRowDragStart/Over/End`) to recognize a dragged `project` node: compute the drop index among project rows only, suppress container-drop highlighting on project rows for project drags, and show the standard insertion marker.
+- On drop, build the full root `ordered_ids` (reordered projects + untouched non-project root nodes in their current relative order) and call `moveNode(id, null, orderedIds)`.
+
+### Phase 2 — validation
+
+- `pnpm exec tsc --noEmit`, `pnpm run lint`.
+- Manual pass over the Verification list in a dev build.
+
+## Verification
+
+- [ ] Drag a project above/below another: order persists across app restart.
+- [ ] Reordering projects does not disturb CHATS & MISSIONS row order.
+- [ ] Dropping a tab or mission onto a project row still moves it into the project.
+- [ ] While dragging a project, project rows do not light up as containers.
+- [ ] A project drag cannot land inside a folder, another project, or the CHATS & MISSIONS section.
+- [ ] Rename-in-progress project rows are not draggable.
+
+## Relevant Code
+
+- `src/components/Sidebar.tsx` — PROJECTS section render (`projectNodes.map`, ~`:2108`), `SortableNavRow` (~`:2772`), `ContainerDropRow`, drag handlers, `moveNode` call (~`:1541`).
+- `src-tauri/src/repo/node.rs:349-408` — `move_and_reorder` (already allows project-at-root; validates full-scope `ordered_ids`).
+- `src-tauri/src/commands/node.rs:169-203` — `node_move` command.
+- `src-tauri/migrations/0014_nodes.sql` — node tree schema; root = NULL parent.
diff --git a/docs/features/archive/47-deferred-mission-nudge-delivery.md b/docs/features/archive/47-deferred-mission-nudge-delivery.md
new file mode 100644
index 00000000..8d8b8cad
--- /dev/null
+++ b/docs/features/archive/47-deferred-mission-nudge-delivery.md
@@ -0,0 +1,66 @@
+# 47 — Deferred mission nudge delivery
+
+Tracking: [#328](https://github.com/yicheng47/runner/issues/328)
+
+## Motivation
+
+Every mission notification funnels into `Router::inject_and_submit` (`src-tauri/src/router/mod.rs:426`): the text is written into the recipient's PTY stdin and a spawned thread fires `\r` 80ms later. To the agent's TUI this is indistinguishable from a human typing a message and pressing Enter — which is the point, but it means the delivery shares the TUI input box with the actual human. When the user is mid-typing in that pane, the nudge appends to their half-typed draft and the trailing Enter submits the concatenation: corrupted draft, garbled message to the agent, and a lost train of thought. The fixed 80ms Enter can also race the user's own Enter.
+
+Affected senders (all route through the same primitive): directed and broadcast `message_nudge` inbox lines, `human_said` relays, `ask_lead` relays to the lead, and `human_response` answers.
+
+There is no PTY mechanism to deliver "around" the input box, and TUI-side tricks (clear line, re-type the draft after delivery) are runtime-specific and fragile. Deferral is the correct shape: nudges are wake-only — the message body lives in the inbox projection (arch §5.5.0) — so delaying delivery until the pane's input is clean costs nothing and can never corrupt input.
+
+## Scope
+
+- Per-session **pending-local-input** tracking in the session manager, fed by the single keystroke path all panes share (`session_inject_stdin` → `inject_direct_stdin`).
+- A per-session **router outbox**: `inject_and_submit` parks deliveries while the recipient pane has pending input or very recent typing, and flushes when the input clears.
+- Coalescing of queued inbox nudges on flush.
+- Out of scope: TUI-side input-box manipulation (no clear-and-retype), spawn-time first-turn delivery (`first_turn_argv` / `inject_paste_with_verify` paths), direct-chat send paths, and any change to nudge wording or the inbox projection.
+
+## Key Decisions
+
+1. **Dirty heuristic lives on `SessionState`.** `local_input_pending: bool` plus `last_local_input_at`. Printable/text bytes set pending; submit (`\r`) and Ctrl+C (0x03) clear it; escape sequences (arrows, etc.) refresh `last_local_input_at` without setting pending. Conservative false-positives are acceptable — a wrongly-held nudge is late, a wrongly-delivered one corrupts input.
+2. **Defer on pending input OR recent typing.** Delivery is blocked while `local_input_pending` is set, and also within a short recently-typing window (~2s since last keystroke) to avoid racing a draft that hasn't produced its first byte-classification yet.
+3. **Hold until input clears — no max-delay cap.** A capped flush would re-introduce the collision at cap expiry. The worst case (user walks away mid-draft) delays a wake, not a message: the body is already in the inbox, and the flush fires the moment the user submits or clears the draft.
+4. **Coalesce inbox nudges, preserve relay bodies.** N parked `[inbox]` lines flush as one summary line; `human_said` / `ask_lead` / `human_response` bodies flush in arrival order, uncoalesced.
+5. **Queue lifecycle follows the session.** Flush triggers: input-clear (submit or Ctrl+C observed in `inject_direct_stdin`), and respawn/resume of the recipient session (fresh TUI = empty input box). Session exit drops its queue — consistent with today's behavior, where a nudge to a dead session is a warn-and-drop.
+6. **`synthesize_wake_busy` moves to flush time.** The recipient is marked busy when the injection actually lands, not when it parks — otherwise the rail shows a busy badge on an agent that hasn't been woken yet.
+
+## Implementation Phases
+
+### Phase 1 — pending-input tracking
+
+- Add `local_input_pending` / `last_local_input_at` to `SessionState` (`src-tauri/src/session/manager/mod.rs`), updated in `inject_direct_stdin` (`src-tauri/src/session/manager/output.rs:197`) beside the existing submit/suppression bookkeeping.
+- Expose a `SessionManager` query for the router (`input_quiescent(session_id) -> bool`) and a clear-notification hook.
+- Unit tests over byte classes: printable sets pending, `\r` clears, Ctrl+C clears, escape sequences refresh the timestamp only.
+
+### Phase 2 — router outbox
+
+- Per-session queue in router state; `inject_and_submit` consults `input_quiescent` and parks instead of injecting when false.
+- Flush on input-clear notification and on session respawn registration; coalesce parked inbox nudges; drop queue on session removal.
+- Move `synthesize_wake_busy` to flush time.
+- Router tests: park-then-flush ordering, coalescing, queue drop on exit, no re-delivery on bus replay (watermark still applies at enqueue time).
+
+### Phase 3 — validation
+
+- `cargo test --workspace`.
+- Manual: type half a message in a mission pane, have another runner send mail — no injection until you submit or clear; then the nudge lands alone on a clean input line.
+
+## Verification
+
+- [ ] Half-typed draft in a mission pane + incoming inbox nudge: draft is untouched; nudge arrives after submit/clear, on its own line.
+- [ ] Three nudges parked behind a draft flush as one coalesced line.
+- [ ] `human_response` parked behind a draft flushes with its full body, in order, after the draft clears.
+- [ ] Nudge to a pane with no pending input delivers immediately (unchanged fast path).
+- [ ] Recipient respawn flushes its parked queue into the fresh TUI.
+- [ ] Busy badge on the rail appears at actual delivery, not at park time.
+- [ ] `cargo test --workspace` clean.
+
+## Relevant Code
+
+- `src-tauri/src/router/mod.rs:426-447` — `inject_and_submit` (park point, 80ms Enter thread), `:369` — `synthesize_wake_busy`.
+- `src-tauri/src/router/handlers.rs:175-210` — `message_nudge` directed/broadcast; `human_said` / `ask_lead` / `human_response` handlers in the same file.
+- `src-tauri/src/session/manager/output.rs:197-262` — `inject_direct_stdin`, the single keystroke path (submit detection, suppression bookkeeping to extend).
+- `src-tauri/src/session/manager/mod.rs:472` — `SessionState` (new fields beside `suppress_local_input_busy`).
+- `src-tauri/src/commands/session.rs:83` — `session_inject_stdin` command (frontend keystroke entry).
+- `docs/impls/archive/0007-spawn-time-prompt-delivery.md` — prior art for removing an injection race at a different lifecycle point.
diff --git a/docs/features/archive/48-mission-inbox-reconciliation-tick.md b/docs/features/archive/48-mission-inbox-reconciliation-tick.md
new file mode 100644
index 00000000..29d4b83a
--- /dev/null
+++ b/docs/features/archive/48-mission-inbox-reconciliation-tick.md
@@ -0,0 +1,59 @@
+# 48 — Mission inbox reconciliation tick
+
+> Tracking issue: [#332](https://github.com/yicheng47/runner/issues/332)
+
+## Motivation
+
+Message *bodies* in a mission are never lost — they live in the append-only event log, and each runner's read position is event-sourced: `runner msg read` emits an `inbox_read` signal with `payload.up_to`, which the bus projects into a per-handle `read_idx` / `unread_count` (`event_bus/mod.rs:250-268`, `cli/src/msg.rs`). What can be lost is the **wake**: nudge delivery is a fire-and-forget stdin injection, and a session that crashes before the Enter lands, a respawn that races the #328 outbox (which drops on session exit by design), or a turn that swallows the nudge text leaves an agent idling forever next to a non-empty inbox. The mission stalls silently until the human notices.
+
+Push delivery is the latency path; it needs a correctness net. A per-mission system clock closes the loop: because unread state is already durable and queryable, "session X missed its wake" is a decidable predicate the system can check and repair — without agents ever polling.
+
+## Scope
+
+- **Per-mission clock in the runner system** — an in-process timer mounted alongside the mission's bus/router (started on mission start / router mount, stopped on mission stop). Not an agent behavior: the crew protocol's "delivery is push, not pull; never poll" stands unchanged.
+- **Tick = pure in-memory check, no messages.** On each tick, for every live session in the mission, read the handle's `unread_count` from the existing bus projection. An empty inbox costs nothing — no injection, no agent wake, no tokens.
+- **Nudge only when non-empty**: if `unread_count > 0` AND the session is idle (activity state) AND no delivery for that handle is in flight, parked in the #328 outbox, or within the backoff window, attempt the standard inbox nudge through the normal atomic reservation/injection path. A clock nudge injects only when the reservation is immediately ready; typing, another in-flight delivery, or an outbox backlog makes that tick a no-op so the next tick can re-evaluate idle and unread state. Existing push delivery still uses #328's pending-input deferral and coalescing unchanged.
+- **Self-quieting**: the agent's `runner msg read` advances the `inbox_read` watermark, `unread_count` drops to zero, and the tick goes silent. Redelivery is idempotent by construction — nudges are wake-only, bodies live in the inbox projection, a redundant nudge at worst shows an agent an empty unread tail.
+- **Re-nudge backoff** so a stuck agent isn't nagged every tick: clock-initiated re-nudges for the same handle are at least two minutes apart.
+
+This retroactively closes #328's drop-on-exit window: an outbox lost at session exit leaves `unread_count > 0`, and the first tick after respawn re-covers it. Together with #328, wake delivery becomes at-least-once.
+
+## Flush grace after input-clear
+
+The #328 outbox flush on `InputCleared` currently uses `SUBMIT_DELAY` (80ms), but that constant exists for the injected body/Enter chord rather than flush pacing. A parked nudge can therefore land as the user hits Enter or Ctrl+C and begins the next draft.
+
+Input-cleared flushes use a separate `INPUT_CLEAR_FLUSH_GRACE` of 500ms. `SUBMIT_DELAY` remains 80ms everywhere else. When the grace expires, the existing `reserve_delivery` / `inject_reserved` path re-checks pending input; if typing resumed during the grace window, the delivery stays parked. Ctrl+U is not treated as input-cleared because the host cannot distinguish an empty single-line draft from one removed line with multiline content remaining. The reconciliation tick is the backstop after a parked delivery is lost with its exiting session, so no additional retry mechanism is needed.
+
+## Timing
+
+- Reconciliation tick interval: 30 seconds.
+- Per-handle backoff between clock-initiated re-nudges: at least two minutes.
+- Input-clear flush grace: 500ms.
+- All timing values are module constants; none are persisted or configurable.
+
+## Out of scope
+
+- Agent-side inbox polling in any form — rejected; it wakes every LLM per tick to usually find nothing and contradicts the push-not-pull crew protocol.
+- Changes to `inbox_read` / watermark semantics or the inbox projection — the tick is a pure consumer of existing state.
+- Direct chats — no inbox, no router; mission-only.
+- Busy sessions — never re-nudge a busy agent; if it finishes its turn without reading, the next tick catches it.
+- UI warnings for an agent that repeatedly wakes without reading.
+
+## Implementation phases
+
+1. **Tick loop** — per-mission timer mounted with the router/bus; per-live-session check of `unread_count` × activity state × nudge-recency; re-nudge through an immediately ready delivery reservation without parking clock nudges. Unit tests with a fake clock cover no-op on empty inbox, no-op on busy sessions, re-nudge on idle sessions with unread mail, pending-input deferral to a later tick, outbox suppression, backoff, and quiescence after a watermark advance.
+2. **Lifecycle wiring** — start/stop with mission mount/stop; no ticking for stopped missions or reaped sessions; tick state drops with the mission.
+3. **Input-clear flush grace** — schedule the existing outbox flush after 500ms and rely on its delivery reservation to re-check pending input.
+4. **Validation** — `cargo test --workspace`; manually send mail to a runner, kill its session before it reads, resume it, and confirm the nudge re-arrives on the next tick without any human message.
+
+## Verification
+
+- [ ] Kill a recipient session between mail arrival and read; resume it; the nudge re-arrives within one tick.
+- [ ] An idle session with an empty inbox is never nudged by the clock.
+- [ ] A busy session with unread mail is not nudged until it goes idle.
+- [ ] After the agent reads (`inbox_read` advances), ticks go silent.
+- [ ] Backoff: clock-initiated re-nudges for a stuck agent are at least two minutes apart.
+- [ ] Mission stop halts the clock; no ticks against stopped sessions.
+- [ ] A parked delivery does not flush when typing resumes during the 500ms input-clear grace.
+- [ ] A parked delivery flushes after a quiet 500ms input-clear grace.
+- [ ] `cargo fmt`, `cargo clippy --workspace --all-targets`, `cargo test --workspace` pass.
diff --git a/docs/features/archive/49-periodic-update-checks.md b/docs/features/archive/49-periodic-update-checks.md
new file mode 100644
index 00000000..858b906f
--- /dev/null
+++ b/docs/features/archive/49-periodic-update-checks.md
@@ -0,0 +1,47 @@
+# 49 — Periodic update checks for long-running sessions
+
+> Tracking issue: [#333](https://github.com/yicheng47/runner/issues/333)
+
+## Motivation
+
+The updater checks exactly once per process: `UpdateContext` fires a single `checkForUpdate` ~3s after mount and never again. Runner is a long-running app — a cockpit left open for days or weeks never learns a release shipped, and the user only updates when something else forces a restart. Everything downstream of the check already handles the long-running case: `checkForUpdate` is re-entrant-guarded and resting-state-only, auto-install advances available → downloaded → ready, and the sidebar prompt card + Settings → About surface "ready" with a manual restart. Only the re-check triggers are missing.
+
+## Scope
+
+- **Interval re-check** — while the app runs, call the existing `checkForUpdate` on a timer (default ~6h). No new state machine; the resting-state guard makes repeated calls free.
+- **Focus-triggered stale check** — track `lastCheckAt`; on window focus, re-check when the last check is older than the interval. This is the half that actually matters on a laptop: WKWebView throttles/suspends timers during sleep, so the overnight-lid-closed case is caught at the moment the user returns, not whenever a starved timer fires.
+- **Never auto-relaunch.** Runner hosts live PTYs with running agents — an automatic restart is data loss by design. Restart stays a manual action on the existing surfaces; a staged update also applies on the next natural quit/launch, so even a user who ignores the card converges.
+- **Decouple checking from the auto-install toggle.** Today the launch check is gated on `STORAGE_AUTO_INSTALL_UPDATES` (`UpdateContext.tsx:37`) — toggle off means *no check at all*. New semantics: checks always run (launch + interval + focus); the toggle governs auto-download only. Toggle-off becomes notify-only ("available" shows in the update surfaces) instead of never-knowing.
+- **Dedicated Settings → Updates pane.** The update ladder (check / available / download progress / ready–restart), the auto-install toggle, and the current version move out of Settings → About into their own `UpdatesPane` in the settings nav. About keeps identity content (version line, credits, links) and loses the interactive updater.
+- **macOS app-menu entry.** Add "Check for Updates…" to the application menu in `build_menu` (`lib.rs`), conventionally right below About. The menu handler emits an event to the webview; the frontend listener triggers `checkForUpdate` and navigates to Settings → Updates so the result is visible immediately. This is the standard macOS discoverability path for users who never open Settings.
+
+Storage: none. The interval and staleness threshold are module constants; `lastCheckAt` is an in-memory ref (every launch re-checks anyway, so persisting it would only create a stale-suppression bug surface). Zero new persisted state; the existing toggle key narrows in meaning to download-only.
+
+## Out of scope
+
+- Any auto-restart, restart nagging, or install-on-quit hooks.
+- Backend/Rust changes beyond the single app-menu item + event emit in `build_menu`.
+- Changing the sidebar prompt card.
+- A configurable interval — constant only; promote to a `settings.ts` key later if ever actually wanted.
+
+## To be decided
+
+- Interval length (default 6h; anything 4–12h is defensible — this is a freshness net, not a delivery SLA).
+- Whether "error" from a background re-check should surface anywhere or stay silent until the next attempt (leaning silent — a transient network failure at 3am shouldn't leave a red badge). A menu-initiated check is explicit and DOES surface its error in the Updates pane.
+
+## Implementation phases
+
+1. **Re-check triggers** — interval timer + `lastCheckAt` + focus listener in `UpdateContext` (or the hook); launch check un-gated from the toggle; toggle governs the available → download transition only.
+2. **Updates pane + menu item** — new `UpdatesPane` in the settings nav with the ladder/toggle/version moved from About; "Check for Updates…" item in `build_menu` emitting to the webview; frontend listener checks + navigates to the pane.
+3. **Validation** — `pnpm exec tsc --noEmit`, `pnpm run lint`, `cargo check` for the menu wiring; unit-test the trigger policy (stale-on-focus fires, fresh-on-focus doesn't, toggle-off checks but doesn't download) with the dev status override / fake timers; manual smoke via the existing `runner.dev.updateStatus` escape hatch.
+
+## Verification
+
+- [ ] With the app left running past the interval, a staged release is detected without a restart (interval path).
+- [ ] Sleep/wake past the staleness threshold triggers a check on focus (focus path).
+- [ ] Auto-install ON: background check quietly reaches "ready"; prompt card appears; no automatic relaunch ever.
+- [ ] Auto-install OFF: background check reaches "available" and surfaces it; no download starts until the user clicks.
+- [ ] A background check error stays silent and the next trigger retries; a menu-initiated check surfaces its error in the Updates pane.
+- [ ] Settings shows a dedicated Updates pane with the ladder, toggle, and version; About no longer hosts the updater.
+- [ ] App menu → "Check for Updates…" opens Settings → Updates with a check in flight.
+- [ ] `pnpm exec tsc --noEmit`, `pnpm run lint`, and `cargo check` pass.
diff --git a/docs/features/archive/50-inbox-delivery-blocked-indicator.md b/docs/features/archive/50-inbox-delivery-blocked-indicator.md
new file mode 100644
index 00000000..a4d74e62
--- /dev/null
+++ b/docs/features/archive/50-inbox-delivery-blocked-indicator.md
@@ -0,0 +1,45 @@
+# 50 — Inbox delivery blocked indicator
+
+> Tracking issue: [#336](https://github.com/yicheng47/runner/issues/336)
+
+## Motivation
+
+Mission inbox bodies are durable, but their wake nudges share the runner's terminal input. Feature 47 correctly parks a nudge rather than corrupting a draft, and feature 48 correctly refuses to clock-nudge while input remains pending. This creates a safe but silent state: unread coordination mail is waiting, the agent is not being woken, and the human may not realize their draft is the blocker.
+
+Ctrl+U makes the ambiguity visible. Runner sees the keystroke but cannot know whether it emptied a single-line draft or removed only one line from a multiline draft. Reconstructing the child TUI's editor state from xterm input would be brittle and runtime-specific, so Runner should keep the conservative delivery gate and tell the human when it is blocking inbox delivery.
+
+## Scope
+
+- Show a pane-local, non-modal indicator when a live mission session has unread inbox mail and pending local input prevents its wake nudge from being delivered safely.
+- Use mechanism-based copy such as `Inbox waiting (2) — typing detected, delivery paused`, including the unread count when it is greater than one. The copy must not assert that a draft exists (backspace-cleared input is undetectable, so the box may already be empty) and must not claim any action notifies the worker: clearing input releases the parked nudge, and Runner delivers it. No @handle in the copy — the indicator is pane-local, so the affected runner is already unambiguous.
+- Include a `Clear input (↵)` button on the indicator that emits a single Enter through the ordinary local-input write path — byte-identical to the user pressing the key. `ClearPending` fires organically and the existing 500ms flush grace plus fire-time re-check still apply, so typing during the grace re-parks delivery. A leftover draft is submitted rather than discarded; Enter on an already-empty box is a no-op. Show the button only while the runner is idle because Enter during a busy turn could submit or steer pending text into the agent's active turn.
+- Keep the indicator outside the terminal byte stream so it cannot alter, submit, or clear the user's draft.
+- Clear the indicator when unread count reaches zero, input clears and delivery proceeds, the session exits, or the mission unmounts.
+- Drive the UI from ephemeral in-process state transitions; do not persist blocked-delivery state or append coordination-log events for it.
+- Accept that a workspace opened after the blocked transition has already fired will not show the indicator until the unread count changes. There is intentionally no persisted state or snapshot API to hydrate a late subscriber.
+- Preserve the push-not-pull protocol. This is a human-facing explanation of a blocked wake, not agent polling or a new delivery path.
+
+## Out of scope
+
+- Reconstructing the agent TUI's draft buffer from xterm keystrokes or screen output.
+- Treating Ctrl+U as proof that all input is clear.
+- A force-deliver action that bypasses delivery safety checks or splices a nudge into remaining draft text. The `Clear input` button is not this: it performs one ordinary Enter keystroke through the same write path, with every reservation/grace check left in place.
+- Direct chats, which have no mission inbox.
+
+## Implementation phases
+
+1. **Blocked-state projection** — combine the router's unread projection with the session delivery reservation/outbox state and emit only blocked/unblocked transitions for live mission sessions.
+2. **Pane indicator** — render the state beside the affected terminal with concise copy and no focus stealing.
+3. **Lifecycle cleanup** — clear blocked state on successful delivery, watermark advance, session exit, router unmount, and pane/session replacement.
+4. **Validation** — cover transition deduplication and concurrency in Rust, then cover pane rendering and cleanup in frontend tests.
+
+## Verification
+
+- [ ] Typing a draft while unread mail arrives parks the nudge and shows the indicator on the correct pane.
+- [ ] Pressing Enter clears the input, permits delivery, and removes the indicator.
+- [ ] Ctrl+U does not falsely declare multiline input clear; the indicator remains until an unambiguous clear.
+- [ ] A watermark advance to zero unread removes the indicator without injecting anything.
+- [ ] Empty inboxes, direct chats, busy sessions without blocked local input, and unrelated panes never show the indicator.
+- [ ] Session exit and mission stop remove blocked state with no stale indicator after remount.
+- [ ] The Clear input button sends exactly one Enter through the local-input path: a leftover draft is submitted, an empty box is unchanged, ClearPending is observed, and delivery proceeds after the grace; the button is absent while the runner is busy.
+- [ ] Repeated reconciliation ticks do not duplicate UI events or churn rendering while the blocked state is unchanged.
diff --git a/docs/features/archive/51-read-mostly-mission-feed.md b/docs/features/archive/51-read-mostly-mission-feed.md
new file mode 100644
index 00000000..618ebc8a
--- /dev/null
+++ b/docs/features/archive/51-read-mostly-mission-feed.md
@@ -0,0 +1,46 @@
+# 51 — Read-mostly mission feed
+
+> Tracking issue: [#330](https://github.com/yicheng47/runner/issues/330)
+
+## Motivation
+
+Mission workspaces expose two competing human-to-runner paths. The native one is direct pane input: click a runner's terminal and type. The duplicate one is the `MissionInput` feed composer, which emits a `human_said` signal that the router then injects into the same PTY anyway (`router/handlers.rs::human_said`) — the composer is pane input with extra steps, plus a synthetic transcript, a recipient picker, and a reply protocol.
+
+The reply protocol is the expensive half. To make composer conversations two-way, every generated prompt teaches a `human` virtual handle: the worker coordination preamble spends a whole "Replying to the human" section on it, the lead prompt spends two coordination bullets, and the CLI reserves the handle in roster validation (`cli/src/roster.rs::HUMAN_HANDLE`). It was the worst-followed part of the crew protocol — #128 exists because agents over-triggered on it with reply spam — and every instruction removed from the preamble gives the surviving ones more weight.
+
+The feed should answer *what is happening across the crew*; the selected pane is where the human talks to a runner. This completes the direction started by #128.
+
+## Scope
+
+- **Remove the `MissionInput` composer.** The feed pane becomes read-mostly: the only remaining feed-side control is the `ask_human` card's choice buttons, which post correlated `human_response` signals exactly as today.
+- **Remove the reply-to-human protocol end to end:**
+  - CLI: drop the `HUMAN_HANDLE` carve-out from roster validation. `runner msg post --to human` fails with a teaching error — "the operator reads your terminal; answer in your TUI output" — so a resumed session whose baked prompt still advertises the old verb self-corrects instead of hitting a generic unknown-handle error.
+  - Worker preamble: delete the "Replying to the human" section and the `human` entry from the valid-handles line. Workers end with zero human-related instructions.
+  - Lead prompt: delete both `--to human` bullets; add one line stating the operator watches the terminals and types directly into a runner's pane. `ask_human` stays as the lead's single structured escalation verb.
+  - Empty-goal fallback text "(no goal set; await human_said)" becomes "await the operator's instructions in your terminal."
+- **Keep the coordination feed intact:** runner-to-runner mail and inbox nudges, coordination signals (`ask_lead`, `ask_human`/`human_question`/`human_response`), router warnings, status transitions, and historical event rendering all stay.
+- **Keep historical log compatibility:** `EventFeed` retains its render paths for `human_said`, `human_response`, and messages addressed to `human`, so pre-51 mission logs replay unchanged. The router's `message_nudge` skip for the `human` target also stays as replay-compat belt-and-braces.
+- **Keep the programmatic channel (decision):** `mission_post_human_signal` (Tauri command and MCP tool) continues to accept `human_said`, and the router's `human_said` handler stays live. It costs nothing toward the simplicity goal — agents never see it, it appears in no prompt — and it is the only programmatic human-to-mission channel, used by external orchestrators to relay mid-flight operator instructions into a running lead. The whitelist comment is updated to name MCP (not the workspace UI) as the producer.
+- Accept that direct pane typing is PTY conversation, not a synthetic event-log transcript.
+
+## Out of scope
+
+- Removing `human_response`, the pending-ask map, or any part of the HITL ask path.
+- Changes to the #328 pending-input outbox or the #332 reconciliation tick.
+- Recording pane keystrokes into the event log.
+- A replacement "type into pane" MCP tool — `human_said` via MCP already covers programmatic injection.
+
+## Implementation phases
+
+1. **UI removal** — delete `MissionInput.tsx` and its `MissionWorkspace` mount; verify feed-pane layout without the dock (paused overlay, ask cards, scroll anchoring). Keep all `EventFeed` human-event render paths.
+2. **CLI + prompt simplification** — roster carve-out becomes a teaching rejection; rewrite `WORKER_COORDINATION_PREAMBLE` and `compose_launch_prompt` coordination bullets; update prompt tests (the #128 tone-guardrail test becomes an absence assertion: no `--to human`, no `human` handle in either prompt).
+3. **Feed + HITL verification** — `ask_human` → card → choice → `human_response` → injection to asker unchanged; historical logs replay; MCP `human_said` still injects; sync `docs/arch/arch.md` §5.5.0 to the read-mostly feed model.
+
+## Verification
+
+- [ ] Mission workspace shows no composer; feed renders and scrolls; ask-card buttons still answer and route to the asker.
+- [ ] `runner msg post --to human` fails with the teaching message; other handles unaffected.
+- [ ] Generated lead and worker prompts contain no `--to human`, no `human` handle, no `[human_said]` reference (test-asserted).
+- [ ] A pre-51 mission log containing `human_said` and messages to `human` renders exactly as before.
+- [ ] MCP `mission_post_human_signal` with `human_said` still injects into the target pane; `mission_goal`/`ask_lead` remain rejected.
+- [ ] `cargo fmt`, `cargo clippy --workspace --all-targets`, `cargo test --workspace`, `pnpm exec tsc --noEmit`, `pnpm run lint` pass.
diff --git a/docs/features/archive/54-draft-aware-delivery-gate.md b/docs/features/archive/54-draft-aware-delivery-gate.md
new file mode 100644
index 00000000..fc8c82e2
--- /dev/null
+++ b/docs/features/archive/54-draft-aware-delivery-gate.md
@@ -0,0 +1,69 @@
+# 54 — Draft-aware delivery gate
+
+> Tracking issue: [#359](https://github.com/yicheng47/runner/issues/359)
+> Priority: P1.
+
+## Motivation
+
+Typing a single stray character into a crew runner's terminal blocks its inbox indefinitely, and the only ways to unblock it are destructive.
+
+The gate itself is right and should stay. The router injects into a slot's PTY stdin to wake an idle agent; if the human has a half-typed draft in that agent's input box, an injection collides with it — corrupting the draft or submitting a merged line. Runner deliberately refuses to deliver in that case (`reserve_delivery` → `DeliveryReservation::PendingInput`, `session/manager/mod.rs:821`). That is the correct trade, and better than the alternative: Orca, which faces the same problem, has no draft concept at all — its Native Chat sends `\x15` (Ctrl-U) to kill the input line before writing (`native-chat-runtime-send.ts:37`) and its automation path injects into a running agent with no readiness check, appending to the human's draft and submitting the merged result.
+
+What's wrong is not the gate but its **state model**. `local_input_pending` is a boolean latch (`session/manager/mod.rs:536`) set by `classify_local_input` (`session/manager/output.rs:16-37`) on any printable byte, and cleared only by `\r` (Enter), `\x03` (Ctrl-C), respawn (`mod.rs:884`), or session lifecycle transitions (`lifecycle.rs:76,309`). Everything else — backspace (`\x7f`), Ctrl-U (`\x15`), Escape, arrow keys — classifies as `ActivityOnly`, which refreshes the 2-second `RECENT_LOCAL_INPUT_WINDOW` (`mod.rs:51`) but leaves the latch set.
+
+The consequence: type `a` into the wrong pane, press backspace, and the input box is empty while the latch is still true. The runner's inbox is now blocked forever. Recovery requires Enter (submits a line to the agent) or Ctrl-C (interrupts it) — there is no non-destructive escape and no UI affordance. Because a stalled inbox is silent, the mission just quietly stops coordinating until someone notices.
+
+## Scope
+
+### In scope
+
+- **Replace the boolean latch with a draft line model.** Track the human's unsubmitted input as a length/emptiness model rather than a one-way flag, so the gate closes when a draft exists and opens again when the draft is gone. Minimum behaviors: printable bytes extend the draft; backspace and word-delete shrink it; Ctrl-U, Ctrl-C, and Escape clear it; Enter clears it (submitted). An empty model means delivery is allowed.
+- **Abandonment backstop.** Even a perfect model can be defeated (a draft left half-typed for an hour, or a TUI whose editing keys we mis-model). A draft that has seen no input for a generous interval should stop blocking delivery. The interval is a decision, not a given — long enough not to interrupt someone thinking, short enough that a forgotten keystroke doesn't strand a mission.
+- **A non-destructive manual clear.** Whatever the model, the human needs one honest way out that neither submits nor interrupts. The runner rail already shows per-slot state; a "clear pending input" affordance there, or a keybinding, closes the loop when heuristics fail.
+- **Observability.** A slot whose inbox is gated on a draft should say so — the current state is indistinguishable from a busy agent in the UI, which is why it goes unnoticed.
+
+### Out of scope
+
+- Removing or weakening the gate. Delivering into a live draft is worse than a delayed delivery; this spec makes the gate accurate, not permissive.
+- Hook-based agent status ([#347](https://github.com/yicheng47/runner/issues/347) / spec 52). That resolves *agent working vs agent idle*; this resolves *input box empty vs input box dirty*. They are orthogonal dimensions of "is it safe to deliver," and #347 does not subsume this — a hook can never report a human's unsubmitted keystrokes.
+- Screen-scraping the TUI's input line to read the draft directly. Per-runtime, per-version fragile; the keystroke model is runtime-agnostic and already sits on a byte stream Runner owns.
+- Changing the router's injection mechanism, the inbox pull model, or `RECENT_LOCAL_INPUT_WINDOW`.
+
+### What to borrow from Orca
+
+Orca does not solve the problem, but it **has already written the algorithm** — for a different purpose, and explicitly disabled in the case that matters here.
+
+`observeAcceptedShellCommandInput` (`src/renderer/src/components/terminal-pane/pty-connection.ts:1492-1567`) maintains `pendingShellCommandLine`: a real line model with a cursor that appends printables (`char >= ' '`), deletes on `\x7f`/`\b`, word-deletes on `\x17`, resets on `\x03`/`\x15`, consumes CSI arrow sequences, and commits on `\r`/`\n`. It exists to notice a human typing `claude` at a shell prompt — and at `:1505` it bails out precisely inside agent TUIs:
+
+```
+// Why: bytes typed inside a live agent TUI are prompt text, not shell
+// commands, even if they spell another agent binary name.
+if (hasFreshPaneAgentSurface()) { resetPendingShellCommandLine(); return }
+```
+
+So the borrowable artifact is the state machine, not the design: Runner wants that same model applied to exactly the context Orca skips, feeding the delivery gate instead of command detection. Runner's version lives in Rust on the input path (`classify_local_input`) rather than in the renderer, and needs no cursor — only whether the draft is empty.
+
+## Open questions
+
+- **Where the model lives.** `classify_local_input` sees raw input bytes and is the natural home, but it is currently stateless per call. A per-session draft model belongs on `SessionState` beside the flag it replaces.
+- **How faithfully to model editing.** Agent TUIs are not readline; claude-code and codex handle kill-line, word-delete, and history differently. The model should fail *closed* (assume a draft still exists when unsure) with the abandonment timeout and manual clear as the release valves — never fail open into a corrupted draft.
+- **Multi-line and paste.** Bracketed paste and `\x16` already set the latch. A pasted block followed by Enter is a normal submit; a pasted block left unsubmitted is a draft. The model should treat paste as content like any other.
+- **Whether the 2-second recency window survives.** With an accurate draft model, `RECENT_LOCAL_INPUT_WINDOW` may be redundant, or may still be worth keeping as protection against delivering mid-keystroke.
+
+## Implementation phases
+
+1. **Draft model.** Replace `local_input_pending` with a draft-state struct on `SessionState`; port the editing state machine into `classify_local_input`'s caller; keep `reserve_delivery`'s contract unchanged apart from consulting emptiness. Unit-test the byte sequences directly: type-then-backspace-to-empty opens the gate, Ctrl-U opens it, partial draft keeps it closed, paste keeps it closed, Enter opens it.
+2. **Backstop and escape hatch.** Abandonment timeout plus a non-destructive manual clear, wired to whatever UI surface the runner rail exposes.
+3. **Surface the state.** Make "inbox gated on your unsent input" visible on the slot, distinct from "agent busy."
+
+## Verification
+
+- [ ] Type a character into a mission slot, backspace it away → the inbox delivers without pressing Enter or Ctrl-C.
+- [ ] Type a real draft and leave it → delivery stays gated.
+- [ ] Ctrl-U on a partial draft → delivery resumes.
+- [ ] Paste a block without submitting → delivery stays gated; submit it → delivery resumes.
+- [ ] An abandoned draft stops gating after the chosen interval.
+- [ ] The manual clear neither submits to nor interrupts the agent.
+- [ ] A gated slot is visually distinguishable from a busy slot.
+- [ ] Delivering into a live draft never happens — the regression this spec must not introduce.
+- [ ] `cargo test --workspace`, `pnpm exec tsc --noEmit`, `pnpm run lint` clean.
diff --git a/docs/features/archive/55-paste-file-paths.md b/docs/features/archive/55-paste-file-paths.md
new file mode 100644
index 00000000..0b2922b7
--- /dev/null
+++ b/docs/features/archive/55-paste-file-paths.md
@@ -0,0 +1,60 @@
+# 55 — Paste file paths into the terminal
+
+Tracking: [#368](https://github.com/yicheng47/runner/issues/368)
+
+History: first landed in PR [#369](https://github.com/yicheng47/runner/pull/369) and reverted when main was rebuilt around the #372 spawn-width fix. This spec re-lands the feature with one correction: the original decision 3 let a file reference beat image bytes, which changed the paste behavior of Finder-copied image *files*. The redo inverts that — the image flow is untouchable, and the path branch runs only where today's handler does nothing.
+
+## Motivation
+
+Copying a file in GoLand (or Finder) and pressing ⌘V over a Runner terminal inserts nothing. Every native terminal — Terminal.app, iTerm2, Ghostty — inserts the file's POSIX path instead, which is how you hand an agent a file to look at without typing the path out.
+
+The cause is that a file copy puts no text on the clipboard. It writes `public.file-url` / `NSFilenamesPboardType` flavors to NSPasteboard, and native terminals read those flavors explicitly and insert the path. Over the WKWebView boundary the paste arrives as a `File` in `DataTransfer` with an empty (or unusable) `text/plain`, so xterm.js's default paste has nothing to insert.
+
+Runner already intercepts paste for exactly this class of problem. `onPaste` in `RunnerTerminal.tsx` handles image paste — it walks `clipboardData.items` for `kind === "file"`, and for images ships the bytes to Rust to restore the NSPasteboard flavor (#79). A non-image file falls straight through: `inferPasteImageMime` returns null, the loop `continue`s, the handler returns **without** `preventDefault`, and xterm's default paste inserts the empty text. So the extension point exists and the gap is one branch wide.
+
+The web layer cannot close it alone. `DataTransfer` deliberately withholds filesystem paths — `File.name` is only the basename, and WKWebView exposes no `file.path`. The path has to come from the native pasteboard, which is what the existing image path already reaches into.
+
+## Scope
+
+- ⌘V over a terminal pane, when the clipboard holds file references and neither an image the current flow would attach nor usable text, inserts the file's absolute path at the cursor, shell-quoted when needed.
+- Multiple copied files insert as multiple quoted paths separated by single spaces.
+- Text pastes and image pastes — **including Finder-copied image files** — keep their current behavior exactly.
+- Out of scope: drag-and-drop of files onto a pane (same underlying need, but Tauri's drag-drop event supplies paths through an entirely different path — its own spec); pasting directory contents; translating paths to be relative to the session cwd; any change to the image-paste flow.
+
+## Key Decisions
+
+1. **The image flow is untouchable; the path branch runs only in today's dead zone.** (Inverts the reverted spec's decision 3.) The handler keeps main's exact ordering: the image scan runs first, and anything it catches — pasted screenshot bytes, browser image copies, *and* Finder-copied `shot.png`, which `inferPasteImageMime`'s extension fallback classifies as an image — takes the #79 attach flow unchanged. Then non-empty `text/plain` falls through to xterm unchanged. Only when both yield nothing — the case where today's handler inserts nothing at all — does the path branch consult the native pasteboard. Consequence accepted: an image *file* pastes as an attachment, not a path; users who want an image's path can get it from Finder's Copy-as-Pathname. The re-land constraint from the revert is exactly this: #368 must not change any behavior the image paste (#79 / #234) has today.
+
+2. **Read the paths in Rust, not in the webview.** A command returns the current pasteboard's file URLs as POSIX paths. The frontend never sees a path it could have fabricated from `File.name`. This is a direct `objc2` NSPasteboard binding, not another `osascript` shell-out like the image write: the read happens on every candidate paste, and a process spawn per paste isn't worth the symmetry. `objc2-app-kit` is declared `default-features = false`, so the `NSPasteboard` feature must be added in `src-tauri/Cargo.toml`.
+
+3. **Decide synchronously, act asynchronously.** `preventDefault()` after an `await` does nothing — the browser has already run the default action by the time an IPC round-trip resolves. So the handler cannot "call the command, then decide". It reads the event synchronously (image scan, text check), commits (`preventDefault` + `stopImmediatePropagation`) once both come up empty, and only then goes async to the pasteboard command. An empty result then swallows a paste that had nothing to insert anyway — a no-op.
+
+4. **Prefer the native read over `text/uri-list`.** WKWebView sometimes exposes a `text/uri-list` flavor with `file://` URLs, which would avoid an IPC round-trip, but its presence is inconsistent across source applications. One authoritative path (the pasteboard) beats two code paths that disagree.
+
+5. **Quote only when the path needs it.** iTerm2 quotes unconditionally because its panes are shell prompts. Runner's panes are usually *agent* prompts, where `'/Users/jason/foo.go'` is noise and defeats `@`-style file completion. Quote when the path contains whitespace or shell metacharacters, using single quotes with embedded quotes escaped (`'` → `'\''`); otherwise insert it bare.
+
+6. **Insert as text, never submit.** Write through the existing raw-stdin injection (`api.session.injectStdin`). Do **not** route through `inject_paste`, which appends Enter — a pasted path is the middle of a sentence the user is still composing. The draft-gate state (spec 54) sees it as ordinary local input, which is correct: the user now has a pending draft.
+
+7. **No cwd-relative rewriting.** Absolute paths always resolve regardless of where the agent has `cd`'d, and the agent can shorten them itself. Relativizing would need the session's live cwd, which Runner only knows at spawn time.
+
+## Verification
+
+- [ ] Copy a `.go` file in GoLand, ⌘V over a terminal pane — the absolute path appears at the cursor, unquoted, not submitted.
+- [ ] Copy a file whose path contains a space — the pasted text is quoted and the agent resolves it.
+- [ ] Copy two files at once — both paths appear, space-separated.
+- [ ] Copy a file in Finder — same result as GoLand.
+- [ ] Take a screenshot to the clipboard (⌘⇧4), paste — the existing `[Image #N]` attach flow is unchanged (#79).
+- [ ] Copy an image *file* in Finder, paste — it **attaches as an image**, exactly as today (decision 1; this inverts the reverted spec).
+- [ ] Copy ordinary text — pastes exactly as before, with no IPC round-trip.
+- [ ] Paste with an empty clipboard — nothing happens, no error.
+- [ ] Paste with the session stopped or the pane disabled — no injection, no error.
+- [ ] The pasted path leaves the pane in a pending-draft state (spec 54), not a submitted one.
+
+## Relevant Code
+
+- `src/components/RunnerTerminal.tsx` — `onPaste` (~`:808`), the interception point whose image-first ordering decision 1 preserves; `inferPasteImageMime` (~`:97`), whose filename fallback is what keeps Finder-copied image files on the attach flow.
+- `src-tauri/src/commands/session.rs` — `session_paste_image` and its MIME→OSType table, the plumbing neighborhood for the new read command.
+- `src-tauri/Cargo.toml` — `objc2-app-kit` with `default-features = false`; `NSPasteboard` must be added to its feature list.
+- `src/lib/api.ts` — `session.injectStdin`; `session.pasteImage`, the call shape to mirror.
+- `docs/features/54-draft-aware-delivery-gate.md` — the draft model a pasted path feeds into (decision 6).
+- Reverted implementation for reference: PR #369, commit `61cafd2` (local branch `fix/367-368-spawn-width-and-paste-paths`) — `src/lib/terminalPaste.ts`, `src/lib/terminalPaste.test.ts`, and `session_clipboard_file_paths` are all reusable; only the orchestration's precedence order changes.
diff --git a/docs/impls/archive/0019-runner-landing-page.md b/docs/impls/0019-runner-landing-page.md
similarity index 100%
rename from docs/impls/archive/0019-runner-landing-page.md
rename to docs/impls/0019-runner-landing-page.md
diff --git a/docs/impls/0033-runtime-executable-discovery-and-overrides.md b/docs/impls/0033-runtime-executable-discovery-and-overrides.md
new file mode 100644
index 00000000..5ac6d195
--- /dev/null
+++ b/docs/impls/0033-runtime-executable-discovery-and-overrides.md
@@ -0,0 +1,94 @@
+# Runtime executable discovery + overrides
+
+## Status
+
+Implemented. Tracking issue [#279](https://github.com/yicheng47/runner/issues/279), scoped to built-in runtimes only — the user-defined custom-runtime extension is explicitly cut (see spec 37 "Out of scope"). Spec: `docs/features/37-agent-runtime-executable-settings.md`. Design: `design/runner-setting.pen`, frame `Settings — Agents` (node `Zes2l`) and `Spec — Agent runtime row states` (node `cXdkp`).
+
+## Problem
+
+Runner launches agent CLIs by bare catalog names (`claude`, `codex`) resolved inside the child PTY against a PATH composed at spawn time (`launch.rs:78`). The PATH's best ingredient comes from a login-shell probe (`shell_path.rs:84`) that is fragile in four ways:
+
+1. **It blocks startup.** `resolve_login_shell_env()` runs synchronously inside Tauri `setup` (`lib.rs:171`) before the first window paint; worst case adds ~2.5 s (2 s deadline + 500 ms drain grace) to every cold start.
+2. **It is all-or-nothing with no memory.** On timeout the app silently falls to launchd's stripped env — one `log::warn` no user ever sees. A launch that timed out loses the PATH a previous launch captured successfully. Slow zsh/Oh My Zsh inits are exactly the setups that also rely on version managers, so the users most likely to time out are the users most hurt by the fallback.
+3. **The fallback seed is thin.** `FALLBACK_CLI_DIRS` (`launch.rs:32`) covers Homebrew, `~/.local/bin`, `~/.cargo/bin`, `~/.npm-global/bin` — but not the version-manager shim dirs (mise, asdf, volta, fnm, nvm, pnpm, bun) where agent CLIs increasingly live.
+4. **Nothing is observable or fixable.** Runner never resolves the executable itself, so "not installed", "probe timed out", and "PATH missing one dir" all present identically: `command not found` printed by the shell into a freshly spawned dead PTY. There is no refresh short of relaunching the app, and no override of any kind.
+
+Reference point: Orca converged on the same architecture-level answers (probe `$SHELL -ilc`, no shell/PATH configuration on POSIX, per-agent executable override as the escape hatch) but hardened the probe: non-blocking startup, 5 s timeout, typed failure reasons, sync seed covering version-manager dirs, on-demand refresh from their settings pane. This impl adopts those refinements without adopting their agent-inside-login-shell spawn model — Runner keeps the agent as PTY root (clean exit observation, byte-quiescence idle detection).
+
+## Key Decisions
+
+1. **Discovery becomes a background service with a persisted last-known-good result.** `resolve_login_shell_env` grows into a small discovery module that returns a structured `DiscoveryResult { shell, outcome, duration, env }` where `outcome ∈ {Ok, Timeout, SpawnError, EmptyCapture, NoShell}`. At startup, `setup` seeds the manager synchronously from the last-known-good snapshot persisted in `_app_state` (key `login_shell_env_lkg`, JSON: env + shell + captured_at) — a fast SQLite read — then fires the real probe on a background thread. On success the probe swaps the live env, persists the new snapshot, and emits `runtime/changed`. On failure the last-known-good stays in effect and the failure reason is kept for the settings pane. Timeout goes from 2 s to 5 s — it no longer costs startup anything. Never re-probe implicitly beyond launch; explicit refresh only.
+2. **`SessionManager.shell_env` becomes shared and swappable.** Today it is a plain owned field set once by `SessionManager::new` (`manager/mod.rs:606,714`). It becomes `Arc<RwLock<LoginShellEnv>>` (std `RwLock`; reads clone, writes are rare). The discovery service holds the same handle. `base_spawn_spec` (`spawn.rs:66,89`) reads through the lock. No spawn ever blocks on discovery: if the probe is still pending, spawn proceeds with the seeded env (last-known-good or default) exactly as today.
+3. **Runner resolves executables itself, in Rust, by walking the composed PATH.** New resolver: split the same PATH `compose_path` produces for a direct chat (no shim/bundled dirs), test each `dir/<command>` for regular-file + executable bit (`0o111`), first hit wins. No shell involvement — aliases and functions can mask binaries from `command -v`, and the child never sees them anyway (same reasoning Orca documents in `posix-command-path-lookup.ts`). This resolution is what the settings pane displays and what spawn substitutes, so "what settings shows" and "what spawn does" cannot drift.
+4. **Fatter sync seed.** Extend `FALLBACK_CLI_DIRS` composition with the version-manager dirs: `~/.local/share/mise/shims`, `~/.asdf/shims`, `~/.volta/bin`, `~/.bun/bin`, `~/.deno/bin`, `~/Library/pnpm`, `~/.local/share/fnm/aliases/default/bin`, and enumerated `~/.nvm/versions/node/*/bin` (newest first, only when the dir exists). Rationale: agent CLIs are typically `#!/usr/bin/env node` scripts — the shim dirs cover both the CLI and the `node` it needs. Keep the list curated and home-relative; dedup already happens in `compose_path`.
+5. **Overrides live in `_app_state`, one JSON key, no migration.** `runtime_overrides` → `{"claude-code": "/abs/path", ...}`. `_app_state` (`db.rs:191`) is the existing backend KV store and overrides are exactly key/value app state. Set validates absolute + regular file + executable and returns a structured error for inline display; empty/None clears back to auto. localStorage is never the source of truth (spec 37 requirement — all windows and all Rust spawn paths must agree).
+6. **Effective-command precedence is one function, applied at the existing choke points.** `effective_runtime_command(runtime) -> {command, source}` with precedence: valid override → detected absolute path → bare catalog name (only while discovery has produced nothing — the child PATH then does its best, as today). A vanished override (file deleted since set) is skipped for spawn — fall through to detected — and surfaced as the Invalid state in the pane; spawning something that works beats failing loudly on a stale preference, but the pane must not pretend the override is in effect. Substitution applies only when the stored command equals the runtime's catalog default: `runtime_direct_runner` (`manager/mod.rs:1153`, already the single choke point for runtime-only chats and runner-less resume), `resolve_runtime_override` (`manager/mod.rs:1119`, slot overrides), and the runner-backed spawn/resume paths where `runner.command` is loaded. A legacy runner row with a genuinely custom command is never touched.
+7. **Not-found fails before the PTY, with a pointer.** When discovery has completed and neither override nor detection resolves the runtime, spawn commands return a structured error naming the runtime and directing to Settings → Agents, instead of forking a PTY that prints `command not found`. If discovery is still pending, spawn proceeds (decision 2) — never block or reject on an unfinished probe.
+8. **Sessions keep recording the effective command; resume re-validates it.** `spawn_direct_inner` already stamps `sessions.agent_command` (`spawn.rs:755-770`) and runner-less resume already honors it (`spawn.rs:1067-1081` → `runtime_direct_runner(runtime, snap.agent_command)`). With substitution, the stamped value becomes an absolute path. Resume adds one check: if the stored command is an absolute path that no longer exists, re-resolve through `effective_runtime_command` instead of failing on a dead path (spec 37: "resume uses the same executable unless the file no longer exists").
+9. **New Tauri surface follows the `<domain>/<verb>` event convention — and actually emits.** Commands: `runtime_status_list` (per-runtime: catalog fields + detected path + override + state + probe diagnostics), `runtime_set_override`, `runtime_clear_override`, `runtime_refresh` (force re-probe + re-resolve). All mutations and every probe completion emit `runtime/changed` with an empty payload; listeners refetch (the `runner/changed` pattern from `Runners.tsx:85`). Note the counter-precedent to avoid: the Tauri `runner_create/update` handlers mutate without emitting — do not copy that.
+10. **The pane is read-mostly and mirrors the design nodes exactly.** New `AgentsPane` under Integrations (above MCP, icon `bot`, PaneKey `"agents"`): a shell-environment card (shell, probe outcome, duration, refresh) and one row per built-in runtime with the six states from `Spec — Agent runtime row states`: detected, override, not-found, checking, probe-timed-out, invalid-override. No shell picker, no PATH editor — the probe stays configuration-free.
+
+## Goals
+
+- Cold start never waits on the shell probe; a probe timeout on this launch cannot lose the PATH captured on a previous launch.
+- Settings → Agents shows, for each built-in runtime, the absolute executable Runner will actually spawn, or a state that distinguishes "not installed" from "shell probe failed" from "override is broken".
+- A user whose CLI lives behind a version manager can fix Runner without touching their shell: install → Refresh → detected, or Browse → override.
+- Spawning a runtime whose executable cannot resolve fails before the PTY with actionable copy, not `command not found` in a dead terminal.
+- A runner row with a custom command, and an existing session's recorded command, behave exactly as before.
+
+## Non-Goals
+
+- User-defined custom runtimes (registry-as-data, capability profiles) — cut from #279's scope for now; nothing here may preclude them, and nothing here builds them.
+- Shell selection or PATH editing UI. `$SHELL` + fallback is the probe; the override is the escape hatch.
+- Accepting aliases, shell functions, or non-absolute overrides.
+- Changing the spawn model (agent stays PTY root; no login-shell wrapper à la Orca).
+- Windows support beyond keeping the existing unsupported state.
+- Unifying the hardcoded frontend `RUNTIME_OPTIONS` mirror (`src/components/ui/runtimes.ts`) with the backend catalog. Its `defaultCommand` values are the bare catalog names, which is precisely what new runner rows should keep storing (no frozen absolute paths) — leave it.
+
+## Implementation Phases
+
+### Phase 1 — backend: discovery service + shared env
+
+- `shell_path.rs`: restructure around `DiscoveryResult` (shell, outcome enum, duration, `LoginShellEnv`); keep the marker probe and parser as-is; timeout 2 s → 5 s. Log line per probe: shell, duration, outcome, nothing else from the env (spec 37 diagnostics rule).
+- `db.rs` / small store helper: read/write the `login_shell_env_lkg` and `runtime_overrides` keys in `_app_state`.
+- `session/manager/mod.rs`: `shell_env` → `Arc<RwLock<LoginShellEnv>>`; constructor takes the handle; `base_spawn_spec` and `compose` read through it.
+- `lib.rs` setup: synchronous seed from last-known-good → construct manager → spawn background probe thread → on completion swap env + persist + emit `runtime/changed`. Startup no longer blocks (delete the inline resolve at `:171`).
+- `launch.rs`: extend the fallback seed per decision 4 (a `version_manager_dirs(home)` helper feeding `compose_path`; nvm enumeration behind an exists-check).
+- Tests: LKG round-trip; timeout keeps prior env; probe outcome mapping; seed dirs dedup and ordering (shell PATH still wins over seed); manager reads swapped env on next spawn.
+
+### Phase 2 — backend: resolution, overrides, spawn integration
+
+- New `runtime_status` module (or extend `router/runtime.rs`): the PATH-walk resolver (decision 3), `effective_runtime_command` (decision 6), and per-runtime status assembly for the pane.
+- Override store: get/set/clear with validation (absolute, regular file, executable bit) and structured validation errors.
+- Choke-point integration: `runtime_direct_runner` (`manager/mod.rs:1153`) resolves through `effective_runtime_command` when no explicit command is passed; `resolve_runtime_override` (`:1119`) substitutes the effective command instead of raw `def.command`; runner-backed spawn/resume substitute only when `runner.command == catalog default`; resume re-validates a stored absolute `agent_command` (decision 8).
+- Pre-spawn not-found error (decision 7) returned from `session_start_runtime`, `session_start_direct`, mission spawn, and resume paths.
+- Watch item: `command_line_matches_recorded_agent` (`pty_runtime.rs:1094`) must keep matching when `agent_command` is an absolute path — add a test against a `ps`-style command line for both bare and absolute spawn forms.
+- Watch item: the launch script must quote an absolute command path containing spaces the same way it already quotes args.
+- Tests: precedence (override > detected > catalog); vanished-override fallthrough; custom runner command untouched; catalog-default runner substituted; resume with live stored path (no re-resolution), with dead stored path (re-resolves), runner-backed vs runtime-only; not-found error carries runtime name; probe-pending spawn proceeds with bare name.
+
+### Phase 3 — Tauri commands + frontend Agents pane
+
+- `commands/runtime.rs`: `runtime_status_list`, `runtime_set_override`, `runtime_clear_override`, `runtime_refresh`; register in `lib.rs`; every mutation and probe completion emits `runtime/changed`.
+- `src/lib/api.ts`: `api.runtime.status()/setOverride()/clearOverride()/refresh()` + types.
+- `src/components/settings/AgentsPane.tsx`: shell-environment card + per-runtime rows per the design nodes; `PaneHeader`/`SettingsCard`/`SettingsRow` primitives where they fit, `McpPane`'s row/status-line shapes for the richer rows; Browse via `@tauri-apps/plugin-dialog` file picker; inline validation errors from `runtime_set_override`; listen on `runtime/changed`.
+- `src/pages/SettingsPage.tsx`: PaneKey `"agents"`, `PANES` entry (label "Agents", icon `Bot`), Integrations group before `mcp` (`:102`).
+- Frontend checks: `pnpm exec tsc --noEmit`, `pnpm run lint`.
+
+### Phase 4 — diagnostics, docs, verification
+
+- Startup + spawn log lines that make timeout / not-found / active-override distinguishable in `runner_logs_reveal` output.
+- `docs/arch/`: update the login-shell environment capture description (background probe, LKG, refresh) and document the command precedence.
+- Spec 37 verification checklist pass; `cargo fmt` + `clippy` + `cargo test --workspace`.
+
+## Verification
+
+Unchecked items below require manual app verification.
+
+- [ ] Cold start paints without waiting on the probe; probe result lands afterwards and the pane updates via `runtime/changed`.
+- [ ] Kill the probe artificially (bogus slow rc) → previous launch's PATH still spawns agents; pane shows probe-timed-out with duration.
+- [x] `runtime_status_list` detected path equals what a spawned PTY actually execs (same composed PATH).
+- [ ] Override set/clear round-trips through `_app_state` and takes effect for direct chat, runner-backed chat (default command), mission slot, and resume — without app restart, in all windows.
+- [x] Runner row with custom command spawns that command unchanged.
+- [x] Vanished override: spawn falls back to detected; pane shows invalid state.
+- [x] Not-found runtime: spawn fails pre-PTY naming the runtime; no dead terminal.
+- [ ] Full checklist in spec 37 § Verification.
diff --git a/docs/impls/0034-qoder-runtime.md b/docs/impls/0034-qoder-runtime.md
new file mode 100644
index 00000000..da98ef90
--- /dev/null
+++ b/docs/impls/0034-qoder-runtime.md
@@ -0,0 +1,107 @@
+# Qoder CLI as a first-class runtime
+
+## Status
+
+Implemented. Tracking issue [#341](https://github.com/yicheng47/runner/issues/341). Capabilities were live-probed against qodercli v1.1.4 on 2026-07-24; its installed bundle and help output were inspected during implementation to pin session-path encoding and unprobed follow-up flags. This is the first runtime added since [0033](0033-runtime-executable-discovery-and-overrides.md) shipped the Agents settings pane, so it doubles as the proof that adding a runtime is now mostly a catalog edit.
+
+## Problem
+
+`RUNTIME_DEFINITIONS` (`router/runtime.rs:37`) has exactly two entries. Qoder's CLI is a near-clone of claude-code's — self-assigned `--session-id`, `--resume <uuid>`, positional first-turn prompt, `--permission-mode`, `~/.qoder/projects/<cwd-slug>/<uuid>.jsonl` session files in the same layout as `~/.claude/projects` — so it takes the cheapest adapter path Runner has. What makes it non-trivial is not the adapter; it's that per-runtime behavior is spread across ~12 `match` arms in Rust plus five hand-ported `Record` maps in TypeScript, several of which have inverted-polarity defaults that silently give a new runtime the *wrong* behavior rather than no behavior.
+
+Three specific traps, all confirmed in the current tree:
+
+1. **`runtime_purges_on_resume` (`output.rs:798`) is a denylist**: `!matches!(runtime, Some("claude-code"))`. A new runtime silently inherits codex's purge-and-full-reset on resume. Qoder repaints its conversation like claude-code, so it needs an explicit exception or resumed panes lose their scrollback.
+2. **`runtimeClearsOnResize` is duplicated** in Rust (`output.rs:770`) and TypeScript (`RunnerTerminal.tsx:168`) with no shared source. Changing one without the other makes the local xterm pre-clear and the backend ring purge disagree.
+3. **Declaration order is load-bearing.** `RUNTIME_OPTIONS[0]` is the default in the Create Runner form (`CreateRunnerModal.tsx:43`), and a `runtime_status.rs` test indexes `.runtimes[0]`. Both lists must be appended to, never prepended.
+
+## Key Decisions
+
+1. **Mirror claude-code, not codex.** Qoder accepts a caller-supplied `--session-id` at spawn, so it uses the self-assign path and needs **no** post-spawn session-key capture. This deliberately avoids `codex_capture.rs` entirely — that module is named and typed for codex (`CodexCaptureContext`, `SessionHandle.codex_capture`), and a second self-assigning runtime would force a refactor we don't need here.
+2. **Share the conversation-file guard, not the project slug encoder.** Claude Code and Qoder both store `<uuid>.jsonl` under an agent dotdir's `projects/<cwd-slug>/`, so the existence check is shared while thin runtime wrappers provide the dotdir and encoder. Qoder's installed v1.1.4 bundle replaces every non-ASCII-alphanumeric UTF-16 code unit with `-`; over 200 units it keeps the first 200 and appends `-${abs(djb2(original)).toString(36)}`. Claude Code keeps Runner's previously verified `/` and `.` replacement. The resume guard at `spawn.rs:1179-1197` accepts both runtimes without assuming their slugs are identical.
+3. **Ship only the permission mode that was probed.** `--permission-mode auto` is verified. Qoder's help declares `default`, `accept_edits`, `bypass_permissions`, `dont_ask`, and `auto`, but only `auto` was live-probed; notably the additional values are snake_case rather than claude-code's camelCase. An invalid value makes the CLI refuse to start, so: `Default` → no flags, `Auto` → `--permission-mode auto`, and the other modes stay omitted until probed. `default_permission_mode()` is `Auto` (`commands/runner.rs:59`) and `runtime_direct_runner` applies it unconditionally, so the verified mode is exactly the one every direct chat needs.
+4. **No model or effort flags in v1.** Qoder's help declares `-m` / `--model` and `--reasoning-effort <level>`, but accepted values and interactive behavior remain unprobed; the effort flag also differs from claude-code's `--effort`. The lookups degrade correctly — `runtimeSupportsEffort()` returns false, `modelSuggestions()` returns `[]`, and `model_effort_args` falls through to `Vec::new()`. Adding verified mappings later is additive and needs no migration.
+5. **Keep-ring on resume, clear-on-resize — both flagged for smoke-test confirmation.** Qoder is a full-repaint TUI that restores the prior conversation on `--resume`, which is claude-code's profile on both axes. Both settings are single-line changes if the smoke test contradicts them, and the verification list below makes that an explicit check rather than an assumption.
+6. **The Agents settings pane needs no per-runtime code — but does need one fix.** `runtime_status.rs` and `commands/runtime.rs` are fully catalog-driven (verified: no hardcoded runtime names outside `#[cfg(test)]`), and `AgentsPane.tsx` renders entirely from `display_name`/`command` off the backend rows. Discovery, override, validation, and the not-found pre-spawn error all come free with the catalog entry. The one hardcode is the loading skeleton at `AgentsPane.tsx:81` — `[0, 1].map(...)` renders exactly two placeholder rows and will be short by one. Make it render from the previous row count or a constant derived from the catalog.
+7. **Register Runner MCP in Qoder's user config.** The [official Qoder CLI MCP documentation](https://docs.qoder.com/en/cli/mcp-servers) stores user-scoped servers in `~/.qoder/settings.json` under the same `mcpServers` JSON shape used by Claude Code. Settings → MCP gets a Qoder toggle and manual snippet; writes preserve every unrelated setting and replace or remove only `mcpServers.runner`. Existing Qoder sessions need `/mcp reload`; new sessions discover it on startup.
+8. **No launch gate.** `enter_claude_launch_gate` (`spawn.rs:70`) exists for claude-code's OAuth refresh-token race under concurrent spawns. Whether qoder has the same problem is unknown; adding a 1500ms serialization gate speculatively would slow every multi-slot mission for a hypothetical bug. Left off, with a smoke-test item that would catch it.
+
+## Goals
+
+- Qoder is selectable everywhere claude-code and codex are: direct-chat runtime picker, runner form, and crew slot runtime override.
+- Settings → Agents shows a Qoder row with its detected `qodercli` path, an override field, and the same six states as the other runtimes.
+- Settings → Agents owns the Direct-mode default runtime; the standalone Chat settings pane is removed.
+- Settings → MCP can register or unregister Runner in Qoder's user config.
+- A qoder direct chat spawns, takes its first turn via positional argv, stops, and resumes into the same conversation with scrollback intact.
+- A qoder mission slot receives its worker preamble and responds to inbox nudges.
+- A missing `~/.qoder/projects/<slug>/<uuid>.jsonl` falls back to a fresh spawn instead of an error loop.
+
+## Non-Goals
+
+- Model and effort flag mapping (unprobed; additive later).
+- `accept_edits` / `bypass_permissions` / `dont_ask` permission modes (declared but unprobed; see decision 3).
+- Generalizing `codex_capture.rs` into a runtime-agnostic capture framework — qoder doesn't need it, and no third self-assigning runtime is on the table.
+- Generating `src/components/ui/runtimes.ts` from the Rust catalog. It stays a hand-port; this impl adds one more entry to the existing maps. (The duplication is real and worth its own issue — out of scope here.)
+
+## Implementation Phases
+
+### Phase 1 — Rust catalog and adapter (`src-tauri/src/router/runtime.rs`)
+
+- **Append** to `RUNTIME_DEFINITIONS` (`:37`): `{ name: "qoder", display_name: "Qoder", command: "qodercli" }`. Append — see Problem trap 3.
+- `resume_plan` (`:612`): add a `"qoder"` arm mirroring claude-code exactly — `Some(k) if is_uuid(k)` → `args: ["--resume", k]`, `prepend: false`, `assigned_key: Some(k)`, `resuming: true`; otherwise self-assign a fresh UUID via `["--session-id", id]`.
+- `first_turn_argv` (`:492`): add `"qoder"` to the `"claude-code" | "codex"` arm. Mandatory — without it a qoder lead spawns with no persona and no mission goal.
+- `permission_mode_args` (`:198`) and `mode_match_pairs` (`:372`): `("qoder", Auto)` → `["--permission-mode", "auto"]`. No `AcceptEdits`/`Bypass` arms (decision 3).
+- `strip_permission_flags` (`:250`): `"qoder"` → `&[("--permission-mode", true)]`, so a mode change round-trips.
+- Generalize the conversation-file existence check per decision 2; keep the claude-code wrapper and add a qoder wrapper pointing at `.qoder` with Qoder's own project-slug encoder.
+- `src-tauri/src/commands/mcp.rs`: add Qoder to the MCP client enum, integration status, toggle command, and manual snippets. Read and write `~/.qoder/settings.json`; preserve unrelated JSON and mutate only `mcpServers.runner`.
+- Tests: extend the both-runtimes loops at `:1414` and `:1436` to include qoder; add resume-plan self-assign/resume arms; add the permission matrix arm at `:960` asserting qoder yields the auto flag and nothing for the unprobed modes.
+
+### Phase 2 — Rust spawn and output policy
+
+- `session/manager/spawn.rs:1179-1197`: the `conversation_missing` and `effective_prior_key` matches must accept `"qoder"` alongside `"claude-code"`, using the qoder guard.
+- `session/manager/spawn.rs:550, 936, 1421`: add `"qoder"` to the first-turn-not-delivered warning gate so the diagnostic stays honest.
+- `session/manager/output.rs:770` `runtime_clears_on_resize`: add `Some("qoder")`.
+- `session/manager/output.rs:798` `runtime_purges_on_resume`: add `Some("qoder")` to the `matches!` so it keeps its ring like claude-code (decision 5 — confirm in smoke test).
+- Do **not** touch `enter_claude_launch_gate` or any `codex_capture` site.
+- Tests in `session/manager/tests.rs`: mirror the claude-code ring-keep-on-resume coverage (`:2836-2922`) for qoder, and add a qoder case to the `resolve_runtime_override` matrix (`:4073`).
+
+### Phase 3 — Frontend
+
+- `src/components/ui/runtimes.ts`: **append** to `RUNTIME_OPTIONS` (`:19`) — `{ value: "qoder", label: "qoder", defaultCommand: "qodercli", description: "Qoder CLI" }`. Add `"qoder"` to `RUNTIMES_WITH_PERMISSION_MODE` (`:40`) and a `PERMISSION_MODES_BY_RUNTIME` entry (`:159`) with default + auto only. Add `MODE_MATCH_PAIRS_BY_RUNTIME` (`:214`) and `PERMISSION_STRIP_KEYS_BY_RUNTIME` (`:307`) entries mirroring Phase 1. Leave `EFFORT_OPTIONS_BY_RUNTIME` and `MODEL_SUGGESTIONS_BY_RUNTIME` without a qoder key (decision 4).
+- `src/components/RunnerTerminal.tsx:168` `runtimeClearsOnResize`: add `"qoder"`, in lockstep with Phase 2 (Problem trap 2).
+- `src/components/settings/AgentsPane.tsx:81`: replace the hardcoded `[0, 1]` skeleton with something that doesn't assume two runtimes (decision 6).
+- `src/components/settings/McpPane.tsx`: add the Qoder registration toggle and manual-config copy action.
+- `StartChatModal`, `RuntimeSelect`, `AddSlotModal`, and `CrewEditor` already read the backend catalog or `RUNTIME_OPTIONS`. Move the existing Direct-mode default-runtime control from the now-misplaced Settings → Chat pane into Settings → Agents, keep its stored preference wired to `StartChatModal`, remove the standalone Chat pane, and use the runtime display name alone for generated Direct-chat titles.
+- Do **not** add qoder to the codex session-key polling in `MissionWorkspace.tsx:448` / `RunnerChat.tsx:527` — qoder's key is assigned at spawn, so there is nothing to poll for.
+
+### Phase 4 — Docs and verification
+
+- `docs/arch/arch.md`: runtime mentions at `:52`, `:82`, `:156`, `:404`, `:439`, `:684` — update the enumerations and record qoder's keep-ring/clear-on-resize policy alongside the others.
+- `README.md`: the runtime enumerations in the tagline and feature copy.
+- `src-tauri/src/mcp/tools/session.rs:15-21`: the `runtime` doc comment lists the valid names as examples — add qoder.
+- Full check matrix: `cargo fmt --check`, `cargo clippy --workspace`, `cargo test --workspace`, `pnpm exec tsc --noEmit`, `pnpm run lint`, `pnpm test`.
+
+## Verification
+
+Automated:
+
+- [ ] `resume_plan("qoder", None)` self-assigns a UUID via `--session-id`; with a prior UUID it emits `--resume <uuid>`, `prepend: false`.
+- [ ] `first_turn_argv("qoder", body)` returns the positional body; suppressed on resume.
+- [ ] Permission round-trip: apply Auto → `--permission-mode auto`; switch to Default → flag stripped.
+- [ ] Qoder project slugs replace every non-alphanumeric UTF-16 code unit and truncate over 200 units with the CLI's djb2/base36 suffix.
+- [ ] `runtime_purges_on_resume("qoder")` is false; `runtime_clears_on_resize("qoder")` is true; the TS mirror agrees.
+- [ ] Unknown-runtime slot-override rejection still lists qoder among valid names.
+- [ ] Settings → Agents persists the Direct-mode default runtime, and Start Chat preselects it.
+- [ ] A generated Direct-chat title is the runtime display name (`Codex`, `Claude Code`, or `Qoder`) without a `Chat with` prefix.
+- [ ] Qoder MCP registration creates `~/.qoder/settings.json` when needed, preserves unrelated settings and servers, and removes only `mcpServers.runner` when disabled.
+- [ ] Settings → MCP renders a Qoder toggle that calls `mcp_set_integration` with the `qoder` client id.
+
+Manual (the human smoke-tests — these decide two of the key decisions):
+
+- [ ] Direct chat: spawn, converse, stop, resume — conversation restored, no blank grid, **no doubled or missing scrollback** (confirms the keep-ring choice in decision 5).
+- [ ] Resize a qoder pane mid-conversation — frame repaints cleanly, no shredded box-drawing in scrollback (confirms clear-on-resize).
+- [ ] First-turn prompt auto-submits in interactive mode; if the positional doesn't submit, fall back to `-i/--prompt-interactive`.
+- [ ] Mission slot: worker preamble lands; the inbox nudge submit chord (80ms Enter) lands in its input box.
+- [ ] Start three or more qoder sessions at once — if any fail to authenticate, the claude-code launch gate may be needed after all (decision 8).
+- [ ] Delete the session `.jsonl` and resume — falls back to a fresh spawn without an error loop.
+- [ ] Settings → Agents shows a Qoder row with the detected `qodercli` path; the loading skeleton renders three rows, not two.
+- [ ] Settings → MCP toggles Qoder registration; a new Qoder session sees Runner tools, while an existing session sees them after `/mcp reload`.
diff --git a/docs/impls/0035-auto-resume-width-and-opt-in.md b/docs/impls/0035-auto-resume-width-and-opt-in.md
new file mode 100644
index 00000000..eb669c64
--- /dev/null
+++ b/docs/impls/0035-auto-resume-width-and-opt-in.md
@@ -0,0 +1,97 @@
+# Auto-resume: correct fork width, and make it opt-in
+
+## Status
+
+Implemented. Follows [0032](archive/0032-in-place-resume-seam-and-width-hardening.md) (which introduced the persisted-dims fallback this bug lives in) and the auto-resume feature shipped in 0.4.2 ([#320](https://github.com/yicheng47/runner/issues/320), spec `docs/features/45-auto-resume-on-launch.md`). Two changes: fix the fork width, and flip the setting to opt-in.
+
+## Release note
+
+Auto-resume is now opt-in, including for existing users who previously received it through the default. Turn on **Settings → General → Resume running agents on launch** to restore the prior launch behavior.
+
+## Problem
+
+Sessions restored by auto-resume-on-launch come back at the wrong terminal width. Manual resume does not have this bug. The asymmetry is the tell, and it traces to a five-step mechanism where two independent holes line up:
+
+1. **Auto-resume asks for no particular size.** `api.session.resumeOnLaunch` invokes `session_resume` with `cols: null, rows: null` (`src/lib/api.ts:371`), unlike manual resume which measures the live pane first — `const dims = terminals.get(targetId)?.measure() ?? null` (`src/pages/RunnerChat.tsx:1185`, whose own comment at `:1182` already notes "measure() can be null right after a restart").
+2. **So the backend falls back to persisted dims.** `resume()` resolves `cols.zip(rows).or(snap.last_cols.zip(snap.last_rows)).unwrap_or(DEFAULT_PTY_SIZE)` (`src-tauri/src/session/manager/spawn.rs:1056-1059`) — exactly the 0032 decision-5 chain. Correct by design; the inputs are what's wrong.
+3. **Hole one — `last_cols`/`last_rows` can't be updated while a session is stopped.** The only writer outside spawn is `SessionManager::resize` (`output.rs:488`), and its first line is `let rt_session = self.live_runtime_session(session_id)?` (`:489`), which returns `Err("session not found")` whenever the session has no live handle (`manager/mod.rs:1029-1035`). The `update_last_size` call sits *after* that early return (`:492`). A pane that mounts and measures while its session is still stopped therefore records nothing.
+4. **Hole two — the frontend believes it already pushed.** `RunnerTerminal`'s mount effect fits, sets `lastPushedColsRef.current = term.cols`, then fires `api.session.resize(...).catch(() => {})` (`RunnerTerminal.tsx:531-543`). The ref is updated unconditionally and the rejection from step 3 is swallowed. Since `pushSize` dedupes against that ref, the frontend will **never re-send** that size unless the pane's geometry genuinely changes.
+5. **Result.** At launch the pane mounts and pushes `W_real` → dropped, because the session hasn't been resumed yet. Auto-resume then forks the PTY at the stale persisted `W_old`. The agent's resume banner and first repaint are emitted hard-wrapped at `W_old`. Nothing corrects it: the frontend thinks `W_real` is already in flight, and the backend never heard it. For claude-code — which *keeps* its ring on resume (`runtime_purges_on_resume`, `output.rs`) — those miswrapped lines stay in scrollback permanently, since no reflow can undo wrapping the agent performed itself.
+
+Manual resume escapes all of this because step 1 supplies real dims from an already-laid-out pane.
+
+Note this is a genuine race, not a fixed ordering: `consumeResumeOnLaunch` fires from `App.tsx:51-61` immediately after `app_ready`, concurrently with route/pane mounting. Whether the mount push or the resume wins is timing-dependent — which is why the fix must not be a timing fix.
+
+## Key Decisions
+
+1. **Persist pane geometry independently of PTY liveness.** Split `SessionManager::resize` so recording `last_cols`/`last_rows` happens before the missing-live-handle early return, and applying the resize to the PTY happens only when a handle exists. Rationale: the dims are a fact about the *pane that will display this session*, not about the process — a stopped session's row should learn its geometry so the next spawn or resume forks correctly. This closes hole one and makes the persisted fallback trustworthy on its own. A resize for a nonexistent session still returns a genuine not-found error and does not create manager state.
+2. **Re-assert size when a session goes live, instead of trusting a dedupe ref that may describe a dropped call.** A dedicated disabled-state effect detects the stopped→running transition, clears `lastPushedColsRef`/`lastPushedRowsRef`, and invokes `reassertSizeRef` to fit and issue one plain deduped push. This closes hole two and, unlike decision 1, also repairs any *other* path where a resize was rejected in flight (session exiting, crash-and-resume, window handoff).
+
+   Stopped and transitional panes deliberately differ: stopped panes keep pushing every laid-out geometry measurement so later zoom, sidebar, split-layout, and window changes can correct the persisted size, while transitional panes suppress resize until the fork completes. Stopped pushes are plain RPCs that skip both the local TUI clear and the forced resize dance, and they preserve `replayJustDrainedRef` so a geometry-only push cannot consume the next clear-capable push's replay protection.
+3. **Do not fix this with ordering.** The obvious alternative — defer `consumeResumeOnLaunch` until layout settles, or have it await a "surfaces ready" signal — makes correctness depend on a race we'd have to keep winning as the boot sequence evolves. Decisions 1 and 2 make the outcome correct whichever side wins: if the mount push lands first, the fork is already right; if the resume lands first, the correction fires the moment the pane is live and measurable. No new timing dependency.
+
+   **Corrected by [0038](0038-launch-resume-fork-width.md) / [#363](https://github.com/yicheng47/runner/issues/363).** The concern was sound but stated too broadly. Both decisions here act *after* the PTY forks, so they fix the eventual state and leave the agent's resume banner wrapped at the fork width — permanently, for runtimes that keep their ring across a resume. Awaiting window-restore settle is not the race this warned about: restore lands asynchronously, so *any* geometry read before it is wrong regardless of who wins. 0038 gates the queue on that settle.
+4. **Accept that background sessions still fork at their last known width.** Most auto-resumed sessions have no mounted pane at all — only the active tab and the persistent surfaces are mounted. There is no correct width to give them, and the persisted one (now trustworthy per decision 1) is the best available guess. When the user later opens such a tab, activation pushes real dims and the agent repaints. Explicitly not solved here: history a background agent hard-wrapped before you ever looked at it. That's the same irreducible limit 0032 recorded.
+
+   **Corrected by [0038](0038-launch-resume-fork-width.md) / [#363](https://github.com/yicheng47/runner/issues/363).** "No correct width" was too pessimistic. `terminalSizing.ts` already derives a grid with nothing mounted, and the persisted tab layouts already say which pane a session returns into — so a background session can be estimated from *this* launch's window and its own share of the split. That beats a value persisted at the previous quit, which is stale by construction whenever the window moved, resized, changed display scale, or the session now renders in a split. The persisted value stays as the rung below the estimate.
+5. **Auto-resume becomes opt-in, default off.** Export one `DEFAULT_RESUME_ON_LAUNCH = false` constant and use it in both the `App.tsx` consumer and the `GeneralPane.tsx` toggle. Sharing the constant structurally prevents the two defaults from drifting and rendering a toggle state the consumer does not act on.
+6. **No migration for existing users, but the behavior change is real and must be stated.** The setting is stored under `settings.resumeOnLaunch` and read with a default; anyone who never touched the toggle has no stored key, so flipping the default silently turns auto-resume off for them too. That is the intent — spawning agents unprompted at launch should be something you choose, not something you discover. It shipped only two releases ago (0.4.2), so the blast radius is small, and writing a one-time "preserve previous default" key would leave a permanent wart to explain. Call it out in the release notes instead.
+7. **The quit-side stamp stays unconditional.** The backend continues to mark sessions at graceful quit regardless of the toggle, and a launch with the toggle off still *clears* pending stamps without resuming (the edge case #320 built deliberately). This keeps enabling the setting a forward-looking act rather than something that resurrects a session set from an old quit.
+
+## Goals
+
+- A session restored by auto-resume forks its PTY at the width of the pane that will display it, and its resume banner is not hard-wrapped at a stale width.
+- A resize issued against a stopped session updates the row, so the next spawn or resume uses it.
+- A session that goes live re-asserts its pane geometry rather than assuming an earlier push landed.
+- Auto-resume is off unless the user turns it on; the toggle and the consumer agree on that default.
+
+## Non-Goals
+
+- Repairing scrollback already hard-wrapped at a previous width — impossible once the agent wrapped it (0032 non-goal, restated).
+- Giving background, never-displayed sessions a "correct" width (decision 4).
+- Reordering or gating the boot sequence (decision 3).
+- Any change to the quit-side stamping, the 300ms stagger, resume-never-fresh-spawn, or the crash-never-stamps rule from #320.
+
+## Implementation Phases
+
+### Phase 1 — backend: record dims regardless of liveness
+
+- `src-tauri/src/session/manager/output.rs`: restructure `resize` so the `update_last_size` write runs before the missing-live-handle early return, and the runtime resize plus the `cols_changed`/`runtime_clears_on_resize` purge run only when a live handle exists. Preserve current behavior exactly for live sessions — the ring-purge rules and the rows-only-keeps-ring reasoning are unchanged.
+- A resize on a nonexistent session returns a genuine not-found error and does not create manager state. Persisting geometry for an unknown id must not silently create state.
+- Tests in `session/manager/tests.rs`: resizing a stopped session updates `last_cols`/`last_rows` and does not error; a subsequent resume with `None` dims forks at those dims; resizing a live session still purges the ring on a cols change and still keeps it on a rows-only change.
+
+### Phase 2 — frontend: re-assert geometry on going live
+
+- `src/components/RunnerTerminal.tsx`: use a dedicated disabled-state effect to reset `lastPushedColsRef`/`lastPushedRowsRef` on the transition into a running session, then fit and issue a plain deduped push through `reassertSizeRef`. Keep geometry enabled for stopped panes but disabled for transitional panes; do not disturb the transitional latch's dance-suppression behavior, which #352 depends on.
+- Verify the interaction with #352's activation change: the re-push must be a plain deduped push, **not** a forced resize dance — the goal is to inform the backend of a size it never received, not to make the agent repaint.
+- Helper tests in `src/lib/terminalResize.test.ts`: a push that the backend rejects must not leave the frontend believing it succeeded; the stopped→running transition resets the dedupe state so the current dims need one push and are deduped afterward. The xterm effect wiring remains a manual check because the repository has no component harness for `RunnerTerminal`.
+
+### Phase 3 — opt-in default
+
+- `src/App.tsx` and `src/components/settings/GeneralPane.tsx`: use the shared false default at both sites.
+- Check the GeneralPane row's sub-copy still reads correctly for an off-by-default control.
+- Test that the consumer does not resume when the key is absent, and still clears pending stamps in that case (decision 7).
+
+### Phase 4 — docs
+
+- `docs/features/45-auto-resume-on-launch.md`: the spec says "One toggle in Settings … default on." Update it and record why.
+- The GitHub release notes must manually include this document's release note stating that auto-resume is now opt-in, including for users who had it working by default (decision 6); the repository has no changelog that publishes it automatically.
+
+## Verification
+
+Automated:
+
+- [x] Resize a stopped session → `last_cols`/`last_rows` updated, no error.
+- [x] Resume with `None` dims after such a resize → forks at those dims, not `DEFAULT_PTY_SIZE` and not the pre-quit value.
+- [x] Live-session resize behavior unchanged: cols change purges the ring for full-repaint runtimes, rows-only keeps it.
+- [x] Push-state helpers clear the pushed-dims refs on going live and dedupe the next size after one push; the xterm effect wiring has no component harness and remains part of manual verification.
+- [x] Toggle absent → no resume, stamps cleared.
+
+Manual (Jason smoke-tests):
+
+- [ ] With the toggle **on**, run two chats and a mission, quit, relaunch → restored panes are wrapped at the current window width, with no short-wrapped resume banner and no shredded box-drawing above it.
+- [ ] Same, but resize the window materially before relaunching → restored panes still match the new width.
+- [ ] Switch to a background auto-resumed tab → it repaints at the correct width on activation.
+- [ ] Fresh profile (or cleared `settings.resumeOnLaunch`) → nothing auto-resumes; the General toggle reads off.
+- [ ] Turn the toggle on, quit, relaunch → auto-resume works as before.
+- [ ] Resume a visible stopped pane and confirm the log records one plain size push, without a forced resize dance.
diff --git a/docs/impls/0036-trae-runtime.md b/docs/impls/0036-trae-runtime.md
new file mode 100644
index 00000000..abe84f14
--- /dev/null
+++ b/docs/impls/0036-trae-runtime.md
@@ -0,0 +1,113 @@
+# TRAE CLI as a runtime
+
+## Status
+
+Planned. Tracking issue [#361](https://github.com/yicheng47/runner/issues/361). Capabilities were live-probed against `traecli 0.200.19 (internal edition)` on 2026-07-27: help output for the interactive and `exec` surfaces, a real `exec` run in a scratch git repo, the on-disk rollout envelope, and an add/remove round-trip through `traecli mcp`. Follows [0034](archive/0034-qoder-runtime.md), which added qoder and established that a runtime is mostly a catalog edit.
+
+## Problem
+
+`RUNTIME_DEFINITIONS` (`router/runtime.rs:37`) has three entries. TRAE CLI is a **Codex fork**, so the adapter is codex's, cloned. The evidence is direct rather than inferred: the rollout envelope records `"originator":"codex_exec"`, and the CLI ships codex's `-c key=value` TOML override flag, the `model_reasoning_effort` config key, the `[mcp_servers.<name>]` config shape, `-a/--ask-for-approval`, `-s/--sandbox`, a `resume` subcommand, `apply`, `--oss`, and the `sessions/YYYY/MM/DD/rollout-<ts>-<uuidv7>.jsonl` layout — rooted at `~/.trae/cli/sessions` rather than `~/.codex/sessions`.
+
+What makes this more than a catalog edit is that trae is the **second capture-needing runtime**. Qoder took the claude-code path (caller-supplied `--session-id`), which is why 0034 decision 1 deliberately avoided `codex_capture.rs` and non-goal 3 deferred generalizing it on the grounds that "no third self-assigning runtime is on the table." Trae is not self-assigning, so that module has to be parameterized now.
+
+Five specific traps, all confirmed in the current tree or by probe:
+
+1. **`codex_capture` is hardcoded to codex twice over.** The sessions root is built inline as `$HOME/.codex/sessions` (`codex_capture.rs:91`), and capture is gated on `runner.runtime == "codex"` at three spawn sites (`spawn.rs:489`, `:876`, `:1336`). Trae's root carries an extra segment (`.trae/cli/sessions`), so a naive copy of the codex arm produces a runtime that never captures a key and therefore never resumes.
+2. **`--session-id` looks like self-assignment but is not.** The flag exists on both the interactive and `exec` surfaces, documented as "Legacy compatibility flag for selecting or naming the session." Probed directly: `traecli exec --session-id d0971d26-…` produced rollout `019fa1b9-a133-7841-…` and our supplied id appears in **no** rollout file. Mirroring qoder or claude-code here would persist an `agent_session_key` that can never resume, and the failure is silent until the user restarts a chat.
+3. **Frontend session-key polling is codex-gated.** `MissionWorkspace.tsx:454`, `:482` and `RunnerChat.tsx:530` all test `runtime === "codex"` before polling for the captured key. Backend capture can work perfectly and the key readout still never populates.
+4. **`runtime_purges_on_resume` (`output.rs:828`) is a denylist** — `!matches!(runtime, Some("claude-code") | Some("qoder"))`. For once the inverted default is *correct*: trae repaints its whole frame on resume exactly like codex, so it must be left out of that list. Adding it "for symmetry" with the other three-runtime match arms would garble resumed panes.
+5. **Directory trust gate.** `traecli exec` in an untrusted or non-git directory fails with `Not inside a trusted directory and --skip-git-repo-check was not specified`. Codex has the same posture, so this is not new, but Runner spawns into arbitrary project cwds and the first spawn in a fresh directory may block on trust.
+
+## Key Decisions
+
+1. **Clone codex's adapter, not claude-code's.** `resume_plan("trae", Some(uuid))` → `args: ["resume", uuid]` with `prepend: true` (subcommand prefix, same as codex); fresh spawn → empty args and `assigned_key: None`, with the key arriving post-spawn through capture. Trae also accepts a `--resume <uuid>` flag form, but the subcommand form is what codex's branch already encodes and what the caller's prepend plumbing expects.
+2. **Parameterize `codex_capture`'s sessions root; do not rename the module.** Add the resolved root to `CaptureRequest` (or a `runtime` field plus a `sessions_root_for(runtime)` helper) so `run()` stops building the path inline, and widen the three spawn gates to `matches!(runner.runtime.as_str(), "codex" | "trae")`. Renaming `codex_capture` / `CodexCaptureContext` / `SessionHandle.codex_capture` / `capture_codex_session_key` to something runtime-neutral is a large diff with zero behavior change — leave the names and add a module doc line stating it serves codex-lineage runtimes. Everything else in the module already generalizes: the rollout envelope, the `payload.id` field, the pid-owned-rollout scan, the cwd + start-time fallback, and the `claimed_rollouts` de-dup are all identical for trae.
+3. **Permission modes mirror codex exactly.** `permission_mode_args("trae", Auto)` → `["--ask-for-approval", "on-request", "--sandbox", "workspace-write"]`; `Bypass` → `["--ask-for-approval", "never", "--sandbox", "workspace-write"]`; `AcceptEdits` → empty (no equivalent, reads as Default). `strip_permission_flags("trae")` → `[("--ask-for-approval", true), ("--sandbox", true)]`. Trae *also* declares a qoder-style `--permission-mode default|bypass_permissions|auto`, deliberately unused: two mechanisms for one setting invites drift, and codex's pair is what the semantics table already encodes. Only `--sandbox read-only` was live-probed; the remaining values are help-declared with value sets identical to codex's.
+4. **Model and effort come free; model suggestions do not.** Trae joins codex's arm in `model_effort_args` — `-m/--model` plus `-c model_reasoning_effort=<lowercased>`, and the user's own `~/.trae/traecli.toml` already carries a `model_reasoning_effort` key, confirming the config path. Effort options mirror codex's TOML enum. `MODEL_SUGGESTIONS_BY_RUNTIME` gets **no** trae key (degrades to `[]`): its catalog is internal and differs from codex's `gpt-5-codex` family, and `traecli models` is the source of truth if suggestions are wanted later.
+5. **MCP registration reuses codex's TOML writer.** `~/.trae/traecli.toml` stores servers as `[mcp_servers.<name>]` with `command` / `args` — verified by adding and removing a probe entry. `codex_status_at` / `codex_write_at` work unchanged behind a new path function. This matters more than for codex: the file is mode `600` and also holds auth state and per-hook trust hashes, so the `toml_edit` document-preserving write (which those functions already use) is load-bearing, and only `mcp_servers.runner` may be touched. `traecli mcp add/remove` exists but is not used — direct config editing is how Runner already treats codex.
+6. **Catalog and internal docs yes, public README no.** Trae is an internal-edition binary authed against Trae with plugins sourced from `code.byted.org`; it is not publicly installable. Advertising it in the README tagline and feature copy would be a promise readers can't act on. The runtime simply activates when `traecli` is on `PATH`. `docs/arch/arch.md` and the MCP tool doc comment do get it, since those are engineering references.
+7. **Hook-based session status is out of scope.** Worth recording because it is a genuine find: the probe run emitted `hook: UserPromptSubmit` and `hook: Stop`, and `traecli.toml` carries per-hook trust state for `user_prompt_submit`, `pre_tool_use`, `post_tool_use`, `post_tool_use_failure`, and `stop` — claude-code's event vocabulary, declared in a plugin's `hooks.json`, with `--dangerously-bypass-hook-trust` available for automation. That would make trae a first-class runtime for spec 52. The open question is whether a Runner-owned hook can be injected per-spawn via `-c hooks…=` overrides without writing to the user's config, which spec 52 requires. Not investigated here.
+8. **No `--skip-git-repo-check`, no launch gate.** Keep the same posture as codex: don't pass trust-bypass flags speculatively. If spawns into non-git project cwds fail, that becomes a follow-up with a real reproduction. Likewise no `enter_claude_launch_gate` equivalent — that exists for claude-code's OAuth refresh race, and adding a serialization gate for a hypothetical would slow every multi-slot mission.
+
+## Goals
+
+- Trae is selectable everywhere the other three runtimes are: direct-chat runtime picker, runner form, and crew slot runtime override.
+- Settings → Agents shows a TRAE CLI row with its detected `traecli` path, an override field, and the same states as the other runtimes.
+- Settings → MCP can register or unregister Runner in `~/.trae/traecli.toml` without disturbing auth or hook-trust state.
+- A trae direct chat spawns, takes its first turn via positional argv, captures its rollout id into `agent_session_key`, stops, and resumes into the same conversation.
+- A trae mission slot receives its worker preamble and responds to inbox nudges.
+- A missing or deleted rollout falls back to a fresh spawn instead of an error loop.
+
+## Non-Goals
+
+- Hook-based session status (decision 7) — belongs to spec 52.
+- Renaming `codex_capture` and its types to runtime-neutral names (decision 2).
+- Model suggestions for trae (decision 4).
+- Trae's `--permission-mode` preset flag (decision 3), `--worktree` isolation, `--add-dir`, `--search`, and `--no-alt-screen`.
+- README and public-facing runtime enumerations (decision 6).
+- Generating `src/components/ui/runtimes.ts` from the Rust catalog — still a hand-port, as 0034 left it.
+
+## Implementation Phases
+
+### Phase 1 — Rust catalog and adapter (`src-tauri/src/router/runtime.rs`)
+
+- **Append** to `RUNTIME_DEFINITIONS` (`:37`): `{ name: "trae", display_name: "TRAE CLI", command: "traecli" }`. Append, never prepend — `RUNTIME_OPTIONS[0]` is the Create Runner default and a `runtime_status.rs` test indexes `.runtimes[0]`.
+- `resume_plan` (`:612`): add a `"trae"` arm mirroring codex — `Some(k) if is_uuid(k)` → `args: ["resume", k]`, `prepend: true`, `assigned_key: Some(k)`, `resuming: true`; otherwise the fresh-spawn shape with `assigned_key: None` (decision 1). Note trae's ids are UUIDv7, which `is_uuid` accepts.
+- `first_turn_argv` (`:522`): add `"trae"` to the `"claude-code" | "codex" | "qoder"` arm. Mandatory — without it a trae lead spawns with no persona and no mission goal.
+- `model_effort_args` (`:107`): add `"trae"` to codex's arm (`--model`, `-c model_reasoning_effort=<lowercased>`).
+- `permission_mode_args` (`:198`) and `mode_match_pairs` (`:372`): trae arms mirroring codex per decision 3.
+- `strip_permission_flags` (`:263`): `"trae"` → `&[("--ask-for-approval", true), ("--sandbox", true)]`.
+- `src-tauri/src/commands/mcp.rs`: add Trae to the client enum (`:66`), the snippet and status structs (`:12`, `:22`), a `trae_path()` returning `~/.trae/traecli.toml`, and wire status/write to the existing `codex_status_at` / `codex_write_at` (decision 5) — the same delegation pattern `qoder_status_at` uses for claude-code.
+- Tests: extend the multi-runtime loops (`:1548` and the permission matrix near `:1090`) to include trae; add resume-plan coverage for both the fresh and prior-key arms; assert the strip set round-trips.
+
+### Phase 2 — Capture parameterization, spawn and output policy
+
+- `src-tauri/src/session/codex_capture.rs`: replace the inline `$HOME/.codex/sessions` (`:91`) with the root carried on `CaptureRequest`; add `sessions_root_for(runtime)` mapping `"codex"` → `.codex/sessions` and `"trae"` → `.trae/cli/sessions`; keep the `is_dir()` bail-out. Update the module header (`:6-7`) to name both runtimes.
+- `src-tauri/src/session/manager/spawn.rs:489`, `:876`, `:1336`: widen `runner.runtime == "codex"` to `matches!(runner.runtime.as_str(), "codex" | "trae")` and pass the resolved root into `CodexCaptureContext`.
+- `src-tauri/src/session/manager/spawn.rs:189-200` `codex_capture_prompt_marker`: same widening, so trae gets the prompt-marker disambiguation path that protects sibling chats in one cwd.
+- `src-tauri/src/session/manager/spawn.rs:550`, `:936`, and the third site near `:1421`: add `"trae"` to the first-turn-not-delivered warning gate so the diagnostic stays honest.
+- `src-tauri/src/session/manager/output.rs:812-815` `runtime_clears_on_resize`: add `Some("trae")`.
+- `src-tauri/src/session/manager/output.rs:843` `runtime_purges_on_resume`: **no change** — leaving trae out of the exception list is what gives it codex's purge-on-resume (trap 4). Add a test asserting it, so a later "symmetry" edit fails loudly.
+- `src-tauri/src/session/manager/output.rs:813` full-repaint list and the `Some("claude-code") | Some("codex") | Some("qoder")` arm near `:874`: add trae.
+- Tests in `session/manager/tests.rs`: mirror codex's purge-on-resume coverage for trae, add a trae case to the `resolve_runtime_override` matrix, and cover `sessions_root_for` for both runtimes.
+
+### Phase 3 — Frontend
+
+- `src/components/ui/runtimes.ts`: **append** to `RUNTIME_OPTIONS` (`:18`) — `{ value: "trae", label: "trae", defaultCommand: "traecli", description: "TRAE CLI" }`. Add `"trae"` to `RUNTIMES_CLEARING_ON_RESIZE` (`:39`, in lockstep with Phase 2) and `RUNTIMES_WITH_PERMISSION_MODE` (`:55`). Add `PERMISSION_MODES_BY_RUNTIME` (`:178`), `MODE_MATCH_PAIRS_BY_RUNTIME` (`:245`), `PERMISSION_STRIP_KEYS_BY_RUNTIME`, and `EFFORT_OPTIONS_BY_RUNTIME` (`:85`) entries mirroring codex. Leave `MODEL_SUGGESTIONS_BY_RUNTIME` without a trae key (decision 4).
+- `src/pages/MissionWorkspace.tsx:454`, `:482` and `src/pages/RunnerChat.tsx:530`: widen the `runtime === "codex"` session-key polling guards to include trae (trap 3).
+- `src/components/settings/McpPane.tsx`: add the TRAE CLI registration toggle and manual-config copy action alongside the existing three.
+- `AgentsPane.tsx` needs no change — it renders from `status.runtimes` and `RUNTIME_OPTIONS`, with no hardcoded row count (0034's skeleton fix already landed).
+
+### Phase 4 — Docs and verification
+
+- `docs/arch/arch.md`: update the runtime enumerations and record trae's purge-on-resume / clear-on-resize policy alongside the others.
+- `src-tauri/src/mcp/tools/session.rs:16`: the `runtime` doc comment lists valid names — add trae.
+- `src-tauri/src/commands/slot.rs`: the invalid-runtime error lists valid names; extend the assertion at `:843` to cover trae.
+- Leave `README.md` untouched (decision 6).
+- Full check matrix: `cargo fmt --check`, `cargo clippy --workspace`, `cargo test --workspace`, `pnpm exec tsc --noEmit`, `pnpm run lint`, `pnpm test`.
+
+## Verification
+
+Automated:
+
+- [ ] `resume_plan("trae", None)` yields no args and `assigned_key: None`; with a prior UUID it emits `["resume", uuid]` with `prepend: true`.
+- [ ] `first_turn_argv("trae", body)` returns the positional body; suppressed on resume.
+- [ ] Permission round-trip: Auto → `--ask-for-approval on-request --sandbox workspace-write`; switch to Default → both flags stripped.
+- [ ] `model_effort_args("trae", Some("…"), Some("High"))` emits `--model` plus `-c model_reasoning_effort=high` (lowercased).
+- [ ] `sessions_root_for` returns `.codex/sessions` for codex and `.trae/cli/sessions` for trae.
+- [ ] `runtime_purges_on_resume("trae")` is **true**; `runtime_clears_on_resize("trae")` is true; the TS mirror agrees.
+- [ ] Unknown-runtime slot-override rejection lists trae among valid names.
+- [ ] Trae MCP registration creates `~/.trae/traecli.toml` when absent, preserves unrelated keys — specifically `[hooks.state]` entries and auth values — and removes only `mcp_servers.runner` when disabled.
+
+Manual (the human smoke-tests):
+
+- [ ] Direct chat: spawn, converse, stop, resume — conversation restored via `traecli resume <uuid>`, no blank grid.
+- [ ] `agent_session_key` populates within the capture window; the key readout appears in the chat meta line (confirms trap 3 is fixed).
+- [ ] Two trae chats started seconds apart in the **same** cwd each capture their own rollout id — no fused keys (exercises the prompt-marker and `claimed_rollouts` paths).
+- [ ] First-turn prompt auto-submits in interactive mode.
+- [ ] Mission slot: worker preamble lands; the inbox nudge submit chord (80ms Enter) lands in its input box.
+- [ ] Resize a trae pane mid-conversation — frame repaints cleanly, no shredded box-drawing.
+- [ ] Delete the rollout `.jsonl` and resume — falls back to a fresh spawn without an error loop.
+- [ ] Spawn a trae chat in a cwd that is not a git repo — record whether the trust gate blocks it (decision 8; becomes a follow-up if it does).
+- [ ] Settings → Agents shows a TRAE CLI row with the detected `traecli` path.
+- [ ] Settings → MCP toggles trae registration; a new trae session sees Runner tools.
diff --git a/docs/impls/0037-webgl-atlas-invalidation.md b/docs/impls/0037-webgl-atlas-invalidation.md
new file mode 100644
index 00000000..cfe53982
--- /dev/null
+++ b/docs/impls/0037-webgl-atlas-invalidation.md
@@ -0,0 +1,121 @@
+# WebGL texture atlas invalidation
+
+## Status
+
+Shipped. Tracking issue [#360](https://github.com/yicheng47/runner/issues/360). Wake behavior confirmed by smoke test on 2026-07-27. Phase 1's findings and the answered open questions are recorded below.
+
+## Problem
+
+After the Mac wakes from sleep, terminal panes repaint with glyphs jammed together — characters drawn at the wrong advance width, so `$ pnpm tauri dev` renders closer to `$pnpmtauridev`. Wrapping is correct; only the glyph raster is wrong. The WebGL renderer's texture atlas holds glyphs rasterized under conditions that no longer hold, and nothing tells it so.
+
+Three facts, all verified in the current tree:
+
+1. **`clearTextureAtlas()` has exactly two call sites** — `RunnerTerminal.tsx:1053` and `:1059`, both inside the storage-event handler for terminal font size and font family. Their comment already names this exact symptom: *"a stale cache after a font change can leave a band of pre-change glyphs at the new size until something else evicts them."*
+2. **The wake path never calls it.** Every wake trigger funnels into `refreshActiveTerminal`, which does `ensureWebglRenderer()` → `fit.fit()` → `t.refresh(0, t.rows - 1)`. `t.refresh()` only marks lines dirty; the renderer faithfully redraws them from the same stale atlas.
+3. **No backing-scale change is detected anywhere.** There is no `devicePixelRatio` listener in `src/` — the only DPR mentions are inside `windowSettle.ts`, which reads it but does not watch it.
+
+### A fourth trigger the issue doesn't cover
+
+`STORAGE_APP_ZOOM` is **absent** from the storage-event handler that clears the atlas on font changes (`RunnerTerminal.tsx:1042-1073` handles font size, font family, cursor style, scrollback, and theme — not zoom). App zoom changes the device-pixel size of every rendered glyph, so it stales the atlas exactly like a font-size change does.
+
+And a DPR watcher would not catch it. `windowSettle.ts:13-14`, written during #363, records the reason: **WebKit does not fold page zoom into `devicePixelRatio`** (webkit#124862). So under app zoom the atlas goes stale while every signal a naive watcher would monitor stays constant.
+
+This matters out of proportion to its size: **app zoom is a deterministic, on-demand reproduction of a bug that otherwise requires sleeping the machine.** Confirm it reproduces first, then develop against it.
+
+> **Corrected after testing (2026-07-27).** It does not reproduce. Jason smoke-tested it: app zoom leaves glyphs correctly spaced. The paragraph above is wrong about the mechanism — page zoom moves nothing the atlas config keys on, so `configEquals` returns true and the same atlas is reused rather than going stale. Zoom was never a repro, and this plan had no deterministic one; the rest of the work was verified at the source and by unit tests instead. Decision 4 still ships, downgraded from "most certain part of the fix" to cheap insurance — see the open questions.
+
+### Why the wake signal is the hard part
+
+`RunEvent::Resumed` → `app/resumed` (`lib.rs:554`) is the only trigger that forces the strong refresh, and it is an app-lifecycle event, not a system sleep/wake one. There is no `NSWorkspace.didWakeNotification` observer. A real system wake typically surfaces only as a window focus change, which routes to the non-forcing `scheduleWakeRefit()`.
+
+Hanging the atlas clear on focus is the obvious shortcut and the wrong answer: focus fires on every alt-tab, and clearing forces every glyph to re-rasterize on the next draw. Cheap once after a wake, wasteful dozens of times an hour.
+
+## Key Decisions
+
+1. **Clear the atlas on the events that actually invalidate it, not on a proxy for them.** Three distinct invalidators, each wired to its own real signal: system wake, backing-scale change, and app zoom. Focus is not one of them — it correlates with wake but fires constantly otherwise, and this codebase has already paid twice (#352, #363) for hanging behavior on a correlated proxy instead of the true signal.
+2. **System wake becomes a first-class signal via `NSWorkspace.didWakeNotification`.** Runner is macOS-only (AGENTS.md), so a native observer costs no portability, and `objc2-app-kit` is already a dependency — this needs an added feature, not a new crate. Emit it on the existing `<domain>/<verb>` event convention (e.g. `app/woke`) so the frontend consumes it like any other backend event. This is the precise signal; everything else is inference.
+3. **Watch backing scale directly.** A `matchMedia("(resolution: Xdppx)")` listener, re-registered on each change, catches lid-open-on-a-different-monitor and display re-initialization — cases where wake and scale change are independent. Note this is *complementary* to decision 2, not redundant: a GPU texture reset with unchanged scale fires no resolution change, and a monitor move fires no wake.
+4. **App zoom joins the existing font-change path.** Add `STORAGE_APP_ZOOM` to the storage-event handler beside font size and family, with the same `clearTextureAtlas()` + `refitAndPush()` treatment. ~~This is the smallest and most certain part of the fix, and the only one with a deterministic repro.~~ **Corrected: it was the *least* certain part.** Zoom does not reproduce the symptom (see above), so this clear is probably inert. Kept regardless — it is one predicate entry on an explicit Cmd +/- press, and "probably inert" is read off the addon source, not measured. Delete it on evidence, not on that reasoning.
+5. **Clearing is the whole remedy; do not add a forced repaint.** `clearTextureAtlas()` drops the cache and glyphs re-rasterize lazily on the next draw, which the existing `t.refresh()` on the wake path already triggers. Do **not** reach for the forced resize dance — it fixes geometry, not rasters, and #352 exists precisely because that dance was over-applied.
+6. **Do not clear on ordinary activation.** Tab switches and pane activation must stay atlas-preserving. If a case emerges where activation genuinely needs it, that is a separate finding with its own evidence, not an extension of this one.
+
+## Open questions — answered
+
+**Does `RunEvent::Resumed` fire on macOS system wake at all?** No, and worse than the issue assumed: it does not fire on macOS *at all*, wake or otherwise. Three links in the locked dependency tree:
+
+1. `tauri-runtime-wry` 2.10.1 `src/lib.rs:4038-4040` maps `RunEvent::Resumed` from `Event::NewEvents(StartCause::Poll)` — not from tao's `Event::Resumed`.
+2. The same function, `src/lib.rs:4029-4031`, forces `*control_flow = ControlFlow::Wait` on every iteration that is not `Exit`.
+3. `tao` 0.34.8 `src/platform_impl/macos/app_state.rs:329-347` produces `StartCause::Poll` only under `ControlFlow::Poll`; `ControlFlow::Wait` yields `WaitCancelled` or `ResumeTimeReached` instead.
+
+Independently, tao's own `Event::Resumed` is emitted only from the iOS (`platform_impl/ios/view.rs:615`) and Android backends — `platform_impl/macos/` contains zero occurrences. So decision 2 does **not** collapse; the native observer is required. The existing `app/resumed` handler is left in place untouched as a #352/#363-owned geometry path.
+
+**Does app zoom actually reproduce the symptom?** **No.** Smoke-tested by Jason on 2026-07-27: changing app zoom leaves glyphs correctly spaced. So this plan never had the deterministic repro it was counting on, and phase 1 delivered one empirical answer instead of two.
+
+The addon source agrees, which is worth recording because it explains *why* the plan's reasoning failed. The atlas config is keyed on `devicePixelRatio` plus the CSS-derived char metrics (`CharAtlasUtils.ts:34-53`, `configEquals` at `:55-76`). Page zoom moves neither: WebKit holds `devicePixelRatio` at the display scale (webkit#124862) and CSS char metrics are unchanged, so `configEquals` returns true and the same atlas is reused rather than going stale. The canvas device-pixel box *does* grow — `DevicePixelObserver` sees it and `_setCanvasDevicePixelDimensions` resizes the canvas — but the glyph shader normalizes against the deliberately-retained older `device.canvas` dimensions (`WebglRenderer.ts:680-690`, `GlyphRenderer.ts:324`), so the whole grid scales uniformly. Bigger, not misaligned.
+
+The plan's error was treating "changes the on-screen size of a glyph" as equivalent to "changes what the atlas keys on." They are different questions, and only the second one stales a cache.
+
+Decision 4 ships anyway, on Jason's call, downgraded to insurance: one predicate entry on an explicit Cmd +/- press, against a mechanism argument that is read off dependency source rather than measured. If WebKit rounds a CSS char metric differently at some zoom step the config genuinely differs, and this is the only thing that would notice. Delete it on evidence, not on the reasoning above.
+
+**Is a full atlas clear the right granularity?** Yes — it is the only granularity the addon exposes. `clearTextureAtlas()` is the sole invalidation entry point on the public surface (`typings/addon-webgl.d.ts`), implemented as `_charAtlas?.clearTexture(); _clearModel(true); _requestRedrawViewport()` (`WebglRenderer.ts:332-336`). Something narrower does exist internally — `acquireTextureAtlas` re-keys on a config that includes `devicePixelRatio` and char metrics (`CharAtlasUtils.ts:55-76`) — but it is reachable only from `handleResize` / `handleDevicePixelRatioChange`, not from the addon's API.
+
+**Multi-pane cost.** Acceptable; no staggering. Cheaper than the question assumed, for two independent reasons:
+
+- Mounted is not the same as holding an atlas. `RunnerTerminal` disposes the WebGL addon whenever a pane leaves the foreground (the `[active]` effect, `RunnerTerminal.tsx:1146-1152`), so only the handful of foreground panes have anything to clear. A pane that was its atlas's sole owner drops the cache entry outright on dispose (`CharAtlasCache.ts:85-100`).
+- The atlas is shared, not per-pane. `charAtlasCache` is module-level and keyed by config, so panes at the same font and scale hold one `TextureAtlas` between them — the re-rasterization is paid once, not once per pane.
+
+### A fifth finding: xterm already watches backing scale
+
+Fact 3 in the problem statement ("no `devicePixelRatio` listener in `src/`") is true of Runner's own code and false of the bundled dependency. xterm core ships `ScreenDprMonitor` (`CoreBrowserService.ts:65-135`) — the same `(resolution: Xdppx)` query, with the same re-registration-per-change — wired through `RenderService.ts:83` to `WebglRenderer.handleDevicePixelRatioChange()` (`WebglRenderer.ts:180-187`), which re-acquires an atlas keyed on the new ratio.
+
+A plain backing-scale change is therefore already handled upstream. Decision 3's listener still ships, as defense-in-depth for the case upstream misses: a display re-initialization that drops GPU texture contents while landing on a scale the config cache has already seen, where `configEquals` returns true and the stale entry is reused. It does not change the shape of the fix, and the cost is one clear on an event that fires when a monitor changes.
+
+## Goals
+
+- After system wake, panes repaint with correctly spaced glyphs, needing no window resize or font-size toggle.
+- Moving the window to a display with a different backing scale repaints correctly.
+- Changing app zoom repaints correctly.
+- Ordinary tab switching and focus changes do not clear the atlas.
+
+## Non-Goals
+
+- The forced resize dance, geometry, or PTY sizing — this is a raster-only defect (#352, #363 own the geometry paths).
+- Reverting the `@xterm/*` beta pin, which fixed a different upstream glyph-corruption bug and stays.
+- A DOM-renderer fallback or any change to `onContextLoss` handling for full context loss.
+- Cross-platform wake detection.
+
+## Implementation Phases
+
+### Phase 1 — reproduce deterministically
+
+- Confirm app zoom reproduces the overlapped-glyph symptom. If it does, it is the development repro for the rest of the work. → **It does not.** There is no deterministic repro; the rest of the work was verified at the source and by unit tests. See the open questions.
+- Verify whether `RunEvent::Resumed` fires on real system wake; record the answer, since it decides Phase 2's size. → It does not fire on macOS at all. See the open questions.
+
+### Phase 2 — wire the three invalidators
+
+All three landed. The invalidation rules moved out of the event handler into `src/lib/textureAtlas.ts`, since #360's shape was an invalidator missing from a list that lived inline in one `if`/`else` chain.
+
+- App zoom → `stalesTextureAtlas()` now owns the storage-key list (font size, font family, app zoom) and the handler consults it, so the list and its use cannot drift apart.
+- Backing scale → `observeBackingScale()`, a `(resolution: Xdppx)` listener re-registered on each change.
+- System wake → `src-tauri/src/wake.rs`: an `NSWorkspace.didWakeNotification` observer emitting `app/woke`, consumed in `RunnerTerminal`. `app/resumed` is untouched.
+
+### Phase 3 — verification
+
+- Tests where the seam allows: `src/lib/textureAtlas.test.ts` and `wake.rs`'s module test.
+- Full check matrix: `cargo fmt --all --check`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`, `pnpm exec tsc --noEmit`, `pnpm run lint`, `pnpm test`.
+
+## Verification
+
+Automated:
+
+- [x] An app-zoom storage event clears the atlas and refits — `stalesTextureAtlas` is asserted true for `STORAGE_APP_ZOOM`, and it is the sole gate on the handler's `clearTextureAtlas()` + `refitAndPush()`. Note this verifies the wiring, not a fix: zoom does not reproduce the symptom, so there is nothing here for the clear to repair.
+- [x] A resolution-change event clears the atlas — the watcher is asserted to fire on change and, crucially, to re-register at the new scale so the *second* display move is caught too.
+- [x] Ordinary activation / tab switch does **not** clear the atlas — held by construction rather than by a test: `clearTextureAtlas()` has exactly three call sites (storage handler, wake listener, backing-scale listener) and none is on the activation path. `stalesTextureAtlas` is asserted false for cursor style, scrollback, theme, unrelated keys, and `null`.
+- [x] The wake event reaches the terminal and clears — `wake.rs`'s test posts `NSWorkspaceDidWakeNotification` to the workspace notification center and asserts the observer runs, which pins the registration and the notification name without sleeping the machine. The `app/woke` → `clearTextureAtlas()` hop is a one-liner in the component, outside the unit seam.
+
+Manual (Jason smoke-tests):
+
+- [x] Change app zoom with visible output on screen → glyphs stay correctly spaced. Done 2026-07-27: they do, and they did before this change too — zoom never broke them.
+- [x] Sleep the Mac with an active pane, wake, focus Runner → glyphs correct without touching anything. **Confirmed 2026-07-27** — the fix works. This is the one that mattered: with no deterministic repro available, it is the only end-to-end evidence that clearing on `app/woke` repairs the symptom #360 reported.
+- [ ] Move the window between displays of different scale → glyphs correct. Not run — decision 3's watcher is unvalidated in the field, and xterm's upstream `ScreenDprMonitor` may be doing the real work here regardless.
+- [ ] Alt-tab repeatedly → no visible re-rasterization stutter. Not run — the no-clear-on-focus constraint is held by construction (no call site on the focus path), not by observation.
diff --git a/docs/impls/0038-launch-resume-fork-width.md b/docs/impls/0038-launch-resume-fork-width.md
new file mode 100644
index 00000000..158b857d
--- /dev/null
+++ b/docs/impls/0038-launch-resume-fork-width.md
@@ -0,0 +1,100 @@
+# Launch auto-resume: fork at the width the pane will actually have
+
+## Status
+
+Implemented. Tracking issue [#363](https://github.com/yicheng47/runner/issues/363). **Corrects [0035](0035-auto-resume-width-and-opt-in.md)**, which fixed the steady state of this bug but left the transient — see Problem.
+
+## Problem
+
+0035 closed two real holes: a stopped session's row can now learn its pane geometry (decision 1), and a pane re-asserts its size when its session goes live (decision 2). Both are right, and both operate *after* the PTY has already forked.
+
+The launch path never supplies a width at all. `resumeOnLaunch` passes `cols: null, rows: null` (`src/lib/api.ts:371-377`), so `resume()` resolves the size as `cols.zip(rows).or(snap.last_cols.zip(snap.last_rows)).unwrap_or(DEFAULT_PTY_SIZE)` (`spawn.rs:1078-1080`) — meaning a launch resume always forks at **last-quit geometry**, or 80×24 when the row has none.
+
+The fork happens first. The agent CLI immediately emits its resume banner and first repaint at that width. Only then does `reassertSizeRef` (`RunnerTerminal.tsx:830`, fired on `wentLive` at `:295`) correct the grid. For claude-code and qoder — excluded from `runtime_purges_on_resume` (`output.rs:843`), so they keep their ring across a resume — those mis-wrapped lines are permanent. No reflow undoes wrapping the agent performed itself.
+
+So 0035 made the *eventual* state correct and left the *visible* damage in place. Two of its decisions need correcting:
+
+- **0035 decision 3 ("do not fix this with ordering") was too strong.** The concern — don't let correctness depend on winning a race — was sound, but it hardened into a blanket ban on sequencing. Awaiting window restore is not racing; it is a data dependency. Window restore (impl 0027) lands asynchronously while `consumeResumeOnLaunch` drains its queue on a 300ms stagger (`src/lib/autoResume.ts`), so any geometry read before restore settles is wrong regardless of who wins.
+- **0035 decision 4 ("no correct width to give background sessions") was too pessimistic.** `terminalGridFromPixels`, `terminalGridFromElement`, `estimateMissionTerminalGrid`, and `pickRespawnDims` (`src/lib/terminalSizing.ts`) already derive a grid with no mounted terminal. An estimate from *this* launch's window beats a persisted value from the previous quit — which is stale by construction whenever the window moved, resized, changed display scale, or the session now renders in a split.
+
+## Key Decisions
+
+1. **Await window-restore settle before the first fork.** The auto-resume queue must not start until the window has its final geometry. This is a correctness prerequisite for every dims computation below, not a timing optimization — no computed width is trustworthy before it. How to observe "settled" is the main open question (see below); pick one mechanism and make it explicit rather than sleeping a magic number.
+2. **Supply dims at the call site; stop passing `null`.** `resumeOnLaunch` gains cols/rows. `session_resume` already threads them (`commands/session.rs:620-649`), so this is caller-side only. Precedence for what to send, best first: **measured** from the session's laid-out container when it has one → **estimated** from current window geometry via the `terminalSizing.ts` helpers → **persisted** `last_cols`/`last_rows` → `DEFAULT_PTY_SIZE`. Today's chain starts at step three; this prepends the two rungs that reflect reality.
+3. **Prefer deferring over guessing for panes that are about to exist.** A session whose pane is mounting should fork against that pane's real measurement rather than an estimate. Where that can be awaited cheaply without stalling the queue, do it; where it cannot, fall to the estimate. Do not stall the whole queue on one pane — a slow or never-mounting surface must not block the rest (0035's failure-tolerance rule from #320 still holds).
+4. **0035's decisions 1 and 2 stay, as the safety net.** Persisting geometry while stopped and re-asserting on going live remain correct and still catch anything dropped in flight (session exiting mid-resize, crash-and-resume, window handoff). This impl removes the need for them to be the *primary* mechanism, not the mechanisms themselves.
+5. **Record the correction in 0035.** Its decisions 3 and 4 currently read as considered acceptances, and someone will hit this again if they stand unqualified. Add a short note pointing at 0038 and this issue.
+
+## Open questions — resolved
+
+- **Observing window-restore settle → the native frame is the authority; the viewport is the observation.** `window_state::restore` runs inside Tauri `setup`, before the webview evaluates any script, so the native frame is already final when the frontend starts; what lags is the webview's layout catching up to it (and `app_ready` reveals the window on top of that). `awaitWindowGeometrySettle` (`src/lib/windowSettle.ts`) therefore polls until the viewport width agrees with `getCurrentWindow().innerSize().width` — an agreement check against an authoritative value, not quiescence and not a sleep. It returns instantly in the common case where they already agree. `WINDOW_SETTLE_CEILING_MS = 1500` is the named ceiling: hitting it (or finding no Tauri window at all, as in browser preview) logs the outcome and proceeds rather than hanging. Width only — it is the dimension that decides wrapping, and both axes settle in the same layout pass.
+
+  Both sides are normalized to **logical points**, and the page zoom is taken from `readAppZoom()` rather than inferred from `devicePixelRatio`. WebKit does not fold page zoom into `devicePixelRatio` (webkit#124862) — it reports the display scale and lets `innerWidth` absorb the zoom — and wry implements `setZoom` as `WKWebView.setPageZoom`. A DPR-based identity would therefore never converge at any non-100% app zoom, and every launch would silently burn the full ceiling. `windowSettleDeps` isolates that conversion so it is covered by tests rather than by assumption.
+
+  A Tauri window event was considered and rejected as the primary path: `onResized` fires only if a resize *happens*, so the common "already correct" launch would wait for the ceiling every time — a timer wearing an event's clothes.
+- **Multi-window → does not arise at launch.** Secondary windows are created on demand by `window_open` with a fresh `window-<ulid>` label (`commands/window.rs`); nothing recreates them at startup, and the main window always boots on `/runners`. At the moment the queue drains, `main` is the only window that exists, so main-window geometry *is* the destination geometry for every stamped session. If window-set restoration ever lands, this becomes real and the dims resolver needs a per-window destination.
+- **Split panes → `estimateMissionTerminalGrid` does not handle the divisor, and should not.** It sizes the mission workspace, which shows one slot terminal at a time. The chat surface is where splits live, so the divisor is threaded in separately: `chatPaneAreaBox()` (`terminalSizing.ts`) yields the tab's whole pane area from `<main>` minus the chat topbar and side panel, and `paneBoxForSession()` (`paneLayout.ts`) divides that area the way `ChatPaneGroup` renders it — percentage splits over the axis minus each 1px separator, clamped to the Panels' 120px `minSize`, then the 34px pane header and 1px focus-ring border once the tab is grouped. The clamp matters because a window restored narrower than the one the sizes were dragged in makes the raw percentages a lie, and Runner sets no window `minWidth`: a pane under the floor renders at the floor with its sibling paying, and once the axis is under 2× the floor neither can be satisfied — the library pins both to the same minimum, and since panels lay out as `flexBasis: 0` with `flexGrow` set to their layout value, two equal values split the axis evenly however lopsided the drag was. Pane *count* alone would not do: `main-2` and `cols-3` are both three panes with different boxes. The layouts come from persisted tab state, so this needs nothing mounted; `App.tsx` awaits `hydratePaneLayoutsFromDb()` alongside the settle gate so the divisor is never read from an unhydrated store.
+
+## Goals
+
+- A session resumed at launch forks at the width it will be displayed at, so its banner and first repaint need no post-hoc correction and leave no mis-wrapped scrollback.
+- Changing the window size, display, or sidebar width between quit and relaunch does not produce mis-wrapped output.
+- A session whose row has no persisted geometry no longer falls to 80×24 when the window's actual geometry is knowable.
+- One slow or absent pane does not block the rest of the resume queue.
+
+## Non-Goals
+
+- Reverting or weakening 0035 decisions 1–2.
+- Repairing scrollback already mis-wrapped by earlier versions — impossible once the agent wrapped it.
+- Changing the 300ms stagger, the quit-side stamp, resume-never-fresh-spawn, or crash-never-stamps (#320).
+- Making auto-resume default-on again.
+
+## Implementation Phases
+
+### Phase 1 — settle gate
+
+- `src/lib/windowSettle.ts`: `awaitWindowGeometrySettle` resolves once the webview viewport agrees with the native frame, or at `WINDOW_SETTLE_CEILING_MS`, or immediately when there is no native window to read. Deps are injected so the gate is testable without a window.
+- `src/App.tsx`: awaited before `consumeResumeOnLaunch`, together with `hydratePaneLayoutsFromDb()` — the other input the dims computation depends on. Originally gated on the resume toggle; since #367 the settle gate runs on every launch, because the mission grid-hint push (`estimateMissionTerminalGrid()` → `mission_grid_hint_set`) needs settled geometry regardless of the toggle. Only the pane-layout hydration remains toggle-gated; the default-off path still clears stamps right after the gate.
+- Tests (`windowSettle.test.ts`): settles with no wait when the viewport already agrees; waits out a lagging viewport; accepts rounding within tolerance; returns `timeout` at the ceiling instead of hanging; returns `unavailable` when the native size cannot be read.
+
+### Phase 2 — dims at the call site
+
+- `src/lib/api.ts`: `resumeOnLaunch` takes cols/rows.
+- `src/lib/autoResume.ts`: `resolveLaunchDims` owns the precedence and guards each source; `consumeResumeOnLaunch` takes a `dimsFor` callback and resolves per session *after* the stagger, with its own guard so one unresolvable session cannot abort the drain.
+- `src/lib/launchDims.ts`: wires the two frontend rungs. Measured = `terminalGridFromHostElement` on the `data-terminal-session` host RunnerTerminal now stamps (works for both surfaces, no surface-local registry needed). Estimated = chat pane box for tab members, `estimateMissionTerminalGrid()` otherwise.
+- The last two rungs stay in the backend: a `null` from here is exactly what selects `last_cols`/`last_rows` then `DEFAULT_PTY_SIZE` in `spawn.rs`, so the chain is unchanged below the frontend.
+- Tests (`autoResume.test.ts`, `launchDims.test.ts`, `paneLayout.test.ts`): precedence order; a session with no mounted pane still gets an estimate rather than `null`; a throwing or degenerate source falls through; one failing resolution does not abort the queue; pane boxes for single / 2-column / nested presets.
+
+### Phase 3 — verification and record
+
+- Regression covered at both seams: the queue sends the resolved dims rather than `null, null` (`autoResume.test.ts`), and the estimate tracks the current window rather than anything persisted (`launchDims.test.ts`).
+- Append the correction note to 0035 decisions 3 and 4.
+- Full check matrix: `cargo fmt --check`, `cargo clippy --workspace`, `cargo test --workspace`, `pnpm exec tsc --noEmit`, `pnpm run lint`, `pnpm test`.
+
+### Incidental fixes
+
+Two pre-existing errors in `estimateMissionTerminalGrid`. Both are fixed here rather than deferred, because this impl newly feeds that helper's output to launch resume — left alone they would reintroduce #363 through a different input. `StartMissionModal`'s spawn dims get the same corrections.
+
+- **Rail width.** The width was read as `Number(localStorage.getItem(...))`, and `Number(null)` is `0` — finite, so the "nothing stored" branch never ran and an un-dragged rail estimated at the 200px minimum instead of its 288px default, overstating the terminal by 88px. The shared `storedSideWidth` helper now falls to the default when nothing is stored.
+- **Header height.** `MISSION_HEADER_HEIGHT_PX` was 88, but the mission topbar is `h-11` (44px) with only the `h-[38px]` tab strip below it — the estimate was subtracting 44px of chrome that does not exist and forking several rows short. Corrected to 44.
+
+`missionPaneAreaBox()` was split out of `estimateMissionTerminalGrid` so both surfaces' chrome arithmetic is assertable without a real xterm fit — the mock in `launchDims.test.ts` cannot see a stale constant, which is how this one survived.
+
+## Verification
+
+Automated:
+
+- [x] `resumeOnLaunch` sends non-null dims whenever any rung of the precedence chain resolves.
+- [x] Precedence order holds: measured → estimated → persisted → default. The frontend covers the first two rungs and the `null` that selects the last two; the persisted → default resolution itself is 0035's Rust coverage, unchanged.
+- [x] The consumer waits for the settle signal, and degrades rather than hangs if it never arrives.
+- [x] A per-session dims failure does not stop the queue.
+
+Estimates are close, not exact: both estimates model chrome from constants (topbar, tab strip, pane header, separator, focus-ring border, pane minimum) rather than measuring it, so a layout change that moves those constants silently biases the estimate. The 0035 re-assert still corrects the grid afterwards — but only the *fork* width prevents mis-wrapped scrollback, so these constants are load-bearing. `terminalSizing.test.ts` and `paneLayout.test.ts` assert them directly for that reason; a drifted constant should fail a test, not a smoke test.
+
+Manual (Jason smoke-tests):
+
+- [ ] Enable auto-resume, run a claude-code chat, quit, **resize the window materially**, relaunch → banner wraps at the new width, no mis-wrapped lines in scrollback.
+- [ ] Same across a display with different scaling.
+- [ ] Same with the sidebar widened, and with the session in a 2-pane split.
+- [ ] A session with no persisted geometry no longer comes back at 80 columns.
+- [ ] Several sessions resuming together all come back correct, none blocking the others.
diff --git a/docs/impls/0039-resize-storm-coalescing.md b/docs/impls/0039-resize-storm-coalescing.md
new file mode 100644
index 00000000..4a0f860c
--- /dev/null
+++ b/docs/impls/0039-resize-storm-coalescing.md
@@ -0,0 +1,56 @@
+# Resize storm coalescing: one settled purge per drag, one writer per session
+
+## Status
+
+Implemented. Tracking issue [#373](https://github.com/yicheng47/runner/issues/373). Corrects the resize-frequency assumption in [0024](archive/0024-resume-scrollback-preservation.md) decision 5. The purge semantics themselves (impl 0020 / #306) and the launch fork-width machinery ([0038](0038-launch-resume-fork-width.md), verified correct by the #373 production log) are unchanged.
+
+## Problem
+
+For full-repaint TUI runtimes, every cols change purges the entire output ring (`SessionManager::resize`, `src-tauri/src/session/manager/output.rs`) — correct for genuinely stale-width bytes, catastrophic when cols changes arrive in storms. The v0.4.7 `[launch-dims]` instrumentation caught production doing exactly that: ~50 cols-gate purges in 90 seconds across three claude-code sessions, wiping the resume transcripts that 0038's correctly-sized forks had just repainted. Three distinct defects were visible in the log:
+
+1. **No coalescing.** A live drag (window edge, sidebar, split gutter) emits one width per frame; every intermediate width purged the full ring and forced a claude repaint, and the final width often equaled the starting width — the whole storm was for nothing.
+2. **A transient garbage fit got pushed.** A ~209-col session received a 78×23 push from a container measured mid-hydration. The source: a terminal wrapper in `ChatPaneGroup`'s flat stack that geometry sync had not yet positioned. An unplaced absolute wrapper shrink-wraps xterm's default 80×24 canvas, which minus padding measures as a plausible-looking ~78×23 box.
+3. **Two writers fought over one session.** A `76 ↔ 209 ↔ 76 ↔ 210` oscillation: the visible owning pane and a background keep-alive mount (hidden pool / background tab under `visibility:hidden`) alternately pushing different sizes for the same session — the #313 desync class inside a single window.
+
+## Key Decisions
+
+1. **The debounce lives in the backend (`SessionManager::resize`), not in the frontend push path.** Considered both; backend wins on four counts. (a) Every push funnels there — `pushSize`, the activation refresh-push, the resize dance, the went-live reassert, stopped-pane geometry persistence, and any future caller — so one chokepoint covers them all, where a frontend debounce would need to wrap N sites and still miss the next one. (b) The round-trip skip needs the cols the ring's bytes were emitted at (`last_pty_cols`); that is backend ground truth, while the frontend's `lastPushed` mirror is deliberately reset by the disabled-change and rejected-push bookkeeping. (c) A backend debounce also coalesces storms produced by *multiple* writers — a per-mount frontend debounce cannot see the other mount. (d) The behavior under test is "how many purges", and the purge lives in Rust; the tests land next to it.
+2. **Rows-only pushes stay synchronous.** The activation dance forces a SIGWINCH repaint by nudging rows−1 → rows with cols held constant; a debounce would collapse those two ioctls into one same-size kernel no-op and kill the dance. Rows-only changes never purge (reflow depends on cols alone, #306), so there is nothing to coalesce away. Same-cols pushes therefore apply immediately, exactly as before — including the failed-ioctl error propagation.
+3. **Only clears-on-resize runtimes debounce.** Shells never purge, so a debounce would buy nothing and cost 175ms of drag latency for a TUI running inside the shell. Their cols changes keep the synchronous ioctl.
+4. **Trailing debounce with a storm-scoped settle thread, 175ms quiescence.** A cols change parks in `SessionState::pending_resize`; every further push (any cols/rows) folds in, extends the deadline, and bumps a suppressed counter. One thread per storm sleeps out the deadline and applies: the child **always lands on the settled size**, and the purge fires only when the settled cols differ from `last_pty_cols` — a drag that ends where it began costs zero purges. 175ms sits in the issue's 150–200ms band: above a drag frame, small enough that the settle repaint feels attached to the drag ending.
+5. **Every settle guarantees the repaint the owner's viewport clear is waiting for.** The owning pane hard-clears its visible region per (real) push to prevent the stacking artifact, and with the debounce no repaint arrives until settle — so the settle must produce one. A purge settle does via the genuine width-change SIGWINCH. A round-trip settle would be a same-size `TIOCSWINSZ` — a kernel no-op with no SIGWINCH — leaving the pane blank until the next output; it therefore applies the rows nudge (rows−1 → rows, width constant, the same trick the frontend dance documents) to force the repaint. On the frontend, `shouldClearViewportBeforePush` pins the other half of the pairing: only a `verdict === "push"` may clear, so a suppressed or deduped push can never blank a viewport that no repaint will restore. **Correction (post-ship):** the per-push owner clear did not survive contact with a still-streaming TUI. `ESC[2J ESC[H` moves the cursor, and with the repaint debounced to the settle, claude's mid-turn relative-positioned output kept flowing against the moved cursor — smearing frames across the viewport on every settings-and-back navigation (the pre-#373 code had the same clear, but its synchronous SIGWINCH re-anchored the frame before more output arrived). The clear now lives backend-side: a width-change settle prepends an in-band `ESC[2J ESC[H` to the just-purged ring and the live event stream — emitted under the session state lock, which strictly orders it ahead of the repaint bytes (the PTY reader's ingest needs that lock) — so every mount clears exactly once, immediately before the repaint. A round-trip settle clears nothing (the retained grid was never wrong; the rows nudge only re-anchors), and the frontend no longer writes `ESC[2J` at all: `shouldClearViewportBeforePush` and the activation dance's local clear are removed.
+6. **The existing resize contracts survive the debounce.** `update_last_size` still runs at request time, so pane geometry persistence stays prompt while the ioctl waits (the row already carries the next fork's desired size). A failed settled ioctl logs a warning, drops the storm, and leaves `last_pty_cols` untouched — a failed live resize still cannot trigger a ring purge; the caller-visible error is gone (there is no caller left at settle time), which is acceptable because the retry path is the next real resize.
+7. **The settle is linearized against kill/respawn under the state lock.** Two windows make anything weaker unsound. First, `kill` sets `killed = true` and only then enters `runtime.stop`, which can spend hundreds of ms reaping the child — the handle/pending teardown runs after stop returns, so a settle firing inside that window sees a live-looking state. Second, the runtime resolves `RuntimeSession` by reusable session id and a respawn *overwrites* that mapping, so an ioctl issued outside the lock could physically resize a child spawned after the storm — a generation check on the commit alone cannot prevent the ioctl itself. So the whole settle — take `pending_resize`, validate, ioctl, commit — runs as one critical section under the session's state lock, aborting if `killed` or `resuming` is set or the handle is gone. The ordering argument: `kill` sets `killed` under this lock *before* stop begins, and `resume` sets `resuming` under this lock *before* `runtime.spawn` can overwrite the map — so a settle that finds neither flag holds the lock while the id still resolves to the child it measured, and no teardown or replacement can start until it releases. An earlier draft used a `resize_epoch` generation validated at commit; it was dropped for the linearization because it could not stop the physical ioctl (review of #373). Holding the lock across a `TIOCSWINSZ` stalls output ingestion only for the ioctl's duration.
+8. **Settle-gate via wrapper placement, not a size heuristic.** `ChatPaneGroup`'s geometry closure now tracks which sessions have been positioned from a real pane-body rect; until then the wrapper is held at 0×0, and a zero-rect pane body refuses to place. That makes an untrustworthy box *unmeasurable* — the same state as a `display:none` pane — so `RunnerTerminal`'s existing rect guards hold every fit and push with no new props, no re-render plumbing, and no magic minimum size. The signal is the layout-hydration state ChatPaneGroup itself owns; when placement lands, the pane's own ResizeObserver drives the first real fit.
+9. **Single-writer via `active`.** `sizePushVerdict` (`src/lib/terminalResize.ts`) is the one gate for backend size pushes: dedupe first (`unchanged`), then transitional suppression (`resizeDisabled`), then ownership — only the visible owning pane (`active`, i.e. surface visible ∧ in-layout ∧ not transitional) may write. Hidden-pool wrappers and background `visibility:hidden` surfaces observe without writing (**correction, post-ship:** without refitting either — a non-owner refit reflows the live byte stream through a grid the PTY isn't at, mis-wrapping every line and stacking duplicated frames into the retained buffer during mid-stream tab switches; the grid now stays frozen at the owner-consistent size until activation refits and pushes together); the activation refresh pushes the new owner's size when a surface comes to the front, so ownership hands over exactly there. This deliberately retires the old "invisible persistent surfaces keep their PTY geometry current" behavior — that standing write was defect 3. The single-window case only; the #313 multi-window ownership protocol is out of scope.
+10. **Instrumentation extends #372 in the same log file.** Backend: `cols-gate purge: … (N coalesced)` on a settled width change, `cols-gate settle: … round-trip, purge skipped (N coalesced)` on a free storm. Frontend, mirrored into `runner.log` via `frontend_log`: `[resize-gate] suppressed-transitional|suppressed-nonowner session=… WxH` (throttled to one line per second per mount) and `[resize-gate] pane-placed session=… WxH` once per wrapper placement. A launch now reads as: fork at real width → pane-placed → first-fit → at most one settle line per storm.
+
+## Goals
+
+- A continuous drag across a live claude-code pane costs at most one purge and one repaint, at the final width; a drag that returns to its starting width costs zero purges and exactly one restoring repaint (the settle's rows nudge — see decision 5).
+- A measurement from an unsettled or transitional container is never pushed to the PTY.
+- Exactly one surface pushes sizes for a session; background mounts never fight it.
+- After a launch with auto-resume, a claude-code pane retains the transcript its resume repaint produced, and the log file proves the storms were coalesced.
+
+## Non-Goals
+
+- Changing the purge semantics. Genuinely stale-width bytes still purge (impl 0020 / #306); this impl only removes purges whose "stale" width was never applied.
+- The full multi-window ownership protocol (#313). `active` resolves the writer within one window; cross-window ownership stays as-is.
+- Debouncing the local xterm fit. Panes keep tracking their container live; only the backend push coalesces.
+- Restoring scrollback already lost to earlier storms.
+
+## Implementation Notes
+
+- `src-tauri/src/session/manager/mod.rs` — `PendingResize`, `SessionState::pending_resize`, `RESIZE_SETTLE_MS` (175), `resize_settle_ms` field (test-tunable), `install_handle` storm invalidation.
+- `src-tauri/src/session/manager/output.rs` — `resize()` split into synchronous same-cols / non-clearing paths and the debounced cols-change path; `settle_pending_resize` (apply + round-trip skip + logs); test-only `settle_pending_resize_now`. The now-unused `purge_output_buffer_keep_modes` helper is folded into the settle (the buffer-only clear happens under the same lock as the `last_pty_cols` update).
+- `src-tauri/src/session/manager/lifecycle.rs` — `kill` drops `pending_resize` with the handle.
+- `src/lib/terminalResize.ts` — `sizePushVerdict`.
+- `src/lib/frontendLog.ts` — `logResizeGate` (`[resize-gate]` lines into `runner.log`).
+- `src/components/RunnerTerminal.tsx` — `pushSize` / `pushBackendResize` routed through the verdict; suppression logging; comments updated for the retired hidden-surface write.
+- `src/lib/paneGeometry.ts` — `createPaneGeometry` moved out of `ChatPaneGroup.tsx` (the export for tests tripped `react-refresh/only-export-components` there), gaining placement tracking, the 0×0 hold for unplaced wrappers, zero-rect body refusal, and the `pane-placed` log.
+
+## Validation
+
+- Rust (`session::manager::tests`): a resize storm collapses to one settled ioctl and one purge; a round-trip storm produces zero purges and forces the repaint via the rows-nudge ioctl pair; a failed settled ioctl purges nothing and leaves the gate unmoved for the retry; rows-only resizes stay synchronous and keep the ring; the settle thread applies without a manual nudge; a settle firing inside kill's stop window (blocking-stop gate) aborts with no ioctl and no purge; a stale settle after kill + respawn touches neither the fresh PTY nor its ring, and the respawn's seeded cols still gate the next push.
+- Frontend (vitest): `sizePushVerdict` — transitional and non-owning mounts push nothing, the visible owner pushes, unchanged sizes dedupe silently; `shouldClearViewportBeforePush` — the viewport clear pairs only with real owner pushes, never with suppressed or deduped ones; `createPaneGeometry` — unplaced wrappers hold at 0×0, zero-rect bodies refuse to place, detach re-gates.
+- Manual smoke: drag the sidebar, a split gutter, and the window edge across a live claude-code pane — scrollback survives with at most one repaint at the end of the drag, and a round-trip drag keeps its full ring and ends with the single nudge-forced repaint; quit and relaunch with auto-resume — `runner.log` shows `pane-placed` before `first-fit`, coalesced settle lines instead of purge storms, and no transient-width purge; scroll history is intact afterwards.
diff --git a/docs/impls/0040-paste-file-paths.md b/docs/impls/0040-paste-file-paths.md
new file mode 100644
index 00000000..ecf55a50
--- /dev/null
+++ b/docs/impls/0040-paste-file-paths.md
@@ -0,0 +1,56 @@
+# Paste file paths: re-land #368 without touching the image flow
+
+## Status
+
+Planned. Tracking issue [#368](https://github.com/yicheng47/runner/issues/368), spec [55](../features/55-paste-file-paths.md). This is a **redo**: the feature first landed in PR [#369](https://github.com/yicheng47/runner/pull/369) (commit `61cafd2`) and was reverted when main was rebuilt around the #372 spawn-width fix. The reverted code survives on the local branch `fix/367-368-spawn-width-and-paste-paths` and is the reference implementation — most of it re-lands verbatim; the one behavioral correction is decision 1.
+
+## Problem
+
+Copying a file (Finder, GoLand) puts only `public.file-url` flavors on NSPasteboard — no text — so ⌘V over a Runner terminal inserts nothing. Native terminals insert the POSIX path. The webview cannot recover the path (`DataTransfer` withholds it by design), so it must be read from NSPasteboard in Rust.
+
+The reverted implementation solved this but changed an existing behavior along the way: its precedence rule ("a file reference beats image bytes") made a Finder-copied `shot.png` paste its *path*, where today it attaches as an image via `inferPasteImageMime`'s extension fallback. The re-land constraint is explicit: **the current image paste behavior (#79 / #234) must not change in any case.**
+
+## Key Decisions
+
+1. **Image scan first, text second, path branch only in the dead zone.** (Inverts the reverted feature-55 decision 3.) `onPaste` keeps main's exact ordering: the synchronous image scan runs first and anything it catches — screenshot bytes, browser image copies, and Finder-copied image *files* — takes the existing attach flow untouched. Non-empty `text/plain` still falls through to xterm's default paste. Only when both come up empty (today: the handler returns and nothing is inserted) does the new branch commit the event and consult the pasteboard. Structurally this guarantees the constraint: every clipboard shape that does something today is dispatched before the new code runs.
+
+2. **Reuse the reverted orchestration module, reordered.** `src/lib/terminalPaste.ts` from `61cafd2` re-lands as the testable orchestration (`handleTerminalPaste` + effects interface), with its internal order changed from text→file→image to image→text→file per decision 1. Its helpers re-land unchanged: `clipboardHasUsableText`, `shellQuotePath` (quote only when whitespace/metacharacters require it, `'` → `'\''`), `formatPastedPaths` (space-joined), and the `normalizePasteImageMime` / `inferPasteImageMime` pair moves out of `RunnerTerminal.tsx` with it. `RunnerTerminal.tsx`'s `onPaste` shrinks to reading refs and delegating.
+
+3. **Reuse the reverted Rust command as-is.** `session_clipboard_file_paths` (`commands/session.rs` on the reference branch): direct `objc2` NSPasteboard binding reading `NSPasteboardTypeFileURL` per item, decoding through `NSURL` (never hand-parsing the percent-encoded URL), filtering non-file URLs, returning `Vec<String>` of POSIX paths; empty when the flavor is absent; `#[cfg(not(target_os = "macos"))]` returns empty. Requires adding `NSPasteboard` (and the `NSPasteboardItem`/`NSURL` support it compiles against) to the `objc2-app-kit` feature list in `src-tauri/Cargo.toml` and registering the command in `lib.rs`. Its doc comment must be updated: the flavor no longer decides path-vs-attach (that was old decision 3); it only feeds the dead-zone branch.
+
+4. **Commit synchronously, act asynchronously.** `preventDefault` after an `await` is a no-op, so the handler reads the event synchronously (image scan + text check), commits with `preventDefault` + `stopImmediatePropagation`, then goes async to the pasteboard command. An empty result swallows a paste that had nothing to insert anyway.
+
+5. **Insert via `injectStdin`, never `inject_paste`.** A pasted path is mid-sentence; `inject_paste` appends Enter. The draft gate (spec 54) sees ordinary local input.
+
+6. **Frontend API surface**: one `api.ts` addition, `session.clipboardFilePaths(): Promise<string[]>`, mirroring the `session.pasteImage` call shape from the reference branch.
+
+## Goals
+
+- Copying a non-image file and pasting into a pane inserts its absolute path (quoted only when needed); multiple files insert space-separated.
+- Every clipboard shape that does something today — text, screenshot bytes, browser image copy, Finder-copied image file — behaves byte-for-byte as before, with no added IPC on those paths.
+- The orchestration is unit-tested without mounting xterm, including regression tests pinning the image-file-still-attaches behavior.
+
+## Non-Goals
+
+- Drag-and-drop of files onto a pane (different Tauri mechanism, its own spec).
+- Directory contents, cwd-relative rewriting, `text/uri-list` parsing.
+- Any change to the image-paste flow, the #367/#372 spawn-width machinery, or `inject_paste` semantics.
+
+## Implementation Notes
+
+- `src-tauri/Cargo.toml` — add `NSPasteboard` feature(s) to `objc2-app-kit`; `objc2-foundation` may need `NSURL` if not already enabled.
+- `src-tauri/src/commands/session.rs` — `session_clipboard_file_paths` (from `61cafd2`, doc comment corrected per decision 3).
+- `src-tauri/src/lib.rs` — register the command.
+- `src/lib/api.ts` — `session.clipboardFilePaths`.
+- `src/lib/terminalPaste.ts` — orchestration from `61cafd2`, reordered per decision 1; mime helpers move here from `RunnerTerminal.tsx`.
+- `src/lib/terminalPaste.test.ts` — re-land the reverted suite, updated for the new precedence; add the two pinning tests: image *bytes* attach, image *file* attaches (not a path), even with file-urls on the pasteboard.
+- `src/components/RunnerTerminal.tsx` — `onPaste` delegates to `handleTerminalPaste` with effects wired to `api.session.*` and `onErrorRef`; keep the `!sid || disabledRef.current` early return ahead of everything.
+
+Reference diff: `git show 61cafd2` (or diff the local branch `fix/367-368-spawn-width-and-paste-paths`) — take only the paste-path files; the spawn-width/db changes there are superseded by #372 and must not be re-landed.
+
+## Validation
+
+- Frontend (vitest, `terminalPaste.test.ts`): text paste → null return, no IPC; image bytes → attach flow + Ctrl-V; **image file with file-urls present → attach flow, never a path** (the redo's pin); non-image file → committed, quoted path injected; multiple files → space-separated; empty pasteboard → committed no-op; quoting table (bare, spaces, embedded `'`, metacharacters).
+- Rust: quoting lives frontend-side, so backend needs no new logic tests beyond compile; `cargo test --workspace` for regressions.
+- Checks: `cargo fmt --check`, `cargo clippy`, `cargo test --workspace`, `pnpm exec tsc --noEmit`, `pnpm run lint`, `pnpm test`.
+- Manual: the spec's Verification list, run by Jason in the app — most importantly ⌘⇧4 screenshot paste and Finder image-file paste behaving exactly as v0.4.7.
diff --git a/docs/impls/0041-draft-aware-delivery-gate.md b/docs/impls/0041-draft-aware-delivery-gate.md
new file mode 100644
index 00000000..6e4c5cc0
--- /dev/null
+++ b/docs/impls/0041-draft-aware-delivery-gate.md
@@ -0,0 +1,60 @@
+# Draft-aware delivery gate: replace the one-way input latch with a line model
+
+## Status
+
+Planned. Tracking issue [#359](https://github.com/yicheng47/runner/issues/359), spec [54](../features/54-draft-aware-delivery-gate.md).
+
+## Problem
+
+`local_input_pending` (`session/manager/mod.rs`) is a boolean latch: any printable byte sets it (`classify_local_input`, `session/manager/output.rs:16`), and only `\r`, `\x03`, respawn, or lifecycle transitions clear it. Backspace, Ctrl-U, and Escape are `ActivityOnly` — they refresh the 2s recency window and leave the latch set. Type one stray character into a mission slot and backspace it away: the box is empty, the latch is true, and `reserve_delivery` returns `PendingInput` forever. The blocked outbox never schedules a retry (unlike `RecentlyTyping`, which does), so the mission silently stops coordinating.
+
+The existing UI surface makes it worse, not better: `InboxBlockedPill` ("typing detected, delivery paused") offers exactly one action, gated on idle — a button that injects `\r`. That *submits a line to the agent*: the destructive clear the spec exists to eliminate, shipped as the remedy.
+
+## Key Decisions
+
+1. **The draft is a bounded byte buffer on `SessionState`, not a boolean and not a length counter.** Word-delete (`\x17`) cannot be modeled by a counter, and a counter fails open the moment it undercounts. Keeping the actual bytes (capped at 4 KB; at the cap the buffer saturates and only a clearing key empties it — fail closed) gives every editing key faithful semantics, and the gate reads exactly one bit: `draft.is_empty()`. The buffer replaces `local_input_pending`; `last_local_input_at` stays.
+
+2. **`classify_local_input` grows from three classes to draft operations; `update_local_input_state` applies them under the existing lock-and-rollback contract.** The byte→op table, per input chunk: printables and bracketed paste (`\x1b[200~…`) and `\x16` append; `\x7f`/`\x08` pop one char; `\x17` pops a word; `\x15` clears; a lone `\x1b` clears (Esc empties the composer in claude-code and codex); `\r` clears (submitted); `\x03` clears (interrupted); `\x1b\r` — the Shift+Enter newline the frontend sends — **appends**, closing a pre-existing hole where a multi-line draft never set the latch at all; **Up/Down arrows (`\x1b[A`/`\x1b[B`) mark the draft non-empty** — in claude-code and codex, Up at an empty composer recalls a previous message into it, so treating arrows as neutral would fail open into a recalled draft; Left/Right/Home/End and every other CSI sequence cannot materialize content and stay activity-only. Unknown control bytes: activity-only, content untouched — when unsure the model stays non-empty, and the backstop and manual clear are the release valves. `inject_direct_stdin`'s write-failure rollback snapshots and restores the whole draft struct, as it does the latch today.
+
+3. **`RECENT_LOCAL_INPUT_WINDOW` survives unchanged.** Emptiness and recency answer different questions: an empty buffer says the box is clean, the 2s window says fingers may be mid-keystroke. Both remain conditions in `reserve_delivery` and `input_quiescent` (resolving the spec's open question: keep it).
+
+4. **The abandonment backstop is enforced lazily at reserve time and retried on schedule — the `RecentlyTyping` pattern, not a timer thread.** `DRAFT_ABANDON_WINDOW` = 10 minutes (const, with a test-tunable field mirroring `resize_settle_ms`). `reserve_delivery` with a non-empty draft whose `last_local_input_at` is older than the window proceeds as `Ready` — after ten untouched minutes the draft is abandoned by definition and unblocking the mission wins. Below the window it returns `PendingInput` carrying the remaining time, and the router schedules an outbox retry at that deadline exactly as it already does for `RecentlyTyping`. This also fixes the silent-stall property for free: a gated outbox always has a scheduled future.
+
+5. **The manual clear injects `\x15` — no new command, no model-only lie.** A clear that only reset Runner's model would leave the human's characters sitting in the composer, and the next delivery would collide with them; the model must not diverge from the box. Injecting Ctrl-U through the normal `inject_direct_stdin` path actually empties the composer line (readline-universal; supported by the claude-code and codex composers), never submits, never interrupts, and flows through the same classifier — so model and reality clear together, `InputCleared` fires, and the blocked outbox retries through the existing listener. The pill's `\r`-injecting button is replaced by this, and the idle-only gating on the action drops (clearing a draft is safe regardless of agent state).
+
+6. **Surfacing rides the existing `DeliveryBlockedEvent` machinery untouched.** `blocked_transition` already distinguishes draft-gated (`pending_input_blocked`) from in-flight, and the pill only renders for the draft case — the state model was the missing piece, not the event plumbing. The pill copy becomes explicit about whose input is blocking ("delivery paused — you have unsent input here") with the always-available "Clear draft" action. No new slot states, no sidebar changes.
+
+7. **Every latch lifecycle site moves to the draft struct.** Respawn/`install_handle` reset, the `kill` and lifecycle clears (`lifecycle.rs`), `purge_session_buffers`, the prune-empty condition on `SessionState`, and `input_quiescent` all swap `local_input_pending` for `draft.is_empty()` mechanically. `reserve_delivery`'s contract is otherwise unchanged.
+
+## Goals
+
+- Type a character into a slot, backspace it away → the inbox delivers with no Enter, no Ctrl-C, no waiting.
+- A real unsubmitted draft — typed or pasted, single- or multi-line — keeps the gate closed; submit or clear it and delivery resumes immediately.
+- A forgotten draft stops blocking after `DRAFT_ABANDON_WINDOW`, and a gated outbox always has a scheduled retry — no silent stalls.
+- The pill's action never submits to or interrupts the agent.
+
+## Non-Goals
+
+- Weakening the gate: delivering into a live draft remains the failure this design must never introduce.
+- Hook-based agent status (spec 52/#347) — orthogonal axis, unchanged.
+- Screen-scraping the TUI input line; changing the injection mechanism, inbox pull model, or outbox semantics beyond the PendingInput retry scheduling.
+- Cursor modeling. The buffer ignores cursor position; only emptiness feeds the gate (Left/Right/Home/End are activity-only; Up/Down are content events per decision 2, not cursor tracking).
+
+## Implementation Notes
+
+- `src-tauri/src/session/manager/mod.rs` — `DraftState` struct (buffer + cap), replaces `local_input_pending` on `SessionState`; `DRAFT_ABANDON_WINDOW` + test-tunable field; `reserve_delivery` emptiness/abandonment logic; `input_quiescent`; prune-empty condition; respawn reset.
+- `src-tauri/src/session/manager/output.rs` — classifier table per decision 2; `update_local_input_state` applies ops and reports the empty↔non-empty transition for `InputCleared`.
+- `src-tauri/src/session/manager/lifecycle.rs` — clear sites move mechanically.
+- `src-tauri/src/router/mod.rs` — `DeliveryReservation::PendingInput` carries the abandonment remaining-time; the reservation handler schedules the outbox retry (mirror of the `RecentlyTyping` arm).
+- Instrumentation: every empty↔non-empty transition logs one `[draft-gate]` line to `runner.log` with the cause (`printable`, `paste`, `history-recall`, `backspace-emptied`, `esc`, `submit`, `interrupt`, `abandoned`, `manual-clear`, `saturated`) — the resize-gate pattern; "why is this inbox blocked" becomes answerable from one file.
+- `src/components/InboxBlockedPill.tsx` — copy per decision 6; action injects `\x15` via the existing `api.session.injectStdin`; idle gating on the action removed.
+- Reference for the editing state machine shape: Orca's `pendingShellCommandLine` model in `~/repos/orca/src/renderer/src/components/terminal-pane/pty-connection.ts` (~line 1246 as of this writing; the spec's line numbers have drifted) — borrow the transitions, not the design (spec 54 §prior art: Orca's own delivery behavior is the anti-pattern). Runner's port drops the cursor, adds `\x1b\r`, lone-Esc, and bracketed-paste handling.
+
+## Validation
+
+- Rust unit tests on byte sequences against the model: type→backspace-to-empty opens the gate; Ctrl-U opens; lone Esc opens; Up/Down close an empty draft (history recall); Left/Right/Home/End leave an empty draft open and a non-empty one closed; Shift+Enter (`\x1b\r`) keeps it closed; bracketed paste keeps it closed until `\r`; `\x03` opens; unknown control bytes leave content untouched; the 4 KB saturation stays non-empty until a clearing key.
+- `reserve_delivery` tests: non-empty draft → `PendingInput` with remaining time; empty draft + recent activity → `RecentlyTyping`; abandoned draft (tunable window pinned low) → `Ready`; write-failure rollback restores the draft.
+- Router test: a PendingInput reservation schedules an outbox retry that fires after abandonment and delivers.
+- Frontend: pill renders the draft copy and the clear action calls `injectStdin` with `\x15`; no idle gating.
+- Manual (spec 54's checklist): stray-keystroke recovery, real-draft gating, Ctrl-U, paste, abandonment, manual clear neither submits nor interrupts, gated-vs-busy visual distinction.
+- `cargo fmt --check`, `cargo clippy`, `cargo test --workspace`, `pnpm exec tsc --noEmit`, `pnpm run lint`, `pnpm test`.
diff --git a/docs/impls/0042-mission-channel-composer.md b/docs/impls/0042-mission-channel-composer.md
new file mode 100644
index 00000000..0bde7287
--- /dev/null
+++ b/docs/impls/0042-mission-channel-composer.md
@@ -0,0 +1,53 @@
+# Mission channel composer: the human becomes a message author
+
+## Status
+
+Planned. Tracking issue [#378](https://github.com/yicheng47/runner/issues/378). Design: `design/mission-feed-composer.pen` — frame `c78FA` (feed + composer), `c78FB` (targeted chip state), `ASuQW` (@ mention picker), note `c78Nt`.
+
+## Problem
+
+The mission feed reads like a group channel but the human can't speak into it. Runners already have the full channel: `runner msg post "<text>"` appends a broadcast message (`to: null`), the router nudges every roster handle at a turn boundary, `runner msg read` projects `to == null OR to == handle`, and the worker preamble teaches both verbs (`router/prompt.rs:108-109`). The CLI even removed `--to human` deliberately — reporting to the channel replaced replying to a person. The only actor without a posting surface is the human: the workspace can emit `human_said` (a targeted stdin inject, lead by default) and `human_response` (ask-card answers), but no channel post. Issue #378's runner-side scope is therefore already shipped; what remains is the composer.
+
+## Key Decisions
+
+1. **Human channel posts are ordinary `message` events — no new signal type.** `EventDraft::message` with `from: "human"`, `to: None` (channel) or `to: Some(handle)` (targeted mail). Every consumer already handles them: `message_nudge` fans a broadcast out to the whole roster minus the sender (`handlers.rs:196` — "human" is never in the roster, so all runners get nudged); `runner msg read`'s inbox projection picks them up; unread accounting and the delivery gate apply per recipient; `EventFeed` renders message rows natively. This resolves the issue's naming question: neither `channel_said` nor a generalized `human_said` — no signal at all.
+
+2. **One new command, `mission_post_human_message`, beside the signal command — and exposed over MCP.** Mirrors `mission_post_human_signal_impl`'s shape (running-mission guard, `EventLog::open` + append, event returned): payload is `{ text, to: Option<handle> }`; refuses a `to` outside the crew roster and refuses `to: "human"`. Exposed as an MCP tool with the same contract so external actors (Claude posting into a mission) can speak channel instead of only `human_said`-injecting the lead.
+
+3. **`human_said` stays untouched as the immediate-attention exception.** It injects into one runner's stdin now (through the draft-aware gate); channel posts wait for a turn boundary via the nudge machinery. Two verbs, two urgencies — the composer speaks mail, MCP keeps both.
+
+4. **Composer UI: a pinned input at the bottom of the feed tab, primary window only.** Plain textarea (Enter posts, Shift+Enter newline), placeholder "Message the crew — @handle to address one runner". Typing `@` at position 0 opens a roster picker above the box (design frame `ASuQW`): one row per slot — handle, `role · runtime` subtitle — filtered as you type, ↑↓ to move, Enter/Tab selects into an accent chip, Esc dismisses. Only a leading `@` targets: mid-text `@` never opens the picker, and an unrecognized `@word` falls through as plain broadcast text. The secondary (read-mostly) workspace stays read-only — it already suppresses PTY interaction, and the composer follows (resolves the issue's open question).
+
+5. **Feed treats human messages as human-authored.** `isHumanAuthored` (`EventFeed.tsx`) extends to `kind === "message" && from === "human"` so the feed commits to bottom on your own post, and the row styling matches the existing `human_said` message-row treatment.
+
+6. **No preamble changes.** Runners already learn both message verbs; the human reading the channel is implicit in the feed being the log. The crew-prompt guidance about *when* to broadcast belongs in crew `system_prompt_addendum` text (user data), not this impl.
+
+## Goals
+
+- Type into the feed composer, hit Enter → the post appears in the timeline, every runner gets an inbox nudge at its next turn boundary, and `runner msg read` shows it.
+- `@reviewer fix the naming` → only @reviewer is nudged; the feed shows the targeted row.
+- Claude (over MCP) can post channel messages into a running mission.
+- No new routing, signal types, or delivery semantics anywhere.
+
+## Non-Goals
+
+- Changing `human_said`/`human_response`, the ask-card flow, or any injection path.
+- Runner-side changes — the CLI, nudge fan-out, and inbox projection ship as-is.
+- Mentions-highlighting in feed rows, message editing/deletion, read receipts, or fuzzy search in the picker (it's a prefix filter over 2-3 handles).
+- The secondary-window composer.
+
+## Implementation Notes
+
+- `src-tauri/src/commands/mission.rs` — `mission_post_human_message_impl` + command beside `mission_post_human_signal_impl` (~:435); roster lookup for `to` validation via the crew's slots.
+- `src-tauri/src/mcp/tools/mission.rs` — MCP tool registration mirroring `mission_post_human_signal`.
+- `src-tauri/src/lib.rs` — command registration.
+- `src/pages/MissionWorkspace.tsx` — composer mount under the feed (`feedActive`), gated on `!isSecondary`; posts via a new `api.mission.postMessage`.
+- `src/components/EventFeed.tsx` — `isHumanAuthored` extension; message-row styling parity for `from === "human"`.
+- `src/lib/api.ts` — `mission.postMessage(missionId, text, to?)`.
+
+## Validation
+
+- Rust: command tests — broadcast append shape (`kind=message`, `from=human`, `to=None`), targeted append with roster validation, refusal for unknown handle, `to="human"`, and non-running mission; router test that a `from="human"` broadcast nudges every roster handle and a targeted one nudges only its recipient.
+- Frontend (vitest): mention-picker state machine (`@` at position 0 opens, mid-text `@` doesn't, typing filters, Enter/Tab commits a chip, Esc dismisses, unknown `@word` falls through as text); `isHumanAuthored` classification for human message rows.
+- Manual: post a broadcast into a live 2-slot mission → both runners nudged at turn boundaries and `runner msg read` shows it; `@handle` post nudges only that runner; post from MCP; composer absent in the secondary window; Enter/Shift+Enter behavior.
+- `cargo fmt --check`, `cargo clippy`, `cargo test --workspace`, `pnpm exec tsc --noEmit`, `pnpm run lint`, `pnpm test`.
diff --git a/docs/impls/0043-model-catalog-refresh.md b/docs/impls/0043-model-catalog-refresh.md
new file mode 100644
index 00000000..952bed40
--- /dev/null
+++ b/docs/impls/0043-model-catalog-refresh.md
@@ -0,0 +1,55 @@
+# Model catalog refresh: seed codex, add fable, catch up effort enums
+
+## Status
+
+In implementation. Tracking issue [#386](https://github.com/yicheng47/runner/issues/386) — resolved in reduced form: the dynamic `codex app-server model/list` path stays documented in the issue but is not built; the catalog stays hardcoded with free-text passthrough, per the Orca precedent (`agent-session-option-catalog-claude-codex.ts`: "keep this seed short and allow unknown persisted ids to pass through"). During implementation, the mission author explicitly expanded the scope to mirror every model visible in the installed Codex catalog, add model/effort controls to Direct chat creation, and make detected agents user-enableable across Start Chat and runner create/edit selectors.
+
+## Problem
+
+The picker machinery already exists — `ModelField` is an editable combobox over `MODEL_SUGGESTIONS_BY_RUNTIME`, with `default` (empty → no `--model` flag) listed explicitly and free text always allowed; `EFFORT_OPTIONS_BY_RUNTIME` feeds the effort dropdowns. But the data is stale in three ways:
+
+1. **codex offers only `default`** — the old comment ("codex has no alias scheme — full names rot every release") predates a usable equilibrium: a short seed of current names plus passthrough covers reality, and is what Orca ships.
+2. **claude-code's list predates the Claude 5 family** — no `fable` alias, while `claude --model fable` is the top-tier pick.
+3. **codex's effort list stops at `xhigh`** — the enum comment was verified against codex-cli 0.130.0; current codex (0.146.0) advertises and accepts `max` (in daily use here) and `ultra` for some models.
+4. **Direct chat creation cannot select model or effort** — the runner forms already have the picker machinery, but Direct mode only selects an agent and working directory.
+5. **Agent selectors offer catalog entries that are not installed** — executable discovery already knows which agents are usable, but the selection surfaces do not consume that status or let the user hide an installed agent.
+
+## Key Decisions
+
+1. **Hardcoded seed + `default` + free text, both runtimes. No CLI querying.** The dynamic `model/list` fetch (verified working, see #386) is deliberately not built: it adds a subprocess JSON-RPC dance to solve staleness that passthrough already absorbs. Revisit trigger recorded in #386.
+2. **Codex mirrors the installed CLI's visible catalog:** `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`, `gpt-5.5`, `gpt-5.4`, `gpt-5.4-mini`, and `gpt-5.3-codex-spark`, with one-line descriptions from the refreshed catalog. Free-text passthrough still covers future, legacy, and private model names.
+3. **Claude seed adds `fable`**; keeps `opus`, `sonnet`, `haiku`. Aliases are version-stable so the list doesn't rot.
+4. **Effort lists stay per-runtime, not per-model.** codex gains `max` and (if the installed CLI accepts it) `ultra`. Per-model narrowing (sol-only tiers) is complexity the free-form enum doesn't need yet.
+5. **Verify against the installed CLIs, not docs.** On codex-cli 0.146.0 the prior invalid-value probe no longer returns the enum error, so the implementation records the refreshed catalog evidence instead: Sol/Terra advertise through `ultra`, Luna through `max`, and the remaining visible models through `xhigh`.
+6. **Direct chat defaults remain true sentinels.** Model and effort display `default`, serialize as `null`, and emit no CLI flag until the user selects a value. Runtime-only sessions persist chosen model/effort beside their recorded agent command so resume does not silently return to CLI defaults.
+7. **Agent availability is detected and user-controlled.** Selection surfaces offer only agents with a detected executable or valid override and whose Settings → Agents toggle is enabled. Toggles default enabled; disabling an agent preserves its override and existing runners but hides it from new Start Chat and runner create/edit choices. Internal APIs and stored fields keep the precise `runtime` term, while visible picker copy uses `Agent`.
+
+## Goals
+
+- Codex Model combobox shows `default` + the seven models visible in the verified refreshed catalog; typing any other name still works and persists.
+- Claude Model combobox shows `fable` alongside the existing aliases.
+- Codex Effort dropdown offers `max` (and `ultra` if verified accepted); existing stored values keep loading (the edit drawer's safe-value guard must not coerce a stored `max` once it is a listed option).
+- Direct chat creation exposes Model and Thinking effort with `default` selected semantically, and runtime-only resume preserves explicit choices.
+- Start Chat and runner create/edit Agent selectors show detected, enabled agents only; Settings → Agents owns a default-enabled toggle for each catalog agent.
+
+## Non-Goals
+
+- Dynamic `model/list` fetching over `codex app-server` (documented in #386, not built).
+- Per-model effort menus, `opus[1m]`-style variant aliases, or model pickers for qoder/trae beyond `default`.
+- #380 (surfacing default model/effort in the Agent view) — this refresh feeds it later but doesn't build it.
+
+## Implementation Notes
+
+- `src/components/ui/runtimes.ts` — `MODEL_SUGGESTIONS_BY_RUNTIME` (codex seed, claude `fable`), `EFFORT_OPTIONS_BY_RUNTIME` (codex `max`/`ultra`), and the stale comments beside both rewritten to match the verified catalog.
+- `src/components/RunnerEditDrawer.tsx` — keep the existing effort safe-value guard behavior with the widened list and pin it with coverage; the guard itself needs no behavior change.
+- `src/components/StartChatModal.tsx`, `src/lib/api.ts`, and the session command/manager — pass Direct mode model/effort into the transient runner and persist them for runtime-only resume.
+- `src/components/settings/AgentsPane.tsx`, `src/lib/settings.ts`, and shared selector helpers — persist default-enabled agent toggles and filter selections by executable status plus user preference.
+- `src/components/CreateRunnerModal.tsx` and `src/components/RunnerEditDrawer.tsx` — consume the same detected/enabled Agent option source while preserving an existing runner's disabled current agent until the user switches away.
+
+## Validation
+
+- Vitest: existing runtimes/ModelField tests extended — exact codex seed, claude list includes `fable`, codex effort list includes `max`; stored-value coercion test for a runner row with `effort: "max"`; Direct mode sentinel/pass-through tests; executable discovery refresh and detected/enabled filtering tests; runner create/edit filtering tests; Agents toggle persistence test.
+- Rust: runtime-only spawn records model/effort, and resume reconstructs the transient runner with both values.
+- CLI probes (documented in the updated comments): codex-cli 0.146.0 refreshed catalog inspected and the former invalid-value enum probe confirmed no longer diagnostic; claude alias set sanity-checked against `claude --help` model flag text.
+- `pnpm exec tsc --noEmit`, `pnpm run lint`, `pnpm test`.
+- Manual: create-runner and edit-drawer combos show the new entries; a free-typed unseeded model persists and round-trips; disabling an agent hides it from new choices without changing an existing runner.
diff --git a/docs/impls/0044-chat-style-feed.md b/docs/impls/0044-chat-style-feed.md
new file mode 100644
index 00000000..e8c06192
--- /dev/null
+++ b/docs/impls/0044-chat-style-feed.md
@@ -0,0 +1,51 @@
+# Chat-style mission feed: Discord-like rows and identicon identity
+
+## Status
+
+Planned. Design: `design/mission-feed-composer.pen` — frame "Feed · chat-style messages" (`rAJ00`), updated "Runners rail" (`LMQ7c`), decision notes (`Rauwg`). Builds on the #392 working-tree fixes already present in `MessageBody.tsx` / `EventFeed.tsx` (list gutter, mission-goal row, spacing). Revives the identicon idea from `docs/features/archive/11-runner-avatar.md`, which was specced but never shipped.
+
+## Problem
+
+The feed renders three different anatomies for what is conversationally the same thing: runner/human messages are bare header+body rows, the mission goal was (until the #392 fix) a bordered mono payload block, and signals are JSON boxes — including the raw `ask_human` signal, which renders its full JSON right above the `human_question` card that presents the same prompt, choices, and attribution properly. Identity is a monospace handle with a single accent color for every runner, so multi-runner missions have nothing for the eye to lock onto, and the rail's busy/idle state lives in a separate dot glyph next to the handle.
+
+## Key Decisions
+
+1. **Discord/Slack row anatomy.** Every conversational event renders as avatar + header (author, context, time) + body. Consecutive message-like events from the same author within a 5-minute window collapse into one group: avatar and header render once, subsequent bodies stack under them. Any non-message block (divider, signal line, ask card) breaks the group. Grouping is a pure function over the filtered event list so it is unit-testable.
+
+2. **`RunnerAvatar` identicon, deterministic from the handle.** 5×5 symmetric pixel grid (columns mirrored) generated from a handle hash, rendered on a raised rounded square — 35px in the feed, 25px in the rail. The same hash picks the runner's hue from a fixed palette of carbon-legible colors (accent green, cyan, violet, orange, …); the hue also colors the handle text wherever it appears. Amber (`warn`) is reserved for the human — "you" always renders amber with a fixed pattern and is never assigned to a runner. The LEAD marker stays a badge, not a color.
+
+3. **Message-like events share one renderer.** `message`, `human_said`, `human_response`, and `mission_goal` all go through the same message-row path. `mission_goal` keeps its small `GOAL` chip in the header (from the #392 fix) and an optional `→ @target`. `mission_start` becomes a thin centered divider (`MISSION STARTED · time`), Discord date-divider style.
+
+4. **Raw `ask_human` signals are hidden.** They are worker→router plumbing fully duplicated by the router's `human_question`; `isHiddenSystemSignal` grows to cover them. The `AskHumanCard` absorbs the identity: it renders inside the asker's avatar row with a `NEEDS YOUR INPUT` chip and the `→ you` chain in the header; card internals (prompt, choice buttons, resolved state) are unchanged.
+
+5. **Remaining signals become one-line rows.** `ask_lead`, `mission_warning`, and unknown types render as a single indented line — zap icon, author handle, `signal · type`, time — with a `payload ▾` disclosure that expands the existing payload rendering (the current JSON box body) inline. `mission_warning` keeps a danger tint on the line so diagnostics still stand out. Answered `ask_lead` lines stay visible; the feed remains a faithful log, no fading.
+
+6. **Rail cards adopt the same identity.** In `RunnersRail`, the avatar (25px) with the runner hue replaces the inline status dot; PTY/runner status moves onto the avatar corner as a presence dot (busy accent, idle dim accent, stopped gray, crashed danger — same priority order as today's `dotClass`). Handle text takes the hue. The rest of the card (LEAD badge, Open-PTY button, status subtitle, `session_key` row) is already aligned between code and the updated design frame and does not change structurally.
+
+## Goals
+
+- One visual grammar for every conversational turn; system rows visibly distinct but quiet.
+- A three-runner mission is scannable by color and pattern without reading handles.
+- Pure presentation: no event-schema, router, log, or backend changes; archived missions replay identically.
+
+## Non-Goals
+
+- Reactions, replies, hover action toolbars, message editing — Discord look, not Discord features.
+- Composer changes (shipped in impl 0042) or `AskHumanCard` flow/button changes.
+- Avatar images/uploads or per-runner color configuration; the mapping is deterministic.
+- Persisting payload-disclosure open state.
+
+## Implementation Notes
+
+- `src/components/ui/RunnerAvatar.tsx` — new. Props: `seed`, `size`, optional `presence`. Exports `hueForSeed(seed)` for handle coloring; special-cases the human seed to `warn`.
+- `src/lib/eventFeed.ts` — add `groupFeedBlocks(events)` returning typed blocks (`divider` | `message-group` | `signal` | `ask-card`); extend the hidden-signal predicate with `ask_human`.
+- `src/components/EventFeed.tsx` — render from `groupFeedBlocks`; message-group renderer (avatar column + header + stacked bodies via `MessageBody`); divider renderer; signal one-liner with payload disclosure reusing `renderPayload` as the expanded body.
+- `src/components/AskHumanCard.tsx` — header row becomes avatar + chip + chain per design; accepts the asker handle it already receives.
+- `src/components/RunnersRail.tsx` — swap dot for `RunnerAvatar` with presence; hue on handle text.
+- `src/components/MessageBody.tsx` — no changes beyond the #392 fixes already in the tree.
+
+## Validation
+
+- Vitest: `groupFeedBlocks` — same-author grouping inside/outside the 5-minute window, group break on interleaved signal, `mission_goal`/`human_said` classified message-like, `ask_human` hidden, `mission_start` → divider; `hueForSeed` determinism and human reservation.
+- Manual: live 2-slot mission — grouped runner turns, goal row, divider, signal disclosure toggle, ask card with avatar; archived mission replay renders identically; rail presence dot tracks busy/idle/stopped.
+- `pnpm exec tsc --noEmit`, `pnpm run lint`, `pnpm test`.
diff --git a/docs/impls/0045-codex-trust-preseed.md b/docs/impls/0045-codex-trust-preseed.md
new file mode 100644
index 00000000..d24b2bb4
--- /dev/null
+++ b/docs/impls/0045-codex-trust-preseed.md
@@ -0,0 +1,46 @@
+# Codex trust pre-seeding: mark the project trusted before spawn
+
+## Status
+
+Planned. Fixes [#404](https://github.com/yicheng47/runner/issues/404) and absorbs the codex half of the #403 trust pre-seeding direction. Supersedes the boot-stall cue previously specced under this number (`BootWatch` byte-budget heuristic) — that mission was archived and its working-tree changes reverted; the general "agent needs you" tier remains future work under `docs/features/52-hook-based-session-status.md`. Approach validated against orca (`~/repos/ai/orca`): `src/main/agent-trust-presets.ts` documents the artifact and why CLI flags are not equivalent; the format is verified there against codex's own source (`codex-rs/tui/src/onboarding/trust_directory.rs`).
+
+## Problem
+
+A codex session spawned in a project codex has never seen boots into the "Do you trust this folder?" onboarding modal. The dialog paints one screen and goes quiet: the byte-flow `IdleDetector` reports Idle, the rail shows a dim presence dot, and the mission reads as stopped while the first-turn goal argv (`spawn.rs`, `first_turn_argv`) sits undelivered behind it. Stray or injected input can answer the modal wrong and kill the CLI. Live repro 2026-08-17: the mission created to fix this bug was itself stopped by it. Claude-code does not hit this in practice; trae and qoder are second-class runtimes — support on demand if an issue appears.
+
+## Key Decisions
+
+1. **Prevent, don't detect.** Before spawning codex, write the exact trust artifact codex writes after the user accepts the dialog, so the dialog never renders and the first turn delivers normally. No detection heuristic: the reverted `BootWatch` byte-budget cue is dropped, and the broader "waiting on something" state arrives later with spec 52's hook-based status.
+
+2. **The artifact is a `trust_level` entry in `~/.codex/config.toml`.** Codex records per-project trust as `[projects."<absolute path>"]` with `trust_level = "trusted"`. Edit via `toml_edit::DocumentMut` — already a dependency, same doc-preserving pattern as `codex_write_at` in `commands/mcp.rs` — so the user's existing config survives byte-for-byte outside the added block. Match codex's own serialization: an explicit `[projects."<path>"]` table header, not an inline table.
+
+3. **Insert-only, never overwrite.** If the project already has any `trust_level` entry — `trusted` or `untrusted` — leave it untouched. Starting a session in a folder is the operator's trust decision and runner materializes it, but an explicit `untrusted` marking is also an operator decision and outranks ours. No write also means no file churn on the common already-trusted path.
+
+4. **Canonicalize, and resolve worktrees to the main repo root.** Codex realpaths before comparing, so seed the realpath of the resolved spawn cwd (macOS `/tmp` vs `/private/tmp`). If the cwd is a linked git worktree (`.git` is a file with `gitdir:` pointing into `<main>/.git/worktrees/<name>`), codex resolves trust at the **main repo root** — seed that instead. Validate the worktree backlink (`<gitdir>/gitdir` must point back to the cwd's `.git` file) before widening, so workspace-controlled `.git` contents can't trick us into trusting an arbitrary path — mirror orca's `resolveCodexProjectTrustRoot` checks. Harmless today; correct the day spec 61's worktree isolation lands.
+
+5. **Seed on every codex spawn path, non-fatally.** Mission spawn, direct chat, and resume all seed, gated on effective runtime == `codex` — the same no-op-for-other-runtimes shape as `enter_claude_launch_gate`. Seeding failure (unreadable config, permissions) logs a WARN and the spawn proceeds: the worst case is the dialog appearing, which is exactly today's behavior. Fail-safe forward too — if a codex update changes the trust format, the dialog comes back; nothing breaks.
+
+## Goals
+
+- A codex mission session in a fresh project delivers its first turn without human intervention; the trust dialog never renders.
+- The user's `~/.codex/config.toml` is preserved exactly outside the single added block; repeat spawns are no-ops.
+- No schema changes, no migrations, no frontend work.
+
+## Non-Goals
+
+- Claude-code, trae, or qoder trust handling — codex only, others on demand.
+- Boot-stall detection heuristics (superseded) or hook-based needs-you status (spec 52).
+- Login prompts or other boot-time modals that cannot be pre-seeded.
+- Flipping an existing `untrusted` entry.
+
+## Implementation Notes
+
+- New module `src-tauri/src/session/codex_trust.rs`: `seed_project_trust(cwd: &Path)` resolving config path from `$HOME`, with a path-injected `seed_project_trust_at(cwd, config_path)` seam for tests (same `_at` pattern as `IdleDetector`). Internals: realpath → worktree-root resolution (decision 4) → `toml_edit` insert-if-absent (decisions 2–3).
+- `src-tauri/src/session/manager/spawn.rs`: call after cwd resolution, before `runtime.spawn`, on the mission-spawn, `spawn_direct*`, and resume paths, gated on the effective runtime.
+- Constant for the config location beside `codex_path()` reuse — don't duplicate the `~/.codex/config.toml` literal; share or mirror `commands/mcp.rs::codex_path`.
+
+## Validation
+
+- Rust unit tests (`codex_trust.rs`, temp dirs): empty/missing config → block created; unrelated existing config preserved verbatim; existing `trusted` entry → file untouched; existing `untrusted` entry → file untouched; linked-worktree cwd → main root seeded; worktree with a forged `gitdir:` and no valid backlink → cwd seeded, not the forged target; symlinked cwd → realpath seeded.
+- Manual: remove the project's `[projects."…"]` entry from `~/.codex/config.toml`, start a codex mission — no trust dialog, first turn delivers; re-check config.toml diff is exactly one block; restart the mission — file unchanged.
+- `cargo test --workspace`.
diff --git a/docs/impls/archive/0024-resume-scrollback-preservation.md b/docs/impls/archive/0024-resume-scrollback-preservation.md
index 629ed64c..a63026dc 100644
--- a/docs/impls/archive/0024-resume-scrollback-preservation.md
+++ b/docs/impls/archive/0024-resume-scrollback-preservation.md
@@ -26,7 +26,7 @@ Contract 3 is real for codex but wrong for claude-code: codex repaints the whole
 2. **A seq watermark replaces "clean buffer" as the pill contract.** At the same top-of-`resume()` point, record `resume_watermark_seq = output_seq` in `SessionState` (all runtimes, uniformly — for codex it's equal to the post-purge floor, so filtering is a no-op). The pill effects' snapshot fast-path only honors TUI-ready escapes in events with `seq > watermark`. Old chunks stay replayable for the terminal but can no longer clear the overlay early.
 3. **Expose the watermark via a dedicated read command,** `session_replay_watermark(session_id) -> u64`, rather than changing `session_output_snapshot`'s return shape (which `RunnerTerminal` replay consumes as a bare array) or `session_resume`'s (a resume can be triggered from another window — impl 0018 — so the pill can't rely on the resume RPC's response reaching it). Fresh spawns report 0, so the filter is inert outside resume flows.
 4. **Do not reset the terminal-mode flags on the keep path.** `purge_output_buffer` resets `alt_screen_on` / `bracketed_paste_on` because after a purge no escape bytes remain to justify a synthetic snapshot prefix. When the buffer is kept, the old chunks carry their own mode escapes and `update_terminal_mode_state` keeps deriving state from the live stream; the seq=0 synthetic prefix stays correct for the evicted-escape case it was built for. Worst case is a redundant `\x1b[?2004h` replayed from both the prefix and a surviving chunk — harmless.
-5. **Accept the double-tail and stale-width artifacts.** The last ~4 turns will appear twice after a resume (once in kept scrollback, once in claude's repaint) — identical to resuming in Ghostty; not a bug. Kept bytes were emitted at the old grid width; a later remount replays them into the current grid, so lines wrap at the recorded width. For claude-code's inline text this reads like ordinary terminal reflow; it is bounded anyway because live resizes still purge the ring for claude-code (`resize` → `purge_output_buffer_keep_modes`, unchanged by this impl), so the kept segment never spans a width change that happened while the session was running.
+5. **Accept the double-tail and stale-width artifacts.** The last ~4 turns will appear twice after a resume (once in kept scrollback, once in claude's repaint) — identical to resuming in Ghostty; not a bug. Kept bytes were emitted at the old grid width; a later remount replays them into the current grid, so lines wrap at the recorded width. For claude-code's inline text this reads like ordinary terminal reflow; it is bounded anyway because live resizes still purge the ring for claude-code (`resize` → `purge_output_buffer_keep_modes`, unchanged by this impl), so the kept segment never spans a width change that happened while the session was running. **Correction ([#373](https://github.com/yicheng47/runner/issues/373)):** this bullet leaned on resizes being rare and user-driven. Production launch logs falsified that — post-launch layout churn fired ~50 cols-gate purges in 90 seconds with nobody touching anything, each one dropping the very transcript this impl preserved. The purge semantics stand, but the push path now coalesces a resize storm into one settled resize and skips the purge entirely when a storm round-trips to the ring's own width — see impl [0039](../0039-resize-storm-coalescing.md).
 6. **No frontend rendering changes.** In-place resume already preserves the mounted xterm buffer under the `opacity-0` overlay; this impl makes the backend ring agree with it so remounts stop losing what the screen already showed. `ResumeSettleTracker` (`src/pages/RunnerChat.tsx`) listens to live events only — live events during a resume window can only come from the new PTY (resume is refused while the row is running) — so it needs no watermark. The stale `clearVersion` comment block at `src/pages/RunnerChat.tsx:1120` gets rewritten to describe the real mechanism.
 
 ## Goals
diff --git a/docs/impls/archive/0032-in-place-resume-seam-and-width-hardening.md b/docs/impls/archive/0032-in-place-resume-seam-and-width-hardening.md
new file mode 100644
index 00000000..a9fdafca
--- /dev/null
+++ b/docs/impls/archive/0032-in-place-resume-seam-and-width-hardening.md
@@ -0,0 +1,90 @@
+# In-place resume seam + PTY width hardening
+
+## Status
+
+Planned. Tracking issue [#344](https://github.com/yicheng47/runner/issues/344) plus the width-divergence findings from the 2026-07-25 rendering investigation. Builds directly on impl [0024](archive/0024-resume-scrollback-preservation.md) (per-runtime ring policy) and reuses its vocabulary: ring as single source of truth, synthetic chunks, `runtime_purges_on_resume` gate.
+
+## Problem
+
+Three defects, one family: what the agent emitted under one terminal state (grid content, emulator modes, width) is later viewed under another, and nothing reconciles the two.
+
+1. **In-place resume streams into the stale grid (#344).** Stopping a session intentionally keeps its final frame visible. But resuming without leaving the tab streams the new PTY into that dead grid: `resumeSession` (`src/pages/RunnerChat.tsx:1183`; mission slots use the same `session_resume` path) never resets anything — the only `term.reset()` lives in the tab-switch/snapshot replay path (`src/components/RunnerTerminal.tsx:1120`). The backend half IS policy-aware (purge-runtimes purge the ring and reset tracked modes, `output.rs:610-622`; claude-code keeps its ring, `output.rs:725-741`), but the live grid mirrors neither policy. Consequences: (a) live/replay divergence for purge-runtimes — the live grid stacks dead frame + new frame while the purged ring holds only the new bytes, so the first later replay silently shows a different scrollback; (b) terminal-mode leakage for all runtimes — the dead session's bracketed paste (`?2004h`), mouse reporting, and SGR attributes survive in the live emulator until the resumed process happens to rewrite them; (c) dirty seam for keep-runtimes — the resume banner lands at the dead cursor position with stale attributes, no newline guarantee.
+2. **Unsized spawns default to 80×24.** `pty_runtime.rs:121` falls back to `(80, 24)` whenever the caller passes no dims. Resume callers measure the mounted pane (`RunnerTerminalHandle.measure`), but the pane is frequently unmeasurable at resume time: hidden persistent layer, unmounted route, resume triggered from another window (impl 0018), or app-relaunch resume before layout settles. Bytes emitted in that window are hard-wrapped at 80 cols; the later fit resizes the PTY but cannot unwrap them. This is the measured Δ≈cols−80 miswrap fingerprint (`RunnerTerminal.tsx:133-139`, `#resume-pty-size-mismatch`).
+3. **Hidden persistent surfaces defer geometry.** `PersistentSurfaces` hides the inactive layer with `display:none` (`src/components/PersistentSurfaces.tsx:68,79`), which makes containers unmeasurable, so `refitAndPush` is gated off while hidden (`RunnerTerminal.tsx:767-772`). Geometry changes made from the other surface (left sidebar, window size, zoom) reach the hidden pane's PTY only on activation. Everything the agent emitted in between carries the stale width — agent-side hard wraps that no reflow can repair.
+
+Explicitly NOT part of this problem: the duplicated-block scrollback artifact. That is upstream claude-code's inline renderer re-emitting the in-progress conversation during multi-tool turns (anthropics/claude-code#52866; the 2.1.121 partial fix does not cover xterm.js hosts). Runner renders those bytes faithfully. User-side workaround is `/tui fullscreen`.
+
+## Key Decisions
+
+1. **Fix resume grid state through the byte path, not frontend choreography.** Both per-runtime policies materialize as synthetic chunks written into the ring through the normal output ingest path — real seq, `session/output` event fanout, `update_terminal_mode_state` derivation — exactly like the seq-0 synthetic mode prefix the snapshot path already prepends (`output.rs:529-556`), but at resume time. Rationale: a resume can be triggered from a window that is not displaying the pane (impl 0018; see 0024 decision 3), and the pane may also be a hidden-but-live persistent layer. Only the byte path reaches every mounted view and every future replay identically. No new frontend invariants.
+2. **Purge-runtimes (codex, shells): seed the purged ring with a synthetic full-reset chunk.** After the existing `purge_output_buffer`, append one chunk that leaves any live grid indistinguishable from a fresh terminal: scrollback gone, modes off, SGR clean, cursor home. Candidate bytes: `\x1bc` (RIS) if xterm.js RIS clears scrollback; otherwise compose `\x1b[3J\x1b[2J\x1b[H\x1b[0m` plus explicit mode-offs. The coder verifies xterm.js behavior and picks; the requirement is behavioral, not a byte spec. Replay is unaffected (replay already `term.reset()`s before re-feed; the chunk is idempotent there).
+3. **Keep-runtimes (claude-code): append a synthetic seam chunk, scrollback intact.** `\x1b[0m` + `\x1b[?2004l` + mouse-reporting off (`\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l`) + `\r\n` — the inverse of the synthetic snapshot prefix. The resume banner starts on a fresh line with clean attributes; kept scrollback above it is untouched. This is the "physical terminal continuity" model 0024 chose, minus the unsanitized seam.
+4. **Seam chunks ride the standard ingest path so tracking and pills stay honest for free.** Mode tracking flips from the seam bytes themselves (no manual flag writes — 0024 decision 4's reasoning). The resume watermark is set before the seam is appended; the seam contains only mode-OFF escapes, so `chunkIndicatesTuiReady` can never fire on it and the starting/resuming pills still wait for the new PTY.
+5. **Sized-spawn fallback chain: explicit dims → last-applied dims → 80×24.** Persist the last applied cols/rows per session (two INTEGER columns on `sessions`, written on every applied resize). `spawn`/`resume` use them whenever the caller passes `None`. 80×24 remains only for a truly-first spawn that has never been sized. Persisting in SQLite (not memory) means app-relaunch resume forks the child at the right width before any frontend layout exists — the exact window behind the cols−80 miswrap.
+6. **Hidden persistent layers stay laid out and keep pushing geometry.** `PersistentSurfaces` switches its hidden layer from `display:none` to invisible-but-measurable: absolutely stacked over the content area, `visibility:hidden`, `pointer-events:none`, `aria-hidden`. `RunnerTerminal` splits its single `active` gate into two concerns: geometry (fit, `pushSize`, per-runtime resize-clear) follows measurability; rendering, focus, WebGL retention, wake-dance, and shortcut handling stay keyed to visibility exactly as today. With dims always current, the activation-time deferred resize dedupes to a no-op, which also retires the "paints stale until a real grid change" window noted at `RunnerTerminal.tsx:296`.
+7. **Accepted consequence of decision 6:** live resizes purge the claude-code ring (`resize` → `purge_output_buffer_keep_modes`, 0024 decision 5), and hidden panes will now resize live, so ring purges can happen while hidden. The mounted xterm buffer retains full scrollback either way (keep-alive); only post-restart/remount replays hold less history — the same tradeoff already accepted for visible resizes.
+
+## Goals
+
+- Stop → in-place Resume with no navigation: a codex pane comes back to a clean grid identical to what a later remount replay shows; a claude-code pane keeps its scrollback with the banner on a fresh clean line. Pasting immediately after resume behaves as plain input (no inherited bracketed paste).
+- The same session renders identically live and via any later replay after a resume — divergence class (a) is gone.
+- A resume or spawn with no measurable pane (hidden layer, other window, app relaunch) forks the child at the session's last-known dims, not 80×24.
+- A geometry change made while on the other surface reaches the hidden mounted pane's PTY at the time of the change; returning to it needs no activation resize and shows output wrapped at the width it was actually emitted for.
+
+## Non-Goals
+
+- The upstream claude-code duplicated-block artifact (see Problem; not fixable in a bytes-as-truth host).
+- Per-tab right-panel state. Global panel state (`runner.chat.panel.open`/`.width`, `runner.mission.rail.open`) is intended chrome behavior.
+- Repairing history already hard-wrapped at an old width. Impossible once the agent split the lines itself.
+- Extending keep-ring resume to shells (0024's open question stays open) or changing ring bounds.
+- Multi-window simultaneous same-session viewing (PTY-daemon territory, deferred with tripwires).
+
+## Implementation Phases
+
+### Phase 1 — backend: synthetic resume chunks (`src-tauri/src/session/manager/`)
+
+- `output.rs`: a helper that appends a synthetic chunk through the normal ingest path (seq assignment, event fanout, mode-state derivation). Constants for the purge-reset bytes and the keep-seam bytes next to the existing synthetic prefix builder (`:529-556`).
+- `spawn.rs` `resume()` (~`:932`, the 0024 purge/watermark point): after watermark + purge decision, append the policy's chunk — reset chunk on the purge path, seam chunk on the keep path — before the child forks.
+- Tests (`tests.rs`): purge-runtime ring after resume = reset chunk then child bytes; keep-runtime ring = prior chunks, seam, child bytes, seq monotonic; mode tracking reports paste/mouse off immediately after the seam; watermark excludes the seam from pill fast-paths; archive/delete purge behavior unchanged.
+
+### Phase 2 — backend: dims persistence + sized fallback
+
+- `db.rs`: migration adding `last_cols`/`last_rows` (nullable INTEGER) to `sessions`.
+- Resize path (`session_resize` handling): persist applied dims. `spawn`/`resume`: fallback chain from decision 5 where `initial_size` is `None` (`pty_runtime.rs:121` consumes the resolved value; keep the resolution in the manager, not the runtime).
+- Respect the existing mission resume-all dims fallback (`src/pages/MissionWorkspace.tsx:600-667`) — frontend-measured dims still win when available.
+- Tests: resume with `None` dims uses persisted dims; first-ever spawn still 80×24; persisted dims survive a manager restart (DB round-trip).
+
+### Phase 3 — frontend: measurable/active split
+
+- `src/components/PersistentSurfaces.tsx`: hidden layer becomes invisible-but-measurable (decision 6). The visible layer's `contents` wrapper and the `visible` prop semantics (window subjects, shortcut listeners) are unchanged.
+- `src/components/RunnerTerminal.tsx`: `refitAndPush` gates on measurability (container rect > 0) instead of `activeRef`; WebGL release, focus, wake-dance, and the transitional latch stay on the existing `active`/`disabled` gating. The per-runtime `\x1b[2J\x1b[H` pre-clear on resize applies to hidden panes too (their buffer keeps parsing live output, so it stays consistent).
+- Audit the activation effect: with dims current, activation should reduce to focus + WebGL restore + replay drain for never-activated panes. Do not remove the drain conditions (`RunnerTerminal.tsx:1062-1077`) — never-activated panes still park replay until first activation.
+- `pnpm exec tsc --noEmit`, `pnpm run lint`, vitest for any extracted helpers.
+
+### Phase 4 — docs + validation
+
+- `docs/arch/arch.md` scrollback/resume section: document the synthetic seam chunks and the dims fallback chain.
+- Manual smoke list (user-run): claude chat stop → in-place resume → clean seam line, paste plain, scroll-up history intact; codex slot stop → in-place resume → clean grid, tab away/back → identical; quit app mid-session → relaunch → resume from the sessions list before opening the chat → open it → no 80-col wrapped segment; collapse sidebar while on mission surface → return to chat → no resize flash, correct wrapping.
+
+## Relevant Code
+
+- `src-tauri/src/session/manager/spawn.rs:932` — resume purge/watermark point (0024).
+- `src-tauri/src/session/manager/output.rs:529-556` — synthetic mode prefix (pattern for the seam builder); `:610-622` purge + tracked-mode reset; `:725-741` `runtime_purges_on_resume`.
+- `src-tauri/src/session/pty_runtime.rs:121` — `initial_size.unwrap_or((80, 24))`.
+- `src-tauri/src/db.rs` — schema + migrations.
+- `src/components/PersistentSurfaces.tsx:67-88` — hidden-layer wrappers.
+- `src/components/RunnerTerminal.tsx:703-742` — `pushSize` + per-runtime pre-clear; `:767-772` hidden-pane gate; `:1062-1077` replay drain conditions; `:1223+` transitional latch (must survive Phase 3 untouched).
+- `src/pages/RunnerChat.tsx:1183` — `resumeSession`; `src/pages/MissionWorkspace.tsx:600-667` — resume-all dims fallback.
+
+## Open Questions
+
+- RIS (`\x1bc`) vs composed reset bytes for the purge chunk — decided by the coder against actual xterm.js 6.1.0-beta behavior (does RIS clear scrollback?). Behavioral requirement is fixed either way.
+- Whether the keep-seam should also fire on `resize`-triggered ring purges for claude-code. Out of scope here; today's behavior stands.
+- Whether a future keep-seam should conditionally exit alt-screen when tracked state says it is active. Impl 0032 follows decision 3's fixed seam bytes and does not emit `\x1b[?1049l`; adding it unconditionally is unsafe because xterm.js restores the saved cursor even when already on the main buffer, so any follow-up must be state-aware.
+
+## References
+
+- Issue #344 — in-place resume streams into the stale xterm grid (this impl closes it).
+- Impl 0024 — per-runtime ring policy, watermark, synthetic prefix vocabulary.
+- anthropics/claude-code#52866 — upstream duplicated-block renderer bug (explicitly out of scope).
+- Investigation notes 2026-07-25: cols−80 miswrap fingerprint; resize-source inventory (sidebar, window, global panel state); hidden-pane deferred resize confirmed at `RunnerTerminal.tsx:767-772`.
diff --git a/docs/product/vision.md b/docs/product/vision.md
index 39c25928..9726b677 100644
--- a/docs/product/vision.md
+++ b/docs/product/vision.md
@@ -94,7 +94,7 @@ The user-facing surfaces, described by the value they deliver, not by their impl
 
 ### 4.9 External control
 
-- **MCP** — external Claude Code / Codex sessions can inspect and operate Runner through the bundled `runner-mcp` bridge: project discovery, crew/runner/slot CRUD, project-aware mission/direct-chat creation, and mission lifecycle, feed, and status tools. Runner.app remains the state owner; MCP is a local control surface, not a remote server.
+- **MCP** — external Claude Code, Codex, and Qoder sessions can inspect and operate Runner through the bundled `runner-mcp` bridge: project discovery, crew/runner/slot CRUD, project-aware mission/direct-chat creation, and mission lifecycle, feed, and status tools. Runner.app remains the state owner; MCP is a local control surface, not a remote server.
 
 ## 5. The demo loop
 
@@ -129,7 +129,7 @@ These are intentionally out of scope — they belong to a different product or a
 - Thread/fact primitives for mission coordination.
 - Secrets management beyond plain env vars.
 - LLM-based signal routing (the router is a flat dispatcher by design — the lead owns coordination judgment).
-- Windows desktop support (macOS + Linux only for the foreseeable future).
+- Linux and Windows desktop support (macOS only).
 
 ## 7. Open product questions
 
@@ -142,6 +142,6 @@ Decisions we have not taken; revisit when the product surfaces them.
 
 ## 8. Risks
 
-- **PTY flakiness across platforms.** Targets macOS + Linux; Windows is deferred.
+- **PTY and process-lifecycle edge cases.** Orphan reaping, resume, and geometry are the recurring trouble spots; targeting macOS alone keeps the matrix to one.
 - **TUI rendering edge cases in xterm.js.** Claude / codex use rich TUIs (alt-screen, OSC 8 hyperlinks, OSC 52 clipboard); every new TUI quirk is a tuning loop.
 - **Agents that don't know the `runner signal` / `runner msg` conventions.** We ship sensible default briefs per runtime so even an untuned agent participates correctly.