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.
The nine tools
All nine aremcpExportPolicy: "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).
Prerequisites
Configuration
The terminal driver is configured per agent, underagents.<id>.skills.terminal.
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.
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
Security model
Every driven session runs in a bwrap jail materializing the entry’sscope:
~/.comiscarve-out (non-dialable): the daemon data dir (master key, secret store) is masked with a tmpfs in every jail, even atfilesystem: full. A driven child can never read Comis’s secrets.- Fail-closed: if the declared scope cannot be materialized (no bwrap),
session_createis rejected — never degraded to an unsandboxed child. The one operator override isunsafeDisableSandbox(below). unsafeDisableSandbox(defaultfalse, DANGEROUS): the operator opt-out of the jail — the peer ofbrowser.noSandbox, for constrained hosts that genuinely cannot run bwrap (a container without unprivileged user-namespaces, a locked-down CI box). Whentrue,session_createruns the driven CLI directly instead of failing closed, with no filesystem / network / uid confinement (thescopedimensions 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 durablebackend: "tmux"drive is force-downgraded to the non-durable PTY backend (a tmux server would inherit env the jail’s per-session--unsetenvnormally strips). It is surfaced at boot incomis fleetconfig-posture (terminalUnsafeDisableSandbox), and it is operator-file-only: no runtime RPC can set it — neitherconfig.patch(the wholeagents.*prefix is immutable there) noragents_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 coversskills.execSandbox(the exec OS-sandbox switch) andskills.terminal.allow(the command allowlist). Leave itfalseon any host where bwrap is available.- The binary must be in scope: the jail can only exec a
match.paththat itsfilesystemscope binds. A binary under/usrrides the system RO base; a home-installed binary (e.g.~/.local/bin/claude, the standard install) is reachable only atfilesystem: home(orlisted-pathsincluding its dir) —filesystem: workspacecannot 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_readredacts 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_keyis 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/projectsfor the default agent,~/.comis/workspace-<agentId>/projectsfor a named agent — with each named project underprojects/<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
~/.comiscarve-out still masks everything else under the data dir — the secret store, config, and the agent’s other workspace files (identity, skills, memory). Only theterminalsubtree 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; atuid: daemonthe 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~/.comiscarve-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 namedhosts. It works for clients that honorHTTPS_PROXY. Driving a CLI whose HTTP client ignoresHTTPS_PROXYthroughlisted-hostsis still being hardened (a transparent-egress path) — usefullfor those today.
Long runs and recovery
- Session lifetime is decoupled from a turn.
session_createreturns 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 doesstatus -> read -> decide -> send -> ends. Theterminal:input_neededandterminal:stuckwake events carry the classificationconfidenceandreasonalongside the state (consumed by the autonomous-drive policy and surfaced in acomis explainof 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 cantmux attachto 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 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 firstterminal_session_waitthat 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 deliveredsend_text). Awaitissued 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 foridleTtlMs, 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. SetidleTtlMs: 0to 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 asawaiting-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 pastidleTtlMswith 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).
- 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
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 slowwait, keyed off a wall clock.drive.modeinstead 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. -
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
truncationsbreadcrumb (a count of dropped bytes) rather than silently trimming.
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. The block remains a closed schema — any unknown key under drive: (a typo) is rejected at config load. See 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. (Setdrive.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 defaulttruemakes every drive durable: the driven CLI is launched inside a detached tmux server (a process independent of the worker, implyingbackend: "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 shipsKillMode=processand the tmux socket lives under the data dir, so the restarted daemon re-attaches; see systemd). On a host with no tmux it degrades to a non-durable pty drive + aWARN(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 itlost. 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 itlost. 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). - 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 asfailedwith its journal preserved for inspection — it is never quietly started over.
- 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
-
spawnis 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 reportedfailed(with its journal preserved); it does not silently resume. -
Runtime fallback when tmux is unavailable.
drive.durable: trueis 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 aWARN; a subsequent restart then reportsfailedwith the journal preserved, rather than a silent double-drive. It is never rejected at config-validation time, and it never silently widens scope.
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.
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, declaredstuck, or reported failed for its duration or its quietness alone.
-
Dialable caps — and a cap-eviction names the cap.
limits.wallClockMsandlimits.maxInteractions(under eachallowentry) 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 thefailedreason (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(defaultnull= 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 itnullto run uncapped. -
drive.heartbeatMs— the internal liveness backstop interval in milliseconds (default90000, 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 synthesizesstuckonly when the child is genuinely hung. A legitimately-busy multi-hour compile is busy, notstuck. 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.drive.heartbeatMsis 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) — distinct from this internal backstop.
drive block (or with only mode/readMode) is unaffected:
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 bydrive.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 explicitterminal_session_waitforText/forExityou 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 (seenotify: nonebelow). -
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 (alimits.wallClockMs/maxInteractionsyou set was reached, and the reason names the cap that fired). A healthy long or quiet drive is neverfailed— 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 liketerminal. The per-wake notification (a line on every answered/waited turn) is not yet implemented, so todayallfires the same terminal outcomes asterminaland 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: noneis 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. Default3600000(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 fromdrive.heartbeatMs(the internal liveness backstop — 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 distinguishesawaiting-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 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-declaredallowId, 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 readsworkingand a/model-style picker readsawaiting-inputrather thanstuck. - Dialogs — it declares the platform’s known interactive dialogs and their safe answer. Under
autoAnswer: safe-onlya non-destructive dialog (Claude’s “trust this folder” gate) is answered with the canned keystroke; adestructivedialog (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.
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:
- Create
packages/skills/src/tools/builtin/terminal-driver/platforms/<id>/profile.tsexporting aTerminalPlatformProfile—id, the operatorallowIdsit claims, aplatformVersion, and any of the optional read-side fields (transformSnapshotfor render,perceptionpattern lists,dialogs). - Register it in
platforms/index.ts(ALL_PROFILES). The load-time guards reject a duplicateallowIdor a ReDoS-prone pattern. - Add the paired bundled skill
packages/daemon/bundled-skills/<id>/SKILL.mdwith a frontmatterversionequal to the profile’splatformVersion(a build-time architecture test enforces the match). - Capture golden frames and assert the profile’s transform/perception/dialogs against them (live with the profile, e.g.
platforms/<id>/profile.test.ts).
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
Coding CLIs: Claude Code & Codex
Per-CLI config + how the skill/profile/sandbox fit together
Autonomous Builds (GSD)
Hand the agent a design doc → it drives Claude Code through GSD to build it end-to-end
Tool Policy
Control which tools each agent can use
Built-in Tools
The full built-in tool surface
