TUI for orchestrating AI coding harness sessions (Claude Code, Codex, Kiro, etc.) via tmux. Click CLI bootstraps a Textual TUI over ProjectService/SessionService backed by SQLite, spawning harness sessions as tmux windows through TmuxManager. Includes recent fixes: - Visible Tab focus highlight on dialog OK/Cancel buttons - Auto-select first project on launch - Auto-select first session + per-project session-selection memory - tmux new-window targets an explicit free index, fixing "index N in use" failures (broken spawn/attach in attached sessions) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
22 KiB
HQ Terminal — Specification
Vision
HQ Terminal (hqt) is a Textual-based TUI application that orchestrates AI coding harness sessions (Claude Code, Kiro CLI, Codex CLI, Aider, Copilot, etc.) using tmux as the session multiplexer. It replaces HQ's web-based dashboard with a terminal-native workflow where the user navigates projects and sessions in the TUI, then switches directly into harness TUIs running inside tmux.
Core Workflow
- User runs
hqt— attaches to (or creates) a dedicated tmux session running the Textual TUI - TUI shows project list → user selects a project → sees its sessions
- User selects an existing session →
tmux switch-clientputs them in the harness pane - User presses a hotkey (e.g.,
Ctrl-b oor custom prefix) → switches back to the TUI - User can create new sessions, specifying harness type, skills, and MCP servers
- Sessions persist across TUI restarts (tmux keeps them alive)
Architecture
┌──────────────────────────────────────────────────────────┐
│ Any terminal emulator │
└─────────────────────────┬────────────────────────────────┘
│
┌─────────────────────────┴────────────────────────────────┐
│ tmux server │
│ │
│ ┌──────────────┐ ┌────────────────┐ ┌──────────────┐ │
│ │ hqt TUI │ │ claude-code │ │ kiro │ │
│ │ (Textual) │ │ (project-a) │ │ (project-b) │ │
│ │ │ └────────────────┘ └──────────────┘ │
│ │ • Projects │ ┌────────────────┐ │
│ │ • Sessions │ │ aider │ │
│ │ • Config │ │ (project-a) │ │
│ └──────────────┘ └────────────────┘ │
│ │
└───────────────────────────────────────────────────────────┘
What Carries Over from HQ
Reuse directly (copy or extract)
- Skills system —
SkillRegistry, skill discovery from~/.config/hq/skills/*/SKILL.md, frontmatter parsing. Skills are injected as prefixes to prompts sent to harnesses. - MCP server definitions — the concept of globally-defined MCP servers bound to projects. Stored in DB, serialized to harness-specific config formats at spawn time.
- Terminal profiles —
TerminalProfiledataclass,terminal_profiles.jsonuser overrides,DEFAULT_TERMINAL_PROFILES. These define how to spawn/resume each TUI harness. - TmuxRunner — the async tmux command abstraction (new_session, has_session, send_text, send_enter, capture_pane, kill_session, etc.)
- Project model concept — name, path, git branch/dirty state.
Reuse conceptually (reimplement simpler)
- Session model — simplified. No connection_status/activity_status two-axis state. A session is: exists in DB + optionally alive in tmux.
- Harness registry — discovery of available harnesses via
shutil.which. Simplified to just terminal-profile harnesses (no JSON-RPC protocol adapters).
Drop entirely
- FastAPI / HTTP / WebSocket server — no web interface
- React frontend — replaced by Textual
- JSON-RPC adapters (kiro_acp, codex_app_server, copilot_acp) — harnesses run as native TUIs
- SessionEvent / append-only event store — no event sourcing needed
- Event broadcasting / supervisor / pump — tmux IS the supervisor
- Docs panel / todo panel — user has direct terminal access to files
- Session runs / turn tracking — harness manages its own conversation state
Data Model
SQLite database at ~/.local/share/hqt/hqt.db.
Projects
| Column | Type | Notes |
|---|---|---|
| id | INTEGER PK | |
| name | TEXT | Display name (default: directory basename) |
| path | TEXT UNIQUE | Absolute path to project root |
| created_at | TIMESTAMP | |
| last_opened_at | TIMESTAMP | For sort order |
| archived | BOOLEAN | Hide from default list |
Harnesses
| Column | Type | Notes |
|---|---|---|
| id | INTEGER PK | |
| name | TEXT UNIQUE | e.g., "claude-code", "kiro", "aider" |
| display_name | TEXT | |
| command_json | TEXT | JSON array of command + args |
| resume_command_json | TEXT | JSON array, nullable |
| profile_json | TEXT | Full TerminalProfile serialized |
Populated from defaults + user terminal_profiles.json at startup.
Sessions
| Column | Type | Notes |
|---|---|---|
| id | INTEGER PK | |
| project_id | FK → projects | |
| harness_id | FK → harnesses | |
| nickname | TEXT | User-assigned name, nullable |
| tmux_session_name | TEXT UNIQUE | e.g., "hqt-42" |
| harness_session_id | TEXT | The harness's own UUID for resume |
| model | TEXT | Selected model, nullable |
| created_at | TIMESTAMP | |
| last_activity_at | TIMESTAMP | |
| archived | BOOLEAN |
MCP Servers
| Column | Type | Notes |
|---|---|---|
| id | INTEGER PK | |
| name | TEXT UNIQUE | |
| transport | TEXT | "stdio" or "sse" or "http" |
| command | TEXT | For stdio |
| args_json | TEXT | JSON array |
| url | TEXT | For sse/http |
| env_json | TEXT | JSON dict |
| headers_json | TEXT | JSON dict |
Project ↔ MCP Server bindings
| Column | Type | Notes |
|---|---|---|
| project_id | FK | |
| mcp_server_id | FK | |
| (composite PK) |
Project ↔ Skill bindings
| Column | Type | Notes |
|---|---|---|
| project_id | FK | |
| skill_name | TEXT | References skill directory name |
| (composite PK) |
Harness Config Injection
Before spawning a tmux session, hqt writes harness-specific config files so the harness picks up MCP servers and skills. This is the key abstraction layer.
class HarnessConfigurator(Protocol):
def prepare(self, project: Project, session: Session, mcps: list[McpServer], skills: list[Skill]) -> SpawnConfig:
"""Write config files, return the command and env to spawn."""
...
@dataclass
class SpawnConfig:
command: list[str]
env: dict[str, str]
cwd: Path
cleanup: Callable[[], None] | None # remove temp config after session ends
Per-harness injection strategies
| Harness | MCP Config | Skills/System Prompt |
|---|---|---|
| Claude Code | ~/.claude/settings.json → mcpServers section, or project .mcp.json |
CLAUDE.md in project root (append skill content) |
| Kiro CLI | Not applicable in TUI mode (Kiro manages own MCP) | Custom instructions file |
| Aider | --read flag for context files |
Prepend to first message or .aider.conf.yml |
| Codex | .codex/config.json or env vars |
AGENTS.md in project root |
| Generic | Env vars if supported | Prepend to first send_text |
TUI Layout (Textual)
Main Screen — Session Navigator
┌─────────────────────────────────────────────────────────┐
│ HQ Terminal [?] Help [q] │
├───────────────────┬─────────────────────────────────────┤
│ PROJECTS │ SESSIONS (project-a) │
│ │ │
│ > project-a │ ● claude-code "feature-xyz" alive │
│ project-b │ ○ kiro "refactor" dead │
│ project-c │ ● aider "bugfix-123" alive │
│ │ │
│ [a] Add project │ [n] New session [d] Delete │
│ │ [Enter] Attach [r] Rename │
└───────────────────┴─────────────────────────────────────┘
│ Status: 3 projects, 5 sessions (3 alive) │
└─────────────────────────────────────────────────────────┘
New Session Dialog
┌─── New Session ────────────────────────┐
│ Project: project-a │
│ Harness: [claude-code ▼] │
│ Name: [________________] │
│ Skills: [brainstorming, workflow] │
│ MCPs: [hq-meta, custom-api] │
│ │
│ [Create] [Cancel] │
└─────────────────────────────────────────┘
Key Bindings (Navigator)
| Key | Action |
|---|---|
j/k or ↑/↓ |
Navigate lists |
Enter |
Attach to selected session (switch tmux client) |
n |
New session dialog |
a |
Add project |
d |
Delete/archive session |
r |
Rename session |
Tab |
Switch focus between project/session panels |
q |
Quit TUI (sessions stay alive in tmux) |
/ |
Filter/search |
tmux Integration
Session Naming Convention
hqt-{session_db_id} — e.g., hqt-42. Deterministic, avoids collisions.
Startup Behavior
When hqt launches:
- Check if already inside tmux (
$TMUXset)- If yes: create a new window in the current tmux session for the TUI
- If no: create a new tmux session
hqt-mainand run the TUI inside it
- TUI attaches and displays the navigator
Switching to a Session
async def attach_session(tmux_session_name: str):
# From within tmux, switch the client to the target session
await tmux_runner.switch_client(tmux_session_name)
Switching Back to TUI
Option A: tmux keybinding — bind a key in tmux.conf to switch-client -t hqt-main
Option B: hqt installs a tmux hook or provides a helper script hqt-back
Recommended: Option A with auto-setup. On first run, hqt suggests adding to ~/.tmux.conf:
bind o switch-client -t hqt-main
Liveness Detection
To show alive/dead status for sessions:
async def is_session_alive(tmux_session_name: str) -> bool:
return await tmux_runner.has_session(tmux_session_name)
Polled periodically (every 2-5s) or on TUI focus.
Skills System
Identical to HQ's current system:
- Skills live in
~/.config/hq/skills/{name}/SKILL.md - Frontmatter provides
nameanddescription - Content is the full skill text
- Skills are bound to projects (stored in DB)
- At session spawn, bound skills are injected per the harness configurator
MCP Server Management
- Global MCP server definitions stored in DB
- Bound to projects via junction table
- At session spawn, the harness configurator writes bound MCPs into the harness-specific config format
- UI provides CRUD for MCP servers and project bindings
Configuration
Config file at ~/.config/hqt/config.toml:
[general]
# Path to tmux binary
tmux_path = "tmux"
# TUI session name
tui_session_name = "hqt-main"
[harnesses]
# Override harness paths
claude_path = "claude"
kiro_path = "kiro"
[shortcuts]
# tmux key to switch back to TUI
switch_back_key = "o"
CLI Interface
hqt # Launch TUI (default)
hqt list # List projects and sessions (for scripting)
hqt attach <session> # Attach to a session by name/id
hqt new <project> <harness> # Create session non-interactively
hqt doctor # Check tmux, harness availability
Sandboxing (Future — v2)
Design hook for bubblewrap isolation:
@dataclass
class SandboxConfig:
enabled: bool = False
read_only_root: bool = True # --ro-bind / /
project_writable: bool = True # --bind project_dir project_dir
network: bool = True # allow network access
extra_binds: list[tuple[str, str]] = field(default_factory=list)
def wrap_command(cmd: list[str], project_dir: Path, sandbox: SandboxConfig) -> list[str]:
if not sandbox.enabled:
return cmd
bwrap = ["bwrap"]
if sandbox.read_only_root:
bwrap += ["--ro-bind", "/", "/"]
if sandbox.project_writable:
bwrap += ["--bind", str(project_dir), str(project_dir)]
bwrap += ["--dev", "/dev", "--proc", "/proc"]
if not sandbox.network:
bwrap += ["--unshare-net"]
for src, dst in sandbox.extra_binds:
bwrap += ["--bind", src, dst]
bwrap += ["--"]
return bwrap + cmd
Package Structure
hqt/
├── pyproject.toml
├── src/
│ └── hqt/
│ ├── __init__.py
│ ├── __main__.py # python -m hqt
│ ├── cli.py # click/typer CLI entry points
│ ├── config.py # Settings from config.toml + env
│ ├── db/
│ │ ├── models.py # SQLAlchemy models
│ │ ├── session.py # Engine/session factory
│ │ └── migrate.py # Schema evolution
│ ├── tui/
│ │ ├── app.py # Textual App subclass
│ │ ├── screens/
│ │ │ ├── navigator.py # Main project/session navigator
│ │ │ └── new_session.py # New session modal
│ │ ├── widgets/
│ │ │ ├── project_list.py
│ │ │ ├── session_list.py
│ │ │ └── status_bar.py
│ │ └── styles.tcss # Textual CSS
│ ├── tmux/
│ │ ├── runner.py # TmuxRunner (from HQ)
│ │ └── manager.py # Session lifecycle (spawn, attach, kill)
│ ├── harnesses/
│ │ ├── registry.py # Available harnesses discovery
│ │ ├── profiles.py # TerminalProfile + loading
│ │ └── configurators/
│ │ ├── base.py # Protocol + SpawnConfig
│ │ ├── claude.py # Claude Code config injection
│ │ ├── kiro.py # Kiro config injection
│ │ ├── aider.py # Aider config injection
│ │ └── generic.py # Fallback (env vars only)
│ ├── skills/
│ │ ├── registry.py # From HQ
│ │ └── injection.py # From HQ
│ ├── mcp/
│ │ ├── service.py # CRUD
│ │ └── serializers.py # Per-harness MCP config writers
│ └── projects/
│ └── service.py # Project CRUD + git status
└── tests/
Tech Stack
- Python ≥ 3.12
- Textual (TUI framework)
- SQLAlchemy (database)
- SQLite (storage)
- Click or Typer (CLI)
- uv (package management)
- pytest (testing)
Non-Goals (explicitly out of scope)
- Streaming harness responses back to the TUI (user interacts directly in tmux)
- Turn tracking / event sourcing
- Token usage tracking (harness-native UIs show this)
- Web interface
- Remote/multiplexer access beyond tmux's native capabilities
- Real-time output mirroring in the navigator (user attaches to see output)
Decisions (from brainstorm)
- Session resume after reboot — Yes, fully supported. Each session stores the harness's own session UUID so resume can reconnect to the same conversation. If a tmux session is dead but the DB record exists, the TUI offers to restart it using the harness's
resume_command+ stored UUID. - tmux topology — Flat: one tmux session per harness session. Naming:
hqt-{db_id}(human-readable prefix, unique suffix). - Skills directory — Copy the skills system from HQ; use
~/.config/hqt/skills/as its own directory (independent from HQ). - Switch-back mechanism —
hqtsets up a tmux keybinding on first run (default:prefix o→switch-client -t hqt-main). Configurable. - Skill injection timing — Skills are injected at spawn time. Mid-session skill changes require session restart for v1.
Harness Abstraction Layer (Critical Design)
This is the most important abstraction in the system. Every harness has its own way of handling session identity, config injection, model selection, and MCP setup. The abstraction must normalize these.
What the abstraction manages per-harness
| Concern | Description |
|---|---|
| Session UUID | Each harness has its own persistent session/conversation ID. Must be stored in DB and passed on start/resume. |
| Spawn command | How to start a fresh session (with UUID, model, cwd) |
| Resume command | How to reconnect to an existing conversation after tmux death |
| Model selection | How to specify which model to use (CLI flag, env var, config file) |
| MCP injection | How to configure MCP servers for this harness (config file path + format) |
| Skill injection | How to provide system prompt / skill content (CLAUDE.md, AGENTS.md, etc.) |
| Ready detection | How to know the harness TUI is ready to accept input (regex on screen) |
| Cancel mechanism | How to interrupt a running turn (Ctrl-C, etc.) |
Protocol definition
from dataclasses import dataclass, field
from pathlib import Path
from typing import Protocol
@dataclass
class SpawnConfig:
"""Everything needed to launch a harness session in tmux."""
command: list[str] # Full command to execute
env: dict[str, str] # Extra environment variables
cwd: Path # Working directory
tmux_width: int = 120
tmux_height: int = 40
@dataclass
class HarnessIdentity:
"""Persistent identity for a harness session."""
harness_session_id: str # The harness's own UUID/session identifier
metadata: dict = field(default_factory=dict) # Harness-specific extras
class HarnessConfigurator(Protocol):
"""Abstraction for harness-specific configuration and lifecycle."""
name: str # e.g., "claude-code"
display_name: str # e.g., "Claude Code"
def generate_session_id(self, hqt_session_id: int) -> str:
"""Generate a deterministic harness session UUID for a new session."""
...
def build_spawn_config(
self,
project_path: Path,
harness_session_id: str,
model: str | None = None,
mcps: list[McpServerConfig] | None = None,
skills: list[Skill] | None = None,
) -> SpawnConfig:
"""Prepare config files and return the spawn command."""
...
def build_resume_config(
self,
project_path: Path,
harness_session_id: str,
model: str | None = None,
mcps: list[McpServerConfig] | None = None,
skills: list[Skill] | None = None,
) -> SpawnConfig:
"""Prepare config for resuming an existing conversation."""
...
@property
def supported_models(self) -> list[str]:
"""Models this harness supports (for UI dropdown)."""
...
@property
def ready_regex(self) -> str | None:
"""Regex to detect when the harness TUI is ready for input."""
...
@property
def busy_regex(self) -> str | None:
"""Regex to detect when the harness is actively processing."""
...
Per-harness implementation notes
Claude Code
- UUID:
--session-id {uuid}on start,--resume {uuid}on resume - UUID generation:
uuid5(namespace, f"hqt-{session_id}")— deterministic from DB id - Model:
--model {model}CLI flag - MCP: Write to project
.mcp.jsonor~/.claude/settings.json - Skills: Append to
CLAUDE.mdin project root (or create it) - Ready regex:
❯(the input prompt marker) - Busy regex:
esc to interrupt
Kiro CLI
- UUID: Kiro manages its own sessions under
~/.kiro/sessions/cli/ - Model: Config file or CLI flag (TBD based on Kiro TUI mode)
- MCP: Kiro manages its own MCP in TUI mode
- Skills: Custom instructions mechanism
- Ready regex: TBD
Aider
- UUID: Aider uses git repo as implicit session;
--restore-chat-historyresumes - Model:
--model {model}CLI flag - MCP: Not applicable (Aider doesn't support MCP)
- Skills:
--read {file}for context files, or prepend to first message - Ready regex:
>oraider>prompt
Codex CLI
- UUID: Codex uses thread IDs internally
- Model:
--model {model}CLI flag - MCP:
.codex/config.jsonor--configflag - Skills:
AGENTS.mdin project root - Ready regex: TBD
Session state management
The DB stores the harness session UUID. tmux liveness is the source of truth for "is it running":
@dataclass
class SessionState:
db_exists: bool # Row in sessions table
tmux_alive: bool # tmux has-session returns 0
harness_session_id: str # Stored UUID for resume
@property
def status(self) -> str:
if not self.db_exists:
return "unknown"
if self.tmux_alive:
return "alive"
return "dead" # Can be resumed
The TUI polls tmux liveness periodically (every 3s) and updates the display. When a session transitions from alive → dead unexpectedly, the TUI can notify the user and offer restart.