TermAl

TermAl — Architecture

A WhatsApp-style interface for controlling AI coding agents running on your machine.


System Overview

Browser UI
  -> /api + /api/events
  -> local TermAl server
       -> AppState / StateInner / persistence
       -> shared Codex app-server
       -> per-session Claude runtime
       -> per-session ACP runtimes (Cursor / Gemini / OpenCode)
       -> RemoteRegistry (SSH tunnels + remote event bridges)
       -> in-process Telegram relay (optional)

Optional sidecar:
  TermAl MCP bridge -> parent-scoped delegation + root-peer tools -> same local TermAl server

Frontend: React 18 + TypeScript, served on :4173 in dev with a Vite proxy to the backend. Backend: Rust + axum + tokio, bound to 127.0.0.1:8787 by default, overridable with TERMAL_PORT. Persistence: ~/.termal/termal.sqlite stores sessions, projects, preferences, remote config, workspace layouts, orchestrator instances, and the visual Response Board. ~/.termal/coordination.sqlite stores durable mailboxes and agent coordination boards in a separate SQLite writer domain. ~/.termal/orchestrators.json stores reusable orchestrator templates. Real-time: Server-Sent Events with a monotonic revision counter for ordering.

Current status: The current implementation includes server-backed workspace layouts, project-scoped SSH remotes, orchestrator templates and runtime instances, session-scoped model controls, workspace terminal tabs, file-change awareness, and the Telegram relay.

Agent mailbox status: Root sessions coordinate through durable neutral mailboxes. Mailbox bodies commit to SQLite before a metadata-only receiver wake-up, are fetched explicitly, and use forward-only compare-and-swap acknowledgements. A mailbox is a separate domain object, not an agent session or runtime. Delegated /review-code adds one child-only, targetless mailbox operation: termal_submit_review_result. TermAl validates its versioned payload, derives the linked parent/delegation identity, stores it without an early wake, and promotes it when the child turn becomes terminal. The accepted result remains authoritative across later child runtime failure or disappearance; those conditions are separate transport diagnostics. Human Markdown remains full output; legacy parsing is not used for these new structured reviews. The submission contract is injected by TermAl for every reviewer-mode delegation, after repository-owned task text, so repositories do not need to modify their own review commands. Read-only policy remains a workspace boundary; this single child-owned result submission is an authenticated control-plane capability. Reviewer mode is currently limited to Claude and Codex because their native permission protocols expose an identity that TermAl can authenticate. ACP v1 permission requests do not expose a portable authenticated MCP tool origin, so Cursor, Gemini, and OpenCode reviewer requests fail before child creation rather than relying on presentation-name heuristics; those agents remain available for explorer delegations where their write policy is supported. Malformed durable review artifacts are quarantined per submission attempt and reported as reviewResultRecoveryError; parent lifecycle endpoints continue through the normal fail-closed path rather than propagating recovery parse errors.

Remote model: The browser connects to a single local TermAl server. That server stores preferences, manages remote connections, and routes project work to local or remote TermAl servers over SSH-managed tunnels.

Delegation status: Phase 1 supports local read-only child delegation sessions and local isolatedWorktree child delegation sessions. Worker mode, sharedWorktree, and remote-backed delegation requests are well-formed but unimplemented and return 501 Not Implemented so clients can treat them as feature-gated rather than malformed input. While a read-only delegation is running, local TermAl-mediated writes are blocked for the delegated project/workdir scope from any session, not just from the child session, so parent/sibling sessionId routing cannot bypass the read-only policy. isolatedWorktree requests may omit worktreePath; the backend generates a TermAl-owned path before persisting the delegation record. OpenCode is an explicit exception to the readOnly agent matrix: the backend rejects OpenCode + readOnly before child/runtime creation because OpenCode cannot currently enforce the shared-worktree read-only contract. OpenCode delegations may use isolatedWorktree.

Delegation MCP bridge: Agent-facing delegation access is a TermAl-owned local MCP bridge spawned per parent agent session with delegation-mcp --parent-session-id <id> --base-url <origin>. The bridge is configured with an implicit parentSessionId and wraps the existing delegation HTTP/API routes. Delegation tools stay parent-scoped (a caller only sees the delegation ids created under its own parent), but the bridge also exposes the peer-messaging tools termal_send_to_session and termal_list_sessions, which deliberately reach root sessions across projects. That crossing is bounded on both sides: the peer tools reach only root sessions (delegation children stay unreachable as peers), and they are offered only to root callers — a bridge serving a delegation child has the peer tools removed from its tools/list and rejected on invocation (failing closed if the caller cannot be resolved), so a child cannot reach root sessions through the bridge. This is a tool-layer guardrail, not process isolation: the loopback HTTP API is unauthenticated under the single-user, local-only trust model (GET /api/state, the mailbox endpoints), so a child able to issue raw HTTP could bypass the bridge — caller-scoped REST auth is deferred with capability tokens. Claude receives it through --mcp-config, ACP/Cursor/Gemini/OpenCode receive it through mcpServers on session/new, session/resume, and session/load, and Codex receives it through config.mcp_servers on thread/start and thread/resume. Claude and Codex native MCP descriptors use an environment object map, while ACP McpServer.env is an array of {name, value} entries; TermAl performs that typed conversion only at the shared ACP boundary. External protocol-shape changes require a live smoke against the real agent binary in addition to TermAl-owned fixtures, because fixtures can encode the same incorrect assumption on both sides.


Backend

Entry Points

The binary has three modes:

  1. Server mode (default) - starts an axum HTTP server on 127.0.0.1:8787 by default, serves the API, and manages long-lived agent processes. TERMAL_PORT can override the port.
  2. REPL mode (repl, cli, or a REPL-capable agent shortcut such as codex) - interactive terminal loop. Reads prompts from stdin and runs one turn at a time via run_turn_blocking(). Claude is intentionally excluded because Claude Code runs through the long-lived server-side stdio runtime.
  3. Delegation MCP mode (delegation-mcp --parent-session-id <id> [--base-url <origin>]) - stdio JSON-RPC bridge exposing parent-scoped delegation tools plus the root-only peer-messaging tools to agent runtimes.

The Telegram relay is not a CLI mode. It is configured from Settings -> Telegram and supervised inside server mode.

Core State

AppState {
    default_workdir: String,
    persistence_path: Arc<PathBuf>,            // ~/.termal/termal.sqlite
    orchestrator_templates_path: Arc<PathBuf>, // ~/.termal/orchestrators.json
    state_events: broadcast::Sender<String>,
    delta_events: broadcast::Sender<String>,
    file_events: broadcast::Sender<String>,    // workspace file-watcher fan-out
    shared_codex_runtime: Arc<Mutex<Option<SharedCodexRuntime>>>,
    remote_registry: Arc<RemoteRegistry>,
    persist_tx: mpsc::Sender<PersistRequest>,  // wake the background persist thread
    inner: Arc<Mutex<StateInner>>,
}

StateInner {
    codex: CodexState,
    preferences: AppPreferences,
    revision: u64,
    next_project_number: usize, // retained for persisted legacy-id validation
    next_session_number: usize,
    next_message_number: u64,
    projects: Vec<Project>,
    pending_coordination_scope_deletions: BTreeSet<String>,
    ignored_discovered_codex_thread_ids: BTreeSet<String>,
    sessions: Vec<SessionRecord>,
    orchestrator_instances: Vec<OrchestratorInstance>,
    workspace_layouts: BTreeMap<String, WorkspaceLayoutDocument>,
}

AppState is the live coordination shell: SSE broadcasters, the shared Codex app-server handle, and the SSH remote registry all live there. StateInner is the mutex-protected durable model that gets serialized to disk.

pending_coordination_scope_deletions is the crash-consistency outbox for project removal across the two SQLite writer domains. The primary project deletion and outbox item become durable in termal.sqlite first; only then does a dedicated cleanup worker fence and cascade that project’s board scope in coordination.sqlite. A failed secondary cleanup remains in the durable outbox and is retried after startup and on the next boot. Because the project is already absent, no HTTP caller can authorize new work for that scope while cleanup is pending. Large cascades and multiple pending scopes never execute on the primary persist worker or the boot thread, so they cannot delay termal.sqlite durability or opening the listener.

AppPreferences is also the source of truth for new-session defaults. In particular, the Codex model, sandbox mode, approval policy, and reasoning effort are persisted through POST /api/settings, returned in state snapshots, and copied into newly created Codex sessions unless the create request supplies an explicit override. The catalog-gated Fast service tier is separate, session-scoped authority persisted through POST /api/sessions/{id}/settings; new Codex sessions start on the Standard tier.

TERMAL_CODEX_SANDBOX, TERMAL_CODEX_APPROVAL, and TERMAL_CODEX_REASONING_EFFORT seed those preferences only when no persisted value exists. After a preference has been saved, the persisted setting remains authoritative across restarts; changing an environment seed does not overwrite an existing user choice.

The shared Codex app-server (one long-lived process hosting every local Codex session in this backend) has its own identity model and failure modes. See features/shared-codex-app-server.md for process-vs-attachment identity, thread-setup parking, and orphan-thread discovery.

SessionRecord wraps the serializable Session with runtime-only fields:

