> ## Documentation Index
> Fetch the complete documentation index at: https://comis-feature-matrix-channel.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Interactive Terminal Driver

> Drive interactive terminal programs -- shells, REPLs, full-screen TUIs, and AI coding CLIs -- through a real PTY, governed like an MCP server

**What it does:** Spawns an allowlisted command in a daemon-supervised, sandboxed worker process, attaches it to a real PTY rendered into a headless terminal emulator, and lets the agent perceive a stable character grid and act via keystrokes. Sessions are long-lived background resources -- one agent (or a subagent) can drive a full-screen AI coding CLI (Claude Code, Codex) across many event-woken turns.

**Who it's for:** Operators who want an agent to drive an interactive CLI that has no API -- a coding assistant, an installer wizard, a REPL, `vim`/`htop`. It is the platform's **most privileged** capability, so it is **off by default** and gated by an operator allowlist, exactly like installing a capable MCP server.

<Warning>
  The terminal driver runs a real program with the scope you grant it. Treat each allowlist entry as a trust decision: the **defaults are least-privilege**, you dial scope up explicitly, and a prompt-injectable agent can never widen it. `~/.comis` (the daemon's secrets) is **always** denied to every driven child, even at `filesystem: full`.
</Warning>

## The nine tools

All nine are `mcpExportPolicy: "never-export"` (never exposed to external MCP) and **non-blocking** (act, return the current settled snapshot, never hold a turn open, never kill the session on turn-abort).

| Tool                         | What it does                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `terminal_session_create`    | Start a session for an allowlisted command. Rejected unless the canonical binary matches an allow entry. Returns a stable `sessionId`.                                                                                                                                                                                                                                                                                                                                                                                              |
| `terminal_session_list`      | List the caller's sessions (owner-scoped).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| `terminal_session_read`      | Read the rendered screen + cursor (`text`/`ansi`/`html`, scrollback). Output is redacted and wrapped as untrusted content.                                                                                                                                                                                                                                                                                                                                                                                                          |
| `terminal_session_send_text` | Type text; optional submit + settle.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| `terminal_session_send_key`  | Send named keys/chords (`C-c`, `Up`, `Enter`, `S-Tab`).                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| `terminal_session_wait`      | Bounded settle on idle/text/exit/timeout. `timeoutMs` is generous (default 15s, cap 600s) so a **slow AI CLI** (e.g. `claude` taking 60-90s+) can be driven — pass a large `timeoutMs` with `forIdleMs` and the wait returns the instant the CLI goes idle; a long wait auto-backgrounds so the turn never blocks. A not-complete **timeout** returns `producing` (was output still arriving — `true` ⇒ keep waiting, `false` ⇒ idle/inspect status) plus a branched `hint`, so a slow-but-working CLI is not misread as a failure. |
| `terminal_session_status`    | Classifier state (`working`/`awaiting-input`/`exited`/`stuck`) plus its `confidence` (`high`/`medium`) and a machine-readable `reason` (e.g. `settled_cursor_parked`, `dialog_detected`, `no_progress`), cursor, interaction count.                                                                                                                                                                                                                                                                                                 |
| `terminal_session_resize`    | Resize the PTY + emulator.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| `terminal_session_kill`      | Terminate a session.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |

## Prerequisites

| Requirement                                                          | Why                                                                                                                                                                                                                                                                                                                                                           | Check                                                             |
| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- |
| **bwrap** (bubblewrap) + unprivileged user namespaces                | The driven child runs in a bwrap jail; without it `create` fails closed (never an unsandboxed child).                                                                                                                                                                                                                                                         | `bwrap --version`; `cat /proc/sys/user/max_user_namespaces` (> 0) |
| **node-pty** built                                                   | The real-PTY backend (full-screen TUIs). Absent ⇒ a degraded pipe backend (no TUI).                                                                                                                                                                                                                                                                           | shipped + built by the installer                                  |
| **tmux** (optional)                                                  | The long-run backend (`backend: "tmux"`): a session survives a worker restart and is re-attachable. Required for a **durable drive** (`drive.durable: true`) to survive a *daemon* restart. Absent ⇒ `tmux` requests fall back to the PTY backend, and a durable drive degrades to non-durable + a `WARN` at runtime (it is **not** rejected at config load). | `tmux -V`                                                         |
| The driven CLI installed **and authenticated for the daemon's user** | Comis is credential-agnostic -- the CLI authenticates itself (e.g. `claude` reads its own `~/.claude.json`).                                                                                                                                                                                                                                                  | run it once as the `comis` user                                   |

## Configuration

The terminal driver is configured **per agent**, under `agents.<id>.skills.terminal`.

<Warning>
  There is **no top-level `skills:` key** -- a top-level `skills:` block fails config validation and the daemon will not start. Always nest under a specific agent: `agents.default.skills.terminal`.
</Warning>

```yaml theme={}
agents:
  default:
    provider: openai-codex
    model: gpt-5.5
    skills:
      terminal:
        enabled: true
        worker:
          maxSessions: 4
          idleTtlMs: 1800000          # reap idle sessions after 30 min
          ringBytes: 1048576          # per-session output ring (1 MiB)
          stuckMs: 60000              # no-progress > 60s ⇒ "stuck" ⇒ escalate
          maxConcurrentAttentionTurns: 2
        defaults: { cols: 120, rows: 40, scrollback: 5000 }
        allow:
          # A no-network shell, locked down (least-privilege).
          - id: bash
            match: { path: /usr/bin/bash }     # canonical binary (realpath/hash-pinned)
            scope:
              filesystem: workspace            # only the session workspace is writable
              network: none                    # deny-all egress
              credentialPaths: []              # no credentials exposed (least-privilege)
              uid: dedicated                   # run as a throwaway uid, not the daemon
            autoAnswer: safe-only
            consent: { acknowledgedRisk: true, acknowledgedAt: "2026-06-14T00:00:00Z" }

          # Drive an interactive AI CLI (Claude Code here, but the driver is TOOL-AGNOSTIC).
          # filesystem: home because (a) the claude binary is home-installed
          # (~/.local/bin/claude -> ~/.local/share/...), which filesystem: workspace cannot
          # reach -- the jail only execs a binary its filesystem scope binds; and (b) claude
          # WRITES ~/.claude (session state + OAuth token refresh), which a read-only
          # credentialPaths bind would block. credentialPaths (RO) instead suits a CLI that is
          # system-installed AND only reads its creds -- see the credentialPaths note below.
          - id: claude
            match:
              path: /home/comis/.local/bin/claude   # canonical binary (realpath/hash-pinned)
              argsPrefix:                            # operator-pinned: the agent cannot change the permission mode
                - --permission-mode
                - bypassPermissions
            scope:
              filesystem: home                 # binds the home-installed binary + RW ~/.claude state
              network: full                    # reach the model API
              credentialPaths: []              # not needed -- filesystem: home already exposes ~/.claude (RW)
              uid: daemon                      # run as the daemon user so it owns its (comis-owned) creds
            autoAnswer: safe-only
            consent: { acknowledgedRisk: true, acknowledgedAt: "2026-06-14T00:00:00Z" }
            limits: { wallClockMs: 7200000, maxInteractions: 800 }
        redactSecrets: true
        audit: { enabled: true }
        # unsafeDisableSandbox: false   # DANGEROUS opt-out of the bwrap jail (see Security model).
                                        # Only for hosts that cannot run bwrap; leave false otherwise.
        drive:                          # autonomous-drive policy (optional; omit ⇒ today's behavior)
          mode: auto                    # auto (default) | attached (never promote) | detached (promote at first wait)
          readMode: digest              # digest (default) | diff | full — the woken-turn read shape
          durable: true                 # default — detached tmux: survive a daemon restart, re-attach + resume (false ⇒ non-durable pty)
          heartbeatMs: 90000            # internal liveness backstop (default 90s) — not a poll, not a user message
          maxCostUsd: null              # per-drive spend ceiling in USD (null = uncapped)
          notify: terminal              # which outcomes reach you: terminal (default) | all (reserved; today == terminal) | none — see Notifications
          heartbeatNotifyMs: 3600000    # user "still working" heartbeat for a long drive (default 1h; 0 = terminal-only)
```

<Tip>
  **Minimal config.** `enabled`, `worker`, `defaults`, `redactSecrets`, and `audit` all carry defaults -- the example above sets them explicitly for illustration, but you may omit any of them. On a bwrap-less host the jail opt-out is a one-liner: `terminal: { unsafeDisableSandbox: true, allow: [ … ] }`. Omitted worker caps default to `maxSessions: 8` / `idleTtlMs: 900000` / `ringBytes: 262144` / `stuckMs: 30000` / `maxConcurrentAttentionTurns: 4`, the emulator to `cols: 80` / `rows: 24` / `scrollback: 1000`, and `redactSecrets` / `audit.enabled` to `true`. You still need `allow` entries to launch anything (an empty allow-set fails closed), and an **absent** `terminal:` block leaves the driver unconfigured.
</Tip>

The whole `drive:` block is **optional** and additive: a config with no `drive` block behaves **exactly as before** (no auto-promotion change, the digest read is already the effective default). An unknown key under `drive:` (a typo) is rejected at config load, like every other terminal key.

The agent then drives it: `terminal_session_create({ allowId: "claude", command: "claude", project: "<name>" })` (the operator-pinned `argsPrefix` above is prepended automatically) -> wait -> `terminal_session_send_text(...)` -> `terminal_session_read(...)`. For the full per-CLI setup (Claude Code **and** Codex), see [Coding CLIs](/agent-tools/coding-clis).

**Per-project folders — always name a coding project.** Pass `project: "<name>"` to `terminal_session_create` and the session opens in a dedicated, auto-created folder `<agent-workspace>/projects/<name>` (e.g. `~/.comis/workspace/projects/<name>` for the default agent). **Any coding project you may revisit should be named this way** — it makes the work persistent and **retrievable**: to fix a bug or add a feature later, start a new session with the **same** `project` name and it reopens in the same folder. **Different** names give isolated folders — that is how multiple projects run **in parallel** (each its own session + folder, no collisions). The agent is instructed (in the `terminal_session_create` tool description) to set `project` automatically for coding work. Two things that do **not** create a project folder: the display `name` param (just a listing label), and `cwd` (an explicit working dir, honored only if *within* the session workspace — a path outside is silently clamped to the workspace, the jail-escape guard). So `project` is the only path-free, retrievable way to organize project work.

**Driving a slow interactive AI CLI** (e.g. `claude`/`codex`): the model's reply takes tens of seconds, so pass a generous `terminal_session_wait({ forIdleMs: 20000, timeoutMs: 300000 })` — the settle returns the instant the CLI goes quiet (not at the timeout), and a long wait auto-backgrounds so the turn never blocks (the completion then arrives as a follow-up, e.g. a Telegram message). The CLI's **first-run startup gates** persist once accepted (recorded in the CLI's credential file), so accept them once **outside the jail** before the first drive: Claude Code shows a "trust this folder" prompt and — under `--permission-mode bypassPermissions` — a "Bypass Permissions" warning whose default highlight is **"No, exit"**, so a reflexive Enter quits the CLI. Once accepted, the trust is recorded in `~/.claude.json`: under `filesystem: home` that file is bound **read-write**, so claude persists the acceptance itself and later drives open straight to the prompt. (If you expose creds via `credentialPaths`, which is **read-only**, the CLI cannot persist the gate state — pre-accepting outside the jail is then mandatory, not just convenient.)

**Nested sandboxes (Claude Code's Bash tool).** Claude Code, on Linux, wraps each Bash-tool command in a second bubblewrap that remounts `$HOME` read-only **in place** — and nested inside this driver's jail, claude's per-command state dir `~/.claude/session-env/<id>` then lands on that read-only mount, failing with `EROFS … mkdir`. The symptom is a claude that can **author** files but cannot **run** them (its Bash tool is dead while Read/Write/Edit work). The driver fixes this by mounting a writable `tmpfs` carve-out at `~/.claude/session-env` — a **separate** sub-mount that survives claude's in-place `$HOME` remount, so the `mkdir` lands on a writable surface and Bash runs. (It also sets `CLAUDE_CODE_BUBBLEWRAP=1` and strips the daemon's `NODE_OPTIONS` from the child env — defense-in-depth and correctness, but the carve-out is what actually unblocks Bash in the production seccomp'd daemon.) All automatic; no configuration required. claude's creds/config under `~/.claude` stay intact — only the transient `session-env` subdir is an ephemeral, per-session tmpfs.

### Allow-entry fields

| Field              | Meaning                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `id`               | The stable `allowId` the agent passes to `terminal_session_create`.                                                                                                                                                                                                                                                                                                                                                                                                      |
| `match.path`       | The canonical absolute binary path (resolved via `realpath`; optional `hash` pin). Spawned as direct argv -- never via a shell.                                                                                                                                                                                                                                                                                                                                          |
| `match.argsPrefix` | Optional pinned argv prefix the agent's `args` must start with.                                                                                                                                                                                                                                                                                                                                                                                                          |
| `scope`            | The sandbox the child runs in (below). Sourced **only** from this entry -- the agent cannot supply or widen scope.                                                                                                                                                                                                                                                                                                                                                       |
| `autoAnswer`       | `none` / `safe-only` (default) / `all`. `safe-only` auto-answers prompts matching `hintPatterns` **and** the selected platform profile's known-safe dialogs (e.g. Claude Code's "trust this folder" gate — see [Platform profiles](#platform-profiles)); everything else — a profile's `destructive` dialog (e.g. Codex's run-command approval), and any destructive/approval/auth-login prompt — escalates. The escalate-always veto wins over any profile/hint answer. |
| `consent`          | Required informed-consent record (`acknowledgedRisk: true` + a timestamp).                                                                                                                                                                                                                                                                                                                                                                                               |
| `limits`           | Optional per-session caps: `wallClockMs`, `maxInteractions`, `maxRequestsPerSession`, `maxSessions`.                                                                                                                                                                                                                                                                                                                                                                     |
| `approveOnCreate`  | Require the approval gate at `session_create`.                                                                                                                                                                                                                                                                                                                                                                                                                           |
| `backend`          | `tmux` (long-run survival, **the default** — a durable drive implies it) or `spawn` (non-durable, the `drive.durable: false` opt-out). See [Durability & resume](#durability--resume).                                                                                                                                                                                                                                                                                   |

## Security model

Every driven session runs in a bwrap jail materializing the entry's `scope`:

| Scope dimension   | Values                                                                                                                                                                                                                                                                                                                           | Default     |
| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
| `filesystem`      | `workspace` / `listed-paths` / `home` / `full`                                                                                                                                                                                                                                                                                   | `workspace` |
| `network`         | `none` / `listed-hosts` / `full`                                                                                                                                                                                                                                                                                                 | `none`      |
| `credentialPaths` | `string[]` — credential paths **RO**-bound into the jail (`~` expands to home). For a CLI that is **system-installed** (binary under `/usr`, so reachable at `filesystem: workspace`) **and** only **reads** its creds. A CLI that writes its cred/state dir (claude, codex) needs `filesystem: home` or `listed-paths` instead. | `[]`        |
| `uid`             | `dedicated` / `daemon`                                                                                                                                                                                                                                                                                                           | `dedicated` |

* **`~/.comis` carve-out (non-dialable):** the daemon data dir (master key, secret store) is masked with a tmpfs in **every** jail, even at `filesystem: full`. A driven child can never read Comis's secrets.
* **Fail-closed:** if the declared scope cannot be materialized (no bwrap), `session_create` is rejected -- never degraded to an unsandboxed child. The one operator override is `unsafeDisableSandbox` (below).
* **`unsafeDisableSandbox` (default `false`, DANGEROUS):** the operator opt-out of the jail — the peer of `browser.noSandbox`, for constrained hosts that genuinely cannot run bwrap (a container without unprivileged user-namespaces, a locked-down CI box). When `true`, `session_create` runs the driven CLI **directly** instead of failing closed, with **no filesystem / network / uid confinement** (the `scope` dimensions are unenforceable without the jail). Two protections are still enforced and never optional: the **env-scrub still strips daemon secrets** (gateway token / master key) from the child env, and a **durable `backend: "tmux"` drive is force-downgraded to the non-durable PTY backend** (a tmux server would inherit env the jail's per-session `--unsetenv` normally strips). It is surfaced at boot in `comis fleet` config-posture (`terminalUnsafeDisableSandbox`), and it is **operator-file-only**: no runtime RPC can set it — neither `config.patch` (the whole `agents.*` prefix is immutable there) **nor** `agents_manage` / `agents.update` (which otherwise writes agent config) — so an admin-trust agent can never self-enable it; only editing the config file and restarting the daemon can. The same file-only rule covers `skills.execSandbox` (the exec OS-sandbox switch) and `skills.terminal.allow` (the command allowlist). Leave it `false` on any host where bwrap is available.
* **The binary must be in scope:** the jail can only exec a `match.path` that its `filesystem` scope binds. A binary under `/usr` rides the system RO base; a **home-installed** binary (e.g. `~/.local/bin/claude`, the standard install) is reachable only at `filesystem: home` (or `listed-paths` including its dir) — `filesystem: workspace` cannot exec it.
* **Env-scrub:** interpreter-control vars (`NODE_OPTIONS`, `BASH_ENV`, `PYTHONSTARTUP`, function-export) and nested-CLI markers (`CLAUDE_CODE_*`, `CLAUDECODE`) are stripped.
* **Untrusted output:** `terminal_session_read` redacts secrets and wraps the screen as untrusted external content -- a driven AI CLI can render attacker text, so the screen is treated as a prompt-injection vector, not trusted input.
* **Audit:** every `send_text`/`send_key` is audited (`sessionId` + redacted payload); a run is reconstructable from logs + events.

### Workspace and persistence

A driven session runs in the **agent's own workspace**, not a throwaway temp dir, so its work **persists** across the session (and across worker restarts):

* The session working directory is `<agent-workspace>/projects` — `~/.comis/workspace/projects` for the default agent, `~/.comis/workspace-<agentId>/projects` for a named agent — with each named project under `projects/<name>`. A full, multi-hour run (e.g. driving Claude Code to build a project) leaves its output there after the session ends.
* Because it lives **inside the agent's workspace**, the agent's other tools (`read`/`write`/`apply_patch`/`exec`, which operate on the same workspace) can see and continue the driven session's work.
* The `~/.comis` carve-out still masks everything else under the data dir — the secret store, config, and the agent's *other* workspace files (identity, skills, memory). Only the `terminal` subtree is re-bound read-write inside the jail, so the driven child is confined to it (least-privilege) and can never reach Comis's secrets.
* `filesystem: workspace` (the default) binds exactly this directory. The directory is created on first use and is world-writable so a jailed child running as a net-new uid (`uid: dedicated`) can write it; at `uid: daemon` the child owns it directly.

### Network modes

* **`none`** -- deny-all egress. Best for shells/tools that don't need the network.
* **`full`** -- unrestricted egress. The bwrap jail + the `~/.comis` carve-out + env-scrub are still the boundary. **This is the mode for driving a full-screen AI CLI** (e.g. Claude Code), whose HTTP client reaches the model API directly.
* **`listed-hosts`** -- a no-secret host-allowlist CONNECT proxy bounds the child to named `hosts`. It works for clients that honor `HTTPS_PROXY`. Driving a CLI whose HTTP client ignores `HTTPS_PROXY` through `listed-hosts` is still being hardened (a transparent-egress path) -- use `full` for those today.

## Long runs and recovery

* **Session lifetime is decoupled from a turn.** `session_create` returns immediately; the PTY lives in the worker across turns. Aborting the spawning turn does **not** kill the session.
* **The agent is woken by attention events**, never polling: the worker classifies each session and emits a `terminal:*` event on a state transition; a short woken turn does `status -> read -> decide -> send -> ends`. The `terminal:input_needed` and `terminal:stuck` wake events carry the classification `confidence` and `reason` alongside the state (consumed by the autonomous-drive policy and surfaced in a `comis explain` of a drive) -- both are redaction-safe machine tags, never raw screen bytes.
* **Worker crash isolation:** the worker is a separate supervised process -- a PTY/emulator crash restarts the worker, not the daemon. For long, multi-hour runs use `backend: "tmux"` so the session survives a worker restart and a human can `tmux attach` to take over.
* **Surviving a *daemon* restart** (not just a worker crash) is the job of a **durable drive** (`drive.durable: true`): the driven CLI runs in a detached tmux server that outlives the daemon, and on restart the drive **re-attaches** to the live session and **resumes from its journal** instead of being lost. See [Durability & resume](#durability--resume) below.

### Autonomous drive (`drive` config)

A genuinely long drive (driving Claude Code to build a project for hours) shouldn't pour its hundreds of event-woken turns into the user's primary conversation. The `drive` block governs **where the woken turns run** and **what each one reads** -- both **optional**, both defaulting to today's behavior.

* **`drive.mode`** -- `auto` (default) / `attached` / `detached`. Controls **auto-promotion**: a drive starts **attached** (inline and snappy), and on the first `terminal_session_wait` that returns *not-complete-but-still-producing* (the CLI is honestly still working, not done) it **auto-promotes** to a detached, journaled drive-owner running in its own isolated session scope. From then on its woken turns no longer touch the primary conversation; only the drive's terminal outcome bridges back. Promotion emits exactly one "drive started (backgrounded)" notification.

  * `auto` -- promote on that honest still-producing signal (the recommended default).
  * `attached` -- **never** promote; every woken turn stays inline, **byte-identical to today**.
  * `detached` -- promote at the first wait **once the drive has been given a task** (a delivered `send_text`). A `wait` issued *before* any task has been sent keeps the drive inline: an un-tasked drive has no work to background, and backgrounding it would strand an idle terminal + persist a wake-state that resurrects on the next daemon restart. (The recommended order is task-then-wait, so in normal use this still promotes at the first wait.)
  * A sub-threshold one-shot (`git status`, `ls`, a quick build that finishes before the first long wait) stays inline and allocates **nothing** -- no detached scope, no journal, no notification. There is no behavior change for a drive that was always short.
  * **A finished, idle backgrounded drive is reaped at `worker.idleTtlMs`.** When a backgrounded drive completes its work and settles at its prompt (`awaiting-input`), an **unattended** origin (a webhook/cron turn — one with no human to hand back to) has its idle drive evicted once it has been idle for `idleTtlMs`, so a finished unattended drive does not linger. An **interactive** origin's drive is left alive (a human owns its lifecycle and may reply later; the completion is delivered as a notification). Either way, sending the drive more work (e.g. a follow-up webhook to the same session) before the cap re-attaches the live drive and resets its idle clock. Set `idleTtlMs: 0` to disable idle eviction entirely.
    * **Only a TRULY-idle drive is reaped, never one still autonomously producing.** A coding CLI such as Claude Code parks its cursor at its persistent `❯` composer *while it is still working* — subagents running, an unattended build step in flight, the status timer/token counter advancing — which the classifier reports as `awaiting-input`. The idle-clock freeze that lets the reaper evict a settled drive is therefore gated on the on-screen render being **unchanged across liveness probes**: a **changing** screen (real progress) keeps the drive's clock fresh so a long **autonomous** unattended drive (e.g. a multi-stage build that runs well past `idleTtlMs` with no operator keystrokes) is never evicted mid-work; only a **static** screen (a genuinely finished/idle drive) accrues idleness and is reaped. The change-detection uses a content-free digest of the render (never the screen bytes).

  <Note>
    This is **distinct** from the wall-clock auto-backgrounding of a long *tool call* (the "a long wait auto-backgrounds so the turn never blocks" behavior above). That mechanism keeps the *agent's own conversation turn* from blocking on a slow `wait`, keyed off a wall clock. `drive.mode` instead controls **where the subsequent event-woken turns execute**, keyed off the honest still-producing settle signal -- not a timer. They are orthogonal: one unblocks the calling turn, the other isolates the long drive.
  </Note>

* **`drive.readMode`** -- `digest` (default) / `diff` / `full`. The shape of the screen read each woken turn performs.
  * `digest` -- a bounded digest of the **current rendered screen** (the viewport grid, never scrollback), so each of thousands of 40h wakes stays cheap.
  * `diff` -- only the rows that changed since the last wake.
  * `full` -- the whole screen, still bounded (opt-in, for diagnosis).
  * Every mode is capped at a byte limit; an over-cap read writes a `truncations` breadcrumb (a count of dropped bytes) rather than silently trimming.

Beyond `mode` and `readMode`, the `drive` block carries the endurance/durability keys covered next: `durable`, `heartbeatMs`, and `maxCostUsd`, plus the user-facing `notify` and `heartbeatNotifyMs` keys covered under [Notifications](#notifications). The block remains a **closed schema** -- any unknown key under `drive:` (a typo) is rejected at config load. See [Notifications](#notifications) for how a drive's outcome and progress reach **you**.

### Durability & resume

A multi-hour-to-40-hour drive has to outlive the daemon restarting under it. By **default** it now does: a durable drive runs the driven CLI in a detached tmux server that survives, and the daemon re-attaches on restart. (Set `drive.durable: false` to opt out to the non-durable `spawn` backend, which ties the CLI's lifetime to the worker so a daemon/worker exit kills it.)

* **`drive.durable`** -- `true` (**default**) / `false`. The default `true` makes every drive **durable**: the driven CLI is launched inside a **detached tmux server** (a process independent of the worker, implying `backend: "tmux"` at runtime), so a worker or daemon exit leaves the CLI + its PTY **running**. This is the default because the tmux backend is both **driveable** (the worker attaches a real pty to stream + send keystrokes) and **survive-a-restart** (the deployed systemd unit ships `KillMode=process` and the tmux socket lives under the data dir, so the restarted daemon re-attaches; see [systemd](/operations/systemd)). On a host with **no tmux** it degrades to a non-durable pty drive + a `WARN` (not a config error). On daemon restart the session registry **re-attaches** to the live tmux session -- matched by a stable per-drive name persisted in the journal -- instead of flipping it `lost`. Re-attach never starts a second copy of the CLI (no double-drive). On daemon restart the session registry **re-attaches** to the live tmux session -- matched by a stable per-drive name persisted in the journal -- instead of flipping it `lost`. Re-attach never starts a second copy of the CLI (no double-drive).
  * **Resume, don't restart.** A durable drive persists a compact, **content-free** rolling journal (the objective, the last classification, the prompts it has already answered, and the steps it has tried -- never screen bytes or secrets) beside the agent's workspace. On re-attach the drive **resumes from that journal**: it picks up where it left off and does **not** re-run from the top or re-answer a prompt it already answered. The journal files live under `<dataDir>/terminal-drive/<agentId>/journals/` (see [the data-directory reference](/operations/data-directory)).
  * **A genuinely-gone session is reported `failed`, never silently restarted.** If the tmux session is truly gone on restart (not merely detached), the drive surfaces as `failed` with its **journal preserved** for inspection -- it is never quietly started over.

* **`spawn` is non-durable (and that's fine for short drives).** The default backend dies with the daemon. A short or one-shot drive needs nothing more. A **non-durable** drive that is interrupted by a restart is honestly reported `failed` (with its journal preserved); it does not silently resume.

* **Runtime fallback when tmux is unavailable.** `drive.durable: true` is **accepted at config load even on a host without tmux** -- tmux availability is a runtime property, not a config error. If tmux is missing (or a re-attach fails) at runtime, the drive **degrades to a non-durable drive and logs a `WARN`**; a subsequent restart then reports `failed` with the journal preserved, rather than a silent double-drive. It is never rejected at config-validation time, and it never silently widens scope.

<Note>
  The real-fork tmux **survive-a-daemon-restart** path (a detached tmux server outliving a forced daemon restart, then the live re-attach inside the bwrap jail with a real CLI) is validated on the VPS operator harness, not in the macOS-dev CI run. The deterministic re-attach-by-name match, the journal round-trip, the busy-vs-hung verdict, and the spend-ceiling check are unit-locked; the real-fork survive-restart is corroboration.
</Note>

### Endurance & liveness

The per-session caps and the reaper are **endurance-aware** and dialable to a 40h+ horizon. The governing rule: a session that is **alive and either progressing or legitimately busy** (a long compile/test that just isn't producing fresh output) is **never** evicted, declared `stuck`, or reported `failed` for its *duration* or its *quietness* alone.

* **Dialable caps -- and a cap-eviction names the cap.** `limits.wallClockMs` and `limits.maxInteractions` (under each `allow` entry) are operator-dialable; both are **uncapped by default** (`undefined` ⇒ no cap), so today's behavior is unchanged and a long drive is not capped unless you set one. If you *do* set a cap and a drive reaches it, the eviction **names the cap that fired** in the `failed` reason (a deliberate operator bound, not a mystery) -- and the idle reaper **excludes a busy session**, so quietness alone never evicts it.

* **`drive.maxCostUsd`** -- a number / `null` (default `null` = uncapped). An optional **per-drive spend ceiling** in USD over the whole run. On breach the drive **escalates and stops** -- never a silent overspend. It bounds cost only (it grants no privilege, path, or credential). Leave it `null` to run uncapped.

* **`drive.heartbeatMs`** -- the **internal liveness backstop** interval in milliseconds (default `90000`, i.e. 90s). This is a **safety net under** the event-driven wake, not a poll: on a tick where **no wake intervened**, it performs **one** liveness check (is the worker/tmux alive? is the child genuinely idle vs. CPU-busy or trickling output?) and synthesizes `stuck` **only when the child is genuinely hung**. A legitimately-busy multi-hour compile is **busy, not `stuck`**. It reads **no screen** (it is not a per-tick screen read), it fires **only in the absence of a wake**, and it resolves to a single check.

  <Note>
    `drive.heartbeatMs` is the **internal liveness tick** -- not a message to the user. The user-facing "still working" progress *notification* is a **separate knob**, `drive.heartbeatNotifyMs` (see [Notifications](#notifications)) -- distinct from this internal backstop.
  </Note>

These three keys are additive and default to today's behavior, so a config with no `drive` block (or with only `mode`/`readMode`) is unaffected:

```yaml theme={}
agents:
  default:
    skills:
      terminal:
        allow:
          - id: claude
            # ... match / scope / consent as above ...
            # Endurance-dialable per-session caps (omit ⇒ uncapped):
            limits: { wallClockMs: 144000000, maxInteractions: 20000 }   # ~40h horizon
        drive:
          mode: auto
          readMode: digest
          durable: true            # detached tmux: survive a daemon restart, re-attach + resume
          heartbeatMs: 90000       # internal liveness backstop (90s) — not a user message, not a poll
          maxCostUsd: 25.0         # per-drive spend ceiling (null ⇒ uncapped)
          notify: terminal         # which outcomes reach YOU: terminal (default) | all (reserved; today == terminal) | none
          heartbeatNotifyMs: 3600000  # user "still working" heartbeat for a long drive (1h; 0 ⇒ terminal-only)
```

### Notifications

A long drive's hundreds of event-woken turns stay **out** of your conversation (they run in the promoted drive-owner's isolated session). What actually reaches **you** is governed by `drive.notify` and `drive.heartbeatNotifyMs` -- both **optional**, both defaulting to a conservative, spam-free posture. Every notification is **content-free** (see below), and an auth/approval/destructive **escalation always reaches you** regardless of the policy.

**Three terminal outcomes.** A drive surfaces back to you on exactly one of three outcomes -- never a synthesized one (a low-confidence or still-working frame stays silent, it is never reported `done`):

* **`done`** -- a clean exit: the driven CLI exited at high confidence, or an explicit `terminal_session_wait` `forText`/`forExit` you set matched. Honest completion only -- never fabricated.

* **`needs-you`** -- an **escalation**: the auto-answer policy hit something it must not answer itself (destructive, an approval gate, an auth/login prompt) or the loop guard tripped. The drive **parks awaiting you** and notifies. This is the one outcome that **always** fires (see `notify: none` below).

* **`failed`** -- a **genuine death**: an unrecoverable durable session (the tmux session is truly gone on restart, not merely detached -- its journal preserved for inspection), or a **named** cap-eviction (a `limits.wallClockMs`/`maxInteractions` you set was reached, and the reason names the cap that fired). A healthy long or quiet drive is **never** `failed` -- duration or silence alone never produces this outcome.

* **`drive.notify`** -- `terminal` (default) / `all` / `none`. Which outcomes reach you.
  * `terminal` -- you are notified **only on a terminal outcome** (`done` / `needs-you` / `failed`). The uninteresting middle -- a safe prompt the policy answered itself, a still-working frame -- stays silent. The recommended default.
  * `all` -- **reserved for a future per-wake debug stream; currently behaves exactly like `terminal`.** The per-wake notification (a line on every answered/waited turn) is **not yet implemented**, so today `all` fires the same terminal outcomes as `terminal` and nothing more. Set it if you want the future debug behavior the moment it ships; until then it carries no extra noise.
  * `none` -- the non-escalation outcomes (`done`, `failed`) **and** the heartbeat are suppressed. **An escalation (`needs-you`) still fires.** `notify: none` is **not** a security off-switch: it never weakens escalate-always (an auth/approval/destructive prompt always reaches you) or the loop guard. It silences only the routine outcomes.

* **`drive.heartbeatNotifyMs`** -- a coarse "still working" progress heartbeat to you for a **promoted (long)** drive, so a 40-hour build is not 40 hours of silence. Default `3600000` (1h); `0` = **terminal-only** (no heartbeat). It is a bounded one-liner from the drive's journal (`still working — elapsed Xh, last activity <digest>, N interactions, ~$Y`). A **short** (unpromoted) drive emits **none**. This is **distinct** from `drive.heartbeatMs` (the [internal liveness backstop](#endurance--liveness) -- not a user message); the two are independent knobs.

* **Every notification is content-free.** A notification or heartbeat carries the outcome/reason/`errorKind`, the duration, the interaction count, and a **redacted** final-screen excerpt -- **never** raw TUI bytes, keystrokes, or secrets. The heartbeat digest is drawn from the content-free drive journal, and a driven AI CLI's screen is treated as untrusted (a prompt-injection vector), so its text never rides a notification verbatim.

### Classifier reliability

The classifier distinguishes `awaiting-input` (a prompt is waiting for you) from `stuck` (no progress) **structurally**, not by a per-CLI pattern table. A settled, unchanged frame is read as `awaiting-input` when its structure is unmistakably an interactive dialog -- a bordered region, an enumerated option list (`1.` / `2.`), an arrow/`(y/n)` selector, or an empty input line beneath a non-empty prompt block. This closes the documented misread where a full-screen permission/menu dialog (the prompt rendered above, the cursor parked on a blank input line below) was mistaken for a hung session. A genuinely hung frame -- no box, no menu, no selector, no progress past the `stuckMs` threshold -- still classifies `stuck`. The actual answer is always gated downstream by the auto-answer policy (`safe-only` escalates anything destructive/approval/auth), so a structurally-detected dialog never bypasses an escalation.

**Locking the classifier against a CLI upgrade:** the `awaiting-input`-vs-`stuck` call is pinned by a **versioned fixture corpus** -- content-free synthetic frames for `claude`/`codex`/`aider` across each state (idle-working, awaiting-text-input, full-screen menu, permission dialog, completed, hung) replayed through the real emulator and asserted for both state and confidence. When a `claude`, `codex`, or `aider` release reshapes its TUI (moving where the cursor parks or changing the dialog chrome), the fix is to **add or refresh a fixture** in `packages/skills/src/tools/builtin/terminal-driver/fixtures/` and re-run the corpus suite -- a drift surfaces there as a failing test, **not** as a misread discovered mid-drive in production. See the [classifier corpus README](https://github.com/comisai/comis/blob/main/packages/skills/src/tools/builtin/terminal-driver/fixtures/README.md) for how to author a fixture (synthetic, via `record-fixture.mjs` -- never hand-edited bytes) and bump the pinned CLI versions.

## Platform profiles

The driver is **agnostic** -- it drives any TUI with the generic perception above. For the **first-class** CLIs (Claude Code, Codex), an optional **read-side platform profile** -- selected by the operator-declared `allowId`, never by the program's own output -- tunes how the driver *perceives* the session:

* **Render** -- it strips Claude Code's dim autocomplete "ghost-text" from the captured screen so a driving model never mistakes the suggestion for queued input.
* **Perception** -- it feeds the classifier the platform's working-line (Claude's spinner, Codex's `Working (Ns)`) and picker/menu signatures, so a busy frame reads `working` and a `/model`-style picker reads `awaiting-input` rather than `stuck`.
* **Dialogs** -- it declares the platform's known interactive dialogs and their **safe** answer. Under `autoAnswer: safe-only` a non-destructive dialog (Claude's "trust this folder" gate) is answered with the canned keystroke; a `destructive` dialog (Codex's run-command approval) **always escalates** to you. The operator safe-only policy and the escalate-always veto still gate every answer -- a profile only *proposes*.

A profile is **read-side only**: it never widens the sandbox, the env, or the launch (those stay operator-controlled). With no matching profile (any other `allowId`) the driver's perception/render/auto-answer is **byte-identical** to the agnostic default.

**Adding a platform is engine-free** -- a drop-in profile + bundled skill, no change to the agnostic render/classifier/dialog code:

1. Create `packages/skills/src/tools/builtin/terminal-driver/platforms/<id>/profile.ts` exporting a `TerminalPlatformProfile` -- `id`, the operator `allowIds` it claims, a `platformVersion`, and any of the optional read-side fields (`transformSnapshot` for render, `perception` pattern lists, `dialogs`).
2. Register it in `platforms/index.ts` (`ALL_PROFILES`). The load-time guards reject a duplicate `allowId` or a ReDoS-prone pattern.
3. Add the paired bundled skill `packages/daemon/bundled-skills/<id>/SKILL.md` with a frontmatter `version` **equal** to the profile's `platformVersion` (a build-time architecture test enforces the match).
4. Capture golden frames and assert the profile's transform/perception/dialogs against them (live with the profile, e.g. `platforms/<id>/profile.test.ts`).

No edit to `terminal-render.ts`, `terminal-classifier.ts`, `terminal-dialog-detector.ts`, or `terminal-auto-answer.ts` is required -- they consume whatever the selected profile provides, with the agnostic fallback when no profile matches.

## Related

<CardGroup cols={2}>
  <Card title="Coding CLIs: Claude Code & Codex" icon="robot" href="/agent-tools/coding-clis">Per-CLI config + how the skill/profile/sandbox fit together</Card>
  <Card title="Autonomous Builds (GSD)" icon="diagram-project" href="/agent-tools/gsd-builder">Hand the agent a design doc → it drives Claude Code through GSD to build it end-to-end</Card>
  <Card title="Tool Policy" icon="shield-halved" href="/skills/tool-policy">Control which tools each agent can use</Card>
  <Card title="Built-in Tools" icon="puzzle-piece" href="/skills/built-in-tools">The full built-in tool surface</Card>
</CardGroup>
