Terminal Session Host (daemon-attached, CLI/GUI seamless switching)
Terminal Session Host (2026-10-08)
Section titled “Terminal Session Host (2026-10-08)”This page designs a session host that lets the vox-term TUI and the Axis desktop GUI attach to one live terminal session, so a session opened in either front-end can be continued in the other with the same blocks, scrollback and running process. The vox-terminal-core engine and its session state live inside the vox-orchestrator-d daemon, renderers only display them, and an in-process fallback keeps headless builds working. It records why Warp is a clean-room reference rather than a fork, the protocol contract, and the implementation tasks.
Decision context
Section titled “Decision context”Option D from the 2026-10-08 Warp evaluation: no Warp fork. The renderers stay as
they are (Tauri/React + xterm.js in Vox Axis (Axis), ratatui in vox-term).
vox-terminal-core is the one terminal engine, and Warp is a clean-room design
reference only, per ADR-026. This page
adds the piece the Warp-parity plan
lacked: a session host that lets both front-ends attach to the same live session.
That gives seamless CLI↔GUI switching and a single shared context between the
terminal and the GUI chat.
Why not a GPU-rendered UI or an AGPL Warp fork: see §8.
- One source of truth. A terminal session (PTY, block list, OSC-633 state, agent messages) lives in exactly one place. Renderers display it; they never own it.
- Seamless switching. A session opened in Axis can be attached from
vox term(and the reverse) with the same blocks, scrollback and running process. - Shared context both ways. GUI chat reads terminal blocks as context. Agents drive the terminal through the same session the human sees, gated by the existing approval path.
- Headless / small builds keep working.
vox-termruns with no daemon (in-process host). - GPU-accelerated rendering in the GUI terminal pane, with no new toolchain.
Non-goals: surviving a daemon restart (PTYs die with their host process), remote/mesh attach, and multi-user sessions. See §7.
1. Current state (verified 2026-10-08)
Section titled “1. Current state (verified 2026-10-08)”| Piece | Reality |
|---|---|
| GUI live terminal path | transport.ts → pty_spawn (crates/vox-gui/src/commands/pty.rs, PtyManager) spawns the PTY inside the GUI process and emits raw bytes to xterm.js. Blocks come from the TypeScript reducer Console/osc633.ts. |
| GUI core path | commands/terminal_core.rs (TerminalSessionManager, term_pty_bytes / term_get_blocks / term_submit) is registered in main.rs but has no front-end caller. term_submit only classifies the input. |
vox-term | Owns its own in-process Session (vox-terminal-core::session::Session). |
Session API | new, subscribe() -> broadcast::Receiver<SessionEvent>, on_pty_bytes, blocks, submit(InputIntent). SessionEvent = BlockOpened/OutputAppended/BlockClosed/AgentMessage. |
| Daemon transport | vox-orchestrator-d: line-JSON DispatchRequest/DispatchResponse over loopback TCP (127.0.0.1:9745, auth token required) or stdio. The GUI already uses OrchDaemonClient (control_plane.rs, mission_control.rs, …). |
| Daemon extension point | orch_daemon::ExtraDispatch::try_handle. Request/response only. Push streams are hard-coded to SUBSCRIBE / SUBSCRIBE_EVENTS in both serve loops (orch_daemon/mod.rs). Only one extra is wired (MCP’s McpExtraDispatch). |
| xterm.js | @xterm/xterm 6 + @xterm/addon-fit; no WebGL addon (DOM renderer). |
Net: two terminal engines (TS reducer and Rust core) and a PTY whose lifetime is the GUI window. Both have to be fixed before switching between front-ends can work.
2. Architecture
Section titled “2. Architecture” ┌──────────── vox-orchestrator-d ────────────┐ │ Orchestrator ◄── agents / chat / MCP tools │ │ ▲ │ │ │ transcript + term_run (approval) │ │ SessionHost (DaemonSide) │ │ Session{PTY, blocks, osc633, ring buf} │ └──────▲──────────────────────────▲──────────┘ term.* (TCP/stdio, token) term.* (TCP) │ │ vox-term (DaemonHost) Axis GUI (DaemonHost) ratatui + alacritty grid React + xterm.js (WebGL)
No daemon: vox-term ──► LocalHost (same Session, in-process)2.1 SessionHost trait (in vox-terminal-core)
Section titled “2.1 SessionHost trait (in vox-terminal-core)”Status:
LocalHostshipped as a concrete struct (H1/H2,crates/vox-terminal-core/src/host.rs). The trait below is extracted at H5, whenDaemonHostgives it a second implementation.attachreturns blocks +replaybytes +seq+ two live receivers (session events, raw output frames).
trait SessionHost: Send + Sync { async fn open(&self, spec: OpenSpec) -> Result<SessionId>; // shell, cwd, cols, rows async fn list(&self) -> Result<Vec<SessionInfo>>; async fn attach(&self, id: &SessionId) -> Result<Attachment>; // Snapshot + event stream async fn input(&self, id: &SessionId, bytes: &[u8], origin: Origin) -> Result<()>; async fn submit(&self, id: &SessionId, line: &str, origin: Origin) -> Result<BlockId>; async fn resize(&self, id: &SessionId, cols: u16, rows: u16) -> Result<()>; async fn close(&self, id: &SessionId) -> Result<()>;}There are two implementations, and both run the same Session code:
LocalHostholds sessions in-process. It absorbs the PTY spawn/read loop that lives invox-gui/src/commands/pty.rstoday, so that file shrinks to Tauri glue.vox-termuses it when no daemon answers, and the GUI falls back to it the same way.DaemonHostis a thin client that maps each method to aterm.*request throughOrchDaemonClient. It adds no dependencies:vox-terminal-corealready depends onvox-orchestrator.
The daemon side is a LocalHost exposed over the wire. That leaves one
session-management implementation, not two.
2.2 Session additions
Section titled “2.2 Session additions”- Replay ring buffer.
Sessionkeeps the last N raw PTY bytes (default 2 MiB, configured throughvox-config). On attach, the renderer re-parses them through its own VT engine (xterm.js /alacritty_terminal), so full-screen programs (vim, htop, the alternate screen) come back correctly without the host serializing a screen grid. The block list carries the structure; the ring buffer carries the pixels. - Sequence numbers. Each output frame carries a monotonically increasing
seq. When a client sees a gap (broadcast lag), it re-attaches and gets a fresh snapshot. There is no per-frame acknowledgement. Originon input and blocks (User|Agent{agent_id}), so the UI can mark agent-typed commands and the transcript/MENS corpus can label them.- Resize policy: the last client to send input or a resize owns the size.
ponytail:single-owner sizing; adopt tmux-style smallest-client sizing only if side-by-side attached clients become common.
2.3 Wire protocol (vox_foundation::protocol::term_method)
Section titled “2.3 Wire protocol (vox_foundation::protocol::term_method)”| Method | Kind | Params → Result |
|---|---|---|
term.open | req | {shell?, cwd?, cols, rows} → {session_id} |
term.list | req | {} → [{session_id, title, cwd, created_ms, attached}] |
term.attach | stream | {session_id} → first frame Snapshot{blocks, replay_b64, cols, rows, seq}, then Output{seq, bytes_b64} / Block{opened|closed, block} / Agent{text} / Exit{code} |
term.input | req | {session_id, bytes_b64, origin} → {} |
term.submit | req | {session_id, line, origin} → {block_id} (goes through classify) |
term.resize | req | {session_id, cols, rows} → {} |
term.close | req | {session_id} → {} |
Output bytes are coalesced per frame (a new method, so unlike SUBSCRIBE_EVENTS
there is no existing one-event-per-frame contract to preserve).
2.4 Daemon changes (small, additive)
Section titled “2.4 Daemon changes (small, additive)”- A streaming hook on
ExtraDispatch:async fn try_stream(&self, req: &DispatchRequest, sink: &mut FrameSink) -> Option<anyhow::Result<()>>, defaulting toNone. Both serve loops (TCP and stdio) call it beforedispatch_one_framed, next to the existingSUBSCRIBE*branches. The wire format doesn’t change. - Chaining extras: a
ChainDispatch(Vec<Arc<dyn ExtraDispatch>>)that takes the firstSome. The daemon binary wires[McpExtraDispatch, TermDispatch]. TermDispatchlives in the daemon crate (crates/vox-orchestrator-d/src/term_dispatch.rs) and wraps aLocalHost. It is there, not invox-terminal-core, because the wire types (DispatchRequest/DispatchResponse) live invox-foundationand the core crate has no edge to it; this keeps the total at one new edge,vox-orchestrator-d → vox-terminal-core(L5 → L3). That edge is in thecrate-edgesexceptions ledger, approved by the owner on 2026-10-08. It is registered only on loopback/stdio binds (§5).
3. Seamless switching (UX)
Section titled “3. Seamless switching (UX)”vox term:orch.pingthe daemon. If it answers, useDaemonHostand offerterm.listsessions in the palette. Otherwise useLocalHost.vox term --attach <id>attaches directly, and--localforces in-process.- Axis GUI: each Console tab is a
DaemonHostsession. A tab action, “Continue in terminal”, copiesvox term --attach <id>. The Console session picker lists sessions opened fromvox termas well. - Detach is free: closing a GUI tab or quitting
vox termdetaches the client;term.closeis explicit. Orphaned sessions show up interm.list(shown asattached: 0) and are reaped after a configurable idle TTL.
4. Chat ↔ terminal
Section titled “4. Chat ↔ terminal”- Terminal → chat (context). The orchestrator subscribes to each session’s block
events in-process (it runs in the same daemon). The chat composer’s context bundle
includes recent blocks (
plain_output(), redacted viavox-redact) for sessions linked to the chat. There’s no extra round-trip: this replaces the TS-siderenderBlockForAgentinConsole.tsx. - Chat → terminal (drive). Add a tool,
term_run{session_id, line}, to the orchestrator/MCP tool surface. It routes through the existing MCP approval gate andvox shell checkexec policy, thenterm.submitwithOrigin::Agent. The human sees the command appear live in whichever front-end is attached, tagged as agent-originated. - Human → agent. The existing
/ai/?input intents (InputIntent::Agent) produceAgentMessageevents on the same session, so either front-end can hold the conversation.
5. Security
Section titled “5. Security”term.*is arbitrary code execution as the daemon user. It is never served without the daemon auth token, and it is refused on non-loopback binds, even with an explicit token, until remote attach is designed (§7).- Agent input never reaches a PTY except through
term_runand the approval gate (H8). Over the wire,origin: "agent"is refused on bothterm.inputandterm.submit(a client could otherwise claim any label); the in-processterm_runpath is the only way to act as an agent. Human raw keystrokes (Origin::User) go through ungated, as in any terminal. - The replay buffer can hold secrets typed or printed in the terminal. It stays in daemon memory and is never persisted. The transcript/corpus sink keeps redacting as it does today.
6. Rendering and builds
Section titled “6. Rendering and builds”- GUI: add
@xterm/addon-webgltoTerminalTab.tsx, falling back to the DOM renderer ononContextLoss. This is the VS Code approach: GPU text rendering for the one surface where throughput matters, with the rest of Axis staying web UI. - CLI-only / headless:
vox+vox-termwith no Tauri.LocalHostneeds no daemon;DaemonHostis a client only. No new dependencies or cargo features, and the build-toolchain invariant is unchanged (WebGL comes from the webview, not a native GPU stack). - Engine retirement: once the GUI consumes
term.attach, deleteConsole/osc633.ts(+ tests) andTerminalSessionManager. This is Track 3 of the parity plan. The Rust parser becomes the only OSC-633 implementation.
7. Open questions / later
Section titled “7. Open questions / later”| Item | Default now | Revisit when |
|---|---|---|
| Session survives a daemon restart | No (PTYs die) | Users rely on long-lived sessions; would need a separate vox-term-host process per session (tmux-style) |
| Remote / mesh attach | Refused (loopback only) | A mesh use case exists; needs per-session capability tokens, not the daemon token |
| Resize with several attached clients | Last-active wins | Side-by-side attach becomes common |
| Ring buffer size | 2 MiB | Measured memory pressure with many sessions |
8. Alternatives considered (2026-10-08)
Section titled “8. Alternatives considered (2026-10-08)”- AGPL Warp fork as a separate binary (B). Warp has its own PTY/block/session
model, which would make a second source of truth. Its Agent Mode/Drive call Warp’s
closed backend, so routing them through
vox_actor_runtime::llmmeans permanently carrying patches in Warp’s highest-churn code (app/ai). A Warp window can’t embed in the Tauri webview, so switching wouldn’t be seamless. On top of that, it splits the product licence. Rejected. - GPU-rendered UI on MIT
warpui(C). It wins on frame rate, latency and memory for a terminal-centric product. It loses most of Axis: chat/markdown/dashboard widgets, text shaping/IME, accessibility, Playwright visual verification and the design-token pipeline.warpuiis also an internal framework with no stability promise. Rejected for Axis; GPU rendering is applied to the terminal pane only (§6). - Warp as reference. Blocks, input classification, command signatures and
Agent Mode UX are studied clean-room (gate G-WARP of the parity plan).
warpui_core’s TUI cell-grid (MIT) may be read as a reference forvox-term.
9. Implementation tasks (TDD order)
Section titled “9. Implementation tasks (TDD order)”Each task starts with its failing test (AGENTS.md §Test-First).
- H1.
SessionHost+LocalHost. Move the PTY spawn/read loop fromvox-gui/src/commands/pty.rsintovox-terminal-core. Test:open→submit("!echo hi")→ a block with outputhi;attachon a second handle sees the same block. - H2. Replay ring buffer +
seq. Test: write 3 MiB to a 2 MiB buffer and get back the last 2 MiB; the snapshotseqequals the last emittedseq. - H3.
ExtraDispatch::try_stream+ChainDispatch. Test: a stream-handling extra receivesterm.attachover a real loopback TCP serve loop; a non-stream request still reachesMcpExtraDispatch. Mutation check: delete thetry_streamcall in one serve loop and confirm the test fails. - H4.
TermDispatch+term_methodconstants. Test: each method round-trips overrun_tcp_server_with_extra; a request without a token is rejected;Origin::Agentonterm.inputis rejected. - H5.
DaemonHost. Test: the H1 scenario passes unchanged withDaemonHostagainst an in-test daemon. This is the parity proof between the two hosts. - H6. GUI on
term.attach. Replace thepty_spawnpath; addaddon-webgl; retireosc633.tsandTerminalSessionManager(merges with Track 3). Test: vitest with a mocked snapshot renders blocks; a Playwright spec oninstallTauriMockcaptures the Console with the WebGL renderer intoreview-bundle/latest/. - H7.
vox termauto-detect /--attach/--local. Test: with no daemon it falls back toLocalHost; withTERM=dumbit still starts. - H8.
term_runtool + chat context bundle. Test: an agentterm_runwaits on a pending approval and opens no block until resolved; after approval, the block’s origin isAgent.