SessionRecord {
    session: Session,                          // id, agent, model, messages, preview, status
    runtime: SessionRuntime,                   // None | Claude | Codex | Acp
    pending_claude_approvals: HashMap,
    pending_codex_approvals: HashMap,
    pending_codex_user_inputs: HashMap,
    pending_codex_mcp_elicitations: HashMap,
    pending_codex_app_requests: HashMap,
    pending_acp_approvals: HashMap,
    queued_prompts: VecDeque<QueuedPromptRecord>,
    remote_id: Option<String>,                 // remote owning the proxy session
    remote_session_id: Option<String>,         // remote session id when proxied
    external_session_id: Option<String>,       // Claude/Codex/ACP resume identifier
    runtime_reset_required: bool,
    hidden: bool,
}

State Mutation Pattern

All client-visible state changes go through commit_locked():

commit_locked(&mut inner)
  → inner.revision += 1
  → persist_tx.send(PersistRequest::Delta) // wake the background persist thread
  → publish_state_locked(inner)            // queue metadata-first StateResponse for SSE
  → Ok(revision)

The background termal-persist thread owns an Arc<Mutex<StateInner>> and a SqlitePersistConnectionCache. On each Delta wake it briefly locks inner, collects the diff via StateInner::collect_persist_delta(watermark) (only sessions whose mutation_stamp advanced past the thread’s watermark, plus drained removed_session_ids), releases the lock, and writes with targeted INSERT OR UPDATE per changed session and DELETE WHERE id = ? per removed id. Unchanged session rows stay untouched — a mutation on one session no longer rewrites every other session row every commit. See src/state.rs for PersistRequest / PersistDelta and src/persist.rs for persist_delta_via_cache.

Mutation stamping is load-bearing: every session mutation must land through StateInner::session_mut / session_mut_by_index / stamp_session_at_index / push_session / remove_session_at / retain_sessions so the record’s mutation_stamp gets bumped. A raw &mut inner.sessions[idx] would skip the stamp and the delta persist would drop the update. See src/state_inner.rs for the helpers.

Streaming paths (append_text_delta, update_command_message) bump revision and publish a DeltaEvent instead of a full snapshot, avoiding the cost of serializing all sessions on every token. They use commit_delta_locked() which bumps revision + wakes the persist thread but skips the full-state broadcast; callers emit the matching DeltaEvent explicitly via publish_delta() under the same lock. publish_state_locked() and publish_delta() both feed one bounded ordered broadcaster mailbox: consecutive snapshots can coalesce, but a retained snapshot queued before a retained delta is sent before that delta so the frontend does not see an artificial revision gap. If snapshot serialization falls behind and the mailbox reaches capacity, producers drop the oldest pending work instead of blocking while holding StateInner; dropped deltas surface as ordinary revision gaps and the frontend repairs from /api/state.

Internal bookkeeping that the frontend doesn’t need (e.g. recording Codex sandbox mode after runtime config) uses persist_internal_locked() directly without bumping revision.

HTTP API

All routes are under /api. The backend serves JSON, and the frontend proxies requests through Vite in development.

