Skip to main content
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.
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.

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).

Prerequisites

Configuration

The terminal driver is configured per agent, under agents.<id>.skills.terminal.
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.
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.
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. 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’s scope:
  • ~/.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 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.modeauto (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).
    • attachednever 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).
    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.
  • drive.readModedigest (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. 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. (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.durabletrue (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). 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).
    • 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.
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, 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.
    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) — distinct from this internal backstop.
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:

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.notifyterminal (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.
    • allreserved 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 — 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 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 TerminalPlatformProfileid, 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.

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