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>
This commit is contained in:
2026-06-10 17:06:57 -04:00
commit 90944948f5
61 changed files with 8834 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
__pycache__/
*.egg-info/
.venv/
dist/
.ruff_cache/
*.pyc
.pytest_cache/
+247
View File
@@ -0,0 +1,247 @@
# HQ Terminal — Implementation Plan
Reference spec: `.hq/2026-06-09-spec-hq-terminal.md`
## Phase 1: Project Scaffold
Set up the new workspace with tooling, dependencies, and basic structure.
### Steps
1. **Create workspace** at `~/dev/hqt/` with `uv init --lib --name hqt`
2. **Configure pyproject.toml**:
- Python ≥ 3.12
- Dependencies: `textual>=2.0`, `sqlalchemy>=2.0`, `click>=8.0`
- Dev dependencies: `pytest`, `pytest-asyncio`, `ruff`
- Entry point: `[project.scripts] hqt = "hqt.cli:main"`
3. **Create package structure** per spec: `src/hqt/` with all subdirectories
4. **Create `.hq/PROJECT.md`** for the new project
5. **Verify**: `uv run hqt --help` produces output
## Phase 2: Database Layer
SQLite models, engine, and schema management.
### Steps
1. **`src/hqt/config.py`** — Settings dataclass with paths:
- `db_path`: `~/.local/share/hqt/hqt.db`
- `config_dir`: `~/.config/hqt/`
- `skills_dir`: `~/.config/hqt/skills/`
- `tmux_path`: `tmux`
- `tui_session_name`: `hqt-main`
2. **`src/hqt/db/models.py`** — SQLAlchemy models: `Project`, `Harness`, `Session`, `McpServer`, `ProjectMcpServer`, `ProjectSkill`
3. **`src/hqt/db/session.py`** — Engine factory, session factory, `ensure_db()` (creates dir + tables)
4. **`src/hqt/db/migrate.py`** — Additive migration helper (add missing columns)
5. **Tests**: model creation, ensure_db on fresh + existing databases
6. **Verify**: `uv run pytest tests/test_db.py -q` passes
## Phase 3: TmuxRunner
The async tmux command abstraction. Largely ported from HQ.
### Steps
1. **`src/hqt/tmux/runner.py`** — Port `TmuxRunner` from HQ:
- `new_session(name, cwd, command, width, height)`
- `has_session(name) -> bool`
- `kill_session(name)`
- `send_text(name, text)`
- `send_enter(name)`
- `capture_pane(name) -> str`
- `switch_client(target)` ← NEW (not in HQ)
- `list_sessions() -> list[str]` ← NEW
2. **`src/hqt/tmux/manager.py`** — Higher-level session lifecycle:
- `spawn_session(session: Session, config: SpawnConfig) -> None`
- `resume_session(session: Session, config: SpawnConfig) -> None`
- `kill_session(session: Session) -> None`
- `attach_session(session: Session) -> None` (switch-client)
- `is_alive(session: Session) -> bool`
- `poll_all_sessions(sessions: list[Session]) -> dict[int, bool]`
3. **Tests**: mock `_exec` function, verify command construction
4. **Verify**: `uv run pytest tests/test_tmux.py -q` passes
## Phase 4: Harness Abstraction Layer
The configurator protocol and concrete implementations.
### Steps
1. **`src/hqt/harnesses/base.py`** — `HarnessConfigurator` protocol, `SpawnConfig`, `HarnessIdentity` dataclasses
2. **`src/hqt/harnesses/claude.py`** — Claude Code configurator:
- UUID generation via `uuid5`
- Spawn: `claude --session-id {uuid} --model {model}`
- Resume: `claude --resume {uuid}`
- MCP: write project `.mcp.json`
- Skills: write/append `CLAUDE.md`
- Ready/busy regexes from terminal profiles
3. **`src/hqt/harnesses/kiro.py`** — Kiro CLI configurator (stub for now):
- Basic spawn command
- MCP: Kiro-managed (no injection in v1)
4. **`src/hqt/harnesses/aider.py`** — Aider configurator:
- Spawn: `aider --model {model} --restore-chat-history`
- Skills: `--read {skill_file}` flags
- No MCP support
5. **`src/hqt/harnesses/generic.py`** — Fallback for unknown harnesses:
- Env-var based config only
6. **`src/hqt/harnesses/registry.py`** — Discover available harnesses (`shutil.which`), return configurator instances
7. **Tests**: each configurator produces correct SpawnConfig for given inputs
8. **Verify**: `uv run pytest tests/test_harnesses.py -q` passes
## Phase 5: Skills & MCP Services
Skill registry and MCP server management.
### Steps
1. **`src/hqt/skills/registry.py`** — Port from HQ: scan `skills_dir`, parse frontmatter, return `Skill` objects
2. **`src/hqt/skills/injection.py`** — Port from HQ: `wrap_prompt_with_skills()`
3. **`src/hqt/mcp/service.py`** — CRUD for MCP servers and project bindings (DB operations)
4. **`src/hqt/projects/service.py`** — Project CRUD: add, list, archive, git status reading
5. **Tests**: skill discovery with fixture directory, MCP CRUD, project CRUD
6. **Verify**: `uv run pytest tests/test_skills.py tests/test_mcp.py tests/test_projects.py -q` passes
## Phase 6: Session Service (Orchestration Core)
The service that ties together DB, tmux, and harness configurators.
### Steps
1. **`src/hqt/sessions/service.py`** — Session lifecycle:
- `create_session(project_id, harness_name, nickname, model, skill_names, mcp_ids) -> Session`
- Generates harness UUID via configurator
- Builds SpawnConfig
- Calls tmux manager to spawn
- Persists session row
- `resume_session(session_id) -> None`
- Loads session from DB
- Builds resume SpawnConfig with stored UUID
- Calls tmux manager to spawn
- `stop_session(session_id) -> None`
- Kills tmux session
- Updates DB (last_activity_at)
- `delete_session(session_id) -> None`
- Kills tmux if alive
- Removes DB row
- `attach_session(session_id) -> None`
- Calls tmux switch-client
- `list_sessions(project_id) -> list[SessionInfo]`
- Joins DB data with tmux liveness
2. **Tests**: mock tmux manager, verify session lifecycle
3. **Verify**: `uv run pytest tests/test_sessions.py -q` passes
## Phase 7: Textual TUI
The user interface.
### Steps
1. **`src/hqt/tui/app.py`** — Textual `App` subclass:
- Bindings: q=quit, n=new session, a=add project, /=filter
- Compose: Header, ProjectList, SessionList, Footer
- Timer: poll tmux liveness every 3s
2. **`src/hqt/tui/widgets/project_list.py`** — ListView of projects:
- Shows name, path, branch
- Selection updates session list
- Keybindings: Enter=select, a=add, d=archive
3. **`src/hqt/tui/widgets/session_list.py`** — ListView of sessions for selected project:
- Shows harness icon, nickname, alive/dead status
- Keybindings: Enter=attach, n=new, d=delete, r=rename, R=resume (if dead)
4. **`src/hqt/tui/screens/new_session.py`** — Modal screen:
- Harness selector (dropdown)
- Nickname input
- Model selector (populated from configurator.supported_models)
- Skills multi-select
- MCP servers multi-select
- Create / Cancel buttons
5. **`src/hqt/tui/screens/add_project.py`** — Modal:
- Path input (with directory completion if feasible, or paste)
- Name override (optional)
6. **`src/hqt/tui/styles.tcss`** — Textual CSS for layout
7. **Integration test**: App mounts, projects display, key bindings respond
8. **Verify**: `uv run pytest tests/test_tui.py -q` passes (headless Textual testing)
## Phase 8: CLI Entry Point & tmux Bootstrap
Wire everything together.
### Steps
1. **`src/hqt/cli.py`** — Click CLI:
- `hqt` (default) — bootstrap into tmux + launch TUI
- `hqt list` — print projects/sessions table
- `hqt attach <id>` — switch to session
- `hqt new <project> <harness>` — create session non-interactively
- `hqt doctor` — check tmux, harness availability
2. **`src/hqt/__main__.py`** — `python -m hqt` support
3. **tmux bootstrap logic**:
```python
def bootstrap():
if not in_tmux():
# Create hqt-main session with TUI as the command
exec tmux new-session -s hqt-main "hqt --inside-tmux"
else:
# Already in tmux, just run the TUI
run_tui()
```
4. **First-run setup**: suggest tmux keybinding, create config dir/skills dir
5. **Verify**: `hqt doctor` works, `hqt` launches TUI inside tmux
## Phase 9: Integration Testing & Polish
### Steps
1. **End-to-end test**: create project → create session → verify tmux session exists → attach → kill → verify dead
2. **Handle edge cases**:
- tmux not installed → clear error
- Harness not found → show in UI, disable create
- DB locked → retry pattern
- Session name collision → fail gracefully
3. **README.md** with quick start
4. **Verify**: full test suite passes, manual smoke test with claude-code
## Dependency Graph
```
Phase 1 (scaffold)
├── Phase 2 (database)
│ │
│ ├── Phase 5 (skills + MCP)
│ │ │
│ │ └──────┐
│ │ │
│ └── Phase 6 (session service) ◄── Phase 4 (harness abstraction)
│ │
├── Phase 3 (tmux runner) ──────────────┘
│ │
│ ▼
└── Phase 7 (TUI) ◄──────────── Phase 6
Phase 8 (CLI + bootstrap)
Phase 9 (integration + polish)
```
## Estimated Effort
| Phase | Complexity | Approx. LOC |
|-------|-----------|-------------|
| 1. Scaffold | Low | ~50 (config) |
| 2. Database | Low | ~150 |
| 3. TmuxRunner | Low | ~120 (mostly port) |
| 4. Harness abstraction | Medium | ~300 |
| 5. Skills + MCP | Low | ~150 (mostly port) |
| 6. Session service | Medium | ~200 |
| 7. TUI | Medium-High | ~400 |
| 8. CLI + bootstrap | Low | ~100 |
| 9. Integration | Medium | ~150 |
| **Total** | | **~1,620** |
## Implementation Order (sequential for a single agent)
1. Phase 1 → 2. Phase 2 → 3. Phase 3 → 4. Phase 4 → 5. Phase 5 → 6. Phase 6 → 7. Phase 7 → 8. Phase 8 → 9. Phase 9
Each phase produces a working, testable increment. The TUI (Phase 7) is the largest single piece but depends on everything below it being solid.
+542
View File
@@ -0,0 +1,542 @@
# 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 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** — `TerminalProfile` 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.
```python
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:
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
```python
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:
```python
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`:
```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:
```python
@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 mechanism**`hqt` sets up a tmux keybinding on first run (default: `prefix o``switch-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
```python
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":
```python
@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.
+12
View File
@@ -0,0 +1,12 @@
# Lessons
## 2026-06-09: tmux windows vs sessions
- **Symptom:** Enter/Shift+R on sessions did nothing; `tmux list-sessions` only showed main session
- **Cause:** Harness sessions were separate tmux sessions. `switch-client` moves the entire client away. Sessions died silently with no error feedback.
- **Fix:** Redesigned to use windows within a single `hqt-main` session. `select-window` stays in context. `remain-on-exit` + `respawn-pane` handles dead processes cleanly.
- **Signal:** If "attach" ever silently fails again, check: does the tmux target actually exist? Is the error being swallowed?
## General
- tmux `remain-on-exit` must be set BEFORE the command can exit (race condition if set after `new-session`/`new-window`)
- `respawn-pane -k` restarts a dead pane in-place without destroying the window — preferred over kill+recreate
- Always check and propagate tmux command return codes; silent failures are the worst debugging experience
+1
View File
@@ -0,0 +1 @@
3.14
+37
View File
@@ -0,0 +1,37 @@
# hqt — HQ Terminal
A TUI for orchestrating AI coding harness sessions (Claude Code, Kiro, Aider, etc.) via tmux.
Manage multiple AI sessions across projects from a single terminal interface.
## Quick Start
```bash
uv pip install -e .
hqt
```
## Commands
| Command | Description |
|---------|-------------|
| `hqt` | Launch the TUI (inside tmux) |
| `hqt doctor` | Check system requirements |
| `hqt list` | List projects and sessions |
## Key Bindings
| Key | Action |
|-----|--------|
| `q` | Quit |
| `n` | New session |
| `a` | Add project |
| `e` | Edit project |
| `Tab` | Switch panel |
| `d` | Delete session |
| `Enter` | Attach to session (auto-resumes if not running) |
| `r` | Rename session |
| `s` | Stop session |
## Architecture
hqt uses a layered architecture: a Click CLI bootstraps into a Textual TUI, which drives service classes (ProjectService, SessionService) backed by SQLite via SQLAlchemy. Sessions are spawned as tmux windows through TmuxManager, with harness-specific configuration provided by pluggable HarnessConfigurator implementations discovered at runtime.
@@ -0,0 +1,808 @@
# Project Editing + Frappé Theme Completion Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Let users edit a project's name and path from the TUI, and make the Catppuccin Frappé theme actually look like Frappé (full theme definition, styled dialogs/headers, colored status symbols).
**Architecture:** `ProjectService` gains an `update` method (unique-path violations surface as `ValueError`). The existing `AddProjectScreen` is generalized into `ProjectFormScreen` with optional pre-filled values, used by both the `a` (add) and new `e` (edit) bindings. The half-specified `FRAPPE_THEME` is replaced with a full definition mirroring Textual's built-in `catppuccin-mocha` structure, plus a `styles.tcss` pass and Rich-markup status colors.
**Tech Stack:** Python 3.12, Textual 8.x, SQLAlchemy 2.x, pytest + pytest-asyncio, `uv` for everything (`uv run pytest ...`).
**Spec:** `docs/superpowers/specs/2026-06-09-project-edit-frappe-theme-design.md`
**Known bug fixed in Task 7:** session labels currently pass `[claude]` to `Label`, which parses it as a Rich markup tag and silently swallows the harness name. Verified by experiment: `Label('x [claude] y')` renders as `'x y'`. The fix escapes the brackets.
---
### Task 1: `ProjectService.update`
**Files:**
- Modify: `src/hqt/projects/service.py`
- Test: `tests/test_services.py`
- [ ] **Step 1: Write the failing tests**
Add to the `TestProjectService` class in `tests/test_services.py`:
```python
def test_update_name_and_path(self, db):
svc = ProjectService(db)
p = svc.create("old", "/old/path")
updated = svc.update(p.id, "new", "/new/path")
assert updated.name == "new"
assert updated.path == "/new/path"
assert svc.get(p.id).path == "/new/path"
def test_update_duplicate_path_raises(self, db):
svc = ProjectService(db)
svc.create("a", "/a")
p2 = svc.create("b", "/b")
with pytest.raises(ValueError, match="already uses path"):
svc.update(p2.id, "b", "/a")
# DB session must remain usable after rollback, values unchanged
assert svc.get(p2.id).path == "/b"
def test_update_unknown_id_raises(self, db):
svc = ProjectService(db)
with pytest.raises(ValueError, match="not found"):
svc.update(9999, "x", "/x")
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `uv run pytest tests/test_services.py -v -k update`
Expected: 3 FAILED with `AttributeError: 'ProjectService' object has no attribute 'update'`
- [ ] **Step 3: Implement `update`**
In `src/hqt/projects/service.py`, add the import at the top:
```python
from sqlalchemy.exc import IntegrityError
```
Add this method to `ProjectService` (after `get`, before `archive`):
```python
def update(self, project_id: int, name: str, path: str) -> Project:
project = self.get(project_id)
if project is None:
raise ValueError(f"Project {project_id} not found")
project.name = name
project.path = path
try:
self.db.commit()
except IntegrityError as err:
self.db.rollback()
raise ValueError(f"Another project already uses path {path}") from err
self.db.refresh(project)
return project
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `uv run pytest tests/test_services.py -v`
Expected: all PASS (including the pre-existing tests)
- [ ] **Step 5: Commit**
```bash
git add src/hqt/projects/service.py tests/test_services.py
git commit -m "feat: ProjectService.update with duplicate-path ValueError"
```
---
### Task 2: `ProjectFormScreen` (rename `AddProjectScreen`, add pre-fill)
**Files:**
- Rename: `src/hqt/tui/screens/add_project.py``src/hqt/tui/screens/project_form.py`
- Modify: `src/hqt/tui/app.py` (import at line 17, `action_add_project` at lines 107114)
- Test: `tests/test_tui.py`
- [ ] **Step 1: Write the failing tests**
Add to `tests/test_tui.py`:
```python
# ---------------------------------------------------------------------------
# ProjectFormScreen: shared add/edit form
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_project_form_prefills_initial_values():
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
from hqt.tui.screens.project_form import ProjectFormScreen
from textual.widgets import Input
app.push_screen(
ProjectFormScreen(
title="Edit Project",
initial_name="myproj",
initial_path="/tmp/myproj",
)
)
await pilot.pause()
assert app.screen.query_one("#name-input", Input).value == "myproj"
assert app.screen.query_one("#path-input", Input).value == "/tmp/myproj"
@pytest.mark.asyncio
async def test_project_form_add_mode_defaults_name_to_basename():
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
from hqt.tui.screens.project_form import ProjectFormScreen
from textual.widgets import Button, Input
results = []
app.push_screen(ProjectFormScreen(), results.append)
await pilot.pause()
app.screen.query_one("#path-input", Input).value = "/tmp/somerepo"
app.screen.query_one("#ok-btn", Button).press()
await pilot.pause()
assert results == [("somerepo", "/tmp/somerepo")]
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `uv run pytest tests/test_tui.py -v -k project_form`
Expected: 2 FAILED with `ModuleNotFoundError: No module named 'hqt.tui.screens.project_form'`
- [ ] **Step 3: Rename the file and generalize the class**
```bash
git mv src/hqt/tui/screens/add_project.py src/hqt/tui/screens/project_form.py
```
Replace the entire contents of `src/hqt/tui/screens/project_form.py` with:
```python
from pathlib import Path
from textual.app import ComposeResult
from textual.containers import Vertical
from textual.screen import ModalScreen
from textual.widgets import Button, Input, Label
class ProjectFormScreen(ModalScreen[tuple[str, str] | None]):
def __init__(
self,
title: str = "Add Project",
initial_name: str = "",
initial_path: str = "",
) -> None:
self._form_title = title
self._initial_name = initial_name
self._initial_path = initial_path
super().__init__()
def compose(self) -> ComposeResult:
with Vertical(id="project-form-dialog"):
yield Label(self._form_title)
yield Label("Path:")
yield Input(
value=self._initial_path,
placeholder="/path/to/project",
id="path-input",
)
yield Label("Name (optional):")
yield Input(
value=self._initial_name,
placeholder="project name",
id="name-input",
)
yield Button("OK", variant="primary", id="ok-btn")
yield Button("Cancel", id="cancel-btn")
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "ok-btn":
path = self.query_one("#path-input", Input).value.strip()
name = self.query_one("#name-input", Input).value.strip()
if path:
if not name:
name = Path(path).name
self.dismiss((name, path))
else:
self.dismiss(None)
else:
self.dismiss(None)
```
- [ ] **Step 4: Update `app.py` to use the renamed screen**
In `src/hqt/tui/app.py`, replace the import:
```python
from hqt.tui.screens.add_project import AddProjectScreen
```
with:
```python
from hqt.tui.screens.project_form import ProjectFormScreen
```
and in `action_add_project`, replace:
```python
self.push_screen(AddProjectScreen(), on_dismiss)
```
with:
```python
self.push_screen(ProjectFormScreen(), on_dismiss)
```
- [ ] **Step 5: Run the full TUI test file**
Run: `uv run pytest tests/test_tui.py -v`
Expected: all PASS
- [ ] **Step 6: Commit**
```bash
git add -A src/hqt/tui tests/test_tui.py
git commit -m "refactor: generalize AddProjectScreen into ProjectFormScreen with pre-fill"
```
---
### Task 3: `ProjectList.get_selected_project_id`
**Files:**
- Modify: `src/hqt/tui/widgets/project_list.py`
- Test: `tests/test_tui.py`
- [ ] **Step 1: Write the failing test**
Add to `tests/test_tui.py`:
```python
@pytest.mark.asyncio
async def test_project_list_get_selected_project_id():
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
from hqt.tui.widgets.project_list import ProjectList
p = app._project_service.create("selproj", "/tmp/selproj")
app._load_projects()
await pilot.pause()
pl = app.query_one(ProjectList)
lv = pl.query_one("#project-list")
lv.focus()
await pilot.press("down")
await pilot.pause()
assert pl.get_selected_project_id() == p.id
```
- [ ] **Step 2: Run test to verify it fails**
Run: `uv run pytest tests/test_tui.py::test_project_list_get_selected_project_id -v`
Expected: FAIL with `AttributeError: 'ProjectList' object has no attribute 'get_selected_project_id'`
- [ ] **Step 3: Implement the method**
Add to `ProjectList` in `src/hqt/tui/widgets/project_list.py` (same pattern as `SessionList.get_selected_session_id`):
```python
def get_selected_project_id(self) -> int | None:
lv = self.query_one("#project-list", ListView)
if lv.highlighted_child and hasattr(lv.highlighted_child, "data"):
return lv.highlighted_child.data
return None
```
- [ ] **Step 4: Run test to verify it passes**
Run: `uv run pytest tests/test_tui.py::test_project_list_get_selected_project_id -v`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add src/hqt/tui/widgets/project_list.py tests/test_tui.py
git commit -m "feat: ProjectList.get_selected_project_id"
```
---
### Task 4: `e` binding + `action_edit_project`
**Files:**
- Modify: `src/hqt/tui/app.py` (BINDINGS at lines 4251, new action after `action_add_project`)
- Test: `tests/test_tui.py`
- [ ] **Step 1: Write the failing tests**
Add to `tests/test_tui.py`:
```python
# ---------------------------------------------------------------------------
# Edit project: binding + action
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_edit_project_binding_exists():
bindings = {b.key: b for b in HqtApp.BINDINGS}
assert "e" in bindings
assert bindings["e"].action == "edit_project"
@pytest.mark.asyncio
async def test_edit_project_no_selection_warns():
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
from unittest.mock import patch
notify_calls = []
with patch.object(
app, "notify", side_effect=lambda msg, **kw: notify_calls.append((msg, kw))
):
await app.run_action("edit_project")
await pilot.pause()
assert any(
kw.get("severity") == "warning" for _, kw in notify_calls
), f"Expected warning notification, got: {notify_calls}"
@pytest.mark.asyncio
async def test_edit_project_opens_prefilled_form_and_updates():
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
from textual.widgets import Input
from hqt.tui.screens.project_form import ProjectFormScreen
from hqt.tui.widgets.project_list import ProjectList
p = app._project_service.create("oldname", "/tmp/oldpath")
app._load_projects()
await pilot.pause()
pl = app.query_one(ProjectList)
pl.query_one("#project-list").focus()
await pilot.press("down")
await pilot.pause()
assert pl.get_selected_project_id() == p.id
await app.run_action("edit_project")
await pilot.pause()
assert isinstance(app.screen, ProjectFormScreen)
assert app.screen.query_one("#name-input", Input).value == "oldname"
assert app.screen.query_one("#path-input", Input).value == "/tmp/oldpath"
app.screen.dismiss(("newname", "/tmp/newpath"))
await pilot.pause()
refreshed = app._project_service.get(p.id)
assert refreshed.name == "newname"
assert refreshed.path == "/tmp/newpath"
@pytest.mark.asyncio
async def test_edit_project_duplicate_path_notifies_error():
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
from unittest.mock import patch
from hqt.tui.widgets.project_list import ProjectList
app._project_service.create("first", "/tmp/first")
p2 = app._project_service.create("second", "/tmp/second")
app._load_projects()
await pilot.pause()
pl = app.query_one(ProjectList)
pl.query_one("#project-list").focus()
# Two items: press down twice to land on the second project
await pilot.press("down")
await pilot.press("down")
await pilot.pause()
assert pl.get_selected_project_id() == p2.id
notify_calls = []
with patch.object(
app, "notify", side_effect=lambda msg, **kw: notify_calls.append((msg, kw))
):
await app.run_action("edit_project")
await pilot.pause()
app.screen.dismiss(("second", "/tmp/first"))
await pilot.pause()
assert any(
kw.get("severity") == "error" and "already uses path" in msg
for msg, kw in notify_calls
), f"Expected duplicate-path error notification, got: {notify_calls}"
# DB unchanged
assert app._project_service.get(p2.id).path == "/tmp/second"
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `uv run pytest tests/test_tui.py -v -k edit_project`
Expected: 4 FAILED (`"e" not in bindings`; the action-based tests fail because the action doesn't exist)
- [ ] **Step 3: Implement binding and action**
In `src/hqt/tui/app.py`, add to `BINDINGS` (after the `"a"` binding):
```python
Binding("e", "edit_project", "Edit Project"),
```
Add this method after `action_add_project`:
```python
def action_edit_project(self) -> None:
project_id = self.query_one(ProjectList).get_selected_project_id()
if project_id is None:
self.notify("Select a project first", severity="warning")
return
project = self._project_service.get(project_id)
if project is None:
self.notify("Project not found", severity="error")
return
def on_dismiss(result: tuple[str, str] | None) -> None:
if result:
name, path = result
try:
self._project_service.update(project_id, name, path)
except ValueError as err:
self.notify(str(err), severity="error")
return
self._load_projects()
self.push_screen(
ProjectFormScreen(
title="Edit Project",
initial_name=project.name,
initial_path=project.path,
),
on_dismiss,
)
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `uv run pytest tests/test_tui.py -v`
Expected: all PASS
- [ ] **Step 5: Commit**
```bash
git add src/hqt/tui/app.py tests/test_tui.py
git commit -m "feat: edit project name/path via 'e' binding"
```
---
### Task 5: Complete the Frappé theme definition
**Files:**
- Modify: `src/hqt/tui/app.py:23-36` (`FRAPPE_THEME`)
- Test: `tests/test_tui.py`
- [ ] **Step 1: Write the failing test**
Add to `tests/test_tui.py`:
```python
# ---------------------------------------------------------------------------
# Catppuccin Frappé theme: full definition
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_frappe_theme_fully_applied():
app = HqtApp()
async with app.run_test() as pilot:
assert app.current_theme.name == "catppuccin-frappe"
variables = app.get_css_variables()
# Spot-check: explicit values, not Textual auto-derivations
assert variables["primary"] == "#8caaee" # Blue
assert variables["background"] == "#292c3c" # Mantle
assert variables["surface"] == "#414559" # Surface0
assert variables["border"] == "#babbf1" # Lavender
assert variables["footer-background"] == "#51576d" # Surface1
assert variables["input-cursor-background"] == "#f2d5cf" # Rosewater
```
- [ ] **Step 2: Run test to verify it fails**
Run: `uv run pytest tests/test_tui.py::test_frappe_theme_fully_applied -v`
Expected: FAIL on `variables["background"] == "#292c3c"` (currently `#303446`)
- [ ] **Step 3: Replace `FRAPPE_THEME`**
In `src/hqt/tui/app.py`, replace the entire `FRAPPE_THEME = Theme(...)` block with:
```python
# Catppuccin Frappé, mirroring the structure of Textual's built-in
# catppuccin-mocha theme so no colors are auto-derived off-palette.
FRAPPE_THEME = Theme(
name="catppuccin-frappe",
primary="#8caaee", # Blue
secondary="#ca9ee6", # Mauve
accent="#ef9f76", # Peach
success="#a6d189", # Green
warning="#e5c890", # Yellow
error="#e78284", # Red
foreground="#c6d0f5", # Text
background="#292c3c", # Mantle
surface="#414559", # Surface0
panel="#51576d", # Surface1
dark=True,
variables={
"input-cursor-foreground": "#232634", # Crust
"input-cursor-background": "#f2d5cf", # Rosewater
"input-selection-background": "#949cbb 30%", # Overlay2 30%
"border": "#babbf1", # Lavender
"border-blurred": "#626880", # Surface2
"footer-background": "#51576d", # Surface1
"block-cursor-foreground": "#303446", # Base
"block-cursor-text-style": "none",
"button-color-foreground": "#292c3c", # Mantle
},
)
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `uv run pytest tests/test_tui.py -v`
Expected: all PASS
- [ ] **Step 5: Commit**
```bash
git add src/hqt/tui/app.py tests/test_tui.py
git commit -m "feat: fully specify Catppuccin Frappé theme variables"
```
---
### Task 6: Style dialogs and panel headers in `styles.tcss`
**Files:**
- Modify: `src/hqt/tui/styles.tcss`
- Test: `tests/test_tui.py`
- [ ] **Step 1: Write the failing test**
Add to `tests/test_tui.py`:
```python
@pytest.mark.asyncio
async def test_project_form_dialog_styled():
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
from hqt.tui.screens.project_form import ProjectFormScreen
app.push_screen(ProjectFormScreen())
await pilot.pause()
dialog = app.screen.query_one("#project-form-dialog")
assert dialog.styles.width.value == 60
assert dialog.styles.border_top[0] == "solid"
```
- [ ] **Step 2: Run test to verify it fails**
Run: `uv run pytest tests/test_tui.py::test_project_form_dialog_styled -v`
Expected: FAIL (width is not 60; no border set)
- [ ] **Step 3: Extend the stylesheet**
Replace the contents of `src/hqt/tui/styles.tcss` with:
```css
ProjectList {
width: 1fr;
min-width: 20;
max-width: 30;
dock: left;
border-right: solid $primary;
}
SessionList {
width: 3fr;
}
#project-header, #session-header {
width: 100%;
text-style: bold;
background: $surface;
}
ProjectFormScreen, NewSessionScreen {
align: center middle;
}
#project-form-dialog, #new-session-dialog {
width: 60;
height: auto;
padding: 1 2;
background: $surface;
border: solid $border;
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `uv run pytest tests/test_tui.py -v`
Expected: all PASS
- [ ] **Step 5: Commit**
```bash
git add src/hqt/tui/styles.tcss tests/test_tui.py
git commit -m "feat: style modal dialogs and panel headers with Frappé palette"
```
---
### Task 7: Colored status symbols + harness-name markup bug fix
**Files:**
- Modify: `src/hqt/tui/widgets/session_list.py`
- Test: `tests/test_tui.py`
Context: labels are Rich markup. The current f-string interpolates `[{s.harness.name}]`, which Rich parses as a markup tag and **silently drops** — the harness name is invisible in the running app today. The new formatting helper escapes user-derived text and adds a Frappé color tag around the status symbol.
- [ ] **Step 1: Write the failing tests**
Add to `tests/test_tui.py`:
```python
# ---------------------------------------------------------------------------
# Status symbol colors + harness-name markup escape
# ---------------------------------------------------------------------------
def test_format_session_text_colors_and_escapes():
from hqt.tui.widgets.session_list import format_session_text
text = format_session_text("mywork", "claude", "working", "")
assert text.startswith("[#a6d189]◐[/]") # Green symbol
assert "\\[claude]" in text # escaped, so brackets render
assert "working" in text
def test_format_session_text_status_colors():
from hqt.tui.widgets.session_list import format_session_text
assert format_session_text("x", "h", "waiting", "").startswith("[#e5c890]") # Yellow
assert format_session_text("x", "h", "idle", "").startswith("[#81c8be]") # Teal
assert format_session_text("x", "h", "active", "").startswith("[#81c8be]") # Teal
assert format_session_text("x", "h", "dead", "").startswith("[#737994]") # Overlay0
@pytest.mark.asyncio
async def test_session_list_renders_harness_name_brackets():
"""Regression: '[claude]' must be visible, not swallowed as a markup tag."""
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
from textual.widgets import Label
from hqt.tui.widgets.session_list import SessionList
sl = app.query_one(SessionList)
infos = [_make_session_info(1, "mywork", "claude", "working", True)]
await sl.refresh_sessions(infos)
await pilot.pause()
lv = sl.query_one("#session-list")
texts = [str(child.query_one(Label).render()) for child in lv.children]
assert any("[claude]" in t for t in texts), f"harness name missing: {texts}"
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `uv run pytest tests/test_tui.py -v -k "format_session_text or renders_harness"`
Expected: 2 FAILED with `ImportError: cannot import name 'format_session_text'`, 1 FAILED on the brackets assertion
- [ ] **Step 3: Implement the formatting helper and use it**
In `src/hqt/tui/widgets/session_list.py`, add at the top:
```python
from rich.markup import escape
```
Add at module level (above the `SessionList` class, next to where `_STATUS_SYMBOLS` will remain inside the class):
```python
_STATUS_COLORS: dict[str, str] = {
"working": "#a6d189", # Green
"waiting": "#e5c890", # Yellow
"active": "#81c8be", # Teal
"idle": "#81c8be", # Teal
"dead": "#737994", # Overlay0
}
def format_session_text(
nickname: str, harness_name: str, status_text: str, symbol: str
) -> str:
color = _STATUS_COLORS.get(status_text, "#c6d0f5") # default: Text
label = escape(f"{nickname} [{harness_name}]")
return f"[{color}]{symbol}[/] {label} {status_text}"
```
In `SessionList.refresh_sessions`, replace:
```python
text = f"{symbol} {s.nickname or s.tmux_session_name} [{s.harness.name}] {status_text}"
```
with:
```python
text = format_session_text(
s.nickname or s.tmux_session_name, s.harness.name, status_text, symbol
)
```
- [ ] **Step 4: Run the full TUI test file**
Run: `uv run pytest tests/test_tui.py -v`
Expected: all PASS — including the pre-existing `test_session_list_label_includes_status`, which reads plain rendered text and is unaffected by markup
- [ ] **Step 5: Commit**
```bash
git add src/hqt/tui/widgets/session_list.py tests/test_tui.py
git commit -m "feat: Frappé-colored status symbols; fix harness name swallowed by markup"
```
---
### Task 8: Full suite + visual verification
**Files:**
- None modified (verification only)
- [ ] **Step 1: Run the entire test suite**
Run: `uv run pytest`
Expected: all tests PASS, no warnings about unknown CSS
- [ ] **Step 2: Render a headless screenshot and inspect it**
```bash
uv run python - <<'EOF'
import asyncio, tempfile
from pathlib import Path
import hqt.config as config
tmp = Path(tempfile.mkdtemp())
config._settings = config.Settings(db_path=tmp/'hqt.db', config_dir=tmp, skills_dir=tmp/'skills')
from hqt.tui.app import HqtApp
async def main():
app = HqtApp()
async with app.run_test(size=(100, 30)) as pilot:
app._project_service.create("demo-project", "/tmp/demo")
app._load_projects()
await pilot.pause()
Path('/tmp/hqt_themed.svg').write_text(app.export_screenshot())
asyncio.run(main())
EOF
rsvg-convert -o /tmp/hqt_themed.png /tmp/hqt_themed.svg
```
Then view `/tmp/hqt_themed.png` (Read tool or image viewer). Check: Mantle background (`#292c3c`), Surface0 panels, Lavender borders, Blue selection highlight, bold panel headers. Repeat with `app.push_screen(ProjectFormScreen())` before the screenshot to verify the dialog styling.
- [ ] **Step 3: Final commit if any fixes were needed**
If the screenshot revealed fixes, commit them:
```bash
git add -A && git commit -m "fix: theme polish from visual verification"
```
@@ -0,0 +1,550 @@
# Session Attach Simplification + Rename Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Remove the redundant `Shift+R` Resume binding (Enter/attach already auto-resumes) and add an `r` Rename action that edits a session's display nickname.
**Architecture:** Resume is pure deletion — `attach_session()` already respawns dead/gone windows before attaching. Rename is a synchronous DB write to `Session.nickname`; the existing 3-second poll (`sync_window_labels`) propagates the new label to tmux. A new modal `RenameSessionScreen` mirrors the existing `ProjectFormScreen` pattern.
**Tech Stack:** Python, Textual (TUI), SQLAlchemy, pytest + pytest-asyncio.
---
## File Structure
- **Modify** `src/hqt/sessions/service.py` — remove `resume_session()`; add `rename_session()` and `get_session()`.
- **Modify** `src/hqt/tui/app.py` — remove the Resume binding + `action_resume_session()`; add the Rename binding + `action_rename_session()`.
- **Create** `src/hqt/tui/screens/rename_session.py``RenameSessionScreen` modal.
- **Modify** `tests/test_tui.py` — delete the resume keypress test; add rename-modal + rename-action tests.
- **Modify** `tests/test_sessions.py` — delete four `resume_session` tests; add `rename_session`/`get_session` tests (reusing existing fixtures).
---
## Task 1: Remove Resume from the service
**Files:**
- Modify: `src/hqt/sessions/service.py:143-150` (the `resume_session` method)
- Modify: `tests/test_sessions.py` (delete four `resume_session` tests)
- [ ] **Step 1: Inventory every reference**
Run: `grep -rn "resume_session" src/ tests/`
Expected references: the method in `src/hqt/sessions/service.py`; `action_resume_session` in `src/hqt/tui/app.py`; `test_resume_keypress_triggers_resume` in `tests/test_tui.py`; and four tests in `tests/test_sessions.py``test_resume_session_passes_env`, `test_resume_session_resume_ok_attaches`, `test_resume_session_resume_fails_fallback_spawn`, `test_resume_session_both_rungs_fail_returns_false`. No other production callers.
Coverage note: `_respawn_with_fallback`'s rung-1/rung-2 behavior is independently covered by `test_attach_session_dead_resume_ok_attaches_once`, `test_attach_session_dead_resume_fails_fallback_spawn`, and `test_attach_session_both_rungs_fail_returns_false`, so deleting the resume tests loses no fallback-ladder coverage.
- [ ] **Step 2: Delete the four `resume_session` tests in `tests/test_sessions.py`**
Remove these four async test functions in full (including decorators and docstrings): `test_resume_session_passes_env`, `test_resume_session_resume_ok_attaches`, `test_resume_session_resume_fails_fallback_spawn`, `test_resume_session_both_rungs_fail_returns_false`. Leave all `test_attach_session_*` tests intact.
- [ ] **Step 3: Confirm the resume tests are gone but attach tests remain**
Run: `grep -n "resume_session\|test_attach_session" tests/test_sessions.py`
Expected: no `resume_session` matches; the three `test_attach_session_*` tests still listed.
- [ ] **Step 4: Delete the `resume_session` method**
In `src/hqt/sessions/service.py`, remove this method entirely (currently lines 143-150):
```python
async def resume_session(self, session_id: int) -> bool:
"""Force-restart the harness in this session's window."""
sess = self.db.get(Session, session_id)
window_name = sess.tmux_session_name
ok = await self._respawn_with_fallback(sess, window_name)
if not ok:
return False
return await self.tmux.attach(window_name)
```
Leave `_respawn_with_fallback()` and `attach_session()` untouched — attach depends on the fallback ladder.
- [ ] **Step 5: Verify nothing in the service references the removed method**
Run: `grep -n "resume_session" src/hqt/sessions/service.py`
Expected: no output.
- [ ] **Step 6: Run the service tests**
Run: `python -m pytest tests/test_sessions.py -q`
Expected: PASS — resume tests removed, attach/fallback tests still green.
- [ ] **Step 7: Commit**
```bash
git add src/hqt/sessions/service.py tests/test_sessions.py
git commit -m "refactor: drop redundant resume_session (attach auto-resumes)"
```
---
## Task 2: Remove Resume from the app + its test
**Files:**
- Modify: `src/hqt/tui/app.py:62-68` (BINDINGS) and `src/hqt/tui/app.py:235-243` (`action_resume_session`)
- Modify: `tests/test_tui.py:822-871` (delete `test_resume_keypress_triggers_resume`)
- [ ] **Step 1: Delete the resume test first**
In `tests/test_tui.py`, remove the section header comment block and the entire `test_resume_keypress_triggers_resume` test (currently lines 822-871), including its `@pytest.mark.parametrize` decorator.
- [ ] **Step 2: Run the suite to confirm only the deleted test is gone**
Run: `python -m pytest tests/test_tui.py -q`
Expected: PASS — the resume test no longer collected; the action still exists so nothing else breaks yet.
- [ ] **Step 3: Remove the Resume binding**
In `src/hqt/tui/app.py`, delete these lines from `BINDINGS` (currently lines 63-66):
```python
# A normal terminal sends the character "R" for Shift+R; only the Kitty
# keyboard protocol emits "shift+r". Bind both so Resume fires either way
# (binding "shift+r" alone never matches on a standard terminal).
Binding("R,shift+r", "resume_session", "Resume"),
```
- [ ] **Step 4: Remove `action_resume_session`**
In `src/hqt/tui/app.py`, delete this method (currently lines 235-243):
```python
def action_resume_session(self) -> None:
sid = self.query_one(SessionList).get_selected_session_id()
if sid:
async def _do() -> None:
ok = await self._session_service.resume_session(sid)
if not ok:
self.notify("Failed to resume session", severity="error")
await self._refresh_sessions()
self.run_worker(_do())
```
- [ ] **Step 5: Confirm no lingering references**
Run: `grep -rn "resume_session\|Resume" src/hqt/tui/app.py`
Expected: no output.
- [ ] **Step 6: Run the full suite**
Run: `python -m pytest tests/test_tui.py -q`
Expected: PASS.
- [ ] **Step 7: Commit**
```bash
git add src/hqt/tui/app.py tests/test_tui.py
git commit -m "refactor: remove Shift+R Resume binding and action"
```
---
## Task 3: Add `rename_session` + `get_session` to the service
**Files:**
- Modify: `src/hqt/sessions/service.py` (add two methods to `SessionService`)
- Test: `tests/test_sessions.py`
- [ ] **Step 1: Write the failing tests**
`tests/test_sessions.py` already exists with shared `db`, `tmux`, `harnesses`, and `service` fixtures (the `db` fixture seeds one `Harness(name="claude-code")` and one `Project` with id 1). Reuse them — do NOT add new setup helpers. Append a small builder and the tests:
```python
def _seed_session(db, nickname=None):
"""Create a session for project 1 / harness 'claude-code' (seeded by the db fixture)."""
from hqt.db.models import Harness, Session
harness = db.query(Harness).filter_by(name="claude-code").first()
sess = Session(
project_id=1,
harness_id=harness.id,
nickname=nickname,
tmux_session_name="hqt-rename",
archived=False,
)
db.add(sess)
db.commit()
return sess
def test_rename_session_sets_nickname(service, db):
from hqt.db.models import Session
sess = _seed_session(db, nickname="old")
service.rename_session(sess.id, "new-name")
assert db.get(Session, sess.id).nickname == "new-name"
def test_rename_session_empty_clears_to_none(service, db):
from hqt.db.models import Session
sess = _seed_session(db, nickname="old")
service.rename_session(sess.id, "")
assert db.get(Session, sess.id).nickname is None
def test_rename_session_whitespace_clears_to_none(service, db):
from hqt.db.models import Session
sess = _seed_session(db, nickname="old")
service.rename_session(sess.id, " ")
assert db.get(Session, sess.id).nickname is None
def test_get_session_returns_row(service, db):
sess = _seed_session(db, nickname="x")
assert service.get_session(sess.id).id == sess.id
def test_get_session_missing_returns_none(service):
assert service.get_session(99999) is None
```
- [ ] **Step 2: Run the tests to verify they fail**
Run: `python -m pytest tests/test_sessions.py -k "rename_session or get_session" -q`
Expected: FAIL with `AttributeError: 'SessionService' object has no attribute 'rename_session'` (and `get_session`).
- [ ] **Step 3: Implement both methods**
In `src/hqt/sessions/service.py`, add these methods to `SessionService` (place them next to `delete_session`):
```python
def get_session(self, session_id: int) -> Session | None:
"""Return the session row, or None if it does not exist."""
return self.db.get(Session, session_id)
def rename_session(self, session_id: int, nickname: str | None) -> None:
"""Update a session's display nickname.
An empty/blank nickname clears it to None, so the label falls back to
the tmux window name. The tmux window label is refreshed by the next
poll (sync_window_labels), so no immediate tmux call is needed here.
"""
sess = self.db.get(Session, session_id)
sess.nickname = (nickname or "").strip() or None
self.db.commit()
```
- [ ] **Step 4: Run the tests to verify they pass**
Run: `python -m pytest tests/test_sessions.py -k "rename_session or get_session" -q`
Expected: PASS (5 tests).
- [ ] **Step 5: Commit**
```bash
git add src/hqt/sessions/service.py tests/test_sessions.py
git commit -m "feat: add rename_session and get_session to SessionService"
```
---
## Task 4: Create the RenameSessionScreen modal
**Files:**
- Create: `src/hqt/tui/screens/rename_session.py`
- Test: `tests/test_tui.py` (append)
- [ ] **Step 1: Write the failing tests**
Append to `tests/test_tui.py`:
```python
# ---------------------------------------------------------------------------
# RenameSessionScreen: prefill + submit
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_rename_screen_prefills_current_nickname():
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
from hqt.tui.screens.rename_session import RenameSessionScreen
from textual.widgets import Input
app.push_screen(RenameSessionScreen(initial_nickname="mywork"))
await pilot.pause()
assert app.screen.query_one("#nickname-input", Input).value == "mywork"
@pytest.mark.asyncio
async def test_rename_screen_ok_returns_stripped_value():
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
from hqt.tui.screens.rename_session import RenameSessionScreen
from textual.widgets import Button, Input
results = []
app.push_screen(RenameSessionScreen(initial_nickname=""), results.append)
await pilot.pause()
app.screen.query_one("#nickname-input", Input).value = " renamed "
app.screen.query_one("#ok-btn", Button).press()
await pilot.pause()
assert results == ["renamed"]
@pytest.mark.asyncio
async def test_rename_screen_cancel_returns_none():
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
from hqt.tui.screens.rename_session import RenameSessionScreen
from textual.widgets import Button
results = []
app.push_screen(RenameSessionScreen(initial_nickname="x"), results.append)
await pilot.pause()
app.screen.query_one("#cancel-btn", Button).press()
await pilot.pause()
assert results == [None]
```
- [ ] **Step 2: Run the tests to verify they fail**
Run: `python -m pytest tests/test_tui.py -k rename_screen -q`
Expected: FAIL with `ModuleNotFoundError: No module named 'hqt.tui.screens.rename_session'`.
- [ ] **Step 3: Implement the modal**
Create `src/hqt/tui/screens/rename_session.py`:
```python
from textual.app import ComposeResult
from textual.containers import Horizontal, Vertical
from textual.screen import ModalScreen
from textual.widgets import Button, Input, Label
class RenameSessionScreen(ModalScreen[str | None]):
"""Rename a session's display nickname.
Dismisses with the stripped nickname on OK, or None on Cancel. Mirrors
ProjectFormScreen's structure so it picks up the same dialog styling
(#project-form-dialog rules are reused via the shared id).
"""
def __init__(self, initial_nickname: str = "") -> None:
self._initial_nickname = initial_nickname
super().__init__()
def compose(self) -> ComposeResult:
with Vertical(id="project-form-dialog"):
yield Label("Rename Session")
yield Label("Name:")
yield Input(
value=self._initial_nickname,
placeholder="session name",
id="nickname-input",
)
with Horizontal(classes="dialog-actions"):
yield Button("OK", variant="primary", id="ok-btn")
yield Button("Cancel", id="cancel-btn")
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "ok-btn":
self.dismiss(self.query_one("#nickname-input", Input).value.strip())
else:
self.dismiss(None)
def on_input_submitted(self, event: Input.Submitted) -> None:
# Enter in the text field confirms, matching the New Session dialog.
self.dismiss(self.query_one("#nickname-input", Input).value.strip())
```
Note: reusing `id="project-form-dialog"` is intentional — it inherits the existing dialog styling in `styles.tcss` (width 60, border, `$surface` fill) verified by `test_project_form_dialog_styled`. No new CSS needed.
- [ ] **Step 4: Run the tests to verify they pass**
Run: `python -m pytest tests/test_tui.py -k rename_screen -q`
Expected: PASS (3 tests).
- [ ] **Step 5: Commit**
```bash
git add src/hqt/tui/screens/rename_session.py tests/test_tui.py
git commit -m "feat: add RenameSessionScreen modal"
```
---
## Task 5: Wire the `r` Rename binding + action into the app
**Files:**
- Modify: `src/hqt/tui/app.py` (BINDINGS, import, new action)
- Test: `tests/test_tui.py` (append)
- [ ] **Step 1: Write the failing tests**
Append to `tests/test_tui.py`:
```python
# ---------------------------------------------------------------------------
# Rename session: binding + action
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_rename_session_binding_exists():
bindings = {b.key: b for b in HqtApp.BINDINGS}
assert "r" in bindings
assert bindings["r"].action == "rename_session"
@pytest.mark.asyncio
async def test_rename_session_no_selection_warns():
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
from unittest.mock import patch
notify_calls = []
with patch.object(
app, "notify", side_effect=lambda msg, **kw: notify_calls.append((msg, kw))
):
await app.run_action("rename_session")
await pilot.pause()
assert any(
kw.get("severity") == "warning" for _, kw in notify_calls
), f"Expected warning notification, got: {notify_calls}"
@pytest.mark.asyncio
async def test_rename_session_opens_prefilled_and_updates():
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
from hqt.db.models import Project, Harness, Session
from hqt.tui.widgets.session_list import SessionList
from hqt.tui.screens.rename_session import RenameSessionScreen
from textual.widgets import Input
proj = Project(name="ren-proj", path="/tmp/ren-proj")
app._db_session.add(proj)
app._db_session.flush()
harness = app._db_session.query(Harness).first()
sess = Session(
project_id=proj.id,
harness_id=harness.id,
nickname="before",
tmux_session_name="hqt-ren",
archived=False,
)
app._db_session.add(sess)
app._db_session.commit()
app._selected_project_id = proj.id
await app._refresh_sessions()
await pilot.pause()
sl = app.query_one(SessionList)
sl.query_one("#session-list").focus()
await pilot.press("down")
await pilot.pause()
assert sl.get_selected_session_id() == sess.id
await app.run_action("rename_session")
await pilot.pause()
assert isinstance(app.screen, RenameSessionScreen)
assert app.screen.query_one("#nickname-input", Input).value == "before"
app.screen.dismiss("after")
await pilot.pause()
await app.workers.wait_for_complete()
await pilot.pause()
assert app._db_session.get(Session, sess.id).nickname == "after"
```
- [ ] **Step 2: Run the tests to verify they fail**
Run: `python -m pytest tests/test_tui.py -k rename_session -q`
Expected: FAIL — `"r"` not in BINDINGS / no `rename_session` action.
- [ ] **Step 3: Add the import**
In `src/hqt/tui/app.py`, add next to the other screen imports (near line 19):
```python
from hqt.tui.screens.rename_session import RenameSessionScreen
```
- [ ] **Step 4: Add the binding**
In `src/hqt/tui/app.py` `BINDINGS`, add this entry (e.g. after the `delete_session` binding):
```python
Binding("r", "rename_session", "Rename"),
```
- [ ] **Step 5: Add the action**
In `src/hqt/tui/app.py`, add this method (e.g. after `action_attach_session`):
```python
def action_rename_session(self) -> None:
sid = self.query_one(SessionList).get_selected_session_id()
if sid is None:
self.notify("Select a session first", severity="warning")
return
sess = self._session_service.get_session(sid)
if sess is None:
self.notify("Session not found", severity="error")
return
def on_dismiss(result: str | None) -> None:
if result is not None:
self._session_service.rename_session(sid, result)
self.run_worker(self._refresh_sessions())
self.push_screen(
RenameSessionScreen(initial_nickname=sess.nickname or ""),
on_dismiss,
)
```
- [ ] **Step 6: Run the tests to verify they pass**
Run: `python -m pytest tests/test_tui.py -k rename_session -q`
Expected: PASS (3 tests).
- [ ] **Step 7: Commit**
```bash
git add src/hqt/tui/app.py tests/test_tui.py
git commit -m "feat: add 'r' rename-session binding and action"
```
---
## Task 6: Full verification + docs
**Files:**
- Modify: `README.md` (if it documents keybindings — check first)
- [ ] **Step 1: Run the entire test suite**
Run: `python -m pytest -q`
Expected: PASS, no failures, no reference to resume.
- [ ] **Step 2: Update keybinding docs if present**
Run: `grep -rn "Resume\|Shift+R\|shift+r" README.md`
If matches exist, replace the Resume entry with a Rename (`r`) entry and note that attach (Enter) now auto-resumes. If no matches, skip.
- [ ] **Step 3: Commit any doc change**
```bash
git add README.md
git commit -m "docs: replace Resume keybinding with Rename ('r')"
```
(Skip this commit if README needed no change.)
---
## Self-Review Notes
- **Spec coverage:** Change 1 (remove Resume) → Tasks 1-2. Change 2 (rename: service, modal, wiring) → Tasks 3-5. Testing section → tests embedded in every task + Task 6. Out-of-scope items (no `tmux_session_name` change, no migration) are respected — only `nickname` is written.
- **Empty-input → None:** handled in `rename_session` (`(nickname or "").strip() or None`) and covered by `test_rename_session_empty_clears_to_none`.
- **Type consistency:** `get_session(session_id) -> Session | None`, `rename_session(session_id, nickname)`, and `RenameSessionScreen(initial_nickname=...)` are used identically across the service, app action, and tests.
- **Label propagation:** no immediate tmux call; the existing `sync_window_labels` poll (app.py `set_interval(3, ...)`) reads the new nickname — consistent with the approved design.
@@ -0,0 +1,158 @@
# Project Editing + Catppuccin Frappé Theme Completion
**Date:** 2026-06-09
**Status:** Approved
## Problem
1. Projects cannot be edited after creation. If a repository moves on disk, the
only options are archiving the project or editing the database by hand.
2. The TUI registers a Catppuccin Frappé theme (`FRAPPE_THEME` in
`src/hqt/tui/app.py`) but only sets the basic semantic fields. Textual
auto-derives every other color (footer keys, borders, selection highlights),
which drifts off-palette, and the modal dialogs, panel headers, and status
symbols are entirely unstyled. The result does not look like Frappé.
## Part 1: Project editing
### Service layer
Add to `ProjectService` (`src/hqt/projects/service.py`):
```python
def update(self, project_id: int, name: str, path: str) -> Project
```
- Updates both fields and commits.
- Raises `ValueError("Project not found")` for an unknown id.
- A duplicate path violates the unique constraint on `Project.path`: catch
`IntegrityError`, roll back, raise `ValueError` with a readable message
(e.g. `"Another project already uses path <path>"`).
- No filesystem validation (consistent with `create`, which accepts any
string).
### Form screen
Rename `AddProjectScreen` (`src/hqt/tui/screens/add_project.py`) to
`ProjectFormScreen`:
- Constructor: `ProjectFormScreen(title="Add Project", initial_name="",
initial_path="")`.
- The dialog heading label shows `title`; inputs are pre-filled with the
initial values.
- Return type unchanged: `tuple[str, str] | None` of `(name, path)`.
- Add-mode behavior unchanged: empty name defaults to `Path(path).name`;
empty path dismisses with `None`.
### Project list
Add `ProjectList.get_selected_project_id() -> int | None`
(`src/hqt/tui/widgets/project_list.py`), same pattern as
`SessionList.get_selected_session_id()`: read `data` off the ListView's
highlighted child.
### App wiring
In `HqtApp` (`src/hqt/tui/app.py`):
- New binding: `Binding("e", "edit_project", "Edit Project")`.
- `action_edit_project`:
- No project highlighted → `notify("Select a project first",
severity="warning")` and return.
- Otherwise push `ProjectFormScreen(title="Edit Project",
initial_name=project.name, initial_path=project.path)`.
- On dismiss with a result: call `ProjectService.update`, refresh the
project list. Catch `ValueError` and show its message as an error
notification.
- `action_add_project` switches to the renamed `ProjectFormScreen` with
defaults; behavior otherwise unchanged.
### Session semantics
Editing a path never touches tmux. Running sessions keep the working
directory they were spawned with; only sessions created after the edit use
the new path. This matches archive semantics (DB-only operation).
## Part 2: Frappé theme completion
### Theme definition
Replace `FRAPPE_THEME` with a fully specified `Theme`, mirroring the
structure of Textual's built-in `catppuccin-mocha` theme with Frappé values:
| Field | Frappé color | Hex |
|--------------|--------------|-----------|
| `primary` | Blue | `#8caaee` |
| `secondary` | Mauve | `#ca9ee6` |
| `accent` | Peach | `#ef9f76` |
| `success` | Green | `#a6d189` |
| `warning` | Yellow | `#e5c890` |
| `error` | Red | `#e78284` |
| `foreground` | Text | `#c6d0f5` |
| `background` | Mantle | `#292c3c` |
| `surface` | Surface0 | `#414559` |
| `panel` | Surface1 | `#51576d` |
| `dark` | — | `True` |
`variables` dict (same keys the built-in Mocha theme sets):
| Variable | Frappé color | Value |
|------------------------------|---------------|----------------|
| `input-cursor-foreground` | Crust | `#232634` |
| `input-cursor-background` | Rosewater | `#f2d5cf` |
| `input-selection-background` | Overlay2 30% | `#949cbb 30%` |
| `border` | Lavender | `#babbf1` |
| `border-blurred` | Surface2 | `#626880` |
| `footer-background` | Surface1 | `#51576d` |
| `block-cursor-foreground` | Base | `#303446` |
| `block-cursor-text-style` | — | `none` |
| `button-color-foreground` | Mantle | `#292c3c` |
Note: `background` moves from Base to Mantle to match how the built-in
Catppuccin themes layer surfaces — panels (Surface0/1) then sit visibly
above the background.
### Widget styling (`src/hqt/tui/styles.tcss`)
- Keep the existing `ProjectList`/`SessionList` layout rules.
- Modal dialogs: `ProjectFormScreen` renames its container id to
`#project-form-dialog`; `NewSessionScreen` keeps `#new-session-dialog`.
Both get: centered (`align: center middle` on the modal screen), fixed
width (~60 cells), `$surface` background, Lavender border, padding.
- Panel headers (`#project-header`, `#session-header`): bold text on
`$surface` background.
### Status symbol colors (`src/hqt/tui/widgets/session_list.py`)
Color the status symbol via Rich markup with Frappé hex literals:
| Status | Frappé color | Hex |
|-----------|--------------|-----------|
| `working` | Green | `#a6d189` |
| `waiting` | Yellow | `#e5c890` |
| `active` | Teal | `#81c8be` |
| `idle` | Teal | `#81c8be` |
| `dead` | Overlay0 | `#737994` |
## Testing
- **Service** (`tests/test_services.py`): `update` renames and repaths;
duplicate path raises `ValueError` and leaves the DB session usable
(rollback); unknown id raises `ValueError`.
- **TUI** (pilot tests, same style as `tests/test_tui.py`):
- Pressing `e` with a project highlighted opens the form pre-filled with
that project's name and path.
- Submitting the edit form updates the DB and refreshes the list.
- Pressing `e` with no project selected shows a warning notification.
- The add-project flow still works through the renamed screen.
- **Theme**: assert `app.current_theme.name == "catppuccin-frappe"` and
spot-check resolved CSS variables (e.g. `background` is `#292c3c`,
`border` variable is `#babbf1`) so a regression to auto-derived colors is
caught.
## Out of scope
- Archiving/un-archiving projects from the TUI.
- Filesystem validation of project paths.
- Moving or migrating existing tmux sessions when a path changes.
- Light-mode (Latte) theme variant.
@@ -0,0 +1,87 @@
# Simplify session attach + add rename
**Date:** 2026-06-10
**Status:** Approved
## Goal
Streamline session handling with two changes:
1. Remove the redundant Resume (`Shift+R`) binding — attach already auto-resumes.
2. Add a Rename action (`r`) to relabel the selected session.
## Background
Each session row carries two names:
- `tmux_session_name` (`hqt-{id}`) — the stable internal identifier used to
target the tmux window and as the unique DB key. Never user-facing.
- `nickname` — an optional display label shown in the session list and in the
tmux window label (falls back to `tmux_session_name` when unset).
`attach_session()` (`src/hqt/sessions/service.py`) already handles every window
state: alive → switch to it; dead pane or missing window → respawn via the
fallback ladder (`_respawn_with_fallback`) then attach. The `resume_session()`
method and its `R,shift+r` binding only duplicate that respawn-then-attach path,
so Resume is dead weight.
## Change 1 — Remove the Resume binding (deletion only)
- **`src/hqt/tui/app.py`**: delete the `Binding("R,shift+r", "resume_session",
"Resume")` entry and the `action_resume_session()` method.
- **`src/hqt/sessions/service.py`**: delete the `resume_session()` method.
Keep `_respawn_with_fallback()` — `attach_session()` depends on it. Attach
behavior is unchanged; Enter becomes the single way into a session, resuming
transparently when the window is dead or gone.
## Change 2 — Add Rename (`r`)
### Service
New synchronous method on `SessionService` (pure DB write — no tmux call):
```python
def rename_session(self, session_id: int, nickname: str | None) -> None:
sess = self.db.get(Session, session_id)
sess.nickname = nickname or None # empty -> None -> label falls back to tmux name
self.db.commit()
```
The next 3-second poll (`sync_window_labels()`) reads the updated `nickname` and
refreshes the tmux window label automatically — no immediate label push needed.
A small `get_session(session_id) -> Session | None` accessor is added so the app
layer can read the current nickname to prefill the dialog without reaching into
`db` directly.
### Modal
New `RenameSessionScreen(ModalScreen[str | None])` in
`src/hqt/tui/screens/rename_session.py`, mirroring `ProjectFormScreen`:
- Titled dialog with a single `Input` prefilled with the current nickname.
- OK / Cancel buttons in a `.dialog-actions` row.
- OK dismisses with the stripped input value; Cancel dismisses with `None`.
### App wiring (`src/hqt/tui/app.py`)
- Add `Binding("r", "rename_session", "Rename")`.
- `action_rename_session()`:
- Get the selected session id; warn ("Select a session first") if none.
- Load the session via `get_session()` to read its current nickname.
- Push `RenameSessionScreen` prefilled with that nickname.
- On a non-`None` dismiss, call `rename_session()` then `_refresh_sessions()`.
## Testing
- `rename_session` sets `nickname`; an empty string clears it to `None`.
- Remove or update any test referencing `resume_session` or the Resume binding.
- Pilot test: press `r`, type a name, confirm, assert the session's nickname /
displayed label updates.
## Out of scope
- No change to `tmux_session_name`.
- No new harness logic.
- No DB migration (reuses the existing `nickname` column).
+27
View File
@@ -0,0 +1,27 @@
[project]
name = "hqt"
version = "0.1.0"
description = "Terminal TUI for orchestrating AI coding harness sessions via tmux"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"textual>=2.0",
"sqlalchemy>=2.0",
"click>=8.0",
"pyyaml>=6.0",
]
[project.scripts]
hqt = "hqt.cli:main"
[build-system]
requires = ["uv_build>=0.9.21,<0.10.0"]
build-backend = "uv_build"
[dependency-groups]
dev = [
"pytest>=8.0",
"pytest-asyncio>=0.24",
"ruff>=0.8",
"textual-dev>=1.0",
]
+2
View File
@@ -0,0 +1,2 @@
def hello() -> str:
return "Hello from hqt!"
+4
View File
@@ -0,0 +1,4 @@
from hqt.cli import main
if __name__ == "__main__":
main()
+103
View File
@@ -0,0 +1,103 @@
import logging
import os
import subprocess
import sys
import traceback
import click
log = logging.getLogger(__name__)
@click.group(invoke_without_command=True)
@click.option('--inside-tmux', is_flag=True, hidden=True)
@click.pass_context
def main(ctx, inside_tmux):
"""HQ Terminal - orchestrate AI coding harness sessions via tmux."""
if ctx.invoked_subcommand is None:
launch(inside_tmux)
def launch(inside_tmux: bool = False):
"""Bootstrap into tmux and launch TUI."""
from hqt.config import get_settings
from hqt.db.engine import ensure_db
settings = get_settings()
ensure_db(settings)
tui_name = settings.tui_session_name
win_name = settings.tui_window_name
tui_cmd = f"HQT_INSIDE=1 {sys.executable} -m hqt"
if inside_tmux or os.environ.get('HQT_INSIDE'):
# We ARE the TUI process inside tmux — run the app
from hqt.logging import setup_logging
setup_logging()
log.info("Starting hqt TUI")
try:
from hqt.tui.app import HqtApp
app = HqtApp()
app.run()
except Exception:
log.exception("TUI crashed")
traceback.print_exc()
input("Press Enter to exit...")
raise
else:
# Check if the hqt session already exists
rc = subprocess.call(
[settings.tmux_path, "has-session", "-t", tui_name],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
if rc == 0:
# Session exists — ensure TUI window is present
result = subprocess.run(
[settings.tmux_path, "list-windows", "-t", tui_name, "-F", "#{window_name}"],
capture_output=True, text=True,
)
windows = result.stdout.strip().splitlines()
if win_name not in windows:
# Recreate TUI window at the end
subprocess.call(
[settings.tmux_path, "new-window", "-t", tui_name, "-n", win_name, tui_cmd],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
os.execvp(settings.tmux_path, [settings.tmux_path, "attach", "-t", f"{tui_name}:={win_name}"])
else:
# Create new session with TUI as first window
os.execvp(settings.tmux_path, [
settings.tmux_path, "new-session", "-s", tui_name, "-n", win_name, tui_cmd,
])
@main.command()
def doctor():
"""Check system requirements."""
import shutil
from hqt.harnesses.registry import discover_harnesses
tmux = shutil.which('tmux')
if tmux:
click.echo(f"tmux: ✓ {tmux}")
else:
click.echo("tmux: ✗ not found — install tmux to use hqt", err=True)
raise SystemExit(1)
harnesses = discover_harnesses()
for name in harnesses:
click.echo(f"{name}: ✓")
if not harnesses:
click.echo("No harnesses found on PATH")
@main.command(name='list')
def list_cmd():
"""List projects and sessions."""
from hqt.config import get_settings
from hqt.db.engine import get_engine, get_session_factory, ensure_db
from hqt.projects.service import ProjectService
settings = get_settings()
ensure_db(settings)
engine = get_engine(settings)
Session = get_session_factory(engine)
with Session() as db:
svc = ProjectService(db)
for p in svc.list_all():
click.echo(f" {p.name} ({p.path})")
+26
View File
@@ -0,0 +1,26 @@
from dataclasses import dataclass, field
from pathlib import Path
@dataclass
class Settings:
db_path: Path = field(default_factory=lambda: Path.home() / ".local/share/hqt/hqt.db")
config_dir: Path = field(default_factory=lambda: Path.home() / ".config/hqt")
skills_dir: Path = field(default_factory=lambda: Path.home() / ".config/hqt/skills")
tmux_path: str = "tmux"
# Name of the orchestrator tmux session. tmux's default status-left
# ("[#{session_name}] ") prints this in the lower-left corner, so keep it
# short and clean ("[hqt]"). Harness windows live *inside* this session and
# are named "hqt-{id}" — a different namespace, so there's no collision.
tui_session_name: str = "hqt"
# Name of the main TUI tmux window. Deliberately distinct from the
# "hqt-{id}" harness session windows so the status bar doesn't read it as a
# truncated session name (the trailing "-" there is tmux's last-window flag).
tui_window_name: str = "⌂ HQT"
_settings: Settings | None = None
def get_settings() -> Settings:
global _settings
if _settings is None:
_settings = Settings()
return _settings
View File
+17
View File
@@ -0,0 +1,17 @@
from sqlalchemy import Engine, create_engine
from sqlalchemy.orm import sessionmaker
from hqt.config import Settings
from hqt.db.models import Base
def get_engine(settings: Settings) -> Engine:
settings.db_path.parent.mkdir(parents=True, exist_ok=True)
return create_engine(f"sqlite:///{settings.db_path}")
def get_session_factory(engine: Engine) -> sessionmaker:
return sessionmaker(bind=engine)
def ensure_db(settings: Settings) -> None:
settings.db_path.parent.mkdir(parents=True, exist_ok=True)
engine = get_engine(settings)
Base.metadata.create_all(engine)
+62
View File
@@ -0,0 +1,62 @@
from datetime import datetime
from sqlalchemy import ForeignKey, String, func
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
class Base(DeclarativeBase):
pass
class Project(Base):
__tablename__ = "projects"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str]
path: Mapped[str] = mapped_column(unique=True)
created_at: Mapped[datetime] = mapped_column(insert_default=func.now())
last_opened_at: Mapped[datetime | None] = mapped_column(default=None)
archived: Mapped[bool] = mapped_column(default=False)
sessions: Mapped[list["Session"]] = relationship(back_populates="project")
class Harness(Base):
__tablename__ = "harnesses"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(unique=True)
display_name: Mapped[str | None] = mapped_column(default=None)
command_json: Mapped[str | None] = mapped_column(default=None)
resume_command_json: Mapped[str | None] = mapped_column(default=None)
profile_json: Mapped[str | None] = mapped_column(default=None)
sessions: Mapped[list["Session"]] = relationship(back_populates="harness")
class Session(Base):
__tablename__ = "sessions"
id: Mapped[int] = mapped_column(primary_key=True)
project_id: Mapped[int] = mapped_column(ForeignKey("projects.id"))
harness_id: Mapped[int] = mapped_column(ForeignKey("harnesses.id"))
nickname: Mapped[str | None] = mapped_column(default=None)
tmux_session_name: Mapped[str] = mapped_column(unique=True)
harness_session_id: Mapped[str | None] = mapped_column(default=None)
model: Mapped[str | None] = mapped_column(default=None)
created_at: Mapped[datetime] = mapped_column(insert_default=func.now())
last_activity_at: Mapped[datetime | None] = mapped_column(default=None)
archived: Mapped[bool] = mapped_column(default=False)
project: Mapped["Project"] = relationship(back_populates="sessions")
harness: Mapped["Harness"] = relationship(back_populates="sessions")
class McpServer(Base):
__tablename__ = "mcp_servers"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(unique=True)
transport: Mapped[str | None] = mapped_column(default=None)
command: Mapped[str | None] = mapped_column(default=None)
args_json: Mapped[str | None] = mapped_column(default=None)
url: Mapped[str | None] = mapped_column(default=None)
env_json: Mapped[str | None] = mapped_column(default=None)
headers_json: Mapped[str | None] = mapped_column(default=None)
class ProjectMcpServer(Base):
__tablename__ = "project_mcp_servers"
project_id: Mapped[int] = mapped_column(ForeignKey("projects.id"), primary_key=True)
mcp_server_id: Mapped[int] = mapped_column(ForeignKey("mcp_servers.id"), primary_key=True)
class ProjectSkill(Base):
__tablename__ = "project_skills"
project_id: Mapped[int] = mapped_column(ForeignKey("projects.id"), primary_key=True)
skill_name: Mapped[str] = mapped_column(String, primary_key=True)
View File
+36
View File
@@ -0,0 +1,36 @@
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from pathlib import Path
@dataclass
class SpawnConfig:
command: list[str]
env: dict[str, str] = field(default_factory=dict)
cwd: Path = Path(".")
class HarnessConfigurator(ABC):
name: str
display_name: str
captures_session_id: bool = False
@abstractmethod
def generate_session_id(self, hqt_session_id: int) -> str: ...
@abstractmethod
def build_spawn_config(self, project_path: Path, harness_session_id: str, model: str | None = None) -> SpawnConfig: ...
@abstractmethod
def build_resume_config(self, project_path: Path, harness_session_id: str, model: str | None = None) -> SpawnConfig: ...
def capture_session_id(self, project_path: Path, since: float) -> str | None:
return None
def parse_status(self, pane_text: str) -> str | None:
"""Parse visible pane text to determine harness status.
Returns "working", "waiting", or None (unknown — caller uses activity-based fallback).
Default implementation returns None for all harnesses that don't override.
"""
return None
+52
View File
@@ -0,0 +1,52 @@
import uuid
from pathlib import Path
from hqt.harnesses.base import HarnessConfigurator, SpawnConfig
NAMESPACE = uuid.UUID("a1b2c3d4-e5f6-7890-abcd-ef1234567890")
class ClaudeConfigurator(HarnessConfigurator):
name = "claude"
display_name = "Claude Code"
# Markers for parse_status — conservative, case-insensitive substring checks.
# None returned when none match; caller falls back to activity-based status.
_WORKING_MARKERS = (
"esc to interrupt",
)
_WAITING_MARKERS = (
"do you want", # permission / confirmation prompts ("Do you want to proceed?")
" 1.", # 1. — numbered choice prompt in Claude's interactive UI
)
def generate_session_id(self, hqt_session_id: int) -> str:
return str(uuid.uuid5(NAMESPACE, str(hqt_session_id)))
def build_spawn_config(self, project_path: Path, harness_session_id: str, model: str | None = None) -> SpawnConfig:
cmd = ["claude", "--session-id", harness_session_id]
if model:
cmd += ["--model", model]
return SpawnConfig(command=cmd, cwd=project_path)
def build_resume_config(self, project_path: Path, harness_session_id: str, model: str | None = None) -> SpawnConfig:
cmd = ["claude", "--resume", harness_session_id]
if model:
cmd += ["--model", model]
return SpawnConfig(command=cmd, cwd=project_path)
def parse_status(self, pane_text: str) -> str | None:
"""Infer harness status from visible pane text.
Returns "working" when claude is executing a tool call (shows interrupt hint),
"waiting" when it's waiting for user input (permission/confirmation prompt),
or None if the screen state is ambiguous (caller uses activity-based fallback).
"""
lower = pane_text.lower()
for marker in self._WORKING_MARKERS:
if marker in lower:
return "working"
for marker in self._WAITING_MARKERS:
if marker in lower:
return "waiting"
return None
+66
View File
@@ -0,0 +1,66 @@
import json
from pathlib import Path
from hqt.harnesses.base import HarnessConfigurator, SpawnConfig
class CodexConfigurator(HarnessConfigurator):
name = "codex"
display_name = "Codex"
captures_session_id = True
# Markers for parse_status — conservative, case-insensitive substring checks.
_WORKING_MARKERS = (
"esc to interrupt",
)
def generate_session_id(self, hqt_session_id: int) -> str:
return str(hqt_session_id)
def build_spawn_config(self, project_path: Path, harness_session_id: str, model: str | None = None) -> SpawnConfig:
cmd = ["codex"]
if model:
cmd += ["-m", model]
return SpawnConfig(command=cmd, cwd=project_path)
def build_resume_config(self, project_path: Path, harness_session_id: str, model: str | None = None) -> SpawnConfig:
cmd = ["codex", "resume", harness_session_id]
if model:
cmd += ["-m", model]
return SpawnConfig(command=cmd, cwd=project_path)
def parse_status(self, pane_text: str) -> str | None:
"""Infer harness status from visible pane text.
Returns "working" when codex is executing (shows interrupt hint),
or None if state is ambiguous (caller uses activity-based fallback).
"""
lower = pane_text.lower()
for marker in self._WORKING_MARKERS:
if marker in lower:
return "working"
return None
def capture_session_id(self, project_path: Path, since: float) -> str | None:
sessions_dir = Path.home() / ".codex" / "sessions"
if not sessions_dir.exists():
return None
candidates: list[tuple[float, Path]] = []
for rollout in sessions_dir.rglob("rollout-*.jsonl"):
try:
mtime = rollout.stat().st_mtime
except OSError:
continue
if mtime >= since:
candidates.append((mtime, rollout))
# Sort newest-first
candidates.sort(key=lambda t: t[0], reverse=True)
for _, rollout in candidates:
try:
with rollout.open() as f:
meta = json.loads(f.readline())
if meta.get("type") == "session_meta" and str(Path(meta["payload"]["cwd"])) == str(project_path):
return meta["payload"]["id"]
except (json.JSONDecodeError, KeyError, OSError):
continue
return None
@@ -0,0 +1,20 @@
from pathlib import Path
from hqt.harnesses.base import HarnessConfigurator, SpawnConfig
class GenericConfigurator(HarnessConfigurator):
name = "generic"
display_name = "Generic"
def __init__(self, command: list[str]) -> None:
self._command = command
def generate_session_id(self, hqt_session_id: int) -> str:
return str(hqt_session_id)
def build_spawn_config(self, project_path: Path, harness_session_id: str, model: str | None = None) -> SpawnConfig:
return SpawnConfig(command=self._command, cwd=project_path)
def build_resume_config(self, project_path: Path, harness_session_id: str, model: str | None = None) -> SpawnConfig:
return SpawnConfig(command=self._command, cwd=project_path)
+17
View File
@@ -0,0 +1,17 @@
from pathlib import Path
from hqt.harnesses.base import HarnessConfigurator, SpawnConfig
class KiroConfigurator(HarnessConfigurator):
name = "kiro"
display_name = "Kiro"
def generate_session_id(self, hqt_session_id: int) -> str:
return str(hqt_session_id)
def build_spawn_config(self, project_path: Path, harness_session_id: str, model: str | None = None) -> SpawnConfig:
return SpawnConfig(command=["kiro-cli", "chat"], cwd=project_path)
def build_resume_config(self, project_path: Path, harness_session_id: str, model: str | None = None) -> SpawnConfig:
return SpawnConfig(command=["kiro-cli", "chat"], cwd=project_path)
+35
View File
@@ -0,0 +1,35 @@
import shutil
from sqlalchemy.orm import Session as DBSession
from hqt.harnesses.base import HarnessConfigurator
from hqt.harnesses.configurators.codex import CodexConfigurator
from hqt.harnesses.configurators.claude import ClaudeConfigurator
from hqt.harnesses.configurators.kiro import KiroConfigurator
from hqt.db.models import Harness
_HARNESSES: list[tuple[str, str, type]] = [
("claude", "claude", ClaudeConfigurator),
("kiro", "kiro-cli", KiroConfigurator),
("codex", "codex", CodexConfigurator),
]
def discover_harnesses() -> dict[str, HarnessConfigurator]:
"""Find available harnesses on PATH."""
found: dict[str, HarnessConfigurator] = {}
for name, binary, cls in _HARNESSES:
if shutil.which(binary):
found[name] = cls()
return found
def ensure_harnesses_in_db(db: DBSession) -> None:
"""Seed harnesses table with discovered harnesses."""
for name, binary, cls in _HARNESSES:
if shutil.which(binary):
existing = db.query(Harness).filter_by(name=name).first()
if not existing:
configurator = cls()
db.add(Harness(name=name, display_name=configurator.display_name))
db.commit()
+20
View File
@@ -0,0 +1,20 @@
import logging
from hqt.config import get_settings
def setup_logging(level: int = logging.DEBUG) -> None:
"""Configure logging to file at ~/.local/share/hqt/hqt.log.
Logs go to the file ONLY — never to a stream handler. The TUI is a Textual
app that paints the screen to the terminal's stderr fd; a StreamHandler bound
to that same fd writes raw log lines straight onto the pane, corrupting tmux's
grid (visible as rendering artifacts when switching back to the hqt window).
"""
log_path = get_settings().db_path.parent / "hqt.log"
log_path.parent.mkdir(parents=True, exist_ok=True)
logging.basicConfig(
level=level,
format="%(asctime)s %(name)s %(levelname)s %(message)s",
handlers=[logging.FileHandler(log_path)],
)
View File
+43
View File
@@ -0,0 +1,43 @@
import json
from sqlalchemy.orm import Session as DBSession
from hqt.db.models import McpServer, ProjectMcpServer
class McpService:
def __init__(self, db: DBSession):
self.db = db
def create(self, name: str, transport: str, command: str | None = None, args: list[str] | None = None, url: str | None = None) -> McpServer:
server = McpServer(name=name, transport=transport, command=command, args_json=json.dumps(args) if args else None, url=url)
self.db.add(server)
self.db.commit()
self.db.refresh(server)
return server
def list_all(self) -> list[McpServer]:
return list(self.db.query(McpServer).all())
def get(self, name: str) -> McpServer | None:
return self.db.query(McpServer).filter_by(name=name).first()
def delete(self, name: str) -> None:
server = self.get(name)
if server:
self.db.delete(server)
self.db.commit()
def bind_to_project(self, project_id: int, mcp_server_id: int) -> None:
self.db.add(ProjectMcpServer(project_id=project_id, mcp_server_id=mcp_server_id))
self.db.commit()
def unbind_from_project(self, project_id: int, mcp_server_id: int) -> None:
self.db.query(ProjectMcpServer).filter_by(project_id=project_id, mcp_server_id=mcp_server_id).delete()
self.db.commit()
def get_project_mcps(self, project_id: int) -> list[McpServer]:
ids = [r.mcp_server_id for r in self.db.query(ProjectMcpServer).filter_by(project_id=project_id).all()]
if not ids:
return []
return list(self.db.query(McpServer).filter(McpServer.id.in_(ids)).all())
View File
+45
View File
@@ -0,0 +1,45 @@
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session as DBSession
from hqt.db.models import Project
class ProjectService:
def __init__(self, db: DBSession):
self.db = db
def create(self, name: str, path: str) -> Project:
project = Project(name=name, path=path)
self.db.add(project)
self.db.commit()
self.db.refresh(project)
return project
def list_all(self, include_archived: bool = False) -> list[Project]:
q = self.db.query(Project)
if not include_archived:
q = q.filter_by(archived=False)
return list(q.all())
def get(self, project_id: int) -> Project | None:
return self.db.get(Project, project_id)
def update(self, project_id: int, name: str, path: str) -> Project:
project = self.get(project_id)
if project is None:
raise ValueError(f"Project {project_id} not found")
project.name = name
project.path = path
try:
self.db.commit()
except IntegrityError as err:
self.db.rollback()
raise ValueError(f"Another project already uses path {path}") from err
self.db.refresh(project)
return project
def archive(self, project_id: int) -> None:
project = self.get(project_id)
if project:
project.archived = True
self.db.commit()
View File
View File
+285
View File
@@ -0,0 +1,285 @@
import asyncio
import logging
import time
from dataclasses import dataclass
from pathlib import Path
from sqlalchemy.orm import Session as DBSession
from hqt.db.models import Harness, Project, Session
from hqt.harnesses.base import HarnessConfigurator
from hqt.status import window_label
from hqt.tmux.manager import SpawnRequest, TmuxManager
from hqt.tmux.runner import WindowInfo
log = logging.getLogger(__name__)
CAPTURE_MAX_ATTEMPTS = 6
CAPTURE_RETRY_INTERVAL = 0.5
ACTIVITY_RECENT_SECS = 5
async def _capture_sleep(t: float) -> None:
await asyncio.sleep(t)
@dataclass
class SessionInfo:
session: Session
alive: bool
status: str = "dead"
@dataclass
class CreateSessionResult:
"""Result of create_session, carrying the DB row plus spawn outcome."""
session: Session
spawn_ok: bool
spawn_error: str = ""
class SessionService:
def __init__(self, db: DBSession, tmux: TmuxManager, harnesses: dict[str, HarnessConfigurator]):
self.db = db
self.tmux = tmux
self.harnesses = harnesses
async def _capture_session_id_with_retry(
self,
configurator: HarnessConfigurator,
project_path: Path,
since: float,
window_name: str,
) -> str | None:
"""Retry capture_session_id up to CAPTURE_MAX_ATTEMPTS times.
Returns the captured id, or None if all attempts fail (a warning is
logged on exhaustion).
"""
captured_id: str | None = None
for attempt in range(CAPTURE_MAX_ATTEMPTS):
if attempt > 0:
await _capture_sleep(CAPTURE_RETRY_INTERVAL)
captured_id = configurator.capture_session_id(project_path, since)
if captured_id:
break
if not captured_id:
log.warning(
"capture_session_id exhausted %d attempts for window %s; keeping placeholder id",
CAPTURE_MAX_ATTEMPTS,
window_name,
)
return captured_id
async def create_session(
self,
project_id: int,
harness_name: str,
nickname: str | None = None,
model: str | None = None,
) -> "CreateSessionResult":
"""Create a new session row, spawn the harness window, and return a
CreateSessionResult with the Session row and spawn outcome.
The capture-retry loop only runs when spawn succeeded.
"""
log.info("Creating session: project=%s harness=%s", project_id, harness_name)
harness_row = self.db.query(Harness).filter_by(name=harness_name).first()
if not harness_row:
log.error("Harness not in DB: %s", harness_name)
raise ValueError(f"Unknown harness: {harness_name}")
configurator = self.harnesses[harness_name]
sess = Session(project_id=project_id, harness_id=harness_row.id, nickname=nickname, model=model, tmux_session_name="placeholder", archived=False)
self.db.add(sess)
self.db.flush()
sess.tmux_session_name = f"hqt-{sess.id}"
sess.harness_session_id = configurator.generate_session_id(sess.id)
project = self.db.get(Project, project_id)
spawn_cfg = configurator.build_spawn_config(Path(project.path), sess.harness_session_id, model)
log.info("Spawning window %s: cmd=%s cwd=%s", sess.tmux_session_name, spawn_cfg.command, spawn_cfg.cwd)
since = time.time()
spawn_result = await self.tmux.spawn(SpawnRequest(
window_name=sess.tmux_session_name, command=spawn_cfg.command, cwd=str(spawn_cfg.cwd), env=spawn_cfg.env,
))
if not spawn_result.ok:
log.error(
"Failed to spawn window %s: %s",
sess.tmux_session_name,
spawn_result.error or "(no output captured)",
)
# Only attempt capture when the spawn actually succeeded; a failed spawn
# could inadvertently capture an unrelated session running in the same cwd.
if spawn_result.ok and configurator.captures_session_id:
captured_id = await self._capture_session_id_with_retry(
configurator, Path(project.path), since, sess.tmux_session_name
)
if captured_id:
sess.harness_session_id = captured_id
self.db.commit()
log.info("Session created: id=%s window=%s", sess.id, sess.tmux_session_name)
return CreateSessionResult(
session=sess,
spawn_ok=spawn_result.ok,
spawn_error=spawn_result.error or "",
)
async def attach_session(self, session_id: int) -> bool:
"""Attach to a session. Handles all states: alive, dead pane, gone."""
sess = self.db.get(Session, session_id)
window_name = sess.tmux_session_name
if await self.tmux.is_alive(window_name):
# Window exists, process running — just switch
return await self.tmux.attach(window_name)
# Window exists but pane is dead, OR window is completely gone
log.info("Session %s not alive, respawning with fallback ladder", window_name)
ok = await self._respawn_with_fallback(sess, window_name)
if not ok:
return False
return await self.tmux.attach(window_name)
async def _respawn_with_fallback(self, sess: Session, window_name: str) -> bool:
"""Try resume config first; if it dies, fall back to fresh spawn config.
Returns True if either rung succeeded, False if both failed.
On rung-2 success, if the harness captures_session_id, a capture-retry
loop updates sess.harness_session_id so later resumes target the fresh
conversation rather than the stale/nonexistent one.
"""
# Rung 1: resume (restore prior conversation)
resume_cfg = self._get_resume_config(sess)
result = await self.tmux.respawn_verified(window_name, resume_cfg.command, str(resume_cfg.cwd), env=resume_cfg.env)
if result.ok:
return True
log.warning(
"Resume failed for %s (%s), falling back to fresh spawn",
window_name,
result.error or "(no output)",
)
# Rung 2: fresh spawn (codex ignores the old session id; start fresh)
configurator = self.harnesses[sess.harness.name]
project = self.db.get(Project, sess.project_id)
spawn_cfg = configurator.build_spawn_config(Path(project.path), sess.harness_session_id, sess.model)
since = time.time()
result = await self.tmux.respawn_verified(window_name, spawn_cfg.command, str(spawn_cfg.cwd), env=spawn_cfg.env)
if not result.ok:
log.error(
"Fallback spawn also failed for %s: %s",
window_name,
result.error or "(no output)",
)
return False
# Rung-2 succeeded — capture the new session id if the harness supports it
# so that future resumes target this fresh conversation.
if configurator.captures_session_id:
new_id = await self._capture_session_id_with_retry(
configurator, Path(project.path), since, window_name
)
if new_id:
sess.harness_session_id = new_id
self.db.commit()
else:
log.warning(
"capture_session_id exhausted after rung-2 for %s; keeping old id %s",
window_name,
sess.harness_session_id,
)
return True
async def stop_session(self, session_id: int) -> None:
sess = self.db.get(Session, session_id)
if await self.tmux.window_exists(sess.tmux_session_name):
await self.tmux.kill(sess.tmux_session_name)
def get_session(self, session_id: int) -> Session | None:
"""Return the session row, or None if it does not exist."""
return self.db.get(Session, session_id)
def rename_session(self, session_id: int, nickname: str | None) -> None:
"""Update a session's display nickname.
An empty/blank nickname clears it to None, so the label falls back to
the tmux window name. The tmux window label is refreshed by the next
poll (sync_window_labels), so no immediate tmux call is needed here.
"""
sess = self.db.get(Session, session_id)
sess.nickname = (nickname or "").strip() or None
self.db.commit()
async def delete_session(self, session_id: int) -> None:
sess = self.db.get(Session, session_id)
if await self.tmux.window_exists(sess.tmux_session_name):
await self.tmux.kill(sess.tmux_session_name)
self.db.delete(sess)
self.db.commit()
async def _status_for(self, sess: Session, wi: WindowInfo | None, now: float) -> str:
"""Compute a session's status string from its window info.
Dead/missing window → "dead". Alive window → the harness's parsed status
(working/waiting) when recognizable, else an activity-based active/idle.
capture_pane is only called for alive windows.
"""
if wi is None or not wi.alive:
return "dead"
configurator = self.harnesses.get(sess.harness.name)
pane_text = await self.tmux.capture_pane(sess.tmux_session_name)
parsed: str | None = None
if pane_text and configurator is not None:
parsed = configurator.parse_status(pane_text)
if parsed is not None:
return parsed
age = now - wi.last_activity
return "active" if age <= ACTIVITY_RECENT_SECS else "idle"
async def list_sessions(
self, project_id: int, now: float | None = None
) -> list[SessionInfo]:
if now is None:
now = time.time()
sessions = self.db.query(Session).filter_by(project_id=project_id, archived=False).all()
names = [s.tmux_session_name for s in sessions]
info_map = await self.tmux.poll_info(names)
result: list[SessionInfo] = []
for s in sessions:
wi = info_map.get(s.tmux_session_name)
status = await self._status_for(s, wi, now)
result.append(SessionInfo(session=s, alive=bool(wi and wi.alive), status=status))
return result
async def sync_window_labels(self, now: float | None = None) -> list[SessionInfo]:
"""Refresh every session's tmux window label to reflect its status.
Runs session-wide (all projects), so the tmux status bar stays accurate
regardless of which project is selected in the TUI. Returns SessionInfo
for all sessions so the caller can reuse the computed status for the UI
without recomputing. Labels are set by NAME; windows that no longer exist
are harmless no-ops.
"""
if now is None:
now = time.time()
sessions = self.db.query(Session).filter_by(archived=False).all()
names = [s.tmux_session_name for s in sessions]
info_map = await self.tmux.poll_info(names)
result: list[SessionInfo] = []
for s in sessions:
wi = info_map.get(s.tmux_session_name)
alive = bool(wi and wi.alive)
status = await self._status_for(s, wi, now)
result.append(SessionInfo(session=s, alive=alive, status=status))
name = s.nickname or s.tmux_session_name
await self.tmux.set_window_label(
s.tmux_session_name, window_label(status, name, alive=alive)
)
return result
def _get_resume_config(self, sess: Session):
configurator = self.harnesses[sess.harness.name]
project = self.db.get(Project, sess.project_id)
return configurator.build_resume_config(Path(project.path), sess.harness_session_id, sess.model)
View File
+33
View File
@@ -0,0 +1,33 @@
from dataclasses import dataclass
from pathlib import Path
import yaml
@dataclass
class Skill:
name: str
description: str
content: str
class SkillRegistry:
def __init__(self, skills_dir: Path):
self.skills_dir = skills_dir
def discover(self) -> list[Skill]:
skills = []
if not self.skills_dir.exists():
return skills
for skill_file in self.skills_dir.glob("*/SKILL.md"):
text = skill_file.read_text()
parts = text.split("---", 2)
if len(parts) < 3:
continue
meta = yaml.safe_load(parts[1])
skills.append(Skill(
name=meta["name"],
description=meta.get("description", ""),
content=parts[2].strip(),
))
return skills
+23
View File
@@ -0,0 +1,23 @@
"""Shared session-status vocabulary.
Both the TUI session list and the tmux window-status labels render the same
status glyphs, so the symbols live here and are imported by both.
"""
STATUS_SYMBOLS: dict[str, str] = {
"working": "",
"waiting": "",
"active": "",
"idle": "",
"dead": "",
}
def status_symbol(status: str, *, alive: bool = True) -> str:
"""Return the glyph for a status, falling back to a generic alive/dead dot."""
return STATUS_SYMBOLS.get(status, "" if alive else "")
def window_label(status: str, name: str, *, alive: bool = True) -> str:
"""Build the tmux window-status label shown in the bar: ``<symbol> <name>``."""
return f"{status_symbol(status, alive=alive)} {name}"
View File
+106
View File
@@ -0,0 +1,106 @@
import logging
import shlex
from dataclasses import dataclass, field
from hqt.tmux.runner import TmuxRunner, WindowInfo
log = logging.getLogger(__name__)
@dataclass
class SpawnRequest:
window_name: str
command: list[str]
cwd: str
env: dict[str, str] | None = None
@dataclass
class SpawnResult:
ok: bool
window_id: str | None
error: str = field(default="")
class TmuxManager:
"""Manages harness windows within the hqt tmux session."""
def __init__(self, runner: TmuxRunner):
self.runner = runner
async def spawn(self, req: SpawnRequest) -> SpawnResult:
"""Spawn a new harness window. Returns SpawnResult with ok, window_id, and error."""
cmd = shlex.join(req.command)
window_id = await self.runner.new_window(req.window_name, req.cwd, cmd, env=req.env)
if window_id is None:
return SpawnResult(ok=False, window_id=None, error="new_window failed")
alive, pane_text = await self.runner.verify_window_alive(req.window_name)
if not alive:
error = pane_text.strip() or f"Window {req.window_name!r} pane died immediately"
return SpawnResult(ok=False, window_id=window_id, error=error)
return SpawnResult(ok=True, window_id=window_id, error="")
async def attach(self, window_name: str) -> bool:
"""Switch to an existing window. If pane is dead, respawn first."""
if not await self.runner.has_window(window_name):
return False
return await self.runner.select_window(window_name)
async def respawn_verified(
self,
window_name: str,
command: list[str],
cwd: str,
env: dict[str, str] | None = None,
) -> SpawnResult:
"""Respawn and verify the pane stays alive. Returns SpawnResult like spawn()."""
cmd = shlex.join(command)
window_id: str | None = None
if not await self.runner.has_window(window_name):
# Window gone entirely — create fresh, which gives us a window_id
window_id = await self.runner.new_window(window_name, cwd, cmd, env=env)
if window_id is None:
return SpawnResult(ok=False, window_id=None, error=f"new_window failed for {window_name!r}")
else:
ok = await self.runner.respawn_pane(window_name, cmd, cwd, env=env)
if not ok:
return SpawnResult(ok=False, window_id=None, error=f"respawn_pane failed for {window_name!r}")
alive, pane_text = await self.runner.verify_window_alive(window_name)
if not alive:
error = pane_text.strip() or f"Window {window_name!r} pane died immediately"
return SpawnResult(ok=False, window_id=window_id, error=error)
return SpawnResult(ok=True, window_id=window_id, error="")
async def kill(self, window_name: str) -> None:
await self.runner.kill_window(window_name)
async def is_alive(self, window_name: str) -> bool:
"""Window exists and pane process is still running."""
if not await self.runner.has_window(window_name):
return False
return not await self.runner.is_pane_dead(window_name)
async def window_exists(self, window_name: str) -> bool:
return await self.runner.has_window(window_name)
async def poll_info(self, names: list[str]) -> dict[str, WindowInfo]:
"""Return WindowInfo for each named window in a single tmux exec.
Windows missing from tmux output map to a dead WindowInfo(alive=False, last_activity=0).
"""
info = await self.runner.list_windows_info()
dead = WindowInfo(alive=False, last_activity=0)
return {name: info.get(name, dead) for name in names}
async def capture_pane(self, window_name: str) -> str:
"""Return the visible text of the named window's pane."""
return await self.runner.capture_pane(window_name)
async def set_window_label(self, window_name: str, label: str) -> None:
"""Set the status-bar label for a window (status symbol + name)."""
await self.runner.set_window_label(window_name, label)
+286
View File
@@ -0,0 +1,286 @@
import asyncio
import logging
from dataclasses import dataclass
log = logging.getLogger(__name__)
# Status-bar format for hqt windows: show the per-window @hqt_label user option
# (a status symbol + name set by set_window_label) when present, else the plain
# window name. #F keeps tmux's flag indicators (*, -, etc.).
WINDOW_STATUS_FORMAT = "#I:#{?@hqt_label,#{@hqt_label},#W}#F"
@dataclass(frozen=True)
class WindowInfo:
"""Activity metadata for a single tmux window."""
alive: bool
last_activity: int
class TmuxRunner:
"""Low-level async tmux command wrapper. All operations target windows within a single session."""
def __init__(self, tmux_path: str = "tmux", session_name: str = "hqt"):
self.tmux_path = tmux_path
self.session_name = session_name
async def _exec(self, *args: str) -> tuple[int, str, str]:
"""Run tmux command, return (returncode, stdout, stderr)."""
proc = await asyncio.create_subprocess_exec(
self.tmux_path, *args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await proc.communicate()
return proc.returncode, stdout.decode(), stderr.decode()
def _target(self, window_name: str) -> str:
return f"{self.session_name}:={window_name}"
# --- Session-level ---
async def has_session(self) -> bool:
rc, _, _ = await self._exec("has-session", "-t", self.session_name)
return rc == 0
# --- Window-level ---
async def _next_window_index(self) -> int:
"""Return a guaranteed-free window index for a new window.
new-window with a session-only target (`-t hqt`) is unreliable: when the
session is attached, tmux resolves the target to the session's current
window and tries to create at THAT index, which is always occupied
("create window failed: index N in use"). Targeting an explicit free index
(max existing + 1) avoids the resolution entirely. Defaults to 0 when the
session somehow has no windows.
"""
rc, stdout, _ = await self._exec(
"list-windows", "-t", self.session_name, "-F", "#{window_index}",
)
if rc != 0:
return 0
indices = [int(x) for x in stdout.split() if x.strip().lstrip("-").isdigit()]
return max(indices) + 1 if indices else 0
@staticmethod
def _env_flags(env: dict[str, str] | None) -> list[str]:
"""Build a flat list of -e KEY=VALUE flags for a tmux env dict."""
if not env:
return []
flags: list[str] = []
for key, value in env.items():
flags.extend(["-e", f"{key}={value}"])
return flags
async def new_window(
self,
window_name: str,
cwd: str,
command: str,
env: dict[str, str] | None = None,
) -> str | None:
"""Create a new window in the hqt session.
Race-free three-step sequence:
1. new-window with no command (default shell keeps window alive), capture window_id.
2. set-option remain-on-exit on (guaranteed before real command).
3. respawn-pane to start the real command (env flags go here, not step 1).
Returns the window_id string on success, None on failure.
"""
# Step 1: create window without a command so it survives instant failures.
# Do NOT pass -e here: new-window -e only sets env for the throwaway initial
# shell, which does not propagate to the process started by respawn-pane -k.
# Target an explicit free index rather than the bare session: a session-only
# target makes an attached tmux create at the active window's (occupied)
# index, failing "index N in use" — see _next_window_index.
idx = await self._next_window_index()
rc, stdout, err = await self._exec(
"new-window",
"-t", f"{self.session_name}:{idx}",
"-n", window_name,
"-c", cwd,
"-P", "-F", "#{window_id}",
)
if rc != 0:
log.error("new-window failed: %s", err)
return None
window_id = stdout.strip()
# Step 2: set remain-on-exit, allow-rename off, and automatic-rename off before
# the real command runs. A single tmux invocation with ";" command separators
# (each ";" is its own argv element — no shell involved) keeps this atomic.
target = self._target(window_name)
rc, _, err = await self._exec(
"set-option", "-t", target, "remain-on-exit", "on",
";", "set-option", "-t", target, "allow-rename", "off",
";", "set-option", "-t", target, "automatic-rename", "off",
)
if rc != 0:
log.error("set-option (window options) failed for %s: %s", window_name, err)
await self.kill_window(window_name)
return None
# Step 3: launch the real command via respawn-pane, passing env here where
# it actually takes effect (respawn-pane -e is supported since tmux ≥3.0).
ok = await self.respawn_pane(window_name, command, cwd, env=env)
if not ok:
log.error("respawn-pane (initial) failed for %s", window_name)
await self.kill_window(window_name)
return None
return window_id
async def has_window(self, window_name: str) -> bool:
"""Check if a window with this name exists."""
rc, stdout, _ = await self._exec(
"list-windows", "-t", self.session_name, "-F", "#{window_name}",
"-f", f"#{{==:#{{window_name}},{window_name}}}",
)
if rc != 0:
return False
return window_name in stdout
async def is_pane_dead(self, window_name: str) -> bool:
"""Check if the pane in this window has exited (remain-on-exit keeps it visible)."""
rc, stdout, _ = await self._exec(
"list-panes", "-t", self._target(window_name), "-F", "#{pane_dead}",
)
if rc != 0:
return True # Window doesn't exist, treat as dead
return stdout.strip() == "1"
async def verify_window_alive(
self,
window_name: str,
timeout: float = 2.0,
interval: float = 0.25,
) -> tuple[bool, str]:
"""Poll is_pane_dead until timeout.
Returns (True, "") if the pane stays alive through the timeout.
Returns (False, captured_text) if the pane dies within the window.
"""
loop = asyncio.get_running_loop()
deadline = loop.time() + timeout
while loop.time() < deadline:
if await self.is_pane_dead(window_name):
text = await self.capture_pane(window_name)
return False, text
await asyncio.sleep(interval)
# Final check: pane may have died during the last sleep interval
if await self.is_pane_dead(window_name):
text = await self.capture_pane(window_name)
return False, text
return True, ""
async def respawn_pane(
self,
window_name: str,
command: str,
cwd: str,
env: dict[str, str] | None = None,
) -> bool:
"""Respawn a dead pane with a new command.
env, if provided, is passed as -e KEY=VALUE flags. Requires tmux 3.0.
"""
rc, _, err = await self._exec(
"respawn-pane", "-t", self._target(window_name), "-k", "-c", cwd,
*self._env_flags(env),
command,
)
if rc != 0:
log.error("respawn-pane failed: %s", err)
return rc == 0
async def set_window_label(self, window_name: str, label: str) -> None:
"""Set the per-window status-bar label shown in the tmux bar.
Applies a label-aware ``window-status-format`` (idempotent) plus the
``@hqt_label`` user option in a single atomic tmux invocation. The window
is targeted by NAME, so its identity used everywhere else for
targeting is preserved; we never rename the window. Setting the format
per-window keeps it scoped to this session's windows and never touches
the user's other sessions or the global option.
"""
target = self._target(window_name)
await self._exec(
"set-option", "-w", "-t", target, "window-status-format", WINDOW_STATUS_FORMAT,
";", "set-option", "-w", "-t", target, "window-status-current-format", WINDOW_STATUS_FORMAT,
";", "set-option", "-w", "-t", target, "@hqt_label", label,
)
async def select_window(self, window_name: str) -> bool:
"""Switch the active window to the named window."""
rc, _, _ = await self._exec("select-window", "-t", self._target(window_name))
return rc == 0
async def kill_window(self, window_name: str) -> None:
await self._exec("kill-window", "-t", self._target(window_name))
# --- Utilities ---
async def list_windows(self) -> list[str]:
"""List all window names in the session."""
rc, stdout, _ = await self._exec(
"list-windows", "-t", self.session_name, "-F", "#{window_name}",
)
if rc != 0:
return []
return [line for line in stdout.strip().splitlines() if line]
async def list_windows_info(self) -> dict[str, WindowInfo]:
"""Return activity metadata for all windows in a single exec.
Runs ``list-panes -s`` which enumerates every pane in the session.
Each line is ``window_name<TAB>pane_dead<TAB>window_activity``.
A window is alive if at least one of its panes has pane_dead==0.
last_activity is the max across all panes. Returns {} on rc != 0.
Malformed lines (not exactly 3 columns) are skipped silently.
"""
rc, stdout, _ = await self._exec(
"list-panes", "-s", "-t", self.session_name, "-F",
"#{window_name}\t#{pane_dead}\t#{window_activity}",
)
if rc != 0:
return {}
alive_map: dict[str, bool] = {}
activity_map: dict[str, int] = {}
for line in stdout.splitlines():
line = line.strip()
if not line:
continue
parts = line.split("\t", 2)
if len(parts) != 3:
continue
window_name, pane_dead, window_activity = parts
try:
activity = int(window_activity)
except ValueError:
continue
pane_alive = pane_dead == "0"
if window_name not in alive_map:
alive_map[window_name] = pane_alive
activity_map[window_name] = activity
else:
alive_map[window_name] = alive_map[window_name] or pane_alive
activity_map[window_name] = max(activity_map[window_name], activity)
return {
name: WindowInfo(alive=alive_map[name], last_activity=activity_map[name])
for name in alive_map
}
async def send_text(self, window_name: str, text: str) -> None:
await self._exec("send-keys", "-t", self._target(window_name), "-l", "--", text)
async def send_enter(self, window_name: str) -> None:
await self._exec("send-keys", "-t", self._target(window_name), "Enter")
async def capture_pane(self, window_name: str) -> str:
_, stdout, _ = await self._exec(
"capture-pane", "-t", self._target(window_name), "-p", "-J",
)
return stdout
View File
+274
View File
@@ -0,0 +1,274 @@
import logging
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.screen import ModalScreen
from textual.theme import Theme
from textual.widgets import Footer
from hqt.config import get_settings
log = logging.getLogger(__name__)
from hqt.db.engine import ensure_db, get_engine, get_session_factory
from hqt.harnesses.registry import discover_harnesses, ensure_harnesses_in_db
from hqt.projects.service import ProjectService
from hqt.sessions.service import SessionService
from hqt.tmux.manager import TmuxManager
from hqt.tmux.runner import TmuxRunner
from hqt.tui.screens.project_form import ProjectFormScreen
from hqt.tui.screens.new_session import NewSessionScreen
from hqt.tui.screens.rename_session import RenameSessionScreen
from hqt.tui.widgets.project_list import ProjectList, ProjectSelected
from hqt.tui.widgets.session_list import SessionList, SessionSelected
# Catppuccin Frappé, mirroring the structure of Textual's built-in
# catppuccin-mocha theme so no colors are auto-derived off-palette.
FRAPPE_THEME = Theme(
name="catppuccin-frappe",
primary="#8caaee", # Blue
secondary="#ca9ee6", # Mauve
accent="#ef9f76", # Peach
success="#a6d189", # Green
warning="#e5c890", # Yellow
error="#e78284", # Red
foreground="#c6d0f5", # Text
background="#292c3c", # Mantle
surface="#414559", # Surface0
panel="#51576d", # Surface1
dark=True,
variables={
"input-cursor-foreground": "#232634", # Crust
"input-cursor-background": "#f2d5cf", # Rosewater
"input-selection-background": "#949cbb 30%", # Overlay2 30%
"border": "#babbf1", # Lavender
"border-blurred": "#626880", # Surface2
"footer-background": "#51576d", # Surface1
"block-cursor-foreground": "#303446", # Base
"block-cursor-text-style": "none",
"button-color-foreground": "#292c3c", # Mantle
},
)
class HqtApp(App):
TITLE = "HQ Terminal"
BINDINGS = [
Binding("q", "quit", "Quit"),
Binding("n", "new_session", "New Session"),
Binding("a", "add_project", "Add Project"),
Binding("e", "edit_project", "Edit Project"),
Binding("tab", "toggle_focus", "Switch Panel"),
Binding("d", "delete_session", "Delete Session"),
Binding("enter", "attach_session", "Attach", priority=True),
Binding("r", "rename_session", "Rename"),
Binding("s", "stop_session", "Stop"),
]
CSS_PATH = "styles.tcss"
def __init__(self) -> None:
super().__init__()
# Register the custom theme BEFORE applying it: setting `theme` as a
# class attribute would apply it during super().__init__(), before the
# theme exists, leaving CSS variables ($primary etc.) resolved against
# a stale palette (Blue would render as Mauve).
self.register_theme(FRAPPE_THEME)
self.theme = "catppuccin-frappe"
self._selected_project_id: int | None = None
# project_id -> last selected session_id, so switching projects restores
# each project's previously highlighted session.
self._selected_session_by_project: dict[int, int] = {}
self._db_session = None
self._session_service: SessionService | None = None
self._project_service: ProjectService | None = None
def compose(self) -> ComposeResult:
# No Header: the project and session panels share the top row directly
# (ProjectList docks left, SessionList fills the rest), with the keybind
# Footer at the bottom — a cleaner, chrome-free layout.
yield ProjectList()
yield SessionList()
yield Footer()
def on_mount(self) -> None:
log.info("TUI mounting")
settings = get_settings()
ensure_db(settings)
engine = get_engine(settings)
factory = get_session_factory(engine)
self._db_session = factory()
ensure_harnesses_in_db(self._db_session)
runner = TmuxRunner(settings.tmux_path, settings.tui_session_name)
tmux = TmuxManager(runner)
harnesses = discover_harnesses()
log.info("Discovered harnesses: %s", list(harnesses.keys()))
self._project_service = ProjectService(self._db_session)
self._session_service = SessionService(self._db_session, tmux, harnesses)
self._load_projects()
self.set_interval(3, self._poll_sessions)
log.info("TUI mounted successfully")
def _load_projects(self) -> None:
projects = self._project_service.list_all()
async def _do() -> None:
await self.query_one(ProjectList).refresh_projects(projects)
self.run_worker(_do())
async def _poll_sessions(self) -> None:
# Sync every session's tmux window label (session-wide), then update the
# UI for the selected project from the same computed status — no recompute.
infos = await self._session_service.sync_window_labels()
if self._selected_project_id is not None:
subset = [
i for i in infos if i.session.project_id == self._selected_project_id
]
await self.query_one(SessionList).refresh_sessions(subset)
async def _refresh_sessions(self, restore_selection: bool = False) -> None:
if self._selected_project_id is None:
return
infos = await self._session_service.list_sessions(self._selected_project_id)
sl = self.query_one(SessionList)
if restore_selection:
# Project switch: restore this project's remembered session, or let the
# widget default to the first session when there's no memory yet.
remembered = self._selected_session_by_project.get(self._selected_project_id)
await sl.refresh_sessions(infos, select_session_id=remembered)
else:
# Poll / post-action refresh: keep the current selection.
await sl.refresh_sessions(infos)
def on_project_selected(self, message: ProjectSelected) -> None:
self._selected_project_id = message.project_id
self.run_worker(self._refresh_sessions(restore_selection=True))
def on_session_selected(self, message: SessionSelected) -> None:
if self._selected_project_id is not None:
self._selected_session_by_project[self._selected_project_id] = message.session_id
def action_toggle_focus(self) -> None:
self.screen.focus_next()
def action_add_project(self) -> None:
def on_dismiss(result: tuple[str, str] | None) -> None:
if result:
name, path = result
self._project_service.create(name, path)
self._load_projects()
self.push_screen(ProjectFormScreen(), on_dismiss)
def action_edit_project(self) -> None:
project_id = self.query_one(ProjectList).get_selected_project_id()
if project_id is None:
self.notify("Select a project first", severity="warning")
return
project = self._project_service.get(project_id)
if project is None:
self.notify("Project not found", severity="error")
return
def on_dismiss(result: tuple[str, str] | None) -> None:
if result:
name, path = result
try:
self._project_service.update(project_id, name, path)
except ValueError as err:
self.notify(str(err), severity="error")
return
self._load_projects()
self.push_screen(
ProjectFormScreen(
title="Edit Project",
initial_name=project.name,
initial_path=project.path,
),
on_dismiss,
)
def action_new_session(self) -> None:
if self._selected_project_id is None:
self.notify("Select a project first", severity="warning")
return
harness_names = list(discover_harnesses().keys())
if not harness_names:
self.notify("No harnesses found", severity="error")
return
def on_dismiss(result: tuple[str, str, str | None] | None) -> None:
if result:
harness_name, nickname, model = result
async def _do() -> None:
create_result = await self._session_service.create_session(
self._selected_project_id, harness_name, nickname, model
)
if not create_result.spawn_ok:
first_line = next(
(ln for ln in create_result.spawn_error.splitlines() if ln.strip()),
create_result.spawn_error,
)[:120]
self.notify(
f"Session created but harness failed to start: {first_line}",
severity="error",
)
await self._refresh_sessions()
self.run_worker(_do())
self.push_screen(NewSessionScreen(harness_names), on_dismiss)
def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
# `Binding("enter", "attach_session", priority=True)` is app-global, so
# without this it would fire inside modal dialogs (New Session, Project
# Form) and swallow Enter before the dialog's own widgets could see it —
# making Enter useless for selecting/confirming in those dialogs.
# Disabling the action while a modal is active lets Enter fall through to
# the focused dialog widget.
if action == "attach_session" and isinstance(self.screen, ModalScreen):
return False
return True
def action_attach_session(self) -> None:
sid = self.query_one(SessionList).get_selected_session_id()
if sid:
async def _do() -> None:
ok = await self._session_service.attach_session(sid)
if not ok:
self.notify("Failed to attach session", severity="error")
self.run_worker(_do())
def action_delete_session(self) -> None:
sid = self.query_one(SessionList).get_selected_session_id()
if sid:
async def _do() -> None:
await self._session_service.delete_session(sid)
await self._refresh_sessions()
self.run_worker(_do())
def action_rename_session(self) -> None:
sid = self.query_one(SessionList).get_selected_session_id()
if sid is None:
self.notify("Select a session first", severity="warning")
return
sess = self._session_service.get_session(sid)
if sess is None:
self.notify("Session not found", severity="error")
return
def on_dismiss(result: str | None) -> None:
if result is not None:
self._session_service.rename_session(sid, result)
self.run_worker(self._refresh_sessions())
self.push_screen(
RenameSessionScreen(initial_nickname=sess.nickname or ""),
on_dismiss,
)
def action_stop_session(self) -> None:
sid = self.query_one(SessionList).get_selected_session_id()
if sid:
async def _do() -> None:
await self._session_service.stop_session(sid)
await self._refresh_sessions()
self.run_worker(_do())
View File
+51
View File
@@ -0,0 +1,51 @@
from textual.app import ComposeResult
from textual.containers import Horizontal, Vertical
from textual.screen import ModalScreen
from textual.widgets import Button, Input, Label, Select
class NewSessionScreen(ModalScreen[tuple[str, str, str | None] | None]):
def __init__(self, harness_names: list[str]) -> None:
self.harness_names = harness_names
super().__init__()
def compose(self) -> ComposeResult:
with Vertical(id="new-session-dialog"):
yield Label("New Session")
yield Label("Harness:")
# allow_blank=False: otherwise Select prepends a phantom blank option
# at index 0, so opening the dropdown highlights an empty row and
# Enter selects nothing. Preselecting the first harness makes keyboard
# selection land where the user expects.
yield Select(
[(n, n) for n in self.harness_names],
id="harness-select",
allow_blank=False,
)
yield Label("Nickname:")
yield Input(placeholder="session nickname", id="nickname-input")
yield Label("Model (optional):")
yield Input(placeholder="model name", id="model-input")
with Horizontal(classes="dialog-actions"):
yield Button("OK", variant="primary", id="ok-btn")
yield Button("Cancel", id="cancel-btn")
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "ok-btn":
self._submit()
else:
self.dismiss(None)
def on_input_submitted(self, event: Input.Submitted) -> None:
# Pressing Enter in a text field confirms the dialog, so the whole flow
# can be completed from the keyboard without reaching for the mouse.
self._submit()
def _submit(self) -> None:
harness = self.query_one("#harness-select", Select).value
nickname = self.query_one("#nickname-input", Input).value
model = self.query_one("#model-input", Input).value or None
if harness and harness != Select.BLANK:
self.dismiss((str(harness), nickname, model))
else:
self.dismiss(None)
+51
View File
@@ -0,0 +1,51 @@
from pathlib import Path
from textual.app import ComposeResult
from textual.containers import Horizontal, Vertical
from textual.screen import ModalScreen
from textual.widgets import Button, Input, Label
class ProjectFormScreen(ModalScreen[tuple[str, str] | None]):
def __init__(
self,
title: str = "Add Project",
initial_name: str = "",
initial_path: str = "",
) -> None:
self._form_title = title
self._initial_name = initial_name
self._initial_path = initial_path
super().__init__()
def compose(self) -> ComposeResult:
with Vertical(id="project-form-dialog"):
yield Label(self._form_title)
yield Label("Path:")
yield Input(
value=self._initial_path,
placeholder="/path/to/project",
id="path-input",
)
yield Label("Name (optional):")
yield Input(
value=self._initial_name,
placeholder="project name",
id="name-input",
)
with Horizontal(classes="dialog-actions"):
yield Button("OK", variant="primary", id="ok-btn")
yield Button("Cancel", id="cancel-btn")
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "ok-btn":
path = self.query_one("#path-input", Input).value.strip()
name = self.query_one("#name-input", Input).value.strip()
if path:
if not name:
name = Path(path).name
self.dismiss((name, path))
else:
self.dismiss(None)
else:
self.dismiss(None)
+40
View File
@@ -0,0 +1,40 @@
from textual.app import ComposeResult
from textual.containers import Horizontal, Vertical
from textual.screen import ModalScreen
from textual.widgets import Button, Input, Label
class RenameSessionScreen(ModalScreen[str | None]):
"""Rename a session's display nickname.
Dismisses with the stripped nickname on OK, or None on Cancel. Mirrors
ProjectFormScreen's structure so it picks up the same dialog styling
(#project-form-dialog rules are reused via the shared id).
"""
def __init__(self, initial_nickname: str = "") -> None:
self._initial_nickname = initial_nickname
super().__init__()
def compose(self) -> ComposeResult:
with Vertical(id="project-form-dialog"):
yield Label("Rename Session")
yield Label("Name:")
yield Input(
value=self._initial_nickname,
placeholder="session name",
id="nickname-input",
)
with Horizontal(classes="dialog-actions"):
yield Button("OK", variant="primary", id="ok-btn")
yield Button("Cancel", id="cancel-btn")
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "ok-btn":
self.dismiss(self.query_one("#nickname-input", Input).value.strip())
else:
self.dismiss(None)
def on_input_submitted(self, event: Input.Submitted) -> None:
# Enter in the text field confirms, matching the New Session dialog.
self.dismiss(self.query_one("#nickname-input", Input).value.strip())
+87
View File
@@ -0,0 +1,87 @@
ProjectList {
width: 1fr;
min-width: 20;
max-width: 30;
dock: left;
border-right: solid $primary;
}
SessionList {
width: 3fr;
}
#project-header, #session-header {
width: 100%;
text-style: bold;
background: $surface;
}
/* Textual's ListView defaults to `background: $surface`, which in Catppuccin
is a light grey-blue Surface0 — too prominent as a full-panel fill. Use the
dark $background so panel bodies read as the Catppuccin base, not grey. */
#project-list, #session-list {
background: $background;
}
ProjectFormScreen, NewSessionScreen {
align: center middle;
}
#project-form-dialog, #new-session-dialog {
width: 60;
height: auto;
padding: 1 2;
background: $surface;
border: solid $border;
}
/* Action row: flat, single-row buttons in a centered row. Textual's default
Button uses `tall` top/bottom borders, which force a 3-row beveled "block"
that reads as heavy and dated. Dropping the bevel (border: none) collapses
each button to one row for a flatter, modern look. */
.dialog-actions {
width: 100%;
height: auto;
align-horizontal: center;
margin-top: 1;
}
.dialog-actions Button {
height: 1;
min-width: 12;
border: none;
margin: 0 1;
}
/* Focus indicator. The buttons drop Textual's default beveled border + reverse
focus styling (border: none above), so a Tab-focused button was nearly
indistinguishable — only the label went bold, invisible on the filled OK and
ghost Cancel. Give focus a clear, shared Peach highlight. The id-level rules
below (#ok-btn) outrank a `.dialog-actions Button:focus` selector, so the
focus rule must be id-scoped too or it would never apply to OK. */
#ok-btn:focus, #cancel-btn:focus {
background: $accent;
color: $background;
text-style: bold;
}
#ok-btn {
background: $primary;
color: $background;
}
#ok-btn:hover {
background: $primary-lighten-1;
}
/* Cancel is a quiet "ghost" button: no fill until hovered, so the primary
action stays visually dominant. */
#cancel-btn {
background: transparent;
color: $text-muted;
}
#cancel-btn:hover {
background: $surface-lighten-1;
color: $foreground;
}
View File
+43
View File
@@ -0,0 +1,43 @@
from textual.app import ComposeResult
from textual.message import Message
from textual.widget import Widget
from textual.widgets import ListView, ListItem, Label
class ProjectSelected(Message):
def __init__(self, project_id: int) -> None:
self.project_id = project_id
super().__init__()
class ProjectList(Widget, can_focus=False):
def compose(self) -> ComposeResult:
yield Label("Projects", id="project-header")
yield ListView(id="project-list")
async def refresh_projects(self, projects: list) -> None:
lv = self.query_one("#project-list", ListView)
prev_index = lv.index
await lv.clear()
for p in projects:
item = ListItem(Label(p.name), id=f"proj-{p.id}")
item.data = p.id # type: ignore[attr-defined]
lv.append(item)
# Default to the first project so one is always selected on launch; keep
# the prior position across reloads (e.g. after add/edit). Setting `index`
# fires Highlighted -> ProjectSelected, which loads that project's sessions.
if projects:
if prev_index is not None and prev_index < len(projects):
lv.index = prev_index
else:
lv.index = 0
def on_list_view_highlighted(self, event: ListView.Highlighted) -> None:
if event.item and hasattr(event.item, "data"):
self.post_message(ProjectSelected(event.item.data))
def get_selected_project_id(self) -> int | None:
lv = self.query_one("#project-list", ListView)
if lv.highlighted_child and hasattr(lv.highlighted_child, "data"):
return lv.highlighted_child.data
return None
+97
View File
@@ -0,0 +1,97 @@
from rich.markup import escape
from textual.app import ComposeResult
from textual.message import Message
from textual.widget import Widget
from textual.widgets import ListView, ListItem, Label
from hqt.status import status_symbol
# Sentinel distinguishing "caller passed no selection" (poll/post-action refresh
# — keep the current selection) from "select this session, or first if None"
# (project switch).
_UNSET = object()
class SessionSelected(Message):
def __init__(self, session_id: int) -> None:
self.session_id = session_id
super().__init__()
_STATUS_COLORS: dict[str, str] = {
"working": "#a6d189", # Green
"waiting": "#e5c890", # Yellow
"active": "#81c8be", # Teal
"idle": "#81c8be", # Teal
"dead": "#737994", # Overlay0
}
def format_session_text(
nickname: str, harness_name: str, status_text: str, symbol: str
) -> str:
color = _STATUS_COLORS.get(status_text, "#c6d0f5") # default: Text
label = escape(f"{nickname} [{harness_name}]")
return f"[{color}]{symbol}[/] {label} {status_text}"
class SessionList(Widget, can_focus=False):
def compose(self) -> ComposeResult:
yield Label("Sessions", id="session-header")
yield ListView(id="session-list")
async def refresh_sessions(self, session_infos: list, select_session_id=_UNSET) -> None:
lv = self.query_one("#session-list", ListView)
# Build new items and check if anything changed
new_items = []
for info in session_infos:
s = info.session
status_text = getattr(info, "status", "dead" if not info.alive else "idle")
symbol = status_symbol(status_text, alive=info.alive)
text = format_session_text(
s.nickname or s.tmux_session_name, s.harness.name, status_text, symbol
)
new_items.append((f"sess-{s.id}", text, s.id))
if select_session_id is _UNSET:
# Poll / post-action refresh: don't disturb the user's current pick.
old_ids = [child.id for child in lv.children]
new_ids = [item[0] for item in new_items]
if old_ids == new_ids:
# Update labels in-place for status changes
for child, (_, text, _) in zip(lv.children, new_items):
child.query_one(Label).update(text)
return
# Rebuild needed — preserve selection by index
saved_index = lv.index
await self._rebuild(lv, new_items)
if saved_index is not None and saved_index < len(new_items):
lv.index = saved_index
return
# Explicit selection (project switch): select the requested session if it
# still exists, otherwise fall back to the first session.
await self._rebuild(lv, new_items)
if new_items:
target = 0
for i, (_, _, sid) in enumerate(new_items):
if sid == select_session_id:
target = i
break
lv.index = target
async def _rebuild(self, lv: ListView, new_items: list) -> None:
await lv.clear()
for item_id, text, sid in new_items:
item = ListItem(Label(text), id=item_id)
item.data = sid # type: ignore[attr-defined]
lv.append(item)
def on_list_view_highlighted(self, event: ListView.Highlighted) -> None:
if event.item and hasattr(event.item, "data"):
self.post_message(SessionSelected(event.item.data))
def get_selected_session_id(self) -> int | None:
lv = self.query_one("#session-list", ListView)
if lv.highlighted_child and hasattr(lv.highlighted_child, "data"):
return lv.highlighted_child.data
return None
+20
View File
@@ -0,0 +1,20 @@
import re
from hqt.config import Settings
def test_tui_window_name_distinct_from_session_windows():
"""The main TUI window name must not collide with the 'hqt-{id}' harness
session-window pattern, so the tmux status bar can't read it as a truncated
session name (the trailing '-' there is tmux's last-window flag, not a name)."""
s = Settings()
# Session windows are named hqt-1, hqt-2, ... The main window must differ.
assert not re.fullmatch(r"hqt(-\d+)?", s.tui_window_name)
assert s.tui_window_name != "hqt"
assert s.tui_window_name == "⌂ HQT"
def test_tui_session_name_is_short_for_status_left():
"""tmux's default status-left prints "[#{session_name}] " in the lower-left
corner; the session name should be the clean "hqt", not "hqt-main"."""
assert Settings().tui_session_name == "hqt"
+72
View File
@@ -0,0 +1,72 @@
from pathlib import Path
from sqlalchemy import inspect
from sqlalchemy.orm import Session as DbSession
from hqt.config import Settings
from hqt.db.engine import ensure_db, get_engine, get_session_factory
from hqt.db.models import Harness, Project, Session
def _tmp_settings(tmp_path: Path) -> Settings:
return Settings(db_path=tmp_path / "test.db")
def test_ensure_db_creates_tables(tmp_path):
settings = _tmp_settings(tmp_path)
ensure_db(settings)
engine = get_engine(settings)
tables = inspect(engine).get_table_names()
assert "projects" in tables
assert "sessions" in tables
assert "harnesses" in tables
assert "mcp_servers" in tables
assert "project_mcp_servers" in tables
assert "project_skills" in tables
def test_project_crud(tmp_path):
settings = _tmp_settings(tmp_path)
ensure_db(settings)
engine = get_engine(settings)
factory = get_session_factory(engine)
with factory() as session:
p = Project(name="test", path="/tmp/test")
session.add(p)
session.commit()
assert p.id is not None
p.name = "updated"
session.commit()
fetched = session.get(Project, p.id)
assert fetched.name == "updated"
session.delete(fetched)
session.commit()
assert session.get(Project, p.id) is None
def test_session_with_fk(tmp_path):
settings = _tmp_settings(tmp_path)
ensure_db(settings)
engine = get_engine(settings)
factory = get_session_factory(engine)
with factory() as session:
p = Project(name="proj", path="/tmp/proj")
h = Harness(name="claude-code")
session.add_all([p, h])
session.commit()
s = Session(
project_id=p.id,
harness_id=h.id,
tmux_session_name="hqt-test-1",
)
session.add(s)
session.commit()
assert s.project.name == "proj"
assert s.harness.name == "claude-code"
assert s in p.sessions
+332
View File
@@ -0,0 +1,332 @@
import json
import os
import time
from pathlib import Path
from unittest.mock import patch
from hqt.harnesses.configurators.codex import CodexConfigurator
from hqt.harnesses.configurators.claude import ClaudeConfigurator
from hqt.harnesses.registry import discover_harnesses
def test_claude_spawn_config():
c = ClaudeConfigurator()
sid = c.generate_session_id(1)
cfg = c.build_spawn_config(Path("/tmp"), sid, model="opus")
assert cfg.command == ["claude", "--session-id", sid, "--model", "opus"]
def test_claude_resume_config():
c = ClaudeConfigurator()
sid = c.generate_session_id(1)
cfg = c.build_resume_config(Path("/tmp"), sid)
assert "--resume" in cfg.command
assert sid in cfg.command
def test_claude_resume_config_includes_model_when_set():
"""build_resume_config appends --model when model is provided."""
c = ClaudeConfigurator()
sid = c.generate_session_id(1)
cfg = c.build_resume_config(Path("/tmp"), sid, model="claude-opus-4-5")
assert "--resume" in cfg.command
assert sid in cfg.command
assert "--model" in cfg.command
assert "claude-opus-4-5" in cfg.command
def test_claude_resume_config_omits_model_when_none():
"""build_resume_config omits --model when model is None."""
c = ClaudeConfigurator()
sid = c.generate_session_id(1)
cfg = c.build_resume_config(Path("/tmp"), sid, model=None)
assert "--model" not in cfg.command
def test_codex_resume_config_includes_model_when_set():
"""build_resume_config appends -m <model> when model is provided."""
c = CodexConfigurator()
cfg = c.build_resume_config(Path("/tmp"), "abc-123", model="o3")
assert cfg.command == ["codex", "resume", "abc-123", "-m", "o3"]
def test_codex_resume_config_omits_model_when_none():
"""build_resume_config omits -m when model is None."""
c = CodexConfigurator()
cfg = c.build_resume_config(Path("/tmp"), "abc-123", model=None)
assert "-m" not in cfg.command
assert cfg.command == ["codex", "resume", "abc-123"]
def test_codex_spawn_with_model():
c = CodexConfigurator()
cfg = c.build_spawn_config(Path("/tmp"), "1", model="o3")
assert cfg.command == ["codex", "-m", "o3"]
def test_codex_resume_uses_uuid():
c = CodexConfigurator()
cfg = c.build_resume_config(Path("/tmp"), "abc-123")
assert cfg.command == ["codex", "resume", "abc-123"]
def test_codex_capture_session_id(tmp_path):
sessions_dir = tmp_path / ".codex" / "sessions" / "2026" / "06" / "09"
sessions_dir.mkdir(parents=True)
rollout = sessions_dir / "rollout-20260609-abcdef12-3456-7890-abcd-ef1234567890.jsonl"
meta = {"type": "session_meta", "payload": {"id": "abcdef12-3456-7890-abcd-ef1234567890", "cwd": "/projects/foo"}}
rollout.write_text(json.dumps(meta) + "\n")
since = time.time() - 60 # far enough back that freshly-written file qualifies
c = CodexConfigurator()
with patch("hqt.harnesses.configurators.codex.Path.home", return_value=tmp_path):
result = c.capture_session_id(Path("/projects/foo"), since)
assert result == "abcdef12-3456-7890-abcd-ef1234567890"
def test_codex_capture_session_id_wrong_cwd(tmp_path):
sessions_dir = tmp_path / ".codex" / "sessions" / "2026" / "06" / "09"
sessions_dir.mkdir(parents=True)
rollout = sessions_dir / "rollout-20260609-abcdef12.jsonl"
meta = {"type": "session_meta", "payload": {"id": "abcdef12", "cwd": "/other/path"}}
rollout.write_text(json.dumps(meta) + "\n")
since = time.time() - 60
c = CodexConfigurator()
with patch("hqt.harnesses.configurators.codex.Path.home", return_value=tmp_path):
result = c.capture_session_id(Path("/projects/foo"), since)
assert result is None
@patch("hqt.harnesses.registry.shutil.which")
def test_registry_discovers_harnesses(mock_which):
mock_which.side_effect = lambda x: "/usr/bin/" + x if x in ("claude", "codex") else None
result = discover_harnesses()
assert "claude" in result
assert "codex" in result
assert "kiro" not in result
# ---------------------------------------------------------------------------
# Task 4: time-fenced capture_session_id and captures_session_id flag
# ---------------------------------------------------------------------------
def test_codex_captures_session_id_flag():
"""CodexConfigurator must expose captures_session_id = True."""
assert CodexConfigurator.captures_session_id is True
def test_base_captures_session_id_flag_false():
"""HarnessConfigurator base class default must be False."""
from hqt.harnesses.base import HarnessConfigurator
assert HarnessConfigurator.captures_session_id is False
def test_claude_captures_session_id_flag_false():
"""ClaudeConfigurator must NOT set captures_session_id (inherits False)."""
assert ClaudeConfigurator.captures_session_id is False
def _write_rollout(path: Path, session_id: str, cwd: str) -> None:
meta = {"type": "session_meta", "payload": {"id": session_id, "cwd": cwd}}
path.write_text(json.dumps(meta) + "\n")
def test_codex_capture_ignores_file_before_since(tmp_path):
"""Rollout with mtime BEFORE since must not be returned even if cwd matches."""
sessions_dir = tmp_path / ".codex" / "sessions" / "dir"
sessions_dir.mkdir(parents=True)
rollout = sessions_dir / "rollout-old.jsonl"
_write_rollout(rollout, "old-id", "/projects/foo")
# Set mtime to something clearly in the past
past = time.time() - 60
os.utime(rollout, (past, past))
since = time.time() - 10 # since is after the file's mtime
c = CodexConfigurator()
with patch("hqt.harnesses.configurators.codex.Path.home", return_value=tmp_path):
result = c.capture_session_id(Path("/projects/foo"), since)
assert result is None
def test_codex_capture_accepts_file_after_since(tmp_path):
"""Rollout with mtime >= since and matching cwd is returned."""
sessions_dir = tmp_path / ".codex" / "sessions" / "dir"
sessions_dir.mkdir(parents=True)
rollout = sessions_dir / "rollout-new.jsonl"
_write_rollout(rollout, "new-id", "/projects/foo")
since = time.time() - 60 # since is well before file was written
# mtime of a freshly written file should be >= since
c = CodexConfigurator()
with patch("hqt.harnesses.configurators.codex.Path.home", return_value=tmp_path):
result = c.capture_session_id(Path("/projects/foo"), since)
assert result == "new-id"
def test_codex_capture_newest_post_since_cwd_match_wins(tmp_path):
"""Among multiple post-since rollouts, newest matching cwd wins over older matching."""
sessions_dir = tmp_path / ".codex" / "sessions" / "dir"
sessions_dir.mkdir(parents=True)
rollout_older = sessions_dir / "rollout-older.jsonl"
rollout_newer = sessions_dir / "rollout-newer.jsonl"
_write_rollout(rollout_older, "older-id", "/projects/foo")
_write_rollout(rollout_newer, "newer-id", "/projects/foo")
now = time.time()
since = now - 120
os.utime(rollout_older, (now - 30, now - 30))
os.utime(rollout_newer, (now - 10, now - 10))
c = CodexConfigurator()
with patch("hqt.harnesses.configurators.codex.Path.home", return_value=tmp_path):
result = c.capture_session_id(Path("/projects/foo"), since)
assert result == "newer-id"
def test_codex_capture_skips_non_matching_cwd_picks_older_matching(tmp_path):
"""Newest post-since file has wrong cwd; older post-since file with right cwd is returned."""
sessions_dir = tmp_path / ".codex" / "sessions" / "dir"
sessions_dir.mkdir(parents=True)
rollout_wrong = sessions_dir / "rollout-wrong.jsonl"
rollout_right = sessions_dir / "rollout-right.jsonl"
_write_rollout(rollout_wrong, "wrong-id", "/other/project")
_write_rollout(rollout_right, "right-id", "/projects/foo")
now = time.time()
since = now - 120
# wrong cwd file is newer
os.utime(rollout_wrong, (now - 5, now - 5))
# right cwd file is older but still after since
os.utime(rollout_right, (now - 20, now - 20))
c = CodexConfigurator()
with patch("hqt.harnesses.configurators.codex.Path.home", return_value=tmp_path):
result = c.capture_session_id(Path("/projects/foo"), since)
assert result == "right-id"
def test_codex_capture_missing_sessions_dir_returns_none(tmp_path):
"""Missing ~/.codex/sessions dir returns None without error."""
c = CodexConfigurator()
with patch("hqt.harnesses.configurators.codex.Path.home", return_value=tmp_path):
result = c.capture_session_id(Path("/projects/foo"), time.time())
assert result is None
# ---------------------------------------------------------------------------
# Task 7: parse_status for harness configurators
# ---------------------------------------------------------------------------
CLAUDE_WORKING_SCREEN = """\
> Implement feature X
Bash(git status)
On branch main
Write(src/foo.py)
Wrote 42 lines
Esc to interrupt
"""
CLAUDE_WAITING_SCREEN = """\
> Do you want to proceed with these changes?
1. Yes
2. No
"""
CLAUDE_PERMISSION_PROMPT = """\
> Do you want to allow this tool to run?
1. Yes, allow once
2. No
"""
CLAUDE_IDLE_SCREEN = """\
>
Done
"""
CODEX_WORKING_SCREEN = """\
Working...
Esc to interrupt
"""
CODEX_IDLE_SCREEN = """\
>
"""
def test_base_parse_status_returns_none():
"""Base HarnessConfigurator.parse_status always returns None."""
from hqt.harnesses.configurators.kiro import KiroConfigurator
k = KiroConfigurator()
assert k.parse_status("anything") is None
assert k.parse_status("") is None
def test_claude_parse_status_working():
"""ClaudeConfigurator: 'esc to interrupt' (case-insensitive) → 'working'."""
c = ClaudeConfigurator()
assert c.parse_status(CLAUDE_WORKING_SCREEN) == "working"
def test_claude_parse_status_working_case_insensitive():
"""ClaudeConfigurator: 'ESC TO INTERRUPT' upper-case → 'working'."""
c = ClaudeConfigurator()
assert c.parse_status("ESC TO INTERRUPT") == "working"
def test_claude_parse_status_waiting_do_you_want():
"""ClaudeConfigurator: line starting with 'Do you want''waiting'."""
c = ClaudeConfigurator()
assert c.parse_status(CLAUDE_WAITING_SCREEN) == "waiting"
def test_claude_parse_status_waiting_permission_prompt():
"""ClaudeConfigurator: ' 1.' style line → 'waiting'."""
c = ClaudeConfigurator()
assert c.parse_status(CLAUDE_PERMISSION_PROMPT) == "waiting"
def test_claude_parse_status_none_for_idle():
"""ClaudeConfigurator: idle/done screen → None (activity-based fallback)."""
c = ClaudeConfigurator()
assert c.parse_status(CLAUDE_IDLE_SCREEN) is None
def test_claude_parse_status_empty_returns_none():
"""ClaudeConfigurator: empty text → None."""
c = ClaudeConfigurator()
assert c.parse_status("") is None
def test_codex_parse_status_working():
"""CodexConfigurator: 'esc to interrupt''working'."""
c = CodexConfigurator()
assert c.parse_status(CODEX_WORKING_SCREEN) == "working"
def test_codex_parse_status_none_for_idle():
"""CodexConfigurator: idle screen → None."""
c = CodexConfigurator()
assert c.parse_status(CODEX_IDLE_SCREEN) is None
def test_codex_parse_status_none_for_empty():
"""CodexConfigurator: empty text → None."""
c = CodexConfigurator()
assert c.parse_status("") is None
+78
View File
@@ -0,0 +1,78 @@
import pytest
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from hqt.db.models import Base, Harness
from hqt.projects.service import ProjectService
from hqt.sessions.service import SessionService
from hqt.tmux.manager import TmuxManager, SpawnResult
from hqt.harnesses.configurators.claude import ClaudeConfigurator
@pytest.fixture
def setup():
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
factory = sessionmaker(bind=engine)
db = factory()
db.add(Harness(name="claude-code", display_name="Claude Code"))
db.commit()
tmux = MagicMock(spec=TmuxManager)
tmux.spawn = AsyncMock(return_value=SpawnResult(ok=True, window_id="@1", error=""))
tmux.kill = AsyncMock()
tmux.is_alive = AsyncMock(return_value=True)
tmux.window_exists = AsyncMock(return_value=True)
tmux.poll_info = AsyncMock(return_value={})
tmux.capture_pane = AsyncMock(return_value="")
tmux.attach = AsyncMock(return_value=True)
configurator = ClaudeConfigurator()
harnesses = {"claude-code": configurator}
proj_svc = ProjectService(db)
sess_svc = SessionService(db=db, tmux=tmux, harnesses=harnesses)
return proj_svc, sess_svc, tmux
@pytest.mark.asyncio
async def test_full_lifecycle(setup):
proj_svc, sess_svc, tmux = setup
# Create project
project = proj_svc.create(name="test-proj", path="/tmp/test-proj")
assert project.id == 1
# Create session
create_result = await sess_svc.create_session(project_id=project.id, harness_name="claude-code", nickname="e2e")
session = create_result.session
assert session.id is not None
tmux.spawn.assert_called_once()
# List sessions - alive
from hqt.tmux.runner import WindowInfo
tmux.poll_info.return_value = {session.tmux_session_name: WindowInfo(alive=True, last_activity=0)}
infos = await sess_svc.list_sessions(project_id=project.id)
assert len(infos) == 1
assert infos[0].alive is True
# Stop session
await sess_svc.stop_session(session_id=session.id)
tmux.kill.assert_called_once_with(session.tmux_session_name)
# List sessions - dead
tmux.poll_info.return_value = {session.tmux_session_name: WindowInfo(alive=False, last_activity=0)}
infos = await sess_svc.list_sessions(project_id=project.id)
assert len(infos) == 1
assert infos[0].alive is False
# Delete session
tmux.window_exists.return_value = False
await sess_svc.delete_session(session_id=session.id)
# List sessions - empty
infos = await sess_svc.list_sessions(project_id=project.id)
assert len(infos) == 0
+42
View File
@@ -0,0 +1,42 @@
import logging
import sys
import hqt.logging as hqt_logging
from hqt.config import Settings
def test_setup_logging_does_not_write_to_terminal(tmp_path, monkeypatch):
"""setup_logging must NOT attach a StreamHandler that writes to the real
terminal stderr/stdout.
The TUI is a Textual app that paints to sys.__stderr__. A root-logger
StreamHandler bound to that same fd writes raw log lines straight onto the
pane Textual owns, corrupting tmux's grid — visible as rendering artifacts
when switching back to the hqt window. Logs must go to file only.
"""
settings = Settings(db_path=tmp_path / "hqt.db")
monkeypatch.setattr(hqt_logging, "get_settings", lambda: settings)
root = logging.getLogger()
saved_handlers = root.handlers[:]
saved_level = root.level
try:
root.handlers.clear()
hqt_logging.setup_logging()
terminal_streams = {sys.stderr, sys.__stderr__, sys.stdout, sys.__stdout__}
offending = [
h
for h in root.handlers
# FileHandler subclasses StreamHandler but writes to a file, not the terminal.
if type(h) is logging.StreamHandler
and getattr(h, "stream", None) in terminal_streams
]
assert not offending, (
f"setup_logging attached a terminal StreamHandler: {offending}"
)
# Logs are still captured to a file.
assert any(isinstance(h, logging.FileHandler) for h in root.handlers)
finally:
root.handlers[:] = saved_handlers
root.setLevel(saved_level)
+75
View File
@@ -0,0 +1,75 @@
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from hqt.db.models import Base
from hqt.mcp.service import McpService
from hqt.projects.service import ProjectService
@pytest.fixture
def db():
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
with Session(engine) as session:
yield session
class TestProjectService:
def test_create_and_get(self, db):
svc = ProjectService(db)
p = svc.create("test", "/tmp/test")
assert p.id is not None
assert svc.get(p.id).name == "test"
def test_list_excludes_archived(self, db):
svc = ProjectService(db)
svc.create("a", "/a")
p2 = svc.create("b", "/b")
svc.archive(p2.id)
assert len(svc.list_all()) == 1
assert len(svc.list_all(include_archived=True)) == 2
def test_update_name_and_path(self, db):
svc = ProjectService(db)
p = svc.create("old", "/old/path")
updated = svc.update(p.id, "new", "/new/path")
assert updated.name == "new"
assert updated.path == "/new/path"
assert svc.get(p.id).path == "/new/path"
def test_update_duplicate_path_raises(self, db):
svc = ProjectService(db)
svc.create("a", "/a")
p2 = svc.create("b", "/b")
with pytest.raises(ValueError, match="already uses path"):
svc.update(p2.id, "b", "/a")
# DB session must remain usable after rollback, values unchanged
assert svc.get(p2.id).path == "/b"
assert svc.get(p2.id).name == "b"
def test_update_unknown_id_raises(self, db):
svc = ProjectService(db)
with pytest.raises(ValueError, match="not found"):
svc.update(9999, "x", "/x")
class TestMcpService:
def test_crud(self, db):
svc = McpService(db)
server = svc.create("test-mcp", "stdio", command="node", args=["server.js"])
assert server.id is not None
assert svc.get("test-mcp") is not None
assert len(svc.list_all()) == 1
svc.delete("test-mcp")
assert svc.get("test-mcp") is None
def test_bind_unbind(self, db):
psvc = ProjectService(db)
msvc = McpService(db)
project = psvc.create("proj", "/proj")
server = msvc.create("s1", "stdio", command="cmd")
msvc.bind_to_project(project.id, server.id)
assert len(msvc.get_project_mcps(project.id)) == 1
msvc.unbind_from_project(project.id, server.id)
assert len(msvc.get_project_mcps(project.id)) == 0
+841
View File
@@ -0,0 +1,841 @@
import pytest
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from hqt.db.models import Base, Harness, Project
from hqt.sessions.service import SessionInfo, SessionService
from hqt.tmux.manager import SpawnResult, TmuxManager
@pytest.fixture
def db():
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
factory = sessionmaker(bind=engine)
session = factory()
session.add(Harness(name="claude-code", display_name="Claude Code"))
session.add(Project(name="myproj", path="/tmp/myproj"))
session.commit()
yield session
session.close()
@pytest.fixture
def tmux():
m = MagicMock(spec=TmuxManager)
m.spawn = AsyncMock(return_value=SpawnResult(ok=True, window_id="@1", error=""))
m.kill = AsyncMock()
m.is_alive = AsyncMock(return_value=True)
m.poll_info = AsyncMock(return_value={})
m.capture_pane = AsyncMock(return_value="")
m.attach = AsyncMock()
m.window_exists = AsyncMock(return_value=True)
m.respawn_verified = AsyncMock(return_value=SpawnResult(ok=True, window_id=None, error=""))
return m
@pytest.fixture
def harnesses():
h = MagicMock()
h.captures_session_id = False
h.generate_session_id.return_value = "sess-1"
h.build_spawn_config.return_value = MagicMock(command=["claude"], env={}, cwd=Path("/tmp/myproj"))
h.parse_status = MagicMock(return_value=None)
return {"claude-code": h}
@pytest.fixture
def service(db, tmux, harnesses):
return SessionService(db=db, tmux=tmux, harnesses=harnesses)
@pytest.mark.asyncio
async def test_create_session(service, db, tmux, harnesses):
result = await service.create_session(project_id=1, harness_name="claude-code", nickname="test")
sess = result.session
assert sess.id is not None
assert sess.tmux_session_name == f"hqt-{sess.id}"
assert sess.harness_session_id == "sess-1"
tmux.spawn.assert_called_once()
harnesses["claude-code"].build_spawn_config.assert_called_once()
@pytest.mark.asyncio
async def test_list_sessions(service, db, tmux):
from hqt.tmux.runner import WindowInfo
await service.create_session(project_id=1, harness_name="claude-code")
tmux.poll_info.return_value = {"hqt-1": WindowInfo(alive=True, last_activity=1000)}
result = await service.list_sessions(project_id=1)
assert len(result) == 1
assert isinstance(result[0], SessionInfo)
assert result[0].alive is True
@pytest.mark.asyncio
async def test_stop_session(service, tmux):
await service.create_session(project_id=1, harness_name="claude-code")
await service.stop_session(session_id=1)
tmux.kill.assert_called_once_with("hqt-1")
@pytest.mark.asyncio
async def test_stop_session_no_kill_when_window_missing(service, db, tmux):
"""stop_session must NOT call kill when the window doesn't exist."""
await service.create_session(project_id=1, harness_name="claude-code")
tmux.window_exists = AsyncMock(return_value=False)
tmux.kill.reset_mock()
await service.stop_session(session_id=1)
tmux.kill.assert_not_called()
@pytest.mark.asyncio
async def test_create_session_logs_failed_spawn(service, db, tmux, caplog):
"""create_session logs error including pane text when spawn fails, but still commits."""
import logging
tmux.spawn.return_value = SpawnResult(ok=False, window_id=None, error="bash: command not found")
with caplog.at_level(logging.ERROR):
result = await service.create_session(project_id=1, harness_name="claude-code")
assert result.session.id is not None # row was committed
# Error was logged and includes captured pane text
error_messages = [r.message for r in caplog.records if r.levelno == logging.ERROR]
assert any("command not found" in m for m in error_messages)
@pytest.mark.asyncio
async def test_attach_session_passes_env(service, db, tmux, harnesses):
"""attach_session passes cfg.env to tmux.respawn_verified when the window needs respawning."""
from unittest.mock import AsyncMock
# Create a session first
await service.create_session(project_id=1, harness_name="claude-code")
# Set up harness to return a config with env
env_cfg = MagicMock(command=["claude"], env={"SESSION_VAR": "abc"}, cwd=Path("/tmp/myproj"))
harnesses["claude-code"].build_resume_config = MagicMock(return_value=env_cfg)
# Simulate window is not alive → respawn path
tmux.is_alive = AsyncMock(return_value=False)
tmux.respawn_verified = AsyncMock(return_value=SpawnResult(ok=True, window_id=None, error=""))
tmux.attach = AsyncMock(return_value=True)
await service.attach_session(session_id=1)
tmux.respawn_verified.assert_called()
first_call = tmux.respawn_verified.call_args_list[0]
# env must be forwarded (passed as keyword or positional)
assert first_call.kwargs.get("env") == {"SESSION_VAR": "abc"} or \
(len(first_call.args) >= 4 and first_call.args[3] == {"SESSION_VAR": "abc"})
# ---------------------------------------------------------------------------
# Task 3: fallback ladder + model preserved on resume
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_attach_session_dead_resume_ok_attaches_once(service, db, tmux, harnesses):
"""attach_session: resume ok → attach called once, no fallback spawn attempted."""
await service.create_session(project_id=1, harness_name="claude-code")
resume_cfg = MagicMock(command=["claude", "--resume", "sess-1"], env={}, cwd=Path("/tmp/myproj"))
harnesses["claude-code"].build_resume_config = MagicMock(return_value=resume_cfg)
harnesses["claude-code"].build_spawn_config = MagicMock(
return_value=MagicMock(command=["claude", "--session-id", "sess-1"], env={}, cwd=Path("/tmp/myproj"))
)
tmux.is_alive = AsyncMock(return_value=False)
tmux.respawn_verified = AsyncMock(return_value=SpawnResult(ok=True, window_id=None, error=""))
tmux.attach = AsyncMock(return_value=True)
result = await service.attach_session(session_id=1)
assert result is True
tmux.attach.assert_called_once()
# Only one respawn_verified call (the resume rung), no fallback
assert tmux.respawn_verified.call_count == 1
first_cmd = tmux.respawn_verified.call_args_list[0].args[1]
assert "--resume" in first_cmd
@pytest.mark.asyncio
async def test_attach_session_dead_resume_fails_fallback_spawn(service, db, tmux, harnesses):
"""attach_session: resume dies → fallback spawn with session-id, attach called once."""
await service.create_session(project_id=1, harness_name="claude-code")
resume_cfg = MagicMock(command=["claude", "--resume", "sess-1"], env={}, cwd=Path("/tmp/myproj"))
spawn_cfg = MagicMock(command=["claude", "--session-id", "sess-1"], env={}, cwd=Path("/tmp/myproj"))
harnesses["claude-code"].build_resume_config = MagicMock(return_value=resume_cfg)
harnesses["claude-code"].build_spawn_config = MagicMock(return_value=spawn_cfg)
tmux.is_alive = AsyncMock(return_value=False)
tmux.respawn_verified = AsyncMock(side_effect=[
SpawnResult(ok=False, window_id=None, error="no transcript"), # resume fails
SpawnResult(ok=True, window_id="@2", error=""), # fresh spawn ok
])
tmux.attach = AsyncMock(return_value=True)
result = await service.attach_session(session_id=1)
assert result is True
# Two respawn_verified calls: first resume, then spawn
assert tmux.respawn_verified.call_count == 2
first_cmd = tmux.respawn_verified.call_args_list[0].args[1]
second_cmd = tmux.respawn_verified.call_args_list[1].args[1]
assert "--resume" in first_cmd
assert "--session-id" in second_cmd
tmux.attach.assert_called_once()
@pytest.mark.asyncio
async def test_attach_session_both_rungs_fail_returns_false(service, db, tmux, harnesses):
"""attach_session: both resume and spawn fail → False, no attach."""
await service.create_session(project_id=1, harness_name="claude-code")
resume_cfg = MagicMock(command=["claude", "--resume", "sess-1"], env={}, cwd=Path("/tmp/myproj"))
spawn_cfg = MagicMock(command=["claude", "--session-id", "sess-1"], env={}, cwd=Path("/tmp/myproj"))
harnesses["claude-code"].build_resume_config = MagicMock(return_value=resume_cfg)
harnesses["claude-code"].build_spawn_config = MagicMock(return_value=spawn_cfg)
tmux.is_alive = AsyncMock(return_value=False)
tmux.respawn_verified = AsyncMock(return_value=SpawnResult(ok=False, window_id=None, error="fatal"))
tmux.attach = AsyncMock(return_value=True)
result = await service.attach_session(session_id=1)
assert result is False
tmux.attach.assert_not_called()
# ---------------------------------------------------------------------------
# Task 4: captures_session_id flag + bounded async retry in create_session
# ---------------------------------------------------------------------------
@pytest.fixture
def harnesses_no_capture():
"""Configurator with captures_session_id=False (like claude-code)."""
h = MagicMock()
h.captures_session_id = False
h.generate_session_id.return_value = "sess-nc"
h.build_spawn_config.return_value = MagicMock(command=["claude"], env={}, cwd=Path("/tmp/myproj"))
return {"claude-code": h}
@pytest.fixture
def harnesses_capture():
"""Configurator with captures_session_id=True (like codex)."""
h = MagicMock()
h.captures_session_id = True
h.generate_session_id.return_value = "placeholder-id"
h.build_spawn_config.return_value = MagicMock(command=["codex"], env={}, cwd=Path("/tmp/myproj"))
return {"claude-code": h}
@pytest.mark.asyncio
async def test_create_session_no_capture_flag_never_calls_capture(db, tmux, harnesses_no_capture):
"""When captures_session_id=False, capture_session_id is never called."""
service = SessionService(db=db, tmux=tmux, harnesses=harnesses_no_capture)
await service.create_session(project_id=1, harness_name="claude-code")
harnesses_no_capture["claude-code"].capture_session_id.assert_not_called()
@pytest.mark.asyncio
async def test_create_session_capture_retries_until_success(db, tmux, harnesses_capture):
"""captures_session_id=True: returns None twice then an id → retries and stores captured id."""
import hqt.sessions.service as svc_mod
harnesses_capture["claude-code"].capture_session_id.side_effect = [None, None, "captured-id-xyz"]
sleep_calls = []
async def fake_sleep(t):
sleep_calls.append(t)
service = SessionService(db=db, tmux=tmux, harnesses=harnesses_capture)
with patch.object(svc_mod, "_capture_sleep", fake_sleep):
result = await service.create_session(project_id=1, harness_name="claude-code")
assert result.session.harness_session_id == "captured-id-xyz"
# capture was called 3 times: first immediate, then twice after sleeps
assert harnesses_capture["claude-code"].capture_session_id.call_count == 3
assert len(sleep_calls) == 2
@pytest.mark.asyncio
async def test_create_session_capture_all_none_keeps_placeholder(db, tmux, harnesses_capture, caplog):
"""All capture attempts return None → placeholder id kept, no crash, warning logged."""
import logging
import hqt.sessions.service as svc_mod
harnesses_capture["claude-code"].capture_session_id.return_value = None
async def fake_sleep(t):
pass
service = SessionService(db=db, tmux=tmux, harnesses=harnesses_capture)
with caplog.at_level(logging.WARNING):
with patch.object(svc_mod, "_capture_sleep", fake_sleep):
result = await service.create_session(project_id=1, harness_name="claude-code")
assert result.session.harness_session_id == "placeholder-id"
# Should have attempted up to 6 times (CAPTURE_MAX_ATTEMPTS)
assert harnesses_capture["claude-code"].capture_session_id.call_count == svc_mod.CAPTURE_MAX_ATTEMPTS
# Exhaustion must emit a warning
warning_messages = [r.message for r in caplog.records if r.levelno == logging.WARNING]
assert any("exhausted" in m for m in warning_messages)
@pytest.mark.asyncio
async def test_create_session_capture_first_attempt_succeeds(db, tmux, harnesses_capture):
"""captures_session_id=True: first attempt succeeds → no sleeps needed."""
import hqt.sessions.service as svc_mod
harnesses_capture["claude-code"].capture_session_id.return_value = "immediate-id"
sleep_calls = []
async def fake_sleep(t):
sleep_calls.append(t)
service = SessionService(db=db, tmux=tmux, harnesses=harnesses_capture)
with patch.object(svc_mod, "_capture_sleep", fake_sleep):
result = await service.create_session(project_id=1, harness_name="claude-code")
assert result.session.harness_session_id == "immediate-id"
assert len(sleep_calls) == 0 # no sleep needed — succeeded on first try
# ---------------------------------------------------------------------------
# Task 7: list_sessions status computation
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_list_sessions_dead_window(db, tmux, harnesses):
"""list_sessions: dead window → status='dead', alive=False."""
from hqt.tmux.runner import WindowInfo
await SessionService(db=db, tmux=tmux, harnesses=harnesses).create_session(
project_id=1, harness_name="claude-code"
)
tmux.poll_info.return_value = {"hqt-1": WindowInfo(alive=False, last_activity=0)}
service = SessionService(db=db, tmux=tmux, harnesses=harnesses)
result = await service.list_sessions(project_id=1)
assert len(result) == 1
assert result[0].alive is False
assert result[0].status == "dead"
@pytest.mark.asyncio
async def test_list_sessions_missing_window_is_dead(db, tmux, harnesses):
"""list_sessions: window not in poll_info result → status='dead', alive=False."""
await SessionService(db=db, tmux=tmux, harnesses=harnesses).create_session(
project_id=1, harness_name="claude-code"
)
tmux.poll_info.return_value = {} # no windows at all
service = SessionService(db=db, tmux=tmux, harnesses=harnesses)
result = await service.list_sessions(project_id=1)
assert result[0].alive is False
assert result[0].status == "dead"
@pytest.mark.asyncio
async def test_list_sessions_alive_parse_working(db, tmux, harnesses):
"""Alive window + harness parse returns 'working' → status='working'."""
from hqt.tmux.runner import WindowInfo
await SessionService(db=db, tmux=tmux, harnesses=harnesses).create_session(
project_id=1, harness_name="claude-code"
)
now = 2000.0
tmux.poll_info.return_value = {"hqt-1": WindowInfo(alive=True, last_activity=1999)}
tmux.capture_pane = AsyncMock(return_value="Esc to interrupt\n")
harnesses["claude-code"].parse_status = MagicMock(return_value="working")
service = SessionService(db=db, tmux=tmux, harnesses=harnesses)
result = await service.list_sessions(project_id=1, now=now)
assert result[0].status == "working"
assert result[0].alive is True
@pytest.mark.asyncio
async def test_list_sessions_alive_parse_none_recent_activity(db, tmux, harnesses):
"""Alive window + parse_status None + recent activity → status='active'."""
from hqt.tmux.runner import WindowInfo
await SessionService(db=db, tmux=tmux, harnesses=harnesses).create_session(
project_id=1, harness_name="claude-code"
)
now = 2000.0
tmux.poll_info.return_value = {"hqt-1": WindowInfo(alive=True, last_activity=1998)} # 2s ago
tmux.capture_pane = AsyncMock(return_value="some text")
harnesses["claude-code"].parse_status = MagicMock(return_value=None)
service = SessionService(db=db, tmux=tmux, harnesses=harnesses)
result = await service.list_sessions(project_id=1, now=now)
assert result[0].status == "active"
@pytest.mark.asyncio
async def test_list_sessions_alive_parse_none_stale_activity(db, tmux, harnesses):
"""Alive window + parse_status None + stale activity → status='idle'."""
from hqt.tmux.runner import WindowInfo
await SessionService(db=db, tmux=tmux, harnesses=harnesses).create_session(
project_id=1, harness_name="claude-code"
)
now = 2000.0
tmux.poll_info.return_value = {"hqt-1": WindowInfo(alive=True, last_activity=1990)} # 10s ago
tmux.capture_pane = AsyncMock(return_value="some text")
harnesses["claude-code"].parse_status = MagicMock(return_value=None)
service = SessionService(db=db, tmux=tmux, harnesses=harnesses)
result = await service.list_sessions(project_id=1, now=now)
assert result[0].status == "idle"
@pytest.mark.asyncio
async def test_list_sessions_capture_empty_falls_back_to_activity(db, tmux, harnesses):
"""capture_pane returns empty text → activity-based fallback, no crash."""
from hqt.tmux.runner import WindowInfo
await SessionService(db=db, tmux=tmux, harnesses=harnesses).create_session(
project_id=1, harness_name="claude-code"
)
now = 2000.0
tmux.poll_info.return_value = {"hqt-1": WindowInfo(alive=True, last_activity=1999)}
tmux.capture_pane = AsyncMock(return_value="") # empty — treat as capture failure
harnesses["claude-code"].parse_status = MagicMock(return_value=None)
service = SessionService(db=db, tmux=tmux, harnesses=harnesses)
result = await service.list_sessions(project_id=1, now=now)
# Should still produce a valid status via activity
assert result[0].status in ("active", "idle")
assert result[0].alive is True
@pytest.mark.asyncio
async def test_list_sessions_alive_parse_waiting(db, tmux, harnesses):
"""Alive window + parse_status 'waiting' → status='waiting'."""
from hqt.tmux.runner import WindowInfo
await SessionService(db=db, tmux=tmux, harnesses=harnesses).create_session(
project_id=1, harness_name="claude-code"
)
now = 2000.0
tmux.poll_info.return_value = {"hqt-1": WindowInfo(alive=True, last_activity=1999)}
tmux.capture_pane = AsyncMock(return_value="Do you want to proceed?\n 1. Yes\n")
harnesses["claude-code"].parse_status = MagicMock(return_value="waiting")
service = SessionService(db=db, tmux=tmux, harnesses=harnesses)
result = await service.list_sessions(project_id=1, now=now)
assert result[0].status == "waiting"
# ---------------------------------------------------------------------------
# sync_window_labels: status-reflecting tmux window titles (session-wide)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_sync_window_labels_sets_status_symbol(db, tmux, harnesses):
"""sync_window_labels pushes a '<symbol> <nickname>' label per session."""
from hqt.tmux.runner import WindowInfo
service = SessionService(db=db, tmux=tmux, harnesses=harnesses)
await service.create_session(project_id=1, harness_name="claude-code", nickname="mywork")
now = 2000.0
tmux.poll_info.return_value = {"hqt-1": WindowInfo(alive=True, last_activity=1999)}
tmux.capture_pane = AsyncMock(return_value="Esc to interrupt\n")
harnesses["claude-code"].parse_status = MagicMock(return_value="working")
tmux.set_window_label = AsyncMock()
infos = await service.sync_window_labels(now=now)
assert len(infos) == 1
assert infos[0].status == "working"
# Label uses the working glyph (◐) and the nickname.
tmux.set_window_label.assert_awaited_once_with("hqt-1", "◐ mywork")
@pytest.mark.asyncio
async def test_sync_window_labels_dead_window_uses_dead_glyph(db, tmux, harnesses):
"""A dead/missing window gets the ○ glyph and falls back to the window name."""
from hqt.tmux.runner import WindowInfo
service = SessionService(db=db, tmux=tmux, harnesses=harnesses)
await service.create_session(project_id=1, harness_name="claude-code") # no nickname
tmux.poll_info.return_value = {"hqt-1": WindowInfo(alive=False, last_activity=0)}
tmux.set_window_label = AsyncMock()
infos = await service.sync_window_labels()
assert infos[0].status == "dead"
# No nickname → falls back to the tmux window name.
tmux.set_window_label.assert_awaited_once_with("hqt-1", "○ hqt-1")
@pytest.mark.asyncio
async def test_sync_window_labels_covers_all_projects(db, tmux, harnesses):
"""sync_window_labels is session-wide: it labels sessions across all projects."""
from hqt.db.models import Project
from hqt.tmux.runner import WindowInfo
db.add(Project(name="proj2", path="/tmp/proj2"))
db.commit()
service = SessionService(db=db, tmux=tmux, harnesses=harnesses)
await service.create_session(project_id=1, harness_name="claude-code", nickname="a")
await service.create_session(project_id=2, harness_name="claude-code", nickname="b")
tmux.poll_info.return_value = {
"hqt-1": WindowInfo(alive=False, last_activity=0),
"hqt-2": WindowInfo(alive=False, last_activity=0),
}
tmux.set_window_label = AsyncMock()
infos = await service.sync_window_labels()
assert len(infos) == 2 # both projects' sessions processed
labelled = {c.args[0] for c in tmux.set_window_label.await_args_list}
assert labelled == {"hqt-1", "hqt-2"}
# ---------------------------------------------------------------------------
# Item 3: real-configurator integration-seam test
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_list_sessions_real_configurator_waiting(db):
"""list_sessions with real ClaudeConfigurator + spec'd TmuxManager mock → status='waiting'.
Uses the REAL ClaudeConfigurator (not a mock) to close the decorative-fixture
gap and ensure parse_status is correctly wired end-to-end.
TDD story: before the capture_pane delegate is added to TmuxManager,
MagicMock(spec=TmuxManager).capture_pane raises AttributeError, which propagates
as an AttributeError from list_sessions. After the delegate is added the spec
allows the attribute and this test passes.
"""
from hqt.tmux.runner import WindowInfo
from hqt.tmux.manager import TmuxManager
from hqt.harnesses.configurators.claude import ClaudeConfigurator
# spec=TmuxManager: only methods that exist on TmuxManager are accessible.
# capture_pane MUST be on TmuxManager (via the delegate) for the spec to allow it.
tmux_spec = MagicMock(spec=TmuxManager)
tmux_spec.spawn = AsyncMock(return_value=SpawnResult(ok=True, window_id="@1", error=""))
tmux_spec.kill = AsyncMock()
tmux_spec.is_alive = AsyncMock(return_value=True)
tmux_spec.poll_info = AsyncMock(return_value={})
# capture_pane returns realistic "waiting" pane text; the spec must allow this
# attribute — fails with AttributeError before TmuxManager.capture_pane delegate exists.
tmux_spec.capture_pane = AsyncMock(return_value="Do you want to proceed?\n 1. Yes\n")
tmux_spec.attach = AsyncMock()
tmux_spec.window_exists = AsyncMock(return_value=True)
tmux_spec.respawn_verified = AsyncMock(return_value=SpawnResult(ok=True, window_id=None, error=""))
configurator = ClaudeConfigurator()
harnesses = {"claude-code": configurator}
service = SessionService(db=db, tmux=tmux_spec, harnesses=harnesses)
# Create a session so there is a row in the DB
await service.create_session(project_id=1, harness_name="claude-code")
now = 2000.0
window_name = "hqt-1"
tmux_spec.poll_info.return_value = {window_name: WindowInfo(alive=True, last_activity=1999)}
result = await service.list_sessions(project_id=1, now=now)
assert len(result) == 1
assert result[0].status == "waiting"
assert result[0].alive is True
tmux_spec.capture_pane.assert_called_once_with(window_name)
# ---------------------------------------------------------------------------
# Item 4: capture_pane NOT called for dead/missing windows
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_list_sessions_dead_window_no_capture(db, tmux):
"""list_sessions: dead window → capture_pane must NOT be called."""
from hqt.tmux.runner import WindowInfo
from hqt.harnesses.configurators.claude import ClaudeConfigurator
harnesses = {"claude-code": ClaudeConfigurator()}
service = SessionService(db=db, tmux=tmux, harnesses=harnesses)
await service.create_session(project_id=1, harness_name="claude-code")
tmux.poll_info.return_value = {"hqt-1": WindowInfo(alive=False, last_activity=0)}
tmux.capture_pane.reset_mock()
await service.list_sessions(project_id=1)
tmux.capture_pane.assert_not_called()
@pytest.mark.asyncio
async def test_list_sessions_missing_window_no_capture(db, tmux):
"""list_sessions: window missing from poll_info → capture_pane must NOT be called."""
from hqt.harnesses.configurators.claude import ClaudeConfigurator
harnesses = {"claude-code": ClaudeConfigurator()}
service = SessionService(db=db, tmux=tmux, harnesses=harnesses)
await service.create_session(project_id=1, harness_name="claude-code")
tmux.poll_info.return_value = {} # window not present at all
tmux.capture_pane.reset_mock()
await service.list_sessions(project_id=1)
tmux.capture_pane.assert_not_called()
# ---------------------------------------------------------------------------
# Fix 1: _respawn_with_fallback rung-2 updates harness_session_id
# ---------------------------------------------------------------------------
@pytest.fixture
def harnesses_capture_fallback():
"""Configurator with captures_session_id=True for fallback ladder tests."""
h = MagicMock()
h.name = "claude-code"
h.captures_session_id = True
h.generate_session_id.return_value = "placeholder-id"
h.build_spawn_config.return_value = MagicMock(command=["codex"], env={}, cwd=Path("/tmp/myproj"))
h.build_resume_config.return_value = MagicMock(command=["codex", "resume", "placeholder-id"], env={}, cwd=Path("/tmp/myproj"))
return {"claude-code": h}
@pytest.mark.asyncio
async def test_respawn_fallback_rung2_updates_harness_session_id(db, tmux, harnesses_capture_fallback):
"""Rung-2 success + captures_session_id=True + capture returns new id → sess.harness_session_id updated and committed."""
import hqt.sessions.service as svc_mod
harnesses_capture_fallback["claude-code"].capture_session_id.return_value = "new-codex-id"
async def fake_sleep(t):
pass
service = SessionService(db=db, tmux=tmux, harnesses=harnesses_capture_fallback)
with patch.object(svc_mod, "_capture_sleep", fake_sleep):
result = await service.create_session(project_id=1, harness_name="claude-code")
sess_id = result.session.id
# Simulate rung-1 resume fails, rung-2 fresh spawn succeeds
harnesses_capture_fallback["claude-code"].capture_session_id.reset_mock()
harnesses_capture_fallback["claude-code"].capture_session_id.return_value = "new-codex-id-after-rung2"
tmux.is_alive = AsyncMock(return_value=False)
tmux.respawn_verified = AsyncMock(side_effect=[
SpawnResult(ok=False, window_id=None, error="no transcript"), # rung-1 resume fails
SpawnResult(ok=True, window_id="@2", error=""), # rung-2 fresh spawn ok
])
tmux.attach = AsyncMock(return_value=True)
with patch.object(svc_mod, "_capture_sleep", fake_sleep):
ok = await service.attach_session(session_id=sess_id)
assert ok is True
# harness_session_id must now be the newly captured id
from hqt.db.models import Session as DBSession
updated = db.get(DBSession, sess_id)
assert updated.harness_session_id == "new-codex-id-after-rung2"
@pytest.mark.asyncio
async def test_respawn_fallback_rung2_capture_exhausted_keeps_old_id(db, tmux, harnesses_capture_fallback, caplog):
"""Rung-2 success + capture exhausted → old id kept + warning logged."""
import logging
import hqt.sessions.service as svc_mod
harnesses_capture_fallback["claude-code"].capture_session_id.return_value = "placeholder-id"
async def fake_sleep(t):
pass
service = SessionService(db=db, tmux=tmux, harnesses=harnesses_capture_fallback)
with patch.object(svc_mod, "_capture_sleep", fake_sleep):
result = await service.create_session(project_id=1, harness_name="claude-code")
sess_id = result.session.id
old_id = result.session.harness_session_id
# After create, reset capture to always return None
harnesses_capture_fallback["claude-code"].capture_session_id.reset_mock()
harnesses_capture_fallback["claude-code"].capture_session_id.return_value = None
tmux.is_alive = AsyncMock(return_value=False)
tmux.respawn_verified = AsyncMock(side_effect=[
SpawnResult(ok=False, window_id=None, error="no transcript"),
SpawnResult(ok=True, window_id="@2", error=""),
])
tmux.attach = AsyncMock(return_value=True)
with caplog.at_level(logging.WARNING):
with patch.object(svc_mod, "_capture_sleep", fake_sleep):
ok = await service.attach_session(session_id=sess_id)
assert ok is True
from hqt.db.models import Session as DBSession
updated = db.get(DBSession, sess_id)
# Old id preserved
assert updated.harness_session_id == old_id
# Warning emitted
warning_messages = [r.message for r in caplog.records if r.levelno == logging.WARNING]
assert any("exhausted" in m for m in warning_messages)
@pytest.mark.asyncio
async def test_respawn_fallback_rung2_no_capture_when_flag_false(db, tmux):
"""Rung-2 success + captures_session_id=False → capture_session_id never called."""
h = MagicMock()
h.name = "claude-code"
h.captures_session_id = False
h.generate_session_id.return_value = "sess-nc"
h.build_spawn_config.return_value = MagicMock(command=["claude"], env={}, cwd=Path("/tmp/myproj"))
h.build_resume_config.return_value = MagicMock(command=["claude", "--resume", "sess-nc"], env={}, cwd=Path("/tmp/myproj"))
harnesses_nc = {"claude-code": h}
service = SessionService(db=db, tmux=tmux, harnesses=harnesses_nc)
result = await service.create_session(project_id=1, harness_name="claude-code")
sess_id = result.session.id
h.capture_session_id.reset_mock()
tmux.is_alive = AsyncMock(return_value=False)
tmux.respawn_verified = AsyncMock(side_effect=[
SpawnResult(ok=False, window_id=None, error="no transcript"),
SpawnResult(ok=True, window_id="@2", error=""),
])
tmux.attach = AsyncMock(return_value=True)
await service.attach_session(session_id=sess_id)
h.capture_session_id.assert_not_called()
# ---------------------------------------------------------------------------
# Fix 2 (Minor): capture NOT called when spawn_result.ok is False
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_create_session_no_capture_when_spawn_fails(db, tmux, harnesses_capture):
"""create_session: spawn fails → capture_session_id must NOT be called."""
import hqt.sessions.service as svc_mod
tmux.spawn.return_value = SpawnResult(ok=False, window_id=None, error="spawn failed")
async def fake_sleep(t):
pass
service = SessionService(db=db, tmux=tmux, harnesses=harnesses_capture)
with patch.object(svc_mod, "_capture_sleep", fake_sleep):
result = await service.create_session(project_id=1, harness_name="claude-code")
harnesses_capture["claude-code"].capture_session_id.assert_not_called()
assert result.spawn_ok is False
assert "spawn failed" in result.spawn_error
# ---------------------------------------------------------------------------
# Fix 3: create_session returns CreateSessionResult
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_create_session_returns_result_dataclass(service, db, tmux, harnesses):
"""create_session returns a CreateSessionResult with spawn_ok=True on success."""
from hqt.sessions.service import CreateSessionResult
result = await service.create_session(project_id=1, harness_name="claude-code", nickname="test")
assert isinstance(result, CreateSessionResult)
assert result.spawn_ok is True
assert result.spawn_error == ""
assert result.session.id is not None
@pytest.mark.asyncio
async def test_create_session_result_propagates_spawn_failure(db, tmux, harnesses):
"""create_session result reflects spawn failure."""
from hqt.sessions.service import CreateSessionResult
tmux.spawn.return_value = SpawnResult(ok=False, window_id=None, error="command not found: claude")
service = SessionService(db=db, tmux=tmux, harnesses=harnesses)
result = await service.create_session(project_id=1, harness_name="claude-code")
assert isinstance(result, CreateSessionResult)
assert result.spawn_ok is False
assert "command not found" in result.spawn_error
# Session row was still committed
assert result.session.id is not None
# ---------------------------------------------------------------------------
# rename_session / get_session
# ---------------------------------------------------------------------------
def _seed_session(db, nickname=None):
"""Create a session for project 1 / harness 'claude-code' (seeded by the db fixture)."""
from hqt.db.models import Harness, Session
harness = db.query(Harness).filter_by(name="claude-code").first()
sess = Session(
project_id=1,
harness_id=harness.id,
nickname=nickname,
tmux_session_name="hqt-rename",
archived=False,
)
db.add(sess)
db.commit()
return sess
def test_rename_session_sets_nickname(service, db):
from hqt.db.models import Session
sess = _seed_session(db, nickname="old")
service.rename_session(sess.id, "new-name")
assert db.get(Session, sess.id).nickname == "new-name"
def test_rename_session_empty_clears_to_none(service, db):
from hqt.db.models import Session
sess = _seed_session(db, nickname="old")
service.rename_session(sess.id, "")
assert db.get(Session, sess.id).nickname is None
def test_rename_session_whitespace_clears_to_none(service, db):
from hqt.db.models import Session
sess = _seed_session(db, nickname="old")
service.rename_session(sess.id, " ")
assert db.get(Session, sess.id).nickname is None
def test_get_session_returns_row(service, db):
sess = _seed_session(db, nickname="x")
assert service.get_session(sess.id).id == sess.id
def test_get_session_missing_returns_none(service):
assert service.get_session(99999) is None
+27
View File
@@ -0,0 +1,27 @@
from pathlib import Path
from hqt.skills.registry import SkillRegistry
def test_discover_skills(tmp_path: Path):
skill_dir = tmp_path / "my-skill"
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text("---\nname: my-skill\ndescription: A test skill\n---\nSkill content here")
registry = SkillRegistry(tmp_path)
skills = registry.discover()
assert len(skills) == 1
assert skills[0].name == "my-skill"
assert skills[0].description == "A test skill"
assert skills[0].content == "Skill content here"
def test_discover_empty_dir(tmp_path: Path):
registry = SkillRegistry(tmp_path)
assert registry.discover() == []
def test_discover_nonexistent_dir(tmp_path: Path):
registry = SkillRegistry(tmp_path / "nope")
assert registry.discover() == []
+809
View File
@@ -0,0 +1,809 @@
import pytest
from unittest.mock import AsyncMock
from hqt.tmux.runner import TmuxRunner
from hqt.tmux.manager import TmuxManager, SpawnRequest, SpawnResult
@pytest.fixture
def runner():
r = TmuxRunner(session_name="hqt-main")
r._exec = AsyncMock(return_value=(0, "", ""))
return r
@pytest.mark.asyncio
async def test_new_window(runner):
runner._exec.side_effect = [
(0, "0\n", ""), # list-windows -F '#{window_index}' (next-index query)
(0, "@1\n", ""), # new-window -P -F
(0, "", ""), # set-option (combined: remain-on-exit + allow-rename + automatic-rename)
(0, "", ""), # respawn-pane
]
result = await runner.new_window("hqt-1", "/tmp", "kiro-cli chat")
assert result == "@1"
# Step 2: combined set-option call with ";" separators
set_call = runner._exec.call_args_list[2].args
assert set_call == (
"set-option", "-t", "hqt-main:=hqt-1", "remain-on-exit", "on",
";", "set-option", "-t", "hqt-main:=hqt-1", "allow-rename", "off",
";", "set-option", "-t", "hqt-main:=hqt-1", "automatic-rename", "off",
)
# respawn-pane runs the actual command
calls = runner._exec.call_args_list
respawn_args = calls[3].args
assert "respawn-pane" in respawn_args
assert "kiro-cli chat" in respawn_args
@pytest.mark.asyncio
async def test_new_window_targets_explicit_free_index(runner):
"""Regression: new-window must target an explicit free index, not the bare
session name.
With a session-only target (`-t hqt`), an *attached* tmux resolves the target
to the session's current/active window and tries to create the new window at
that window's index — which is always occupied — failing with
'create window failed: index N in use'. That broke both spawning new sessions
and respawning dead ones (so attach failed). Computing max(existing index)+1
and targeting `session:idx` sidesteps the resolution entirely."""
runner._exec.side_effect = [
(0, "0\n1\n2\n", ""), # list-windows -F '#{window_index}' (existing: 0,1,2)
(0, "@9\n", ""), # new-window -P -F -> window_id
(0, "", ""), # set-option
(0, "", ""), # respawn-pane
]
window_id = await runner.new_window("hqt-9", "/tmp", "claude")
assert window_id == "@9"
new_win_args = runner._exec.call_args_list[1].args
assert "new-window" in new_win_args
# Explicit free index = max(0,1,2)+1 = 3, targeted as "<session>:3".
assert "hqt-main:3" in new_win_args, f"expected explicit free index target, got {new_win_args}"
# Must NOT use the bare session name as the new-window target.
ti = new_win_args.index("-t")
assert new_win_args[ti + 1] == "hqt-main:3"
@pytest.mark.asyncio
async def test_has_window_true(runner):
runner._exec.return_value = (0, "hqt-1\nhqt-2\n", "")
assert await runner.has_window("hqt-1") is True
assert runner._exec.call_count == 1
@pytest.mark.asyncio
async def test_has_window_false(runner):
runner._exec.return_value = (0, "hqt-2\n", "")
assert await runner.has_window("hqt-1") is False
@pytest.mark.asyncio
async def test_is_pane_dead_true(runner):
runner._exec.return_value = (0, "1\n", "")
assert await runner.is_pane_dead("hqt-1") is True
@pytest.mark.asyncio
async def test_is_pane_dead_false(runner):
runner._exec.return_value = (0, "0\n", "")
assert await runner.is_pane_dead("hqt-1") is False
@pytest.mark.asyncio
async def test_select_window(runner):
await runner.select_window("hqt-1")
runner._exec.assert_called_with("select-window", "-t", "hqt-main:=hqt-1")
@pytest.mark.asyncio
async def test_respawn_pane(runner):
await runner.respawn_pane("hqt-1", "kiro-cli chat", "/tmp")
runner._exec.assert_called_with(
"respawn-pane", "-t", "hqt-main:=hqt-1", "-k", "-c", "/tmp", "kiro-cli chat",
)
@pytest.mark.asyncio
async def test_list_windows(runner):
runner._exec.return_value = (0, "hqt\nhqt-1\nhqt-2\n", "")
result = await runner.list_windows()
assert result == ["hqt", "hqt-1", "hqt-2"]
@pytest.mark.asyncio
async def test_manager_spawn(runner):
runner._exec.side_effect = [
(0, "0\n", ""), # list-windows (next-index query)
(0, "@3\n", ""), # new-window -P -F
(0, "", ""), # set-option
(0, "", ""), # respawn-pane
]
runner.verify_window_alive = AsyncMock(return_value=(True, ""))
mgr = TmuxManager(runner)
req = SpawnRequest(window_name="hqt-1", command=["kiro-cli", "chat"], cwd="/home")
result = await mgr.spawn(req)
assert result.ok is True
assert result.window_id == "@3"
# new-window call must NOT have trailing command
new_win_args = runner._exec.call_args_list[1].args
assert "new-window" in new_win_args
assert "kiro-cli chat" not in new_win_args
@pytest.mark.asyncio
async def test_manager_attach_alive(runner):
"""Attach to alive window just selects it."""
mgr = TmuxManager(runner)
runner._exec.reset_mock()
runner._exec.return_value = (0, "hqt-1\n", "")
result = await mgr.attach("hqt-1")
assert result is True
@pytest.mark.asyncio
async def test_manager_is_alive_true(runner):
"""Window exists and pane running."""
# has_window needs list-windows to return the name (one _exec call)
# is_pane_dead needs list-panes to return "0"
runner._exec.side_effect = [
(0, "hqt-1\n", ""), # has_window
(0, "0\n", ""), # is_pane_dead
]
mgr = TmuxManager(runner)
assert await mgr.is_alive("hqt-1") is True
@pytest.mark.asyncio
async def test_manager_is_alive_false_dead_pane(runner):
"""Window exists but pane dead."""
runner._exec.side_effect = [
(0, "hqt-1\n", ""), # has_window
(0, "1\n", ""), # is_pane_dead
]
mgr = TmuxManager(runner)
assert await mgr.is_alive("hqt-1") is False
@pytest.mark.asyncio
async def test_manager_is_alive_false_no_window(runner):
"""Window doesn't exist at all."""
runner._exec.side_effect = [
(0, "hqt-2\n", ""), # has_window — hqt-1 not in output
]
mgr = TmuxManager(runner)
assert await mgr.is_alive("hqt-1") is False
def test_target_exact_match(runner):
"""_target() must use =prefix to force exact-match-only resolution in tmux."""
assert runner._target("hqt-1") == "hqt-main:=hqt-1"
@pytest.mark.asyncio
async def test_has_window_false_single_exec(runner):
"""has_window returns False with a single exec when name is absent."""
runner._exec.return_value = (0, "hqt-2\n", "")
assert await runner.has_window("hqt-1") is False
assert runner._exec.call_count == 1
# ---------------------------------------------------------------------------
# Task 2: Safe spawn sequence — new_window race-free redesign
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_new_window_race_free_sequence(runner):
"""new_window issues: new-window (no command), set-option, respawn-pane — in order."""
runner._exec.side_effect = [
(0, "0\n", ""), # list-windows (next-index query)
(0, "@5\n", ""), # new-window -P -F -> window_id
(0, "", ""), # set-option
(0, "", ""), # respawn-pane
]
window_id = await runner.new_window("hqt-1", "/tmp", "kiro-cli chat")
assert window_id == "@5"
calls = runner._exec.call_args_list
assert len(calls) == 4
# Step 1: new-window without a trailing command; must have -P and -F
new_win_args = calls[1].args
assert "new-window" in new_win_args
assert "-P" in new_win_args
assert "#{window_id}" in new_win_args
# Must NOT have the command as the final positional arg (no trailing command)
assert new_win_args[-1] != "kiro-cli chat"
assert "kiro-cli chat" not in new_win_args
# Step 2: combined set-option call with ";" separators for all three options
set_args = calls[2].args
assert set_args == (
"set-option", "-t", "hqt-main:=hqt-1", "remain-on-exit", "on",
";", "set-option", "-t", "hqt-main:=hqt-1", "allow-rename", "off",
";", "set-option", "-t", "hqt-main:=hqt-1", "automatic-rename", "off",
)
# Step 3: respawn-pane runs the command
resp_args = calls[3].args
assert "respawn-pane" in resp_args
assert "kiro-cli chat" in resp_args
@pytest.mark.asyncio
async def test_new_window_with_env(runner):
"""new_window passes -e KEY=VALUE flags on respawn-pane (step 3), NOT on new-window (step 1)."""
runner._exec.side_effect = [
(0, "0\n", ""), # list-windows (next-index query)
(0, "@7\n", ""),
(0, "", ""),
(0, "", ""),
]
window_id = await runner.new_window("hqt-2", "/tmp", "claude", env={"FOO": "bar", "X": "1"})
assert window_id == "@7"
calls_list = runner._exec.call_args_list
# Step 1 (new-window) must NOT carry -e flags — they're useless there
new_win_args = calls_list[1].args
assert "-e" not in new_win_args
assert "FOO=bar" not in new_win_args
assert "X=1" not in new_win_args
# Step 3 (respawn-pane) must carry -e KEY=VALUE pairs
respawn_args = calls_list[3].args
assert "respawn-pane" in respawn_args
assert "-e" in respawn_args
flat = list(respawn_args)
assert "FOO=bar" in flat
assert "X=1" in flat
@pytest.mark.asyncio
async def test_new_window_set_option_failure_aborts(runner):
"""If set-option fails, new_window kills the orphaned window and returns None."""
runner._exec.side_effect = [
(0, "0\n", ""), # list-windows (next-index query)
(0, "@3\n", ""), # new-window succeeds
(1, "", "some tmux error"), # set-option fails
(0, "", ""), # kill-window cleanup
]
result = await runner.new_window("hqt-3", "/tmp", "claude")
assert result is None
# kill-window must have been called to clean up the orphaned shell
assert runner._exec.call_count == 4
kill_args = runner._exec.call_args_list[3].args
assert "kill-window" in kill_args
assert "hqt-main:=hqt-3" in kill_args
@pytest.mark.asyncio
async def test_new_window_respawn_failure_kills_window(runner):
"""If respawn-pane fails, new_window kills the orphaned window and returns None."""
runner._exec.side_effect = [
(0, "0\n", ""), # list-windows (next-index query)
(0, "@4\n", ""), # new-window succeeds
(0, "", ""), # set-option succeeds
(1, "", "respawn error"), # respawn-pane fails
(0, "", ""), # kill-window cleanup
]
result = await runner.new_window("hqt-4", "/tmp", "claude")
assert result is None
assert runner._exec.call_count == 5
kill_args = runner._exec.call_args_list[4].args
assert "kill-window" in kill_args
assert "hqt-main:=hqt-4" in kill_args
@pytest.mark.asyncio
async def test_new_window_returns_window_id(runner):
"""new_window returns the window_id string on full success."""
runner._exec.side_effect = [
(0, "0\n", ""), # list-windows (next-index query)
(0, "@42\n", ""),
(0, "", ""),
(0, "", ""),
]
result = await runner.new_window("hqt-5", "/var", "sleep 30")
assert result == "@42"
@pytest.mark.asyncio
async def test_new_window_returns_none_on_new_window_failure(runner):
"""new_window returns None when the initial new-window command fails."""
runner._exec.side_effect = [
(0, "0\n", ""), # list-windows (next-index query)
(1, "", "session not found"), # new-window fails
]
result = await runner.new_window("hqt-x", "/tmp", "sleep 1")
assert result is None
assert runner._exec.call_count == 2
# ---------------------------------------------------------------------------
# Task 2: verify_window_alive
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_verify_window_alive_alive(runner):
"""Pane stays alive through timeout → (True, '')."""
# is_pane_dead returns False (pane alive) on every poll
runner._exec.return_value = (0, "0\n", "")
ok, text = await runner.verify_window_alive("hqt-1", timeout=0.1, interval=0.05)
assert ok is True
assert text == ""
@pytest.mark.asyncio
async def test_verify_window_alive_dead_with_text(runner):
"""Pane dies within timeout → (False, captured text)."""
runner._exec.side_effect = [
(0, "1\n", ""), # is_pane_dead → dead
(0, "command not found\n", ""), # capture_pane
]
ok, text = await runner.verify_window_alive("hqt-1", timeout=0.5, interval=0.05)
assert ok is False
assert "command not found" in text
@pytest.mark.asyncio
async def test_verify_window_alive_dead_empty_text(runner):
"""Pane dies with empty capture → (False, '') still False."""
runner._exec.side_effect = [
(0, "1\n", ""), # is_pane_dead
(0, "", ""), # capture_pane empty
]
ok, text = await runner.verify_window_alive("hqt-1", timeout=0.5, interval=0.05)
assert ok is False
assert text == ""
@pytest.mark.asyncio
async def test_verify_window_alive_dies_during_last_interval(runner):
"""Pane alive throughout polling loop but dies during the final sleep → (False, text)."""
# All in-loop polls return alive (0), final post-loop check returns dead (1)
runner._exec.side_effect = [
(0, "0\n", ""), # is_pane_dead (in-loop) → alive
(0, "1\n", ""), # is_pane_dead (post-loop final check) → dead
(0, "died at last\n", ""), # capture_pane
]
ok, text = await runner.verify_window_alive("hqt-1", timeout=0.05, interval=0.1)
assert ok is False
assert "died at last" in text
# ---------------------------------------------------------------------------
# Task 2: SpawnResult + manager.spawn changes
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_spawn_result_ok(runner):
"""spawn returns SpawnResult(ok=True, window_id=...) on success."""
runner._exec.side_effect = [
(0, "0\n", ""), # list-windows (next-index query)
(0, "@10\n", ""), # new-window
(0, "", ""), # set-option
(0, "", ""), # respawn-pane
]
runner.verify_window_alive = AsyncMock(return_value=(True, ""))
mgr = TmuxManager(runner)
req = SpawnRequest(window_name="hqt-1", command=["sleep", "30"], cwd="/tmp")
result = await mgr.spawn(req)
assert isinstance(result, SpawnResult)
assert result.ok is True
assert result.window_id == "@10"
assert result.error == ""
@pytest.mark.asyncio
async def test_spawn_result_failure_verification(runner):
"""spawn returns SpawnResult(ok=False, error=...) when pane dies immediately."""
runner._exec.side_effect = [
(0, "0\n", ""), # list-windows (next-index query)
(0, "@11\n", ""), # new-window
(0, "", ""), # set-option
(0, "", ""), # respawn-pane
]
runner.verify_window_alive = AsyncMock(return_value=(False, "no such binary\n"))
mgr = TmuxManager(runner)
req = SpawnRequest(window_name="hqt-1", command=["bad-cmd"], cwd="/tmp")
result = await mgr.spawn(req)
assert result.ok is False
assert "no such binary" in result.error
@pytest.mark.asyncio
async def test_spawn_shlex_join(runner):
"""spawn uses shlex.join so args with spaces are properly quoted."""
runner._exec.side_effect = [
(0, "0\n", ""), # list-windows (next-index query)
(0, "@99\n", ""),
(0, "", ""),
(0, "", ""),
]
runner.verify_window_alive = AsyncMock(return_value=(True, ""))
mgr = TmuxManager(runner)
req = SpawnRequest(window_name="hqt-1", command=["my cmd", "arg with space"], cwd="/tmp")
await mgr.spawn(req)
# The respawn-pane call (now the 4th _exec call) should have the shlex-joined string
respawn_call = runner._exec.call_args_list[3].args
assert "respawn-pane" in respawn_call
joined_cmd = respawn_call[-1]
# shlex.join of ["my cmd", "arg with space"] → "'my cmd' 'arg with space'"
import shlex
expected = shlex.join(["my cmd", "arg with space"])
assert joined_cmd == expected
@pytest.mark.asyncio
async def test_spawn_passes_env(runner):
"""spawn passes env dict through to respawn-pane (step 3), NOT to new-window (step 1)."""
runner._exec.side_effect = [
(0, "0\n", ""), # list-windows (next-index query)
(0, "@20\n", ""),
(0, "", ""),
(0, "", ""),
]
runner.verify_window_alive = AsyncMock(return_value=(True, ""))
mgr = TmuxManager(runner)
req = SpawnRequest(window_name="hqt-env", command=["bash"], cwd="/tmp", env={"MY_VAR": "hello"})
result = await mgr.spawn(req)
assert result.ok is True
# new-window (step 1) must NOT carry -e
new_win_args = runner._exec.call_args_list[1].args
assert "-e" not in new_win_args
assert "MY_VAR=hello" not in new_win_args
# respawn-pane (step 3) must carry -e MY_VAR=hello
respawn_args = runner._exec.call_args_list[3].args
assert "respawn-pane" in respawn_args
assert "-e" in respawn_args
assert "MY_VAR=hello" in respawn_args
@pytest.mark.asyncio
async def test_respawn_pane_with_env(runner):
"""respawn_pane passes -e KEY=VALUE flags when env is provided."""
await runner.respawn_pane("hqt-1", "claude", "/tmp", env={"MYKEY": "myval", "A": "b"})
call_args = runner._exec.call_args_list[0].args
assert "respawn-pane" in call_args
assert "-e" in call_args
assert "MYKEY=myval" in call_args
assert "A=b" in call_args
@pytest.mark.asyncio
async def test_respawn_pane_no_env(runner):
"""respawn_pane without env emits no -e flags (backward compat)."""
await runner.respawn_pane("hqt-1", "claude", "/tmp")
call_args = runner._exec.call_args_list[0].args
assert "respawn-pane" in call_args
assert "-e" not in call_args
@pytest.mark.asyncio
async def test_spawn_verify_dead_empty_text_returns_fallback_error(runner):
"""When verify_window_alive returns (False, ''), spawn returns the fallback error message."""
runner._exec.side_effect = [
(0, "0\n", ""), # list-windows (next-index query)
(0, "@30\n", ""), # new-window
(0, "", ""), # set-option
(0, "", ""), # respawn-pane
]
# Empty pane text — verify_window_alive signals immediate death with no output
runner.verify_window_alive = AsyncMock(return_value=(False, ""))
mgr = TmuxManager(runner)
req = SpawnRequest(window_name="hqt-dead", command=["gone-cmd"], cwd="/tmp")
result = await mgr.spawn(req)
assert result.ok is False
assert "pane died immediately" in result.error
# ---------------------------------------------------------------------------
# Task 3: respawn_verified
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_respawn_verified_success_path(runner):
"""respawn_verified: window exists, pane stays alive → SpawnResult(ok=True)."""
runner.verify_window_alive = AsyncMock(return_value=(True, ""))
runner._exec.side_effect = [
(0, "hqt-1\n", ""), # has_window → True
(0, "", ""), # respawn-pane
]
mgr = TmuxManager(runner)
result = await mgr.respawn_verified("hqt-1", ["claude", "--resume", "abc"], "/tmp")
assert result.ok is True
assert result.error == ""
@pytest.mark.asyncio
async def test_respawn_verified_pane_dies_after_respawn(runner):
"""respawn_verified: window exists, pane dies immediately → SpawnResult(ok=False, error=pane text)."""
runner.verify_window_alive = AsyncMock(return_value=(False, "no such transcript\n"))
runner._exec.side_effect = [
(0, "hqt-1\n", ""), # has_window → True
(0, "", ""), # respawn-pane
]
mgr = TmuxManager(runner)
result = await mgr.respawn_verified("hqt-1", ["claude", "--resume", "abc"], "/tmp")
assert result.ok is False
assert "no such transcript" in result.error
@pytest.mark.asyncio
async def test_respawn_verified_pane_dies_empty_text_fallback_message(runner):
"""respawn_verified: pane dies with no output → fallback error message."""
runner.verify_window_alive = AsyncMock(return_value=(False, ""))
runner._exec.side_effect = [
(0, "hqt-1\n", ""), # has_window → True
(0, "", ""), # respawn-pane
]
mgr = TmuxManager(runner)
result = await mgr.respawn_verified("hqt-1", ["claude", "--resume", "abc"], "/tmp")
assert result.ok is False
assert "pane died immediately" in result.error
@pytest.mark.asyncio
async def test_respawn_verified_window_gone_branch(runner):
"""respawn_verified: window is gone → new_window + verify → SpawnResult(ok=True, window_id=...)."""
runner.verify_window_alive = AsyncMock(return_value=(True, ""))
runner._exec.side_effect = [
(0, "other\n", ""), # has_window → False
(0, "0\n", ""), # new_window: list-windows (next-index query)
(0, "@9\n", ""), # new-window step 1
(0, "", ""), # set-option step 2
(0, "", ""), # respawn-pane step 3
]
mgr = TmuxManager(runner)
result = await mgr.respawn_verified("hqt-gone", ["claude", "--resume", "abc"], "/tmp")
assert result.ok is True
assert result.window_id == "@9"
@pytest.mark.asyncio
async def test_respawn_verified_window_gone_new_window_fails(runner):
"""respawn_verified: window gone, new_window fails → SpawnResult(ok=False)."""
runner._exec.side_effect = [
(0, "other\n", ""), # has_window → False
(0, "0\n", ""), # new_window: list-windows (next-index query)
(1, "", "session not found"), # new-window fails
]
mgr = TmuxManager(runner)
result = await mgr.respawn_verified("hqt-gone", ["claude", "--resume", "abc"], "/tmp")
assert result.ok is False
assert result.error != ""
@pytest.mark.asyncio
async def test_respawn_verified_forwards_env(runner):
"""respawn_verified passes env to respawn_pane when window exists."""
runner.verify_window_alive = AsyncMock(return_value=(True, ""))
runner._exec.side_effect = [
(0, "hqt-1\n", ""), # has_window → True
(0, "", ""), # respawn-pane
]
mgr = TmuxManager(runner)
result = await mgr.respawn_verified(
"hqt-1", ["claude"], "/tmp", env={"MY_VAR": "abc"}
)
assert result.ok is True
resp_call = runner._exec.call_args_list[1].args
assert "respawn-pane" in resp_call
assert "-e" in resp_call
assert "MY_VAR=abc" in resp_call
@pytest.mark.asyncio
async def test_respawn_verified_uses_shlex_join(runner):
"""respawn_verified uses shlex.join so args with spaces are properly quoted."""
import shlex
runner.verify_window_alive = AsyncMock(return_value=(True, ""))
runner._exec.side_effect = [
(0, "hqt-1\n", ""), # has_window → True
(0, "", ""), # respawn-pane
]
mgr = TmuxManager(runner)
await mgr.respawn_verified("hqt-1", ["cmd with space", "arg"], "/tmp")
expected_cmd = shlex.join(["cmd with space", "arg"])
resp_call = runner._exec.call_args_list[1].args
assert "respawn-pane" in resp_call
assert expected_cmd in resp_call
@pytest.mark.asyncio
async def test_respawn_verified_window_gone_forwards_env(runner):
"""respawn_verified forwards env to new_window when window is gone."""
runner.verify_window_alive = AsyncMock(return_value=(True, ""))
runner._exec.side_effect = [
(0, "other-window\n", ""), # has_window → False
(0, "0\n", ""), # new_window: list-windows (next-index query)
(0, "@5\n", ""), # new-window step 1
(0, "", ""), # set-option step 2
(0, "", ""), # respawn-pane step 3
]
mgr = TmuxManager(runner)
result = await mgr.respawn_verified("hqt-gone", ["bash"], "/tmp", env={"GONE_VAR": "x"})
assert result.ok is True
# The respawn-pane (step 3 of new_window) should carry the env
resp_call = runner._exec.call_args_list[4].args
assert "respawn-pane" in resp_call
assert "-e" in resp_call
assert "GONE_VAR=x" in resp_call
# ---------------------------------------------------------------------------
# Task 5: Literal send-keys (-l)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_send_text_uses_literal_flag(runner):
"""send_text must pass -l and '--' before the text."""
await runner.send_text("hqt-1", "Enter; echo hi C-c")
runner._exec.assert_called_once_with(
"send-keys", "-t", "hqt-main:=hqt-1", "-l", "--", "Enter; echo hi C-c",
)
@pytest.mark.asyncio
async def test_send_enter_no_literal_flag(runner):
"""send_enter intentionally sends the Enter key name — must NOT have -l."""
await runner.send_enter("hqt-1")
runner._exec.assert_called_once_with(
"send-keys", "-t", "hqt-main:=hqt-1", "Enter",
)
# ---------------------------------------------------------------------------
# Task 7: list_windows_info + WindowInfo + capture_pane -J + send_text --
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_list_windows_info_alive_and_activity(runner):
"""list_windows_info parses 3-column output; alive=True when pane_dead=0."""
runner._exec.return_value = (0, "hqt-1\t0\t1000000\nhqt-2\t1\t999999\n", "")
from hqt.tmux.runner import WindowInfo
result = await runner.list_windows_info()
assert result["hqt-1"] == WindowInfo(alive=True, last_activity=1000000)
assert result["hqt-2"] == WindowInfo(alive=False, last_activity=999999)
runner._exec.assert_called_once_with(
"list-panes", "-s", "-t", "hqt-main", "-F",
"#{window_name}\t#{pane_dead}\t#{window_activity}",
)
@pytest.mark.asyncio
async def test_list_windows_info_multi_pane_alive_max_activity(runner):
"""Multi-pane: alive if any pane alive; last_activity = max across panes."""
runner._exec.return_value = (
0,
"hqt-1\t1\t1000\nhqt-1\t0\t2000\nhqt-2\t1\t500\nhqt-2\t1\t600\n",
"",
)
from hqt.tmux.runner import WindowInfo
result = await runner.list_windows_info()
assert result["hqt-1"] == WindowInfo(alive=True, last_activity=2000)
assert result["hqt-2"] == WindowInfo(alive=False, last_activity=600)
@pytest.mark.asyncio
async def test_list_windows_info_malformed_line_skipped(runner):
"""Lines that don't have 3 tab-separated columns are skipped silently."""
runner._exec.return_value = (0, "hqt-1\t0\t1000\nbad_line\nhqt-2\t1\t500\n", "")
result = await runner.list_windows_info()
assert "hqt-1" in result
assert "bad_line" not in result
assert "hqt-2" in result
@pytest.mark.asyncio
async def test_list_windows_info_rc_nonzero_returns_empty(runner):
"""rc != 0 → empty dict."""
runner._exec.return_value = (1, "", "no server running")
result = await runner.list_windows_info()
assert result == {}
@pytest.mark.asyncio
async def test_capture_pane_includes_J_flag(runner):
"""capture_pane must pass -J to join wrapped lines."""
runner._exec.return_value = (0, "some output\n", "")
await runner.capture_pane("hqt-1")
args = runner._exec.call_args.args
assert "-J" in args
assert "capture-pane" in args
assert "-p" in args
@pytest.mark.asyncio
async def test_send_text_includes_double_dash(runner):
"""send_text must pass '--' before the text to prevent flag parsing."""
await runner.send_text("hqt-1", "-flag-looking text")
runner._exec.assert_called_once_with(
"send-keys", "-t", "hqt-main:=hqt-1", "-l", "--", "-flag-looking text",
)
@pytest.mark.asyncio
async def test_send_text_double_dash_with_normal_text(runner):
"""send_text includes '--' even for normal text (belt-and-suspenders)."""
await runner.send_text("hqt-1", "hello world")
runner._exec.assert_called_once_with(
"send-keys", "-t", "hqt-main:=hqt-1", "-l", "--", "hello world",
)
@pytest.mark.asyncio
async def test_poll_info_single_exec(runner):
"""poll_info calls _exec exactly once and returns WindowInfo per name."""
runner._exec.return_value = (0, "hqt-1\t0\t1234\nhqt-2\t1\t5678\n", "")
from hqt.tmux.runner import WindowInfo
mgr = TmuxManager(runner)
result = await mgr.poll_info(["hqt-1", "hqt-2", "hqt-missing"])
assert runner._exec.call_count == 1
assert result["hqt-1"] == WindowInfo(alive=True, last_activity=1234)
assert result["hqt-2"] == WindowInfo(alive=False, last_activity=5678)
# Missing window gets a dead placeholder
assert result["hqt-missing"].alive is False
@pytest.mark.asyncio
async def test_poll_info_rc_nonzero_all_dead(runner):
"""poll_info rc!=0 → all names map to dead WindowInfo."""
runner._exec.return_value = (1, "", "error")
mgr = TmuxManager(runner)
result = await mgr.poll_info(["hqt-x"])
assert result["hqt-x"].alive is False
@pytest.mark.asyncio
async def test_set_window_label_sets_option_and_format(runner):
"""set_window_label sets @hqt_label + a label-aware window-status-format,
targeting the window by NAME (identity preserved no rename)."""
from hqt.tmux.runner import WINDOW_STATUS_FORMAT
await runner.set_window_label("hqt-1", "◐ myproj")
args = runner._exec.call_args.args
# Targets by name (exact-match prefix), never renames the window.
assert "hqt-main:=hqt-1" in args
assert "@hqt_label" in args
assert "◐ myproj" in args
# Both formats reference the label option so the bar shows it.
assert args.count(WINDOW_STATUS_FORMAT) == 2
assert "window-status-format" in args
assert "window-status-current-format" in args
# All in a single atomic tmux invocation.
assert runner._exec.call_count == 1
@pytest.mark.asyncio
async def test_manager_set_window_label_delegates(runner):
"""TmuxManager.set_window_label forwards to the runner."""
mgr = TmuxManager(runner)
await mgr.set_window_label("hqt-2", "○ deadproj")
args = runner._exec.call_args.args
assert "hqt-main:=hqt-2" in args
assert "○ deadproj" in args
@pytest.mark.asyncio
async def test_poll_info_empty_names(runner):
"""poll_info with empty names list returns empty dict with exactly one exec."""
runner._exec.return_value = (0, "", "")
mgr = TmuxManager(runner)
result = await mgr.poll_info([])
assert result == {}
assert runner._exec.call_count == 1
+1059
View File
File diff suppressed because it is too large Load Diff
Generated
+1079
View File
File diff suppressed because it is too large Load Diff