Method Path Purpose
GET /api/health Health check + capability probe
GET /api/file?path=... Read file content
PUT /api/file Write file content
GET /api/fs?path=... List directory entries
GET /api/git/status?path=... Git status and branch info
POST /api/git/diff Build a structured git diff preview; registered submodules return a read-only nested patch
POST /api/git/file Apply a file-level git action
POST /api/git/commit Create a git commit from staged changes
POST /api/git/push Push the current repo
POST /api/git/sync Pull, rebase, or otherwise sync the current repo
POST /api/terminal/run Run a shell command in a project- or session-scoped working directory. Request body enforces command ≤ 20,000 chars and workdir ≤ 4,096 chars (no interior NUL bytes), and captured output is capped. There is no process timeout. Returns 429 ({ "error": ... }) when the concurrency cap for that destination is exhausted; local and remote commands have independent budgets of 4 in-flight requests each. When the destination is remote, a 429 emitted by the remote host is re-emitted locally with the remote’s display name prefixed onto the error message (e.g. remote alice: too many local terminal commands are already running; limit is 4), so the caller can distinguish a local cap rejection from a remote-side propagation.
POST /api/terminal/run/stream Run the same terminal command as /api/terminal/run, but return an SSE stream. output events carry { "stream": "stdout" \| "stderr", "text": string }, complete carries the normal terminal response, and error carries { "error": string, "status": number } for failures after the stream has started. Validation, workdir/scope resolution, and local concurrency-cap failures are returned as normal HTTP errors before the stream starts; local cap failures use HTTP 429 with { "error": ... } and the same independent local/remote 4-in-flight budgets as the JSON route. Remote 429s discovered by the proxy are surfaced with status: 429 and the remote display-name prefix in the error message; after the local SSE response has started they travel as SSE error frames rather than changing the local HTTP status. There is no process timeout. Remote-scoped commands proxy this streamed route when the remote supports it and fall back to the JSON route only for 404/405 older-remotes responses; successful non-SSE stream responses are treated as remote protocol errors to avoid double-running commands.
GET /api/state Metadata-first state snapshot; sessions are summary shells with messagesLoaded: false and no transcript payload
GET /api/workspaces List saved workspace layout summaries
GET /api/workspaces/{id} Read a persisted workspace layout
PUT /api/workspaces/{id} Save a persisted workspace layout
DELETE /api/workspaces/{id} (200) -> WorkspaceLayoutsResponse Delete a persisted workspace layout and return the remaining layout summaries
POST /api/settings Update app-wide preferences and remote config
POST /api/remotes/{id}/register Register an existing checkout on an SSH remote for TermAl lifecycle actions. Verifies the remote checkout/tooling and writes ~/.termal/remote-install.json; returns capped stdout/stderr.
POST /api/remotes/{id}/upgrade Build and install TermAl on a registered SSH remote by running git pull --ff-only and cargo build --release --bin termal, then copying the release binary to ~/.termal/bin/termal; returns capped stdout/stderr.
GET /api/orchestrators/templates List orchestrator templates
POST /api/orchestrators/templates Create orchestrator template
GET /api/orchestrators/templates/{id} Read orchestrator template
PUT /api/orchestrators/templates/{id} Update orchestrator template
DELETE /api/orchestrators/templates/{id} (200) -> OrchestratorTemplatesResponse Delete orchestrator template and return the remaining template list so the client can replace local state after deletion
GET /api/orchestrators List orchestrator instances
POST /api/orchestrators Create orchestrator instance
GET /api/orchestrators/{id} Read orchestrator instance
POST /api/orchestrators/{id}/pause Pause an orchestrator instance -> StateResponse
POST /api/orchestrators/{id}/resume Resume an orchestrator instance -> StateResponse
POST /api/orchestrators/{id}/stop Stop an orchestrator instance -> StateResponse
GET /api/instructions/search Search instruction files for a session/workdir
GET /api/events SSE stream (state + delta events)
GET /api/reviews/{change_set_id} Read a persisted diff review document
PUT /api/reviews/{change_set_id} Save a persisted diff review document
GET /api/reviews/{change_set_id}/summary Read review-thread summary counts
POST /api/projects Create project
DELETE /api/projects/{id} Remove the local project reference and return StateResponse. Existing sessions and orchestrator instances are detached from the project and remain visible outside project scope. Remote-backed projects are removed only from local state; this route does not delete project data on the remote backend.
POST /api/projects/pick Pick a local project root
GET /api/telegram/status Read Telegram relay configuration/status -> TelegramStatusResponse with configured/enabled/running state, lifecycle, linked chat, masked token, subscribed projects, and default targets. Current implementation is singleton; the target multi-bot spec changes this to an aggregate profile status while keeping compatibility for the default bot.
POST /api/telegram/config Update the singleton Telegram relay token in the OS credential store, enabled flag, subscribed projects, and default project/session. Returns sanitized TelegramStatusResponse; validation failures use the standard { "error": ... } envelope. Multi-bot work should supersede this with profile-scoped create/update/delete routes.
POST /api/telegram/test Validate a supplied or saved Telegram bot token through getMe -> TelegramTestResponse. Local test throttling returns 429 with Retry-After; Telegram auth/validation failures return 422, and upstream/network failures return 502. Multi-bot work should add /api/telegram/bots/{bot_id}/test.
POST /api/sessions Create session
GET /api/sessions/{id} Fetch one bounded recent suffix -> SessionResponse { revision, serverInstanceId, session }. The default is 20 messages; ?tail=N accepts 1..=64. There is no unbounded transcript response or summary fallback. Remote-proxy sessions forward the same bounded tail request to the owner.
GET /api/sessions/{id}/history Fetch one ascending transcript page by exclusive before/after cursor, true start, centered global around position, or latest tail -> SessionHistoryResponse. limit defaults to and is capped at 64.
GET /api/sessions/{id}/overview Fetch one whole-conversation position map -> SessionOverviewResponse. buckets defaults to 200 and accepts 1..=512; repeated bucket JSON is gzip-compressed on the wire.
POST /api/sessions/{id}/settings Update session config
POST /api/sessions/{id}/model-options/refresh Refresh live model list/options
GET /api/sessions/{id}/codex/mcp-servers List sanitized Codex MCP server/tool status
POST /api/sessions/{id}/codex/thread/fork Fork the live Codex thread into a new session
POST /api/sessions/{id}/codex/thread/archive Archive the live Codex thread
POST /api/sessions/{id}/codex/thread/unarchive Restore an archived Codex thread
POST /api/sessions/{id}/codex/thread/compact Request Codex thread compaction
POST /api/sessions/{id}/codex/thread/rollback Roll back the live Codex thread
GET /api/sessions/{id}/agent-commands Read local agent-command shortcuts
GET /api/sessions/{id}/markers List conversation markers anchored to messages in the session transcript. Local sessions read from the local record; remote-proxy sessions are read-only unless routed through the remote marker mutation endpoints below.
POST /api/sessions/{id}/markers Create a conversation marker and publish ConversationMarkerCreated. Returns 201 with ConversationMarkerResponse; malformed JSON uses the standard ApiError envelope.
PATCH /api/sessions/{id}/markers/{marker_id} Patch marker kind/name/body/color/message anchors. Nullable body and endMessageId clear those fields. Publishes ConversationMarkerUpdated.
DELETE /api/sessions/{id}/markers/{marker_id} Delete one conversation marker and publish ConversationMarkerDeleted.
POST /api/sessions/{id}/messages Send an ordinary prompt to a session; queues on the target’s pending-prompt FIFO if it is mid-turn. Peer coordination uses the durable mailbox routes below rather than injecting bodies through this route.
GET /api/sessions/{id}/mailboxes List neutral mailbox summaries for a participant, including peer display names, latest sequence, and unread count. Unknown session ids return 404.
POST /api/sessions/{id}/mailboxes/send Atomically append a routine message from {id} to a target root session. Requires a sender-scoped idempotency key; returns the durable receipt before/beside a best-effort metadata wake-up. Writer-admission exhaustion returns a typed 503 before this operation commits; bridge transport loss instead reports an unknown outcome and requires retrying the same key.
POST /api/sessions/{id}/delegation-review-result Child-only submission for the current structured /review-code result. The backend derives the linked parent, topic, state stamp, and idempotency key; validates the complete schema; appends without waking the parent; and rejects root, explorer, unrelated, stale, or malformed callers.
POST /api/sessions/{id}/mailboxes/{mailbox_id}/read Read a FIFO mailbox range without advancing the participant cursor. Each message exposes mutable notificationState; the send receipt’s immutable point-in-time outcome remains notificationDisposition. This intentionally uses POST because the bounded range request is a JSON body; it remains read-only.
POST /api/sessions/{id}/mailboxes/{mailbox_id}/acknowledge Advance {id}’s processed cursor with a forward-only compare-and-swap. Replaying an acknowledgement whose requested cursor is already satisfied succeeds idempotently; a stale expected cursor that requests additional progress returns a typed 409 whose detail survives the MCP bridge. The response summary is prepared inside the cursor transaction, leaving no fallible post-commit lookup.
GET /api/sessions/{id}/mailbox-messages/{message_id} Read one exact durable mailbox message after participant authorization, including its current mutable notificationState.
GET /api/sessions/{id}/board List one local project’s coordination-board entries through a local root session. Supports generation-aware pagination and an unchanged fast path.
GET /api/sessions/{id}/board/keys/{key} Read one active coordination-board head, including its CAS revision, updatedAtGeneration (when the key last changed), and current scopeGeneration. Missing and tombstoned keys return 404 with reconciliation detail when available.
POST /api/sessions/{id}/board/set Create, update, deliberately restore, or delete one coordination-board key with revision CAS and a sender-scoped idempotency key. A successful first create returns 201; duplicate replays and later mutations return 200; conflicts return 409.
GET /api/response-board Compatibility view of placed cards in the default Response Board tab.
GET/POST /api/response-board/tabs List durable board canvases and the global staged-card count, or create a custom board.
POST /api/response-board/tabs/reorder Persist a validated complete ordering of board partitions.
GET/PATCH/DELETE /api/response-board/tabs/{id} Read one canvas plus the shared staging inbox, rename it, or delete a custom canvas without placed cards.
POST /api/response-board/cards/stage Snapshot a durable transcript message with a tab/project destination hint. The default action stages it; placement: "placed" with finite x/y atomically creates or reuses and places it on the destination canvas. Returns 201 for a new card and 200 when reusing the source’s existing card; staging a placed card pulls that same card off its prior canvas rather than creating a copy. Returns 409 for a duplicate placed source in the destination or when the destination canvas/global staging capacity is exhausted.
POST /api/response-board/cards Legacy placed-card create in the default tab. The server owns snapshot content.
PATCH /api/response-board/cards/{id} Persist bounded geometry, explicit staged/placed state, or tab membership.
DELETE /api/response-board/cards/{id} Remove one visual response card.
POST /api/sessions/{id}/delegations Create a Phase 1 local child delegation session with readOnly or isolatedWorktree write policy. Returns 201 with DelegationResponse; unsupported worker/sharedWorktree/remote-backed variants return 501, active-limit conflicts return 409, handler-level prompt/scope validation returns 400, and JSON schema/deserialization failures return 422.
GET /api/sessions/{id}/delegations List compact summaries for delegations owned by this parent -> DelegationListResponse. This recovery endpoint returns exact delegation/child-session ids, title, agent, and fresh lifecycle status without prompts or transcripts; same-title delegations remain distinct. Unknown parent ids return 404. Backs termal_list_delegations.
POST /api/sessions/{id}/delegation-waits Create a parent-scoped backend resume wait for one or more delegations. Returns 201 with DelegationWaitResponse; terminal targets may consume the wait immediately and queue/resume the parent in the same response cycle.
POST /api/sessions/{id}/queued-prompts/{prompt_id}/cancel Cancel queued prompt
POST /api/sessions/{id}/stop Stop active turn
POST /api/sessions/{id}/kill Kill and remove session
POST /api/sessions/{id}/approvals/{message_id} Submit approval decision
POST /api/sessions/{id}/user-input/{message_id} Submit structured Codex user-input answers
POST /api/sessions/{id}/mcp-elicitation/{message_id} Submit an MCP elicitation response
POST /api/sessions/{id}/codex/requests/{message_id} Reply to a generic Codex app-server request
GET /api/sessions/{id}/delegations/{delegation_id} Read delegation metadata/status -> DelegationStatusResponse. This read also refreshes the target child delegation from its linked session, so it can persist terminal status/result data, publish delegation/card SSE deltas, and consume backend waits that watch that delegation. Unknown delegation ids, unknown parent ids, and wrong-parent requests return 404.
GET /api/sessions/{id}/delegations/{delegation_id}/result Read a completed delegation result packet -> DelegationResultResponse. This read also refreshes the target child delegation from its linked session before deciding whether a result is available, so it can persist terminal status/result data, publish delegation/card SSE deltas, and consume backend waits that watch that delegation. Unknown delegation ids, unknown parent ids, and wrong-parent requests return 404; unfinished delegations return 409.
GET /api/sessions/{id}/delegations/{delegation_id}/result/output Read a bounded UTF-8-safe page of the authoritative final child output. offsetBytes defaults to 0; limitBytes defaults to 4096 and is bounded to 256..=8192. Responses include nextOffsetBytes, totalBytes, and complete, so MCP callers can reconstruct outputs larger than model/tool transport limits without temporary-file side channels. Ownership and terminal-state errors match the compact result endpoint.
POST /api/sessions/{id}/delegations/{delegation_id}/cancel Cancel a running delegation child session -> DelegationStatusResponse; unknown delegation ids, unknown parent ids, and wrong-parent requests return 404. Terminal delegations are idempotent and return the current status.
POST /api/sessions/{id}/delegations/{delegation_id}/followup Re-arm a terminal (Completed/Failed) delegation and dispatch another turn to its existing child session -> DelegationStatusResponse. A delegation that is still Running, was canceled, or whose child was removed returns 409; unknown/wrong-parent ids return 404. Backs the termal_followup_session MCP tool.

GET /api/sessions/{id} is always a bounded transcript read. It returns the newest 20 messages by default; ?tail=N requires 1 <= N <= SESSION_TAIL_HYDRATION_MAX_MESSAGES (64). Out-of-range values return 400 instead of silently changing the requested window. The response preserves the full messageCount and keeps messagesLoaded: false unless the returned suffix reaches the beginning. There is no size-based compatibility branch and no HTTP path that serializes an unbounded local transcript.

Clients prepend older pages from GET /api/sessions/{id}/history?before={messageId}&limit=N. History pages are ascending and exclusive-before, with N <= 64, nextBefore, and hasMore. Older local pages come from the indexed messages table. Range reads use the (session_id, position) primary key and message-id cursors use the unique (session_id, message_id) index described in SQLite session storage. Tail and page responses are separate HTTP reads, so their revisions may differ if the session changes between requests; the frontend preserves same-instance live tail appends and rejects missing cursors or replacement server instances through authoritative resync.

Direct jumps use around={globalMessagePosition} and return one centered page with messageStartIndex; they never page-walk through intervening history.

GET /api/sessions/{id}/overview?buckets=N is the separate bounded whole-conversation summary used by the minimap rail. The server maps message positions into equal semantic buckets from typed resident messages plus a transactionally maintained one-byte-per-message SQLite overview blob. The rail uses only this position-space response; virtualizer layout snapshots, pixel estimates, focus state, and resident-message fallbacks are intentionally not part of the overview path. Bucket h counts source-free human text prompts, separately from compatibility field u, which can also include peer/mailbox messages authored as you. The UI keeps ordinary kind buckets quiet and reserves strong accents for h, errors, markers, and the outlined viewport handle.

Remote proxies preserve the same bounds at the source: tail requests forward ?tail=N to the owner, history requests forward the selected bounded cursor or position plus limit, and overview requests forward the bucket count. They do not fetch a whole remote transcript and slice it locally, and a targeted transcript read does not trigger a broad remote /api/state side fetch.

GET /api/health currently returns { ok: true, supportsInlineOrchestratorTemplates: true }. Remote launchers use supportsInlineOrchestratorTemplates during health probes to decide whether a remote can accept inline local orchestrator templates or must be upgraded first.

