# Runner — Architecture > Companion to [`../product/vision.md`](../product/vision.md). The vision doc defines *what* we're building and why; this doc defines *how* it works — tech stack, the model concepts the code is built around, and the protocol / schema decisions that make the model work. Rewritten 2026-08-22 (M6.5) for the native GPUI app (shipped as `v0.6.0` on 2026-08-23; §14 updated then); the Tauri + xterm.js era version is in history (`git show 0e5ea18:docs/arch/arch.md`) and the port that replaced it is recorded in [`../impls/archive/gpui-rewrite/`](../impls/archive/gpui-rewrite/README.md). For new agent support, use the [runtime integration checklist](runtime-integration.md): P0 requirements, P1/P2 capabilities, acceptance checks, and implementation entry points. ## 1. Overview Runner is a local desktop app for macOS and Windows. A user configures a **crew** of CLI coding agents, launches a **mission** to activate it, and watches the crew coordinate in real time. Three processes separate presentation from session lifetime: the GPUI app, `runnerd` (`crates/runner-daemon`) and the bundled `runner` CLI. The daemon owns SQLite, PTYs, mission routers and an authoritative `alacritty_terminal` grid per live session. The app sends requests over `runnerd.sock` and paints terminal mirrors; the CLI sends typed requests over the same client protocol. Per-mission NDJSON remains the coordination record. ### 1.1 Runtime picture ```text Runner.app ── requests/input/resize ──► runnerd ── PTY ──► agent / shell children ◄─ events/snapshots/bytes ── ◄─ hooks ── AppStore + terminal mirrors AppCore + authoritative terminal models GPUI windows and panes SQLite, SessionManager, buses, routers runnerd.sock ◄── app and runner CLI ``` See [Process model](process-model.md) for ownership, workers and data paths. **Three layers across the app and daemon.** *Orchestration (lifecycle only).* **MissionManager** (`ops::mission`) starts, stops, archives and resets missions, composes each role's system prompt at spawn, and re-mounts router + bus state for `running` missions on daemon launch. Once a mission is up it goes quiet; it is not in the runtime data path. *Runtime (the hot path).* **SessionManager** owns each PTY master, the blocking reader thread, the serialized writer, the idle detector and the session's last applied size. **EventBus** tails the per-mission NDJSON file with `notify`, parses each new line, hands it to the **Router** for handler dispatch and republishes a `mission/changed` notification for the UI. "Projections" — inbox, pending HITL cards, status map — are in-memory rollups over the same event stream. *Presentation (the app crate).* **`AppStore`** holds read snapshots of the rows the UI renders and turns `AppEvent`s into scoped GPUI notifications. **`TerminalBridge`** (in `crates/runner-terminal`) maintains app-side terminal mirrors. The daemon’s authoritative model parses output, answers terminal queries and derives draft observations even without a client. The terminal element paints its mirror; keys, IME and mouse events encode locally and go to the daemon’s ordered input worker. *Agent usage.* The backend `usage` module keeps Claude Code and Codex plan windows in memory and emits `usage/updated` for the sidebar popover. On macOS it reads Claude Code's OAuth token through `/usr/bin/security`, the same Keychain tool Claude Code uses, so no prompt is expected; elsewhere it reads `~/.claude/.credentials.json`. It then calls Anthropic's usage endpoint through the captured login-shell proxy. Codex usage comes from the effective CLI's `app-server` JSON-RPC interface. Fetches run on background threads; the module does not write credentials or persist usage. *Agent updates.* Sessions never prompt to update their CLI: Codex gets `-c check_for_update_on_startup=false`, Claude Code gets `DISABLE_INSTALLATION_CHECKS=1` and `CLAUDE_CODE_DISABLE_FEEDBACK_SURVEY=1`, Copilot gets `--no-auto-update`, pi gets `PI_SKIP_VERSION_CHECK=1` ([#475](../../crates/runner-daemon/src/runtimes/)). Antigravity CLI updates itself in the background at launch with no opt-out, and a running session keeps its version, so Runner leaves it alone ([#644](../features/archive/644-antigravity-runtime.md) decision 7). The update signal lives in Settings → Agents instead ([#533](../features/archive/533-agent-cli-updates.md)). `runtime_status::versions` records the first semver token of ` --version`, probed off the UI thread with a five-second timeout when discovery completes, on Refresh, on an override change and after an update exits, and compares it with the npm dist-tag the runtime's own updater follows (`latest`, or `stable` when Claude Code's `autoUpdatesChannel` says so), fetched through the usage module's proxy-aware client and cached for six hours per app run. A check asked for while discovery is still resolving executables waits for it; a failed check drops the answer, so the row shows its version alone. Only the newest probe and the newest npm check per runtime store their results, so an override set during a probe, a channel switched during a check, or a later forced run supersedes the older one. Antigravity CLI's row shows the bare semver `agy --version` prints and never offers Update: agy is not on npm, so its definition names no package and no update arguments. The Update button runs the runtime's own update command (`claude update`, `codex update`, `copilot update`, `pi update`) in a modal terminal. Its PTY is an unlisted process in the SessionManager, spawned by `spawn_unlisted`: it has no `sessions` row and reports only to the modal, so it never reaches the sidebar, tabs, archive, relaunch or any session list, and it gets the agent environment without Runner's layers, with the home directory as cwd. **Two channels out of the daemon.** Numbered terminal bytes and snapshots use binary client-protocol frames; raw output is queued to clients before the authoritative model parses it. Slow clients resync without slowing agents. Row changes, mission events and status flow through the `AppEvent` broadcast and the socket to `AppStore`. Neither path changes the mission log. **One session.** The session row is one slot's PTY process: SessionManager holds the master file descriptor; the child runs the agent binary with a real tty on stdin/stdout/stderr. The env vars are what make the bundled `runner` CLI work inside that child — when the agent runs `runner msg post …`, the CLI reads `RUNNER_MISSION_ID` + `RUNNER_EVENT_LOG` from its environment, builds the JSON line, and `flock`-appends to the right file. That append needs no socket; the daemon’s bus watches the file. **Closing the loop.** Child invokes `runner` CLI → CLI appends a line to `events.ndjson` → `notify` wakes the EventBus → the line goes to (a) the Router and (b) the UI as `mission/changed`. If a handler needs to wake a session, it writes bytes into that session's PTY through SessionManager's writer. The bus is the spine: all coordination flows through one append-only file, which is why it's debuggable with `tail -f | jq`. **What's not in the hot path.** SQLite holds configuration and session-lifecycle metadata (roles, crews, slots, projects, the sidebar tree, mission rows, session rows with PID, runtime metadata and last size). Live coordination state lives in the NDJSON file or in the router's memory; live screen state lives in the `Term`s. **The invariant this picture encodes.** There is exactly one piece of mutable shared state per mission: `events.ndjson`. Every other component is a writer to it (the `runner` CLI; the router for `human_question` / `mission_warning`), a reader of it (EventBus → router + UI), or a per-session PTY pipeline that doesn't touch it. On restart Runner re-opens the file and reconstructs router/feed projections from replay. PTY children survive app restart when Keep running is chosen. Daemon restart demotes stale rows, sweeps orphans and resumes stamped sessions. ### 1.2 Code map Crate boundaries are in [`AGENTS.md`](../../AGENTS.md); this is the shape *inside* `crates/runner-app/src/surfaces/`, where most of the UI lives. **One directory per surface, one file per concern.** A surface that outgrows a single file becomes a directory: `mod.rs` holds the types, the state struct and the `Render` impl; every other file owns one concern and carries its own `use` block; tests live in `tests.rs`. `settings/` set the pattern and the four largest surfaces followed it in [#478](https://github.com/yicheng47/runner/issues/478) on 2026-09-13. | Surface | Files | Largest | Concerns beyond `mod.rs` and `tests.rs` | |---|---|---|---| | `mission_workspace/` | 14 | 884 | state, routing, attach, drawer, events, actions, input, view, feed, composer, terminal pane, rail | | `sidebar/` | 13 | 1049 | state, activation, archive, rows, shortcuts, menus, project, drag, view, row renders, elements | | `crews/` | 10 | 777 | list, editor, editor sections, create, add slot, slots, overlays, logic | | `roles/` | 10 | 570 | forms, create, edit, delete, list, detail, menu, logic | `chat.rs`, `panes.rs`, `settings_page.rs` and `start_chat.rs` are still single files. The ones that have since outgrown the shape are tracked in [#582](https://github.com/yicheng47/runner/issues/582). **Three privacy rules, one per error, and they are not interchangeable.** A **field** declared in `mod.rs` is visible to every child, so `self.field` from a concern file needs nothing. A private **method** moved into one child and called from a sibling is `E0624`; it takes `pub(super)`, applied only where the compiler asks, and needing `pub(crate)` means the item is reached from outside the directory and the cut is wrong. A private **field of a type declared in a child**, read from a sibling, is `E0616`; `pub(super)` on the type does not expose its fields, so the type moves to `mod.rs` instead. **Paths into a surface stay stable.** `panes.rs` and `chat.rs` reach into `sidebar::` directly, so every item that is `pub(crate)` keeps resolving at `crate::surfaces::::` through a `pub(crate) use` in `mod.rs`. The proof that a split preserved this is that the calling modules do not appear in its diff. **Splitting one of these is a move, not a refactor.** Nothing about behavior, signatures or formatting changes; the permitted mechanical differences are imports, module declarations, `impl` wrappers, compiler-driven `pub(super)`, and the rustfmt reflow a widened signature forces. Verify a structural split with a `syn` item audit keyed by `Type::method`, ignoring only approved import/module/visibility mechanics, plus an unchanged workspace test count. Validate the comparator against temporary negative controls: a removed statement, changed literal, missing match arm, widened visibility and dropped test/platform attribute must each fail and name the item. The old per-surface plans are in git history; their [recorded outcome](../impls/archive/maintenance-history.md#478--split-outcome) is retained separately. ## 2. Tech stack | Layer | Choice | Why | |---|---|---| | UI framework | **GPUI** (`gpui-pre` 0.3.7, `zed@1a28cff`, Metal / DirectX) | A crates.io snapshot of upstream Zed's retained-mode Rust UI: entities + elements, native text shaping and IME, with the application core in `runnerd`. Replaced Tauri + React in the 2026-08 rewrite. | | Terminal model | **`alacritty_terminal` 0.26** | Grid, VTE parser, scrollback with reflow, selection, mouse/alt-screen modes. The same model Zed embeds. | | Terminal renderer | custom GPUI element (`runner-app/src/terminal/element.rs`) | Walks the `Term` grid per frame, shapes runs through GPUI's text system; bundled JetBrainsMono Nerd Font Mono is the default face, Menlo the alternative. | | Application core | **Rust** crate `runner-daemon`, UI-agnostic | SQLite, session manager, event bus, router and client protocol. The daemon serves the app and CLI through one request table. | | PTY runtime | **`portable-pty`** (inside `runnerd`) | One blocking OS thread per session reads the master; writes are serialized per session. | | Persistence | **SQLite via `rusqlite`** + `r2d2` pool, WAL | Config + session lifecycle only. Migrations in `crates/runner-daemon/migrations/` (0001–0028). | | Event transport | **Append-only NDJSON per mission** | Tailable, crash-durable, replayable; `flock(LOCK_EX)` for cross-process append atomicity. | | File watching | **`notify`** | The bus tails the NDJSON file and republishes lines. | | Bundled CLI | **`runner`** (`crates/runner-cli/`) | Agents talk to the bus through it — `runner signal …`, `runner msg post …`, `runner msg read`. Dropped at `$APPDATA/bin/runner` on first run, PATH-prepended per spawn. | | Client protocol | **Length-prefixed frames** over owner-only local IPC | The app and bundled CLI use `$APPDATA/runnerd.sock` on Unix or `\\.\pipe\com.wycstudios.runnerd[-dev]` on Windows for typed requests, pushed events and terminal streams. | | Logging | **`tracing`** + rotating file layer + panic hook | `~/Library/Logs/com.wycstudios.runner/runner.log`; release filter `info`, debug builds `debug`, `RUST_LOG` overrides. | | Updater | **Sparkle 2.9.5** via `objc2` (`updater` feature) | `SPUStandardUpdaterController`, EdDSA-signed appcasts on GitHub Releases, with separate production and nightly feeds — see §14. | | Packaging | `script/bundle-mac` | `.app` assembly, Developer ID codesign, notarization, DMG; `CFBundleVersion` is the build stamp. | | Input | GPUI key dispatch + native IME (`terminal_ime.rs`) | Pinyin composition in the terminal was the hard requirement of the rewrite. | **GPUI bumps.** Pin every direct `gpui-pre-*` dependency to the same exact release and move the pins together. The release's crates.io description names its Zed commit; read that commit's source when checking API and behavior changes. Keep `gpui-pre-platform` on macOS with `font-kit`, and use `gpui-pre-windows` directly with default features disabled on Windows. Re-check the defaults and the CI step “Verify Runner manifest and icon” after each bump: enabling GPUI's `windows-manifest` would embed a second manifest beside Runner's own `longPathAware` manifest. **Platform target.** macOS on Apple Silicon, Windows x64 and Linux ARM64/x86_64. Linux source and package definitions are documented in [Linux development](./linux.md), including native validation gaps. Intel Macs are not supported and no Intel build is planned: universal packaging shipped in `v0.6.0` and `v0.6.1` and was dropped for download size — 43 MB universal against 19 MB arm64 (Jason, 2026-08-25). Windows has shipped since `0.8.0`: every production release carries a signed `Runner-Setup-…-x64.exe` beside the arm64 DMG. Both platforms develop from `main`; `Rust / macOS` and `Rust / Windows` are both required branch checks, verified on 2026-10-22. Inspect current branch protection before relying on auto-merge. See [Windows development](./windows.md) and §14. Platform window chrome lives in `crates/runner-app/src/platform_ui/{macos,windows,linux}.rs` with fonts in the adjacent `fonts_{macos,windows,linux}.rs`; Windows/Linux share custom chrome in `desktop.rs`, selected at compile time; everything else is shared, and a Unix-only mechanism needs a `cfg` arm rather than an assumption. Linux CI checks both architectures; required-check settings must be configured separately when Linux support is accepted. WSL/WSLg acceptance remains unverified in [#823](https://github.com/yicheng47/runner/issues/823). ## 3. Domain model Domain objects split into two layers: - **Configuration** — persistent, user-edited. Outlives missions. Role, Crew, Slot, crew addendum, Project, the sidebar tree. - **Runtime** — created at mission start, torn down at mission end. Mission, Session, the in-memory router state, the per-mission shared context. The key insight: **a Role is config; a Session is its runtime instance** — same pattern as Crew (config) → Mission (runtime). A role is instantiated as a session *inside a mission* or as a one-off direct-chat session outside any mission. ### 3.1 Relationship diagram ``` ┌─ Configuration (persistent) ─────────┐ ┌─ Runtime (mission-scoped) ──────────────┐ │ │ │ │ │ Role ──── Slot (per-crew handle, │ │ Session ─► PTY process │ │ ▲ display order, ┼────┼─► (one per roster member; │ │ │ runtime/model/ │ │ independent slot lifecycle) │ │ │ effort overrides) │ │ │ │ │ ▲ │ │ ▲ │ │ │ │ composes │ │ │ spawned & owned by │ │ │ Crew │ │ │ │ │ │ │ │ │ Mission ─── events.ndjson │ │ │ └── system_prompt │ │ │ │ │ │ │ addendum │ │ │ ├─► Signal │ │ │ │ │ │ └─► Message │ │ │ │ │ │ │ │ └─ direct chat session (off-bus, no mission, no router) ◄───────────────────── │ │ │ │ │ │ │ Project (cwd) ── nodes tree │ │ ├─► Router in-memory state │ │ (projects, tabs, missions) │ │ │ (pending asks, status, outbox) │ │ │ │ └─► Shared context: brief + roster │ └──────────────────────────────────────┘ └─────────────────────────────────────────┘ ``` A mission is a container. Everything in the runtime column is either the container itself (Mission) or an object whose lifecycle is scoped by it. ### 3.2 Role — *one configured agent* A reusable template: handle, display name, runtime, command + args, working dir, system prompt (persona), env, optional model and effort. Known runtimes use the `Runtime` enum in code (`Codex`, `ClaudeCode`, `Antigravity`, `Pi`, `Copilot`, `Trae`, `Cursor`, `Grok`, `OpenCode`, `Shell`); SQLite runtime names remain plain strings, and legacy or arbitrary names, including `qoder` rows from before v0.6.7, stay unchanged and readable without a migration. Dispatch parses those names while preserving the existing behavior for unknown runtimes. **Top-level, not nested under a crew.** The same role can be used by many crews simultaneously, and can also be the subject of standalone direct-chat sessions. A role has two identifying fields: - **`handle`** — a lowercase slug (`coder`, `reviewer`). Required, **globally unique**, immutable once set. The handle is the role's identity in direct chats and in `from` fields when the session is not in a crew. - **`display_name`** — free-form UI label. Editable; presentation-only. Keeping these separate means renaming a role for the UI doesn't break briefs or historical events. Runtime argv is composed by the adapter in `runtimes//` from the stored role args, and is not itself stored: the permission mode (`--permission-mode` / Codex `--ask-for-approval` + `--sandbox` / Copilot `--yolo` / Antigravity `--dangerously-skip-permissions`) follows `MissionPermissionMode` for mission slots, while attended chats strip permission flags without replacement; the adapter adds model and effort flags, Codex, Copilot and Antigravity's `--add-dir` grant for the mission directory, and the first-turn body. Pi has no permission flags: mission slots receive `--approve`, chats do not, every spawn sets `PI_SKIP_VERSION_CHECK=1`, and model/thinking map to `--model ` and lowercased `--thinking `. Every Pi invocation also receives `-e /pi-hooks/runner-status.ts` before its system-prompt and goal arguments when the Runner-owned extension is installed. Claude Code additionally receives one compact `--settings` JSON that selects its alternate-screen renderer (`"tui":"fullscreen"`), installs the `/clear` rekey hook, and, when the effective mode is Bypass, acknowledges its bypass consent dialog (`"skipDangerousModePermissionPrompt":true`) so a mission slot never waits on it, unless the role's own args already pass `--settings`. Runner owns the renderer for the sessions it spawns; `--settings` outranks the user's `~/.claude/settings.json`. Copilot always receives `--no-auto-update` and an additive `--plugin-dir /copilot-hooks` backed by a Runner-owned plugin regenerated at startup; each hook entry carries a `command` (the `sh` reporter, run on macOS) and a `powershell` slot (an inline reporter, run on Windows), and Copilot picks the slot by OS. The plugin takes its per-session feed path and generation from the spawn environment, so repeated user `--plugin-dir` flags compose and no file under `~/.copilot` is changed. A user `disableAllHooks` setting produces no feed and leaves the terminal-activity baseline in control. Antigravity CLI always receives `--log-file /antigravity/logs/.log`, and on macOS an additive `--add-dir /antigravity-hooks`, a Runner-owned folder regenerated at startup whose `.agents/hooks.json` agy loads for that launch only; nothing under `~/.gemini` changes. Its model and effort come only from the pairs `ANTIGRAVITY_MODELS` lists, because agy silently runs its default model on any other pair: `--effort` is never passed without `--model`, nor with a model that lacks that level. ### 3.3 Crew — *a configured team, composed of slots* A named, persistent group of **slots**. Carries the optional team-conventions addendum, its only prose. It does not run. It is blueprint. Every mission states its own goal. Crews are composed of **slots**, not roles directly. A slot is the indirection that lets the same role participate in many crews: - **`slot_handle`** — the slot's in-crew handle (`@impl`, `@reviewer`). Required, unique within the crew. This is what crewmates address each other by — `runner msg post --to impl`. - **`role_id`** — which role fills the slot. - **`position`** — display order within the crew. - **`lead`** — a retained storage field ignored by current code. New slots and roster files write `false`; old flags load without a schema migration. Companion payloads keep the field, always `false`. - **`runtime_override`, `model_override`, `effort_override`** — per-slot deviations from the template, so one crew can run the same persona on two runtimes. **Why slot vs role.** Users curate a small library of roles and re-use them across crews and direct chats. Tying the in-crew handle and overrides to the role would force duplicating configs every time a role shows up in a new crew. **Every member can ask the person directly.** `runner ask ""` and its `--human` alias emit `ask_human`; the answer returns to the asker. `on_behalf_of` remains accepted for compatibility. The retired `ask_lead` signal still parses and renders in old logs but is refused on send and has no router side effect. ### 3.4 Mission — *one live run of its own roster* A mission groups its own roster, sessions and coordination state. Direct chats and standalone shells are independent sessions outside any mission. Within a mission, one member stopping or failing leaves the others running: - A **Session** per slot (the PTY processes — §3.5). - The **coordination bus** — the NDJSON event log carrying signals and messages. - The **router's in-memory state** — pending HITL asks, latest session availability, per-slot delivery outboxes. - The **shared context** — composed system prompts (brief, roster, coordination notes, optional team conventions) injected at spawn. Lifecycle: - **Start**: a mission row is created (with its own `cwd` and an optional per-mission `goal_override`), crew slots spawn or a no-crew start begins with an empty roster, the router boots with fresh state, and an NDJSON file is opened. Missions can be started from the UI or through `runner mission start`. - **Stop**: live PTYs are killed, but the mission row remains `running`; router/bus state stays mounted and stopped slots can be resumed. - **Archive**: Runner appends `mission_stopped`, marks the row `completed`, sets `archived_at`, kills any live PTYs (verified dead before the row flips), and unmounts router/bus state. Archived missions are hidden from active lists and render read-only. - **Reset**: kills the slots and re-spawns them against the same mission row and log; forks read the persisted last size so they open at the width the pane had. **Mission cwd is authoritative.** Each mission carries its own `cwd` column. Spawned slots inherit `mission.cwd` regardless of what the role's `working_dir` says — that field is only used in direct chats. Starting from a project copies the project's cwd into the row. The mission owns `roster.json` beside its event log. Version 2 copies the crew's name, team conventions and slots at start, including slot ids, handles, order, role ids and overrides; legacy lead flags load but are ignored. Startup, router and bus rebuilds, message validation, session resume, the app and the CLI read this file; later crew edits never change its runtime roster or prompt copies. Crew provenance is optional; status readers return no crew when the template was deleted. Roles remain linked by id; Resume replays the recorded launch definition, and Restart uses the current role and roster overrides. A role cannot be deleted while an unarchived mission has a session for it. A start without a crew writes an empty version 2 roster, mounts the bus and router with no members, and remains running. Each added role is a member; its first turn includes the shared goal and the team preamble. `missions.crew_id` is nullable provenance without a foreign key: deleting a crew removes its slots, keeps every mission, and retains the original crew id for locating the files. A crew start requires at least one slot. While a mission runs, the person can add a configured worker or remove a worker from the rail or CLI. One mutex per mission serializes these operations. Both refuse any pending mission spawn; add registers cancellation immediately after its checks. Added slots persist their overrides in the roster and record a launch definition as at cold start. Removal stops the session and drops its roster slot without archiving or deleting its historical session row; session lists and Resume all keep only roster slots. A worker's exit or failed spawn leaves the rest of the mission running. The bus replays the log from offset zero and learns broadcast membership from human `role_joined` and `role_removed` signals. Its initial team is the file's handles minus mid-mission joins; a newcomer's unread count starts at its join. Directed messages always project to their addressee, including before a join. `runner msg read` still scans the entire log and includes earlier broadcasts. The router's mutable roster is changed directly by the operations; broadcasts and Restart prompts read that current roster. Before mounting routers at daemon startup, Runner converts every legacy version 1 roster, including archived missions. It preserves the file's handles and order, ignores legacy lead flags, matches current session-slot handles first and then unmatched session slots by first-start order, and uses the session's configuration when its slot was deleted. A handle without a session uses the crew's matching slot; one with neither is dropped with a warning. Version 2 files are left alone. A missing file or one that is not valid JSON falls back to the crew's current slots only when that crew still exists, writes version 2 and logs a warning. Without a crew, the original load error is returned. Other read errors, unsupported versions and invalid roster shapes return an error without rewriting the file; one mission's conversion failure does not block startup. Concurrent missions on the same crew are allowed — a crew is a reusable template, and per-mission state (sessions, the bus, the runner-CLI shim path, the roster sidecar) is fully namespaced by `mission_id`. ### 3.5 Session — *one slot's PTY process* The runtime instance of a slot inside a mission, a role-backed direct chat, or a runtime-only direct chat. A Session is to a slot/role/runtime what a Mission is to a Crew: the *run* of a *configuration*. Two flavors, distinguished by whether `mission_id` is set on the session row: - **Mission session** — spawned when a mission starts; one session per slot. It participates in the crew's bus, sees broadcasts, can receive stdin injection from the router. `RUNNER_HANDLE` carries the *slot* handle, not the role's global handle. - **Direct-chat session** — spawned ad-hoc without a parent mission, backed by a role or a bare runtime selection. `mission_id` is null and the working directory lives on the session row. The agent is **not on any coordination bus**. Role-backed chats keep `role_id`; runtime-only chats store `role_id = NULL`. Both record their runtime in `agent_runtime` and their configured launch inputs in a versioned launch definition, so later role edits never change an existing session. At spawn, each session records its configured command, args (including permission flags), env, effective model, effort and Codex speed, and the role's own prompt text. Resume and fork replay that definition; null model, effort or speed means the agent's default. Mission-slot Restart is the explicit way to use the current role and the mission roster slot's overrides, and records a new definition. A direct chat uses role edits by starting a new chat. The chat header, side panel and `runner session show` show the recorded command, model, effort and speed; CLI JSON omits the definition because env can hold keys. Executable resolution and Settings → Agents overrides remain live at every launch, as does the app-wide mission permission mode: Auto/Bypass replace recorded permission args and RoleDefault retains them. Direct chats strip permission flags every time. Mission prompts still compose the recorded role text with the roster's crew addendum and the latest goal and team. Cursor and Grok conversation probes still use the current shell environment. A session owns, in the core: - A PTY master handle (the only object in the system with a file descriptor to a running child process). - A blocking reader thread that drains the PTY into the manager forwarder, which sequences bytes for terminal subscribers and the authoritative model; the runtime also feeds the idle detector. - A serialized writer for stdin (the human's keystrokes, pastes, and the router's injections all go through it). - Its last applied PTY size (`last_cols`/`last_rows` on the row, the latest measurement in memory) and an exit status once the child has terminated. The daemon owns an authoritative `alacritty_terminal::Term` until the session exits. The app’s `TerminalBridge` owns an attached mirror restored from a snapshot and numbered live output (§5.5). Each `Term` holds its own screen and scrollback. 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 → 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** — 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`. A tab or mission created in a project or in RECENTS opens at the top of that list, and dragging still reorders it; new projects join the end of the project list ([#884](https://github.com/yicheng47/runner/issues/884)). Deleting a project archives its chats and missions but never touches the directory on disk. - **Window** — a real OS window (⇧⌘N, `File → New Window`). The core's per-window subject registry (`crates/runner-daemon/src/windows.rs`) tracks every visible direct-chat subject, focus recency for duplicate-session ownership, and current focus for viewed-attention semantics. A session shown in two windows has one primary; the secondary window shows a duplicate-chat placeholder rather than a second live grid. - **Tab** — one stable, ULID-keyed group of panes rendered as exactly one sidebar row. Tabs, projects and mission references are rows of the `nodes` tree (§10.1) with an optional project parent, name, order, JSON layout, pin position and completion/viewed watermarks. Every active direct-chat session belongs to exactly one tab. Per-window active-tab selection is ephemeral. - **Pane** — one slot inside a tab, holding one chat or shell session (move-not-copy). `⌘[` / `⌘]` cycle pane focus, `⌘W` closes the focused drawer, pane or tab through its lifecycle action; closing a running chat pane requires archive confirmation and stops that chat. Every pane carries a split icon — Split Right `⌘D`, Split Down `⇧⌘D` — which turns that pane's leaf into a 50/50 split with a new pane on the chosen side; a single-pane tab shows the icon in its header instead. There is no pane ceiling, only a size floor: an item is disabled once either half would fall under 240 × 160 px at 1× zoom. A terminal tab has at least one session and every filled pane holds a shell; empty panes do not affect its kind. Splitting it immediately spawns and focuses a shell in the split-from shell's live cwd, the last directory it reported through OSC 7, or its stored spawn cwd when it has reported none that still exists ([#575](../guide/guides/chats-and-windows.md#split-and-navigate), §5.3). A tab containing a chat still splits to an empty New chat stub. New terminal on a terminal tab fills an existing empty pane or splits right through the same size gate, starting from the focused shell the same way; terminal tabs keep the drawer icon hidden. A tab's layout persists as a **tree** in its `nodes.layout` JSON: `tree` is nested splits (`id`, `orientation`, `sizes`, `a`, `b`) and leaves (`id`, `session_id`), with leaf ids `p` and split ids `s` drawn from one per-tab counter. `slots` (leaf session ids in leaf order) and `drawer` stay beside it because the backend reconciler reads only those two. Dragging a pane within the tree moves its leaf without changing its id or session, and new split ids continue the counter. Rows written before the tree — `preset` + `slots` + `sizes`, one of six picker shapes — are rebuilt through a legacy reader into the tree that preset drew, and are rewritten in the new shape on their next save. Sessions exist independently of the display tree: closing a pane or window never kills a PTY, and since M6.8 it does not even drop the session's `Term` — the grid keeps ingesting while hidden and is simply painted again when a pane shows it. The mission workspace's per-slot terminal switcher predates this hierarchy and is a different, mission-scoped UI element — not a Tab in the sense above. ### 3.7 Settings surface Settings is a route inside the shared work card, with its own grouped navigation and panes: General (command installation, quit and resume policy), Notifications, Appearance (zoom, theme), Terminal (font, cursor, theme palette), Agents (a page per runtime for discovery, executable overrides, version/update controls and Claude accounts), Skills (global skill visibility and editing), MCP (the global server catalog), Keyboard shortcuts (a view over the registry in `runner-app/src/keymap.rs`), Updates, Diagnostics (log path, open-log), About, and Archived. Entry points — the sidebar Settings row, the command palette, and `⌘,` — navigate to the route and return to the caller's location. Settings → Skills reads each runtime's declared user roots. Antigravity CLI contributes `~/.gemini/antigravity-cli/skills` and `~/.gemini/skills` as a read/edit catalog with no Runner toggle. TRAE CLI contributes only `~/.trae/skills` (not its `.coco` or `.trae-cn` compatibility roots); its pane is a read/edit catalog with no Runner toggle, and its caption points to the skill frontmatter's `disable-model-invocation` switch. Settings → MCP reads the global server entries declared by supported runtime adapters, including Claude Code, Codex, TRAE CLI, GitHub Copilot CLI, Antigravity CLI and OpenCode directly from their config files, with no Runner-side server store. A runtime dropdown selects a detected, enabled agent; toggles register or unregister the named server for that agent, copying the first registered entry in agent order when turning it on. A server named `runner` is an ordinary user-configured row with the same toggle and editor as every other server. The detail modal shows each agent's native entry and, when agents hold different definitions under the same name, a neutral "Configured differently per agent" note rather than a warning, since per-agent arguments are often intentional; its JSON/TOML editor can also translate the change to the other registered agents, on by default only for agents whose entry already matches, while preserving their unmodelled keys. Each write changes only the named entry and preserves the rest of the file, including formatting and comments; adding servers and auth flows stay with the agents' own tooling. Reads refresh on entry, Refresh, and after writes; running sessions pick up changes on their next launch. Copilot's registration is `~/.copilot/mcp-config.json` under `mcpServers`, with a `local` entry containing `command`, empty `args`, and `tools: ["*"]`; the file is created when absent. Antigravity CLI's is `~/.gemini/config/mcp_config.json` under `mcpServers`, which agy ships at 0 bytes and Runner reads as `{}`; a copied stdio server is written the way `agy mcp add` writes it, `args` (omitted when empty), `command`, `disabled: false` and `env`, while an HTTP server is refused with a pointer to `agy mcp add` because agy's HTTP entry shape is unprobed. The 0.11 upgrade step consults the legacy `initializedMcpClients` set once and removes only a `runner` entry with no arguments whose command is this installation's legacy bridge executable under `/bin`; another installation's entry and a hand edit are logged and left unchanged. A config that cannot be read or parsed is left untouched for that launch and logged as deferred; a second-read race or write failure leaves the step pending for the next launch. See [#555](../features/archive/555-mcp-settings.md). Preferences persist in `$APPDATA/ui-settings.json`, read by the app at launch; a first launch starts from defaults (resume-on-launch on). **Claude accounts.** Settings → Agents → Claude Code manages named account directories, authentication status and optional shared configuration. New and resumed sessions can select an account; a running session keeps the account it launched with. Resume preserves the existing conversation location, forks use the selected account's folder, and `/clear` uses the running account. Account storage and sharing decisions are retained in the [#955 spec](../features/archive/955-claude-accounts.md); the [guide](../guide/guides/claude-accounts.md) describes the current controls. **Notifications.** The app's notification controller observes session and mission events and posts only while Runner is in the background. Chat events default on; four independent mission switches default off. Clicking a notification routes to the target chat or mission. Settings → Notifications owns these preferences; native OS permission remains separate. **Updates.** Settings → Updates and the sidebar indicator open the available update flow. macOS uses Sparkle for signed appcast checks and installation; Windows downloads and verifies the signed installer before its Install and restart action. The channel and update contracts are in §14 and [Windows update behavior](windows.md#update-behavior). ## 4. Coordination primitives — *what flows between crew members* Crew members don't share a programming model; they share an IM-like surface. | Primitive | Role | Shipped | Planned | |---|---|:---:|:---:| | **Signal** | Typed notification; the router handles built-ins. Verb grammar. | ✅ | | | **Message** | Prose, broadcast or directed to a specific slot. | ✅ | | | **Inbox** | Per-slot projection: broadcasts + messages addressed to me. | ✅ | | | **Thread** | Scoped sub-conversation within a mission. | | not planned | | **Fact** | KV whiteboard; "what is currently true in this mission." | | not planned | | **Mention** | Leading `@handle` addresses one or more members; mentions later in prose remain text. | ✅ | | | **Reaction** | Lightweight signal attached to a message. | | later | ### 4.1 Signal — *"something happened, please wake the right surface"* Short, typed, router-visible. Grammar: past-tense verb (or asker verbs like `ask_human`). The router has fixed handlers keyed to built-in signal types (§8.1). A signal carries an optional `payload` (JSON) for the router and UI. Human-readable conversation belongs in messages. ### 4.2 Message — *"here's what I think"* Prose, addressed to one member, several members or the mission. - **Broadcast** — `runner msg post ""`. Goes to every other crew member's inbox; a human-authored broadcast goes to every crew member. - **Directed** — `runner msg post --to coder --to reviewer ""`, or `runner msg post "@coder @reviewer "`. Repeatable explicit recipients take precedence over leading mentions. Mid-text mentions do not address. Unknown handles and `human` are refused with the valid handles. Messages are flat, with one stream per mission. Durable conclusions belong in project files, code, commits or message prose. `Recipients` reads a string, an array or `null`; it writes one handle as a string, several as an array and a broadcast as `null`. Existing single-recipient logs keep their form. CLI posting, mission operations and the composer use `runner_core::addressing::resolve`. ### 4.3 Inbox — *"what's in my mailbox"* Each member's subscription starts at its human-authored `role_joined`, or at mission start for an original member. Membership is shared by the bus, router and reads through `Event::addresses(h)`: ``` inbox(h) = events since joining where kind = "message" AND from != h AND (to is empty OR to contains h) ``` `runner msg read` prints only messages after the latest ack, in stream order, followed by a digest of other members' directed traffic. The digest excludes the caller's sent and addressed messages, spans the previous ack through the last inbox message printed, and shows the latest ten entries in log order as truncated single lines. Empty inbox reads emit no digest or ack. `runner msg read --all` prints all crew messages since joining, including the caller's sent messages and traffic between others, without a digest. `--since ID` selects after that event ID; `--from HANDLE` filters the selected messages by sender. Either filter disables acks and the digest, including with `--all`. Unfiltered reads ack only fully printed messages in the caller's own inbox, through its last printed inbox ID, and only after successful output. Other members' traffic and digest entries never advance the ack. Filtered reads with unread inbox messages emit a stderr diagnostic with the unread count from the captured batch and an actionable unfiltered-read hint, including when no selected messages print. The count ignores the query filters and respects the membership boundary and latest valid acknowledgement; stdout and acknowledgement semantics are unchanged. The dispatcher types the unchanged pointer notification ``[inbox] N new messages — run `runner msg read` to view.`` according to §8.5. A recipient reads when notified and uses `--all` after a resume or when earlier messages are no longer in context. ### 4.4 Event — *the unifying transport* Every coordination primitive is persisted as an **event** — one line in the per-mission NDJSON file: ```jsonc { "id": "01HG3K1YRG7RQ3N9...", // ULID: time-sortable, monotonic within ms "ts": "2026-04-21T12:34:56.123Z", "crew_id": "01HG...", // omitted for a crew-less mission "mission_id": "01HG...", "kind": "signal", // signal | message "from": "coder", // slot handle | "human" | "router" "to": null, // null = broadcast; string or array = directed "type": "ask_human", // for kind=signal; omitted for kind=message "payload": { "...": "..." } // kind-specific (e.g. { "text": "..." } for messages) } ``` `kind` discriminates. For `kind: "signal"`, `type` carries the verb; for `kind: "message"`, the prose lives in `payload.text`. Crew members interact through CLI verbs (`runner signal`, `runner msg`), not the event schema directly. Event-log primitives (ULIDs with a monotonic floor, the `flock` append, tail repair of a torn last line) live in `crates/runner-core`. ## 5. PTY session runtime A **pseudo-terminal (PTY)** is a kernel-emulated terminal device. To the child it looks like a real TTY — `isatty()` is true, `ioctl(TIOCGWINSZ)` reports a window size, signals route correctly — but the other end is a file descriptor held by a controlling process. The kernel exposes the pair as a **slave** (the child's stdin/stdout/stderr) and a **master** (what Runner reads from and writes to). It's the same primitive `ssh`, `tmux`, and every terminal emulator use. Runner uses the `portable-pty` crate, which wraps the POSIX primitives; for the rigorous treatment see Stevens & Rago, *APUE*, chapter 19. ### 5.1 Topology at a glance Output (agent → screen + status): ```text child → PTY reader → daemon manager forwarder ├─ numbered bytes → client queue → app TerminalMirror → GPUI └─ authoritative TerminalModel → draft observations hooks / idle detector → daemon session state → events → app AppStore ``` Input (UI + router → agent): ```text GPUI element → mirror key/IME/mouse encoding → input frame → daemon input queue ─┐ Router (prompts, human responses, inbox nudges) → reserved delivery path ────────┼→ PTY writer → child TerminalModel query replies → terminal-event worker ──────────────────────────┘ ``` PtyRuntime holds the master and serialized writer in `runnerd`. Bus-side coordination continues independently of an app connection: ```text child → runner CLI → events.ndjson → daemon EventBus → Router → delivery writer ``` Each live session has its own daemon reader, writer, idle detector and authoritative `Term`; attached app panes use mirrors. [Process model](process-model.md) covers their channel and snapshot boundaries. ### 5.2 Why PTY (not pipes) and why alacritty Claude Code, Codex, TRAE CLI, GitHub Copilot CLI, pi, and Antigravity CLI are TUIs. They check `isatty()`; if false they degrade. Their output is escape sequences that only a terminal emulator can render. The PTY gives the child a real terminal; `alacritty_terminal` gives Runner a correct emulator — grid, VTE parser, alt screen, scrollback reflow, selection, mouse reporting modes — without reinventing one. GPUI paints the grid it maintains. Runner feeds each chunk to the `Term`'s VTE parser itself (`TerminalModel::feed_output` in the daemon and `TerminalMirror::feed_output` in the app) instead of running alacritty's `EventLoop`, so it also services the parser's synchronized-update deadline, which that loop would otherwise own: vte holds everything between `ESC[?2026h` and `ESC[?2026l` and only records when to give up waiting for the end marker, so a per-session flusher applies a held update 150 ms after its last begin marker and logs the flush at info level (#647). ### 5.3 Spawn The session runtime is encapsulated behind a `SessionRuntime` trait so SessionManager doesn't know whether the runtime is in-process PTY or anything else; only `PtyRuntime` is shipped. Login-shell discovery is startup-safe and shared: setup seeds SessionManager from the last successful `LoginShellEnv` snapshot, 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 and emits `runtime/changed`; a failure leaves the prior snapshot active and records the outcome for Settings → Agents. Built-in runtime commands are resolved in Rust against the same PATH the child receives: a persisted runtime override, then the first executable found on the composed PATH, then the bare catalog command only while discovery is in flight. Once discovery completes, a missing executable fails before PTY creation with a pointer to Settings → Agents. ``` portable_pty::openpty(rows, cols) rows/cols: explicit pane size > latest in-memory ├─ master handle → kept by SessionManager measurement > persisted last_cols/rows > 80×24 └─ slave handle → given to child via spawn_command() Child inherits (mission session): PATH = :$APPDATA/bin::: RUNNER_CREW_ID = RUNNER_MISSION_ID = RUNNER_HANDLE = RUNNER_EVENT_LOG = $APPDATA/crews//missions//events.ndjson TERM = xterm-256color COLORTERM = truecolor COLUMNS / LINES = the spawn size install_handle: record the PTY, cache the runtime's policy flags, reconcile any size pushed while the PTY did not exist, emit session/spawned ← the bridge creates the Term here start the forwarder (reader thread + idle detector) Reader thread (blocking): loop { read(master) → SessionEvents::output(raw bytes) ; feed idle detector } on EOF: wait(child) → emit session/exit { code } → update sessions row ``` **Terminal shells report their directory** ([#575](../guide/guides/chats-and-windows.md#split-and-navigate)). The OSC 7 integration is injected only into Runner's own terminal shells: the runtime-only `runtime:shell` role outside a mission, on Unix, when the command is `zsh` or `bash`. `apply_runtime_args` calls `shell_integration::inject`. That writes the scripts embedded from `crates/runner-daemon/shell-integration/` to `$APPDATA/shell-integration/`, compared first and replaced through a rename, and adds environment variables. Argv stays as it is. - **zsh:** `ZDOTDIR` points at a wrapper `.zshenv`. It restores the user's `ZDOTDIR` (passed as `RUNNER_ZSH_ZDOTDIR`) or unsets it, then sources the user's `.zshenv`, so zsh reads the rest of the user's startup files itself. - **bash:** `PROMPT_COMMAND` carries a bootstrap, `. "$RUNNER_BASH_INTEGRATION"`. At the first prompt, after bash has read its own startup files, the bootstrap swaps itself for the hook and stops exporting `PROMPT_COMMAND`. The hook prints `ESC ] 7 ; file:// ST` at each prompt, with an empty host: it only runs in a local shell, and a hostname read at shell start goes stale when the network changes it. When the user's configuration already sends OSC 7 (a prompt hook, a function one calls, or the prompt string, found by a bounded transitive scan), the hook removes itself. It rescans whenever the hook lists or the prompt strings change. Agent sessions, role-backed shell chats, mission slots, other shells and Windows get no injection. On the terminal side, The terminal model and mirror scan the raw output of `shell`-runtime sessions for OSC 7 before `vte` parses it, since `vte` drops the sequence. The scan carries a cut-off introducer or report across chunks and ends a report at BEL or ST. It keeps the last report whose host is this machine and whose path is absolute as the session's live cwd. `live_cwd()` offers that cwd only while it is a directory. A terminal split and New terminal start from it (§3.6), and relative file links try it before the spawn cwd. The terminal drawer is unchanged: its source is the tab's chat. The composed prompt is delivered by the adapter in `runtimes//`: a positional argument where accepted, `-i ` for Copilot and Antigravity, `-- ` for Pi, or a verified paste after a Windows batch wrapper is ready. Every mission member receives the same prompt shape. Genuine conversation resumes suppress the first turn. Pi mission resumes reapply the same member context through the system-prompt file so older crew conversations retain their instructions. Pi direct chats use `--append-system-prompt /session-prompts/.md`; Runner refreshes that file from the recorded role on Resume or the current role on spawn and Restart, removes it when the session ends, and sweeps leftovers at startup. Every fresh mission-slot spawn carries `compose_member_first_turn`: the team coordination preamble, latest mission goal, crew conventions and role brief. This includes Restart and a manual Resume that falls back to fresh because a conversation file or captured key is missing. A genuine conversation resume sends no first turn; Pi reapplies the member prompt through `--append-system-prompt`. Fresh Codex/TRAE respawns receive a new capture marker; Windows batch wrappers queue the same body through the existing first-turn delivery fallback. Each mission session is independent: one session's exit or failed spawn never stops another. A clean exit marks only that session stopped; a crash or failed spawn marks only that session crashed and emits `session/exit`. Live siblings continue running, queued slots continue spawning, and the mission stays `running`. Stop all, mission stop and Archive still kill every session and cancel queued spawns; role deletion still kills every session for that role. Mission start's synchronous registration phase remains all-or-nothing. A mission slot has three lifecycle actions ([542](../guide/concepts/missions.md#stop-resume-and-archive)): **Stop** synchronously kills and reaps only its PTY, leaving the mission and sibling slots running; **Resume** respawns its existing row and asks the agent CLI to restore its conversation; **Restart** kills a running PTY and respawns the same row as a fresh conversation with its cold-start first turn. Restart replaces the agent key while retaining the Runner session id, router mapping, tabs, and mission event history. Resume and Restart share a per-session claim so concurrent requests cannot spawn twice or kill an in-flight respawn. Both accept current pane dimensions ahead of the persisted size, including when a stopped pane was resized. A mission slot only resumes or restarts while its mission is running and unarchived. Restart records a human `slot_restarted` signal followed by a `runner` message to every other member asking them to re-send the slot's task and context. The existing message router nudges the recipients' inboxes. A notification append failure leaves the successful restart intact and emits a session warning so the human knows to re-send context. Rail controls act immediately; the stopped-slot pane offers **Resume slot** and **Restart slot** while siblings are live. When all slots are stopped, the mission-wide **Resume** and **Archive** card remains. Only header **Stop all slots** asks for confirmation and names the running slot count ("Stop all N running slots?", or "Stop the running slot?" for one). ### 5.4 Native wiring and human takeover - The terminal element renders its attached mirror’s `Term`. A pane that mounts late first restores the daemon’s snapshot, then follows numbered output; paint reads only the local grid. - Keys are encoded from the `Term`'s mode (application cursor keys, bracketed paste, ⌥ as Meta, and, once an app has pushed the kitty keyboard protocol's disambiguate level, Esc and the ambiguous chords such as Shift/Alt+Enter and Alt/Ctrl+letter as `CSI code;mods u` in place of `ESC CR`, `ESC d` and the control bytes); the terminal answers the protocol's `CSI ? u` query, acknowledging only the disambiguate flag it implements, and tracks the app's push and pop, so a strict decoder like pi's reads Shift+Enter as a newline and Alt+Enter as its own key; IME composition is native, with marked text drawn in the grid; mouse reporting modes 1000/1002/1003/1006 are honored and Shift bypasses them for selection and scrollback; selection and copy are the element's own. - **Resize requests** are immediate. The pane that owns the terminal's size pushes each measured size from `prepaint`; `TerminalMirror::resize` sends a one-way frame. The daemon applies a controller's PTY ioctl and authoritative resize when that frame arrives, and sends an ordered `Resized` frame to every viewer including the sender. Each mirror reflows its local `Term` only from the attach snapshot and those frames, so a rejected non-controller request leaves the mirror's size unchanged. A 175 ms settle thread only persists the final size once per storm. Nothing clears the grid on resize: the TUI's own SIGWINCH repaint plus alacritty's reflow is the whole story (M6.6 — the earlier "clear and replay" contract duplicated history, because `ESC[2J` on the primary screen is `clear_viewport`, which scrolls the viewport into scrollback). **Human takeover is a first-class capability.** At any moment the human can type directly into any session's stdin — the same writer the router uses. The pane is a real terminal, not a log viewer: special keys pass through untouched, and the agent cannot tell whether bytes came from the router, the human, or its normal terminal input. The mission feed is read-mostly: it renders coordination events and historical human-authored events, has no free-form composer, and its only input is the choice control on a pending `human_question` card. External orchestrators use the `runner` CLI to post `human_said` when they need to relay an operator instruction programmatically. ### 5.5 Sessions outlive views; terminals outlive panes Sessions live in the core and belong to the mission, not to any window, tab or pane. Closing a window does *not* kill the sessions — the agents keep running, events keep flowing, the router keeps handling live signals. The daemon holds the authoritative `TerminalModel` for each live session, regardless of open panes or clients. The app’s `TerminalBridge` holds mirrors fed by a snapshot and numbered output. Closing a view releases its viewer lease, not the process or authoritative grid. Relaunching the app reconnects and attaches current snapshots; it does not resume already-running agents. **Quitting chooses session lifetime.** Settings → General → When Runner quits defaults to Ask. With live sessions, a user quit (⌘Q, Quit menu, Windows last-window close) offers Keep them running or Stop them, names working agents and supports Cancel, Enter, Escape and Don’t ask again. The last confirmed choice is preselected. Keep disconnects only the app. Stop uses `Shutdown { stop_sessions: true }`: stamp launch resume, kill sessions and end the daemon. macOS ⌥⌘Q bypasses the dialog and always stops. With no live sessions a normal quit leaves the idle daemon running. OS and update quits never ask and leave the daemon running. On OS SIGTERM or Windows logoff/shutdown/console-close, the daemon stamps and stops the sessions itself. Sparkle marks relaunch through its delegate; Windows marks the installer quit. A new app build detects a sidecar hash mismatch, stops the old daemon with stamping, replaces the sidecar and starts the new daemon; on Windows it first waits for the old daemon's process to exit and moves a sidecar still in use aside ([Windows](windows.md)). The toast “Restarted N sessions in Runner ” counts successful launch resumes. Crashes stop the affected daemon’s sessions; the app reconnects and shows the short error toast “Background service restarted · N sessions stopped”. After repeated failures, each window’s main content has a persistent danger banner with Open log and Try again beneath its header, excluding the sidebar and side panel. Try again restores the connection in one attempt without resuming stopped sessions; a failed attempt or another crash returns to the capped notice. Resume creates a fresh child against the same row and saved conversation key. Copilot assigns a UUID before spawning and persists it as `agent_session_key`, then passes `--session-id ` for both fresh starts and resumes. The conversation probe checks `$COPILOT_HOME` (otherwise `~/.copilot`), `session-state//events.jsonl`; a missing transcript starts fresh with the same id and includes the cold-start first turn. Before every Copilot spawn, Runner seeds the exact cwd in `config.json` `trustedFolders`, keeping the leading `//` header and unrelated values; `--yolo` alone does not bypass folder trust. Default and Accept edits role permission modes respectively write no permission flag and `--allow-tool=write`; Auto is not offered. Chats strip all Copilot permission flags. Pi also assigns and persists a UUID before the first process spawn, then passes `--session-id ` on fresh and resumed sessions. Its conversation probe mirrors the child's effective environment: the role overrides Runner's inherited process environment, `PI_CODING_AGENT_SESSION_DIR` selects one flat directory, `PI_CODING_AGENT_DIR` selects `sessions//` beneath the agent directory, and the fallback is `~/.pi/agent/sessions//`; every layout names files `_.jsonl`. `sessionDir` in `settings.json` and a role's own `--session-dir` argument are not honoured. A missing file starts fresh under the same id, with every mission member receiving its goal again. Direct chats refresh the `--append-system-prompt` file even on a genuine resume. Antigravity CLI assigns its own conversation id, and only when the first message is sent, so a fresh spawn carries no key. `session/agy_capture.rs` tails the session's `--log-file` for agy's `Created conversation ` line and writes that id into `agent_session_key`, guarded by the row's start time and running status; a blank chat keeps tailing until its first message or the end of the session. A resume passes `--conversation ` after the role's args. The probe checks `~/.gemini/antigravity-cli/conversations/.db`, a global store, so the cwd does not matter; when it is missing the resume starts fresh with the cold-start first turn and captures a new key, and when agy itself falls back to a new conversation, that conversation's line replaces the key. The log is cleared before every spawn, removed with its row, and swept at startup for rows that no longer exist. Before every spawn, `session/agy_trust.rs` adds the exact cwd to `trustedWorkspaces` in `~/.gemini/antigravity-cli/settings.json`, rewriting only that member so every other byte of agy's file stays as agy wrote it. Accept edits writes `--mode accept-edits`, Bypass `--dangerously-skip-permissions`, and Auto is not offered; the strip covers both flags in every spelling Go's `flag` package accepts (`-x`, `--x`, `-x=v`, `--x=v`). Fork creates a new direct-chat row without writing the source row, key, PTY, or conversation file. The new row copies the source's project, role, cwd, runtime, command, model, and effort columns. Claude Code starts the visible TUI directly with `--resume --fork-session --session-id ` and a caller-assigned key; Pi uses `--fork --session-id ` with the fork row's own refreshed system-prompt file; Codex runs a bounded headless `exec fork` until it has a lineage-validated `thread.started` key, persists that key, marks the temporary row stopped, and starts the visible PTY through the ordinary resume path. A direct-spawn, materialization, or resume failure removes both the fork row and its tab. The runtime definition's `native_fork` capability enables Claude Code, Codex and Pi and excludes TRAE, Copilot and Antigravity. An untouched Claude fork is intentionally copy-on-write: manual resume falls back to a fresh chat and launch-time resume reports it unavailable; re-fork the source instead of adding recovery state. ### 5.6 Writer serialization The PTY writer is shared between the human and the router. Each session's writer is serialized, one `write_all` per turn. Direct chats and mission panes send one-way input frames to a per-session **queued input worker** in the daemon, so a delivery waiting on the draft gate (§8.5) never parks GPUI’s render thread. Pastes are bracketed when the TUI has enabled bracketed paste, and the first-turn paste is verified against the grid before Enter is sent. ### 5.7 Threads, not async `portable-pty`'s reader is blocking. One OS thread per session does one blocking `read(2)` in a loop; the kernel parks it cheaply. Writes are short and take a per-session lock. The core uses a small tokio runtime only where the libraries want one — the client-protocol server and the broadcast channel behind `AppEvent`s. ### 5.8 Scrollback and size Each authoritative `Term` keeps 10,000 lines of scrollback and persistent parser state. The app mirrors it through snapshots with unfinished parser input, sequence numbers, cursor and modes, then applies numbered output. Its viewer’s selection and scroll position stay local. A pane resize immediately sends a request to the daemon; every mirror, including the sender, follows the attach snapshot and ordered `Resized` frames. Non-controller requests leave the mirror's size unchanged. The manager persists settled sizes, and daemon launch resume reads those dimensions before a pane exists. ### 5.9 Death and kill The reader thread owns the child handle. On EOF it calls `wait()`, emits `session/exit`, updates the sessions row. No auto-restart. Kill: SIGHUP to the process group via `portable-pty`; escalate to SIGKILL if the child lingers. Archive paths verify the child is dead before the row flips. ### 5.10 Busy / idle inference The baseline detector infers activity from PTY byte traffic ([#124](../../crates/runner-daemon/src/session/pty_runtime.rs)). It is a heuristic: output silence cannot distinguish thinking from readiness inside an interactive agent, and foreground process ownership has the same limitation. Codex adds a narrow title hint inside this baseline: the backend PTY reader passes bytes to the Codex terminal adapter for every Codex session, including a mission slot with no open pane. The adapter parses OSC titles, and a recognized Codex activity title takes precedence over bytes while the fallback owns the session. The session manager holds one `session/state/SessionModel` under the existing per-session mutex. Its pure `apply(SessionEvent, now) -> Effects` owns status, draft, attention, completion, activity revision and conversation identity. The manager executes publications, mission-log appends, delivery notifications and guarded key persistence after reduction; the reducer performs no IO or locking. Watchers emit `AgentEvent`s from native reports and CLI-specific correlation; `session/state/agent/` owns turn state, open interactions, detail and outcomes and builds the published `AgentObservation`. Local cancel reaches a watcher as a drain argument. Claude's parser receives reducer feedback through the existing output channel to preserve deferred transcript reads. `StatusSource` gives the existing source strings typed names without changing event payloads. The current sources and precedence are: 1. **`forwarder`.** `IdleDetector` reports Busy on PTY output and Idle after 2 s of silence. A 500 ms grace window re-armed on resize prevents resize output from waking an idle session. The Codex adapter owns `CodexTitleHint` and `CodexStartup` in `runtimes/codex/terminal.rs` and emits title/readiness events for the reducer; the shared detector holds no Codex state. Two distinct frames from Codex's ten-frame leading activity spinner confirm the title format; a matching title with that leading activity item removed is an Idle hint. The recorded thread-renaming suffix may keep an already established Idle hint through its own spinner and generated-name update, but a bare/custom/reset title, a status word, or a spinner embedded in unrelated text cannot establish Idle. Unknown formats immediately return to byte activity. A successful local, pasted, routed or automatic submission invalidates the hint, and a new PTY starts without title state. `CodexStartup` remains the separate untouched-launch readiness mechanism from [#687](../tests/archive/687-codex-pre-hook-idle.md). An active hook adapter owns activity and makes the reducer reject all forwarder status transitions; `note_forwarder_transition` delegates that decision to it. Without hook ownership, it rejects forwarder Busy while `suppress_local_input_busy` is set. 2. **`input-submit`.** Input submission can mark the session Busy and is not blocked by the forwarder guards. The sink clears local-input suppression on an accepted Idle report and deduplicates unchanged activity. The input path reduces submission and typing under the same state lock as the stdin write, and reduces a rollback if the write fails. `InputTracker` supplies input observations and influences local-input suppression; it does not own Busy/Idle. Mission sessions and direct chats are both seeded Busy with source `spawn` at spawn, so a slot reads Working · estimated from its first byte. After that seed, the output forwarder appends mission `session_status` transitions with their source to the mission log and the router updates its status projection. Direct chats stay off-bus: SessionManager stores their latest activity and emits `session/status` to every window; sidebar aggregation drives activity and completion/unread indicators. Activity alone does not decide inbox delivery: draft state, surfaced interactions and submission confirmation govern readiness (§8.5). Shells still use byte activity, so a silent command can read Idle until the planned shell integration provides explicit boundaries. Claude Code, Codex, GitHub Copilot CLI, pi and OpenCode have lifecycle adapters on macOS and Windows. Antigravity, Cursor and Grok have hook adapters on Unix; native platform evidence is recorded separately in the [supported-agent guide](../guide/reference/supported-agents.md). Reporters send bounded typed reports to the secured daemon endpoint with session and spawn-generation checks ([#797](../features/archive/797-hook-status-ipc.md)). The bundled `runner hook report` is connect-only. Codex uses an optional persistent `runner hook serve` stdio helper for normal events and a command hook for `SessionEnd`; this internal status helper does not restore the retired Runner workspace MCP API. `HookFeed` drains an in-memory receiver, with no per-event payload files. Antigravity's Runner-owned `hooks.json` registers `PreInvocation`, `PostToolUse`, `PostInvocation` and `Stop`, never `PreToolUse`, because agy reads a `PreToolUse` reply as a permission decision and `{}` denies the tool. Working comes from the first three; a fully-idle Stop ends the turn and a non-empty error marks failure. Its reporter keeps the provider-neutral response, and agy has no surfaced approval or question event for Needs you. TRAE remains on estimated terminal activity. Before the first accepted hook, when hooks are unavailable, and after explicit bridge failure, the Codex title hint can refine the baseline except while `CodexStartup` owns the untouched prompt or protected automatic first turn. Healthy hook ownership persists across quiet output and missing later events; bridge failure requires fresh title evidence before title-based fallback resumes. The adapter contract is [#347, hook-based agent status](../features/archive/347-hook-based-session-status.md): supported runtime lifecycle adapters own activity, while output silence never overrides a healthy hook-driven turn. Only unresolved surfaced approvals or questions hold crew delivery; Working and Idle do not. Title-derived transitions keep source `forwarder`, observation source `baseline`, and the estimated UI vocabulary; they emit no completion outcome, interaction hold, draft state or delivery side effect. Byte activity remains unchanged for unsupported runtimes, unrecognized/customized titles, and bridge loss until a fresh supported Codex title is observed. The accepted Codex grammar is pinned to the upstream [default activity title and ten frames](https://github.com/openai/codex/blob/7f01a84effccef40d4726c3ca12e6c839ec98d7a/codex-rs/tui/src/chatwidget/status_surfaces.rs#L26-L36), [active-progress gating and animation](https://github.com/openai/codex/blob/7f01a84effccef40d4726c3ca12e6c839ec98d7a/codex-rs/tui/src/chatwidget/status_surfaces.rs#L1009-L1068), [thread-title progress spinner](https://github.com/openai/codex/blob/7f01a84effccef40d4726c3ca12e6c839ec98d7a/codex-rs/tui/src/chatwidget/thread_title_status.rs#L32-L49), and [OSC 0 writer](https://github.com/openai/codex/blob/7f01a84effccef40d4726c3ca12e6c839ec98d7a/codex-rs/tui/src/terminal_title.rs#L46-L90); `runner-terminal/fixtures/codex-title-working.ndjson` records the corresponding bytes and is the evidence for the literal `renaming... ` progress prefix. Claude's retained fixture remains regression evidence for byte activity, but no Claude title shape is accepted without an inspectable upstream producer. [#904](https://github.com/yicheng47/runner/issues/904) plans OSC 133 shell integration for command boundaries and command-finished notifications, superseding #586's process-detection proposal; unmarked shells keep the byte heuristic. [#587](../../crates/runner-core/src/protocol/session_title.rs) displays terminal-provided titles independently of status and routing; `runner-core/src/protocol/session_title.rs::provider_title` remains display-only. ## 6. System prompt composition Every spawned session receives a composed prompt — one shape for mission members and a separate persona-only shape for direct chats. The composition and its split across system-prompt and first-turn channels are mechanical: pure functions over slot + crew + mission inputs, no LLM in the loop. Source of truth lives in `crates/runner-daemon/src/router/prompt.rs`; delivery mechanics in `runtimes//`. ### 6.1 The three layers 1. **Layer 1 — platform preamble** (code-owned). For every mission member: a fixed block describing the `runner` CLI verbs, current-team lookup, own-part handoffs and the inbox convention, followed by the shared goal (§6.3). 2. **Layer 2 — crew team conventions** (`crews.system_prompt_addendum`, optional). Spliced under `== Team conventions ==`. 3. **Layer 3 — role prompt** (`roles.system_prompt`). Spliced under `== Your brief ==`. ### 6.2 What each session sees | Session kind | Layer 1 | Layer 2 | Layer 3 | Delivery | |---|:---:|:---:|:---:|---| | Mission member | preamble + mission goal | if set | persona | fresh: first turn for every runtime, including Pi; genuine Pi resume: same context in system-prompt file, no first turn | | Direct chat | — | — | persona | established runtimes: first turn; Pi: system-prompt file, no first turn | Direct chats see *only* Layer 3 — the team preamble’s verbs and the team conventions don't make sense off-bus. ### 6.3 The member prompt Start, add-role and fresh respawn share `member_prompt_channels`. Every member receives the team preamble, latest mission goal, team conventions and its own brief. The preamble says to run `runner mission show` before messaging and to act only on the member’s own part, finishing its turn while waiting for a handoff. No roster is embedded because membership can change. The opening `mission_goal` event remains a durable feed record with no stdin-injection side effect. ## 7. Coordination bus Human roster changes append two signals outside the agent signal whitelist: `role_joined` carries `{handle, slot_id, session_id, role_id, lead, runtime, model, effort, speed}` (`lead: false`, effective settings with null for unset); `role_removed` carries `{handle, slot_id, session_id}`. Both have `from: human` and `to: null`. Each is followed by a `runner` system broadcast announcing the change and current team; add can also append a directed task from `human`. The feed shows join/leave dividers attributed to the person and labels `runner` message groups `system`. ### 7.1 Transport ``` $APPDATA/crews/{crew_id}/missions/{mission_id}/events.ndjson ``` One line per event, append-only, one file per mission. Debuggable (`tail -f | jq .`), crash-durable, atomic under explicit guards, replayable for projections. #### 7.1.1 Concurrent-write correctness Multiple crew members can invoke `runner signal` / `runner msg` at the same time from different PTYs, and the core writes router-generated events (`human_question`, `mission_warning`, `session_status`) to the same file: 1. Open the log with `O_APPEND | O_WRONLY | O_CREAT`. 2. `flock(fd, LOCK_EX)`. 3. Exactly one `write(2)` of the serialized JSON line including the trailing `\n`. 4. `close(fd)`, which releases the lock. Ordering from `O_APPEND`, atomicity across writers from the lock, no partial lines from the single write. The app data directory must be on a local POSIX filesystem; network and iCloud-synced volumes may not honor `flock()`. **No `fsync`, by decision** (2026-08-20): page-cache durability is the right trade for a local tool. The cost is a hard power loss mid-append; the log's tail repair already handles a torn last line on open, and coordination state is reconstructible from the agents' own session logs. ULIDs carry a monotonic floor per log so two writers (the app and the CLI) never emit out-of-order ids within a millisecond. ### 7.2 Consumers Two subscribers to each mission's file, both fed by one `notify` watcher: - **Router** — deserializes each new line and dispatches it (§8). - **UI** — the bus republishes the line's arrival as a `mission/changed` `AppEvent`; the mission workspace re-reads the feed from its cursor and projects events into the feed, the HITL cards and the status pills (incremental append is M6.4). #### Startup replay On router boot: open the mission's file, fold `human_question` / `human_response` rows into in-memory state, record the byte offset after the last reconstructed entry (the router cursor), then handle only appends beyond that offset. Event IDs remain the cumulative ack contract; a later append with a lower ID still reaches the router. Replay rebuilds projections; it never re-runs historical stdin pushes or inbox nudges. ## 8. Signal router The router is a flat dispatcher, not a policy engine. There is no per-crew `{when, do}` rule list. Role prompts and team conventions define coordination; the router owns parent-process plumbing that a child PTY cannot do itself. Stdin pushes are deliberately silent: the router writes bytes into the target PTY but does not synthesize `stdin_injected` audit events. The event log records the signal or message that caused the push, plus `human_question` / `human_response` for HITL cards and `mission_warning` when a delivery cannot happen. Add records `role_joined` before publishing `roster.json`, then updates the router and pending session map before the introduction and task, so the newcomer receives their nudges. A failed publication records `role_removed`, restores the old roster, removes routing and deletes the unspawned row. Remove takes the session's existing Resume/Restart claim before stopping, refuses with its handle if that claim is held, and keeps it through publication and rollback. It stops first, writes the smaller roster, drops routing and pending deliveries, then records the leave and notice. Removed handles stay taken for the mission's life. A remount rebuilds routing from the file and unread membership from the log. The person-only CLI uses `runner mission add-role --handle ` and `remove-role `; both refuse mission-session callers and `--as`. ### 8.1 Fixed handler table | Event | Fixed handler | |---|---| | `mission_goal` | No runtime side effect; the launch prompt was composed before spawn and this event remains the durable goal record. | | `human_said` | Validate and persist resolved recipients in `payload.target` (string, array or null), then append a message from `human` with those recipients; reopen repair preserves the same recipients. | | `ask_human` | Append a `human_question` event for the UI. | | `human_response` | Append a message from `human` to the original asker, with `payload.question_id`; retain the signal as the card record. | | `session_status` | The feed renders status; readiness comes directly from the session manager. | | message (any) | Signal backlogs for the addressed subscribers through `Event::addresses`; the dispatcher sends the unchanged pointer notification. Legacy messages to `human` remain visible in the feed and have no subscriber. | | `inbox_read` | Internal — owned by the bus's projection layer to track read watermarks. | `mission_start`, `mission_stopped`, `human_question`, `mission_warning` are observed but not routed: they are events the router or the lifecycle itself emits. ### 8.2 `ask_human` — payload shapes and matching `ask_human { prompt, choices }` produces two correlated signals: ```jsonc // When the card is shown: { "id": "01HG...", // canonical question_id (use this in human_response) "kind": "signal", "type": "human_question", "from": "router", "payload": { "triggered_by": , "prompt": "Reviewer requested changes. Accept or override?", "choices": ["accept", "override"], "on_behalf_of": "@impl" // optional; see §8.3 } } // When the human clicks a choice: { "kind": "signal", "type": "human_response", "from": "human", "payload": { "question_id": , "choice": "accept" } } ``` Causality is carried in-payload rather than on the envelope. The canonical `question_id` is the `human_question` event's own `id`, assigned at flock-guarded append time. ### 8.3 Asking the person 1. **Member asks the person.** `runner ask "Decision needed" --choices "Proceed,Pause"` emits `ask_human`; `--human` remains an alias and choices are optional. 2. **Human responds.** The person answers the feed card or uses `mission answer` with the question id. 3. **Member receives the answer.** The router appends a durable message to the asker and nudges its inbox. ### 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 crew member. Crew members cannot address a virtual `human` message recipient: `runner msg post --to human` fails with guidance to answer in TUI output. The feed keeps its render paths for historical `human_said`, `human_response`, and messages addressed to `human`, so old logs replay unchanged. ### 8.5 Delivery model Mission messaging is a small durable queue with push delivery. This section names its parts in standard message-queue terms; the decisions, the code and the tests below use these terms, and architecture §8.5 takes this section in the commit that adds the dispatcher. Names already in the log (`inbox_read`, `up_to`) do not change. | Term | In Runner | Code or log name | | --- | --- | --- | | Stream | The mission's event log: append-only, with one total order, the order of the file, and each append under `flock`. | `events.ndjson`, `EventLog` | | Producer | Anything that appends: the `runner` CLI, the daemon's mission operations, the router. | `EventLog::append` | | Message | An event with `kind: "message"`. Signals are not messages and are not queued for subscribers. | `EventKind::Message` | | Subscriber | One mission member, addressed by its handle. The person is not a subscriber; a message to `human` is in no subscription. | roster handle | | Subscription | The messages a subscriber receives: `from ≠ h` and (`to` is empty or contains `h`), starting at the subscriber's `role_joined`, or at mission start for an original member. | bus `RoleInbox` | | Ack | A cumulative acknowledgement written by or for the subscriber: every message in its subscription with an ID at or below `up_to` is consumed. Acks only move forward; the bus ignores a stale one. | `inbox_read { up_to }` signal | | Backlog | The subscription's messages after the latest ack. | bus unread count (`InboxUpdate.unread_count`) | | Dispatcher | The per-mission loop that pushes notifications to subscribers with a backlog. | `router/dispatch.rs` | | Notification | The line that tells a subscriber it has a backlog. It carries no message, is composed at send time from the backlog, and is idempotent. | `[inbox] N new messages — run runner msg read to view.` | | Delivery attempt | Typing one notification into the subscriber's terminal, submitting it, and confirming that it was taken. | `Attempt` | | Readiness | Flow control: whether the subscriber's session can take input now. The session manager owns it. | `SessionManager::delivery_readiness` | | In-flight limit | One attempt per subscriber at a time. | — | | Redelivery | A new notification to an idle subscriber whose backlog is still there, after a backoff. | the reconciliation deadline | | Router cursor | The router's own position in the stream, as a byte offset. The router acts only on events past it. | `AppendedEvent.next_offset` | Guarantees: - **Durability.** A message is durable once appended, and no message is held only in memory, so a session exit, a stop or a daemon restart loses none. - **At-least-once notification.** A subscriber with a backlog is notified until it acks. A duplicate notification is harmless; a lost one is repaired by redelivery. - **Order.** A subscriber reads its messages in stream order, and its attempts are serial. - **Soft dispatcher state.** Everything the dispatcher holds is rebuilt from the stream on mount: backlogs from acks, the cursor from the log's end. - **Who acks.** The subscriber acks successfully printed inbox messages through an unfiltered `runner msg read` or `runner msg read --all`. Filtered reads never ack. A confirmed notification attempt does not ack messages. Not in this model: partitions, consumer groups, exactly-once delivery, per-message acks, dead-letter queues and priorities. A subscriber whose attempt is unsubmitted is paused, not dead-lettered: its backlog stays until a later attempt is confirmed or it acks. Readiness belongs to the session manager. One dispatcher driver per mission handles channel inputs and deadlines, without holding router state across session-manager calls. It waits for a bus projection batch to include its acks before notifying from that backlog. Attempts are allowed while the agent is busy; redelivery runs every 30 seconds and on Idle, only for idle subscribers, with a two-minute backoff. Pending spawn, startup, drafts, recent typing and human approvals hold attempts. A stopped session keeps its backlog and warns once until it resumes. Each attempt types a notification and presses Enter after `SUBMIT_DELAY` (80 ms on macOS, 300 ms on Windows for #753), then releases the input gate. For 1.5 seconds it watches for a real runtime `TurnStarted` or for the probe's captured input rows to clear. Only then is the outcome Confirmed and synthetic busy published. If the exact typed text remains in the same rows, one additional Enter is allowed under an atomic generation/local-input/approval check. Text still present after a second window gives Unsubmitted and pauses the subscriber with “Message not submitted — press Return”; clearing the input resumes it. An unfound or ambiguous probe, or local input during confirmation, gives Unverified, is logged with its runtime, and leaves the backlog for redelivery. Draft and approval holds show “Inbox waiting (N)”. Approval and bridge releases and successful raw Enter/Ctrl-C writes emit `ReadinessChanged`; they do not count as input-clear evidence. The terminal composer tracks injected notifications and generated submissions without advancing the local-input revision; generated submissions keep their bookkeeping internal, so a later output-observed clear can provide `InputCleared` evidence. After a height change makes the original probe unknown, input-clear evidence can release the pause only after readiness is Ready. A probe that still positively sees the notification always retains the pause. Fresh starts and reopens share `mount_mission_router`; only the session registration and replay mode differ. Router reconstruction and cold sidebar counts use the same `pending_asks` fold, including cards whose original ask is missing. Start, add-role and respawn share `member_prompt_channels`, including the shared mission goal. Person signals carry `delivery: "inbox"`; their derived messages carry `source_event`. Reopen repairs a marked signal whose message was not appended before the daemon stopped. Legacy unmarked signals stay projection-only on replay. Answer messages include `question_id` and `Answer to "": `; the feed hides the derived answer because the card records the choice. Untargeted human messages broadcast to every member. ## 9. The `runner` CLI The bundled CLI is Runner's external command surface for people, scripts, direct chats, and mission sessions. It connects to the daemon's local client-protocol endpoint for workspace and lifecycle operations; the socket protocol is an implementation detail. Mission sessions continue to use the same binary for direct event-log messaging. ### 9.1 Surface ``` # meta runner status runner help [agents | ] runner call [] # projects and roles runner project list | show | create [--path ] | rename | delete [--force] runner role list | show runner role create --runtime [--name ] [--model ] [--effort ] [--speed inherit|standard|fast] [--permission ] [--prompt | --prompt-file ] [--arg ]... [--env KEY=VALUE]... [--cwd ] runner role update [the same optional flags] | delete # crews; slots are addressed by handle runner crew list | show runner crew create [--conventions-file ] runner crew update [--name ] [--conventions-file ] runner crew delete runner crew add [--as ] [--runtime ] [--model ] [--effort ] [--speed inherit|standard|fast] runner crew set [--as ] [--runtime ] [--model ] [--effort ] [--speed inherit|standard|fast] runner crew remove | order ... # missions, chats, and sessions runner mission list [--crew ] | show [] runner mission start [--crew ] [--goal | --goal-file ] [--title ] [--project <project> | --cwd <dir>] # no --crew starts empty; needs --title or --goal runner mission stop | resume | archive | unarchive | pin | unpin [<mission>] runner mission rename <mission> <title> | move [<mission>] (--project <project> | --unfile) runner mission feed [<mission>] [--follow] [--since <offset>] [--limit <n>] [--oldest-first] [--types <kind,...> | --all] [--from <handle>] runner mission answer <mission> <question_id> <choice> runner chat start (<role> | --runtime <runtime>) [--model <model>] [--effort <effort>] [--speed inherit|standard|fast] [--project <project> | --cwd <dir>] runner session list | show <session> | stop <session> | archive <session> | resume <session> | restart <session> # mission-scoped runner msg post [--mission <mission>] [--as <handle>] [--to <handle>] <text> runner msg read [--since <ulid>] [--from <handle>] runner signal <type> [--mission <mission>] [--as <handle>] [--payload <json>] runner ask <question> [--choices <a,b,...>] [--mission <mission>] [--as <handle>] runner ask --human <prompt> [--choices <a,b,...>] [--mission <mission>] [--as <handle>] ``` Every command accepts `--json` for JSON output and `-q` for result ids. stdout carries data; stderr carries diagnostics. Exit 0 is success, 1 means the tool refused the operation, 2 is usage or reference resolution, 3 means the app is not running, and 5 means the connection to the socket was denied although the socket exists, which is a command sandbox such as Codex's default one: the caller runs the command again outside the sandbox. 4 is reserved for [#916](https://github.com/yicheng47/runner/issues/916)'s `wait` timing out. `spawn`, `ps`, `wait`, `stop <handle>`, and `done` are reserved for [#916](https://github.com/yicheng47/runner/issues/916), verbs granted per role or slot; `runner help` still labels them `RESERVED FOR #562`. ### 9.2 References and defaults Roles resolve by their unique handle. Crews and projects resolve by id or exact name; an ambiguous name exits 2 and prints the matching ids. Missions and sessions resolve by id or unique id prefix. `session_list` contains direct chats only, so a mission session can be resumed or restarted only by its full 26-character id, which passes through for backend validation. Active mission lookup comes from `mission_list`; because archived missions are absent from that tool, any full 26-character mission id is validated with `mission_get` and then passed to the requested tool. An active-only operation on an archived mission is therefore a tool refusal (exit 1), while a missing name, prefix or full mission id remains an unresolvable reference (exit 2). `mission start`, `chat start`, and `project create` default to the shell's current directory exactly unless a project or explicit directory is supplied. Relative paths are joined to that directory and normalized lexically by removing `.` and resolving `..`, without filesystem canonicalization. Long text accepts an inline flag or a file, and `-` reads stdin. On update/set commands, an empty optional value clears the field. For Codex Speed, `inherit` clears a saved role or slot choice; omitting `--speed` preserves it on update/set and follows the role or Codex config for a new chat. Default output is command-aware rather than a generic JSON projection. List commands expose only their identifying and operational columns; show commands use key-value blocks plus noun-specific sections; mission feed emits one chronological line per event. Table cells collapse whitespace, truncate at a fixed width with an ellipsis, and render null as `-`. `--json` preserves the tool's JSON text verbatim and `-q` prints ids only. `mission feed --follow` subscribes to the daemon's event stream before printing the requested snapshot window. Each `event/appended` payload carries the event's durable byte `next_offset`; the follower prints new entries immediately and ignores buffered entries already covered by its cursor. There is no CLI poll timer or notification batching. On subscription lag, it drains the log from its last cursor in oldest-first pages before continuing pushed delivery. Each event is printed once. Startup, mission/session lifecycle notifications and subscription lag read `mission_status`, then drain `mission_feed` through that snapshot's last-event offset before deciding whether to end. The existing event bus supplies pushed events and their log cursors; the follower adds no watcher. Taking the snapshot before reading the feed lets it drain final events before exiting. Ctrl-C, archive, completed mission state and all sessions stopped exit successfully. An aborted mission or terminal crashed sessions exit 1; a partial session crash is reported immediately while watching the surviving sessions. Empty startup rosters and busy/idle activity do not end the watch. `mission stop` leaves the mission row running for later resume, so the follower uses session lifecycle too. A crew message saying “done” is surfaced as a message, not interpreted as a lifecycle transition. Resume requires a new watcher. Every stopped/crashed session transition and successful watch end is reported on stderr, independently of feed filters; session notices use roster handles when available. App disconnect exits 3. A watch request timing out after 30 seconds exits 1 and explicitly says Runner may still be running; the timeout applies to snapshot/drain requests, while an idle pushed subscription waits for events. A missing mission or non-advancing feed cursor also exits 1. Errors identify the mission and last consumed cursor with show/re-follow arguments for the same Runner executable, preserving the development/installed app boundary. There is no silent retry. Host watch expiry is handled by the agent's watch rule, since the host can kill the CLI before it can report: each follow JSON line carries its own `next_offset`, so the agent re-arms with `--since <last line's next_offset> --oldest-first` instead of replaying the log. Claude Code's Monitor expires after at most 30 minutes, shorter than most missions, so this is the routine path there. Watch facilities must deliver stderr and process exit as well as stdout; Claude Code Monitor needs `2>&1` because only stdout produces notifications. That merged host stream can include plain-text diagnostics; the CLI's own stdout remains event NDJSON. `--types` and `--from` filter client-side. Human feed output and every follow stream hide routine `session_status`/legacy `runner_status` and `inbox_read` unless `--all` is present or `--types` explicitly names them; status payloads with `status.lifecycle = "error"` or `status.observation.outcome = "failed"` remain visible by default. A failed outcome persists across later busy/idle rows, so each distinct failure-bearing event remains visible until the outcome clears; event-ID deduplication still applies. One-shot `--json` without a filter remains the exact tool result; follow JSON is one flushed event per NDJSON line, with that entry's `next_offset` added to the event object as the `--since` cursor that resumes after it; no separate cursor line is printed. `--follow --limit 0` is rejected because it cannot deliver events or advance through a backlog. `runner help agents` is compiled into the CLI and therefore matches its installed version. It summarizes the command tree, references, output modes, exit codes, identity rule, and mission flow. An external operator starting a different mission must arm exactly one ID-bound watcher that delivers events, errors and exit to the initiating agent during later work and while idle; merely redirecting to an unread log or waiting only for completion is insufficient. The CLI start command itself remains one-shot, with unchanged ID/JSON output and no new flag. If the host cannot deliver events, the agent must say automatic watching is unavailable and give the foreground follow command. The guide names no host versions, because compiled text cannot follow host releases: a facility qualifies only if its own documentation says each output line and the exit reach an idle agent, with Claude Code's Monitor (`2>&1`, `timeout_ms` at its maximum) as the one worked example. The dated per-host evidence lives in [#686's smoke plan](../tests/archive/686-watch-cli-started-missions.md). ### 9.3 Two modes and one identity rule Inside a mission, `RUNNER_CREW_ID`, `RUNNER_MISSION_ID`, `RUNNER_HANDLE`, and `RUNNER_EVENT_LOG` identify the caller. `msg post`, `msg read`, `signal`, and `ask` append or read the event log directly without a Tokio runtime. `msg read` may first issue a bounded, read-only `mission_read_cursor(mission_id, handle)` query to the existing daemon, without auto-start. The optional snapshot publishes the membership start offset, latest ack ID and offset immediately after that acknowledged inbox message together after a complete bus projection batch, and rebuilds on mount. Default reads may seek after that message; `--all` and `--since` start at the membership boundary. The CLI folds newer valid acks in one captured batch before selection, then writes its ack after output. The offset never points after the later `inbox_read` signal, so messages appended between capture and ack remain unread. An unavailable socket or cursor falls back to folding the log from offset 0 for exactly the same join boundary, selection and ack behavior. Stale acks never move the watermark backwards. Other commands use the socket, and mission commands with no explicit target default to the caller's mission. An explicit different mission uses the outside path. Outside a mission, mission-scoped writes require `--mission`; `msg read` is inside-only and points outside callers to `mission feed`. The caller acts for the user, so posts and answers with no handle appear as the person, represented as `human`. `--as <handle>` becomes the socket tool's `from` after roster validation, but must only name a slot the caller holds, never another agent's slot; outside seats remain planned in [#916](https://github.com/yicheng47/runner/issues/916). `ask` requires `--as` outside; `mission answer` is always the person's verb and posts `human_response` without a handle. ### 9.4 Direct chats Direct chats remain off the mission bus, so they have no implicit mission, mission identity, event log, router, or inbox. Agent direct chats export `RUNNER_HANDLE` as their process label, but the CLI treats that handle-only environment as off-bus; it does not turn the label into a caller identity. Every chat, terminal, resume, and fork gets `<app data>/bin` first on PATH, so bare `runner` is the sidecar belonging to the app that spawned it even if the runtime loads a skill installed by another build; a mission slot's identity shim remains ahead of that folder. PATH does not select or suppress skills. The socket endpoint remains selected at compile time, with no environment override. Workspace commands go through the socket, mission-scoped writes name `--mission`, calls without `--as` act for the user, and `msg read` is unavailable. Until #916 adds outside seats, direct chats and terminals must not use `--as` to speak as a mission slot. ### 9.5 Socket transport Each outside command connects to `runnerd.sock` through `SocketTransport` and `DaemonClient`, bounds connection and handshake establishment to 500 ms, performs the reference-list calls and requested operation, prints the result, and exits. A fast `app_version` request reports the daemon version without hashing the CLI executable. Undecodable requests or responses report protocol skew with a restart-Runner message and exit 1. The exhaustive recorder test exercises the CLI adapter and checks its typed requests against `api.rs`. `runnerd` also binds the old MCP endpoint as an accept-and-close sentinel: if a 0.12 app replaces it, the ownership check stops the daemon before two processes own the database. The stdio bridge is gone; the once-only upgrade step removes only the exact bridge registrations this installation wrote, leaves mismatched entries unchanged as final skips, and defers unreadable, unparseable, or concurrently changed configs for retry without changing them. ### 9.6 Agent discovery skill Runner embeds a compact `SKILL.md` that points agents to `runner help agents`. Release builds install `runner` and prefer bare `runner` on PATH. Development builds install a separate `runner-dev` skill with a development-only frontmatter trigger and always invoke its quoted absolute sidecar, including for `help agents`, so it neither competes with the release skill nor follows a production `runner` from another PATH. Both generated skills distinguish participants from outside operators. Participants finish their turn when waiting with no independent work, receive pushed inbox notifications and run unfiltered `runner msg read`; they do not watch, poll or sleep-loop for their own inbox. External operators require start-and-watch without a separate user request, one watcher per exact returned mission ID, immediate handoffs/questions/failure reporting, and cleanup at lifecycle end. The development skill uses its quoted absolute sidecar for both start and feed. On an expired/broken watch, the agent reports the gap, checks mission state, and re-arms one watcher from the `next_offset` of the last event line it received; only when none arrived does it catch up from offset 0 and skip event IDs it has seen. Host limitations must be reported honestly. The skill states the same qualifying test as the guide and names only Claude Code's Monitor; everything else takes the limitation path unless its documentation says it delivers while idle. These are workflow instructions, not host configuration changes. Updated skills reach real agent homes only through an app build and the normal app-managed installation path. The four managed user roots are `~/.claude/skills`, `~/.agents/skills` (shared by Codex, Copilot, pi, Cursor, Grok and OpenCode), `~/.trae/skills`, and Antigravity's `~/.gemini/antigravity-cli/skills`. Runtime identities declare these roots in `runner-core/src/runtime.rs`. The `runnerSkillEnabled` switch in Settings → General is the truth and defaults on. When on, launch and every runtime-discovery refresh install or refresh the skill in each root read by a detected runtime; the Settings → Agents enable switch is deliberately irrelevant, and a marked folder deleted by hand returns. When off, Runner removes every owned folder from all four roots and does not reinstall it. A root no detected runtime reads is left alone. Each Runner-owned folder has `.runner-managed`. Runner rewrites only absent or marked folders and never touches a foreign folder of the same name. A foreign folder is reported beneath the General switch; a healthy enabled switch and a disabled switch have no status line. Settings → Skills continues to show the folder in each runtime's ordinary catalog, with a `Managed by Runner` badge when the marker is present. The older `initializedSkillRoots` setting remains decodable but is no longer consulted. ### 9.7 Command installation The command installed for a person's terminal is a link to the sidecar in `<app data>/bin`, never the app executable. Release builds call it `runner`; macOS development builds call it `runner-dev`. Runner owns a macOS command only when it is a symlink whose target is this build's sidecar. A regular file, a directory, or a symlink to anything else is foreign and is never replaced or removed. Status distinguishes not installed, installed at a path, foreign at a path, and an owned install shadowed by another executable earlier on the login PATH. After login-shell discovery succeeds, the first-launch default chooses only `~/.local/bin` when that exact directory is on the login PATH, otherwise `/usr/local/bin` when it is on the login PATH and writable without escalation. It does nothing and records nothing when neither qualifies, so a later runtime refresh can try again. The default never prompts. Once it installs, finds an owned link, or finds a foreign entry, `initializedCommandInstall` records the decision. Explicit Install and Uninstall record it before acting, so a link removed by the user is not resurrected at the next launch. Explicit Install uses the default target when one exists. Otherwise it tries `/usr/local/bin/<name>` directly, then runs `/usr/bin/osascript` with administrator privileges; cancellation is a normal unchanged outcome. Uninstall removes only an owned link and uses the same escalation path when a direct removal is not permitted. Settings → General and the command palette share these actions, and `runner status` reports the resulting state. That status check ignores the sidecar and mission-shim PATH entries Runner injects into its own sessions, so they cannot falsely shadow the user install. On Windows, release builds add exactly the sidecar directory to `HKCU\Environment\Path`, preserving `REG_SZ` versus `REG_EXPAND_SZ`, then broadcast `WM_SETTINGCHANGE` for `Environment`. Comparison is case-insensitive and ignores a trailing backslash. Both Settings and `runner status` read that registry value as the ownership source; the current process PATH is used only to detect shadowing. Uninstall removes exactly that entry and broadcasts again. A Windows debug build does not install a command because adding its directory could only expose `runner.exe`, not a separate `runner-dev.exe`. ## 10. Data model ### 10.1 SQLite (config + session lifecycle) Migration 0026 records each existing session's next-resume launch definition inside the migration transaction, including archived rows and runtime-only chats and shells. Migration 0027 then drops the six process-host fields and the three override fields it replaces. Resume refuses a row without a definition with `session … has no launch definition`; it never falls back to the current role. Each successful launch records `pid` and its resolved `agent_command` together for the startup orphan sweep. Migration `0025` removes the unused crew purpose and default goal columns. Older Runner versions still select those columns, so they cannot use a database after this migration. Downgrading requires a database backup made before migration; the column drop cannot be reversed to recover their contents. ```sql crews ( id TEXT PRIMARY KEY, name TEXT NOT NULL, system_prompt_addendum TEXT, -- Layer-2 team conventions; nullable created_at TEXT, updated_at TEXT ); roles ( id TEXT PRIMARY KEY, handle TEXT NOT NULL UNIQUE, -- globally unique slug; §3.2 display_name TEXT NOT NULL, runtime TEXT NOT NULL, -- claude-code | codex | trae | copilot | pi | antigravity (shell, qoder: legacy rows only) command TEXT NOT NULL, args_json TEXT, working_dir TEXT, -- direct-chat working dir; missions use mission.cwd system_prompt TEXT, -- Layer 3 persona env_json TEXT, model TEXT, effort TEXT, -- optional overrides, composed into argv at spawn created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); slots ( id TEXT PRIMARY KEY, crew_id TEXT NOT NULL REFERENCES crews(id) ON DELETE RESTRICT, role_id TEXT NOT NULL REFERENCES roles(id) ON DELETE RESTRICT, slot_handle TEXT NOT NULL, -- in-crew handle; unique within crew position INTEGER NOT NULL, lead INTEGER NOT NULL DEFAULT 0, runtime_override TEXT, model_override TEXT, effort_override TEXT, added_at TEXT NOT NULL, UNIQUE (crew_id, slot_handle), UNIQUE (crew_id, position) ); projects ( id TEXT PRIMARY KEY, name TEXT NOT NULL, cwd TEXT NOT NULL, position INTEGER NOT NULL, created_at TEXT NOT NULL ); -- The sidebar tree: projects, tabs and mission references as one ordered -- forest (migration 0014 replaced the folders/tabs tables). 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, -- 'project' | 'tab' | 'mission' name TEXT, -- tab title ref_id TEXT, -- projects.id / missions.id layout TEXT, -- tab-only: pane layout JSON pinned_position INTEGER, -- non-NULL = pinned last_completed_at TEXT, last_viewed_at TEXT, -- tab attention watermarks created_at TEXT NOT NULL ); missions ( id TEXT PRIMARY KEY, crew_id TEXT, -- optional origin; retained after crew deletion project_id TEXT REFERENCES projects(id) ON DELETE SET NULL, title TEXT NOT NULL, status TEXT NOT NULL, -- running | completed | aborted goal_override TEXT, cwd TEXT, -- authoritative working dir for slot spawns started_at TEXT NOT NULL, stopped_at TEXT, archived_at TEXT, pinned_at TEXT ); sessions ( id TEXT PRIMARY KEY, mission_id TEXT REFERENCES missions(id) ON DELETE SET NULL, -- NULL = direct chat project_id TEXT REFERENCES projects(id) ON DELETE SET NULL, role_id TEXT REFERENCES roles(id) ON DELETE RESTRICT, slot_id TEXT, cwd TEXT, status TEXT NOT NULL, -- running | stopped | crashed pid INTEGER, started_at TEXT, stopped_at TEXT, agent_session_key TEXT, -- the agent CLI's own conversation id, for Resume agent_runtime TEXT, -- recorded runtime; SQL filters use this column agent_command TEXT, -- resolved executable for the recorded pid launch_definition_json TEXT, -- versioned configured inputs; NULL = unconvertible legacy row live_title TEXT, -- the agent's own conversation title, when it reports one last_cols INTEGER, last_rows INTEGER, -- last applied PTY size (§5.8) resume_on_launch INTEGER NOT NULL DEFAULT 0, archived_at TEXT, title TEXT, pinned_at TEXT ); session_attention ( session_id TEXT PRIMARY KEY REFERENCES sessions(id) ON DELETE RESTRICT, unread_since INTEGER, -- ms epoch: finished while its tab was not viewed error_acknowledged_at TEXT ); ``` Migrations live in `crates/runner-daemon/migrations/` (`0001_init.sql` … `0028_optional_mission_crew_restrict_deletes.sql`) and are forward-only: an older Runner cannot open a database a newer one has migrated, which the 0023 table rename accepted deliberately. Two rules from that rename hold for every table. Every SQL statement that names a table lives behind a `repo/` function, so `ops/` and `session/` speak only in domain terms; the gate is a grep for the table and column names inside SQL strings, which must hit only `repo/` and the migrations. A persisted value whose spelling changes is written in the new form and read in both, with nothing frozen under the old name for compatibility's sake: `role-default` reads `runner-default`, the start-chat mode `role` reads `runner`, the saved route `/roles` reads `/runners`. ### 10.2 Filesystem ``` ~/Library/Application Support/com.wycstudios.runner/ ($APPDATA; debug builds: …runner-dev) ├── runner.db # SQLite (WAL) ├── ui-settings.json # preferences (§3.7) ├── mcp.sock # accept-and-close sentinel for older apps ├── bin/ │ └── runner # general CLI + direct mission-bus verbs ├── shell-integration/ # OSC 7 hooks for terminal shells (§5.3) │ ├── zsh/.zshenv │ └── bash/runner.bash └── crews/{crew_id}/missions/{mission_id}/ ├── events.ndjson # per-mission event log └── roster.json # version 2 mission roster ~/Library/Logs/com.wycstudios.runner/runner.log # rotating app log + panic backtraces ``` A mission's directory is fixed at start: `crews/<crew_id>/missions/<id>/` for a crew start, `missions/<id>/` for an empty start. Deleting a crew never moves its missions. Session shims stay under `missions/<id>/shims/<handle>/bin/`; crew-less sessions omit `RUNNER_CREW_ID` in both the PTY environment and the shim. `event_log::mission_dir` is the shared resolver. The data directory has been the same since the Tauri app, so every upgrade finds its roles, crews, missions and sessions in place. Startup removes the stale legacy bridge executable from `bin/` best-effort. Direct chats are off-disk beyond their row in `sessions`; mission sessions share their mission's directory, with `events.ndjson` and `roster.json` as durable artifacts. Screen state lives in memory (§5.8). ### 10.3 Deletion What a delete takes with it is decided in code, not by the `REFERENCES` clauses in §10.1 ([#619](https://github.com/yicheng47/runner/issues/619)). A repository delete removes rows from its own table; the owning delete in `ops/` removes or unbinds every dependent row first, inside the transaction the operation already holds. The constraints and `PRAGMA foreign_keys = ON` stay as a backstop that nothing relies on: each delete path has a test that runs it with the pragma off, and one test in `ops/tests.rs` runs them all on a real database and checks that `PRAGMA foreign_key_check` stays empty. That test also pins the list of foreign keys, so a new one fails it until its owning delete handles it. Foreign keys never cascade-delete: the delete actions are `RESTRICT` or `SET NULL`. Migration 0028 rebuilds referenced tables with enforcement disabled before the transaction and requires `foreign_key_check` to pass before committing; the runner restores enforcement on success or failure. | Entity | Owning delete | Deletes first | Unbinds | Refused when | |---|---|---|---|---| | Role | `ops::role::delete` | its sessions | — | a crew has a slot for it; it has an unarchived direct chat; an unarchived mission has a session for it | | Crew | `ops::crew::delete` | its slots | — | — | | Slot | `ops::slot::delete` | — | — | — | | Mission | `ops::mission::delete_rows` | its sessions | — | Settings delete: it is not archived | | Session | `ops::session::delete_rows` | its `session_attention` row | — | Settings delete: it is not archived, or it belongs to a mission. Close: it is not a shell | | Project | `ops::project::project_delete_impl` | its tabs, archiving chats and deleting terminals | `sessions.project_id`, `missions.project_id` | a session in one of its tabs is missing, bound to a mission, or still running; then, its node still has a child once the tabs are gone. The socket tool also refuses running members unless forced | | Node | `repo::node::delete` | — | — | it has children, with SQLite's own `FOREIGN KEY constraint failed` | Dependents go through their own entity's delete: deleting a mission through `ops::mission::delete_rows` removes its sessions and attention rows through `ops::session::delete_rows`. Crew deletion removes only its slots and preserves mission history. Both take a list of ids and issue one statement per table, however many rows go. A mission's sessions are deleted, not unbound: a mission-scoped row with no mission would surface as a direct chat, so the `ON DELETE SET NULL` on `sessions.mission_id` never fires. Role delete refuses instead of taking the role's slots with it: a slot is part of its crew, so a crew only loses a member through slot delete, which repacks positions. The sessions a role delete does take are its archived chats and the sessions of missions whose slot for it was removed since. The node refusal is the one guard in the repository layer, because the sidebar tree is a single table that references itself. A refused delete rolls back its transaction, including any dependents it had already removed. Work committed before that transaction stays committed: a project delete archives its member missions one by one first, so a delete refused later can leave them archived, as it always could. `ops/` also owns what is product rather than integrity: dense positions after a slot delete, and removing a mission's directories after commit. Live processes stop before the transaction opens. Role delete kills the role's sessions, in both `ops::role::role_delete` and the socket tool. Mission delete closes its drawer shells. Project delete archives its missions and kills its running chats before its final transaction. References with no foreign key behind them, `sessions.slot_id`, `nodes.ref_id` and the session ids inside `nodes.layout`, are handled where they always were: `repo::node::remove_session`, `delete_mission_node` and the `ensure_active_sessions` repair. ## 11. Process and thread model Runner has three process roles: the native app, `runnerd`, and one CLI process per command. [Concurrency](concurrency.md) explains their executors; [Process model](process-model.md) maps ownership and terminal paths. ### 11.1 The shape ```text Runner.app GPUI foreground/background executors, AppStore, terminal mirrors, updater socket reader/writer and managed reconnect worker runnerd AppCore, SQLite pool, SessionManager, authoritative terminals per-session PTY reader, idle detector, forwarder, terminal-event and input workers per-mission bus watchers, router cooldown/reconciliation timers runner-ipc tokio runtime: client protocol and older-app sentinel discovery/login-shell/version probes and usage workers runner CLI current-thread tokio runtime for typed requests and pushed feed events; mission appends write directly ``` ### 11.2 Why a thread per PTY reader (not async) `portable-pty`'s read side is blocking. An OS thread doing one blocking `read(2)` in a loop is the right shape: the kernel parks it cheaply when there are no bytes and wakes it instantly when there are. The manager forwarder publishes numbered output and feeds the daemon’s authoritative `Term`. The app has a separate socket reader and mirror parser; GPUI only re-renders when a viewed mirror changes. ### 11.3 What runs per-mission vs. app-wide | Lifetime | Components | |---|---| | App-wide | GPUI, AppStore, terminal mirrors, socket connection, updater and app log writer. | | Daemon-wide | AppCore, SessionManager, SQLite pool, both socket servers, discovery, usage and daemon log writer. | | Per live mission | One notify watcher + bus tail + router dispatch, wired to that mission's NDJSON file. | | Per live session | The PTY reader thread, the writer and input worker, the idle detector, an authoritative `Term` (10,000-line scrollback), and a mirror while the app is attached. | Per-mission components come up at mission start (or daemon launch for `running` missions) and go down at archive. A reversible Stop kills PTYs but leaves router/bus state mounted. ### 11.4 Cost model The target scale is one operator with a handful of concurrent missions and ≤ ~10 live sessions. That is ~10 reader threads, an authoritative `Term` per session and an attached mirror (tens of MB each only when scrollback is full), a few notify watchers and one SQLite pool. Rendering cost scales with *visible* terminals, not live ones. Per-frame work today re-walks the visible grid (M6.7 lists what is still wasteful); nothing in the model needs an event loop over PTY fds at this footprint. ### 11.5 Failure isolation A panic in a PTY reader thread only affects that session: the forwarder ends, the session is marked stopped. An app crash leaves daemon sessions running. A daemon crash closes PTYs on macOS and kill-on-close jobs on Windows; its next startup sweeps stragglers and demotes stale rows. After three crashes in five minutes or a failed automatic replacement, the app stops automatic recovery and retains a banner in each window’s main content with Open log for `runnerd.log` and Try again. It says “Runner's background service keeps crashing. Sessions are stopped until it runs again.” Requests while the service is down leave that banner intact. Try again resets the counter and makes one connection/start attempt; restored panes resume sessions explicitly, and a failed start or another crash returns to the cap. A panic on the GPUI main thread takes only the app down — the panic hook writes the backtrace to `runner.log` first, and the crash reporter's `.ips` lands in `~/Library/Logs/DiagnosticReports/`. The standing GPUI rules in `impl_log.md` exist because several of those were found the hard way (`window.current_view()` outside render, entity updates inside their own observers). ## 12. Architectural bets 1. **Mission is the runtime unit.** Crew is config; mission is a run. 2. **Slot is the indirection** that lets one role participate in many crews and direct chats without duplication. 3. **PTYs in `runnerd` via `portable-pty`, not pipes or tmux.** TUI fidelity is non-negotiable. 4. **NDJSON file per mission, not a broker.** Debuggable and crash-durable. 5. **One CLI for people, scripts, and agents.** The local client protocol is its transport. 6. **Signals and messages as distinct primitives.** Keeps the router simple and prose natural. 7. **The signal router is the only urgent wake-up path**, and every push goes through one delivery gate. 8. **Prompt composition at spawn time (Layer 1/2/3).** Replaces runtime handshakes. 9. **Small vocabulary.** Signals + messages; no threads or facts. 10. **One authoritative terminal in the daemon, a mirror in the app.** Both use the same `alacritty_terminal` parser. Numbered raw bytes and exact snapshots cross the socket; only the daemon answers terminal queries. 11. **ULID for event IDs.** Sortable, monotonic within ms. 12. **Mission state and PTYs outlive the app process.** Keep running disconnects the app; Stop and updates stamp launch resume and replace children. The daemon keeps routing while no window is open. 13. **The core is UI-agnostic.** `runner-daemon` knows nothing about GPUI; the app and CLI consume the client protocol. ## 13. What would break this architecture - A runtime with no way to take a first turn or a system prompt at spawn. - An agent that won't learn to call CLI tools. - NDJSON append atomicity breaking on a network or cloud-synced filesystem. App data must stay on a local filesystem with the platform's supported locking semantics. - Platform differences in PTY behavior, especially ConPTY redraws, Job Object lifetime, resize and Windows shutdown ordering. - A GPUI API break: `gpui-pre` is pinned exactly (0.3.7) and bumped deliberately; the terminal element and IME integration are the surfaces most exposed to it. ## 14. Program state — line, landing, channels Release-channel contract with one rolling prerelease per channel and shared app identity (#504/#505, following #502); the first live cut ran on 2026-09-08 and the old Windows release is gone: - **`main`** is the production integration branch; remote control developed on the `nightly-remote` umbrella and reached `main` on 2026-10-25 in [#1020](https://github.com/yicheng47/runner/pull/1020); the Tauri + React line ended at `276a3a4` and its last release was `v0.5.2`. The temporary update bridge into `v0.6.0` is now closed. Work lands as a task branch → PR → required macOS and Windows checks → reviewed merge. Behavior changes carry their guide updates; shipped specs, plans and evidence are reconciled in the release docs sweep. Coders and reviewers do not launch the app; a crew's qa slot runs selected live checks after a clean code review, in the isolated development environment under [Test Scope](../../AGENTS.md#test-scope). - **Versions.** Nightly is a rolling development channel, shown as `Nightly (<short-sha>)`; it is independent of an official release number. Official releases alone get `vX.Y.Z` version tags. `CFBundleVersion` is the UTC build stamp `YYYYMMDD.HHMM` on both macOS channels, which Sparkle compares; `CFBundleShortVersionString` is the short commit on nightly and `X.Y.Z` on production. Sparkle supplies the `Runner` app name in its native alert; Runner’s own update displays format the commit as `Nightly (<sha>)`. Windows installers use `nightly.<sha>.<stamp>` and update by the trailing stamp, while the app and update offer show `Nightly (<sha>)`. All five crates inherit the root `[workspace.package].version` since #635. Production releases bump that one version; nightlies keep it unchanged and do not require a `-nightly` suffix. Unstamped `make run` builds show `<crate-version> (dev)`. - **Nightly workflow.** `gh workflow run nightly.yml --ref main` is dispatch-only and defaults to `platform=all`. `nightly-on-merge.yml` dispatches it with `platform=all` for every pull request merged into `main` that changes more than `docs/` and Markdown files; direct pushes to `main`, such as docs commits and release version bumps, do not (Jason, 2026-10-27). Dispatch by hand for anything else; `-f platform=macos`, `-f platform=windows` or `-f platform=linux` selects one platform family. Legacy `both` selects macOS+Windows. `prepare` computes one UTC stamp, commit-based nightly identity, and dispatch SHA without consulting the official crate version. All selected builds check out that SHA and pass artifacts from the current run to one `publish` job. One `nightly` concurrency group cancels the whole older run. Publishing requires every selected build to succeed and tolerates the unselected build being skipped; a failed or cancelled selected build prevents publication. It does not wait for `main`'s CI: code reaches `main` only through pull requests whose required checks passed (Jason, 2026-10-25). - **Nightly channels.** One public `nightly` prerelease serves macOS, Windows and Linux and is excluded from latest stable. macOS publishes the signed/notarized `Runner-Nightly-<sha>.<stamp>-arm64.dmg`, then a signed `appcast.xml` with an Apple Silicon requirement. The `com.wycstudios.runner` bundle (`Runner.app`) reads `releases/download/nightly/appcast.xml` and offers updates through Sparkle. Windows reads `releases/tags/nightly` and selects the newest `Runner-Setup-nightly.<sha>.<stamp>-x64.exe` with its matching `.sig`, ignoring macOS assets. One combined notes file, `script/nightly-release-notes.md`, covers all platforms. The workflow verifies release flags and every selected asset’s anonymous download before pruning each platform independently to two builds by stamp; Windows installers and signatures are deleted together, and the appcast’s DMG is retained. Single-platform cuts leave the other platform’s assets and feed alone. After validating the artifacts, `publish` force-moves the annotated `nightly` tag to the built commit and writes the release notes: the commits since the latest official `v*` tag reachable from the commit (capped at one hundred, merges omitted) above the fixed install text in `script/nightly-release-notes.md`, so the list reads as what the nightly has beyond stable and resets at each release. `git checkout nightly` is therefore the shipped nightly, and the release page shows its commit. Uploads are not transactional: failure or cancellation after an upload means an incomplete cut, and upload/download failures prevent all pruning. The `/nightly run [all|both|macos|windows|linux]` and `/nightly check [all|both|macos|windows|linux]` skill paths use this contract. - **Production channel.** Tag `vX.Y.Z` on a commit whose crate version is exactly `X.Y.Z` → `release.yml` builds the same ARM64 bundle for `com.wycstudios.runner` (`SUFeedURL` `releases/latest/download/appcast.xml`), generates a one-item signed appcast with an Apple Silicon hardware requirement, and attaches everything to a **draft** release; publishing is the human's switch, because it moves the `releases/latest` alias. `workflow_dispatch` with `dry_run` builds the same artifacts into a throwaway draft. One Sparkle EdDSA keypair (`packaging/sparkle-public-key`) serves both feeds. Intel packaging is not planned (§2); an Intel install left on the universal `v0.6.0`/`v0.6.1` stays there, correctly withheld by the appcast's Apple Silicon hardware requirement. - **Tauri bridge, closed.** `v0.6.0` carried the Tauri updater files so a dormant 0.5.x install could hop to Sparkle; the hard cutoff at 0.7.0 has passed, and a 0.5.x install still dormant installs the DMG by hand. - **Linux definitions.** Default nightly selection `all` adds four architecture-labelled tar/deb assets and four matching minisign signatures, with one stamp retained per architecture; legacy `both` continues to mean macOS+Windows. The native ARM64/x86_64 matrix uses Ubuntu 22.04 containers. Production joins the shared draft release and its CI gate; nightly validates selected builds and publishes without waiting on main CI. Linux verifies signed in-app updates, using separate stable/latest and nightly feeds. See [Linux packaging](./linux.md#packages). - **Windows integration.** Both platforms develop from `main`, with CI on PRs and main pushes. `release.yml` computes one identity, builds the macOS artifacts and Windows x64 installer separately, and creates one draft only after all selected platform builds and CI pass. Windows binaries and installers are Authenticode-signed through Certum SimplySign in CI and carry minisign update signatures; see [Code signing](./windows.md#code-signing). `bundle-windows.ps1 -Channel production` bakes the stable release channel and base display version; the default nightly channel uses `nightly` and displays `Nightly (<sha>)`. The updater compares the trailing UTC stamp, including older bare-version/`X.Y.Z-nightly` installers and new commit-based nightlies. Both channels retain `%APPDATA%\com.wycstudios.runner`; see [Windows update behavior](./windows.md#update-behavior). - **Isolation.** Nightly and production use the same macOS bundle id and `Runner.app` name, retaining the data directory in §10.2. Installing a nightly over Runner switches that install to the nightly feed; installing a stable DMG switches it back. Production Sparkle reads only `releases/latest/download/appcast.xml`; nightly Sparkle reads `releases/download/nightly/appcast.xml`. The feed address provides channel isolation. Public nightlies are discoverable on the releases page, but prereleases never become latest stable. Updates require the user’s install action; dispatch can lead to a nightly update offer and Windows background download, but does not itself restart the app. - **One-time transition.** The `nightly-win` release and tag no longer exist, so a dormant Windows install that missed the hop reinstalls from `nightly`; the workflow never read or wrote the old release. On macOS the first unified DMG goes over `Runner.app` by hand and `Runner Nightly.app` is deleted, because Sparkle cannot install across bundle identifiers. History of how this was decided: [`../impls/archive/gpui-rewrite/README.md`](../impls/archive/gpui-rewrite/README.md) (condensed) and [`../impls/archive/gpui-rewrite/plan.md`](../impls/archive/gpui-rewrite/plan.md) §Release channels (full); the M6 remainder queue, all landed by 2026-08-27: [`../impls/archive/gpui-rewrite/m6-remainder.md`](../impls/archive/gpui-rewrite/m6-remainder.md).