A WhatsApp-style interface for controlling AI coding agents running on your machine.
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.
The binary has three modes:
127.0.0.1:8787 by default, serves the API, and manages long-lived agent processes. TERMAL_PORT can override the port.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.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.
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,
}
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.
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.
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:
flutter run, watches, and REPL-like
processes./api/terminal/run/stream SSE
contract when available, and fall back to the JSON route only when the remote
returns 404 or 405 for the stream route.GET /api/events returns a Server-Sent Events stream with four event types:
state — metadata-first StateResponse JSON. Sent on initial connect, after commit_locked(), and as a recovery when the client falls behind. Session entries carry shell metadata, messageCount, and messagesLoaded: false; transcript tails and older bounded pages are loaded through the targeted session routes. Active liveActivity prompt/command fields are whitespace-normalized and bounded to a short UI hint here; the complete values remain available only in targeted session detail.delta - incremental DeltaEvent JSON. Sent during streaming (text deltas, text replacements, command output updates) and small process-global updates such as CodexUpdated. Cheaper than full state.workspaceFilesChanged - coalesced local workspace file watcher hints. Sent outside the main state revision stream with its own monotonically increasing file-event revision so source, diff, file tree, and git-preview panels can refresh only when touched paths match their scope.lagged — control marker with a single-byte payload (wire format: event: lagged\ndata:1\n\n). Emitted in api_sse.rs::state_events when the broadcast receiver returns RecvError::Lagged on either the state-receiver or delta-receiver branch — i.e., the consumer fell past the broadcast channel capacity (state=128, delta=256) and dropped events. The marker is always immediately followed by a recovery state event on the same stream, but the recovery snapshot’s revision may equal the client’s latestStateRevisionRef (the consumer read some events from the burst before falling behind). Without the marker, the client’s gate in handleStateEvent would reject the recovery as a redundant catch-up and silently lose whatever the dropped deltas carried. The client’s handleLaggedEvent arms forceAdoptNextStateEventRef = true and records the current revision as the recovery baseline; the next state event is force-adopted only if the local stream has not already advanced past that baseline. The "1" payload is reserved — clients must NOT parse data; the byte is there because empty-data SSE frames can be coalesced/skipped by browsers per the WHATWG spec, so a control event without a data: line may never reach the JS handler. The lagged event has no revision field, and remote-proxy bridges absorb the marker locally by force-applying the next remote state snapshot rather than forwarding it.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.
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.
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.
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.
~/.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.
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.
┌──────────────────────────────┐
│ 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 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:
ssh client to connect to the
remote host.termal server on the remote over that SSH session.ssh -N) and expects a TermAl server to already be listening on the
remote host.127.0.0.1:8787 on the remote machine.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.
The local TermAl server owns:
The local server is therefore both:
Remote routing has two related owner signals:
Project.remoteId selects the execution target for project-scoped creation
and workspace actions.Session.remoteId identifies the owner of a browser-facing remote-proxy
session after state aggregation.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.
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.
The browser consumes one state stream from the local control plane.
That means the local server:
StateResponseThe frontend does not need to know whether a project is local or remote in order to consume normal state and delta updates.
SSH is not just a bootstrap convenience for the first version. It is the intended long-term remote transport model.
Design constraints:
termal server lifecycle.ssh and ssh-agent should be preferred over custom browser-managed
key handling.The current managed startup mode is intentionally small:
termal server on the remote host./api/health succeeds.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.
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:
ensure_server_runningrequestopen_event_streamWith at least two implementations:
LocalConnectorSshConnectorThe UI now follows these constraints:
The UI should still not evolve toward:
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.
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:
ClaudeRuntimeCommand from an mpsc channel, serializes to NDJSON, writes to stdinAppState methodschild.try_wait() to detect process exitLifecycle:
control_request { subtype: "initialize" } → receive control_response with pid, models, commands{ type: "user", message: { role: "user", content: [...] } } to stdinassistant (text, tool_use, tool_result), result (turn complete)control_request { subtype: "can_use_tool" } → TermAl either auto-approves or shows approval card → sends control_response with decisionTransient 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.
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:
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.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:
initialize RPC -> receive capabilities (only blocking step)thread/start (new) or thread/resume (existing) -> waiter thread extracts thread IDturn/start with input items (text + optional image attachments)item/agentMessage/delta, item/completed, and turn/completedSession 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.
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.
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.
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.
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.
$...$ inline and $$...$$
block math```mermaid flowcharts and diagrams,
rendered inside a sandboxed iframe per diagramFeature-level behaviour for these renderers is captured in
features/source-renderers.md and
features/markdown-document-view.md.
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.
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:
session (chat), prompt (input focus), commands (command list), diffs (diff list)source, filesystem, gitStatus, diffPreviewWhen 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.
No external state library. State lives in App.tsx via useState and useRef:
sessions — canonical session list from backendworkspace: pane/tab layout for the active workspace ID, cached locally and persisted through /api/workspaces/{id}codexState — shared Codex rate-limit infodraftsBySessionId — per-session message drafts (local)draftAttachmentsBySessionId — per-session image attachments (local)latestStateRevisionRef — tracks the highest revision seenOn mount, the frontend opens an EventSource to /api/events:
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.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.
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.
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.
Messages are rendered as typed cards:
IN / OUT layout with copy button, collapsible output, status indicatorLong 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.
Two Monaco components:
MonacoCodeEditor — read/write source viewing with language detectionMonacoDiffEditor — side-by-side diff preview (original vs modified, reconstructed from unified patch)Workers are loaded for JSON, CSS, HTML, and TypeScript/JavaScript. Theme mapping bridges TermAl themes to Monaco’s built-in dark/light themes.
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
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.
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.
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.