Terminal Command Execution

Workspace terminal tabs are command runners, not full PTY emulators. Each run executes one shell command in a session- or project-scoped working directory and records a history entry inside the tab. The frontend uses the streamed endpoint by default so stdout and stderr arrive incrementally.

Design constraints:

SSE Event Stream

GET /api/events returns a Server-Sent Events stream with four event types:

The first three carry a revision: u64 field. state and delta share the main state revision counter, which the frontend uses to reject stale snapshots and detect gaps in the delta sequence. workspaceFilesChanged uses a separate file-event revision counter; the frontend batches same-tick file events and ignores file-event revisions strictly older than the last seen revision (same-revision events are merged while buffered). lagged carries no revision; it only scopes recovery for the immediately following state event.

Graceful shutdown contract

On Ctrl+C / SIGTERM, main.rs::shutdown_signal() resolves and the wrapping with_graceful_shutdown future calls AppState::trigger_shutdown_signal(), which flips the shared tokio::sync::watch::Sender<bool> to true. Every live /api/events stream owns a watch receiver and checks borrow_and_update() before entering the tokio::select! loop and again through wait_for_shutdown_signal() inside the loop. The watch value is sticky, so a receiver created after the shutdown trigger still observes true; this avoids the missed-waiter race that existed with one-shot Notify wakeups. Without this signal, the SSE handler’s only loop-exit branch would be RecvError::Closed on the broadcast channels, whose senders live on AppState clones — including the shutdown_state clone the main task keeps alive for the post-serve drain — so graceful shutdown could wait forever. After the streams end and axum::serve finishes graceful shutdown, shutdown_state.shutdown_persist_blocking() sends PersistRequest::Shutdown to the persist worker, which performs one final drain of collect_persist_delta + persist_delta_via_cache so the latest streamed assistant message is durable before the process exits.

The durability contract only covers graceful shutdown. Hard kills such as SIGKILL, power loss, or a crash can still land between a mutation being queued for the background persist worker and the corresponding SQLite commit. In that case TermAl may lose at most the last un-drained mutation. This is an accepted Phase-1 limitation of background persistence rather than a frontend recovery problem; after restart, the browser must treat SQLite as authoritative.

EventSource recovery on the client

Per the WHATWG spec an EventSource whose response ends with a non-200 status transitions to readyState === CLOSED permanently and stops auto-reconnecting. In dev that happens routinely: Vite’s proxy (ui/vite.config.ts:configureBackendUnavailableProxy) returns 502 Bad Gateway during the brief gap between the old backend exiting and the new one binding the port, and that 502 reaches the browser as a non-200 SSE response. Production rarely hits this (no proxy in the way) but some browsers also close on certain clean stream ends. The transport useEffect in ui/src/app-live-state.ts::useAppLiveState defends against it: when onerror fires with eventSource.readyState === 2, a recovery timer (exponential backoff, 500 ms → 5 s cap) bumps an sseEpoch state, the effect re-runs, the dead EventSource is closed, and a fresh one is constructed. onopen resets the recovery counter and clears any pending timer. The numeric literal 2 is used instead of EventSource.CLOSED because tests stub the global EventSource with a mock whose CLOSED is undefined; the production code is robust by checking typeof readyState === "number" first.

Live-state reconnect and watchdog recovery

The frontend has two recovery paths that intentionally use different revision gates. Reconnect and explicit action-recovery probes may accept same-instance snapshots at the requested revision when the request context proves the response is repairing a known in-flight gap; this covers cases where the backend persisted the same revision but the browser missed transcript data or a server restart reset the stream. The live-session resume watchdog is broader: it watches active sessions for a wake gap or stale transport window and polls /api/state with authoritative rollback enabled, but every data-bearing state or delta event advances markLiveSessionResumeWatchdogBaseline() for the affected sessions. That baseline prevents normal long-running streams from being mistaken for sleep/resume gaps.

The asymmetry is deliberate. Reconnect probes answer “can this snapshot repair the exact request I made?”, so they key off request revision, server instance id, and response metadata. The watchdog answers “has an active stream been silent too long after the browser may have slept?”, so it keys off wall-clock activity and session status. Do not collapse those into a single >= latest revision check: same-revision repair is required for missed incremental Markdown/text deltas, while stale watchdog polling must back off as soon as live data resumes.

