How Slash Commands Are Routed in the Hermes TUI Gateway
The article explains the design of Hermes' terminal UI gateway, detailing why the TUI does not import the Agent directly, how JSON‑RPC over stdio or WebSocket enables local and remote modes, and how slash commands are processed through a two‑layer routing system with a dedicated _SlashWorker subprocess and a pure‑function crash‑recovery plan.
01 | Background: Why TUI Doesn't Import Agent Directly
A naive implementation would import the Agent code into the TUI process, but Hermes needs to support both local mode (TUI on the user’s machine) and remote mode (Gateway on a server). Direct import would break remote mode. Mixing I/O‑heavy UI work with compute‑heavy LLM calls also risks a single‑process crash. Protocolizing the interaction lets TUI and Gateway be tested independently, with planGatewayRecovery as a pure, zero‑dependency function.
02 | Industry Research: UI‑Core Separation in Terminal AI Tools
Hermes follows patterns used by other tools:
LSP (Language Server Protocol) : VS Code separates editor UI from language services via JSON‑RPC over stdio.
DAP (Debug Adapter Protocol) : Debugger UI is separated; events are pushed with an event message, similar to Hermes' gateway event push.
Claude Code : Packs CLI and UI in one Node process, which is simple but cannot be remote or independently tested.
Hermes Choice : Implements a dual‑mode JSON‑RPC protocol that works over stdin/stdout for local development and WebSocket for remote dashboards.
03 | Design Conclusion: JSON‑RPC over stdio + Dual‑Mode Connection
Core principles:
Uniform protocol format – request, response, and event messages follow JSON‑RPC 2.0.
Automatic connection mode – if HERMES_TUI_GATEWAY_URL is set, TUI connects via WebSocket; otherwise it spawns a local Gateway subprocess.
Two‑layer slash command routing – UI‑layer commands (/help, /quit, /model, /mouse) are handled inside the TUI process; Agent‑layer commands (/compact, plugins, etc.) are sent to the Gateway via slash.exec.
04 | Dual‑Mode Connection: Local Spawn vs Remote Attach
The TUI side uses GatewayClient (src/gatewayClient.ts) to manage communication:
export class GatewayClient {
private _ws: WebSocket | null = null
private _proc: ChildProcess | null = null
async connect() {
const remoteUrl = process.env.HERMES_TUI_GATEWAY_URL
if (remoteUrl) {
// Remote mode: attach via WebSocket
this._ws = new WebSocket(remoteUrl)
await this._waitForOpen()
} else {
// Local mode: spawn Python subprocess and use stdio transport
this._proc = spawn('python', ['-m', 'tui_gateway.entry'], {
stdio: ['pipe', 'pipe', 'pipe'],
})
this._setupStdioTransport(this._proc)
}
}
// Unified RPC method, works for both WS and stdio
rpc<T>(method: string, params: object): Promise<T> {
return new Promise((resolve, reject) => {
const id = ++this._seq
this._pending.set(id, { resolve, reject })
this._send({ jsonrpc: '2.0', id, method, params })
})
}
}The Gateway side reads JSON‑RPC lines from stdin, dispatches them, and writes responses to stdout.
05 | Two‑Layer Slash Command Routing
When the TUI receives input, it first checks if the text starts with a slash:
export function looksLikeSlashCommand(text: string): boolean {
return text.trimStart().startsWith('/')
}
export function parseSlashCommand(text: string): { name: string; arg: string } {
const trimmed = text.trim().slice(1)
const [name, ...rest] = trimmed.split(/\s+/)
return { name: name.toLowerCase(), arg: rest.join(' ') }
}Parsed commands are looked up in SLASH_COMMANDS (registry.ts). Local commands are listed in LOCAL_COMMANDS and handled directly; all other commands are forwarded to the Gateway via slash.exec.
Gateway enforces a whitelist:
Skill commands – rejected with error 4018, must use command.dispatch.
Pending‑input commands (/y, /n, etc.) – also rejected, intended for Agent confirmation.
Plugin commands – allowed, routed to the plugin handler.
Regular commands (/compact, /clear, …) – sent to the _SlashWorker subprocess.
06 | _SlashWorker: Dedicated Subprocess Design
_SlashWorkerruns as a separate Python process per session, isolating potentially slow slash commands (e.g., /compact that triggers an LLM call) from the main Gateway loop.
Communication protocol (JSON‑RPC line with id and command) and timeout handling are implemented as follows:
def run(self, command: str) -> str:
if self.proc.poll() is not None:
raise RuntimeError("slash worker exited")
with self._lock:
self._seq += 1
rid = self._seq
self.proc.stdin.write(json.dumps({"id": rid, "command": command}) + "
")
self.proc.stdin.flush()
while True:
try:
msg = self.stdout_queue.get(timeout=_SLASH_WORKER_TIMEOUT_S)
except queue.Empty:
raise RuntimeError("slash worker timed out")
if msg is None:
break
if msg.get("id") != rid:
continue
if not msg.get("ok"):
raise RuntimeError(msg.get("error", "slash worker failed"))
return str(msg.get("output", "")).rstrip()Timeout defaults to 45 seconds (minimum 5 seconds) and is configurable via HERMES_TUI_SLASH_TIMEOUT_S. If a timeout occurs, the Gateway does not kill the worker; it simply stops waiting for that request.
07 | Crash Self‑Healing: planGatewayRecovery
planGatewayRecoveryis a pure function that decides whether to restart a crashed Gateway and which session to resume. It limits automatic restarts to three attempts within a 60‑second sliding window.
export const GATEWAY_RECOVERY_LIMIT = 3
export const GATEWAY_RECOVERY_WINDOW_MS = 60_000
export function planGatewayRecovery(liveSid: null | string, recoverSid: null | string, attempts: number[], now: number): RecoveryPlan {
const sid = liveSid ?? recoverSid
const recent = attempts.filter(t => now - t < GATEWAY_RECOVERY_WINDOW_MS)
const recover = Boolean(sid) && recent.length < GATEWAY_RECOVERY_LIMIT
return {
attempts: recover ? [...recent, now] : recent,
recover,
sid,
}
}Test cases illustrate the budget logic and crash‑loop handling.
Common Pitfalls
Pitfall 1 : Sending local slash commands (e.g., /model, /sessions) through slash.exec yields error 4018. Use the LOCAL_COMMANDS list.
Pitfall 2 : slash.exec runs in _SlashWorker and is meant for output‑type commands; command.dispatch handles state‑changing commands.
Pitfall 3 : A worker timeout does not kill the process; the worker remains alive and may be reused for subsequent commands.
Pitfall 4 : In remote mode, HERMES_TUI_GATEWAY_URL must be a plain WebSocket endpoint (e.g., ws://host:8765) without a path suffix.
Summary
Hermes TUI Gateway’s core design is "protocol first, connection mode later": a single JSON‑RPC definition works over stdio for local development and WebSocket for remote dashboards, slash commands are split between fast UI‑side handling and full Gateway processing via a dedicated subprocess, and crash recovery is expressed as a testable pure function with a 60 s/3‑attempt budget. This architecture enables one‑click local startup and reliable long‑running server deployment.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
James' Growth Diary
I am James, focusing on AI Agent learning and growth. I continuously update two series: “AI Agent Mastery Path,” which systematically outlines core theories and practices of agents, and “Claude Code Design Philosophy,” which deeply analyzes the design thinking behind top AI tools. Helping you build a solid foundation in the AI era.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
