Files
hqt/.hq/2026-06-09-spec-hq-terminal.md
T
felixm 90944948f5 Initial commit: hqt — HQ Terminal TUI
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>
2026-06-10 17:06:57 -04:00

22 KiB
Raw Blame History

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

  1. User runs hqt — attaches to (or creates) a dedicated tmux session running the Textual TUI
  2. TUI shows project list → user selects a project → sees its sessions
  3. User selects an existing session → tmux switch-client puts them in the harness pane
  4. User presses a hotkey (e.g., Ctrl-b o or custom prefix) → switches back to the TUI
  5. User can create new sessions, specifying harness type, skills, and MCP servers
  6. 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 systemSkillRegistry, 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 profilesTerminalProfile dataclass, terminal_profiles.json user 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.jsonmcpServers 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:

  1. Check if already inside tmux ($TMUX set)
    • If yes: create a new window in the current tmux session for the TUI
    • If no: create a new tmux session hqt-main and run the TUI inside it
  2. 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 name and description
  • 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)

  1. 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.
  2. tmux topology — Flat: one tmux session per harness session. Naming: hqt-{db_id} (human-readable prefix, unique suffix).
  3. Skills directory — Copy the skills system from HQ; use ~/.config/hqt/skills/ as its own directory (independent from HQ).
  4. Switch-back mechanismhqt sets up a tmux keybinding on first run (default: prefix oswitch-client -t hqt-main). Configurable.
  5. 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.json or ~/.claude/settings.json
  • Skills: Append to CLAUDE.md in 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-history resumes
  • 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: > or aider> prompt

Codex CLI

  • UUID: Codex uses thread IDs internally
  • Model: --model {model} CLI flag
  • MCP: .codex/config.json or --config flag
  • Skills: AGENTS.md in 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.