state events and every snapshot-bearing response (StateResponse, HealthResponse, CreateSessionResponse, SessionResponse) additionally carry a serverInstanceId: string — a per-process UUID generated once via Uuid::new_v4() at AppState::new_with_paths. The id is not a secret and not a protocol boundary; it exists so the frontend can detect a server restart deterministically. After a restart, the revision counter rewinds to whatever SQLite held (usually lower than the browser’s last-seen revision), which would otherwise cause every monotonic check in shouldAdoptStateRevision to reject the fresh state. isServerInstanceMismatch in ui/src/state-revision.ts returns true only when both the last-seen and incoming ids are non-empty AND differ; shouldAdoptSnapshotRevision accepts only unseen mismatched ids as restarts; mismatched ids already seen by the tab are rejected as late responses from older server processes. The unseen-restart branch overrides both the monotonic check and any allowRevisionDowngrade gate. The empty-string sentinel (#[serde(default)] on Rust, "" fallback on older servers or fallback SSE payloads) means “unknown instance” and cannot trigger a restart branch — this is what lets empty_state_events_response() send a fallback payload without masquerading as a restart. New endpoints that return state-shaped responses must emit a non-empty serverInstanceId sourced from AppState::server_instance_id; otherwise a session hydration in flight across a restart gets silently rejected by the revision guard until the safety-net pollers re-fetch.

HTTP error responses intentionally carry only { "error": string }. The backend’s ApiErrorKind is in-process classifier metadata used before a response is serialized; decode_remote_json in src/remote_ssh.rs reconstructs forwarded remote errors from status plus message with no typed kind. Recovery code that depends on a typed kind must therefore tag the error on the local proxy side after a successful remote response, not rely on typed metadata surviving another HTTP hop. In a chained-remote topology, every intermediate hop reconstructs ApiError with kind: None, so typed recovery is effectively single-hop.

Every Session or session summary serialized on the wire carries messageCount: u32. StateResponse.sessions are metadata-first summary shells: they retain normal session metadata, set messagesLoaded: false, and keep messages: []. Bounded transcript suffixes come from SessionResponse; newly created empty sessions come from CreateSessionResponse. SessionCreated and OrchestratorsUpdated.sessions are metadata-first delta summaries with messagesLoaded: false and messages: []. The backend computes messageCount from the session record’s transcript at wire-projection time; the frontend keeps it on the session summary so reconnect/state adoption can preserve transcript height and gap-detection metadata without waiting for another session-scoped delta.

Session.messageCount defaults only for persisted empty-session construction; DeltaEvent.*.messageCount is intentionally required on the wire. Remote SSE bridges that omit delta counts are treated as a hard protocol break; see docs/metadata-first-state-plan.md Contract Precisions -> Field semantics.

DeltaEvent::TextDelta            { revision, session_id, message_id, message_index, message_count, text_start_byte?, delta, preview, session_mutation_stamp? }
DeltaEvent::TextReplace          { revision, session_id, message_id, message_index, message_count, text, preview, session_mutation_stamp? }
DeltaEvent::CommandUpdate        { revision, session_id, message_id, message_index, message_count, command, output, status, preview, session_mutation_stamp?, ... }
DeltaEvent::ParallelAgentsUpdate { revision, session_id, message_id, message_index, message_count, agents, preview, session_mutation_stamp? }
DeltaEvent::MessageCreated       { revision, session_id, message_id, message_index, message_count, message, preview, status, session_mutation_stamp? } // inserts a new message at message_index; if the id already exists, remove and reinsert it at that literal index
DeltaEvent::MessageUpdated       { revision, session_id, message_id, message_index, message_count, message, preview, status, session_mutation_stamp? } // replaces an existing message in place; message_index is a fast-path hint and must not reorder the transcript
DeltaEvent::SessionCreated       { revision, session_id, session } // metadata-first session summary; local + remote-proxied session creation; forwarded by remote backends after id localization
DeltaEvent::CodexUpdated         { revision, codex } // latest process-global CodexState snapshot; remotes consume the revision for ordering but do not localize Codex state into proxy sessions
DeltaEvent::OrchestratorsUpdated { revision, orchestrators[], sessions[] } // sessions[] contains metadata-first summaries for referenced sessions and is omitted on the wire when empty; IDs inside each instance are scoped to the originating server; translate via sync_remote_state_inner before forwarding remotely.
DeltaEvent::ConversationMarkerCreated { revision, session_id, marker, session_mutation_stamp? } // marker inserted on a session; marker.session_id must match the event session_id after remote localization
DeltaEvent::ConversationMarkerUpdated { revision, session_id, marker, session_mutation_stamp? } // marker replacement; mismatched marker/session ids are treated as a resync-worthy protocol error
DeltaEvent::ConversationMarkerDeleted { revision, session_id, marker_id, session_mutation_stamp? } // marker removal; delete is idempotent for remote replay
DeltaEvent::DelegationCreated    { revision, delegation } // summary-safe delegation record for a newly spawned child session
DeltaEvent::DelegationWaitCreated { revision, wait } // backend-owned wait record for parent fan-in resume scheduling
DeltaEvent::DelegationWaitConsumed { revision, wait_id, parent_session_id, reason } // wait was satisfied or invalidated and removed from state
DeltaEvent::DelegationWaitResumeDispatchFailed { revision, parent_session_id, error } // wait was consumed but queued parent resume dispatch failed; UI should repair via state and operators get a structured warning
DeltaEvent::DelegationUpdated    { revision, delegation_id, status, updated_at } // lightweight lifecycle status transition; failed transitions require follow-up result fetch today
DeltaEvent::DelegationCompleted  { revision, delegation_id, result, completed_at } // terminal completion with summary-safe result payload
DeltaEvent::DelegationFailed     { revision, delegation_id, result, failed_at } // terminal failure with summary-safe result payload
DeltaEvent::DelegationCanceled   { revision, delegation_id, canceled_at, reason? } // terminal cancellation status for parent-card and drawer updates

For inbound remote session-scoped deltas, session_mutation_stamp? is a freshness marker when present. A missing stamp means “unknown”, not “clear the cached stamp”, so receivers preserve any prior cached stamp and let later metadata-only summaries decide whether targeted hydration is needed.

When a delta targets an unloaded remote-proxy session, TermAl repairs that single transcript with remote GET /api/sessions/{id}. The returned SessionResponse.revision is a remote-global revision, not a per-session freshness marker: it may be greater than the triggering delta revision because unrelated sessions changed upstream. The targeted repair accepts that newer global revision only when the returned session’s (sessionMutationStamp, messageCount) exactly matches the triggering delta’s post-state metadata. If the stamp is missing or mismatched, the repair is rejected and the remote event bridge falls back to /api/state resync so a future same-session transcript is not localized early and then replayed again by later deltas. Successful remote delta applications record a bounded in-memory replay key from the remote revision plus the delta’s semantic payload identity. Cheap variants use structural fields such as session/message ids, message index, message count, and mutation stamp; content-bearing or complex variants also include the exact mutating payload, or a stable fingerprint of that payload, so distinct same-revision sibling deltas still apply. Replay keys are cleared with the remote applied-revision watermark when event-stream continuity is lost. Targeted repairs record a session-specific transcript watermark at the returned remote response revision, but keep the broad remote watermark at the triggering delta revision so same-session stale deltas are skipped without suppressing unrelated intermediate deltas from other sessions.

WorkspaceFilesChangedEvent {
  revision,
  changes: [
    { path, kind, rootPath?, sessionId?, mtimeMs?, sizeBytes? }
  ]
}

kind is created, modified, deleted, or other. rootPath and sessionId scope a watcher hint when it can be tied to a project root or session workdir; unscoped events still carry the absolute changed path as a fallback.

TextDelta appends streaming text to an in-progress message. Current backends include textStartByte, the UTF-8 byte length of the exact message prefix the delta extends. Clients append only when their retained prefix has that byte length; a mismatch proves an earlier streaming event was missed and must trigger authoritative session hydration instead of displaying a corrupted draft. The field is optional only for soft rollout across existing remote TermAl peers. TextReplace overwrites the full message text when the backend receives an authoritative completed payload that diverges from the streamed draft, so clients should replace the target message body instead of appending.

On broadcast channel lag, the backend falls back to sending a full state snapshot.

Persistence

~/.termal/
|-- termal.sqlite          # primary store: app_state + sessions + delegations tables (+ WAL/-shm sidecars)
|-- coordination.sqlite    # mailbox + coordination-board tables, isolated writer/WAL
|-- orchestrators.json     # reusable orchestrator templates
`-- telegram-bot.json      # optional Telegram relay runtime metadata/state; UI config is mirrored from app_state

Background persistence favors UI responsiveness over hard-kill durability. A normal shutdown drains the persist worker before exit, but SIGKILL, power loss, or process crash can discard the last mutation that was signaled but not yet committed to SQLite. The expected loss bound is one un-drained mutation; once the backend restarts, persisted SQLite state is the source of truth.

PersistedState is the logical projection of StateInner that excludes runtime handles and pending approval maps. On disk, app_state carries global metadata, sessions carries compact per-session metadata, and delegations carries delegation records. Transcript bodies, their compact overview, and bounded composer history live separately in messages, session_overviews, and session_prompt_histories. Prompt history has its own mutation watermark, so streamed assistant cards can update session metadata without repeatedly serializing and rewriting up to 512 KiB of user prompts; an existing embedded session.promptHistory value is migrated into the separate row on startup. This split lets the background persist thread write only the changed rows on each commit — see collect_persist_delta, persist_delta_via_cache, and SqlitePersistConnectionCache in src/persist.rs.

On startup, the backend loads state from termal.sqlite when it exists and otherwise boots a fresh local state. Template definitions live in orchestrators.json so reusable workflow designs can be managed separately from running instances. Normalized session and delegation rows are independent startup failure boundaries: a malformed record is reported and skipped rather than aborting the process and hiding every healthy session. Schema-v1 transcript extraction is likewise batched and skips malformed session rows.

Coordination bootstrap runs before the persist worker, coordination stores, and HTTP listener. When upgrading from the former single-database layout, it attaches termal.sqlite read-only and copies all mailbox/board rows into coordination.sqlite; copy, invariant verification, and the destination migration marker commit atomically in one destination transaction. The old tables remain inert so an interrupted or rolled-back migration never destroys the only copy.


Remote Architecture

The implemented remote architecture is:

Browser -> local TermAl server -> remote TermAl server

The browser does not manage multiple backend origins directly. Instead, the local server is the control plane and exposes the single browser-facing /api and /api/events interface.

Topology

Remote Connection Diagram

┌──────────────────────────────┐
│ Browser UI                   │
│ React app                    │
│ - one /api origin            │
│ - one /api/events stream     │
└──────────────┬───────────────┘
               │ HTTP + SSE
               ▼
┌─────────────────────────────────────────────────────────────┐
│ Local TermAl Server                                         │
│ Control plane                                               │
│ - stores preferences and remote config                      │
│ - owns browser-facing REST + SSE                            │
│ - maps project -> remoteId                                  │
│ - rewrites ids and aggregates state                         │
│ - supervises SSH sessions and remote servers                │
└──────────────┬───────────────────────────────┬──────────────┘
               │                               │
               │ local execution               │ SSH managed start + persistent tunnel
               ▼                               ▼
┌──────────────────────────────┐   ┌──────────────────────────────────────────┐
│ Local machine runtime        │   │ Remote machine                           │
│ LocalConnector               │   │ sshd                                     │
│ - local projects             │   │  └─ runs or reuses `termal server`       │
│ - local agent processes      │   │     through ssh -L port forwarding       │
│                              │   │     bound to 127.0.0.1 on remote host    │
└──────────────────────────────┘   └───────────────────┬──────────────────────┘
                                                       │ tunneled HTTP + SSE
                                                       ▼
                                    ┌──────────────────────────────────────────┐
                                    │ Remote TermAl Server                     │
                                    │ SshConnector target                      │
                                    │ - remote projects                        │
                                    │ - remote sessions                        │
                                    │ - remote agent runtimes                  │
                                    └──────────────────────────────────────────┘

Project Routing Diagram

Project selection in UI
        │
        ▼
projectId -> remoteId lookup in local control plane
        │
        ├─ remoteId = local
        │      -> LocalConnector
        │      -> local TermAl execution
        │
        └─ remoteId = build-box / laptop / workstation
               -> SshConnector
               -> SSH tunnel
               -> remote TermAl execution

For a remote machine:

  1. The local TermAl server uses the system ssh client to connect to the remote host.
  2. Managed mode runs termal server on the remote over that SSH session.
  3. If managed mode does not become healthy, TermAl falls back to tunnel-only mode (ssh -N) and expects a TermAl server to already be listening on the remote host.
  4. The remote TermAl server listens on 127.0.0.1:8787 on the remote machine.
  5. The local TermAl server keeps a persistent local-forward tunnel to that remote server.
  6. The local TermAl server speaks the normal TermAl HTTP and SSE protocol over that tunnel.

This is intentionally similar to the Remote-SSH shape used by editor tooling: SSH is used to reach the machine, start the remote server, and carry the transport. The browser still only talks to the local control plane. The current remote config stores only connection settings (id, name, transport, enabled, host, port, and user). Remote lifecycle actions are explicit one-shot SSH operations, not background services: registration verifies an existing checkout and writes ~/.termal/remote-install.json, and upgrade pulls/builds that checkout and installs termal or termal.exe under the remote .termal/bin directory. POSIX checkout paths use sh -lc; Windows-style checkout paths use encoded PowerShell. Managed startup still runs termal server from the remote command environment so Windows SSH hosts are not forced through a POSIX shell just to start the server.

Control Plane Responsibilities

The local TermAl server owns:

The local server is therefore both:

Project-Scoped Routing

Remote routing has two related owner signals:

This avoids teaching the UI to choose a backend for every action. The user chooses a remote when creating a project, and the rest of the routing follows from that association.

Session and Project Identity

Remote-native ids cannot be trusted to be globally unique across multiple machines. The local control plane therefore exposes local browser-facing ids for proxy projects, sessions, and orchestrator instances, while storing the remote-native ids in runtime/persisted mapping fields such as remote_session_id, remote_project_id, and remote_orchestrator_id.

The browser should treat those local ids as canonical. Remote-native ids remain an internal proxying detail.

Session.remoteId is browser-facing ownership metadata. In the current Phase 1 SSH model it is intentionally treated as non-secret shared control-plane metadata: every configured remote is a TermAl backend reached through a user-managed SSH trust relationship, and remote aliases such as ssh-lab are local routing labels rather than credentials. Remote backends must still discard untrusted inbound Session.remoteId values when localizing snapshots or deltas; the field is authoritative only when projected from local SessionRecord metadata. If TermAl later supports untrusted remotes, shared multi-user remotes, or public HTTP exposure, add a caller-aware wire projection that strips Session.remoteId from responses served to those remotes.

State and Event Aggregation

The browser consumes one state stream from the local control plane.

That means the local server:

The frontend does not need to know whether a project is local or remote in order to consume normal state and delta updates.

SSH as the Permanent Remote Transport

SSH is not just a bootstrap convenience for the first version. It is the intended long-term remote transport model.

Design constraints:

Managed SSH Startup

The current managed startup mode is intentionally small:

  1. Build an SSH command with batch mode, local port forwarding, and keepalive settings.
  2. Run termal server on the remote host.
  3. Probe the forwarded local URL until /api/health succeeds.
  4. Cache the remote capabilities and begin proxying REST/SSE through the tunnel.
  5. If that path fails, try tunnel-only mode and probe the same forwarded URL.

This keeps the transport model simple and uses the user’s normal system SSH configuration and ssh-agent. It does not currently update remote source checkouts or install TermAl binaries.

API Shape

The remote TermAl server exposes the same HTTP and SSE protocol shape as a local TermAl server as much as possible.

This keeps the system simpler:

Recommended control-plane connector abstraction:

With at least two implementations:

UI Implications

The UI now follows these constraints:

The UI should still not evolve toward:


Agent Integration

Process descriptor capacity

On Unix, TermAl raises its process-wide soft RLIMIT_NOFILE toward 8192 at startup, capped by the current hard limit. This happens before any agent runtime is spawned, so the shared Codex app-server, Claude processes, and their tools inherit the effective limit without requiring a shell-level ulimit. Set TERMAL_NOFILE_LIMIT to a positive integer to override the default. The lift is best-effort and never lowers an existing higher limit; TermAl logs a warning and continues when the OS rejects it. Already-running children cannot inherit a later change, so changing the override requires a full TermAl restart.

Claude Code

Invocation:

claude --print --output-format stream-json --input-format stream-json \
  --verbose --permission-prompt-tool stdio --include-partial-messages \
  --include-hook-events \
  --setting-sources user,project,local --no-chrome --replay-user-messages \
  --resume <external_session_id>   # if resuming

Environment: CLAUDE_CODE_ENTRYPOINT=termal

Protocol: Bidirectional NDJSON over stdin/stdout. One process per session, long-lived across turns.

Thread architecture: 4 dedicated threads per runtime:

  1. Writer — receives ClaudeRuntimeCommand from an mpsc channel, serializes to NDJSON, writes to stdin
  2. Reader — reads stdout line-by-line, parses JSON, routes events to AppState methods
  3. Stderr — logs Claude’s stderr output
  4. Waiter — polls child.try_wait() to detect process exit

Lifecycle:

  1. Spawn process → send control_request { subtype: "initialize" } → receive control_response with pid, models, commands
  2. On user message → write { type: "user", message: { role: "user", content: [...] } } to stdin
  3. Receive streaming events: assistant (text, tool_use, tool_result), result (turn complete)
  4. On tool approval needed → Claude sends control_request { subtype: "can_use_tool" } → TermAl either auto-approves or shows approval card → sends control_response with decision

Transient API retries: Terminal Claude results with is_error: true and api_error_status 429, 503, or 529 retry the exact prompt inside the existing runtime, with bounded exponential backoff and visible attempt status. Replay is fail-closed: once an attempt emits transcript content, reaches a tool or approval boundary, or produces a protocol event that is not explicitly proven effect-free, the error remains terminal rather than risking duplicate side effects. TermAl enables Claude’s --include-hook-events stream so configured prompt hooks cross that safety boundary instead of remaining invisible. Hook lifecycle envelopes update only the replay-safety latch and never create transcript cards. This broader fail-closed coverage deliberately trades some retry availability for protection against replaying effects introduced by future or unrecognized protocol events. The parser starts each accepted prompt generation with fresh turn-local state. Claude’s exact text/image user echo, process-scoped SessionStart hooks, the status=requesting admission envelope, and quota-only rate_limit_event are the explicitly verified effect-free frames; tool results, prompt hooks, output, and unknown shapes remain replay barriers. Delayed retries are bound to the originating prompt generation, revalidate the live runtime immediately before writing, and are discarded when the runtime enters Stop or is replaced. The saved prompt is cleared when its terminal result is handled. Therefore this protects pre-effect overload failures, not every 529 that can occur during a long-running turn.

Session resume: Pass --resume <session_id> on spawn. Claude restores full conversation context from its own ~/.claude/sessions/ storage.

Codex

Invocation:

codex app-server   # JSON-RPC over stdin/stdout

Protocol: JSON-RPC 2.0 over stdio. One shared app-server process is reused across all live Codex sessions, and each session is mapped onto its own Codex thread inside that process.

Thread architecture: The shared process uses four helper threads:

  1. Writer — serializes queued commands and JSON-RPC responses to stdin. All JSON-RPC requests except initialize (startup handshake) and model/list (pagination) are fire-and-forget: the writer writes the request and immediately returns to process the next command. Response waiting is handled by short-lived waiter threads spawned per-request, so one slow Codex response never blocks other sessions or commands.
  2. Reader — parses stdout JSON lines and routes events to the correct session recorder. Non-JSON lines (log output, warnings) are skipped and logged to stderr rather than treated as fatal errors, so a single malformed line does not tear down the shared runtime.
  3. Stderr — logs diagnostic output.
  4. Waiter — watches for child-process exit and tears down any attached sessions.

Fire-and-forget flow for prompts: When a session already has a thread ID, the writer sends turn/start directly and returns. When a new thread is needed, the writer sends thread/start (or thread/resume) as a fire-and-forget write and spawns a waiter thread. That waiter extracts the thread ID from the response and feeds a StartTurnAfterSetup command back through the writer’s command channel, which then sends turn/start. The writer thread never blocks on either step.

Lifecycle:

  1. Spawn shared process -> send initialize RPC -> receive capabilities (only blocking step)
  2. For each session, send thread/start (new) or thread/resume (existing) -> waiter thread extracts thread ID
  3. On user message, send turn/start with input items (text + optional image attachments)
  4. Receive notifications such as item/agentMessage/delta, item/completed, and turn/completed
  5. On approval or structured interaction, surface a TermAl message card and answer via JSON-RPC once the user responds

Session resume: The persisted external_session_id holds the Codex thread ID. Session-scoped actions such as fork, archive, compact, and rollback are issued through the shared app-server.

Fast mode: Codex model/list entries may advertise a Fast service tier. TermAl retains those tiers in SessionModelOption, exposes Fast only for the active supporting model, persists the session authority, and sends the catalog-advertised tier id (priority in the current catalog and compatibility fallback) on thread/start, thread/resume, and turn/start. Standard turns send serviceTier: null to clear a tier inherited by the thread.

Cursor

Invocation:

cursor-agent acp

Protocol: ACP over stdio. One process per session.

Behavior: Cursor emits ACP session updates for thinking, assistant text, tool calls, and config updates. TermAl maps Cursor’s permission options onto the session cursor_mode (agent, plan, or ask) before deciding whether to auto-answer or show an approval card.

Gemini

Invocation:

gemini --acp [--approval-mode <mode>]

Protocol: ACP over stdio. One process per session.

Behavior: Gemini uses the same ACP normalization layer as Cursor, but its launch command can include the configured Gemini approval mode. TermAl also performs local readiness checks so missing CLI auth or missing gemini installation is surfaced before a session starts.

OpenCode

Invocation:

opencode acp

Protocol: ACP over stdio. One process per session.

Behavior: OpenCode advertises dynamic model, reasoning-variant (effort), and mode config. Auto keeps the agent authoritative; explicit TermAl selections are re-applied in model-then-effort-then-mode order and acknowledged after new/resume/load before prompt dispatch. Every handshake or live update reconciles only option lists actually present in that payload. User settings share one 55-second scheduling and acknowledgement deadline, and expired writer-queued changes are discarded before execution. A combined model and dependent-option update waits for OpenCode to advertise options for the acknowledged model before validating or applying effort and mode. Once the model is acknowledged, an unavailable, rejected, or unconfirmed dependent selection resets individually to Auto with a visible notice instead of reporting the already-committed model update as a wholesale failure. All OpenCode resume/load failures surface and preserve the exact stored continuity id; TermAl never invents a replacement session from an unverified error shape. User stop sends a bounded OpenCode-only session/cancel grace before local process termination. Permission requests use the ordered ACP approval queue. See OpenCode ACP Integration.

Message Types

All agent integrations normalize into the same TermAl message model. Some variants are common across all agents, while others are only emitted by specific backends such as Codex or ACP.

Type Fields Typical source
Text text, attachments, author User input or agent response
Thinking title, lines Claude or ACP thought streaming
Command command, output, status, languages Tool calls and shell execution
Diff file_path, summary, diff, change_type File edit/create tools
Markdown title, markdown Structured markdown output
FileChanges title, files[] Local workspace watcher summary for files changed during or just after an agent turn
SubagentResult title, summary, conversation_id, turn_id Agent subagent/task results
ParallelAgents agents[] with id, source, status, title, detail Delegation progress from the TermAl delegation runtime (any agent backend, source: "delegation", id is a delegation id) or tool progress (source: "tool", id is an opaque tool-use id)
Approval title, command, detail, decision Permission requests
UserInputRequest title, detail, questions, state Codex request_user_input
McpElicitationRequest title, detail, request, state Codex MCP elicitation
CodexAppRequest title, detail, method, params, state Generic Codex app-server requests

ParallelAgents rows are disambiguated by (message_id, agent.id, agent.source). agent.id alone is not unique because a delegation id and an agent-runtime tool id can share the same visible string.


Frontend

Stack

Feature-level behaviour for these renderers is captured in features/source-renderers.md and features/markdown-document-view.md.

Component Structure

App.tsx (main orchestrator)
├── Sidebar
│   ├── Session list (filterable: all / working / asking / completed)
│   ├── New session button + agent picker
│   └── Settings panel (defaults, theme)
├── Workspace
│   ├── WorkspaceNode (binary tree of splits)
│   │   ├── Pane
│   │   │   ├── PaneTabs (draggable, closable)
│   │   │   ├── Active tab content:
│   │   │   │   ├── AgentSessionPanel (chat view)
│   │   │   │   ├── SourcePanel (Monaco editor)
│   │   │   │   ├── DiffPanel (Monaco diff editor)
│   │   │   │   ├── FileSystemPanel (directory browser)
│   │   │   │   └── GitStatusPanel (branch + file status)
│   │   │   └── AgentSessionPanelFooter (composer + controls)
│   │   └── Split divider (drag to resize)
│   └── ...nested splits
└── Theme switcher

The current workspace also includes standalone control-surface tabs (controlPanel, sessionList, projectList, orchestratorList), orchestrator canvases, terminal tabs, and instruction-debugger tabs. The block above is intentionally high-level rather than an exhaustive component tree.

Workspace System

The workspace is a binary tree of panes. Each node is either a leaf (pane) or a split (two children with a direction and ratio).

WorkspaceNode = { type: "pane", paneId }
             | { type: "split", id, direction: "row" | "column", ratio, children: [node, node] }

WorkspacePane = {
  id, tabs: WorkspaceTab[], activeTabId, activeSessionId,
  viewMode: PaneViewMode, sourcePath, ...
}

Tab types: session, source, filesystem, gitStatus, terminal, controlPanel, orchestratorList, canvas, orchestratorCanvas, sessionList, projectList, instructionDebugger, and diffPreview. Tabs are draggable between panes and can be split into adjacent panes by dropping on pane edges.

View modes per pane:

When a session becomes active in a pane, the frontend keeps the existing scroll-to-latest behavior and autofocuses the composer so typing can begin immediately. Session conversation pages remain mounted for live tabs where possible, which preserves browser scroll state across ordinary tab switches. When a pane rebuild really does remount a session view, TermAl restores the saved offset or forces the view back to the latest response when that tab had been pinned to the bottom.

State Management

No external state library. State lives in App.tsx via useState and useRef:

Real-time Updates

On mount, the frontend opens an EventSource to /api/events:

  1. state events — metadata-first state snapshot. Accepted only if revision > latestRevision (via shouldAdoptStateRevision), OR if the carried serverInstanceId differs from the last-seen id (via isServerInstanceMismatch) — the restart branch accepts a revision downgrade because the monotonic check is meaningless across a counter rewind.
  2. delta events — incremental updates. Accepted only if revision === latestRevision + 1 (via decideDeltaRevisionAction). Session-scoped deltas use the session reducer; orchestratorsUpdated is handled separately because it carries orchestrator state without a sessionId, and remote forwarding must translate the embedded server-scoped IDs before re-publishing it locally. If a gap is detected, triggers a full state resync.

Applied deltas update the specific session/message in-place via applyDeltaToSessions(), avoiding full reconciliation.

Session creation always advances the main revision counter before any SessionCreated delta is published. Same-revision session deltas are therefore replay-only frames for sessions the client already knows about; they must not introduce a brand-new session id without a later authoritative state event that advances or repairs the client view. This is the protocol contract behind the same-revision ignored delta branch in ui/src/app-live-state.ts.

Session creation returns CreateSessionResponse { sessionId, session, revision, serverInstanceId }; the frontend adopts the concrete created session immediately and records the response revision without requiring a full state snapshot.

Server-restart detection is keyed off serverInstanceId. latestStateRevisionRef and lastSeenServerInstanceIdRef are updated in lockstep on every accepted adoption (state events, adoptCreatedSessionResponse, adoptFetchedSession). A restart produces a new UUID at AppState::new_with_paths; the next snapshot from the restarted server carries that new id, isServerInstanceMismatch fires, shouldAdoptSnapshotRevision returns true regardless of revision ordering, and the client resyncs. This closes the “prompt invisible after server restart” class of bug: without it, a stale browser tab against a freshly started server would reject every snapshot the server sent (because the revision rewound) until the user forced a refresh.

Session Reconciliation

reconcileSessions() merges incoming server state with the current local state, preserving React object identity where possible. This minimizes re-renders: if a session’s data hasn’t changed, the same object reference is reused.

Theming

18 selectable color themes (defined in themes.ts) are stored as .css files in ui/src/themes/. Each theme defines CSS custom properties (--ink, --paper, --line, background gradients, Monaco colors, etc.). The active theme is set via data-theme on <html> and persisted to localStorage.

Chrome style is separate from color theme. The user can keep the theme’s own chrome or choose Terminal, Editorial, Studio, or Blueprint styling with data-ui-style. Global UI font size, editor font size, and density are also runtime preferences.

Message Rendering

Messages are rendered as typed cards:

Long conversations (30+ messages) use page-band virtualized rendering. The mounted transcript band is real DOM; only unseen pages above/below it are represented by virtual spacers. VirtualizedConversationMessageList.tsx measures whole mounted page bands, grows the mounted band from real DOM edges, and preserves a visible-row anchor when prepending pages or applying deferred page-height corrections. The design intentionally avoids per-message estimated height corrections in the live scroll path.

Deferred heavy message content participates in the same scroll contract. During active transcript scrolling or page-jump cooldowns, the virtualized stack marks the .message-stack with data-deferred-render-suspended="true". Deferred heavy content must not activate while that marker is present. When the cooldown ends, the stack removes the marker and dispatches termal:deferred-render-resume so near-viewport heavy blocks can activate after scroll geometry has settled. Assistant markdown should keep the same deferred-render component mounted when scroll state changes; toggling between eager markdown and the deferred wrapper can swap measured content for a placeholder and shift the virtualized scroll height during the first PageUp from the bottom.

Monaco Integration

Two Monaco components:

Workers are loaded for JSON, CSS, HTML, and TypeScript/JavaScript. Theme mapping bridges TermAl themes to Monaco’s built-in dark/light themes.


Session Lifecycle

Create session (POST /api/sessions)
  → SessionRecord created, status = Idle, preview = "Ready for a prompt."
  → commit_locked() bumps revision, persists, publishes

Send message (POST /api/sessions/{id}/messages)
  → If session is Active or Approval: queue the prompt, return Queued
  → Otherwise: start turn immediately
    → Spawn agent process if runtime is None
    → Run initialize handshake
    → Send user message to agent stdin
    → Status = Active

Streaming response
  → Agent writes events to stdout
  → Reader thread parses, calls AppState methods:
    → push_message() for new messages (text, diff, command, etc.)
    → append_text_delta() for streaming text chunks
    → update_command_message() for running command output
  → Each call bumps revision and publishes delta or full state

Approval needed
  → Agent requests permission for a tool call
  → TermAl adds Approval message, status = Approval
  → Frontend shows approval card
  → User submits decision (POST /api/sessions/{id}/approvals/{mid})
  → Decision forwarded to agent, status = Active

Turn complete
  → Agent sends result/turn_completed
  → Status = Idle
  → If queued prompts exist: dispatch next one automatically

Stop (POST /api/sessions/{id}/stop)
  → Kill active runtime process
  → Reject pending approvals
  → Status = Idle
  → Dispatch next queued prompt if any

Kill (POST /api/sessions/{id}/kill)
  → Kill runtime, remove session from list entirely

Prompt Queueing

When a session is busy (Active or Approval), new messages are queued in a VecDeque. The frontend shows these as PendingPrompt entries below the composer. Users can cancel individual queued prompts. After each turn completes, dispatch_next_queued_turn() pops the next prompt and starts it automatically.


Project Structure

termal/
|-- src/
|   |-- main.rs              # process mode selection + router assembly;
|   |                        # assembles the other *.rs files via include!()
|   |                        # into a single flat crate
|   |-- process_limits.rs    # early Unix RLIMIT_NOFILE lift inherited by agents
|   |
|   |-- # State: core types + sessions + persist + broadcast
|   |-- state.rs             # AppState, StateInner, PersistRequest/Delta, core types
|   |-- state_inner.rs       # StateInner CRUD + session-array primitives + finders
|   |-- state_accessors.rs   # snapshot / readiness cache / session-state readers
|   |-- state_boot.rs        # boot-time: discovered Codex threads + recovery + normalize
|   |-- app_boot.rs          # AppState::new_with_paths — the heavy startup wiring
|   |-- sse_broadcast.rs     # commit_locked + persist-wake + state/delta/file broadcast
|   |-- persist.rs           # SQLite schema + persist_delta_via_cache + connection cache
|   |-- persisted_state.rs   # disk-projection types (PersistedState / PersistedSessionRecord)
|   |-- paths.rs             # path resolution, canonicalization, project-scoped guards
|   |
|   |-- # Durable coordination
|   |-- coordination_persist.rs # coordination.sqlite schema + atomic legacy migration
|   |-- mailboxes.rs         # durable edge-triggered peer messages + cursors
|   |-- delegation_review_results.rs # typed review validation, projection + recovery
|   |-- coordination_board.rs # level-triggered project facts + CAS/idempotency
|   |-- board_routes.rs      # authorized board list/get/set HTTP handlers
|   |-- delegation_mcp.rs    # parent-scoped delegation, mailbox, and board MCP bridge
|   |
|   |-- # Sessions + turns + messages
|   |-- session_crud.rs       # create_session, create/delete_project, update_app_settings
|   |-- session_lifecycle.rs  # kill/stop/cancel session
|   |-- session_messages.rs   # push_message / append_text_delta / upsert_command_message
|   |-- session_config.rs     # update_session_settings + refresh_session_model_options
|   |-- session_identity.rs   # message IDs + external_session_id bindings + Codex thread state
|   |-- session_sync.rs       # runtime-driven syncs (model options, agent commands, cursor mode)
|   |-- session_interaction.rs # pending-approval registers + preview-text projections
|   |-- session_runtime.rs    # runtime handle types + kill utilities
|   |-- messages.rs           # low-level SessionRecord message helpers
|   |
|   |-- # Turn engine
|   |-- turns.rs             # canonical turn runner + blocking REPL turn
|   |-- turn_dispatch.rs     # start_turn + dispatch_* queue draining
|   |-- turn_lifecycle.rs    # Idle↔Active↔Approval state machine + RuntimeToken guards
|   |-- recorders.rs         # TurnRecorder / CodexTurnRecorder ecosystem
|   |
|   |-- # Agents
|   |-- agent_readiness.rs   # CLI availability probing cache
|   |-- claude.rs            # Claude NDJSON message handling
|   |-- claude_spawn.rs      # Claude CLI subprocess spawn + wire writers
|   |-- claude_args.rs       # Claude CLI argv construction + message parsing
|   |-- codex.rs             # Codex shared-runtime spawn + session state
|   |-- codex_home.rs        # Codex home directory setup + stderr formatters
|   |-- codex_bin.rs         # Codex executable discovery + web-search formatters
|   |-- codex_rpc.rs         # Codex JSON-RPC transport (send + wait for response)
|   |-- codex_events.rs      # inbound Codex event dispatcher
|   |-- codex_notices.rs     # shared-runtime global notice handling
|   |-- codex_text_stream.rs # agent-message delta dedup + subagent buffering
|   |-- codex_app_requests.rs # approval/user-input/MCP request + item-event handlers
|   |-- codex_turn_cleanup.rs # per-turn reset + completed-turn cleanup worker
|   |-- codex_submissions.rs # user-driven approvals / replies back into Codex
|   |-- codex_thread_actions.rs # fork/archive/unarchive/compact/rollback Codex thread
|   |-- codex_discovery.rs   # scan Codex home for pre-existing threads
|   |-- codex_validation.rs  # validation helpers for Codex payloads
|   |-- shared_codex_mgr.rs  # shared Codex app-server lifecycle + exit cascade
|   |-- repl_codex.rs        # REPL-mode Codex driver
|   |-- acp.rs               # ACP (Cursor / Gemini / OpenCode) protocol driver
|   |-- gemini.rs            # Gemini-specific dotenv + GEMINI_CLI_SYSTEM_SETTINGS setup
|   |-- opencode.rs          # OpenCode executable resolution + bounded readiness diagnostics
|   |
|   |-- # HTTP API
|   |-- api.rs               # thin Axum handlers + shared helpers (router is in main.rs)
|   |-- api_git.rs           # git workflow routes (status/diff/file/commit/push/sync)
|   |-- api_files.rs         # file read/write + directory list + agent command discovery
|   |-- api_sse.rs           # state SSE stream + initial-snapshot + fallback payloads
|   |-- api_review.rs        # review-document CRUD routes
|   |-- runtime.rs           # shared runtime types + Claude/Codex/ACP command enums
|   |
|   |-- # Wire (DTOs)
|   |-- wire.rs              # shared wire vocabulary: ApiError, Agent, enums, core types
|   |-- wire_messages.rs     # Message enum + interaction request DTOs + parallel-agent types
|   |-- wire_git.rs          # every Git DTO (request + response + GitDiff types)
|   |-- wire_terminal.rs     # TerminalCommand DTOs + streaming types + tuning constants
|   |-- wire_review.rs       # ReviewDocument + threaded-comment shapes
|   |-- wire_project_digest.rs # project digest DTOs + status/progress text formatters
|   |
|   |-- # Remote
|   |-- remote.rs            # SSH tunnels + HTTP transport + terminal stream bridge
|   |-- remote_ssh.rs        # SSH connection setup + validation + health checks
|   |-- remote_routes.rs     # remote HTTP plumbing (get/post/put_json) + state sync
|   |-- remote_create_proxies.rs   # create_remote_{project,session,orchestrator}_proxy
|   |-- remote_codex_proxies.rs    # fork/archive/unarchive/compact/rollback thread proxies
|   |-- remote_session_proxies.rs  # uniform "resolve-forward-sync" session action proxies
|   |-- remote_sync.rs       # ID localization + apply_remote_state + delta event fan-out
|   |-- remote_terminal.rs   # remote terminal stream proxy
|   |
|   |-- # Orchestrators
|   |-- orchestrators.rs     # template CRUD + instance creation + draft normalizers
|   |-- orchestrator_lifecycle.rs    # running-instance state machine (pause/resume/stop)
|   |-- orchestrator_transitions.rs  # per-transition engine (prompt injection, branching)
|   |
|   |-- # Misc subsystems
|   |-- instructions.rs      # instruction search graph traversal + document classification
|   |-- git.rs               # git diff loading, status parsing, worktree readers, repo sync
|   |-- terminal.rs          # terminal run/stream, process-tree lifecycle, output buffer
|   |-- review.rs            # review-document persistence + change-set-id validation
|   |-- workspace_queries.rs # workspace layout CRUD + agent command listing
|   |-- workspace_watch.rs   # workspace file watcher threads
|   |-- telegram_runtime.rs  # Telegram relay lifecycle and environment config
|   |-- telegram_state.rs    # Telegram relay persisted state and redaction
|   |-- telegram_clients.rs  # Telegram/TermAl API clients and wire DTOs
|   |-- telegram_messages.rs # Telegram send-message chunking helpers
|   |-- telegram_forwarding.rs # assistant reply forwarding state machine
|   |-- telegram_digest.rs   # project/session digest rendering and command replies
|   |-- telegram.rs          # Telegram update/callback/prompt handling
|   |
|   `-- tests/               # backend regression tests, split by domain
|       |-- mod.rs           # shared fixtures: TestRecorder, HTTP test server, handle factories
|       |-- acp_gemini.rs    # ACP + Gemini runtime configuration
|       |-- agent_commands.rs, agent_readiness.rs, claude.rs, codex_discovery.rs,
|       |-- codex_protocol.rs, codex_threads.rs, cursor.rs, file_changes.rs,
|       |-- git.rs, http_routes.rs, instruction_search.rs, json_rpc.rs,
|       |-- orchestrator.rs, persist.rs, project_digest.rs, projects.rs,
|       |-- remote.rs, review.rs, runtime_rpc.rs, session_lifecycle.rs,
|       |-- session_settings.rs, session_stop.rs, session_stop_runtime.rs,
|       |-- shared_codex.rs, shared_codex_events.rs,
|       |-- coordination_board_routes.rs, telegram.rs,
|       |-- terminal.rs, workspace.rs
|-- ui/
|   |-- src/
|   |   |-- App.tsx
|   |   |-- api.ts
|   |   |-- workspace.ts
|   |   |-- live-updates.ts
|   |   `-- panels/
|   `-- vite.config.ts
|-- docs/
|   |-- architecture.md
|   |-- vision.md
|   |-- roadmap.md
|   |-- bugs.md
|   `-- features/
|-- Cargo.toml
`-- README.md

The backend is still compiled as one crate-level module through include!, but the implementation is now split by concern instead of living entirely inside main.rs.


Key Design Decisions

Single-process control plane. All local state, HTTP handlers, SSE broadcasting, and remote supervision live inside one Rust server. Agent runtimes remain child processes managed over stdin/stdout, and remote machines are bridged back into that same control plane.

SSE over WebSocket. Server-Sent Events are enough for TermAl’s unidirectional update stream. The client sends commands through REST, while SSE handles low-latency streaming updates and reconnection.

Revision counter over timestamps. A monotonic u64 makes ordering cheap and deterministic. The frontend rejects stale snapshots and forces a resync when delta revisions skip.

Shared Codex app-server. Codex threads already carry their own cwd and thread identity, so one shared app-server process can service many Codex sessions. That reduces process churn while keeping session state logically separate.

Include-split backend. The backend still shares one crate namespace but is split across many focused files (see the Project Structure listing above) assembled via include!() in main.rs. Each file owns a specific concern — agent protocol driver, HTTP route group, wire DTO cluster, state sub-area, remote proxy family — so day-to-day edits touch one or two files instead of navigating a monolith. Rust’s multiple-impl-blocks rule lets AppState / StateInner method clusters live in whichever file matches their domain, and the flat namespace means types and helpers are visible across every file without any pub use boilerplate.

Agent-agnostic UI message model. Claude, Codex, Cursor, Gemini, and OpenCode are normalized into the same Message enum. Adding a new agent is mostly a runtime and normalization task rather than a frontend rewrite.

Custom CSS over Tailwind. The frontend uses CSS custom properties and standalone theme files for theming, keeping runtime theme switching simple and avoiding build-time CSS machinery.